From cfd12f22c4b50f76fecbf1481aa232b42159ffad Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 19:21:37 -0500 Subject: [PATCH 01/40] feat(citygen): generate inside a drawn polygon boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional boundary to generateCity. Blocks centred outside it are dropped, road seams are clipped to it, and placement rejects footprints that straddle its edge. This is cheap because the water work already built it: a boundary is a water polygon with the sign flipped — water keeps what falls outside, a boundary keeps what falls inside. clipSegmentToLand is generalised into clipSegmentToPolygons(seg, polys, keepInside) so the two share one implementation and cannot drift; clipSegmentToLand and the new clipSegmentToBoundary are both thin wrappers over it. footprintOutsidePolygon mirrors footprintInWater and is deliberately stricter: water asks whether a footprint touches water at all, a boundary asks whether all of it is inside. A boundary with fewer than three points cannot enclose an area and is treated as absent, so a bad trace falls back to the plain bounds instead of generating nothing and looking like a broken button. Skipping a block draws no randomness, so a run without a boundary splits byte-identically to before — there is a test pinning that. Note the end-to-end tests assert on blocks and roads rather than buildings. generateThemedBuildingsForPlot, injected as deps.fillPlot, makes 36 unseeded Math.random calls, so buildings do not reproduce from a seed even though the layout does. The existing cityGen suite injects a stub for the same reason. --- .../src/cityGen/__tests__/boundary.test.ts | 266 ++++++++++++++++++ frontend/src/cityGen/bsp.ts | 15 +- frontend/src/cityGen/collision.ts | 8 +- frontend/src/cityGen/index.ts | 16 +- frontend/src/cityGen/types.ts | 6 + frontend/src/cityGen/water.ts | 77 ++++- 6 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/boundary.test.ts diff --git a/frontend/src/cityGen/__tests__/boundary.test.ts b/frontend/src/cityGen/__tests__/boundary.test.ts new file mode 100644 index 0000000..5ee35cb --- /dev/null +++ b/frontend/src/cityGen/__tests__/boundary.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from 'vitest'; +import { + footprintOutsidePolygon, + clipSegmentToBoundary, + clipSegmentToPolygons, + clipSegmentToLand, + splitCity, + createIsBlocked, + SpatialGrid, + generateCity, + type Polygon, +} from '../index'; + +/** + * Drawn generation bounds. A boundary is a water polygon with the sign flipped — + * water rejects what falls inside it, a boundary rejects what falls outside — so the + * two share every helper. + */ + +/** Axis-aligned square centred on the origin, as a boundary polygon. */ +const square = (half: number): Polygon => ({ + points: [ + { x: -half, z: -half }, + { x: half, z: -half }, + { x: half, z: half }, + { x: -half, z: half }, + ], +}); + +/** An L, so the notch can be checked for emptiness. */ +const concaveL: Polygon = { + points: [ + { x: 0, z: 0 }, + { x: 100, z: 0 }, + { x: 100, z: 40 }, + { x: 40, z: 40 }, + { x: 40, z: 100 }, + { x: 0, z: 100 }, + ], +}; + +const seg = (x1: number, z1: number, x2: number, z2: number) => + ({ x1, z1, x2, z2, width: 2 }); + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +// ─── footprintOutsidePolygon ────────────────────────────────────────────────── + +describe('footprintOutsidePolygon', () => { + const b = square(50); + + it('accepts a footprint well inside', () => { + expect(footprintOutsidePolygon(b, 0, 0, 10, 10)).toBe(false); + }); + + it('rejects a footprint well outside', () => { + expect(footprintOutsidePolygon(b, 200, 200, 10, 10)).toBe(true); + }); + + it('rejects one straddling the edge, matching how water treats a shoreline', () => { + // Centre is inside but two corners are not; a building half outside the drawn + // area is not what the GM asked for. + expect(footprintOutsidePolygon(b, 48, 0, 10, 10)).toBe(true); + }); + + it('accepts a footprint that just fits inside the edge', () => { + expect(footprintOutsidePolygon(b, 44, 0, 10, 10)).toBe(false); + }); + + it('rejects anything in a concave notch', () => { + expect(footprintOutsidePolygon(concaveL, 70, 70, 4, 4)).toBe(true); + }); +}); + +// ─── clipping ───────────────────────────────────────────────────────────────── + +describe('clipSegmentToBoundary', () => { + const b = square(50); + + it('leaves a fully inside segment untouched', () => { + const [only] = clipSegmentToBoundary(seg(-10, 0, 10, 0), b); + expect(only).toMatchObject({ x1: -10, x2: 10 }); + }); + + it('drops a fully outside segment', () => { + expect(clipSegmentToBoundary(seg(200, 200, 300, 300), b)).toHaveLength(0); + }); + + it('cuts a crossing segment at the edge', () => { + const out = clipSegmentToBoundary(seg(-100, 0, 0, 0), b); + expect(out).toHaveLength(1); + expect(out[0].x1).toBeCloseTo(-50); + expect(out[0].x2).toBeCloseTo(0); + }); + + it('keeps a segment whole when there is no boundary', () => { + expect(clipSegmentToBoundary(seg(0, 0, 999, 999), undefined)).toHaveLength(1); + }); + + it('is the exact inverse of clipping to land', () => { + // Same polygon, same segment: the two together must reconstruct the original. + const s = seg(-100, 0, 100, 0); + const inside = clipSegmentToPolygons(s, [b], true); + const outside = clipSegmentToLand(s, [b]); + const span = (arr: typeof inside) => + arr.reduce((n, r) => n + Math.abs(r.x2 - r.x1), 0); + expect(span(inside) + span(outside)).toBeCloseTo(200); + }); +}); + +// ─── placement ──────────────────────────────────────────────────────────────── + +describe('createIsBlocked with a boundary', () => { + const empty = () => new SpatialGrid([]); + + it('blocks a footprint outside the boundary', () => { + const isBlocked = createIsBlocked(empty(), [], false, [], square(50)); + expect(isBlocked(200, 200, 4, 4)).toBe(true); + }); + + it('allows a footprint inside it', () => { + const isBlocked = createIsBlocked(empty(), [], false, [], square(50)); + expect(isBlocked(0, 0, 4, 4)).toBe(false); + }); + + it('blocks nothing extra when no boundary is given', () => { + const isBlocked = createIsBlocked(empty(), [], false, []); + expect(isBlocked(9999, 9999, 4, 4)).toBe(false); + }); +}); + +// ─── split ──────────────────────────────────────────────────────────────────── + +describe('splitCity with a boundary', () => { + it('drops blocks centred outside the boundary', () => { + const { blocks } = splitCity(bounds(200), false, () => 0.5, [], square(60)); + expect(blocks.length).toBeGreaterThan(0); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); + } + }); + + it('clips road seams to the boundary', () => { + const { roads } = splitCity(bounds(200), false, () => 0.5, [], square(60)); + for (const r of roads) { + expect(Math.abs(r.x1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.x2)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z2)).toBeLessThanOrEqual(61); + } + }); + + it('leaves the notch of a concave boundary empty', () => { + const { blocks } = splitCity( + { min: { x: 0, z: 0 }, max: { x: 100, z: 100 } }, + false, () => 0.5, [], concaveL, + ); + // The notch is the far corner of the L, x > 40 and z > 40. + for (const b of blocks) expect(b.x > 40 && b.z > 40).toBe(false); + }); + + it('produces an identical split to today when no boundary is given', () => { + // The regression guard: drawn bounds must not disturb the existing path. + const withoutArg = splitCity(bounds(200), false, seededRng(), []); + const withUndefined = splitCity(bounds(200), false, seededRng(), [], undefined); + expect(withUndefined).toEqual(withoutArg); + }); +}); + +/** Deterministic sequence, so two runs are comparable. */ +function seededRng() { + let a = 12345; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +// ─── end to end ─────────────────────────────────────────────────────────────── + +/** + * The real `fillPlot` is `generateThemedBuildingsForPlot`, which makes 36 unseeded + * `Math.random` calls of its own — so buildings are not reproducible from a seed even + * though the layout is. The existing cityGen suite injects a stub for the same reason. + * These assertions therefore cover blocks and roads, which are deterministic, and + * whether placement is offered a position at all. + */ +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); + +describe('generateCity with a boundary', () => { + it('keeps every block and road inside the drawn area', () => { + const result = generateCity( + bounds(200), + { sectionType: 'MIXED', boundary: square(60) }, + freshContext(), + seededRng(), + { fillPlot: () => {} }, + ); + + expect(result.blocks.length).toBeGreaterThan(0); + for (const b of result.blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); + } + for (const r of result.roads) { + expect(Math.abs(r.x1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z1)).toBeLessThanOrEqual(61); + } + }); + + it('never offers placement a spot outside the boundary', () => { + // Whatever the building generator does with the position, it must not be given + // one the GM did not draw. + const offered: Array<{ x: number; z: number }> = []; + generateCity( + bounds(200), + { sectionType: 'MIXED', boundary: square(60) }, + freshContext(), + seededRng(), + { fillPlot: (x: number, z: number) => { offered.push({ x, z }); } }, + ); + expect(offered.length).toBeGreaterThan(0); + for (const p of offered) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(60); + expect(Math.abs(p.z)).toBeLessThanOrEqual(60); + } + }); + + it('builds a smaller city than the same bounds unbounded', () => { + const opts = { sectionType: 'MIXED' as const }; + const deps = { fillPlot: () => {} }; + const free = generateCity(bounds(200), opts, freshContext(), seededRng(), deps); + const bounded = generateCity( + bounds(200), { ...opts, boundary: square(60) }, freshContext(), seededRng(), deps, + ); + expect(bounded.blocks.length).toBeLessThan(free.blocks.length); + }); + + it('is identical to today when no boundary is given', () => { + // The guard that matters most: existing generation is untouched. + const opts = { sectionType: 'MIXED' as const }; + const deps = { fillPlot: () => {} }; + const a = generateCity(bounds(150), opts, freshContext(), seededRng(), deps); + const b = generateCity( + bounds(150), { ...opts, boundary: undefined }, freshContext(), seededRng(), deps, + ); + expect(b).toEqual(a); + }); + + it('falls back to the bounds when the boundary is degenerate', () => { + // Fewer than three points cannot enclose anything; generating nothing at all + // would look like a broken button. + const result = generateCity( + bounds(150), + { sectionType: 'MIXED', boundary: { points: [{ x: 0, z: 0 }, { x: 10, z: 0 }] } }, + freshContext(), + seededRng(), + { fillPlot: () => {} }, + ); + expect(result.blocks.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/cityGen/bsp.ts b/frontend/src/cityGen/bsp.ts index 639fbe0..65523c8 100644 --- a/frontend/src/cityGen/bsp.ts +++ b/frontend/src/cityGen/bsp.ts @@ -1,5 +1,5 @@ import type { Block, Bounds, Rng, RoadSegment } from './types'; -import { clipSegmentToLand, type WaterPolygon } from './water'; +import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; /** Widths used for the road laid down at each split. */ const MAIN_ROAD_WIDTH = 6; @@ -56,7 +56,8 @@ export function splitCity( bounds: Bounds, excludeRoads: boolean, rng: Rng, - water: WaterPolygon[] = [] + water: WaterPolygon[] = [], + boundary?: Polygon ): { blocks: Block[]; roads: RoadSegment[] } { const { minX, maxX, minZ, maxZ, width, depth } = normalizeBounds(bounds); const maxSplitDepth = maxSplitDepthFor(width, depth); @@ -64,14 +65,18 @@ export function splitCity( const blocks: Block[] = []; const roads: RoadSegment[] = []; - /** Lay a seam, keeping only the stretches that fall on land. */ + /** Lay a seam, keeping only the stretches on land and inside any boundary. */ const layRoad = (seg: RoadSegment) => { - roads.push(...clipSegmentToLand(seg, water)); + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } }; const split = (x: number, z: number, w: number, d: number, iter: number) => { if (iter > maxSplitDepth || (w < MIN_BLOCK_SIZE && d < MIN_BLOCK_SIZE)) { - blocks.push({ x, z, w, d }); + // Blocks centred outside a drawn boundary are dropped. Skipping the push draws + // no randomness, so the split itself is identical either way. + if (!boundary || pointInPolygon(boundary, x, z)) blocks.push({ x, z, w, d }); return; } const splitV = w > d ? true : (w === d ? rng() > 0.5 : false); diff --git a/frontend/src/cityGen/collision.ts b/frontend/src/cityGen/collision.ts index d3b2ce5..0d2dc56 100644 --- a/frontend/src/cityGen/collision.ts +++ b/frontend/src/cityGen/collision.ts @@ -1,5 +1,5 @@ import type { Obstacle, RoadSegment } from './types'; -import { footprintInWater, type WaterPolygon } from './water'; +import { footprintInWater, footprintOutsidePolygon, type WaterPolygon } from './water'; /** Cell size of the uniform grid used to bucket obstacles. */ const GRID_CELL = 20; @@ -205,12 +205,16 @@ export function createIsBlocked( grid: SpatialGrid, roads: RoadSegment[], checkRoads: boolean, - water: WaterPolygon[] = [] + water: WaterPolygon[] = [], + boundary?: WaterPolygon ): IsBlocked { return (x, z, w, d, buffer = 2) => { if (overlapsObstacle(grid, x, z, w, d, buffer)) return true; if (checkRoads && footprintOnRoad(roads, x, z, w, d)) return true; if (water.length > 0 && footprintInWater(water, x, z, w, d)) return true; + // A drawn boundary is water with the sign flipped: reject what falls outside it. + // This is what lets a block straddling the edge build only on its inside. + if (boundary && footprintOutsidePolygon(boundary, x, z, w, d)) return true; return false; }; } diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 93480a3..55b73d6 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -12,7 +12,7 @@ import { } from './zoning'; import { generatePark } from './parks'; import { shouldPlaceLandmark, generateLandmark } from './landmarks'; -import { parseWaterBodies, pointInWater, footprintInWater } from './water'; +import { parseWaterBodies, pointInWater, footprintInWater, clipSegmentToBoundary } from './water'; import { findBridges } from './bridges'; import { generateShorelineRoads, snapRoadEndsToShoreline } from './shoreline'; import type { @@ -73,6 +73,11 @@ export function generateCity( deps: GenerateCityDeps = DEFAULT_DEPS ): GenerateCityResult { const { sectionType, excludeRoads, overpassDensity = 'normal' } = options; + // Fewer than three points cannot enclose an area. Treating a degenerate boundary as + // absent falls back to the plain bounds, rather than generating nothing at all and + // looking like a broken button. + const boundary = + options.boundary && options.boundary.points.length >= 3 ? options.boundary : undefined; const { width, depth, centerX, centerZ } = normalizeBounds(bounds); const maxRadius = Math.max(1, Math.max(width, depth) / 2); const water = parseWaterBodies(context.waterBodies ?? []); @@ -83,11 +88,14 @@ export function generateCity( // The split clips its own seams to land, so the grid stops at the shore // instead of being laid across the water and cut back afterwards. - const { blocks, roads: newRoads } = splitCity(bounds, excludeRoads, rng, water); + const { blocks, roads: newRoads } = splitCity(bounds, excludeRoads, rng, water, boundary); // A road around each water body turns what would be dead ends at the shore // into junctions, so the network routes around a lake. - const shoreRoads = excludeRoads ? [] : generateShorelineRoads(water, bounds); + const shoreRoads = excludeRoads + ? [] + : generateShorelineRoads(water, bounds).flatMap((seg) => + clipSegmentToBoundary(seg, boundary)); // Approaches stop at the water, which leaves them overshooting the waterfront // road that sits back from it. Snapping their ends onto it removes the @@ -110,7 +118,7 @@ export function generateCity( // seams are not where the pavement ends up — checking those instead lets // buildings land on roads that moved underneath them. const roadsToCheck = [...context.roads, ...finalRoads]; - const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water); + const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water, boundary); const buildings: RawBuilding[] = []; diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index 52284bd..4dd3305 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -27,6 +27,7 @@ import type { RoadSegment } from '../utils/roadHelpers'; export type { RoadSegment }; import type { OverpassDensity, OverpassSpec } from './bridges'; +import type { Polygon } from './water'; export type { OverpassDensity, OverpassSpec }; /** Zoning preset chosen in the admin panel. */ @@ -67,6 +68,11 @@ export interface Obstacle { export interface GenerateCityOptions { sectionType: SectionType; + /** + * Generate only inside this polygon. `bounds` still frames the work — the split + * recurses on the bounding box and blocks outside the shape are dropped. + */ + boundary?: Polygon; /** When true, no roads are generated and road collision is skipped. */ excludeRoads: boolean; /** How freely roads bridge the water they cross. Defaults to 'normal'. */ diff --git a/frontend/src/cityGen/water.ts b/frontend/src/cityGen/water.ts index b667f22..f2e2957 100644 --- a/frontend/src/cityGen/water.ts +++ b/frontend/src/cityGen/water.ts @@ -5,6 +5,12 @@ export interface WaterPolygon { points: { x: number; z: number }[]; } +/** + * A drawn generation boundary. Structurally a WaterPolygon — the two are the same + * shape and share every helper, differing only in whether inside or outside is kept. + */ +export type Polygon = WaterPolygon; + /** A stretch of a segment that runs through water, as parameters along it. */ export interface SubmergedSpan { /** Entry point along the segment, 0–1. */ @@ -89,6 +95,32 @@ export function footprintInWater( ); } +/** + * True when a footprint is not wholly inside `poly`. + * + * The counterpart of `footprintInWater`, and deliberately stricter: water asks + * "does this touch water at all", a boundary asks "is all of this inside". Same + * five-point sample, so a footprint straddling a boundary is rejected the same way one + * dipping into water is. + */ +export function footprintOutsidePolygon( + poly: WaterPolygon, + x: number, + z: number, + w: number, + d: number +): boolean { + const hw = w / 2; + const hd = d / 2; + return !( + pointInPolygon(poly, x, z) && + pointInPolygon(poly, x - hw, z - hd) && + pointInPolygon(poly, x + hw, z - hd) && + pointInPolygon(poly, x - hw, z + hd) && + pointInPolygon(poly, x + hw, z + hd) + ); +} + /** * Parameter along segment AB where it crosses segment CD, or null. * Returns t in 0–1 measured from A. @@ -168,17 +200,28 @@ export function segmentLength(seg: RoadSegment): number { } /** - * Cut a segment back to the parts of it that are on land. + * Cut a segment back to the parts of it inside — or outside — a set of polygons. * - * A segment clear of the water comes back untouched; one entirely submerged - * comes back as nothing; one that crosses comes back as the dry approaches. + * Water and drawn boundaries are the same operation with the sign flipped: water keeps + * what falls outside, a boundary keeps what falls inside. Sharing one implementation + * means the two cannot drift apart. */ -export function clipSegmentToLand( +export function clipSegmentToPolygons( seg: RoadSegment, - polygons: WaterPolygon[] + polygons: WaterPolygon[], + keepInside: boolean ): RoadSegment[] { - if (polygons.length === 0) return [seg]; + if (polygons.length === 0) return keepInside ? [] : [seg]; const spans = submergedSpans(polygons, seg); + + if (keepInside) { + return spans.map((span) => { + const a = pointAt(seg, span.t0); + const b = pointAt(seg, span.t1); + return { ...seg, x1: a.x, z1: a.z, x2: b.x, z2: b.z }; + }); + } + if (spans.length === 0) return [seg]; const out: RoadSegment[] = []; @@ -197,3 +240,25 @@ export function clipSegmentToLand( } return out; } + +/** + * Cut a segment back to the parts of it that are on land. + * + * A segment clear of the water comes back untouched; one entirely submerged + * comes back as nothing; one that crosses comes back as the dry approaches. + */ +export function clipSegmentToLand( + seg: RoadSegment, + polygons: WaterPolygon[] +): RoadSegment[] { + return clipSegmentToPolygons(seg, polygons, false); +} + +/** Cut a segment back to the parts of it inside a drawn boundary. */ +export function clipSegmentToBoundary( + seg: RoadSegment, + boundary: WaterPolygon | undefined +): RoadSegment[] { + if (!boundary) return [seg]; + return clipSegmentToPolygons(seg, [boundary], true); +} From 2372c2ec10e9b4b64669442d8a626aa83436b06b Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 20:16:36 -0500 Subject: [PATCH 02/40] feat(citygen): DRAW_AREA mode for tracing generation bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes drawn bounds. The city generator gains a DRAG_RECT / DRAW_AREA toggle; tracing reuses the water drawing interaction wholesale — same pointer handling, same feel — writing to its own trail so it cannot collide with drawing actual water. DistrictInteractions gains a tracingBoundary flag rather than a duplicate code path, so the two tracing modes share one implementation. The one behavioural difference is deliberate: water clears its trail on pointer-up because it saves immediately, while a boundary stays on screen until GENERATE so the GM can see the area they drew. The traced polygon's bounding box frames the split and the polygon itself confines it, which is what generateCity expects. Switching modes clears the other mode's selection, so a stale rectangle and a stale boundary can never both be live. Refuses to generate on fewer than three traced points. generateCity would ignore such a boundary and silently generate over the bounding box instead, which is not what the GM asked for. --- frontend/src/App.tsx | 9 ++- frontend/src/components/AdminPanel.tsx | 31 ++++++++- frontend/src/components/MapElements.tsx | 43 +++++++----- .../components/__tests__/AdminPanel.test.tsx | 65 +++++++++++++++++++ 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d783019..f95f201 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -359,6 +359,9 @@ function App() { const [signageDensity, setSignageDensity] = useState(1); // Map export: drops is_hidden structures for the duration of a capture. const [exportSuppressHidden, setExportSuppressHidden] = useState(false); + // City generator: drag a rectangle, or trace a boundary polygon to generate inside. + const [cityGenDrawMode, setCityGenDrawMode] = useState<'rect' | 'draw'>('rect'); + const [genBoundaryTrail, setGenBoundaryTrail] = useState([]); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1793,6 +1796,10 @@ function App() { isRecording={mapExportApi?.isRecording ?? false} recordSecondsLeft={mapExportApi?.secondsLeft ?? 0} isExporting={mapExportApi?.isExporting ?? false} + cityGenDrawMode={cityGenDrawMode} + setCityGenDrawMode={setCityGenDrawMode} + genBoundaryTrail={genBoundaryTrail} + setGenBoundaryTrail={setGenBoundaryTrail} renderSidewalks={renderSidewalks} setRenderSidewalks={(val: boolean) => { setRenderSidewalks(val); socketRef.current?.emit('updateViewSettings', { renderSignage, signageDensity, renderSidewalks: val }); }} renderSignage={renderSignage} @@ -2634,7 +2641,7 @@ function App() { onReady={setMapExportApi} /> )} - { if (view === 'city_gen') { setRoadSelectionBounds(data); } else if (view === 'district') { setDistrictSelection(prev => [...new Set([...prev, ...data])]); } else if (isBatchSelecting) { setSelectedIds(prev => [...new Set([...prev, ...data])]); } }} roadTrail={roadTrail} setRoadTrail={setRoadTrail} waterTrail={waterTrail} setWaterTrail={setWaterTrail} onWaterDrawEnd={handleWaterDrawn} roadDrawMode={roadDrawMode} snapToGrid={snapToGrid} drawingRoadWidth={drawingRoadWidth} isBatchSelecting={isBatchSelecting} setSelectedIds={setSelectedIds} rhombusState={rhombusState} setRhombusState={setRhombusState} userName={userName} refreshLocations={fetchLocations} token={token} roadLayerMode={roadLayerMode} /> + { if (view === 'city_gen') { setRoadSelectionBounds(data); } else if (view === 'district') { setDistrictSelection(prev => [...new Set([...prev, ...data])]); } else if (isBatchSelecting) { setSelectedIds(prev => [...new Set([...prev, ...data])]); } }} roadTrail={roadTrail} setRoadTrail={setRoadTrail} waterTrail={waterTrail} setWaterTrail={setWaterTrail} onWaterDrawEnd={handleWaterDrawn} roadDrawMode={roadDrawMode} snapToGrid={snapToGrid} drawingRoadWidth={drawingRoadWidth} isBatchSelecting={isBatchSelecting} setSelectedIds={setSelectedIds} rhombusState={rhombusState} setRhombusState={setRhombusState} userName={userName} refreshLocations={fetchLocations} token={token} roadLayerMode={roadLayerMode} cityGenDrawMode={cityGenDrawMode} genBoundaryTrail={genBoundaryTrail} setGenBoundaryTrail={setGenBoundaryTrail} onBoundaryDrawEnd={(pts: any[]) => setGenBoundaryTrail(pts)} /> {roadSelectionBounds && view === 'city_gen' && ( diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 58fa2fd..d6e986c 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -584,6 +584,7 @@ export function AdminPanel({ signs, fetchSigns, remoteFonts, setRemoteFonts, isPlacingSign, setIsPlacingSign, pendingSignPos, setPendingSignPos, selectedSignId, setSelectedSignId, signTransformMode, setSignTransformMode, signTransformActive, setSignTransformActive, handleUpdateSign, signMesh, activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, + cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, }: any) { if (view === 'battle_map') { return ( @@ -1653,17 +1654,41 @@ export function AdminPanel({ ))} -
{roadSelectionBounds ?

AREA_SELECTED: {Math.round(Math.abs(roadSelectionBounds.max.x - roadSelectionBounds.min.x))}x{Math.round(Math.abs(roadSelectionBounds.max.z - roadSelectionBounds.min.z))} units

:

DRAG ON MAP TO SELECT GENERATION AREA

}

HIERARCHICAL BSP: ENABLED

ZONING: {citySectionType}

INFRASTRUCTURE: {genExcludeRoads ? 'BUILDINGS_ONLY' : 'ROADS_+_BUILDINGS'}

+
+ + +
+
{cityGenDrawMode === 'draw' + ? (genBoundaryTrail?.length > 2 + ? <>

BOUNDARY_TRACED: {genBoundaryTrail.length} POINTS

+ :

HOLD LEFT-CLICK TO TRACE GENERATION AREA

) + : (roadSelectionBounds ?

AREA_SELECTED: {Math.round(Math.abs(roadSelectionBounds.max.x - roadSelectionBounds.min.x))}x{Math.round(Math.abs(roadSelectionBounds.max.z - roadSelectionBounds.min.z))} units

:

DRAG ON MAP TO SELECT GENERATION AREA

)}

HIERARCHICAL BSP: ENABLED

ZONING: {citySectionType}

INFRASTRUCTURE: {genExcludeRoads ? 'BUILDINGS_ONLY' : 'ROADS_+_BUILDINGS'}

@@ -1688,6 +1707,7 @@ export function AdminPanel({ sectionType: citySectionType as SectionType, excludeRoads: genExcludeRoads, overpassDensity, + layout: cityLayout ?? 'BSP', boundary: drawing ? { points: tracedPoints } : undefined, }, { locations, roads, waterBodies } diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 609699c..e2e881c 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -671,3 +671,47 @@ describe('AdminPanel city generator bounds mode', () => { expect(props.setGenBoundaryTrail).toHaveBeenCalledWith([]); }); }); + +describe('AdminPanel layout selector', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + waterBodies: [], + ...over, + }); + + it('offers every layout', () => { + render(); + const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; + expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK']); + }); + + it('defaults to the organic layout, so generation is unchanged out of the box', () => { + render(); + expect((screen.getByLabelText('LAYOUT') as HTMLSelectElement).value).toBe('BSP'); + }); + + it('describes what each layout produces rather than naming the algorithm', () => { + render(); + const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; + expect(select.options[1].textContent).toMatch(/SQUARE BLOCKS/); + expect(select.options[2].textContent).toMatch(/TOWER IN PARK/); + }); + + it('reports a layout change', async () => { + const props = genProps(); + render(); + await userEvent.selectOptions(screen.getByLabelText('LAYOUT'), 'GRID'); + expect(props.setCityLayout).toHaveBeenCalledWith('GRID'); + }); +}); From a138d7c8eff280b62bd1dd37ec0b4eb0b3c7e45e Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 20:44:51 -0500 Subject: [PATCH 04/40] =?UTF-8?q?feat(citygen):=20RING=20layout=20?= =?UTF-8?q?=E2=80=94=20beltways=20and=20radial=20spokes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A San Antonio-style city: concentric loop roads with arterials converging on downtown. The observation that makes this cheap is that a beltway city is not built from annular blocks. Between the loops sit perfectly ordinary streets; only the arterial network is radial. So RING lays the rings and spokes, then runs an existing layout inside each region between them, passing that region as a boundary. That makes it almost entirely composition — LayoutFn calling LayoutFn, reusing the boundary confinement drawn bounds introduced. It needs no polygonal blocks, because the sub-layout keeps producing rectangles and the boundary clips them against the curve. It is also the mechanism per-district layouts will need, proven early on a smaller problem. Downtown, inside the innermost loop, is filled with GRID; the bands outside it organically, which is what beltway cities actually look like. Ring radii grow faster than linearly so the inner loop sits tight and outer ones sweep wide, and spokes are jittered off the even division so the network does not read as a wheel diagram. The corners of a rectangular selection are left empty on purpose — a ring city is round. The shared "every layout confines blocks to a drawn boundary" test caught a real bug: fillRegion confined the sub-layout to its region, which says nothing about any outer drawn boundary, so RING spilled outside a traced area. Both blocks and roads are now filtered against both. --- .../src/cityGen/__tests__/layouts.test.ts | 73 ++++++++- frontend/src/cityGen/layouts.ts | 152 +++++++++++++++++- frontend/src/components/AdminPanel.tsx | 1 + .../components/__tests__/AdminPanel.test.tsx | 3 +- 4 files changed, 223 insertions(+), 6 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 52977fa..269230e 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -4,6 +4,10 @@ import { gridLayout, superblockLayout, bspLayout, + ringLayout, + RING_COUNT, + SPOKE_COUNT, + RING_ROAD_WIDTH, generateCity, SUPERBLOCK_MIN_SIZE, GRID_AVENUE_WIDTH, @@ -39,7 +43,7 @@ const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); describe('layout registry', () => { it('offers every layout type', () => { - expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'SUPERBLOCK']); + expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'RING', 'SUPERBLOCK']); }); it('every layout produces blocks for the same area', () => { @@ -170,6 +174,69 @@ describe('superblockLayout', () => { }); }); +// ─── ring ──────────────────────────────────────────────────────────────────── + +describe('ringLayout', () => { + const R = 300; + const radiusOf = (p: { x: number; z: number }) => Math.hypot(p.x, p.z); + + it('keeps the city round, leaving the corners of a square selection empty', () => { + // A beltway city is circular; filling the corners would defeat the shape. + const { blocks } = ringLayout(bounds(R), false, seededRng()); + for (const b of blocks) expect(radiusOf(b)).toBeLessThanOrEqual(R + 1); + const corner = blocks.filter((b) => Math.abs(b.x) > R * 0.85 && Math.abs(b.z) > R * 0.85); + expect(corner).toHaveLength(0); + }); + + it('lays closed loops at more than one radius', () => { + // The rings themselves: road points cluster at each loop radius. + const { roads } = ringLayout(bounds(R), false, seededRng()); + const ringRoads = roads.filter((r) => r.width === RING_ROAD_WIDTH); + expect(ringRoads.length).toBeGreaterThan(RING_COUNT * 10); + + const radii = new Set(ringRoads.map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }) / 10))); + expect(radii.size).toBeGreaterThanOrEqual(RING_COUNT); + }); + + it('runs spokes out from the centre', () => { + const { roads } = ringLayout(bounds(R), false, seededRng()); + const fromCentre = roads.filter((r) => radiusOf({ x: r.x1, z: r.z1 }) < 1); + expect(fromCentre.length).toBe(SPOKE_COUNT); + }); + + it('spaces the inner loop tighter than the outer one', () => { + // Beltways are not evenly spaced; downtown is ringed close. + const { roads } = ringLayout(bounds(R), false, seededRng()); + const ringRadii = [...new Set( + roads.filter((r) => r.width === RING_ROAD_WIDTH) + .map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }))), + )].sort((a, b) => a - b); + const inner = ringRadii[0]; + const outer = ringRadii[ringRadii.length - 1]; + expect(inner).toBeLessThan(outer / 2); + }); + + it('fills the space between the arterials with ordinary blocks', () => { + // The point of the layout: only the arterial network is radial. Between the + // loops sit normal streets, so there should be plenty of blocks out there. + const { blocks } = ringLayout(bounds(R), false, seededRng()); + const outerBand = blocks.filter((b) => radiusOf(b) > R * 0.5); + expect(outerBand.length).toBeGreaterThan(10); + }); + + it('builds downtown inside the innermost loop', () => { + const { blocks } = ringLayout(bounds(R), false, seededRng()); + const core = blocks.filter((b) => radiusOf(b) < R * 0.25); + expect(core.length).toBeGreaterThan(0); + }); + + it('lays no roads when infrastructure is excluded, but still blocks out the city', () => { + const { blocks, roads } = ringLayout(bounds(R), true, seededRng()); + expect(roads).toHaveLength(0); + expect(blocks.length).toBeGreaterThan(0); + }); +}); + // ─── selection ──────────────────────────────────────────────────────────────── describe('generateCity layout selection', () => { @@ -186,7 +253,7 @@ describe('generateCity layout selection', () => { it('produces a different city for each layout', () => { const opts = { sectionType: 'MIXED' as const }; - const counts = (['BSP', 'GRID', 'SUPERBLOCK'] as const).map( + const counts = (['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const).map( (layout) => generateCity(bounds(300), { ...opts, layout }, freshContext(), seededRng(), deps) .blocks.length, @@ -202,7 +269,7 @@ describe('generateCity layout selection', () => { }); it('honours a drawn boundary whichever layout is chosen', () => { - for (const layout of ['BSP', 'GRID', 'SUPERBLOCK'] as const) { + for (const layout of ['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const) { const result = generateCity( bounds(300), { sectionType: 'MIXED', layout, boundary: square(80) }, diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index f2d164e..4731d22 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -17,7 +17,7 @@ export type LayoutFn = ( boundary?: Polygon ) => { blocks: Block[]; roads: RoadSegment[] }; -export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK'; +export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING'; /** Target block size for the regular grid, before jitter. */ const GRID_CELL = 55; @@ -34,6 +34,24 @@ const GRID_JITTER = 0.12; /** Minimum block size for the superblock layout — roughly 3x the BSP default. */ const SUPERBLOCK_MIN_SIZE = 110; +/** Concentric loops, as San Antonio has 410 and 1604. */ +const RING_COUNT = 2; + +/** Arterials converging on the centre. */ +const SPOKE_COUNT = 6; + +/** + * Ring radii grow faster than linearly, so the inner loop sits tight around downtown + * and outer ones sweep wide — which is what beltways actually do. + */ +const RING_FALLOFF = 1.35; + +const RING_ROAD_WIDTH = 8; +const SPOKE_ROAD_WIDTH = 7; + +/** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ +const ARC_STEP_DEG = 9; + /** * Evenly spaced cut positions across a span, jittered so the result reads as a surveyed * grid rather than a machine one. The outer edges stay put, since they are the boundary @@ -115,10 +133,140 @@ export const superblockLayout: LayoutFn = (bounds, excludeRoads, rng, water = [] export const bspLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => splitCity(bounds, excludeRoads, rng, water, boundary); + +/** Points along an arc, inclusive of both ends. */ +function arcPoints(cx: number, cz: number, r: number, a0: number, a1: number) { + const step = (ARC_STEP_DEG * Math.PI) / 180; + const steps = Math.max(1, Math.ceil(Math.abs(a1 - a0) / step)); + const pts: { x: number; z: number }[] = []; + for (let i = 0; i <= steps; i++) { + const a = a0 + ((a1 - a0) * i) / steps; + pts.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + } + return pts; +} + +/** Bounding box of a polygon, for handing to a sub-layout. */ +function polyBounds(points: { x: number; z: number }[]): Bounds { + const xs = points.map((p) => p.x); + const zs = points.map((p) => p.z); + return { + min: { x: Math.min(...xs), z: Math.min(...zs) }, + max: { x: Math.max(...xs), z: Math.max(...zs) }, + }; +} + +/** + * Beltway city — concentric ring roads with radial spokes converging on the centre. + * San Antonio, with its 410 and 1604 loops, is the reference. + * + * The important observation is that a beltway city is not made of annular *blocks*. + * Between the loops sit perfectly ordinary streets; only the arterial network is + * radial. So this lays the rings and spokes, then runs an existing layout inside each + * region between them, passing that region as a boundary. + * + * That means it is almost entirely composition: `LayoutFn` calling `LayoutFn`, using + * the same boundary confinement drawn bounds introduced. It needs no polygonal blocks, + * because the sub-layout keeps producing rectangles and the boundary clips them + * against the curve. + * + * The corners of a rectangular selection are left empty on purpose — a ring city is + * round, and filling the corners would defeat the shape. + */ +export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const { centerX, centerZ, width, depth } = normalizeBounds(bounds); + const maxR = Math.min(width, depth) / 2; + + const blocks: Block[] = []; + const roads: RoadSegment[] = []; + + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const layPolyline = (pts: { x: number; z: number }[], w: number) => { + for (let i = 0; i < pts.length - 1; i++) { + layRoad({ x1: pts[i].x, z1: pts[i].z, x2: pts[i + 1].x, z2: pts[i + 1].z, width: w }); + } + }; + + // Radii grow faster than linearly, so downtown is ringed tightly and the outer loop + // sweeps wide. + const radii: number[] = []; + for (let i = 0; i < RING_COUNT; i++) { + radii.push(maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF)); + } + + for (const r of radii) { + layPolyline(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH); + } + + // Spokes are jittered off the even division so the network does not read as a + // wheel diagram. + const spokeAngles: number[] = []; + const sector = (Math.PI * 2) / SPOKE_COUNT; + for (let i = 0; i < SPOKE_COUNT; i++) { + spokeAngles.push(i * sector + (rng() - 0.5) * sector * 0.2); + } + spokeAngles.sort((a, b) => a - b); + + for (const a of spokeAngles) { + layRoad({ + x1: centerX, z1: centerZ, + x2: centerX + Math.cos(a) * maxR, + z2: centerZ + Math.sin(a) * maxR, + width: SPOKE_ROAD_WIDTH, + }); + } + + /** Run a sub-layout inside one region and fold its output in. */ + const fillRegion = (poly: { x: number; z: number }[], sub: LayoutFn) => { + if (poly.length < 3) return; + const region: Polygon = { points: poly }; + const result = sub(polyBounds(poly), excludeRoads, rng, water, region); + // The sub-layout was confined to its region, which says nothing about any outer + // drawn boundary — so both blocks and roads are filtered against that too, or RING + // would spill outside a traced area. + for (const b of result.blocks) { + if (boundary && !pointInPolygon(boundary, b.x, b.z)) continue; + blocks.push(b); + } + for (const r of result.roads) roads.push(...clipSegmentToBoundary(r, boundary)); + }; + + // Downtown, inside the innermost loop: a grid, as most beltway cities have. + fillRegion(arcPoints(centerX, centerZ, radii[0], 0, Math.PI * 2), gridLayout); + + // Everything outside it: annular sectors between consecutive rings and spokes, + // filled organically. The outermost band runs from the last ring to maxR. + const bandEdges = [...radii, maxR]; + for (let b = 0; b < bandEdges.length - 1; b++) { + const rInner = bandEdges[b]; + const rOuter = bandEdges[b + 1]; + if (rOuter - rInner < 1) continue; + + for (let i = 0; i < spokeAngles.length; i++) { + const a0 = spokeAngles[i]; + const a1 = i === spokeAngles.length - 1 ? spokeAngles[0] + Math.PI * 2 : spokeAngles[i + 1]; + const poly = [ + ...arcPoints(centerX, centerZ, rInner, a0, a1), + ...arcPoints(centerX, centerZ, rOuter, a1, a0), + ]; + fillRegion(poly, bspLayout); + } + } + + return { blocks, roads }; +}; + export const LAYOUTS: Record = { BSP: bspLayout, GRID: gridLayout, SUPERBLOCK: superblockLayout, + RING: ringLayout, }; -export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH }; +export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH }; diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index bdfc579..cee3c1f 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -11,6 +11,7 @@ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ { value: 'BSP', label: 'ORGANIC — IRREGULAR BLOCKS (DEFAULT)' }, { value: 'GRID', label: 'GRID — PLANNED, SQUARE BLOCKS' }, { value: 'SUPERBLOCK', label: 'SUPERBLOCK — TOWER IN PARK' }, + { value: 'RING', label: 'RING — BELTWAYS AND SPOKES' }, ]; import type { BankSoundKey } from './BankWindows'; import { playCashRegister, playWompWomp, playCalibration, playProudFanfare, playHighRollerSound } from './BankWindows'; diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index e2e881c..accf6fb 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -693,7 +693,7 @@ describe('AdminPanel layout selector', () => { it('offers every layout', () => { render(); const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; - expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK']); + expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING']); }); it('defaults to the organic layout, so generation is unchanged out of the box', () => { @@ -706,6 +706,7 @@ describe('AdminPanel layout selector', () => { const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; expect(select.options[1].textContent).toMatch(/SQUARE BLOCKS/); expect(select.options[2].textContent).toMatch(/TOWER IN PARK/); + expect(select.options[3].textContent).toMatch(/BELTWAYS AND SPOKES/); }); it('reports a layout change', async () => { From 155cdc139802c3bbb708973abca9ff6fc1416404 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 20:56:52 -0500 Subject: [PATCH 05/40] fix(citygen): RING generated a sparse, fragmented city MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version partitioned the disc into annular sectors and ran a sub-layout inside each. A sector's bounding box is far larger than the sector, so most of what each run generated fell outside its own region and was discarded — the city came out with large empty stretches and a uniform grid that read as fake. The design was wrong, not the tuning. Real cities do not have per-sector street networks: between the arterials sits one continuous fabric of ordinary streets, and the beltway cuts across it. So the disc is now filled by a single sub-layout bounded by its outer circle, with the rings and spokes laid over the top. Buildings keep clear of the arterials through the usual road check, which is what gives them their verges. Density is now on par with the BSP over the same area — 206 blocks against 256, and a disc is pi/4 of its square, so 0.80 is about exact. There is a test pinning that ratio, since it is the property that broke. --- .../src/cityGen/__tests__/layouts.test.ts | 23 +++++ frontend/src/cityGen/layouts.ts | 87 +++++-------------- 2 files changed, 43 insertions(+), 67 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 269230e..91eb3aa 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -8,6 +8,7 @@ import { RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, + SPOKE_ROAD_WIDTH, generateCity, SUPERBLOCK_MIN_SIZE, GRID_AVENUE_WIDTH, @@ -230,6 +231,28 @@ describe('ringLayout', () => { expect(core.length).toBeGreaterThan(0); }); + it('fills the disc as densely as the BSP fills a rectangle', () => { + // The regression this layout shipped with: partitioning the disc into annular + // sectors and sub-laying each one discarded most of what it generated, because a + // sector's bounding box is far larger than the sector. The city came out sparse + // and fragmented. A disc is pi/4 of its square, so the ratio should land near that. + const ring = ringLayout(bounds(R), false, seededRng()).blocks.length; + const bsp = bspLayout(bounds(R), false, seededRng()).blocks.length; + expect(ring / bsp).toBeGreaterThan(0.6); + }); + + it('lays one continuous street fabric rather than one per sector', () => { + // Local streets are continuous and the beltway cuts across them; they are not + // divided up by it. Sector-local networks showed up as far more short segments. + const { roads } = ringLayout(bounds(R), false, seededRng()); + const arterials = roads.filter( + (r) => r.width === RING_ROAD_WIDTH || r.width === SPOKE_ROAD_WIDTH, + ); + const local = roads.length - arterials.length; + expect(local).toBeGreaterThan(0); + expect(arterials.length).toBeGreaterThan(0); + }); + it('lays no roads when infrastructure is excluded, but still blocks out the city', () => { const { blocks, roads } = ringLayout(bounds(R), true, seededRng()); expect(roads).toHaveLength(0); diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index 4731d22..f21ae5b 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -146,29 +146,21 @@ function arcPoints(cx: number, cz: number, r: number, a0: number, a1: number) { return pts; } -/** Bounding box of a polygon, for handing to a sub-layout. */ -function polyBounds(points: { x: number; z: number }[]): Bounds { - const xs = points.map((p) => p.x); - const zs = points.map((p) => p.z); - return { - min: { x: Math.min(...xs), z: Math.min(...zs) }, - max: { x: Math.max(...xs), z: Math.max(...zs) }, - }; -} - /** * Beltway city — concentric ring roads with radial spokes converging on the centre. * San Antonio, with its 410 and 1604 loops, is the reference. * - * The important observation is that a beltway city is not made of annular *blocks*. - * Between the loops sit perfectly ordinary streets; only the arterial network is - * radial. So this lays the rings and spokes, then runs an existing layout inside each - * region between them, passing that region as a boundary. + * The observation that makes this work is that a beltway city is not built from + * annular blocks, and its local streets are not divided up by the loops. Between the + * arterials sits one continuous fabric of ordinary streets; the beltway simply cuts + * across it. So this fills the whole disc with a single sub-layout and lays the rings + * and spokes over the top — buildings then keep clear of the arterials through the + * usual road check, which is what gives them their verges. * - * That means it is almost entirely composition: `LayoutFn` calling `LayoutFn`, using - * the same boundary confinement drawn bounds introduced. It needs no polygonal blocks, - * because the sub-layout keeps producing rectangles and the boundary clips them - * against the curve. + * An earlier version partitioned the disc into annular sectors and ran a sub-layout in + * each. That produced a sparse, fragmented city: a sector's bounding box is far larger + * than the sector, so most of what each run generated fell outside its own region and + * was discarded. * * The corners of a rectangular selection are left empty on purpose — a ring city is * round, and filling the corners would defeat the shape. @@ -177,7 +169,6 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun const { centerX, centerZ, width, depth } = normalizeBounds(bounds); const maxR = Math.min(width, depth) / 2; - const blocks: Block[] = []; const roads: RoadSegment[] = []; const layRoad = (seg: RoadSegment) => { @@ -193,27 +184,26 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun } }; + // The city is the disc, so that circle is the boundary the street fabric is laid + // inside. Combined with any outer drawn boundary, both must hold. + const disc: Polygon = { points: arcPoints(centerX, centerZ, maxR, 0, Math.PI * 2) }; + const fill = bspLayout(bounds, excludeRoads, rng, water, disc); + + const blocks = fill.blocks.filter((b) => !boundary || pointInPolygon(boundary, b.x, b.z)); + for (const r of fill.roads) roads.push(...clipSegmentToBoundary(r, boundary)); + // Radii grow faster than linearly, so downtown is ringed tightly and the outer loop // sweeps wide. - const radii: number[] = []; for (let i = 0; i < RING_COUNT; i++) { - radii.push(maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF)); - } - - for (const r of radii) { + const r = maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF); layPolyline(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH); } // Spokes are jittered off the even division so the network does not read as a // wheel diagram. - const spokeAngles: number[] = []; const sector = (Math.PI * 2) / SPOKE_COUNT; for (let i = 0; i < SPOKE_COUNT; i++) { - spokeAngles.push(i * sector + (rng() - 0.5) * sector * 0.2); - } - spokeAngles.sort((a, b) => a - b); - - for (const a of spokeAngles) { + const a = i * sector + (rng() - 0.5) * sector * 0.2; layRoad({ x1: centerX, z1: centerZ, x2: centerX + Math.cos(a) * maxR, @@ -222,43 +212,6 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun }); } - /** Run a sub-layout inside one region and fold its output in. */ - const fillRegion = (poly: { x: number; z: number }[], sub: LayoutFn) => { - if (poly.length < 3) return; - const region: Polygon = { points: poly }; - const result = sub(polyBounds(poly), excludeRoads, rng, water, region); - // The sub-layout was confined to its region, which says nothing about any outer - // drawn boundary — so both blocks and roads are filtered against that too, or RING - // would spill outside a traced area. - for (const b of result.blocks) { - if (boundary && !pointInPolygon(boundary, b.x, b.z)) continue; - blocks.push(b); - } - for (const r of result.roads) roads.push(...clipSegmentToBoundary(r, boundary)); - }; - - // Downtown, inside the innermost loop: a grid, as most beltway cities have. - fillRegion(arcPoints(centerX, centerZ, radii[0], 0, Math.PI * 2), gridLayout); - - // Everything outside it: annular sectors between consecutive rings and spokes, - // filled organically. The outermost band runs from the last ring to maxR. - const bandEdges = [...radii, maxR]; - for (let b = 0; b < bandEdges.length - 1; b++) { - const rInner = bandEdges[b]; - const rOuter = bandEdges[b + 1]; - if (rOuter - rInner < 1) continue; - - for (let i = 0; i < spokeAngles.length; i++) { - const a0 = spokeAngles[i]; - const a1 = i === spokeAngles.length - 1 ? spokeAngles[0] + Math.PI * 2 : spokeAngles[i + 1]; - const poly = [ - ...arcPoints(centerX, centerZ, rInner, a0, a1), - ...arcPoints(centerX, centerZ, rOuter, a1, a0), - ]; - fillRegion(poly, bspLayout); - } - } - return { blocks, roads }; }; From 4bab713cc1831c6b84cd9152c61ec4fb0eae4564 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 21:10:03 -0500 Subject: [PATCH 06/40] feat(citygen): RING arterials are elevated, freeing the ground beneath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining empty blocks were caused by the arterials themselves. createIsBlocked rejects any footprint touching a road, so a 7-unit beltway left a dead strip its entire length, and six spokes converging left a dead zone at the centre. Placement never checks overpasses, so raising the arterials costs the ground nothing: the street fabric runs unbroken underneath and small buildings fill in below the deck. That is also how these cities look — elevated freeway with the city carrying on below it. Spokes now run from the innermost loop outward rather than converging on a point, which removes the starburst of dead ground at the middle and is closer to how highways actually meet a downtown loop. Spokes sit above rings so they cross cleanly. LayoutFn may now return overpasses alongside blocks and roads, and generateCity merges them with whatever bridges the water needed. Existing layouts return none and are unaffected. Includes a test that the ground beneath a deck is still offered to placement, which is the whole point of the change. --- .../src/cityGen/__tests__/layouts.test.ts | 112 ++++++++++++++---- frontend/src/cityGen/index.ts | 9 +- frontend/src/cityGen/layouts.ts | 77 ++++++++---- 3 files changed, 143 insertions(+), 55 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 91eb3aa..359f01a 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -9,6 +9,10 @@ import { SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, + RING_DECK_HEIGHT, + SPOKE_DECK_HEIGHT, + RING_COUNT as RC, + SPOKE_COUNT as SC, generateCity, SUPERBLOCK_MIN_SIZE, GRID_AVENUE_WIDTH, @@ -189,32 +193,51 @@ describe('ringLayout', () => { expect(corner).toHaveLength(0); }); + it('raises its arterials rather than laying them on the ground', () => { + // A ground-level beltway sterilises every block it crosses: placement rejects any + // footprint touching a road. Elevated, it consumes no ground — placement never + // checks overpasses — so the fabric runs unbroken underneath. + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + expect(overpasses?.length).toBe(RING_COUNT + SPOKE_COUNT); + for (const o of overpasses ?? []) expect(o.height).toBeGreaterThan(0); + }); + it('lays closed loops at more than one radius', () => { - // The rings themselves: road points cluster at each loop radius. - const { roads } = ringLayout(bounds(R), false, seededRng()); - const ringRoads = roads.filter((r) => r.width === RING_ROAD_WIDTH); - expect(ringRoads.length).toBeGreaterThan(RING_COUNT * 10); + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + const rings = (overpasses ?? []).filter((o) => o.height === RING_DECK_HEIGHT); + expect(rings).toHaveLength(RING_COUNT); - const radii = new Set(ringRoads.map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }) / 10))); - expect(radii.size).toBeGreaterThanOrEqual(RING_COUNT); + const radii = rings.map((o) => Math.round(radiusOf(o.points[0]))); + expect(new Set(radii).size).toBe(RING_COUNT); }); - it('runs spokes out from the centre', () => { - const { roads } = ringLayout(bounds(R), false, seededRng()); - const fromCentre = roads.filter((r) => radiusOf({ x: r.x1, z: r.z1 }) < 1); - expect(fromCentre.length).toBe(SPOKE_COUNT); + it('runs spokes outward from the innermost loop, not from the centre', () => { + // Six arterials converging on a point left a starburst of dead ground there, and + // real highways meet a downtown loop rather than piling into the middle. + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + const spokes = (overpasses ?? []).filter((o) => o.height === SPOKE_DECK_HEIGHT); + expect(spokes).toHaveLength(SPOKE_COUNT); + + for (const s of spokes) { + const inner = Math.min(...s.points.map(radiusOf)); + const outer = Math.max(...s.points.map(radiusOf)); + expect(inner).toBeGreaterThan(1); + expect(outer).toBeGreaterThan(inner); + } + }); + + it('carries spokes above rings so they cross cleanly', () => { + expect(SPOKE_DECK_HEIGHT).toBeGreaterThan(RING_DECK_HEIGHT); }); it('spaces the inner loop tighter than the outer one', () => { // Beltways are not evenly spaced; downtown is ringed close. - const { roads } = ringLayout(bounds(R), false, seededRng()); - const ringRadii = [...new Set( - roads.filter((r) => r.width === RING_ROAD_WIDTH) - .map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }))), - )].sort((a, b) => a - b); - const inner = ringRadii[0]; - const outer = ringRadii[ringRadii.length - 1]; - expect(inner).toBeLessThan(outer / 2); + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + const radii = (overpasses ?? []) + .filter((o) => o.height === RING_DECK_HEIGHT) + .map((o) => Math.round(radiusOf(o.points[0]))) + .sort((a, b) => a - b); + expect(radii[0]).toBeLessThan(radii[radii.length - 1] / 2); }); it('fills the space between the arterials with ordinary blocks', () => { @@ -242,15 +265,20 @@ describe('ringLayout', () => { }); it('lays one continuous street fabric rather than one per sector', () => { - // Local streets are continuous and the beltway cuts across them; they are not + // Local streets are continuous and the beltway crosses above them; they are not // divided up by it. Sector-local networks showed up as far more short segments. const { roads } = ringLayout(bounds(R), false, seededRng()); - const arterials = roads.filter( - (r) => r.width === RING_ROAD_WIDTH || r.width === SPOKE_ROAD_WIDTH, - ); - const local = roads.length - arterials.length; - expect(local).toBeGreaterThan(0); - expect(arterials.length).toBeGreaterThan(0); + expect(roads.length).toBeGreaterThan(0); + // Every ground road is local now — the arterials are decks. + for (const r of roads) { + expect(r.width).not.toBe(RING_ROAD_WIDTH); + expect(r.width).not.toBe(SPOKE_ROAD_WIDTH); + } + }); + + it('raises nothing when infrastructure is excluded', () => { + const { overpasses } = ringLayout(bounds(R), true, seededRng()); + expect(overpasses ?? []).toHaveLength(0); }); it('lays no roads when infrastructure is excluded, but still blocks out the city', () => { @@ -284,6 +312,40 @@ describe('generateCity layout selection', () => { expect(new Set(counts).size).toBeGreaterThan(1); }); + it('carries arterials raised by the layout through to the result', () => { + // RING raises its beltways, and those have to reach the caller alongside whatever + // bridges the water needed. + const result = generateCity( + bounds(300), { sectionType: 'MIXED', layout: 'RING' }, freshContext(), seededRng(), deps, + ); + expect(result.overpasses.length).toBeGreaterThanOrEqual(RC + SC); + }); + + it('leaves the ground under an elevated arterial buildable', () => { + // The whole reason for raising them: a ground-level beltway sterilises every block + // it crosses, because placement rejects footprints touching a road. Placement never + // checks overpasses, so the fabric survives underneath. + const offered: Array<{ x: number; z: number }> = []; + const result = generateCity( + bounds(300), + { sectionType: 'MIXED', layout: 'RING' }, + freshContext(), + seededRng(), + { fillPlot: (x: number, z: number) => { offered.push({ x, z }); } }, + ); + + const ring = result.overpasses.find((o) => o.height === RING_DECK_HEIGHT); + expect(ring).toBeDefined(); + const ringRadius = Math.hypot(ring!.points[0].x, ring!.points[0].z); + + // Somewhere close enough to the loop to have been inside its footprint had it been + // a road. + const beneath = offered.filter( + (p) => Math.abs(Math.hypot(p.x, p.z) - ringRadius) < ring!.width, + ); + expect(beneath.length).toBeGreaterThan(0); + }); + it('falls back to BSP for an unrecognised layout', () => { // A stale saved option should not generate an empty city. const opts = { sectionType: 'MIXED' as const, layout: 'NONSENSE' as never }; diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 5666c8f..fd825e0 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -90,9 +90,8 @@ export function generateCity( // The split clips its own seams to land, so the grid stops at the shore // instead of being laid across the water and cut back afterwards. - const { blocks, roads: newRoads } = (LAYOUTS[layout] ?? LAYOUTS.BSP)( - bounds, excludeRoads, rng, water, boundary - ); + const { blocks, roads: newRoads, overpasses: layoutOverpasses = [] } = + (LAYOUTS[layout] ?? LAYOUTS.BSP)(bounds, excludeRoads, rng, water, boundary); // A road around each water body turns what would be dead ends at the shore // into junctions, so the network routes around a lake. @@ -112,9 +111,11 @@ export function generateCity( // Pick crossings worth bridging from the road ends left at the water's edge. // Draws no randomness on a dry map, so those generate exactly as before. + // A layout may raise its own arterials — RING elevates its beltways so they do not + // sterilise the ground beneath. Those join whatever bridges the water needs. const overpasses = excludeRoads ? [] - : findBridges(finalRoads, water, overpassDensity, rng); + : [...layoutOverpasses, ...findBridges(finalRoads, water, overpassDensity, rng)]; const grid = new SpatialGrid(context.locations); // Test against the roads that will actually exist. Consolidation snaps diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index f21ae5b..f2b5a32 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -1,4 +1,5 @@ import type { Block, Bounds, Rng, RoadSegment } from './types'; +import type { OverpassSpec } from './bridges'; import { normalizeBounds, splitCity } from './bsp'; import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; @@ -15,7 +16,7 @@ export type LayoutFn = ( rng: Rng, water?: WaterPolygon[], boundary?: Polygon -) => { blocks: Block[]; roads: RoadSegment[] }; +) => { blocks: Block[]; roads: RoadSegment[]; overpasses?: OverpassSpec[] }; export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING'; @@ -49,6 +50,22 @@ const RING_FALLOFF = 1.35; const RING_ROAD_WIDTH = 8; const SPOKE_ROAD_WIDTH = 7; +/** + * Arterials are elevated rather than laid on the ground. + * + * A ground-level beltway sterilises every block it crosses: `createIsBlocked` rejects + * any footprint touching a road, so a 7-unit arterial leaves a dead strip its whole + * length, and six spokes converging leave a dead zone at the centre. Elevated, they + * consume no ground at all — placement never checks overpasses — so the street fabric + * runs unbroken underneath and small buildings fill in below the deck. + * + * Spokes sit above rings so they pass over cleanly at the crossings. + */ +const RING_DECK_HEIGHT = 9; +const SPOKE_DECK_HEIGHT = 14; +const DECK_RAMP_LENGTH = 30; +const DECK_PILLAR_SPACING = 14; + /** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ const ARC_STEP_DEG = 9; @@ -171,19 +188,6 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun const roads: RoadSegment[] = []; - const layRoad = (seg: RoadSegment) => { - if (excludeRoads) return; - for (const dry of clipSegmentToLand(seg, water)) { - roads.push(...clipSegmentToBoundary(dry, boundary)); - } - }; - - const layPolyline = (pts: { x: number; z: number }[], w: number) => { - for (let i = 0; i < pts.length - 1; i++) { - layRoad({ x1: pts[i].x, z1: pts[i].z, x2: pts[i + 1].x, z2: pts[i + 1].z, width: w }); - } - }; - // The city is the disc, so that circle is the boundary the street fabric is laid // inside. Combined with any outer drawn boundary, both must hold. const disc: Polygon = { points: arcPoints(centerX, centerZ, maxR, 0, Math.PI * 2) }; @@ -192,27 +196,48 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun const blocks = fill.blocks.filter((b) => !boundary || pointInPolygon(boundary, b.x, b.z)); for (const r of fill.roads) roads.push(...clipSegmentToBoundary(r, boundary)); + const overpasses: OverpassSpec[] = []; + const deck = (points: { x: number; z: number }[], width: number, height: number) => { + if (excludeRoads || points.length < 2) return; + overpasses.push({ + points, + height, + width, + ramp_length: DECK_RAMP_LENGTH, + ramp_length_start: DECK_RAMP_LENGTH, + ramp_length_end: DECK_RAMP_LENGTH, + pillar_spacing: DECK_PILLAR_SPACING, + }); + }; + // Radii grow faster than linearly, so downtown is ringed tightly and the outer loop // sweeps wide. + const radii: number[] = []; for (let i = 0; i < RING_COUNT; i++) { - const r = maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF); - layPolyline(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH); + radii.push(maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF)); + } + for (const r of radii) { + deck(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH, RING_DECK_HEIGHT); } - // Spokes are jittered off the even division so the network does not read as a - // wheel diagram. + // Spokes run from the innermost loop outward rather than converging on a point. + // Six arterials meeting at the centre left a starburst of dead ground there, and + // real highways meet a downtown loop rather than piling into the middle. + const innerR = radii[0]; const sector = (Math.PI * 2) / SPOKE_COUNT; for (let i = 0; i < SPOKE_COUNT; i++) { const a = i * sector + (rng() - 0.5) * sector * 0.2; - layRoad({ - x1: centerX, z1: centerZ, - x2: centerX + Math.cos(a) * maxR, - z2: centerZ + Math.sin(a) * maxR, - width: SPOKE_ROAD_WIDTH, - }); + deck( + [ + { x: centerX + Math.cos(a) * innerR, z: centerZ + Math.sin(a) * innerR }, + { x: centerX + Math.cos(a) * maxR, z: centerZ + Math.sin(a) * maxR }, + ], + SPOKE_ROAD_WIDTH, + SPOKE_DECK_HEIGHT, + ); } - return { blocks, roads }; + return { blocks, roads, overpasses }; }; export const LAYOUTS: Record = { @@ -222,4 +247,4 @@ export const LAYOUTS: Record = { RING: ringLayout, }; -export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH }; +export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, RING_DECK_HEIGHT, SPOKE_DECK_HEIGHT }; From 9ee19e95822069dec40de8dbc1252095b40efdbf Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 30 Jul 2026 21:26:10 -0500 Subject: [PATCH 07/40] fix(citygen): decks no longer pierce buildings; spokes reach the ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with the elevated arterials. Decks ran through towers. Placement deliberately ignores overpasses so the ground beneath stays buildable, which is what stops an arterial sterilising every block it crosses — but nothing then stopped a building rising through the deck. Rather than blocking the ground again, anything under a deck is now capped just below it, which is what actually happens around an elevated freeway. Where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. Ramps did not reach the ground. Ring decks were the worse offender: a closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point, and both read as broken. Rings are ground roads again — a loop with verges is what a beltway looks like anyway — and only the spokes are elevated, which is what was actually asked for. Spoke ramps are now sized from the spoke rather than being a fixed 30 units, so both ends always reach the ground however large the city is. A fixed ramp longer than half the deck leaves it ending in mid-air. --- .../src/cityGen/__tests__/layouts.test.ts | 165 ++++++++++++------ frontend/src/cityGen/collision.ts | 94 ++++++++++ frontend/src/cityGen/index.ts | 14 +- frontend/src/cityGen/layouts.ts | 69 +++++--- 4 files changed, 262 insertions(+), 80 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 359f01a..729cc3b 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -8,11 +8,8 @@ import { RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, - SPOKE_ROAD_WIDTH, - RING_DECK_HEIGHT, - SPOKE_DECK_HEIGHT, - RING_COUNT as RC, SPOKE_COUNT as SC, + clampBuildingsUnderDecks, generateCity, SUPERBLOCK_MIN_SIZE, GRID_AVENUE_WIDTH, @@ -44,6 +41,19 @@ function seededRng() { const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +/** Perpendicular distance from a point to a segment, on the XZ plane. */ +function distanceToSegment( + p: { x: number; z: number }, + a: { x: number; z: number }, + b: { x: number; z: number }, +) { + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + const t = lenSq < 1e-9 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq)); + return Math.hypot(p.x - (a.x + dx * t), p.z - (a.z + dz * t)); +} + // ─── registry ───────────────────────────────────────────────────────────────── describe('layout registry', () => { @@ -193,32 +203,28 @@ describe('ringLayout', () => { expect(corner).toHaveLength(0); }); - it('raises its arterials rather than laying them on the ground', () => { - // A ground-level beltway sterilises every block it crosses: placement rejects any - // footprint touching a road. Elevated, it consumes no ground — placement never - // checks overpasses — so the fabric runs unbroken underneath. - const { overpasses } = ringLayout(bounds(R), false, seededRng()); - expect(overpasses?.length).toBe(RING_COUNT + SPOKE_COUNT); - for (const o of overpasses ?? []) expect(o.height).toBeGreaterThan(0); + it('elevates the spokes but leaves the loops on the ground', () => { + // A closed loop has no ends to ramp down at, so an elevated ring either never + // meets the street network or does so at one arbitrary point. Both read as broken. + const { roads, overpasses } = ringLayout(bounds(R), false, seededRng()); + expect(overpasses?.length).toBe(SPOKE_COUNT); + expect(roads.some((r) => r.width === RING_ROAD_WIDTH)).toBe(true); }); it('lays closed loops at more than one radius', () => { - const { overpasses } = ringLayout(bounds(R), false, seededRng()); - const rings = (overpasses ?? []).filter((o) => o.height === RING_DECK_HEIGHT); - expect(rings).toHaveLength(RING_COUNT); + const { roads } = ringLayout(bounds(R), false, seededRng()); + const ringRoads = roads.filter((r) => r.width === RING_ROAD_WIDTH); + expect(ringRoads.length).toBeGreaterThan(RING_COUNT * 10); - const radii = rings.map((o) => Math.round(radiusOf(o.points[0]))); - expect(new Set(radii).size).toBe(RING_COUNT); + const radii = new Set(ringRoads.map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }) / 10))); + expect(radii.size).toBeGreaterThanOrEqual(RING_COUNT); }); it('runs spokes outward from the innermost loop, not from the centre', () => { // Six arterials converging on a point left a starburst of dead ground there, and // real highways meet a downtown loop rather than piling into the middle. const { overpasses } = ringLayout(bounds(R), false, seededRng()); - const spokes = (overpasses ?? []).filter((o) => o.height === SPOKE_DECK_HEIGHT); - expect(spokes).toHaveLength(SPOKE_COUNT); - - for (const s of spokes) { + for (const s of overpasses ?? []) { const inner = Math.min(...s.points.map(radiusOf)); const outer = Math.max(...s.points.map(radiusOf)); expect(inner).toBeGreaterThan(1); @@ -226,17 +232,26 @@ describe('ringLayout', () => { } }); - it('carries spokes above rings so they cross cleanly', () => { - expect(SPOKE_DECK_HEIGHT).toBeGreaterThan(RING_DECK_HEIGHT); + it('brings both ends of a spoke down to the ground', () => { + // Ramps that do not fit inside the deck leave it ending in mid-air. + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + for (const s of overpasses ?? []) { + const length = Math.hypot( + s.points[1].x - s.points[0].x, s.points[1].z - s.points[0].z, + ); + expect(s.ramp_length_start + s.ramp_length_end).toBeLessThanOrEqual(length); + expect(s.ramp_length_start).toBeGreaterThan(0); + expect(s.ramp_length_end).toBeGreaterThan(0); + } }); it('spaces the inner loop tighter than the outer one', () => { // Beltways are not evenly spaced; downtown is ringed close. - const { overpasses } = ringLayout(bounds(R), false, seededRng()); - const radii = (overpasses ?? []) - .filter((o) => o.height === RING_DECK_HEIGHT) - .map((o) => Math.round(radiusOf(o.points[0]))) - .sort((a, b) => a - b); + const { roads } = ringLayout(bounds(R), false, seededRng()); + const radii = [...new Set( + roads.filter((r) => r.width === RING_ROAD_WIDTH) + .map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }))), + )].sort((a, b) => a - b); expect(radii[0]).toBeLessThan(radii[radii.length - 1] / 2); }); @@ -265,15 +280,11 @@ describe('ringLayout', () => { }); it('lays one continuous street fabric rather than one per sector', () => { - // Local streets are continuous and the beltway crosses above them; they are not - // divided up by it. Sector-local networks showed up as far more short segments. + // Local streets are continuous; they are not divided up by the loops. + // Sector-local networks showed up as far more short segments. const { roads } = ringLayout(bounds(R), false, seededRng()); - expect(roads.length).toBeGreaterThan(0); - // Every ground road is local now — the arterials are decks. - for (const r of roads) { - expect(r.width).not.toBe(RING_ROAD_WIDTH); - expect(r.width).not.toBe(SPOKE_ROAD_WIDTH); - } + const local = roads.filter((r) => r.width !== RING_ROAD_WIDTH); + expect(local.length).toBeGreaterThan(0); }); it('raises nothing when infrastructure is excluded', () => { @@ -313,16 +324,16 @@ describe('generateCity layout selection', () => { }); it('carries arterials raised by the layout through to the result', () => { - // RING raises its beltways, and those have to reach the caller alongside whatever + // RING elevates its spokes, and those have to reach the caller alongside whatever // bridges the water needed. const result = generateCity( bounds(300), { sectionType: 'MIXED', layout: 'RING' }, freshContext(), seededRng(), deps, ); - expect(result.overpasses.length).toBeGreaterThanOrEqual(RC + SC); + expect(result.overpasses.length).toBeGreaterThanOrEqual(SC); }); - it('leaves the ground under an elevated arterial buildable', () => { - // The whole reason for raising them: a ground-level beltway sterilises every block + it('leaves the ground under an elevated spoke buildable', () => { + // The whole reason for raising them: a ground-level arterial sterilises every block // it crosses, because placement rejects footprints touching a road. Placement never // checks overpasses, so the fabric survives underneath. const offered: Array<{ x: number; z: number }> = []; @@ -334,16 +345,11 @@ describe('generateCity layout selection', () => { { fillPlot: (x: number, z: number) => { offered.push({ x, z }); } }, ); - const ring = result.overpasses.find((o) => o.height === RING_DECK_HEIGHT); - expect(ring).toBeDefined(); - const ringRadius = Math.hypot(ring!.points[0].x, ring!.points[0].z); - - // Somewhere close enough to the loop to have been inside its footprint had it been - // a road. - const beneath = offered.filter( - (p) => Math.abs(Math.hypot(p.x, p.z) - ringRadius) < ring!.width, - ); - expect(beneath.length).toBeGreaterThan(0); + const spoke = result.overpasses[0]; + expect(spoke).toBeDefined(); + const near = offered.filter((p) => + p.x * 0 === 0 && distanceToSegment(p, spoke.points[0], spoke.points[1]) < spoke.width); + expect(near.length).toBeGreaterThan(0); }); it('falls back to BSP for an unrecognised layout', () => { @@ -369,3 +375,64 @@ describe('generateCity layout selection', () => { } }); }); + +// ─── decks over buildings ───────────────────────────────────────────────────── + +describe('clampBuildingsUnderDecks', () => { + const deck = (over: Partial<{ points: { x: number; z: number }[]; width: number; height: number }> = {}) => ({ + points: [{ x: 0, z: 0 }, { x: 200, z: 0 }], + width: 8, + height: 14, + ramp_length: 60, + ramp_length_start: 60, + ramp_length_end: 60, + ...over, + }); + + const building = (over: Partial<{ x: number; z: number; width: number; depth: number; height: number }> = {}) => ({ + x: 100, z: 0, width: 6, depth: 6, height: 50, ...over, + }); + + it('leaves buildings clear of any deck alone', () => { + const b = building({ z: 500 }); + expect(clampBuildingsUnderDecks([b], [deck()])).toEqual([b]); + }); + + it('leaves everything alone when there are no decks', () => { + const b = building(); + expect(clampBuildingsUnderDecks([b], [])).toEqual([b]); + }); + + it('caps a tower that would rise through the deck', () => { + // The reported bug: overpasses running through buildings. + const [out] = clampBuildingsUnderDecks([building({ height: 50 })], [deck()]); + expect(out.height).toBeLessThan(14); + expect(out.height).toBeGreaterThan(0); + }); + + it('leaves a building that already fits underneath', () => { + const b = building({ height: 4 }); + const [out] = clampBuildingsUnderDecks([b], [deck()]); + expect(out.height).toBe(4); + }); + + it('drops a building where the deck is too low to build under', () => { + // Near a ramp the deck is at ground level, which is just a road. + const out = clampBuildingsUnderDecks([building({ x: 3, height: 20 })], [deck()]); + expect(out).toHaveLength(0); + }); + + it('caps against the lowest deck when several cross', () => { + const low = deck({ height: 10, points: [{ x: 0, z: 0 }, { x: 200, z: 0 }] }); + const high = deck({ height: 24, points: [{ x: 100, z: -100 }, { x: 100, z: 100 }] }); + const [out] = clampBuildingsUnderDecks([building({ height: 60 })], [high, low]); + expect(out.height).toBeLessThan(10); + }); + + it('accounts for footprint width, not just the centre point', () => { + // A wide building whose centre clears the deck can still be under its edge. + const wide = building({ z: 9, width: 20, depth: 20, height: 40 }); + const [out] = clampBuildingsUnderDecks([wide], [deck()]); + expect(out.height).toBeLessThan(40); + }); +}); diff --git a/frontend/src/cityGen/collision.ts b/frontend/src/cityGen/collision.ts index 0d2dc56..06270d5 100644 --- a/frontend/src/cityGen/collision.ts +++ b/frontend/src/cityGen/collision.ts @@ -1,5 +1,6 @@ import type { Obstacle, RoadSegment } from './types'; import { footprintInWater, footprintOutsidePolygon, type WaterPolygon } from './water'; +import { elevationAt } from '../utils/overpassHelpers'; /** Cell size of the uniform grid used to bucket obstacles. */ const GRID_CELL = 20; @@ -218,3 +219,96 @@ export function createIsBlocked( return false; }; } + +/** Clearance kept between the top of a building and the underside of a deck. */ +export const DECK_CLEARANCE = 3; + +/** Below this there is no room to build under a deck at all. */ +export const MIN_UNDER_DECK_HEIGHT = 2.5; + +interface DeckLike { + points: { x: number; z: number }[]; + width: number; + height: number; + ramp_length: number; + ramp_length_start?: number; + ramp_length_end?: number; +} + +/** Distance along a polyline to the point nearest (px,pz), and the total length. */ +function arcLengthToNearest(points: { x: number; z: number }[], px: number, pz: number) { + let best = Infinity; + let bestS = 0; + let total = 0; + const lengths: number[] = []; + + for (let i = 0; i < points.length - 1; i++) { + const len = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].z - points[i].z); + lengths.push(len); + total += len; + } + + let run = 0; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + const t = lenSq < 1e-9 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (pz - a.z) * dz) / lenSq)); + const cx = a.x + dx * t; + const cz = a.z + dz * t; + const dist = Math.hypot(px - cx, pz - cz); + if (dist < best) { + best = dist; + bestS = run + lengths[i] * t; + } + run += lengths[i]; + } + + return { distance: best, s: bestS, total }; +} + +/** + * Keep buildings from being run through by an elevated deck. + * + * Placement deliberately ignores overpasses, so the ground under a beltway stays + * buildable — that is what stops an arterial sterilising every block it crosses. The + * cost is that nothing stops a tower rising through the deck. Rather than blocking the + * ground again, anything beneath a deck is capped just under it, which is what actually + * happens around an elevated freeway. + * + * Where the deck is too low to build under at all — near its ramps, where it meets the + * ground — the building is dropped instead of being squashed to nothing. + */ +export function clampBuildingsUnderDecks( + buildings: T[], + decks: DeckLike[] +): T[] { + if (decks.length === 0) return buildings; + + const out: T[] = []; + for (const b of buildings) { + let cap = Infinity; + + for (const deck of decks) { + if (deck.points.length < 2) continue; + const reach = deck.width / 2 + Math.max(b.width, b.depth) / 2; + const { distance, s, total } = arcLengthToNearest(deck.points, b.x, b.z); + if (distance > reach) continue; + + const deckY = elevationAt( + s, total, deck.height, deck.ramp_length, + false, false, + deck.ramp_length_start, deck.ramp_length_end + ); + cap = Math.min(cap, deckY - DECK_CLEARANCE); + } + + if (cap === Infinity) { out.push(b); continue; } + if (cap < MIN_UNDER_DECK_HEIGHT) continue; + out.push(b.height > cap ? { ...b, height: cap } : b); + } + + return out; +} diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index fd825e0..4d45ef0 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -2,7 +2,7 @@ import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from '../components/Buildings'; import { LAYOUTS } from './layouts'; import { normalizeBounds } from './bsp'; -import { SpatialGrid, createIsBlocked, footprintOnRoad } from './collision'; +import { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks } from './collision'; import { createSectorLayout, normalizedDistance, @@ -27,7 +27,7 @@ import type { export * from './types'; export { splitCity, normalizeBounds, maxSplitDepthFor } from './bsp'; -export { SpatialGrid, createIsBlocked, footprintOnRoad } from './collision'; +export { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks, DECK_CLEARANCE, MIN_UNDER_DECK_HEIGHT } from './collision'; export * from './zoning'; export { generatePark } from './parks'; export { shouldPlaceLandmark, generateLandmark } from './landmarks'; @@ -195,5 +195,13 @@ export function generateCity( tagPlot(zonePrefix); }); - return { blocks, roads: finalRoads, buildings, overpasses }; + // Placement ignores overpasses so the ground beneath stays buildable; nothing there + // stops a tower rising through a deck, so anything under one is capped just below it. + // Applies to water bridges too, which pierce buildings for the same reason. + return { + blocks, + roads: finalRoads, + buildings: clampBuildingsUnderDecks(buildings, overpasses), + overpasses, + }; } diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index f2b5a32..596c552 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -51,19 +51,21 @@ const RING_ROAD_WIDTH = 8; const SPOKE_ROAD_WIDTH = 7; /** - * Arterials are elevated rather than laid on the ground. + * Spokes are elevated; rings are not. * - * A ground-level beltway sterilises every block it crosses: `createIsBlocked` rejects - * any footprint touching a road, so a 7-unit arterial leaves a dead strip its whole - * length, and six spokes converging leave a dead zone at the centre. Elevated, they - * consume no ground at all — placement never checks overpasses — so the street fabric - * runs unbroken underneath and small buildings fill in below the deck. + * An elevated deck consumes no ground — placement never checks overpasses — so the + * street fabric runs unbroken beneath it and small buildings fill in below. That is + * what stops six converging arterials sterilising the middle of the city. * - * Spokes sit above rings so they pass over cleanly at the crossings. + * Rings stay on the ground because a closed loop has no ends to ramp down at. An + * elevated loop either never touches the street network or does so at one arbitrary + * point, and both read as broken. A ground-level loop simply has verges, which is what + * a beltway looks like anyway. */ -const RING_DECK_HEIGHT = 9; const SPOKE_DECK_HEIGHT = 14; -const DECK_RAMP_LENGTH = 30; + +/** Fraction of a spoke given over to each ramp, so both ends reach the ground. */ +const DECK_RAMP_FRACTION = 0.3; const DECK_PILLAR_SPACING = 14; /** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ @@ -188,6 +190,19 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun const roads: RoadSegment[] = []; + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const layPolyline = (pts: { x: number; z: number }[], w: number) => { + for (let i = 0; i < pts.length - 1; i++) { + layRoad({ x1: pts[i].x, z1: pts[i].z, x2: pts[i + 1].x, z2: pts[i + 1].z, width: w }); + } + }; + // The city is the disc, so that circle is the boundary the street fabric is laid // inside. Combined with any outer drawn boundary, both must hold. const disc: Polygon = { points: arcPoints(centerX, centerZ, maxR, 0, Math.PI * 2) }; @@ -197,18 +212,6 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun for (const r of fill.roads) roads.push(...clipSegmentToBoundary(r, boundary)); const overpasses: OverpassSpec[] = []; - const deck = (points: { x: number; z: number }[], width: number, height: number) => { - if (excludeRoads || points.length < 2) return; - overpasses.push({ - points, - height, - width, - ramp_length: DECK_RAMP_LENGTH, - ramp_length_start: DECK_RAMP_LENGTH, - ramp_length_end: DECK_RAMP_LENGTH, - pillar_spacing: DECK_PILLAR_SPACING, - }); - }; // Radii grow faster than linearly, so downtown is ringed tightly and the outer loop // sweeps wide. @@ -217,24 +220,34 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun radii.push(maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF)); } for (const r of radii) { - deck(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH, RING_DECK_HEIGHT); + layPolyline(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH); } // Spokes run from the innermost loop outward rather than converging on a point. // Six arterials meeting at the centre left a starburst of dead ground there, and // real highways meet a downtown loop rather than piling into the middle. const innerR = radii[0]; + const spokeLength = Math.max(1, maxR - innerR); + // Both ramps have to fit inside the spoke, or the deck never reaches the ground and + // the road ends in mid-air. + const rampLength = spokeLength * DECK_RAMP_FRACTION; + const sector = (Math.PI * 2) / SPOKE_COUNT; for (let i = 0; i < SPOKE_COUNT; i++) { const a = i * sector + (rng() - 0.5) * sector * 0.2; - deck( - [ + if (excludeRoads) continue; + overpasses.push({ + points: [ { x: centerX + Math.cos(a) * innerR, z: centerZ + Math.sin(a) * innerR }, { x: centerX + Math.cos(a) * maxR, z: centerZ + Math.sin(a) * maxR }, ], - SPOKE_ROAD_WIDTH, - SPOKE_DECK_HEIGHT, - ); + height: SPOKE_DECK_HEIGHT, + width: SPOKE_ROAD_WIDTH, + ramp_length: rampLength, + ramp_length_start: rampLength, + ramp_length_end: rampLength, + pillar_spacing: DECK_PILLAR_SPACING, + }); } return { blocks, roads, overpasses }; @@ -247,4 +260,4 @@ export const LAYOUTS: Record = { RING: ringLayout, }; -export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, RING_DECK_HEIGHT, SPOKE_DECK_HEIGHT }; +export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; From 9980357327f5e95b34adf15fed567f9d2210f45d Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 13:14:05 -0500 Subject: [PATCH 08/40] =?UTF-8?q?chore:=20release=201.8.0=20=E2=80=94=20dr?= =?UTF-8?q?awn=20bounds=20and=20street=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feature release rather than a patch: generation gains a boundary you draw and four selectable street layouts. Also refreshes the README for the new cityGen surface — layouts.ts, the shared water/boundary clipper, clampBuildingsUnderDecks, and the two new test suites. --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ README.md | 13 ++++++++----- frontend/package.json | 2 +- package.json | 2 +- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f10542..f686482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.8.0] - 2026-07-28 + +### Added + +- **Drawn generation bounds** — the city generator gains a `DRAG_RECT` / `DRAW_AREA` toggle. `DRAW_AREA` traces a boundary the way water is drawn, and generation is confined to that shape: blocks centred outside it are dropped, road seams are clipped to it, and a footprint straddling its edge is rejected. A concave shape generates nothing in its notch, so an L or a crescent works as drawn. The traced outline stays on screen until GENERATE, unlike water, which saves immediately. +- **Street layouts** — a `LAYOUT` selector offering four distinct city types. Everything downstream of the block list is layout-agnostic, so a layout only has to produce blocks and the roads between them. + - **`GRID`** — two perpendicular families of streets with avenues every fourth line. Reads as Manhattan or Chicago, and is genuinely distinct from the default, which always produces *irregular* rectangles however it is tuned. That road hierarchy is most of what makes a grid look designed rather than generated. + - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. + - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. + - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. + +### Fixed + +- **Elevated arterials no longer run through buildings.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an arterial sterilising every block it crosses — but nothing then stopped a tower rising through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. + +### Technical + +- **A drawn boundary is a water polygon with the sign flipped** — water keeps what falls outside, a boundary keeps what falls inside. `clipSegmentToLand` was generalised into `clipSegmentToPolygons(seg, polys, keepInside)` so the two share one implementation and cannot drift; there is a test asserting they are exact inverses. `footprintOutsidePolygon` mirrors `footprintInWater` but is stricter: water asks whether a footprint touches water at all, a boundary asks whether all of it is inside. +- A boundary of fewer than three points cannot enclose an area and is treated as absent, falling back to the plain bounds rather than generating nothing and looking like a broken button. +- Skipping a block draws no randomness, so generation without a boundary splits byte-identically to before. Tests pin that at both the split and the whole-city level. +- **`RING` fills the disc with a single sub-layout and lays its arterials over the top.** A first version partitioned the disc into annular sectors and sub-laid each one, which produced a sparse, fragmented city — a sector's bounding box is far larger than the sector, so most of what each run generated fell outside its own region and was discarded. Density is now on par with the default over the same area. +- **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. +- Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. +- `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. + +--- + ## [1.7.4] - 2026-07-28 ### Added diff --git a/README.md b/README.md index 2605fac..8fa0957 100644 --- a/README.md +++ b/README.md @@ -403,21 +403,24 @@ CITY_NET/ │ │ ├── App.tsx # Root component — state, routing, socket wiring │ │ ├── App.css / index.css # Global styles and CSS variables │ │ ├── cityGen/ # Pure city generator — bounds + options + world state in, blocks/roads/buildings/overpasses out. No React, no network; AdminPanel persists the result -│ │ │ ├── index.ts # generateCity orchestrator; injected rng and fillPlot make it testable +│ │ │ ├── index.ts # generateCity orchestrator; selects a layout, caps buildings under decks; injected rng and fillPlot make it testable │ │ │ ├── types.ts # Bounds, Block, RawBuilding, Obstacle, options/context/result shapes -│ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land as they are laid -│ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers) + exact segment-vs-box road test +│ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land and to any drawn boundary as they are laid; optional minimum block size +│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc) +│ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp │ │ │ ├── parks.ts # Holotree park plots │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule -│ │ │ ├── water.ts # Water polygon parsing, point/footprint-in-water, submerged spans, segment clipping +│ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them │ │ │ ├── bridges.ts # Shore-stub pairing, span/grade limits, deck levelling by graph colouring, OVERPASS_DENSITY │ │ │ └── __tests__/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation +│ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary +│ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks │ │ │ └── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels │ │ ├── components/ -│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes OVERPASS_DENSITY; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) +│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds and OVERPASS_DENSITY; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) │ │ │ ├── HitPoints.tsx # HP tracking + injury panel + HealthReviewWindow; STIM_HEAL (CWN), STABILIZE button for allies on mortal wound │ │ │ ├── BankWindows.tsx # Player bank UI │ │ │ ├── ChatWindow.tsx # In-game chat diff --git a/frontend/package.json b/frontend/package.json index e2412e2..ce94df9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.4", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/package.json b/package.json index 99a1fc6..6c12b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapsystem", - "version": "1.7.4", + "version": "1.8.0", "description": "", "main": "index.js", "scripts": { From 753bfeb2057c9cdc59f9dd2185274220a66d7340 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 13:23:15 -0500 Subject: [PATCH 09/40] feat(citygen): graded road hierarchy, skyline taper, per-zone setbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that improve every layout at once rather than adding a fifth. Road width was a straight either/or — arterial for the first two splits, side street for everything after — which read as two kinds of road rather than a hierarchy. Width now steps down through a table indexed by split depth, so the network runs arterial, collector, local. That gradient is one of the strongest cues that a street plan was laid out rather than scattered. Building height stepped at zone boundaries. Zone already varies with distance, so there was a coarse taper, but it changed in bands and the skyline came out as flat plateaus with hard seams. Heights are now scaled within the zone by distance from the centre, so downtown reads as a peak rather than a mesa. Deliberately gentle — the zone bands do the heavy lifting and a strong multiplier would fight them. Landmarks are exempt; a hero building is sized on purpose. Every zone filled its plot the same way, so districts differed in what they built but not in how it sat on the ground. Plots are now inset by a per-zone coverage ratio: corporate leaves a forecourt, slums and markets build to the lot line. --- .../src/cityGen/__tests__/layouts.test.ts | 155 ++++++++++++++++++ frontend/src/cityGen/bsp.ts | 25 ++- frontend/src/cityGen/index.ts | 17 +- frontend/src/cityGen/zoning.ts | 51 ++++++ 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 729cc3b..bde7f70 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -10,6 +10,9 @@ import { RING_ROAD_WIDTH, SPOKE_COUNT as SC, clampBuildingsUnderDecks, + roadWidthForDepth, + heightScaleFor, + lotCoverageFor, generateCity, SUPERBLOCK_MIN_SIZE, GRID_AVENUE_WIDTH, @@ -436,3 +439,155 @@ describe('clampBuildingsUnderDecks', () => { expect(out.height).toBeLessThan(40); }); }); + +// ─── road hierarchy ─────────────────────────────────────────────────────────── + +describe('roadWidthForDepth', () => { + it('narrows as the split goes deeper', () => { + // The earliest splits carve the largest areas, so they carry the arterials. + const widths = [0, 1, 2, 3, 4].map(roadWidthForDepth); + for (let i = 1; i < widths.length; i++) { + expect(widths[i]).toBeLessThan(widths[i - 1]); + } + }); + + it('offers a real gradient, not just two kinds of road', () => { + // It used to be arterial for the first two splits and side street for the rest, + // which read as two road types rather than a hierarchy. + const distinct = new Set([0, 1, 2, 3, 4].map(roadWidthForDepth)); + expect(distinct.size).toBeGreaterThanOrEqual(4); + }); + + it('holds the narrowest width past the end of the table', () => { + expect(roadWidthForDepth(99)).toBe(roadWidthForDepth(4)); + }); + + it('treats a negative depth as the widest', () => { + expect(roadWidthForDepth(-3)).toBe(roadWidthForDepth(0)); + }); +}); + +describe('street hierarchy in generated layouts', () => { + it('gives the default layout several road widths', () => { + const { roads } = bspLayout(bounds(400), false, seededRng()); + expect(new Set(roads.map((r) => r.width)).size).toBeGreaterThanOrEqual(3); + }); +}); + +// ─── height gradient ────────────────────────────────────────────────────────── + +describe('heightScaleFor', () => { + it('builds tallest at the centre and lowest at the rim', () => { + expect(heightScaleFor(0)).toBeGreaterThan(heightScaleFor(1)); + }); + + it('falls off continuously rather than in steps', () => { + // Zone already steps with distance; the skyline came out as flat plateaus with + // hard seams. This is what softens them. + const samples = [0, 0.2, 0.4, 0.6, 0.8, 1].map(heightScaleFor); + for (let i = 1; i < samples.length; i++) { + expect(samples[i]).toBeLessThan(samples[i - 1]); + } + expect(new Set(samples).size).toBe(samples.length); + }); + + it('stays gentle, so it softens the zone bands rather than fighting them', () => { + expect(heightScaleFor(0) / heightScaleFor(1)).toBeLessThan(2); + }); + + it('clamps outside the normalised range', () => { + expect(heightScaleFor(-5)).toBe(heightScaleFor(0)); + expect(heightScaleFor(99)).toBe(heightScaleFor(1)); + }); +}); + +describe('skyline taper end to end', () => { + it('builds taller near the centre than at the edge', () => { + const heights: Array<{ r: number; h: number }> = []; + generateCity( + bounds(400), + { sectionType: 'MIXED' }, + freshContext(), + seededRng(), + { + // A fixed height, so any difference in the result is the gradient alone. + fillPlot: (x: number, z: number, _bw: number, _bd: number, _zone: number, + _blocked: unknown, _key: unknown, _cells: unknown, out: { height: number }[]) => { + out.push({ x, z, width: 4, depth: 4, height: 10, name: '', color: '#fff', shape: 'box', y: 0 } as never); + }, + } as never, + ).buildings.forEach((b) => heights.push({ r: Math.hypot(b.x, b.z), h: b.height })); + + const inner = heights.filter((v) => v.r < 120); + const outer = heights.filter((v) => v.r > 300); + expect(inner.length).toBeGreaterThan(0); + expect(outer.length).toBeGreaterThan(0); + + const mean = (v: typeof inner) => v.reduce((a, x) => a + x.h, 0) / v.length; + expect(mean(inner)).toBeGreaterThan(mean(outer)); + }); +}); + +// ─── lot coverage ───────────────────────────────────────────────────────────── + +describe('lotCoverageFor', () => { + const CORPO = 1.0, URBAN = 0.5, SLUMS = 0.1, INDUSTRIAL = -0.1, MARKETS = 2.0; + + it('leaves corporate plots a forecourt', () => { + expect(lotCoverageFor(CORPO)).toBeLessThan(lotCoverageFor(URBAN)); + }); + + it('builds slums and markets to the lot line', () => { + expect(lotCoverageFor(SLUMS)).toBeGreaterThan(0.9); + expect(lotCoverageFor(MARKETS)).toBeGreaterThan(0.9); + }); + + it('distinguishes the zones rather than treating them alike', () => { + // Previously every zone filled its plot the same way, so districts differed only + // in what they built, not how it sat on the ground. + const all = [CORPO, URBAN, SLUMS, INDUSTRIAL, MARKETS].map(lotCoverageFor); + expect(new Set(all).size).toBeGreaterThan(2); + }); + + it('never exceeds the plot or collapses it', () => { + for (const z of [CORPO, URBAN, SLUMS, INDUSTRIAL, MARKETS, 1.7, 3.0, 99]) { + expect(lotCoverageFor(z)).toBeGreaterThan(0.5); + expect(lotCoverageFor(z)).toBeLessThanOrEqual(1); + } + }); +}); + +describe('setbacks end to end', () => { + it('offers corporate plots a smaller footprint than the block', () => { + const offered: Array<{ bw: number; bd: number }> = []; + generateCity( + bounds(400), + { sectionType: 'CORPO' }, + freshContext(), + seededRng(), + { + fillPlot: (_x: number, _z: number, bw: number, bd: number) => { + offered.push({ bw, bd }); + }, + } as never, + ); + expect(offered.length).toBeGreaterThan(0); + // Every plot handed to placement is inset from its block. + for (const p of offered) { + expect(p.bw).toBeGreaterThan(0); + expect(p.bd).toBeGreaterThan(0); + } + }); + + it('gives slums a fuller plot than corporate for the same block size', () => { + const capture = (sectionType: 'CORPO' | 'SLUMS') => { + const areas: number[] = []; + generateCity( + bounds(400), { sectionType }, freshContext(), seededRng(), + { fillPlot: (_x: number, _z: number, bw: number, bd: number) => { areas.push(bw * bd); } } as never, + ); + return areas.reduce((a, v) => a + v, 0) / areas.length; + }; + expect(capture('SLUMS')).toBeGreaterThan(capture('CORPO')); + }); +}); diff --git a/frontend/src/cityGen/bsp.ts b/frontend/src/cityGen/bsp.ts index 820000e..0e4cd00 100644 --- a/frontend/src/cityGen/bsp.ts +++ b/frontend/src/cityGen/bsp.ts @@ -1,9 +1,25 @@ import type { Block, Bounds, Rng, RoadSegment } from './types'; import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; -/** Widths used for the road laid down at each split. */ -const MAIN_ROAD_WIDTH = 6; -const SIDE_ROAD_WIDTH = 3; +/** + * Road width by split depth, widest first. + * + * The earliest splits carve the largest areas, so they are the arterials; each level + * down is a smaller street. This used to be a straight either/or — arterial for the + * first two splits, side street for everything after — which left the network reading + * as two kinds of road rather than a hierarchy. Real networks step down through + * arterial, collector and local, and that gradient is one of the strongest cues that a + * street plan was laid out rather than scattered. + * + * Depths past the end of the table all use the last entry. + */ +const ROAD_WIDTH_BY_DEPTH = [7, 5.5, 4, 3, 2.5]; + +/** Width of a seam laid at the given recursion depth. */ +export function roadWidthForDepth(depth: number): number { + const i = Math.max(0, Math.min(ROAD_WIDTH_BY_DEPTH.length - 1, depth)); + return ROAD_WIDTH_BY_DEPTH[i]; +} /** A block stops subdividing once both dimensions fall under this. */ const MIN_BLOCK_SIZE = 35; @@ -85,7 +101,8 @@ export function splitCity( return; } const splitV = w > d ? true : (w === d ? rng() > 0.5 : false); - const roadW = iter < 2 ? MAIN_ROAD_WIDTH : SIDE_ROAD_WIDTH; + const roadW = roadWidthForDepth(iter); + // Bigger splits wander more, in step with the road they carry. const jitter = (rng() - 0.5) * (iter < 2 ? 10 : 5); if (splitV) { diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 4d45ef0..b713377 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -6,6 +6,8 @@ import { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks import { createSectorLayout, normalizedDistance, + heightScaleFor, + lotCoverageFor, parkProbability, assignZoneType, zonePrefixFor, @@ -26,7 +28,7 @@ import type { } from './types'; export * from './types'; -export { splitCity, normalizeBounds, maxSplitDepthFor } from './bsp'; +export { splitCity, normalizeBounds, maxSplitDepthFor, roadWidthForDepth } from './bsp'; export { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks, DECK_CLEARANCE, MIN_UNDER_DECK_HEIGHT } from './collision'; export * from './zoning'; export { generatePark } from './parks'; @@ -181,6 +183,11 @@ export function generateCity( ); const zonePrefix = zonePrefixFor(zoneTypeVal); ({ bw, bd } = clampPlotAspect(bw, bd, zoneTypeVal)); + // Setback: corporate plots leave forecourts, slums and markets build to the lot + // line. Applied after the aspect clamp so it shrinks the plot actually used. + const coverage = lotCoverageFor(zoneTypeVal); + bw *= coverage; + bd *= coverage; if (shouldPlaceLandmark(block, bw, bd, zoneTypeVal, isBlocked, rng)) { generateLandmark(block, bw, bd, buildings, grid, rng); @@ -188,10 +195,18 @@ export function generateCity( return; } + const beforeFill = buildings.length; deps.fillPlot( block.x, block.z, bw, bd, zoneTypeVal, isBlocked, grid.key, grid.cells, buildings, context.locations, plotId ); + // Zone already steps down with distance, but only in bands — the skyline came out + // as flat plateaus with hard seams. Scaling within the zone softens those into a + // continuous taper. Landmarks are left alone; a hero building is sized on purpose. + const heightScale = heightScaleFor(normDist); + for (let i = beforeFill; i < buildings.length; i++) { + buildings[i].height *= heightScale; + } tagPlot(zonePrefix); }); diff --git a/frontend/src/cityGen/zoning.ts b/frontend/src/cityGen/zoning.ts index 295d077..f2fd2e6 100644 --- a/frontend/src/cityGen/zoning.ts +++ b/frontend/src/cityGen/zoning.ts @@ -155,3 +155,54 @@ export function clampPlotAspect( if (bd > bw * maxRatio) return { bw, bd: bw * maxRatio }; return { bw, bd }; } + +/** How much taller the very centre builds than the outskirts. */ +export const HEIGHT_GRADIENT_PEAK = 1.25; +export const HEIGHT_GRADIENT_EDGE = 0.75; + +/** + * Height multiplier for a plot at normalised distance `normDist` from the centre. + * + * Zone already varies with distance, so there is a coarse taper: CORPO towers near the + * middle, slums at the rim. But zone changes in steps, so the skyline came out as three + * or four flat plateaus with hard seams between them. This blends *within* a zone, so + * height falls off smoothly and a downtown reads as a peak rather than a mesa. + * + * Deliberately gentle. The zone bands are doing the heavy lifting; this only softens + * their edges, and a strong multiplier would fight them. + */ +export function heightScaleFor(normDist: number): number { + const t = Math.min(1, Math.max(0, normDist)); + return HEIGHT_GRADIENT_PEAK + (HEIGHT_GRADIENT_EDGE - HEIGHT_GRADIENT_PEAK) * t; +} + +/** + * Fraction of its plot a zone builds on, the rest left as setback. + * + * Dense coverage reads as an old city that grew to its lot lines; generous setbacks + * read as modern and corporate, with plazas and forecourts. Every zone previously + * filled its plot the same way, which is part of why districts differed only in what + * they built rather than how they sat on the ground. + * + * Keyed off the same `zoneTypeVal` bands `fillPlot` uses, so the two agree about what + * a plot is. + */ +export const LOT_COVERAGE = { + MARKETS: 0.95, + LANDMARK: 0.70, + CORPO: 0.72, + URBAN: 0.85, + SLUMS: 0.95, + INDUSTRIAL: 0.80, + DEFAULT: 0.85, +} as const; + +export function lotCoverageFor(zoneTypeVal: number): number { + if (zoneTypeVal === 2.0) return LOT_COVERAGE.MARKETS; + if (zoneTypeVal >= 1.5 && zoneTypeVal < 2.0) return LOT_COVERAGE.LANDMARK; + if (zoneTypeVal > 0.8 && zoneTypeVal < 1.5) return LOT_COVERAGE.CORPO; + if (zoneTypeVal > 0.3 && zoneTypeVal < 0.8) return LOT_COVERAGE.URBAN; + if (zoneTypeVal <= 0.25 && zoneTypeVal >= 0) return LOT_COVERAGE.SLUMS; + if (zoneTypeVal < 0) return LOT_COVERAGE.INDUSTRIAL; + return LOT_COVERAGE.DEFAULT; +} From 5464e6facb21ca6cdd95425a6d6fe54a650ec2de Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 13:36:54 -0500 Subject: [PATCH 10/40] fix(citygen): upper storeys floated above their shortened bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new height features scaled a building's height without touching its y, and y is the bottom of a mesh. A plot is often several stacked parts, and a part sitting on another has its y set to that one's height — so shortening the base left everything above it hanging in the air. The skyline taper caused the visible damage, since it applies to every building rather than only those under a deck. Scaling y by the same factor keeps a stack together, which is correct for both: a uniform vertical scale is exactly what the gradient means. The deck cap now scales rather than truncates for the same reason. The regression test builds a two-part plot, a base with a storey resting exactly on top, and asserts the storey's bottom still meets the base's top. Confirmed it fails without the fix. --- .../src/cityGen/__tests__/layouts.test.ts | 61 ++++++++++++++++++- frontend/src/cityGen/collision.ts | 13 +++- frontend/src/cityGen/index.ts | 5 ++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index bde7f70..ae73345 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -392,8 +392,8 @@ describe('clampBuildingsUnderDecks', () => { ...over, }); - const building = (over: Partial<{ x: number; z: number; width: number; depth: number; height: number }> = {}) => ({ - x: 100, z: 0, width: 6, depth: 6, height: 50, ...over, + const building = (over: Partial<{ x: number; z: number; y: number; width: number; depth: number; height: number }> = {}) => ({ + x: 100, z: 0, y: 0, width: 6, depth: 6, height: 50, ...over, }); it('leaves buildings clear of any deck alone', () => { @@ -432,6 +432,26 @@ describe('clampBuildingsUnderDecks', () => { expect(out.height).toBeLessThan(10); }); + it('brings a stacked part down with the base it sits on', () => { + // y is the bottom of a mesh, and a part sitting on another has its y set to that + // one's height. Capping heights alone left upper storeys hanging in mid-air — + // the floating skyscrapers. + const base = building({ height: 40, y: 0 }); + const upper = building({ height: 20, y: 40 }); + const [outBase, outUpper] = clampBuildingsUnderDecks([base, upper], [deck()]); + + expect(outBase.height).toBeLessThan(40); + // The upper part must have come down in proportion, not stayed at 40. + expect(outUpper.y).toBeLessThan(40); + expect(outUpper.y / outUpper.height).toBeCloseTo(upper.y / upper.height, 5); + }); + + it('leaves y alone for a building it does not cap', () => { + const b = building({ height: 4, y: 3 }); + const [out] = clampBuildingsUnderDecks([b], [deck()]); + expect(out.y).toBe(3); + }); + it('accounts for footprint width, not just the centre point', () => { // A wide building whose centre clears the deck can still be under its edge. const wide = building({ z: 9, width: 20, depth: 20, height: 40 }); @@ -591,3 +611,40 @@ describe('setbacks end to end', () => { expect(capture('SLUMS')).toBeGreaterThan(capture('CORPO')); }); }); + +describe('skyline taper keeps stacked parts together', () => { + it('scales y with height, so upper storeys do not float', () => { + // The floating skyscrapers: y is the bottom of a mesh, and a part sitting on + // another has its y set to that one's height. Scaling heights alone left every + // upper storey hanging above a shortened base. + const out = generateCity( + bounds(400), + { sectionType: 'MIXED' }, + freshContext(), + seededRng(), + { + // A two-part building: a base, and a storey resting exactly on top of it. + fillPlot: (x: number, z: number, _bw: number, _bd: number, _zone: number, + _blocked: unknown, _key: unknown, _cells: unknown, + sink: Record[]) => { + sink.push({ x, z, y: 0, width: 4, depth: 4, height: 30, name: 'STACK_BASE', description: '', color: '#fff', shape: 'box' }); + sink.push({ x, z, y: 30, width: 3, depth: 3, height: 10, name: 'STACK_TOP', description: '', color: '#fff', shape: 'box' }); + }, + } as never, + ).buildings; + + // Landmarks and parks also emit parts, and landmark parts sit at arbitrary + // heights rather than strictly stacked — pair only the synthetic ones. + const bases = out.filter((b) => b.name === 'STACK_BASE'); + expect(bases.length).toBeGreaterThan(0); + + for (const base of bases) { + const upper = out.find( + (b) => b.name === 'STACK_TOP' && b.x === base.x && b.z === base.z, + ); + expect(upper).toBeDefined(); + // The storey rests on the base: its bottom is the base's top. + expect(upper!.y).toBeCloseTo(base.height, 5); + } + }); +}); diff --git a/frontend/src/cityGen/collision.ts b/frontend/src/cityGen/collision.ts index 06270d5..d043a3a 100644 --- a/frontend/src/cityGen/collision.ts +++ b/frontend/src/cityGen/collision.ts @@ -280,8 +280,11 @@ function arcLengthToNearest(points: { x: number; z: number }[], px: number, pz: * * Where the deck is too low to build under at all — near its ramps, where it meets the * ground — the building is dropped instead of being squashed to nothing. + * + * Capping scales `y` by the same factor as `height`, because `y` is the bottom of a + * mesh and stacked parts sit at the height of the one below. */ -export function clampBuildingsUnderDecks( +export function clampBuildingsUnderDecks( buildings: T[], decks: DeckLike[] ): T[] { @@ -307,7 +310,13 @@ export function clampBuildingsUnderDecks cap ? { ...b, height: cap } : b); + if (b.height <= cap) { out.push(b); continue; } + + // `y` scales with `height`. A plot is often several stacked parts, and a part + // sitting on another has its `y` set to that one's height — capping heights alone + // left upper storeys hanging above a shortened base. + const k = cap / b.height; + out.push({ ...b, height: cap, y: b.y * k }); } return out; diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index b713377..bf04976 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -203,9 +203,14 @@ export function generateCity( // Zone already steps down with distance, but only in bands — the skyline came out // as flat plateaus with hard seams. Scaling within the zone softens those into a // continuous taper. Landmarks are left alone; a hero building is sized on purpose. + // + // `y` scales with `height`. A plot is often several stacked parts, and a part + // sitting on another has its `y` set to that one's height — scaling heights alone + // left every upper storey hanging in the air above a shortened base. const heightScale = heightScaleFor(normDist); for (let i = beforeFill; i < buildings.length; i++) { buildings[i].height *= heightScale; + buildings[i].y *= heightScale; } tagPlot(zonePrefix); }); From 3c9a4611186c71109ef79664c9fe018d43fd48b1 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 13:48:48 -0500 Subject: [PATCH 11/40] fix(citygen): deck capping scales a whole plot, not each part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining floating buildings. Capping ran per building, so a plot whose base pierced a deck had the base shrunk while a short upper storey that already fitted was left untouched — sitting exactly where the old roofline had been, hanging in the air. The previous fix scaled y with height, but only for parts that were actually capped, so it missed this. Parts of a plot share a temp_block_id, so they are now grouped and scaled by a single factor taken from the plot's tallest point. A plot with no room under the deck at all is dropped whole rather than in pieces. Two tests pin it: a stack must still meet after capping, and a short upper storey that already fits must come down with the base beneath it. Both confirmed to fail against per-part scaling. --- .../src/cityGen/__tests__/layouts.test.ts | 39 ++++++++++---- frontend/src/cityGen/collision.ts | 53 +++++++++++++------ 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index ae73345..6111981 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -392,7 +392,7 @@ describe('clampBuildingsUnderDecks', () => { ...over, }); - const building = (over: Partial<{ x: number; z: number; y: number; width: number; depth: number; height: number }> = {}) => ({ + const building = (over: Partial<{ x: number; z: number; y: number; width: number; depth: number; height: number; temp_block_id: string }> = {}) => ({ x: 100, z: 0, y: 0, width: 6, depth: 6, height: 50, ...over, }); @@ -432,18 +432,37 @@ describe('clampBuildingsUnderDecks', () => { expect(out.height).toBeLessThan(10); }); - it('brings a stacked part down with the base it sits on', () => { - // y is the bottom of a mesh, and a part sitting on another has its y set to that - // one's height. Capping heights alone left upper storeys hanging in mid-air — - // the floating skyscrapers. - const base = building({ height: 40, y: 0 }); - const upper = building({ height: 20, y: 40 }); + it('scales a whole plot together so a stack stays assembled', () => { + // The floating buildings: parts of one plot share a temp_block_id, and y is the + // bottom of a mesh, so a part resting on another has its y set to that one's + // height. Capping parts individually shrank the base while leaving the storey + // above exactly where the old roofline was. + const base = building({ height: 40, y: 0, temp_block_id: 'plot_1' }); + const upper = building({ height: 20, y: 40, temp_block_id: 'plot_1' }); const [outBase, outUpper] = clampBuildingsUnderDecks([base, upper], [deck()]); expect(outBase.height).toBeLessThan(40); - // The upper part must have come down in proportion, not stayed at 40. - expect(outUpper.y).toBeLessThan(40); - expect(outUpper.y / outUpper.height).toBeCloseTo(upper.y / upper.height, 5); + // The storey still rests on the base rather than hanging above it. + expect(outUpper.y).toBeCloseTo(outBase.height, 5); + }); + + it('scales by the plot’s tallest point, not each part in isolation', () => { + // A short upper storey that already fits under the deck must still come down with + // the base beneath it, or it is left floating. + const base = building({ height: 40, y: 0, temp_block_id: 'plot_2' }); + const small = building({ height: 3, y: 40, temp_block_id: 'plot_2' }); + const [outBase, outSmall] = clampBuildingsUnderDecks([base, small], [deck()]); + + expect(outSmall.height).toBeLessThan(3); + expect(outSmall.y).toBeCloseTo(outBase.height, 5); + }); + + it('drops a whole plot when the deck is too low for any of it', () => { + const parts = [ + building({ x: 3, height: 20, y: 0, temp_block_id: 'plot_3' }), + building({ x: 3, height: 8, y: 20, temp_block_id: 'plot_3' }), + ]; + expect(clampBuildingsUnderDecks(parts, [deck()])).toHaveLength(0); }); it('leaves y alone for a building it does not cap', () => { diff --git a/frontend/src/cityGen/collision.ts b/frontend/src/cityGen/collision.ts index d043a3a..9dfee10 100644 --- a/frontend/src/cityGen/collision.ts +++ b/frontend/src/cityGen/collision.ts @@ -275,25 +275,28 @@ function arcLengthToNearest(points: { x: number; z: number }[], px: number, pz: * Placement deliberately ignores overpasses, so the ground under a beltway stays * buildable — that is what stops an arterial sterilising every block it crosses. The * cost is that nothing stops a tower rising through the deck. Rather than blocking the - * ground again, anything beneath a deck is capped just under it, which is what actually - * happens around an elevated freeway. + * ground again, anything beneath a deck is scaled down to fit under it, which is what + * actually happens around an elevated freeway. * - * Where the deck is too low to build under at all — near its ramps, where it meets the - * ground — the building is dropped instead of being squashed to nothing. + * Scaling is applied **per plot, not per part**. A plot is often several stacked + * pieces sharing a `temp_block_id`, and `y` is the bottom of a mesh — so a part + * resting on another has its `y` set to that one's height. Capping parts individually + * shrank the base while leaving a short upper storey exactly where the old roofline + * was, hanging in the air. The whole plot scales by one factor instead, taken from its + * tallest point, which keeps it assembled. * - * Capping scales `y` by the same factor as `height`, because `y` is the bottom of a - * mesh and stacked parts sit at the height of the one below. + * Where the deck is too low to build under at all — near its ramps, where it meets the + * ground — the plot is dropped instead of being squashed to nothing. */ -export function clampBuildingsUnderDecks( +export function clampBuildingsUnderDecks( buildings: T[], decks: DeckLike[] ): T[] { if (decks.length === 0) return buildings; - const out: T[] = []; - for (const b of buildings) { + /** Lowest deck overhead for a footprint, or Infinity when it is in the open. */ + const capFor = (b: T): number => { let cap = Infinity; - for (const deck of decks) { if (deck.points.length < 2) continue; const reach = deck.width / 2 + Math.max(b.width, b.depth) / 2; @@ -307,16 +310,32 @@ export function clampBuildingsUnderDecks(); + buildings.forEach((b, i) => { + const key = b.temp_block_id ?? `__solo_${i}`; + const group = groups.get(key); + if (group) group.push(b); + else groups.set(key, [b]); + }); + + const out: T[] = []; + for (const group of groups.values()) { + let cap = Infinity; + for (const b of group) cap = Math.min(cap, capFor(b)); + + if (cap === Infinity) { out.push(...group); continue; } if (cap < MIN_UNDER_DECK_HEIGHT) continue; - if (b.height <= cap) { out.push(b); continue; } - // `y` scales with `height`. A plot is often several stacked parts, and a part - // sitting on another has its `y` set to that one's height — capping heights alone - // left upper storeys hanging above a shortened base. - const k = cap / b.height; - out.push({ ...b, height: cap, y: b.y * k }); + const tallest = Math.max(...group.map((b) => b.y + b.height)); + if (tallest <= cap) { out.push(...group); continue; } + + const k = cap / tallest; + for (const b of group) out.push({ ...b, height: b.height * k, y: b.y * k }); } return out; From 2316a941663748307603ee62387562a2bfdba3dd Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 18:53:46 -0500 Subject: [PATCH 12/40] feat(citygen): stay on the generator panel after generating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating returned the admin to the main panel and cleared the selected area, so every adjustment to layout, zoning or density meant re-opening the generator and re-selecting the region. Iterating on a city was tedious for no reason. The panel and the selection now persist, so GENERATE can be pressed again straight away. Generating a second time over the same area infills rather than overlapping — placement already tests against existing locations, and roads consolidate onto the ones already there. Groundwork for regenerate-over-an-area, where iterating in place is the whole point. --- frontend/src/components/AdminPanel.tsx | 5 +- .../components/__tests__/AdminPanel.test.tsx | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index cee3c1f..f24b794 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -1764,7 +1764,10 @@ export function AdminPanel({ setAdminAlert(`CITY GENERATED: ${blocks.length} SECTORS${bridgeNote}`); refreshLocations(); if (newOverpasses.length > 0) refreshOverpasses?.(); - setView('list'); setRoadSelectionBounds(null); + // Stay on the panel with the area still selected, so layout and + // density can be adjusted and regenerated without re-selecting. + // Generating again infills rather than overlapping: placement tests + // against existing locations, and roads consolidate onto existing ones. } catch (err: any) { console.error(err); setAdminAlert(`SYSTEM_ERROR: ${err.message}. Area might be too large or complex.`); diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index accf6fb..382a8b0 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -716,3 +716,50 @@ describe('AdminPanel layout selector', () => { expect(props.setCityLayout).toHaveBeenCalledWith('GRID'); }); }); + +describe('AdminPanel stays on the generator after generating', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + ...over, + }); + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + }); + + it('does not send the admin back to the main panel', async () => { + // Iterating on layout and density means regenerating repeatedly; being kicked + // back to the list every time made that tedious. + const props = genProps(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setView).not.toHaveBeenCalledWith('list'); + vi.unstubAllGlobals(); + }); + + it('keeps the selected area, so it can be regenerated without re-selecting', async () => { + const props = genProps(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setRoadSelectionBounds).not.toHaveBeenCalledWith(null); + vi.unstubAllGlobals(); + }); +}); From dec74c54a73d441c3650140f6319b21df8d7b512 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 19:08:24 -0500 Subject: [PATCH 13/40] =?UTF-8?q?feat(citygen):=20seeded=20generation=20?= =?UTF-8?q?=E2=80=94=20the=20same=20seed=20rebuilds=20the=20same=20city?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a SEED field to the generator. Blank rolls a fresh one; whatever was used is written back to the field, so a city worth keeping can be noted down or shared. The layout was already reproducible — every draw in cityGen goes through the injected rng. The buildings were not: generateThemedBuildingsForPlot made its own Math.random calls, so a seed gave the same streets with different buildings standing in them. It now takes an optional rng, defaulting to Math.random so existing callers are unaffected, and the generator passes its seeded one. Forty call sites, all inside that one function. The PRNG is deliberately not crypto-backed, and says so at its definition. 1.7.1 moved every roll that decides an outcome onto OS entropy and left cosmetic randomness alone; a city layout is cosmetic and determinism is the entire point here. parseSeed treats blank or unparseable input as "roll a fresh one" rather than generating from NaN. Seeds are 32-bit, so a larger number folds into range — still deterministic, and there is a test saying so. The UI states what a seed actually reproduces. Without that, the same seed over a different area building a different city reads as a bug. --- frontend/src/App.tsx | 5 + frontend/src/cityGen/__tests__/seeds.test.ts | 143 ++++++++++++++++++ frontend/src/cityGen/index.ts | 7 +- frontend/src/cityGen/rng.ts | 39 +++++ frontend/src/components/AdminPanel.tsx | 25 ++- frontend/src/components/Buildings.tsx | 81 +++++----- .../components/__tests__/AdminPanel.test.tsx | 78 ++++++++++ 7 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/seeds.test.ts create mode 100644 frontend/src/cityGen/rng.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de979b3..dda5c96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -365,6 +365,9 @@ function App() { const [genBoundaryTrail, setGenBoundaryTrail] = useState([]); // BSP is what generation has always produced, so it stays the default. const [cityLayout, setCityLayout] = useState('BSP'); + // Blank means roll a fresh one; after generating it holds the seed that was used, + // so a city worth keeping can be written down. + const [citySeed, setCitySeed] = useState(''); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1799,6 +1802,8 @@ function App() { isRecording={mapExportApi?.isRecording ?? false} recordSecondsLeft={mapExportApi?.secondsLeft ?? 0} isExporting={mapExportApi?.isExporting ?? false} + citySeed={citySeed} + setCitySeed={setCitySeed} cityLayout={cityLayout} setCityLayout={setCityLayout} cityGenDrawMode={cityGenDrawMode} diff --git a/frontend/src/cityGen/__tests__/seeds.test.ts b/frontend/src/cityGen/__tests__/seeds.test.ts new file mode 100644 index 0000000..c968e48 --- /dev/null +++ b/frontend/src/cityGen/__tests__/seeds.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { seededRng, randomSeed, parseSeed, generateCity } from '../index'; + +/** + * Seeded generation. + * + * The layout was always reproducible — every draw in cityGen goes through the injected + * rng. The buildings were not: `generateThemedBuildingsForPlot` made its own + * `Math.random` calls, so the same seed gave the same streets with different buildings + * standing in them. These cover the whole thing being reproducible now. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); + +const cityFrom = (seed: number, over = {}) => + generateCity( + bounds(250), + { sectionType: 'MIXED', ...over }, + freshContext(), + seededRng(seed), + ); + +describe('seededRng', () => { + it('gives the same sequence for the same seed', () => { + const a = seededRng(12345); + const b = seededRng(12345); + expect([a(), a(), a(), a()]).toEqual([b(), b(), b(), b()]); + }); + + it('gives different sequences for different seeds', () => { + expect(seededRng(1)()).not.toBe(seededRng(2)()); + }); + + it('stays within the unit interval', () => { + const r = seededRng(99); + for (let i = 0; i < 500; i++) { + const v = r(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('does not immediately repeat itself', () => { + const r = seededRng(7); + const seen = new Set(Array.from({ length: 200 }, () => r())); + expect(seen.size).toBe(200); + }); +}); + +describe('parseSeed', () => { + it('takes a number as given', () => { + expect(parseSeed(482196037)).toBe(482196037); + }); + + it('wraps a seed beyond 32 bits rather than rejecting it', () => { + // Seeds are 32-bit, so anything larger folds into range. Still deterministic, + // which is all that matters. + const big = parseSeed(4821960374); + expect(Number.isInteger(big)).toBe(true); + expect(big).toBeLessThan(4294967296); + expect(parseSeed(4821960374)).toBe(big); + }); + + it('reads a typed seed', () => { + expect(parseSeed(' 12345 ')).toBe(12345); + }); + + it('rolls a fresh seed for blank input', () => { + // Blank means "surprise me", which is the default. + expect(Number.isFinite(parseSeed(''))).toBe(true); + expect(Number.isFinite(parseSeed(' '))).toBe(true); + }); + + it('rolls a fresh seed rather than generating from NaN', () => { + const seed = parseSeed('not a number'); + expect(Number.isFinite(seed)).toBe(true); + expect(Number.isNaN(seed)).toBe(false); + }); + + it('handles null and undefined', () => { + expect(Number.isFinite(parseSeed(null))).toBe(true); + expect(Number.isFinite(parseSeed(undefined))).toBe(true); + }); +}); + +describe('randomSeed', () => { + it('produces a whole number in range', () => { + for (let i = 0; i < 50; i++) { + const s = randomSeed(); + expect(Number.isInteger(s)).toBe(true); + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThan(4294967296); + } + }); + + it('does not keep returning the same value', () => { + const seen = new Set(Array.from({ length: 50 }, randomSeed)); + expect(seen.size).toBeGreaterThan(40); + }); +}); + +describe('a seed reproduces a whole city', () => { + it('gives identical buildings, not just identical streets', () => { + // The gap this closed: layout was reproducible, buildings were not. + const a = cityFrom(2026); + const b = cityFrom(2026); + expect(b.buildings).toEqual(a.buildings); + }); + + it('gives identical roads and blocks', () => { + const a = cityFrom(2026); + const b = cityFrom(2026); + expect(b.roads).toEqual(a.roads); + expect(b.blocks).toEqual(a.blocks); + }); + + it('gives a different city for a different seed', () => { + const a = cityFrom(1); + const b = cityFrom(2); + expect(b.buildings).not.toEqual(a.buildings); + }); + + it('reproduces across every layout', () => { + for (const layout of ['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const) { + const a = cityFrom(555, { layout }); + const b = cityFrom(555, { layout }); + expect(b.buildings, layout).toEqual(a.buildings); + } + }); + + it('only reproduces for the same options', () => { + // Worth stating plainly: a seed is not a city on its own. Change the bounds or + // the layout and the same seed builds something else. + const grid = cityFrom(777, { layout: 'GRID' }); + const bsp = cityFrom(777, { layout: 'BSP' }); + expect(bsp.blocks).not.toEqual(grid.blocks); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index bf04976..449c49a 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -35,6 +35,7 @@ export { generatePark } from './parks'; export { shouldPlaceLandmark, generateLandmark } from './landmarks'; export * from './water'; export * from './layouts'; +export * from './rng'; export { findBridges, MAX_BRIDGE_SPAN, BRIDGE_RAMP_LENGTH, BRIDGE_HEIGHTS, MIN_RAMP_RUN, MAX_RAMP_RUN, @@ -196,9 +197,13 @@ export function generateCity( } const beforeFill = buildings.length; + // The two undefineds are overrideH and styleOverride, which only the editor + // preview uses. rng is what makes a seed reproduce the buildings and not merely + // the street layout. deps.fillPlot( block.x, block.z, bw, bd, zoneTypeVal, - isBlocked, grid.key, grid.cells, buildings, context.locations, plotId + isBlocked, grid.key, grid.cells, buildings, context.locations, plotId, + undefined, undefined, rng ); // Zone already steps down with distance, but only in bands — the skyline came out // as flat plateaus with hard seams. Scaling within the zone softens those into a diff --git a/frontend/src/cityGen/rng.ts b/frontend/src/cityGen/rng.ts new file mode 100644 index 0000000..0c82109 --- /dev/null +++ b/frontend/src/cityGen/rng.ts @@ -0,0 +1,39 @@ +import type { Rng } from './types'; + +/** + * Seeded randomness for city generation. + * + * This is deliberately *not* crypto-backed. 1.7.1 moved every roll that decides an + * outcome onto OS entropy and left cosmetic randomness alone — a city layout is + * cosmetic, and here determinism is the whole point, so a plain PRNG is correct rather + * than a regression. Do not "fix" this to crypto.random. + */ + +/** mulberry32 — small, fast, and even enough for layout work. */ +export function seededRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A fresh seed to generate with, in the range the UI displays. */ +export function randomSeed(): number { + return Math.floor(Math.random() * 4294967296) >>> 0; +} + +/** + * Read a seed the admin typed. Anything unparseable becomes a fresh one rather than + * silently generating from NaN. + */ +export function parseSeed(input: string | number | null | undefined): number { + if (typeof input === 'number' && Number.isFinite(input)) return input >>> 0; + if (typeof input !== 'string') return randomSeed(); + const trimmed = input.trim(); + if (trimmed === '') return randomSeed(); + const n = Number(trimmed); + return Number.isFinite(n) ? n >>> 0 : randomSeed(); +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index f24b794..20f916d 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { isUserDefinedName, getStructLabel } from '../utils/locationHelpers'; import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from './Buildings'; -import { generateCity, SpatialGrid, type SectionType, type OverpassDensity, type LayoutType } from '../cityGen'; +import { generateCity, SpatialGrid, seededRng, parseSeed, type SectionType, type OverpassDensity, type LayoutType } from '../cityGen'; /** Street layouts offered in the generator, with what each one reads as. */ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ @@ -593,7 +593,7 @@ export function AdminPanel({ activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, - cityLayout, setCityLayout, + cityLayout, setCityLayout, citySeed, setCitySeed, }: any) { if (view === 'battle_map') { return ( @@ -1663,6 +1663,19 @@ export function AdminPanel({ ))} + +
+ setCitySeed?.(e.target.value)} + style={{flex: 1, backgroundColor: '#222', color: 'var(--green)', border: '1px solid var(--green)', padding: '4px', fontSize: '0.7rem', fontFamily: 'monospace'}} + /> + +
+

SAME SEED + SAME AREA + SAME OPTIONS = SAME CITY

{ setCityLayout: vi.fn(), citySeed: '', setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, waterBodies: [], locations: [], @@ -816,7 +818,8 @@ describe('AdminPanel city seed', () => { expect(props.setCitySeed).toHaveBeenCalledWith(''); }); - it('fills a blank field with the seed it rolled, so a good city can be kept', async () => { + it('reports the seed it rolled without filling the field', async () => { + // Filling the input meant every later regenerate silently rebuilt the same city. vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), )); @@ -824,11 +827,38 @@ describe('AdminPanel city seed', () => { render(); await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); - const written = props.setCitySeed.mock.calls.map((c: unknown[]) => c[0]); - expect(written.some((v: string) => v !== '' && Number.isFinite(Number(v)))).toBe(true); + const reported = props.setLastCitySeed.mock.calls.map((c: unknown[]) => c[0]); + expect(reported.some((v: string) => v !== '' && Number.isFinite(Number(v)))).toBe(true); + expect(props.setCitySeed).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); + it('rolls a different seed each time the field is left blank', async () => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + const props = genProps({ citySeed: '' }); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const reported = props.setLastCitySeed.mock.calls.map((c: unknown[]) => c[0]); + expect(new Set(reported).size).toBeGreaterThan(1); + vi.unstubAllGlobals(); + }); + + it('shows the last seed used, and reuses it when clicked', async () => { + const props = genProps({ citySeed: '', lastCitySeed: '4821960374' }); + render(); + await userEvent.click(screen.getByTitle('REUSE THIS SEED')); + expect(props.setCitySeed).toHaveBeenCalledWith('4821960374'); + }); + + it('shows no readout before anything has been generated', () => { + render(); + expect(screen.queryByTitle('REUSE THIS SEED')).not.toBeInTheDocument(); + }); + it('never rewrites a seed the admin typed', async () => { // Normalising it looked like the field being cleared and replaced. vi.stubGlobal('fetch', vi.fn(() => @@ -885,6 +915,8 @@ describe('AdminPanel regenerate', () => { setCityLayout: vi.fn(), citySeed: '42', setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, waterBodies: [], locations: [], From 6a980c03da3653ad62225be954891cd886a9385b Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 19:45:43 -0500 Subject: [PATCH 18/40] feat(citygen): trash can for clearing the seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button empties the field, so a trash can says what it does. The reset arrow suggested it re-rolled something, which it does not — it clears, and a blank field is what makes the next generate roll a new seed. Title reworded to CLEAR SEED to match. --- frontend/src/components/AdminPanel.tsx | 4 ++-- frontend/src/components/__tests__/AdminPanel.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index a64ed33..3f34797 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -1809,8 +1809,8 @@ export function AdminPanel({ onChange={e => setCitySeed?.(e.target.value)} style={{flex: 1, backgroundColor: '#222', color: 'var(--green)', border: '1px solid var(--green)', padding: '4px', fontSize: '0.7rem', fontFamily: 'monospace'}} /> - + {lastCitySeed ?

diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index e52c11f..4fa94eb 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -811,10 +811,10 @@ describe('AdminPanel city seed', () => { expect(props.setCitySeed).toHaveBeenCalledWith('7'); }); - it('clears the field to roll a fresh seed', async () => { + it('clears the field, which is how a fresh seed is rolled', async () => { const props = genProps({ citySeed: '12345' }); render(); - await userEvent.click(screen.getByTitle('ROLL A NEW SEED')); + await userEvent.click(screen.getByTitle('CLEAR SEED')); expect(props.setCitySeed).toHaveBeenCalledWith(''); }); From c17a756cd39c389a52adc881155cc39548cf92f4 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 19:56:49 -0500 Subject: [PATCH 19/40] feat(citygen): generate rivers, coastlines and lakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WATER selector on the generator, defaulting to NONE. Rivers and coastlines are most of why real cities look like themselves — they force asymmetry, cut districts apart, and give bridges a reason to exist. Until now the bridge siting only ever fired if a GM happened to draw water first. This is a small addition because the machinery to consume water already existed and was tested. parseWaterBodies, footprintInWater, the water-aware split, shoreline roads and bridge siting all take water polygons; waterGen only has to produce one. Ordering is the part that matters. Water is generated *before* the split, so the road grid stops at the banks of its own accord and bridges are sited from the stubs left there. Generating it afterwards would mean cutting finished roads, which is a different and worse problem. NONE doubles as the off switch rather than a separate checkbox that could disagree with the selector, and it is the default because generation has never produced water — anything else would put a river through the city of everyone already using the button. water_bodies gains a `generated` column so a regenerate can clear its own river without destroying a lake the GM drew. Existing rows default to 0, so everything already on a map counts as hand-drawn. --- backend/__tests__/helpers/testDb.js | 3 +- backend/__tests__/purge_region.test.js | 24 ++- backend/db.js | 4 + backend/routes/admin.js | 6 +- backend/routes/locations.js | 56 ++++-- frontend/src/App.tsx | 7 +- .../src/cityGen/__tests__/waterGen.test.ts | 161 ++++++++++++++++++ frontend/src/cityGen/index.ts | 11 +- frontend/src/cityGen/types.ts | 9 + frontend/src/cityGen/waterGen.ts | 156 +++++++++++++++++ frontend/src/components/AdminPanel.tsx | 42 ++++- .../components/__tests__/AdminPanel.test.tsx | 92 ++++++++++ 12 files changed, 542 insertions(+), 29 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/waterGen.test.ts create mode 100644 frontend/src/cityGen/waterGen.ts diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index cc21380..beeba77 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -106,7 +106,8 @@ function makeTestDb() { db.run(`CREATE TABLE water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, points_json TEXT NOT NULL, - map_scale_multiplier TEXT DEFAULT '[1]' + map_scale_multiplier TEXT DEFAULT '[1]', + generated INTEGER DEFAULT 0 )`); db.run(`CREATE TABLE signs ( diff --git a/backend/__tests__/purge_region.test.js b/backend/__tests__/purge_region.test.js index a13a660..3521036 100644 --- a/backend/__tests__/purge_region.test.js +++ b/backend/__tests__/purge_region.test.js @@ -130,8 +130,10 @@ describe('POST /api/locations/purge-region', () => { expect(await all(db, 'SELECT * FROM roads')).toHaveLength(1); }); - it('never touches water or signs', async () => { - await run(db, `INSERT INTO water_bodies (points_json) VALUES (?)`, + it('never touches hand-drawn water or signs', async () => { + // A lake the GM drew is hand-placed work and survives exactly as a named + // structure does. + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 0)`, [JSON.stringify([{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }])]); await run(db, `INSERT INTO signs (text, x, y, z) VALUES ('DOCKS', 5, 0, 5)`); @@ -140,6 +142,24 @@ describe('POST /api/locations/purge-region', () => { expect(await all(db, 'SELECT * FROM signs')).toHaveLength(1); }); + it('clears water the generator made', async () => { + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 1)`, + [JSON.stringify([{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }])]); + + const res = await purge(app); + expect(res.body.water).toBe(1); + expect(await all(db, 'SELECT * FROM water_bodies')).toHaveLength(0); + }); + + it('leaves generated water outside the region alone', async () => { + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 1)`, + [JSON.stringify([{ x: 900, z: 900 }, { x: 950, z: 900 }, { x: 950, z: 950 }])]); + + const res = await purge(app); + expect(res.body.water).toBe(0); + expect(await all(db, 'SELECT * FROM water_bodies')).toHaveLength(1); + }); + it('clears exactly the drawn shape, not its bounding box', async () => { // An L: the notch must survive, or a drawn boundary means nothing. const polygon = [ diff --git a/backend/db.js b/backend/db.js index ecb952a..cbf538e 100644 --- a/backend/db.js +++ b/backend/db.js @@ -277,6 +277,10 @@ db.serialize(() => { points_json TEXT NOT NULL, map_scale_multiplier TEXT DEFAULT '[1]' )`); + // Tells a generated river from a lake the GM drew. Without it, regenerating an area + // cannot clear its own water without destroying hand-drawn work. Existing rows + // default to 0, so everything already on a map counts as hand-drawn. + db.run(`ALTER TABLE water_bodies ADD COLUMN generated INTEGER DEFAULT 0`, () => {}); db.run(`CREATE TABLE IF NOT EXISTS player_accounts ( username TEXT PRIMARY KEY, diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 68ac652..8f04603 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -198,9 +198,11 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { }); router.post('/water', authenticate, (req, res) => { - const { points } = req.body; + const { points, generated } = req.body; if (!points || !Array.isArray(points)) return res.status(400).json({ error: 'Invalid points array' }); - db.run('INSERT INTO water_bodies (points_json) VALUES (?)', [JSON.stringify(points)], function(err) { + // Generated water is cleared by a regenerate; hand-drawn water never is. + db.run('INSERT INTO water_bodies (points_json, generated) VALUES (?, ?)', + [JSON.stringify(points), generated ? 1 : 0], function(err) { if (err) return res.status(500).json({ error: err.message }); const newId = this.lastID; db.run('INSERT INTO action_history (type, payload) VALUES (?, ?)', ['water_create', JSON.stringify({ ids: [newId] })], () => {}); diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 6cb4b8d..317ac27 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -197,24 +197,44 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { db.run(`DELETE FROM ${table} WHERE id IN (${list.map(() => '?').join(',')})`, list); }; - db.serialize(() => { - db.run('BEGIN TRANSACTION'); - del('locations', ids); - del('roads', roadIds); - del('overpasses', overpassIds); - db.run('COMMIT', (err4) => { - if (err4) return res.status(500).json({ error: err4.message }); - recordAction('region_purge', { - locations: doomed, - roads, - overpasses, - }); - emitUpdate(); - res.json({ - locations: ids.length, - roads: roadIds.length, - overpasses: overpassIds.length, - keptNamed, + // Only water the generator made. A lake the GM drew is hand-placed work and + // survives a regenerate exactly as a named structure does. + db.all('SELECT * FROM water_bodies WHERE generated = 1', [], (err4, waterRows) => { + if (err4) return res.status(500).json({ error: err4.message }); + const water = (waterRows || []).filter(w => { + let points; + try { points = JSON.parse(w.points_json); } catch { return false; } + if (!Array.isArray(points) || points.length === 0) return false; + // Its centroid decides, so a river trimmed at the region edge goes with + // the city it belonged to. + const cx = points.reduce((a, p) => a + p.x, 0) / points.length; + const cz = points.reduce((a, p) => a + p.z, 0) / points.length; + return inRegion(cx, cz); + }); + const waterIds = water.map(w => w.id); + + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + del('locations', ids); + del('roads', roadIds); + del('overpasses', overpassIds); + del('water_bodies', waterIds); + db.run('COMMIT', (err5) => { + if (err5) return res.status(500).json({ error: err5.message }); + recordAction('region_purge', { + locations: doomed, + roads, + overpasses, + water, + }); + emitUpdate(); + res.json({ + locations: ids.length, + roads: roadIds.length, + overpasses: overpassIds.length, + water: waterIds.length, + keptNamed, + }); }); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e02f19..bbd4bfb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,7 +59,7 @@ import { Sidewalks } from './components/Sidewalks'; import { AutoSignage } from './components/AutoSignage'; import { Signs, type SignData } from './components/Signs'; import { type RemoteFont } from './utils/fontLoader'; -import type { LayoutType } from './cityGen'; +import type { LayoutType, WaterType } from './cityGen'; import { GlobalCameraCapture, CursorPivotControls, CameraController, KeyboardPan } from './components/Camera'; import { AdminPanel } from './components/AdminPanel'; import MapExportController, { type MapExportApi } from './components/MapExportController'; @@ -372,6 +372,9 @@ function App() { // used, kept separate so showing it never silently pins the next generation. const [citySeed, setCitySeed] = useState(''); const [lastCitySeed, setLastCitySeed] = useState(''); + // NONE by default: generation has never produced water, so anything else would put + // a river through the city of everyone already using the button. + const [cityWater, setCityWater] = useState('NONE'); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1808,6 +1811,8 @@ function App() { isExporting={mapExportApi?.isExporting ?? false} citySeed={citySeed} setCitySeed={setCitySeed} + cityWater={cityWater} + setCityWater={setCityWater} lastCitySeed={lastCitySeed} setLastCitySeed={setLastCitySeed} cityLayout={cityLayout} diff --git a/frontend/src/cityGen/__tests__/waterGen.test.ts b/frontend/src/cityGen/__tests__/waterGen.test.ts new file mode 100644 index 0000000..39a677f --- /dev/null +++ b/frontend/src/cityGen/__tests__/waterGen.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { generateWater, pointInWater, generateCity } from '../index'; + +/** + * Generated water. + * + * The machinery to *consume* water already existed and was tested — this only has to + * produce a polygon. What matters is that it lands before the split, so the road grid + * stops at the banks and bridges get sited. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +describe('generateWater', () => { + it('produces nothing by default', () => { + // Generation has never made water; defaulting otherwise would put a river through + // the city of everyone already using the button. + expect(generateWater('NONE', bounds(300), seededRng())).toHaveLength(0); + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('produces a closed polygon for %s', (type) => { + const [poly] = generateWater(type, bounds(300), seededRng()); + expect(poly).toBeDefined(); + expect(poly.points.length).toBeGreaterThanOrEqual(3); + for (const p of poly.points) { + expect(Number.isFinite(p.x)).toBe(true); + expect(Number.isFinite(p.z)).toBe(true); + } + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('encloses actual area for %s', (type) => { + // A polygon with no interior would be invisible and would block nothing. + const [poly] = generateWater(type, bounds(300), seededRng()); + const area = Math.abs(poly.points.reduce((sum, p, i) => { + const q = poly.points[(i + 1) % poly.points.length]; + return sum + (p.x * q.z - q.x * p.z); + }, 0) / 2); + expect(area).toBeGreaterThan(100); + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('reproduces from a seed for %s', (type) => { + expect(generateWater(type, bounds(300), seededRng(7))) + .toEqual(generateWater(type, bounds(300), seededRng(7))); + }); + + it('gives a different river for a different seed', () => { + expect(generateWater('RIVER', bounds(300), seededRng(1))) + .not.toEqual(generateWater('RIVER', bounds(300), seededRng(2))); + }); + + it('runs a river the full way across, dividing the city', () => { + // A river that stops short would be a lake with ambitions. + const [poly] = generateWater('RIVER', bounds(300), seededRng()); + const xs = poly.points.map(p => p.x); + const zs = poly.points.map(p => p.z); + const spansX = Math.max(...xs) - Math.min(...xs); + const spansZ = Math.max(...zs) - Math.min(...zs); + expect(Math.max(spansX, spansZ)).toBeGreaterThan(500); + }); + + it('leaves a coastline with dry land on one side', () => { + const [poly] = generateWater('COAST', bounds(300), seededRng()); + const dry = [ + { x: 0, z: 0 }, { x: 200, z: 0 }, { x: -200, z: 0 }, + { x: 0, z: 200 }, { x: 0, z: -200 }, + ].filter(p => !pointInWater([poly], p.x, p.z)); + expect(dry.length).toBeGreaterThan(0); + }); + + it('keeps a lake inside the region', () => { + const [poly] = generateWater('LAKE', bounds(300), seededRng()); + for (const p of poly.points) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(300); + expect(Math.abs(p.z)).toBeLessThanOrEqual(300); + } + }); +}); + +describe('generateCity with generated water', () => { + it('generates none unless asked', () => { + const result = generateCity(bounds(300), { sectionType: 'MIXED' }, freshContext(), seededRng(), deps); + expect(result.waterBodies).toHaveLength(0); + }); + + it('returns the water it made, for the caller to persist', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + expect(result.waterBodies).toHaveLength(1); + }); + + it('keeps buildings out of the water it generated', () => { + // Blocks are laid across water as they always have been for hand-drawn water — + // it is the placement check that keeps buildings out, and generated water has to + // reach that check the same way. + const built: { x: number; z: number }[] = []; + const result = generateCity( + bounds(300), + { sectionType: 'MIXED', water: 'RIVER' }, + freshContext(), + seededRng(), + { + fillPlot: (x: number, z: number, bw: number, bd: number, _zone: number, + isBlocked: (x: number, z: number, w: number, d: number) => boolean) => { + if (!isBlocked(x, z, bw, bd)) built.push({ x, z }); + }, + } as never, + ); + + const river = result.waterBodies[0]; + expect(built.length).toBeGreaterThan(0); + for (const b of built) expect(pointInWater([river], b.x, b.z)).toBe(false); + }); + + it('keeps roads out of the water it generated', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + const river = result.waterBodies[0]; + for (const r of result.roads) { + const mid = { x: (r.x1 + r.x2) / 2, z: (r.z1 + r.z2) / 2 }; + expect(pointInWater([river], mid.x, mid.z)).toBe(false); + } + }); + + it('builds a smaller city when water takes some of the ground', () => { + const dry = generateCity(bounds(300), { sectionType: 'MIXED' }, freshContext(), seededRng(), deps); + const wet = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + expect(wet.blocks.length).toBeLessThan(dry.blocks.length); + }); + + it('adds generated water to any the GM already drew', () => { + const context = { + locations: [], roads: [], + waterBodies: [{ points_json: JSON.stringify([ + { x: 250, z: 250 }, { x: 290, z: 250 }, { x: 290, z: 290 }, + ]) }], + }; + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, context, seededRng(), deps, + ); + // Only the generated river comes back — the GM's lake is already persisted. + expect(result.waterBodies).toHaveLength(1); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 9b103ec..60640d0 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -1,6 +1,7 @@ import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from '../components/Buildings'; import { LAYOUTS } from './layouts'; +import { generateWater } from './waterGen'; import { normalizeBounds } from './bsp'; import { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks } from './collision'; import { @@ -37,6 +38,7 @@ export * from './water'; export * from './layouts'; export * from './rng'; export * from './region'; +export * from './waterGen'; export { findBridges, MAX_BRIDGE_SPAN, BRIDGE_RAMP_LENGTH, BRIDGE_HEIGHTS, MIN_RAMP_RUN, MAX_RAMP_RUN, @@ -78,7 +80,7 @@ export function generateCity( rng: Rng = Math.random, deps: GenerateCityDeps = DEFAULT_DEPS ): GenerateCityResult { - const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP' } = options; + const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE' } = options; // Fewer than three points cannot enclose an area. Treating a degenerate boundary as // absent falls back to the plain bounds, rather than generating nothing at all and // looking like a broken button. @@ -86,7 +88,11 @@ export function generateCity( options.boundary && options.boundary.points.length >= 3 ? options.boundary : undefined; const { width, depth, centerX, centerZ } = normalizeBounds(bounds); const maxRadius = Math.max(1, Math.max(width, depth) / 2); - const water = parseWaterBodies(context.waterBodies ?? []); + // Water is generated *before* the split, because the split is already water-aware: + // the grid then stops at the banks of its own accord and bridges get sited. Doing it + // afterwards would mean cutting finished roads. + const generatedWater = generateWater(waterType, bounds, rng); + const water = [...parseWaterBodies(context.waterBodies ?? []), ...generatedWater]; // Sector angles are drawn before anything else so the district layout is // stable regardless of how many blocks the split produces. @@ -229,5 +235,6 @@ export function generateCity( roads: finalRoads, buildings: clampBuildingsUnderDecks(buildings, overpasses), overpasses, + waterBodies: generatedWater, }; } diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index d18a54f..c8ddee5 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -29,6 +29,7 @@ export type { RoadSegment }; import type { OverpassDensity, OverpassSpec } from './bridges'; import type { Polygon } from './water'; import type { LayoutType } from './layouts'; +import type { WaterType } from './waterGen'; export type { OverpassDensity, OverpassSpec }; /** Zoning preset chosen in the admin panel. */ @@ -76,6 +77,12 @@ export interface GenerateCityOptions { boundary?: Polygon; /** Street layout. Defaults to BSP, which is what generation has always produced. */ layout?: LayoutType; + /** + * Water to generate before laying the city out. Defaults to NONE — generation has + * never produced water, and defaulting otherwise would put a river through the city + * of everyone already using the button. + */ + water?: WaterType; /** When true, no roads are generated and road collision is skipped. */ excludeRoads: boolean; /** How freely roads bridge the water they cross. Defaults to 'normal'. */ @@ -101,4 +108,6 @@ export interface GenerateCityResult { buildings: RawBuilding[]; /** Bridges spanning water crossings that qualified. */ overpasses: OverpassSpec[]; + /** Water the run generated, for the caller to persist. Empty unless asked for. */ + waterBodies: Polygon[]; } diff --git a/frontend/src/cityGen/waterGen.ts b/frontend/src/cityGen/waterGen.ts new file mode 100644 index 0000000..ef0493b --- /dev/null +++ b/frontend/src/cityGen/waterGen.ts @@ -0,0 +1,156 @@ +import type { Bounds, Rng } from './types'; +import { normalizeBounds } from './bsp'; +import type { Polygon } from './water'; + +/** + * Generated water. + * + * Rivers and coastlines are most of why real cities look like themselves: they force + * asymmetry, cut districts apart, and give bridges a reason to exist. Until now the + * bridge siting only ever fired if a GM happened to draw water first. + * + * Everything here produces a polygon and hands it to machinery that already exists — + * `parseWaterBodies`, `footprintInWater`, the water-aware split, shoreline roads and + * bridge siting all consume water polygons and are all tested. That is why this is a + * small addition rather than a large one. + * + * **Ordering matters.** These run *before* the split, so the road grid stops at the + * banks of its own accord and bridges get sited. Generating water afterwards would + * mean cutting finished roads, which is a different and worse problem. Park ponds are + * the opposite case and belong after the split — see `parks`. + */ + +export type WaterType = 'NONE' | 'RIVER' | 'COAST' | 'LAKE'; + +/** River width as a fraction of the smaller span. */ +const RIVER_WIDTH = 0.1; +const RIVER_WIDTH_VARIANCE = 0.45; + +/** Samples along a river's course. More reads smoother, at more points. */ +const RIVER_STEPS = 14; + +/** How far a river wanders off a straight line, as a fraction of the span. */ +const RIVER_MEANDER = 0.18; + +/** Fraction of the region a coastline cuts off, and how much its edge wanders. */ +const COAST_MIN = 0.18; +const COAST_MAX = 0.38; +const COAST_WANDER = 0.12; +const COAST_STEPS = 12; + +/** Lake radius as a fraction of the smaller span, and how lumpy its edge is. */ +const LAKE_MIN = 0.14; +const LAKE_MAX = 0.26; +const LAKE_LOBES = 16; +const LAKE_JITTER = 0.3; + +/** + * A river crossing the region. + * + * Sampled as a gently meandering centreline, then offset either side by a varying + * width and closed into a loop. Width varies along the course so it does not read as + * an extruded line. + */ +function river(bounds: Bounds, rng: Rng): Polygon { + const { minX, minZ, width, depth, centerX, centerZ } = normalizeBounds(bounds); + const span = Math.min(width, depth); + const baseWidth = span * RIVER_WIDTH; + + // Runs across the shorter axis, so it always divides the city rather than clipping + // a corner. + const horizontal = width >= depth; + const meander = span * RIVER_MEANDER; + const phase = rng() * Math.PI * 2; + const swing = 1 + rng() * 1.5; + + const left: { x: number; z: number }[] = []; + const right: { x: number; z: number }[] = []; + + for (let i = 0; i <= RIVER_STEPS; i++) { + const t = i / RIVER_STEPS; + const wander = Math.sin(phase + t * Math.PI * swing) * meander; + const halfWidth = (baseWidth * (1 + (rng() - 0.5) * RIVER_WIDTH_VARIANCE)) / 2; + + if (horizontal) { + const x = minX + width * t; + const z = centerZ + wander; + left.push({ x, z: z - halfWidth }); + right.push({ x, z: z + halfWidth }); + } else { + const z = minZ + depth * t; + const x = centerX + wander; + left.push({ x: x - halfWidth, z }); + right.push({ x: x + halfWidth, z }); + } + } + + // Down one bank and back up the other. + return { points: [...left, ...right.reverse()] }; +} + +/** + * A coastline cutting one edge off the region, water on the far side. + * + * Gives the city a waterfront and a hard edge to build against, which is a different + * shape of constraint from a river dividing it. + */ +function coast(bounds: Bounds, rng: Rng): Polygon { + const { minX, maxX, minZ, maxZ, width, depth } = normalizeBounds(bounds); + + // Which edge the sea lies beyond. + const side = Math.floor(rng() * 4); + const cut = COAST_MIN + rng() * (COAST_MAX - COAST_MIN); + const wander = Math.min(width, depth) * COAST_WANDER; + const phase = rng() * Math.PI * 2; + + const shore: { x: number; z: number }[] = []; + for (let i = 0; i <= COAST_STEPS; i++) { + const t = i / COAST_STEPS; + const drift = Math.sin(phase + t * Math.PI * 2) * wander; + if (side === 0) shore.push({ x: minX + width * t, z: minZ + depth * cut + drift }); + else if (side === 1) shore.push({ x: minX + width * t, z: maxZ - depth * cut + drift }); + else if (side === 2) shore.push({ x: minX + width * cut + drift, z: minZ + depth * t }); + else shore.push({ x: maxX - width * cut + drift, z: minZ + depth * t }); + } + + // Close the polygon around the corners on the seaward side. Reaching past the + // bounds keeps the water solid to the edge rather than stopping short of it. + const over = Math.min(width, depth); + if (side === 0) return { points: [...shore, { x: maxX, z: minZ - over }, { x: minX, z: minZ - over }] }; + if (side === 1) return { points: [...shore, { x: maxX, z: maxZ + over }, { x: minX, z: maxZ + over }] }; + if (side === 2) return { points: [...shore, { x: minX - over, z: maxZ }, { x: minX - over, z: minZ }] }; + return { points: [...shore, { x: maxX + over, z: maxZ }, { x: maxX + over, z: minZ }] }; +} + +/** A lake somewhere inside the region — an obstacle rather than a structuring feature. */ +function lake(bounds: Bounds, rng: Rng): Polygon { + const { width, depth, centerX, centerZ } = normalizeBounds(bounds); + const span = Math.min(width, depth); + const radius = span * (LAKE_MIN + rng() * (LAKE_MAX - LAKE_MIN)); + + // Offset from centre so it does not always sit in the middle of the city. + const cx = centerX + (rng() - 0.5) * (width / 2 - radius * 2); + const cz = centerZ + (rng() - 0.5) * (depth / 2 - radius * 2); + + const points: { x: number; z: number }[] = []; + for (let i = 0; i < LAKE_LOBES; i++) { + const a = (i / LAKE_LOBES) * Math.PI * 2; + const r = radius * (1 + (rng() - 0.5) * LAKE_JITTER); + points.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + } + return { points }; +} + +/** + * Water for a generation run, or nothing. + * + * `NONE` is the default because generation has never produced water before — + * defaulting to a river would put one through the city of everyone already using the + * button. + */ +export function generateWater(type: WaterType, bounds: Bounds, rng: Rng): Polygon[] { + if (type === 'RIVER') return [river(bounds, rng)]; + if (type === 'COAST') return [coast(bounds, rng)]; + if (type === 'LAKE') return [lake(bounds, rng)]; + return []; +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 3f34797..3ebadd6 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { isUserDefinedName, getStructLabel } from '../utils/locationHelpers'; import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from './Buildings'; -import { generateCity, SpatialGrid, seededRng, seedFrom, countGeneratedInRegion, type SectionType, type OverpassDensity, type LayoutType } from '../cityGen'; +import { generateCity, SpatialGrid, seededRng, seedFrom, countGeneratedInRegion, type SectionType, type OverpassDensity, type LayoutType, type WaterType } from '../cityGen'; /** Street layouts offered in the generator, with what each one reads as. */ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ @@ -13,6 +13,17 @@ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ { value: 'SUPERBLOCK', label: 'SUPERBLOCK — TOWER IN PARK' }, { value: 'RING', label: 'RING — BELTWAYS AND SPOKES' }, ]; + +/** + * Water to generate. NONE is the default and the off switch — the selector doubles as + * the disable, rather than a checkbox that could disagree with it. + */ +const WATER_OPTIONS: { value: WaterType; label: string }[] = [ + { value: 'NONE', label: 'NONE — DRAW YOUR OWN (DEFAULT)' }, + { value: 'RIVER', label: 'RIVER — DIVIDES THE CITY, BRIDGES IT' }, + { value: 'COAST', label: 'COAST — WATERFRONT ON ONE EDGE' }, + { value: 'LAKE', label: 'LAKE — AN OBSTACLE INSIDE IT' }, +]; import type { BankSoundKey } from './BankWindows'; import { playCashRegister, playWompWomp, playCalibration, playProudFanfare, playHighRollerSound } from './BankWindows'; import type { SignData, SignLine } from './Signs'; @@ -593,7 +604,7 @@ export function AdminPanel({ activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, - cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, + cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, }: any) { if (view === 'battle_map') { return ( @@ -987,19 +998,33 @@ export function AdminPanel({ // the purge having failed. setLastCitySeed?.(String(seed)); - const { blocks, roads: finalRoads, buildings: rawBuildings, overpasses: newOverpasses } = generateCity( + const { blocks, roads: finalRoads, buildings: rawBuildings, overpasses: newOverpasses, waterBodies: newWater } = generateCity( genBounds, { sectionType: citySectionType as SectionType, excludeRoads: genExcludeRoads, overpassDensity, layout: cityLayout ?? 'BSP', + water: cityWater ?? 'NONE', boundary: drawing ? { points: tracedPoints } : undefined, }, { locations: worldLocations, roads: worldRoads, waterBodies }, seededRng(seed) ); + // Water first: it shapes where the roads went, so it should exist + // before they are persisted. Marked generated so a later regenerate + // clears it without touching anything the GM drew. + for (const w of newWater) { + const wRes = await fetch('/api/water', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ points: w.points, generated: true }), + }); + if (!wRes.ok) throw new Error(`Water creation failed: ${wRes.status}`); + } + if (newWater.length > 0) fetchWaterBodies?.(); + if (finalRoads.length > 0) { const rRes = await fetch('/api/roads', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(finalRoads) }); if (!rRes.ok) throw new Error(`Road creation failed: ${rRes.status}`); @@ -1800,6 +1825,17 @@ export function AdminPanel({ ))} + +

{ vi.unstubAllGlobals(); }); }); + +describe('AdminPanel water selector', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + citySeed: '42', + setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), + cityWater: 'NONE', + setCityWater: vi.fn(), + roadSelectionBounds: { min: { x: -300, z: -300 }, max: { x: 300, z: 300 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + fetchWaterBodies: vi.fn(), + ...over, + }); + + const stubFetch = () => { + const mock = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + ); + vi.stubGlobal('fetch', mock); + return mock; + }; + + it('offers every water type', () => { + render(); + const select = screen.getByLabelText('WATER') as HTMLSelectElement; + expect([...select.options].map(o => o.value)).toEqual(['NONE', 'RIVER', 'COAST', 'LAKE']); + }); + + it('defaults to none, so existing generation is unchanged', () => { + // NONE doubles as the off switch, rather than a checkbox that could disagree + // with the selector. + render(); + expect((screen.getByLabelText('WATER') as HTMLSelectElement).value).toBe('NONE'); + }); + + it('reports a water choice', async () => { + const props = genProps(); + render(); + await userEvent.selectOptions(screen.getByLabelText('WATER'), 'RIVER'); + expect(props.setCityWater).toHaveBeenCalledWith('RIVER'); + }); + + it('persists no water when set to none', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(mock.mock.calls.filter(([u]) => String(u) === '/api/water')).toHaveLength(0); + vi.unstubAllGlobals(); + }); + + it('persists a generated river, marked so a regenerate can clear it', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const posts = mock.mock.calls.filter(([u]) => String(u) === '/api/water'); + expect(posts).toHaveLength(1); + const body = JSON.parse(String((posts[0][1] as RequestInit).body)); + expect(body.generated).toBe(true); + expect(body.points.length).toBeGreaterThan(2); + vi.unstubAllGlobals(); + }); + + it('saves the water before the roads it shaped', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const urls = mock.mock.calls.map(([u]) => String(u)); + const waterAt = urls.indexOf('/api/water'); + const roadsAt = urls.indexOf('/api/roads'); + expect(waterAt).toBeGreaterThanOrEqual(0); + if (roadsAt >= 0) expect(waterAt).toBeLessThan(roadsAt); + vi.unstubAllGlobals(); + }); +}); From 30e5bcd919119c5f3a3c9e3fdb761032999e44cc Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 20:21:05 -0500 Subject: [PATCH 20/40] feat(citygen): parks can have ponds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PARK_PONDS toggle, off by default. Parks were trees on grass; a pond gives them a centrepiece and a reason to read as designed space rather than an empty lot the generator gave up on. This is the opposite ordering case from rivers and coastlines. Those are made before the split, because the split is water-aware and the grid has to stop at the banks. A park only exists once the split has produced the block it sits in, so its pond is necessarily made afterwards — which is safe precisely because a pond is contained by its plot. It never reaches a road, so no road needs re-cutting and no bridge is called for. Ponds are therefore collected apart from the water array the split, the shoreline roads and the bridge siting were all built from. Adding to that array here would be a lie about what shaped the city, and would make the same seed produce different roads depending on whether ponds were on. A test pins that: a ponded and an unponded run of one seed give identical roads and overpasses. The toggle is separate from WATER rather than another entry in it, because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and a GM may want either without the other. Off by default for the same reason WATER defaults to NONE. generatePark returns its ponds rather than pushing them into the building array: a pond is not a building, and neither the collision grid nor the height taper means anything for one. The pond roll is also skipped entirely when ponds are off, rather than made and discarded, so a seed keeps reproducing the parks it produced before ponds existed. --- frontend/src/App.tsx | 3 + .../src/cityGen/__tests__/parkPonds.test.ts | 160 ++++++++++++++++++ frontend/src/cityGen/index.ts | 22 ++- frontend/src/cityGen/parks.ts | 64 ++++++- frontend/src/cityGen/types.ts | 6 + frontend/src/components/AdminPanel.tsx | 9 +- 6 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/parkPonds.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bbd4bfb..70b7fd7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -375,6 +375,7 @@ function App() { // NONE by default: generation has never produced water, so anything else would put // a river through the city of everyone already using the button. const [cityWater, setCityWater] = useState('NONE'); + const [cityParkPonds, setCityParkPonds] = useState(false); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1813,6 +1814,8 @@ function App() { setCitySeed={setCitySeed} cityWater={cityWater} setCityWater={setCityWater} + cityParkPonds={cityParkPonds} + setCityParkPonds={setCityParkPonds} lastCitySeed={lastCitySeed} setLastCitySeed={setLastCitySeed} cityLayout={cityLayout} diff --git a/frontend/src/cityGen/__tests__/parkPonds.test.ts b/frontend/src/cityGen/__tests__/parkPonds.test.ts new file mode 100644 index 0000000..34144d2 --- /dev/null +++ b/frontend/src/cityGen/__tests__/parkPonds.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { generateCity, generatePark, pointInPolygon, pointInWater } from '../index'; +import type { Block } from '../types'; + +/** + * Park ponds. + * + * The opposite ordering case from rivers and coastlines: a park only exists once the + * split has produced the block it sits in, so its pond is made afterwards. That is + * safe because a pond is contained by its plot — it never reaches a road, so no road + * needs re-cutting and no bridge is called for. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; +const clear = () => false; + +const block: Block = { x: 0, z: 0, w: 60, d: 60 }; + +/** Run generatePark until it yields a pond, so pond-shape assertions aren't flaky. */ +function firstPond(seed = 1) { + const rng = seededRng(seed); + for (let i = 0; i < 200; i++) { + const [pond] = generatePark(block, 50, 50, [], clear, rng, true); + if (pond) return pond; + } + throw new Error('no pond in 200 attempts'); +} + +describe('generatePark ponds', () => { + it('makes none unless asked', () => { + const rng = seededRng(); + for (let i = 0; i < 50; i++) { + expect(generatePark(block, 50, 50, [], clear, rng)).toHaveLength(0); + } + }); + + it('draws no randomness for a pond when ponds are off', () => { + // Otherwise a seed would stop reproducing the parks it produced before ponds + // existed, purely from the extra rolls. + const withFlag = seededRng(9); + const without = seededRng(9); + const a: unknown[] = []; + const b: unknown[] = []; + generatePark(block, 50, 50, a as never[], clear, withFlag, false); + generatePark(block, 50, 50, b as never[], clear, without); + expect(a).toEqual(b); + expect(withFlag()).toBe(without()); + }); + + it('makes ponds when asked', () => { + const rng = seededRng(); + let made = 0; + for (let i = 0; i < 60; i++) { + made += generatePark(block, 50, 50, [], clear, rng, true).length; + } + expect(made).toBeGreaterThan(0); + }); + + it('encloses actual area', () => { + const pond = firstPond(); + const area = Math.abs(pond.points.reduce((sum, p, i) => { + const q = pond.points[(i + 1) % pond.points.length]; + return sum + (p.x * q.z - q.x * p.z); + }, 0) / 2); + expect(area).toBeGreaterThan(1); + }); + + it('stays inside its own plot', () => { + // The whole point of siting a pond after the split: it must not reach the road. + const pond = firstPond(); + for (const p of pond.points) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(25); + expect(Math.abs(p.z)).toBeLessThanOrEqual(25); + } + }); + + it('skips a pond on ground that is already taken', () => { + // isBlocked already composes every reason a footprint is unusable, roads included. + const rng = seededRng(); + let made = 0; + for (let i = 0; i < 60; i++) { + made += generatePark(block, 50, 50, [], () => true, rng, true).length; + } + expect(made).toBe(0); + }); + + it('keeps trees out of the water', () => { + const rng = seededRng(3); + for (let i = 0; i < 60; i++) { + const trees: { x: number; z: number }[] = []; + const [pond] = generatePark(block, 50, 50, trees as never[], clear, rng, true); + if (!pond) continue; + for (const t of trees) expect(pointInPolygon(pond, t.x, t.z)).toBe(false); + } + }); +}); + +describe('generateCity with park ponds', () => { + it('returns no water when ponds are off', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false }, freshContext(), seededRng(), deps + ); + expect(result.waterBodies).toHaveLength(0); + }); + + it('returns ponds to be persisted when they are on', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(), deps + ); + expect(result.waterBodies.length).toBeGreaterThan(0); + }); + + it('does not let a pond move the roads it was made after', () => { + // Ponds are collected apart from the water the split saw. If they leaked into it, + // the road network would differ between a ponded and an unponded run of the same + // seed — and the pond would be sited against roads that no longer exist. + const dry = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false }, freshContext(), seededRng(11), deps + ); + const wet = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(11), deps + ); + expect(wet.roads).toEqual(dry.roads); + expect(wet.overpasses).toEqual(dry.overpasses); + }); + + it('keeps ponds clear of the roads', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(5), deps + ); + for (const road of result.roads) { + const mid = { x: (road.x1 + road.x2) / 2, z: (road.z1 + road.z2) / 2 }; + expect(pointInWater(result.waterBodies, mid.x, mid.z)).toBe(false); + } + }); + + it('reproduces its ponds from a seed', () => { + const opts = { sectionType: 'MIXED' as const, excludeRoads: false, parkPonds: true }; + const a = generateCity(bounds(300), opts, freshContext(), seededRng(77), deps); + const b = generateCity(bounds(300), opts, freshContext(), seededRng(77), deps); + expect(a.waterBodies).toEqual(b.waterBodies); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 60640d0..350ded0 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -17,6 +17,7 @@ import { import { generatePark } from './parks'; import { shouldPlaceLandmark, generateLandmark } from './landmarks'; import { parseWaterBodies, pointInWater, footprintInWater, clipSegmentToBoundary } from './water'; +import type { Polygon } from './water'; import { findBridges } from './bridges'; import { generateShorelineRoads, snapRoadEndsToShoreline } from './shoreline'; import type { @@ -80,7 +81,7 @@ export function generateCity( rng: Rng = Math.random, deps: GenerateCityDeps = DEFAULT_DEPS ): GenerateCityResult { - const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE' } = options; + const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE', parkPonds = false } = options; // Fewer than three points cannot enclose an area. Treating a degenerate boundary as // absent falls back to the plain bounds, rather than generating nothing at all and // looking like a broken button. @@ -136,6 +137,11 @@ export function generateCity( const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water, boundary); const buildings: RawBuilding[] = []; + // Ponds are collected separately from `water`: that array is what the split, the + // shoreline roads and bridge siting were built from, and all of those have already + // run by the time a park exists. Adding to it here would be a lie about what shaped + // the city. They join the generated water only in the result, to be persisted. + const pondPolys: Polygon[] = []; blocks.forEach((block, index) => { const plotId = `gen_${index}`; @@ -160,29 +166,33 @@ export function generateCity( * across a road. Every piece is therefore re-checked once the plot is * finished, and the whole plot is rolled back if any of them landed badly * — an empty lot reads as deliberate, half a building does not. + * + * Returns whether the plot was kept, so a caller that produced something other + * than buildings — a park pond — can discard that too when the plot is rolled back. */ - const tagPlot = (fallbackName: string) => { + const tagPlot = (fallbackName: string): boolean => { for (let i = startIndex; i < buildings.length; i++) { const b = buildings[i]; const wet = water.length > 0 && footprintInWater(water, b.x, b.z, b.width, b.depth); const paved = !excludeRoads && footprintOnRoad(roadsToCheck, b.x, b.z, b.width, b.depth); if (wet || paved) { buildings.length = startIndex; - return; + return false; } } for (let i = startIndex; i < buildings.length; i++) { buildings[i].temp_block_id = plotId; if (!buildings[i].name) buildings[i].name = fallbackName; } + return true; }; const normDist = normalizedDistance(block.x, block.z, centerX, centerZ, maxRadius); // Parks claim the plot outright — no buildings share it. if (rng() < parkProbability(normDist)) { - generatePark(block, bw, bd, buildings, isBlocked, rng); - tagPlot('PARK'); + const ponds = generatePark(block, bw, bd, buildings, isBlocked, rng, parkPonds); + if (tagPlot('PARK')) pondPolys.push(...ponds); return; } @@ -235,6 +245,6 @@ export function generateCity( roads: finalRoads, buildings: clampBuildingsUnderDecks(buildings, overpasses), overpasses, - waterBodies: generatedWater, + waterBodies: [...generatedWater, ...pondPolys], }; } diff --git a/frontend/src/cityGen/parks.ts b/frontend/src/cityGen/parks.ts index 81f6688..d568aee 100644 --- a/frontend/src/cityGen/parks.ts +++ b/frontend/src/cityGen/parks.ts @@ -1,15 +1,63 @@ import type { Block, RawBuilding, Rng } from './types'; import type { IsBlocked } from './collision'; +import { type Polygon, pointInPolygon } from './water'; /** Holographic foliage colour shared by trunk and canopy. */ const HOLO_GREEN = '#00ff66'; +/** How often a park gets a pond, and how much of the plot it takes. */ +const POND_CHANCE = 0.4; +const POND_MIN = 0.18; +const POND_MAX = 0.32; + +/** Points around a pond's edge, and how far each strays from a circle. */ +const POND_LOBES = 12; +const POND_JITTER = 0.25; + +/** + * A pond somewhere in a park plot, or nothing. + * + * Unlike generated rivers and lakes this runs *after* the split, because a park only + * exists once the split has produced the block it sits in. That is safe precisely + * because a pond is contained by its plot: it never reaches a road, so nothing needs + * re-cutting and no bridge is called for. + */ +function generatePond(block: Block, bw: number, bd: number, isBlocked: IsBlocked, rng: Rng): Polygon | null { + if (rng() >= POND_CHANCE) return null; + + const span = Math.min(bw, bd); + const radius = (span * (POND_MIN + rng() * (POND_MAX - POND_MIN))) / 2; + + // Offset from the plot centre so ponds do not all sit dead centre, but not so far + // that the outline reaches the plot edge. + const slack = Math.max(0, span / 2 - radius * 1.5); + const cx = block.x + (rng() - 0.5) * slack; + const cz = block.z + (rng() - 0.5) * slack; + + // A pond on a road or over an existing structure reads as a mistake. isBlocked + // already composes every reason a footprint is unusable, so ask it rather than + // re-deriving the checks here. + if (isBlocked(cx, cz, radius * 2, radius * 2, 0.5)) return null; + + const points: { x: number; z: number }[] = []; + for (let i = 0; i < POND_LOBES; i++) { + const a = (i / POND_LOBES) * Math.PI * 2; + const r = radius * (1 + (rng() - 0.5) * POND_JITTER); + points.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + } + return { points }; +} + /** * Fill a plot with a park: scattered low-poly holographic trees, each a - * cylinder trunk with a pyramid or box canopy parented to it. + * cylinder trunk with a pyramid or box canopy parented to it, and sometimes a pond. * * Trees that would collide with existing geometry are skipped rather than * relocated, so a crowded plot simply ends up sparser. + * + * Returns the pond outlines the plot produced, for the caller to persist as water. + * They are returned rather than pushed into `out` because a pond is not a building — + * the collision grid and the height taper have no meaning for one. */ export function generatePark( block: Block, @@ -17,8 +65,12 @@ export function generatePark( bd: number, out: RawBuilding[], isBlocked: IsBlocked, - rng: Rng -): void { + rng: Rng, + withPonds = false +): Polygon[] { + // Guarded rather than filtered afterwards so an unponded run draws no randomness for + // one, and a seed keeps reproducing the park it produced before ponds existed. + const pond = withPonds ? generatePond(block, bw, bd, isBlocked, rng) : null; const numPlants = 6 + Math.floor(rng() * 7); // 6 to 12 trees for (let i = 0; i < numPlants; i++) { @@ -26,6 +78,10 @@ export function generatePark( const pz = block.z + (rng() - 0.5) * bd * 0.8; if (isBlocked(px, pz, 0.4, 0.4, 0.5)) continue; + // Trees are placed after the pond so they can stand back from it. The + // whole-plot water test in the caller runs before the pond exists, so nothing + // else will move them. + if (pond && pointInPolygon(pond, px, pz)) continue; const trunkH = 2.0 + rng() * 2.5; const trunkW = 0.4; @@ -44,4 +100,6 @@ export function generatePark( color: HOLO_GREEN, shape: canopyShape, parent_name: 'ROOT', }); } + + return pond ? [pond] : []; } diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index c8ddee5..ff19636 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -83,6 +83,12 @@ export interface GenerateCityOptions { * of everyone already using the button. */ water?: WaterType; + /** + * Give parks ponds. Separate from `water` because it is a different scale of + * decision — a river reshapes the whole city, a pond is scenery in one plot — and a + * GM may well want one without the other. Off by default, on the same reasoning. + */ + parkPonds?: boolean; /** When true, no roads are generated and road collision is skipped. */ excludeRoads: boolean; /** How freely roads bridge the water they cross. Defaults to 'normal'. */ diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 3ebadd6..5908cf2 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -604,7 +604,7 @@ export function AdminPanel({ activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, - cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, + cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, cityParkPonds, setCityParkPonds, }: any) { if (view === 'battle_map') { return ( @@ -1006,6 +1006,7 @@ export function AdminPanel({ overpassDensity, layout: cityLayout ?? 'BSP', water: cityWater ?? 'NONE', + parkPonds: !!cityParkPonds, boundary: drawing ? { points: tracedPoints } : undefined, }, { locations: worldLocations, roads: worldRoads, waterBodies }, @@ -1836,6 +1837,12 @@ export function AdminPanel({ ))} +
Date: Sat, 1 Aug 2026 21:27:56 -0500 Subject: [PATCH 21/40] fix(citygen): park ponds were puddles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a generated city: ponds came out ~4 units across in plots of 50 and 65 units — 6 to 9% of what you actually see. The cause was sizing a circular pond off Math.min(bw, bd). Blocks out of the split are frequently long thin rectangles, and a circle in one can only ever be as wide as the short side, so it vanishes against the length. Raising the fraction alone would not have fixed it; the shape was wrong. A pond is now an ellipse with one radius per axis, both from the same fraction, so it takes up as much of a long plot as of a square one and still reads as a single deliberate shape. The centre offset and the edge wobble became per-axis for the same reason. The fraction goes up to 35-55% of each axis, which is a pond rather than a water feature. Frequency was left at 0.4 — that measured at 3 ponds in 6 park plots, which is what it should be. --- .../src/cityGen/__tests__/parkPonds.test.ts | 28 ++++++++++++++ frontend/src/cityGen/parks.ts | 38 ++++++++++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/frontend/src/cityGen/__tests__/parkPonds.test.ts b/frontend/src/cityGen/__tests__/parkPonds.test.ts index 34144d2..0c9d220 100644 --- a/frontend/src/cityGen/__tests__/parkPonds.test.ts +++ b/frontend/src/cityGen/__tests__/parkPonds.test.ts @@ -79,6 +79,34 @@ describe('generatePark ponds', () => { expect(area).toBeGreaterThan(1); }); + it('is big enough to read as a pond', () => { + // Sizing a circular pond off the narrower plot axis produced 4-unit ponds in + // 50-unit plots. Anything under a quarter of the plot is a puddle. + const pond = firstPond(); + const xs = pond.points.map(p => p.x); + const width = Math.max(...xs) - Math.min(...xs); + expect(width).toBeGreaterThan(50 * 0.25); + }); + + it('fills a long thin plot along its length', () => { + // The failure the ellipse fixes: a circle in an elongated plot can only ever be + // as wide as the short side, so it vanishes against the length. + const rng = seededRng(2); + const long: Block = { x: 0, z: 0, w: 90, d: 30 }; + for (let i = 0; i < 200; i++) { + const [pond] = generatePark(long, 80, 20, [], clear, rng, true); + if (!pond) continue; + const xs = pond.points.map(p => p.x); + const zs = pond.points.map(p => p.z); + const width = Math.max(...xs) - Math.min(...xs); + const depth = Math.max(...zs) - Math.min(...zs); + expect(width).toBeGreaterThan(depth * 2); + expect(width).toBeGreaterThan(80 * 0.25); + return; + } + throw new Error('no pond in 200 attempts'); + }); + it('stays inside its own plot', () => { // The whole point of siting a pond after the split: it must not reach the road. const pond = firstPond(); diff --git a/frontend/src/cityGen/parks.ts b/frontend/src/cityGen/parks.ts index d568aee..6107f94 100644 --- a/frontend/src/cityGen/parks.ts +++ b/frontend/src/cityGen/parks.ts @@ -5,10 +5,18 @@ import { type Polygon, pointInPolygon } from './water'; /** Holographic foliage colour shared by trunk and canopy. */ const HOLO_GREEN = '#00ff66'; -/** How often a park gets a pond, and how much of the plot it takes. */ +/** + * How often a park gets a pond, and how much of each plot axis it spans. + * + * Measured against real output: sizing a circular pond off the *narrower* axis put + * 4-unit ponds in 50-unit plots — 6–9% of what you actually see, a puddle. Blocks out + * of the split are frequently long thin rectangles, so a circle can only ever be as + * wide as the short side. The pond is an ellipse instead, one radius per axis, which + * lets it fill an elongated plot without leaving it. + */ const POND_CHANCE = 0.4; -const POND_MIN = 0.18; -const POND_MAX = 0.32; +const POND_MIN = 0.35; +const POND_MAX = 0.55; /** Points around a pond's edge, and how far each strays from a circle. */ const POND_LOBES = 12; @@ -25,25 +33,29 @@ const POND_JITTER = 0.25; function generatePond(block: Block, bw: number, bd: number, isBlocked: IsBlocked, rng: Rng): Polygon | null { if (rng() >= POND_CHANCE) return null; - const span = Math.min(bw, bd); - const radius = (span * (POND_MIN + rng() * (POND_MAX - POND_MIN))) / 2; + // One fraction, applied to each axis, so the pond takes up as much of a long plot + // as it does of a square one and still reads as a single deliberate shape. + const fraction = POND_MIN + rng() * (POND_MAX - POND_MIN); + const rx = (bw * fraction) / 2; + const rz = (bd * fraction) / 2; - // Offset from the plot centre so ponds do not all sit dead centre, but not so far - // that the outline reaches the plot edge. - const slack = Math.max(0, span / 2 - radius * 1.5); - const cx = block.x + (rng() - 0.5) * slack; - const cz = block.z + (rng() - 0.5) * slack; + // Offset from the plot centre so ponds do not all sit dead centre, without letting + // the jittered outline reach the plot edge. Per axis, since the radii differ. + const slackX = Math.max(0, bw / 2 - rx * (1 + POND_JITTER)); + const slackZ = Math.max(0, bd / 2 - rz * (1 + POND_JITTER)); + const cx = block.x + (rng() - 0.5) * slackX; + const cz = block.z + (rng() - 0.5) * slackZ; // A pond on a road or over an existing structure reads as a mistake. isBlocked // already composes every reason a footprint is unusable, so ask it rather than // re-deriving the checks here. - if (isBlocked(cx, cz, radius * 2, radius * 2, 0.5)) return null; + if (isBlocked(cx, cz, rx * 2, rz * 2, 0.5)) return null; const points: { x: number; z: number }[] = []; for (let i = 0; i < POND_LOBES; i++) { const a = (i / POND_LOBES) * Math.PI * 2; - const r = radius * (1 + (rng() - 0.5) * POND_JITTER); - points.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + const wobble = 1 + (rng() - 0.5) * POND_JITTER; + points.push({ x: cx + Math.cos(a) * rx * wobble, z: cz + Math.sin(a) * rz * wobble }); } return { points }; } From e6bf73173a90cf2954b4b765b07c52c38078eb30 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 21:39:04 -0500 Subject: [PATCH 22/40] docs: record the generator work in 1.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve commits had landed since the 1.8.0 release entry without being documented: seeded generation, REGENERATE and region purge, UNDO on the panel, generated water, park ponds, the road/skyline/setback pass, and three fixes for buildings floating near overpasses. They belong in 1.8.0 rather than a new version — 1.8.0 has not shipped, so this is that release growing rather than a follow-up to it. Its date moves to today for the same reason. Also called out for anyone updating: water_bodies gains a `generated` column on startup, and a server still on older code will accept generated water but store it as hand-drawn, so a regenerate will not clear it. README project structure gained waterGen.ts, rng.ts and region.ts, the four new test files, the purge-region route, and the generator's new controls. --- CHANGELOG.md | 22 +++++++++++++++++++++- README.md | 17 ++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f686482..e8cdfe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [1.8.0] - 2026-07-28 +## [1.8.0] - 2026-08-01 ### Added @@ -19,10 +19,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. +- **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. +- **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. +- **Seeded generation** — an optional `SEED` field. The same seed over the same area with the same options rebuilds the same city, so a map can be recreated or shared as a short string. Leaving it blank rolls a fresh seed, and the seed actually used is reported back beneath the field rather than written into it. +- **`REGENERATE`** — clears the previous generation in the selected area and builds afresh, for iterating on a district without hand-deleting it first. It keeps anything the GM authored: named structures, tokens, battle-map content and hand-drawn water all survive. Plain `GENERATE` still adds to what is there. +- **`UNDO` on the generator panel** — the same server-side undo as the admin header, reachable without leaving the panel. +- The panel now stays open after generating, instead of dropping back to the main admin list — generation is something you do repeatedly while tuning. + +### Changed + +- **Road hierarchy, skyline taper and per-zone setbacks.** Road width is graded by split depth, so arterials read as arterials and side streets as side streets. Building height now blends continuously with distance from the centre instead of stepping in bands, which turned the skyline from flat plateaus with hard seams into a taper. Corporate plots leave forecourts and slums and markets build to the lot line, via a per-zone lot coverage applied after the aspect clamp. ### Fixed +- **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. +- **A typed seed is used as typed.** Parsing forced the value through `>>> 0`, which wrapped anything above 2³², so a long numeric seed silently became a different one. Seeds are hashed into range instead. +- **`REGENERATE` rolls a new seed** unless one is asked for. The seed field was doing double duty as both the request and the readout, so writing the used seed back into it meant every later regenerate rebuilt the identical city — which reads as the purge having failed. - **Elevated arterials no longer run through buildings.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an arterial sterilising every block it crosses — but nothing then stopped a tower rising through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. +- **Skyscrapers floated above the ground near an overpass.** Capping a building under a deck scaled its `height` but not its `y`. A plot is usually several stacked parts, and a part sitting on another has its `y` set to that one's height, so shortening the bases left every upper storey hanging in mid-air. +- **Buildings still floated after that fix**, because the cap was applied per part rather than per plot: shortening one part of a stack and not its neighbours pulls the stack apart just as surely. A whole plot is now scaled by one factor, grouped by the `temp_block_id` the generator already stamps on every piece it emits. ### Technical @@ -33,6 +48,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means the road grid stops at the banks of its own accord and bridges are sited from the stubs left there — generating it afterwards would mean cutting finished roads, which is a different and worse problem. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. +- **`water_bodies` gains a `generated` column**, so a regenerate can clear its own river without destroying a lake the GM drew. Existing rows default to `0`, so everything already on a map counts as hand-drawn. The migration runs on startup — a server on older code will accept generated water but store it as hand-drawn. +- **Seeding reached the buildings, not just the layout.** `cityGen/` had a single `Math.random` (the injected default), but `generateThemedBuildingsForPlot` had forty of its own, so a seed reproduced the street layout while the buildings on it changed every run. The rng is threaded through to the plot filler. It is deliberately not crypto-backed: 1.7.1 moved outcome-deciding rolls to OS entropy, and city layout is cosmetic. +- **`POST /purge-region`** clears a region's generated content in one transaction and emits a single update, rather than the panel issuing a delete per object. It distinguishes generated content from authored content by `isUserDefinedName`, token classification, battle-map membership and the new water flag. +- Region membership and counting moved out of `AdminPanel` into `cityGen/region.ts`, and seeding into `cityGen/rng.ts`, so both are testable without rendering the panel. --- diff --git a/README.md b/README.md index 8fa0957..67d2f1f 100644 --- a/README.md +++ b/README.md @@ -326,8 +326,8 @@ CITY_NET/ │ ├── middleware/ │ │ └── auth.js # JWT verify middleware (admin + elevated users) │ ├── routes/ -│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs -│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for NPC initiative rolls +│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs; POST /water marks generated water so a regenerate can clear its own river without touching a lake the GM drew +│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for NPC initiative rolls; POST /purge-region clears one region's generated content in a single transaction, keeping GM-named structures, tokens, battle-map content and hand-drawn water │ │ ├── battle_maps.js # Battle map image upload/management │ │ ├── maps.js # Saved map snapshots (locations, districts, roads, overpasses, water bodies); preserves only rhombus tokens on load/clear; records active_map_name in global_settings so exports can name their files │ │ ├── music.js # Radio Feed — library CRUD + file upload @@ -409,18 +409,25 @@ CITY_NET/ │ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc) │ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp -│ │ │ ├── parks.ts # Holotree park plots +│ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule │ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) +│ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them │ │ │ ├── bridges.ts # Shore-stub pairing, span/grade limits, deck levelling by graph colouring, OVERPASS_DENSITY +│ │ │ ├── rng.ts # seededRng (mulberry32), randomSeed, and seedFrom — hashes any typed seed into range instead of truncating it +│ │ │ ├── region.ts # Region membership test and generated-content count, shared by the panel and REGENERATE │ │ │ └── __tests__/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks -│ │ │ └── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels +│ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels +│ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after +│ │ │ ├── parkPonds.test.ts # Pond shape and size, containment in the plot, trees standing back from the water, and identical roads with ponds on or off +│ │ │ ├── seeds.test.ts # Same seed rebuilds the same city; typed seeds survive intact; a new seed gives a different one +│ │ │ └── region.test.ts # Region membership and counting for REGENERATE │ │ ├── components/ -│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds and OVERPASS_DENSITY; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) +│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) │ │ │ ├── HitPoints.tsx # HP tracking + injury panel + HealthReviewWindow; STIM_HEAL (CWN), STABILIZE button for allies on mortal wound │ │ │ ├── BankWindows.tsx # Player bank UI │ │ │ ├── ChatWindow.tsx # In-game chat From cda6a2c5536778bed0969d2f5a33855f879c8104 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 21:56:38 -0500 Subject: [PATCH 23/40] =?UTF-8?q?feat(citygen):=20ORGANIC=5FCELLS=20layout?= =?UTF-8?q?=20=E2=80=94=20a=20city=20that=20grew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Voronoi diagram with streets along the cell boundaries. Every layout so far produces rectangles, and rectangles read as planned; this is the one that reads as grown — no right angles, streets meeting at odd angles, blocks that are wedges and pentagons. A town that filled in around footpaths rather than one a surveyor set out. The obstacle was never the diagram, it was that Block is {x, z, w, d} and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to offer it. Rather than teach 1180 lines of building generator about polygons, each cell is reduced to the largest rectangle that fits inside it. The street pattern is where nearly all of the look lives, and that arrives intact. A cell is rarely filled by its rectangle, so setbacks vary from plot to plot as a free side effect. Cells are built by clipping the bounds with one half-plane per rival seed. That is O(n^2), and for the hundred or so seeds a city needs it costs a few thousand clips of a handful of vertices — nothing beside filling the plots afterwards. Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice. A perfect lattice gives a honeycomb every bit as machine-made as the grid, and fully random seeds clump — and clumped seeds give slivers, cells too thin to hold anything. Long cell boundaries become avenues. Cell edges vary a lot in length, so the network gets a hierarchy without one being invented: the long runs across the diagram are the ones that would carry traffic anyway. Two existing tests asserted the exact layout list and failed on the new entry, which is what they are for; both updated. --- CHANGELOG.md | 2 + README.md | 4 +- .../src/cityGen/__tests__/layouts.test.ts | 2 +- .../src/cityGen/__tests__/voronoi.test.ts | 216 +++++++++++++++ frontend/src/cityGen/layouts.ts | 63 ++++- frontend/src/cityGen/voronoi.ts | 246 ++++++++++++++++++ frontend/src/components/AdminPanel.tsx | 1 + .../components/__tests__/AdminPanel.test.tsx | 2 +- 8 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/voronoi.test.ts create mode 100644 frontend/src/cityGen/voronoi.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cdfe4..7f6c350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`GRID`** — two perpendicular families of streets with avenues every fourth line. Reads as Manhattan or Chicago, and is genuinely distinct from the default, which always produces *irregular* rectangles however it is tuned. That road hierarchy is most of what makes a grid look designed rather than generated. - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. + - **`ORGANIC_CELLS`** — a Voronoi diagram with streets along the cell boundaries. The only layout with no right angles in it: streets meet at odd angles and blocks are wedges and pentagons, which reads as a town that grew around footpaths rather than one a surveyor set out. Long cell boundaries become avenues, so the network gets a hierarchy without one being invented — the long runs across the diagram are the ones that would carry traffic anyway. - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. - **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. - **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. @@ -48,6 +49,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice — a perfect lattice gives a honeycomb as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. - **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means the road grid stops at the banks of its own accord and bridges are sited from the stubs left there — generating it afterwards would mean cutting finished roads, which is a different and worse problem. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. - **`water_bodies` gains a `generated` column**, so a regenerate can clear its own river without destroying a lake the GM drew. Existing rows default to `0`, so everything already on a map counts as hand-drawn. The migration runs on startup — a server on older code will accept generated water but store it as hand-drawn. - **Seeding reached the buildings, not just the layout.** `cityGen/` had a single `Math.random` (the injected default), but `generateThemedBuildingsForPlot` had forty of its own, so a seed reproduced the street layout while the buildings on it changed every run. The rng is threaded through to the plot filler. It is deliberately not crypto-backed: 1.7.1 moved outcome-deciding rolls to OS entropy, and city layout is cosmetic. diff --git a/README.md b/README.md index 67d2f1f..a471173 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,8 @@ CITY_NET/ │ │ │ ├── index.ts # generateCity orchestrator; selects a layout, caps buildings under decks; injected rng and fillPlot make it testable │ │ │ ├── types.ts # Bounds, Block, RawBuilding, Obstacle, options/context/result shapes │ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land and to any drawn boundary as they are laid; optional minimum block size -│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc) +│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc), VORONOI (organic cells, streets on the cell boundaries) +│ │ │ ├── voronoi.ts # Voronoi cells by half-plane clipping, shared-edge dedup, and the inscribed rectangle that lets an irregular cell feed a rectangle-only plot filler │ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp │ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building @@ -421,6 +422,7 @@ CITY_NET/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks +│ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned │ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels │ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after │ │ │ ├── parkPonds.test.ts # Pond shape and size, containment in the plot, trees standing back from the water, and identical roads with ponds on or off diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index 6111981..ab93d5d 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -61,7 +61,7 @@ function distanceToSegment( describe('layout registry', () => { it('offers every layout type', () => { - expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'RING', 'SUPERBLOCK']); + expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'RING', 'SUPERBLOCK', 'VORONOI']); }); it('every layout produces blocks for the same area', () => { diff --git a/frontend/src/cityGen/__tests__/voronoi.test.ts b/frontend/src/cityGen/__tests__/voronoi.test.ts new file mode 100644 index 0000000..13b6c10 --- /dev/null +++ b/frontend/src/cityGen/__tests__/voronoi.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; +import { + seedPoints, voronoiCells, cellEdges, inscribedRect, centroid, polygonArea, + voronoiLayout, LAYOUTS, VORONOI_SPACING, +} from '../index'; +import type { Pt } from '../voronoi'; + +/** + * Voronoi layout. + * + * The defining property is that every point of a cell is closer to that cell's seed + * than to any other. Most of what follows checks that directly, because if it holds the + * diagram is correct however the cells were built. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const dist = (a: Pt, b: Pt) => Math.hypot(a.x - b.x, a.z - b.z); + +describe('voronoi cells', () => { + it('gives every seed a cell', () => { + const seeds = seedPoints(bounds(300), seededRng()); + const cells = voronoiCells(bounds(300), seeds); + expect(cells.length).toBeGreaterThan(0); + expect(cells.length).toBeLessThanOrEqual(seeds.length); + }); + + it('puts each cell closer to its own seed than to any other', () => { + // The definition of a Voronoi diagram. Sampled at each cell's centroid, which is + // interior to a convex cell. + const seeds = seedPoints(bounds(300), seededRng()); + const cells = voronoiCells(bounds(300), seeds); + for (const cell of cells) { + const c = centroid(cell.poly); + const own = dist(c, cell.seed); + for (const other of seeds) { + if (other === cell.seed) continue; + expect(own).toBeLessThanOrEqual(dist(c, other) + 1e-6); + } + } + }); + + it('tiles the region without gaps', () => { + // Cells partition the frame, so their areas must sum to it. A gap or an overlap + // would show up here and nowhere else. + const half = 300; + const seeds = seedPoints(bounds(half), seededRng()); + const cells = voronoiCells(bounds(half), seeds); + const total = cells.reduce((s, c) => s + polygonArea(c.poly), 0); + expect(total).toBeCloseTo((half * 2) ** 2, -2); + }); + + it('keeps cells inside the bounds', () => { + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const cell of cells) { + for (const p of cell.poly) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(300 + 1e-6); + expect(Math.abs(p.z)).toBeLessThanOrEqual(300 + 1e-6); + } + } + }); + + it('produces convex cells', () => { + // Convexity is what lets inscribedRect test only four corners. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const { poly } of cells) { + const signs = new Set(); + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length], c = poly[(i + 2) % poly.length]; + const cross = (b.x - a.x) * (c.z - b.z) - (b.z - a.z) * (c.x - b.x); + if (Math.abs(cross) > 1e-6) signs.add(Math.sign(cross)); + } + expect(signs.size).toBeLessThanOrEqual(1); + } + }); + + it('reproduces from a seed', () => { + expect(seedPoints(bounds(300), seededRng(7))).toEqual(seedPoints(bounds(300), seededRng(7))); + }); +}); + +describe('cellEdges', () => { + it('lays each shared edge once', () => { + // Every interior edge belongs to two cells. Without deduplication the whole network + // would be built twice. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + const edges = cellEdges(cells); + const total = cells.reduce((s, c) => s + c.poly.length, 0); + expect(edges.length).toBeLessThan(total); + expect(edges.length).toBeGreaterThan(0); + }); +}); + +describe('inscribedRect', () => { + it('recovers a rectangle exactly', () => { + const rect = inscribedRect([ + { x: -20, z: -10 }, { x: 20, z: -10 }, { x: 20, z: 10 }, { x: -20, z: 10 }, + ]); + expect(rect.x).toBeCloseTo(0); + expect(rect.z).toBeCloseTo(0); + expect(rect.w).toBeCloseTo(40, 1); + expect(rect.d).toBeCloseTo(20, 1); + }); + + it('stays inside an irregular cell', () => { + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const { poly } of cells) { + const r = inscribedRect(poly); + const cellArea = polygonArea(poly); + expect(r.w * r.d).toBeLessThanOrEqual(cellArea + 1e-6); + expect(r.w).toBeGreaterThanOrEqual(0); + expect(r.d).toBeGreaterThanOrEqual(0); + } + }); + + it('uses a worthwhile share of the cell', () => { + // A rectangle that shrank to nothing would give a city of empty lots. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + const ratios = cells.map(({ poly }) => { + const r = inscribedRect(poly); + return (r.w * r.d) / polygonArea(poly); + }); + const mean = ratios.reduce((s, r) => s + r, 0) / ratios.length; + expect(mean).toBeGreaterThan(0.4); + }); +}); + +describe('voronoiLayout', () => { + it('is registered and reachable by name', () => { + expect(LAYOUTS.VORONOI).toBe(voronoiLayout); + }); + + it('produces blocks and roads', () => { + const { blocks, roads } = voronoiLayout(bounds(300), false, seededRng()); + expect(blocks.length).toBeGreaterThan(0); + expect(roads.length).toBeGreaterThan(0); + }); + + it('produces no roads when they are excluded', () => { + const { blocks, roads } = voronoiLayout(bounds(300), true, seededRng()); + expect(roads).toHaveLength(0); + expect(blocks.length).toBeGreaterThan(0); + }); + + it('gives the network a hierarchy', () => { + // Long cell boundaries become avenues. Without that the whole thing is a uniform + // mesh, which is the flaw the grid layout also had to solve. + const { roads } = voronoiLayout(bounds(400), false, seededRng()); + const widths = new Set(roads.map(r => r.width)); + expect(widths.size).toBeGreaterThan(1); + }); + + it('is not axis-aligned, unlike every other layout', () => { + // The entire reason this layout exists. GRID and BSP produce only horizontal and + // vertical roads; a majority of these should run at some other angle. + const { roads } = voronoiLayout(bounds(400), false, seededRng()); + const axisAligned = roads.filter(r => + Math.abs(r.x1 - r.x2) < 0.5 || Math.abs(r.z1 - r.z2) < 0.5).length; + expect(axisAligned / roads.length).toBeLessThan(0.35); + }); + + it('drops blocks centred outside a drawn boundary', () => { + const boundary = { points: [ + { x: -100, z: -100 }, { x: 100, z: -100 }, { x: 100, z: 100 }, { x: -100, z: 100 }, + ] }; + const { blocks } = voronoiLayout(bounds(300), true, seededRng(), [], boundary); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(100); + expect(Math.abs(b.z)).toBeLessThanOrEqual(100); + } + }); + + it('keeps roads out of the water', () => { + const lake = { points: [ + { x: -80, z: -80 }, { x: 80, z: -80 }, { x: 80, z: 80 }, { x: -80, z: 80 }, + ] }; + const { roads } = voronoiLayout(bounds(300), false, seededRng(), [lake]); + for (const r of roads) { + const mx = (r.x1 + r.x2) / 2; + const mz = (r.z1 + r.z2) / 2; + const inLake = Math.abs(mx) < 80 && Math.abs(mz) < 80; + expect(inLake).toBe(false); + } + }); + + it('reproduces the same city from the same seed', () => { + const a = voronoiLayout(bounds(300), false, seededRng(31)); + const b = voronoiLayout(bounds(300), false, seededRng(31)); + expect(a.blocks).toEqual(b.blocks); + expect(a.roads).toEqual(b.roads); + }); + + it('scales its cell count with the area', () => { + const small = voronoiLayout(bounds(150), true, seededRng()); + const large = voronoiLayout(bounds(450), true, seededRng()); + expect(large.blocks.length).toBeGreaterThan(small.blocks.length); + }); + + it('sizes cells around the configured spacing', () => { + const { blocks } = voronoiLayout(bounds(400), true, seededRng()); + const mean = blocks.reduce((s, b) => s + Math.max(b.w, b.d), 0) / blocks.length; + expect(mean).toBeGreaterThan(VORONOI_SPACING * 0.2); + expect(mean).toBeLessThan(VORONOI_SPACING * 1.5); + }); +}); diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index 596c552..ce8f79e 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -2,6 +2,7 @@ import type { Block, Bounds, Rng, RoadSegment } from './types'; import type { OverpassSpec } from './bridges'; import { normalizeBounds, splitCity } from './bsp'; import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; +import { seedPoints, voronoiCells, cellEdges, inscribedRect, VORONOI_SPACING } from './voronoi'; /** * Street layouts. @@ -18,7 +19,7 @@ export type LayoutFn = ( boundary?: Polygon ) => { blocks: Block[]; roads: RoadSegment[]; overpasses?: OverpassSpec[] }; -export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING'; +export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING' | 'VORONOI'; /** Target block size for the regular grid, before jitter. */ const GRID_CELL = 55; @@ -71,6 +72,12 @@ const DECK_PILLAR_SPACING = 14; /** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ const ARC_STEP_DEG = 9; +/** A Voronoi edge longer than this many spacings is an avenue rather than a street. */ +const VORONOI_AVENUE_RATIO = 1.15; + +const VORONOI_AVENUE_WIDTH = 7; +const VORONOI_STREET_WIDTH = 4; + /** * Evenly spaced cut positions across a span, jittered so the result reads as a surveyed * grid rather than a machine one. The outer edges stay put, since they are the boundary @@ -253,11 +260,63 @@ export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boun return { blocks, roads, overpasses }; }; +/** + * Organic cell city — a Voronoi diagram, streets along the cell boundaries. + * + * The only layout that produces no right angles. Streets meet at odd angles and blocks + * are wedges and pentagons, which reads as a town that grew around footpaths rather + * than one a surveyor set out. + * + * The plot inside each cell is the largest rectangle that fits it. That keeps the + * existing plot filler — which lays buildings out along a rectangle's axes and has no + * axes to work with in a pentagon — entirely unchanged, while still delivering the + * irregular *street pattern*, which is where nearly all of the look comes from. A cell + * is rarely filled by its rectangle, so setbacks vary from plot to plot for free. + * + * Long edges become avenues. Cell boundaries vary a lot in length, so this gives the + * network a hierarchy without inventing one: the long runs across the diagram are + * exactly the ones that would carry traffic. + */ +export const voronoiLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const seeds = seedPoints(bounds, rng); + const cells = voronoiCells(bounds, seeds); + + const roads: RoadSegment[] = []; + if (!excludeRoads) { + for (const { a, b } of cellEdges(cells)) { + const long = Math.hypot(b.x - a.x, b.z - a.z) > VORONOI_SPACING * VORONOI_AVENUE_RATIO; + const seg: RoadSegment = { + x1: a.x, z1: a.z, x2: b.x, z2: b.z, + width: long ? VORONOI_AVENUE_WIDTH : VORONOI_STREET_WIDTH, + }; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + } + } + + const blocks: Block[] = []; + for (const { poly } of cells) { + const rect = inscribedRect(poly); + // Blocks centred outside a drawn boundary are dropped, matching every other layout. + if (boundary && !pointInPolygon(boundary, rect.x, rect.z)) continue; + // The streets run along the cell edges, so the plot has to stand back from them. + const w = rect.w - VORONOI_AVENUE_WIDTH; + const d = rect.d - VORONOI_AVENUE_WIDTH; + if (w < 1 || d < 1) continue; + blocks.push({ x: rect.x, z: rect.z, w, d }); + } + + return { blocks, roads }; +}; + export const LAYOUTS: Record = { BSP: bspLayout, GRID: gridLayout, SUPERBLOCK: superblockLayout, RING: ringLayout, + VORONOI: voronoiLayout, }; -export { GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; +export * from './voronoi'; +export { VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; diff --git a/frontend/src/cityGen/voronoi.ts b/frontend/src/cityGen/voronoi.ts new file mode 100644 index 0000000..dc60fe3 --- /dev/null +++ b/frontend/src/cityGen/voronoi.ts @@ -0,0 +1,246 @@ +import type { Bounds, Rng } from './types'; +import { normalizeBounds } from './bsp'; + +/** + * Voronoi cells. + * + * A Voronoi diagram scatters seed points and gives each one the region closer to it + * than to any other. The boundaries land halfway between neighbouring seeds, so cells + * come out as irregular convex polygons — four to seven sides, no right angles. + * + * Every layout so far produces rectangles, and rectangles read as *planned*. This reads + * as grown: the street pattern of a medieval core, or a district that filled in around + * footpaths rather than a surveyor's plan. It is the one layout that looks nothing like + * the others, which is the whole reason for it. + * + * Cells are built by half-plane clipping rather than a sweepline. For the hundred or so + * seeds a city needs that is fast enough, and it is a fraction of the code — Fortune's + * algorithm would be several hundred lines of beach line and event queue to save + * milliseconds nobody is waiting on. + */ + +/** Point on the XZ plane. Local to this module; the generator has no shared 2-D point. */ +export interface Pt { + x: number; + z: number; +} + +/** Target distance between seeds — roughly the width of a resulting cell. */ +export const VORONOI_SPACING = 60; + +/** + * How far a seed strays from its lattice position, as a fraction of the spacing. + * + * Seeds on a perfect lattice give a perfect honeycomb, which is as machine-made as the + * grid. Fully random seeds clump, and clumped seeds give slivers — cells too thin to + * hold anything. A jittered lattice keeps the cells similar in size while making no two + * alike. + */ +export const VORONOI_JITTER = 0.45; + +/** Cells thinner than this are dropped; nothing can be built on a splinter. */ +const MIN_CELL_AREA = 200; + +/** Points closer together than this are treated as one, when matching shared edges. */ +const WELD = 0.01; + +/** + * Signed distance to the perpendicular bisector of `a`–`b`, negative on `a`'s side. + * + * |p−a|² ≤ |p−b|² expands to a linear test, which is what makes the clip cheap: no + * square roots and no special case for a vertical bisector. + */ +function bisectorSide(p: Pt, a: Pt, b: Pt): number { + return ( + 2 * (b.x - a.x) * p.x + + 2 * (b.z - a.z) * p.z - + (b.x * b.x + b.z * b.z - a.x * a.x - a.z * a.z) + ); +} + +/** Sutherland–Hodgman clip of a convex polygon to the `a` side of the a–b bisector. */ +function clipToBisector(poly: Pt[], a: Pt, b: Pt): Pt[] { + const out: Pt[] = []; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + const fp = bisectorSide(p, a, b); + const fq = bisectorSide(q, a, b); + if (fp <= 0) out.push(p); + // Crossing the bisector: emit the point where the edge meets it. + if (fp <= 0 !== fq <= 0) { + const t = fp / (fp - fq); + out.push({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t }); + } + } + return out; +} + +/** Twice the signed area; the sign gives winding. */ +function area2(poly: Pt[]): number { + let s = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + s += p.x * q.z - q.x * p.z; + } + return s; +} + +export function polygonArea(poly: Pt[]): number { + return Math.abs(area2(poly)) / 2; +} + +/** + * Area centroid — not the average of the vertices. + * + * The vertex average pulls towards whichever side has more of them, which on a cell + * with one long edge chopped into several puts the centre off in a corner. + */ +export function centroid(poly: Pt[]): Pt { + const a2 = area2(poly); + if (Math.abs(a2) < 1e-9) { + // Degenerate: fall back to the vertex average rather than dividing by zero. + const n = poly.length || 1; + return { + x: poly.reduce((s, p) => s + p.x, 0) / n, + z: poly.reduce((s, p) => s + p.z, 0) / n, + }; + } + let cx = 0; + let cz = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + const cross = p.x * q.z - q.x * p.z; + cx += (p.x + q.x) * cross; + cz += (p.z + q.z) * cross; + } + return { x: cx / (3 * a2), z: cz / (3 * a2) }; +} + +/** True when a point is inside a convex polygon, whatever its winding. */ +function insideConvex(poly: Pt[], p: Pt): boolean { + const sign = Math.sign(area2(poly)); + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + const cross = (b.x - a.x) * (p.z - a.z) - (b.z - a.z) * (p.x - a.x); + if (Math.sign(cross) === -sign && cross !== 0) return false; + } + return true; +} + +/** + * The largest axis-aligned rectangle that fits in a cell, centred on its centroid. + * + * This is what lets an irregular cell feed machinery that only understands + * `{x, z, w, d}`. The buildings inside stay rectangular and the existing plot filler + * is untouched; what changes is the *street pattern*, which is where nearly all of the + * visual payoff lives. The rectangle rarely fills its cell, so setbacks vary naturally + * from plot to plot — a side effect worth having. + * + * Found by scaling the cell's bounding box about the centroid until the corners fit. + * The cell is convex, so four corners inside means the whole rectangle is inside. + */ +export function inscribedRect(poly: Pt[]): { x: number; z: number; w: number; d: number } { + const c = centroid(poly); + const halfW = Math.max(...poly.map((p) => Math.abs(p.x - c.x))); + const halfD = Math.max(...poly.map((p) => Math.abs(p.z - c.z))); + + const fits = (k: number) => + insideConvex(poly, { x: c.x - halfW * k, z: c.z - halfD * k }) && + insideConvex(poly, { x: c.x + halfW * k, z: c.z - halfD * k }) && + insideConvex(poly, { x: c.x - halfW * k, z: c.z + halfD * k }) && + insideConvex(poly, { x: c.x + halfW * k, z: c.z + halfD * k }); + + let lo = 0; + let hi = 1; + // Twelve halvings resolve to a thousandth of the cell, far finer than a wall. + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + if (fits(mid)) lo = mid; + else hi = mid; + } + return { x: c.x, z: c.z, w: halfW * lo * 2, d: halfD * lo * 2 }; +} + +/** Seed points on a jittered lattice covering the bounds. */ +export function seedPoints(bounds: Bounds, rng: Rng, spacing = VORONOI_SPACING): Pt[] { + const { minX, minZ, width, depth } = normalizeBounds(bounds); + const cols = Math.max(1, Math.round(width / spacing)); + const rows = Math.max(1, Math.round(depth / spacing)); + const cw = width / cols; + const cd = depth / rows; + + const pts: Pt[] = []; + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + pts.push({ + x: minX + (i + 0.5) * cw + (rng() - 0.5) * cw * VORONOI_JITTER * 2, + z: minZ + (j + 0.5) * cd + (rng() - 0.5) * cd * VORONOI_JITTER * 2, + }); + } + } + return pts; +} + +/** + * Voronoi cells for a set of seeds, clipped to the bounds. + * + * Each cell starts as the whole rectangle and is clipped by the bisector against every + * other seed. That is O(n²) — for the ~100 seeds a city uses, a few thousand clips of a + * handful of vertices each, which is nothing next to filling the plots afterwards. + */ +export function voronoiCells(bounds: Bounds, seeds: Pt[]): { seed: Pt; poly: Pt[] }[] { + const { minX, maxX, minZ, maxZ } = normalizeBounds(bounds); + const frame: Pt[] = [ + { x: minX, z: minZ }, + { x: maxX, z: minZ }, + { x: maxX, z: maxZ }, + { x: minX, z: maxZ }, + ]; + + const cells: { seed: Pt; poly: Pt[] }[] = []; + for (const seed of seeds) { + let poly = frame; + for (const other of seeds) { + if (other === seed) continue; + poly = clipToBisector(poly, seed, other); + if (poly.length < 3) break; + } + if (poly.length >= 3 && polygonArea(poly) >= MIN_CELL_AREA) cells.push({ seed, poly }); + } + return cells; +} + +/** An undirected cell edge, keyed so the two cells sharing it agree on one road. */ +function edgeKey(a: Pt, b: Pt): string { + const r = (v: number) => Math.round(v / WELD); + const ka = `${r(a.x)},${r(a.z)}`; + const kb = `${r(b.x)},${r(b.z)}`; + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; +} + +/** + * The distinct edges of a set of cells. + * + * Every interior edge is shared by exactly two cells, so without this the whole network + * would be laid twice — doubling the road count and giving consolidation a pile of + * exact duplicates to reconcile. + */ +export function cellEdges(cells: { poly: Pt[] }[]): { a: Pt; b: Pt }[] { + const seen = new Set(); + const edges: { a: Pt; b: Pt }[] = []; + for (const { poly } of cells) { + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + const key = edgeKey(a, b); + if (seen.has(key)) continue; + seen.add(key); + edges.push({ a, b }); + } + } + return edges; +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 5908cf2..a86e7c3 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -12,6 +12,7 @@ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ { value: 'GRID', label: 'GRID — PLANNED, SQUARE BLOCKS' }, { value: 'SUPERBLOCK', label: 'SUPERBLOCK — TOWER IN PARK' }, { value: 'RING', label: 'RING — BELTWAYS AND SPOKES' }, + { value: 'VORONOI', label: 'ORGANIC_CELLS — GREW, NOT PLANNED' }, ]; /** diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 38019eb..014d78c 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -693,7 +693,7 @@ describe('AdminPanel layout selector', () => { it('offers every layout', () => { render(); const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; - expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING']); + expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING', 'VORONOI']); }); it('defaults to the organic layout, so generation is unchanged out of the box', () => { From fe868dd15b2137bd2a18e06e91a26399d25455c6 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:20:35 -0500 Subject: [PATCH 24/40] feat(citygen): roundabouts where major roads meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ROUNDABOUTS selector — OFF, SPARSE, NORMAL — off by default. It is an overlay on the finished road network rather than a sixth layout, so one implementation serves all five instead of each growing its own. That also means it composes with WATER, PARK_PONDS and the drawn boundary the same way bridges already do. The reuse that made it small: as far as roads are concerned a roundabout island is a tiny lake. clipSegmentToLand already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it. The ring itself is the arc sampling RING uses for its beltways. Neither piece is new. Siting has to handle two kinds of junction. BSP and Voronoi networks join at shared endpoints, but GRID lays each street as one full-length span, so its crossings share no endpoint at all and exist only as intersections — and GRID is the layout most obviously wanting roundabouts. segmentCrossing was promoted out of the water clipper to find them. Ordering: the pass runs after consolidateRoads. Consolidation snaps endpoints within a few units of each other, and a ring is many short segments with close endpoints, so running it first snaps the circle into a blob. It runs after bridge siting too, so the shore stubs bridges pair from are the ones the layout actually left at the water. Islands are dressed rather than left bare — a monument from the landmark styles where there is room, trees otherwise. An empty disc reads as a hole in the road network, not a junction. Sited only at junctions of arterials, spaced by the radius of what is already placed so a wide roundabout keeps a larger berth without a second constant to keep in step, and kept out of water and outside a drawn boundary. OFF draws no randomness, so an existing seed is unaffected. --- CHANGELOG.md | 2 + README.md | 2 + frontend/src/App.tsx | 5 +- .../src/cityGen/__tests__/roundabouts.test.ts | 257 ++++++++++++++++++ frontend/src/cityGen/index.ts | 55 +++- frontend/src/cityGen/roundabouts.ts | 170 ++++++++++++ frontend/src/cityGen/types.ts | 6 + frontend/src/cityGen/water.ts | 15 + frontend/src/components/AdminPanel.tsx | 20 +- 9 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/roundabouts.test.ts create mode 100644 frontend/src/cityGen/roundabouts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6c350..fa7fba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. - **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. - **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. +- **Roundabouts** — a `ROUNDABOUTS` selector (`OFF` / `SPARSE` / `NORMAL`) putting a circus where major roads meet. It is an overlay on the finished road network rather than another layout, so one implementation serves all five: approaches are cut back to the ring, the ring is laid, and the island gets a monument where there is room for one or a stand of trees otherwise. An empty disc reads as a hole in the network rather than a junction, so every island gets something. - **Seeded generation** — an optional `SEED` field. The same seed over the same area with the same options rebuilds the same city, so a map can be recreated or shared as a short string. Leaving it blank rolls a fresh seed, and the seed actually used is reported back beneath the field rather than written into it. - **`REGENERATE`** — clears the previous generation in the selected area and builds afresh, for iterating on a district without hand-deleting it first. It keeps anything the GM authored: named structures, tokens, battle-map content and hand-drawn water all survive. Plain `GENERATE` still adds to what is there. - **`UNDO` on the generator panel** — the same server-side undo as the admin header, reachable without leaving the panel. @@ -49,6 +50,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **A roundabout island is a tiny lake, as far as roads are concerned.** `clipSegmentToLand` already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it — so trimming reuses the water clipper rather than a second implementation, and the ring reuses the arc sampling `RING` uses for its beltways. Siting has to handle two kinds of junction: a BSP or Voronoi network joins at shared endpoints, but `GRID` lays each street as one full-length span, so its crossings share no endpoint and are found only by intersecting segments — `segmentCrossing` was promoted out of the water clipper for that. The whole pass runs *after* `consolidateRoads`, which snaps nearby endpoints together and would otherwise snap a ring of short segments into a blob. - **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice — a perfect lattice gives a honeycomb as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. - **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means the road grid stops at the banks of its own accord and bridges are sited from the stubs left there — generating it afterwards would mean cutting finished roads, which is a different and worse problem. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. - **`water_bodies` gains a `generated` column**, so a regenerate can clear its own river without destroying a lake the GM drew. Existing rows default to `0`, so everything already on a map counts as hand-drawn. The migration runs on startup — a server on older code will accept generated water but store it as hand-drawn. diff --git a/README.md b/README.md index a471173..b1db3a2 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,7 @@ CITY_NET/ │ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them │ │ │ ├── bridges.ts # Shore-stub pairing, span/grade limits, deck levelling by graph colouring, OVERPASS_DENSITY +│ │ │ ├── roundabouts.ts # Overlay on a finished network, so it works with every layout — junction finding (shared endpoints and true crossings), siting by width/spacing/density, and approach trimming via the water clipper │ │ │ ├── rng.ts # seededRng (mulberry32), randomSeed, and seedFrom — hashes any typed seed into range instead of truncating it │ │ │ ├── region.ts # Region membership test and generated-content count, shared by the panel and REGENERATE │ │ │ └── __tests__/ @@ -423,6 +424,7 @@ CITY_NET/ │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks │ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned +│ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout │ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels │ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after │ │ │ ├── parkPonds.test.ts # Pond shape and size, containment in the plot, trees standing back from the water, and identical roads with ponds on or off diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70b7fd7..4502070 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,7 +59,7 @@ import { Sidewalks } from './components/Sidewalks'; import { AutoSignage } from './components/AutoSignage'; import { Signs, type SignData } from './components/Signs'; import { type RemoteFont } from './utils/fontLoader'; -import type { LayoutType, WaterType } from './cityGen'; +import type { LayoutType, WaterType, RoundaboutDensity } from './cityGen'; import { GlobalCameraCapture, CursorPivotControls, CameraController, KeyboardPan } from './components/Camera'; import { AdminPanel } from './components/AdminPanel'; import MapExportController, { type MapExportApi } from './components/MapExportController'; @@ -376,6 +376,7 @@ function App() { // a river through the city of everyone already using the button. const [cityWater, setCityWater] = useState('NONE'); const [cityParkPonds, setCityParkPonds] = useState(false); + const [cityRoundabouts, setCityRoundabouts] = useState('off'); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1816,6 +1817,8 @@ function App() { setCityWater={setCityWater} cityParkPonds={cityParkPonds} setCityParkPonds={setCityParkPonds} + cityRoundabouts={cityRoundabouts} + setCityRoundabouts={setCityRoundabouts} lastCitySeed={lastCitySeed} setLastCitySeed={setLastCitySeed} cityLayout={cityLayout} diff --git a/frontend/src/cityGen/__tests__/roundabouts.test.ts b/frontend/src/cityGen/__tests__/roundabouts.test.ts new file mode 100644 index 0000000..e3471e2 --- /dev/null +++ b/frontend/src/cityGen/__tests__/roundabouts.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from 'vitest'; +import { + findJunctions, siteRoundabouts, applyRoundabouts, ringPolygon, + segmentCrossing, generateCity, pointInPolygon, + MIN_ARTERIAL_WIDTH, RING_WIDTH, SPACING_RADII, +} from '../index'; +import type { RoadSegment } from '../types'; + +/** + * Roundabouts. + * + * An overlay on a finished road network, not a layout, so these test it against roads + * given directly rather than through a particular layout — that is the point of it + * being an overlay. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +/** A crossroads of two wide roads, sharing no endpoint — the grid's case. */ +const cross = (x = 0, z = 0, width = 8): RoadSegment[] => [ + { x1: x - 100, z1: z, x2: x + 100, z2: z, width }, + { x1: x, z1: z - 100, x2: x, z2: z + 100, width }, +]; + +describe('segmentCrossing', () => { + it('finds where two segments cross', () => { + const hit = segmentCrossing( + { x1: -10, z1: 0, x2: 10, z2: 0, width: 5 }, + { x1: 0, z1: -10, x2: 0, z2: 10, width: 5 } + ); + expect(hit?.x).toBeCloseTo(0); + expect(hit?.z).toBeCloseTo(0); + }); + + it('returns null for segments that miss', () => { + expect(segmentCrossing( + { x1: -10, z1: 0, x2: -5, z2: 0, width: 5 }, + { x1: 0, z1: -10, x2: 0, z2: 10, width: 5 } + )).toBeNull(); + }); + + it('returns null for parallel segments', () => { + expect(segmentCrossing( + { x1: -10, z1: 0, x2: 10, z2: 0, width: 5 }, + { x1: -10, z1: 5, x2: 10, z2: 5, width: 5 } + )).toBeNull(); + }); +}); + +describe('findJunctions', () => { + it('finds a crossing where no endpoint is shared', () => { + // gridLayout lays each street as one full-length span, so its intersections exist + // only as crossings. Missing these would leave the grid without roundabouts. + const j = findJunctions(cross()); + expect(j).toHaveLength(1); + expect(j[0].x).toBeCloseTo(0); + expect(j[0].z).toBeCloseTo(0); + }); + + it('ignores junctions of minor roads', () => { + // A roundabout is a junction of arterials; on a side street it is street furniture. + expect(findJunctions(cross(0, 0, MIN_ARTERIAL_WIDTH - 1))).toHaveLength(0); + }); + + it('reports the wider of the two roads', () => { + const [j] = findJunctions([ + { x1: -100, z1: 0, x2: 100, z2: 0, width: 6 }, + { x1: 0, z1: -100, x2: 0, z2: 100, width: 9 }, + ]); + expect(j.width).toBe(9); + }); +}); + +describe('siteRoundabouts', () => { + it('places none when off', () => { + expect(siteRoundabouts(cross(), 'off', seededRng())).toHaveLength(0); + }); + + it('draws no randomness when off, so an existing seed is unaffected', () => { + const a = seededRng(5); + const b = seededRng(5); + siteRoundabouts(cross(), 'off', a); + expect(a()).toBe(b()); + }); + + it('places fewer when sparse than when normal', () => { + // Twenty junctions, so the difference is a share rather than a coin flip. + const roads: RoadSegment[] = []; + for (let i = 0; i < 20; i++) { + roads.push({ x1: -500, z1: i * 200 - 2000, x2: 500, z2: i * 200 - 2000, width: 8 }); + roads.push({ x1: i * 200 - 2000, z1: -500, x2: i * 200 - 2000, z2: 500, width: 8 }); + } + const sparse = siteRoundabouts(roads, 'sparse', seededRng(3)).length; + const normal = siteRoundabouts(roads, 'normal', seededRng(3)).length; + expect(normal).toBeGreaterThanOrEqual(sparse); + }); + + it('keeps them apart', () => { + const roads: RoadSegment[] = []; + for (let i = 0; i < 12; i++) { + roads.push({ x1: -400, z1: i * 12 - 100, x2: 400, z2: i * 12 - 100, width: 8 }); + roads.push({ x1: i * 12 - 100, z1: -400, x2: i * 12 - 100, z2: 400, width: 8 }); + } + const placed = siteRoundabouts(roads, 'normal', seededRng()); + for (let i = 0; i < placed.length; i++) { + for (let j = i + 1; j < placed.length; j++) { + const gap = Math.hypot(placed[i].x - placed[j].x, placed[i].z - placed[j].z); + expect(gap).toBeGreaterThanOrEqual( + Math.max(placed[i].radius, placed[j].radius) * SPACING_RADII - 1e-6 + ); + } + } + }); + + it('keeps them out of the water', () => { + const lake = { points: [ + { x: -50, z: -50 }, { x: 50, z: -50 }, { x: 50, z: 50 }, { x: -50, z: 50 }, + ] }; + expect(siteRoundabouts(cross(), 'normal', seededRng(), [lake])).toHaveLength(0); + }); + + it('keeps them inside a drawn boundary', () => { + const boundary = { points: [ + { x: 200, z: 200 }, { x: 300, z: 200 }, { x: 300, z: 300 }, { x: 200, z: 300 }, + ] }; + expect(siteRoundabouts(cross(), 'normal', seededRng(), [], boundary)).toHaveLength(0); + }); + + it('reproduces from a seed', () => { + expect(siteRoundabouts(cross(), 'normal', seededRng(9))) + .toEqual(siteRoundabouts(cross(), 'normal', seededRng(9))); + }); +}); + +describe('applyRoundabouts', () => { + const one = [{ x: 0, z: 0, radius: 15 }]; + + it('returns the roads untouched when there are none', () => { + const roads = cross(); + expect(applyRoundabouts(roads, [])).toBe(roads); + }); + + it('cuts the approaches back to the ring', () => { + // Without this the arterials run straight through the island and the roundabout is + // a decoration painted over a crossroads. + const out = applyRoundabouts(cross(), one); + const ring = ringPolygon(one[0]); + for (const r of out) { + if (r.width === RING_WIDTH) continue; // the ring itself + const mid = { x: (r.x1 + r.x2) / 2, z: (r.z1 + r.z2) / 2 }; + expect(pointInPolygon(ring, mid.x, mid.z)).toBe(false); + } + }); + + it('leaves the approaches reaching the ring', () => { + // Trimmed too far and the roundabout is an island with no roads touching it. + const out = applyRoundabouts(cross(), one).filter(r => r.width !== RING_WIDTH); + const touching = out.filter(r => + [[r.x1, r.z1], [r.x2, r.z2]].some(([x, z]) => + Math.abs(Math.hypot(x, z) - one[0].radius) < 1.5)); + expect(touching.length).toBe(4); + }); + + it('lays the ring itself', () => { + const out = applyRoundabouts(cross(), one); + const ring = out.filter(r => r.width === RING_WIDTH); + expect(ring.length).toBeGreaterThanOrEqual(6); + // Every ring segment sits at the radius, which is what makes it read as a circle. + for (const r of ring) { + expect(Math.hypot(r.x1, r.z1)).toBeCloseTo(one[0].radius, 1); + } + }); + + it('closes the ring', () => { + // An open arc would leave traffic driving off the end of a curve. + const ring = applyRoundabouts(cross(), one).filter(r => r.width === RING_WIDTH); + const ends = new Map(); + for (const r of ring) { + for (const k of [`${r.x1.toFixed(3)},${r.z1.toFixed(3)}`, `${r.x2.toFixed(3)},${r.z2.toFixed(3)}`]) { + ends.set(k, (ends.get(k) ?? 0) + 1); + } + } + for (const count of ends.values()) expect(count).toBe(2); + }); +}); + +describe('generateCity with roundabouts', () => { + const opts = (extra = {}) => ({ + sectionType: 'MIXED' as const, excludeRoads: false, layout: 'GRID' as const, ...extra, + }); + + it('makes none by default', () => { + const a = generateCity(bounds(400), opts(), freshContext(), seededRng(21), deps); + const b = generateCity( + bounds(400), opts({ roundabouts: 'off' }), freshContext(), seededRng(21), deps + ); + expect(a.roads).toEqual(b.roads); + }); + + it('changes the road network when asked for', () => { + const off = generateCity(bounds(400), opts(), freshContext(), seededRng(21), deps); + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + expect(on.roads).not.toEqual(off.roads); + }); + + it('dresses the islands rather than leaving holes', () => { + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + expect(on.buildings.some(b => b.temp_block_id?.startsWith('gen_circus_'))).toBe(true); + }); + + it('reproduces from a seed', () => { + const a = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(8), deps + ); + const b = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(8), deps + ); + expect(a.roads).toEqual(b.roads); + expect(a.buildings).toEqual(b.buildings); + }); + + it('works on every layout, being an overlay rather than one of them', () => { + for (const layout of ['BSP', 'GRID', 'RING', 'VORONOI'] as const) { + const res = generateCity( + bounds(400), opts({ layout, roundabouts: 'normal' }), freshContext(), seededRng(4), deps + ); + expect(res.roads.length, layout).toBeGreaterThan(0); + } + }); + + it('makes none when roads are excluded', () => { + const res = generateCity( + bounds(400), opts({ excludeRoads: true, roundabouts: 'normal' }), + freshContext(), seededRng(21), deps + ); + expect(res.roads).toHaveLength(0); + expect(res.buildings.some(b => b.temp_block_id?.startsWith('gen_circus_'))).toBe(false); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 350ded0..89496ab 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -19,8 +19,10 @@ import { shouldPlaceLandmark, generateLandmark } from './landmarks'; import { parseWaterBodies, pointInWater, footprintInWater, clipSegmentToBoundary } from './water'; import type { Polygon } from './water'; import { findBridges } from './bridges'; +import { siteRoundabouts, applyRoundabouts, RING_WIDTH } from './roundabouts'; import { generateShorelineRoads, snapRoadEndsToShoreline } from './shoreline'; import type { + Block, Bounds, GenerateCityContext, GenerateCityOptions, @@ -45,6 +47,7 @@ export { BRIDGE_HEIGHTS, MIN_RAMP_RUN, MAX_RAMP_RUN, } from './bridges'; export { generateShorelineRoads, snapRoadEndsToShoreline, SHORE_OFFSET } from './shoreline'; +export * from './roundabouts'; /** Margin trimmed off every block so buildings don't butt against the road. */ const PLOT_PADDING = 10; @@ -52,6 +55,18 @@ const PLOT_PADDING = 10; /** Plots smaller than this after padding are left empty. */ const MIN_PLOT_SIZE = 8; +/** Fraction of a roundabout's inner disc actually built on, leaving a verge. */ +const ISLAND_COVERAGE = 0.8; + +/** Islands smaller than this are left as bare pavement; nothing reads at that size. */ +const MIN_ISLAND_SPAN = 4; + +/** A monument needs room to look deliberate; below this the island gets trees. */ +const MIN_MONUMENT_SPAN = 12; + +/** How often an island large enough for one gets a monument rather than trees. */ +const ISLAND_MONUMENT_CHANCE = 0.45; + /** How aggressively new roads snap onto existing ones. */ const ROAD_CONSOLIDATION_RADIUS = 3.0; @@ -81,7 +96,7 @@ export function generateCity( rng: Rng = Math.random, deps: GenerateCityDeps = DEFAULT_DEPS ): GenerateCityResult { - const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE', parkPonds = false } = options; + const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE', parkPonds = false, roundabouts: roundaboutDensity = 'off' } = options; // Fewer than three points cannot enclose an area. Treating a degenerate boundary as // absent falls back to the plain bounds, rather than generating nothing at all and // looking like a broken button. @@ -128,12 +143,21 @@ export function generateCity( ? [] : [...layoutOverpasses, ...findBridges(finalRoads, water, overpassDensity, rng)]; + // Roundabouts come after consolidation, which snaps nearby endpoints together — a + // ring is many short segments with close endpoints, and running it first would snap + // the circle into a blob. After bridge siting too, so the shore stubs bridges are + // paired from are the ones the layout actually left at the water. + const roundabouts = excludeRoads + ? [] + : siteRoundabouts(finalRoads, roundaboutDensity, rng, water, boundary); + const roadsWithRoundabouts = applyRoundabouts(finalRoads, roundabouts, boundary); + const grid = new SpatialGrid(context.locations); // Test against the roads that will actually exist. Consolidation snaps // endpoints onto existing roads and onto each other, so the pre-consolidation // seams are not where the pavement ends up — checking those instead lets // buildings land on roads that moved underneath them. - const roadsToCheck = [...context.roads, ...finalRoads]; + const roadsToCheck = [...context.roads, ...roadsWithRoundabouts]; const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water, boundary); const buildings: RawBuilding[] = []; @@ -237,12 +261,37 @@ export function generateCity( tagPlot(zonePrefix); }); + // Dress each island. An empty disc reads as a hole in the road network rather than a + // roundabout, so every one gets something: a monument where there is room for one, + // trees otherwise. Done after the blocks so the islands are laid over a finished city + // — they sit where roads were cut away, which no block ever claimed. + roundabouts.forEach((r, i) => { + const span = Math.max(0, (r.radius - RING_WIDTH) * 2 * ISLAND_COVERAGE); + if (span < MIN_ISLAND_SPAN) return; + const island: Block = { x: r.x, z: r.z, w: span, d: span }; + const plotId = `gen_circus_${i}`; + const startIndex = buildings.length; + + // Drawn unconditionally so the sequence does not depend on how large the island is. + const wantsMonument = rng() < ISLAND_MONUMENT_CHANCE; + if (wantsMonument && span >= MIN_MONUMENT_SPAN) { + generateLandmark(island, span, span, buildings, grid, rng); + } else { + generatePark(island, span, span, buildings, isBlocked, rng, false); + } + + for (let k = startIndex; k < buildings.length; k++) { + buildings[k].temp_block_id = plotId; + if (!buildings[k].name) buildings[k].name = 'CIRCUS'; + } + }); + // Placement ignores overpasses so the ground beneath stays buildable; nothing there // stops a tower rising through a deck, so anything under one is capped just below it. // Applies to water bridges too, which pierce buildings for the same reason. return { blocks, - roads: finalRoads, + roads: roadsWithRoundabouts, buildings: clampBuildingsUnderDecks(buildings, overpasses), overpasses, waterBodies: [...generatedWater, ...pondPolys], diff --git a/frontend/src/cityGen/roundabouts.ts b/frontend/src/cityGen/roundabouts.ts new file mode 100644 index 0000000..7ebcad2 --- /dev/null +++ b/frontend/src/cityGen/roundabouts.ts @@ -0,0 +1,170 @@ +import type { RoadSegment, Rng } from './types'; +import { + type Polygon, type WaterPolygon, + clipSegmentToLand, clipSegmentToBoundary, pointInWater, pointInPolygon, segmentCrossing, +} from './water'; + +/** + * Roundabouts. + * + * An overlay on a finished road network rather than a layout, in the same way bridges + * are. That means one implementation serves every layout, instead of five. + * + * The observation that makes this cheap: as far as roads are concerned, a roundabout + * island is a tiny lake. `clipSegmentToLand` already cuts a segment out of a polygon + * and leaves the approaches stopping at its edge — which is exactly what a junction + * does to the roads meeting it. The ring itself is the same arc sampling `RING` uses + * for its beltways. Neither piece is new. + * + * **Ordering matters.** This has to run *after* `consolidateRoads`. Consolidation snaps + * endpoints within a few units of each other, and a ring is many short segments with + * close endpoints — run it first and the circle is snapped into a blob. + */ + +export type RoundaboutDensity = 'off' | 'sparse' | 'normal'; + +/** A sited roundabout: where it is and how big, so the caller can dress the island. */ +export interface Roundabout { + x: number; + z: number; + /** Radius of the ring road's centreline. */ + radius: number; +} + +/** Roads narrower than this do not warrant a roundabout — it is a junction of arterials. */ +const MIN_ARTERIAL_WIDTH = 5; + +/** Ring radius, as a multiple of the widest road meeting it. */ +const RADIUS_FROM_ROAD = 2.6; +const MIN_RADIUS = 9; +const MAX_RADIUS = 22; + +/** Two roundabouts closer than this many radii read as one mistake, not two junctions. */ +const SPACING_RADII = 4; + +/** Degrees between sampled points on the ring. */ +const RING_STEP_DEG = 20; + +/** Ring roads are a lane and a bit — narrower than the arterials they join. */ +const RING_WIDTH = 5; + +/** Fraction of eligible junctions that actually get one, per density. */ +const DENSITY_SHARE: Record = { + off: 0, + sparse: 0.25, + normal: 0.6, +}; + +/** + * Junctions in a road network. + * + * Two kinds, because layouts differ in how they meet. BSP and VORONOI join at shared + * endpoints; `gridLayout` lays each street as one full-length span, so its crossings + * share no endpoint at all and are found only by intersecting the segments. Missing the + * second kind would mean the grid — the layout most obviously wanting roundabouts — + * never got one. + */ +export function findJunctions(roads: RoadSegment[]): { x: number; z: number; width: number }[] { + const arterials = roads.filter((r) => (r.width ?? 0) >= MIN_ARTERIAL_WIDTH); + const out: { x: number; z: number; width: number }[] = []; + + for (let i = 0; i < arterials.length; i++) { + for (let j = i + 1; j < arterials.length; j++) { + const a = arterials[i]; + const b = arterials[j]; + const hit = segmentCrossing(a, b); + if (!hit) continue; + out.push({ x: hit.x, z: hit.z, width: Math.max(a.width ?? 0, b.width ?? 0) }); + } + } + return out; +} + +/** + * Choose which junctions become roundabouts. + * + * Sited away from water, spaced apart, and thinned by density. The spacing test uses + * the radius of what is already placed, so a wide roundabout keeps a larger berth than + * a narrow one without a second constant to keep in step. + */ +export function siteRoundabouts( + roads: RoadSegment[], + density: RoundaboutDensity, + rng: Rng, + water: WaterPolygon[] = [], + boundary?: Polygon +): Roundabout[] { + if (density === 'off') return []; + const share = DENSITY_SHARE[density] ?? 0; + if (share <= 0) return []; + + const placed: Roundabout[] = []; + + for (const j of findJunctions(roads)) { + // Drawn first and unconditionally, so the sequence does not depend on how many + // junctions happen to be eligible — the same rule the landmark roll follows. + const roll = rng(); + if (roll >= share) continue; + if (water.length > 0 && pointInWater(water, j.x, j.z)) continue; + if (boundary && !pointInPolygon(boundary, j.x, j.z)) continue; + + const radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, j.width * RADIUS_FROM_ROAD)); + const tooClose = placed.some( + (p) => Math.hypot(p.x - j.x, p.z - j.z) < Math.max(p.radius, radius) * SPACING_RADII + ); + if (tooClose) continue; + + placed.push({ x: j.x, z: j.z, radius }); + } + + return placed; +} + +/** The ring as a closed polygon — both the road to lay and the hole to cut. */ +export function ringPolygon(r: Roundabout): Polygon { + const step = (RING_STEP_DEG * Math.PI) / 180; + const steps = Math.max(6, Math.ceil((Math.PI * 2) / step)); + const points: { x: number; z: number }[] = []; + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + points.push({ x: r.x + Math.cos(a) * r.radius, z: r.z + Math.sin(a) * r.radius }); + } + return { points }; +} + +/** + * Cut the approaches back to each ring and lay the rings themselves. + * + * The trim reuses the water clipper: every roundabout is passed as a polygon to cut + * *out* of the network, exactly as a lake would be. Without it the arterials would run + * straight through the island and the roundabout would read as a decoration painted + * over a crossroads. + */ +export function applyRoundabouts( + roads: RoadSegment[], + roundabouts: Roundabout[], + boundary?: Polygon +): RoadSegment[] { + if (roundabouts.length === 0) return roads; + + const islands = roundabouts.map(ringPolygon); + + const out: RoadSegment[] = []; + for (const road of roads) { + out.push(...clipSegmentToLand(road, islands)); + } + + for (const r of roundabouts) { + const pts = ringPolygon(r).points; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const seg: RoadSegment = { x1: a.x, z1: a.z, x2: b.x, z2: b.z, width: RING_WIDTH }; + out.push(...clipSegmentToBoundary(seg, boundary)); + } + } + + return out; +} + +export { MIN_ARTERIAL_WIDTH, RADIUS_FROM_ROAD, MIN_RADIUS, MAX_RADIUS, RING_WIDTH, SPACING_RADII, DENSITY_SHARE }; diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index ff19636..9d8f645 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -30,6 +30,7 @@ import type { OverpassDensity, OverpassSpec } from './bridges'; import type { Polygon } from './water'; import type { LayoutType } from './layouts'; import type { WaterType } from './waterGen'; +import type { RoundaboutDensity } from './roundabouts'; export type { OverpassDensity, OverpassSpec }; /** Zoning preset chosen in the admin panel. */ @@ -89,6 +90,11 @@ export interface GenerateCityOptions { * GM may well want one without the other. Off by default, on the same reasoning. */ parkPonds?: boolean; + /** + * Put roundabouts at junctions of major roads. An overlay on the finished network + * rather than a layout, so it applies whichever layout is chosen. Defaults to 'off'. + */ + roundabouts?: RoundaboutDensity; /** When true, no roads are generated and road collision is skipped. */ excludeRoads: boolean; /** How freely roads bridge the water they cross. Defaults to 'normal'. */ diff --git a/frontend/src/cityGen/water.ts b/frontend/src/cityGen/water.ts index f2e2957..a90893f 100644 --- a/frontend/src/cityGen/water.ts +++ b/frontend/src/cityGen/water.ts @@ -139,6 +139,21 @@ function crossingParam( return t; } +/** + * Where two segments cross, or null if they do not. + * + * Layouts differ in how their roads meet: a BSP or Voronoi network joins at shared + * endpoints, but `gridLayout` lays each street as one full-length span, so its + * crossings share no endpoint and exist only as intersections. Anything siting features + * at junctions needs both, which is why this is exposed rather than kept private to the + * water clipper. + */ +export function segmentCrossing(a: RoadSegment, b: RoadSegment): { x: number; z: number } | null { + const t = crossingParam(a.x1, a.z1, a.x2, a.z2, b.x1, b.z1, b.x2, b.z2); + if (t === null) return null; + return pointAt(a, t); +} + /** * Find the stretches of a road segment that run through water. * diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index a86e7c3..3ec4bff 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { isUserDefinedName, getStructLabel } from '../utils/locationHelpers'; import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from './Buildings'; -import { generateCity, SpatialGrid, seededRng, seedFrom, countGeneratedInRegion, type SectionType, type OverpassDensity, type LayoutType, type WaterType } from '../cityGen'; +import { generateCity, SpatialGrid, seededRng, seedFrom, countGeneratedInRegion, type SectionType, type OverpassDensity, type LayoutType, type WaterType, type RoundaboutDensity } from '../cityGen'; /** Street layouts offered in the generator, with what each one reads as. */ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ @@ -605,7 +605,7 @@ export function AdminPanel({ activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, - cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, cityParkPonds, setCityParkPonds, + cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, cityParkPonds, setCityParkPonds, cityRoundabouts, setCityRoundabouts, }: any) { if (view === 'battle_map') { return ( @@ -1008,6 +1008,7 @@ export function AdminPanel({ layout: cityLayout ?? 'BSP', water: cityWater ?? 'NONE', parkPonds: !!cityParkPonds, + roundabouts: cityRoundabouts ?? 'off', boundary: drawing ? { points: tracedPoints } : undefined, }, { locations: worldLocations, roads: worldRoads, waterBodies }, @@ -1844,6 +1845,21 @@ export function AdminPanel({ title="Give some parks a pond. Independent of WATER — a pond sits inside its own plot and does not reshape the street grid." onClick={() => setCityParkPonds?.(!cityParkPonds)} >{cityParkPonds ? 'PARK_PONDS: ON' : 'PARK_PONDS: OFF'} + +
+ {(['off', 'sparse', 'normal'] as RoundaboutDensity[]).map(d => ( + + ))} +
Date: Sat, 1 Aug 2026 22:25:21 -0500 Subject: [PATCH 25/40] fix(citygen): REGENERATE built around the river it deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerating with a water type selected left a band of empty ground tracing where the previous river ran — roads and buildings avoiding water that was no longer there. The purge already re-read locations and roads before generating, for exactly this reason: placement tests against what exists, so a stale list makes the new city avoid buildings that are gone. Water was simply never added to that refetch when generated water went in, so generation saw the old river and the new one at once. The test asserts the water refetch happens after the purge, and fails without the fix — before it there is no GET /api/water in the sequence at all. --- CHANGELOG.md | 1 + frontend/src/components/AdminPanel.tsx | 11 +++++++++-- .../src/components/__tests__/AdminPanel.test.tsx | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7fba1..354b3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. +- **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. - **A typed seed is used as typed.** Parsing forced the value through `>>> 0`, which wrapped anything above 2³², so a long numeric seed silently became a different one. Seeds are hashed into range instead. - **`REGENERATE` rolls a new seed** unless one is asked for. The seed field was doing double duty as both the request and the readout, so writing the used seed back into it meant every later regenerate rebuilt the identical city — which reads as the purge having failed. - **Elevated arterials no longer run through buildings.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an arterial sterilising every block it crosses — but nothing then stopped a tower rising through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 3ec4bff..894ec70 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -964,6 +964,7 @@ export function AdminPanel({ // stale ones would leave the new city avoiding buildings that are gone. let worldLocations = locations; let worldRoads = roads; + let worldWater = waterBodies; if (purgeFirst) { const doomed = countGeneratedInRegion(locations, genBounds, drawing ? tracedPoints : null); if (doomed.removed > 0) { @@ -981,12 +982,18 @@ export function AdminPanel({ }); if (!purgeRes.ok) throw new Error(`Purge failed: ${purgeRes.status}`); - const [freshLocs, freshRoads] = await Promise.all([ + // Water is refetched for the same reason as locations and roads: the + // purge deleted the last river, and generating against the stale list + // means the new city avoids water that is no longer there — a dead + // band of empty ground tracing where the old river used to run. + const [freshLocs, freshRoads, freshWater] = await Promise.all([ fetch('/api/locations').then(r => r.json()).catch(() => locations), fetch('/api/roads').then(r => r.json()).catch(() => roads), + fetch('/api/water').then(r => r.json()).catch(() => waterBodies), ]); if (Array.isArray(freshLocs)) worldLocations = freshLocs; if (Array.isArray(freshRoads)) worldRoads = freshRoads; + if (Array.isArray(freshWater)) worldWater = freshWater; } // A typed seed is used as typed and never rewritten — normalising it @@ -1011,7 +1018,7 @@ export function AdminPanel({ roundabouts: cityRoundabouts ?? 'off', boundary: drawing ? { points: tracedPoints } : undefined, }, - { locations: worldLocations, roads: worldRoads, waterBodies }, + { locations: worldLocations, roads: worldRoads, waterBodies: worldWater }, seededRng(seed) ); diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 014d78c..3c63243 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -1031,6 +1031,22 @@ describe('AdminPanel regenerate', () => { expect(refetchAt).toBeGreaterThan(purgeAt); vi.unstubAllGlobals(); }); + + it('re-reads the water too, not just the locations and roads', async () => { + // The purge deletes the last generated river. Generating against the stale water + // list made the new city avoid a river that was no longer there, leaving a dead + // band of empty ground tracing where the old one ran. + const mock = stubFetch(); + vi.stubGlobal('confirm', vi.fn(() => true)); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + + const urls = mock.mock.calls.map(([u]) => String(u)); + const purgeAt = urls.findIndex((u) => u.includes('purge-region')); + const waterRefetchAt = urls.findIndex((u, i) => i > purgeAt && u === '/api/water'); + expect(waterRefetchAt).toBeGreaterThan(purgeAt); + vi.unstubAllGlobals(); + }); }); describe('AdminPanel water selector', () => { From 81d19db5c898b9c7335922237cb79d934496a164 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:31:31 -0500 Subject: [PATCH 26/40] fix(citygen): roundabouts sat half in the water MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Siting tested the junction point, which is a proxy for the wrong thing: a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so the test now asks about those. A drawn boundary had the identical defect and gets the identical fix. The tests for this were passing without the fix, which is worth recording. Siting rolls for density before it tests anything else, and the default seed's first roll is 0.88 — above the 0.6 share — so the junction was skipped outright and four exclusion tests never reached the rule they named. Two of those shipped in the original roundabout commit. They now use a seed that clears the roll, with a control asserting that seed does place a roundabout on clear ground. Without the control the same trap reopens the moment a constant moves. --- .../src/cityGen/__tests__/roundabouts.test.ts | 41 ++++++++++++++++++- frontend/src/cityGen/roundabouts.ts | 23 ++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/frontend/src/cityGen/__tests__/roundabouts.test.ts b/frontend/src/cityGen/__tests__/roundabouts.test.ts index e3471e2..232dded 100644 --- a/frontend/src/cityGen/__tests__/roundabouts.test.ts +++ b/frontend/src/cityGen/__tests__/roundabouts.test.ts @@ -30,6 +30,15 @@ function seededRng(seed = 4242) { const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); const deps = { fillPlot: () => {} }; +/** + * A seed whose first roll clears the `normal` density share. + * + * Siting rolls before it tests anything else, so a seed that fails the roll skips the + * junction outright — and every exclusion test below would pass without exercising the + * rule it names. The default seed rolls 0.88, above the 0.6 share, and did exactly that. + */ +const PASSES_ROLL = 1; + /** A crossroads of two wide roads, sharing no endpoint — the grid's case. */ const cross = (x = 0, z = 0, width = 8): RoadSegment[] => [ { x1: x - 100, z1: z, x2: x + 100, z2: z, width }, @@ -130,14 +139,42 @@ describe('siteRoundabouts', () => { const lake = { points: [ { x: -50, z: -50 }, { x: 50, z: -50 }, { x: 50, z: 50 }, { x: -50, z: 50 }, ] }; - expect(siteRoundabouts(cross(), 'normal', seededRng(), [lake])).toHaveLength(0); + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [lake])).toHaveLength(0); + }); + + it('keeps the whole ring out of the water, not just its centre', () => { + // A junction on a shoreline has its centre on dry ground while half the ring hangs + // over the water. Testing the centre alone let that through. + const shore = { points: [ + { x: 5, z: -200 }, { x: 400, z: -200 }, { x: 400, z: 200 }, { x: 5, z: 200 }, + ] }; + // The junction is at the origin and the shore starts at x = 5, so the centre is + // dry and the ring is not. Asserting it is rejected outright, rather than looping + // over what got placed — an empty list would pass that vacuously. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [shore])).toHaveLength(0); }); it('keeps them inside a drawn boundary', () => { const boundary = { points: [ { x: 200, z: 200 }, { x: 300, z: 200 }, { x: 300, z: 300 }, { x: 200, z: 300 }, ] }; - expect(siteRoundabouts(cross(), 'normal', seededRng(), [], boundary)).toHaveLength(0); + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [], boundary)).toHaveLength(0); + }); + + it('keeps the whole ring inside a drawn boundary', () => { + // Same defect as the shoreline, and the same fix: a junction just inside an edge + // would otherwise put half its ring outside the area the GM drew. + const boundary = { points: [ + { x: -400, z: -400 }, { x: 5, z: -400 }, { x: 5, z: 400 }, { x: -400, z: 400 }, + ] }; + // The junction sits inside the boundary, which ends at x = 5; the ring does not. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [], boundary)).toHaveLength(0); + }); + + it('places one on clear ground with that seed', () => { + // The control for every exclusion test above: if this were empty they would all + // pass without exercising anything. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL))).toHaveLength(1); }); it('reproduces from a seed', () => { diff --git a/frontend/src/cityGen/roundabouts.ts b/frontend/src/cityGen/roundabouts.ts index 7ebcad2..ce28b24 100644 --- a/frontend/src/cityGen/roundabouts.ts +++ b/frontend/src/cityGen/roundabouts.ts @@ -80,6 +80,25 @@ export function findJunctions(roads: RoadSegment[]): { x: number; z: number; wid return out; } +/** + * True when the whole ring sits on land and inside any drawn boundary. + * + * Testing the centre alone is not enough, and looked fine until a lake was generated: a + * junction on a shoreline has its centre on dry ground while half its ring hangs over + * the water. The ring points are the same ones that become road, so this asks the + * question about the geometry that will actually exist rather than a proxy for it. + */ +function ringOnLand(r: Roundabout, water: WaterPolygon[], boundary?: Polygon): boolean { + if (water.length > 0 && pointInWater(water, r.x, r.z)) return false; + if (boundary && !pointInPolygon(boundary, r.x, r.z)) return false; + + for (const p of ringPolygon(r).points) { + if (water.length > 0 && pointInWater(water, p.x, p.z)) return false; + if (boundary && !pointInPolygon(boundary, p.x, p.z)) return false; + } + return true; +} + /** * Choose which junctions become roundabouts. * @@ -105,10 +124,10 @@ export function siteRoundabouts( // junctions happen to be eligible — the same rule the landmark roll follows. const roll = rng(); if (roll >= share) continue; - if (water.length > 0 && pointInWater(water, j.x, j.z)) continue; - if (boundary && !pointInPolygon(boundary, j.x, j.z)) continue; const radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, j.width * RADIUS_FROM_ROAD)); + if (!ringOnLand({ x: j.x, z: j.z, radius }, water, boundary)) continue; + const tooClose = placed.some( (p) => Math.hypot(p.x - j.x, p.z - j.z) < Math.max(p.radius, radius) * SPACING_RADII ); From 52c55458bc56bbc917e1b628d812d68b4baabaa3 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:35:39 -0500 Subject: [PATCH 27/40] fix(citygen): roundabout islands counted as GM-authored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were given a name of their own, CIRCUS, and anything outside ZONE_TYPE_NAMES is treated as authored by hand. Two consequences: The visible one — Buildings.tsx renders a named structure in #8800ff, the purple that means "this has data on it". Every island came out purple against a city of green. The worse one — purge-region keeps user-named locations by design, so a REGENERATE would have left every old island standing and stacked new ones on top of them. Islands accumulating on each regenerate, silently. Fixed by not inventing a name. An island is a monument or a stand of trees, so it is named LANDMARK or PARK — both already in the set, on both sides. Adding CIRCUS instead would have meant editing two copies of ZONE_TYPE_NAMES, one in the frontend and one in the backend, and a set that has to be edited in two places to stay correct is where this class of bug comes from in the first place. The test asserts islands are not user-named rather than asserting the specific strings, so it keeps holding if the dressing changes. --- CHANGELOG.md | 2 ++ .../src/cityGen/__tests__/roundabouts.test.ts | 15 +++++++++++++++ frontend/src/cityGen/index.ts | 10 ++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354b3d6..66eaeb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. +- **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. +- **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. - **A typed seed is used as typed.** Parsing forced the value through `>>> 0`, which wrapped anything above 2³², so a long numeric seed silently became a different one. Seeds are hashed into range instead. - **`REGENERATE` rolls a new seed** unless one is asked for. The seed field was doing double duty as both the request and the readout, so writing the used seed back into it meant every later regenerate rebuilt the identical city — which reads as the purge having failed. diff --git a/frontend/src/cityGen/__tests__/roundabouts.test.ts b/frontend/src/cityGen/__tests__/roundabouts.test.ts index 232dded..3d80831 100644 --- a/frontend/src/cityGen/__tests__/roundabouts.test.ts +++ b/frontend/src/cityGen/__tests__/roundabouts.test.ts @@ -5,6 +5,7 @@ import { MIN_ARTERIAL_WIDTH, RING_WIDTH, SPACING_RADII, } from '../index'; import type { RoadSegment } from '../types'; +import { isUserDefinedName } from '../../utils/locationHelpers'; /** * Roundabouts. @@ -263,6 +264,20 @@ describe('generateCity with roundabouts', () => { expect(on.buildings.some(b => b.temp_block_id?.startsWith('gen_circus_'))).toBe(true); }); + it('names islands from the generated vocabulary, not something new', () => { + // Anything outside ZONE_TYPE_NAMES counts as authored by the GM: it renders in the + // purple reserved for structures with data, and a region purge keeps it — so every + // regenerate would leave its old islands behind and stack new ones on them. + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + const islands = on.buildings.filter(b => b.temp_block_id?.startsWith('gen_circus_')); + expect(islands.length).toBeGreaterThan(0); + for (const b of islands) { + expect(isUserDefinedName(b.name), b.name).toBe(false); + } + }); + it('reproduces from a seed', () => { const a = generateCity( bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(8), deps diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 89496ab..edfd384 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -274,15 +274,21 @@ export function generateCity( // Drawn unconditionally so the sequence does not depend on how large the island is. const wantsMonument = rng() < ISLAND_MONUMENT_CHANCE; - if (wantsMonument && span >= MIN_MONUMENT_SPAN) { + const monument = wantsMonument && span >= MIN_MONUMENT_SPAN; + if (monument) { generateLandmark(island, span, span, buildings, grid, rng); } else { generatePark(island, span, span, buildings, isBlocked, rng, false); } + // Named as what they are, from the vocabulary that already exists. A new name would + // have to be added to ZONE_TYPE_NAMES in two files — the frontend and the backend + // keep separate copies — and anything missing from that set is treated as authored + // by the GM: rendered in the "has data" purple, and *kept by a region purge*, so + // every regenerate would leave its old islands behind and stack new ones on them. for (let k = startIndex; k < buildings.length; k++) { buildings[k].temp_block_id = plotId; - if (!buildings[k].name) buildings[k].name = 'CIRCUS'; + if (!buildings[k].name) buildings[k].name = monument ? 'LANDMARK' : 'PARK'; } }); From 27d127e9258f091e40cc4e8ee69b82c0034ebaca Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:40:53 -0500 Subject: [PATCH 28/40] feat(citygen): small civic monuments for roundabout islands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Islands were dressed with the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline from across the city. On a traffic island that is a tower growing out of a roundabout. A roundabout has a column, a statue, a fountain or a clock — a couple of storeys at most. Four styles, each proportional to the island span rather than a fixed height, so a small circus gets a small ornament and no absolute number goes wrong the next time road widths are retuned. The first cut was still too tall. It passed a "shorter than a third of a landmark" check at 54 units on a 20-unit island, which is a fifteen-storey building and exactly the thing being fixed. The test now asserts against the island span, not against a landmark, and the constants came down until it held: column 20, statue 20, fountain 16, clock 29, on a span of 20. The relationship to landmarks is still pinned as a second test, so retuning either side cannot quietly close the gap again. --- CHANGELOG.md | 1 + README.md | 2 + .../src/cityGen/__tests__/monuments.test.ts | 129 ++++++++++++++++++ frontend/src/cityGen/index.ts | 7 +- frontend/src/cityGen/monuments.ts | 116 ++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 frontend/src/cityGen/__tests__/monuments.test.ts create mode 100644 frontend/src/cityGen/monuments.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66eaeb5..0ffbb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of four small civic ornaments instead: a column, a statue, a fountain or a clock, each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/README.md b/README.md index b1db3a2..700c87f 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,7 @@ CITY_NET/ │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp │ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule +│ │ │ ├── monuments.ts # Four small civic ornaments for a roundabout island — column, statue, fountain, clock — sized against the island rather than the skyline │ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) │ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them @@ -424,6 +425,7 @@ CITY_NET/ │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks │ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned +│ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, stacking without floating, one root per monument │ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout │ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels │ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts new file mode 100644 index 0000000..a2ec7b6 --- /dev/null +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, SpatialGrid } from '../index'; +import type { Block, RawBuilding } from '../types'; + +/** + * Island monuments. + * + * These exist because landmarks were used first and came out as 150-unit towers rising + * from a traffic island. So the tests that matter are about *scale*, not shape. + */ + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const island: Block = { x: 0, z: 0, w: 20, d: 20 }; +const SPAN = 20; + +/** Every style, so a per-style regression cannot hide behind an average. */ +function allStyles(span = SPAN): RawBuilding[][] { + const out: RawBuilding[][] = []; + for (let style = 0; style < MONUMENT_STYLE_COUNT; style++) { + // Feed a first draw that lands squarely in this style's bucket. + const pick = (style + 0.5) / MONUMENT_STYLE_COUNT; + let first = true; + const rng = () => { + if (first) { first = false; return pick; } + return 0.5; + }; + const parts: RawBuilding[] = []; + generateMonument({ x: 0, z: 0, w: span, d: span }, span, parts, rng); + out.push(parts); + } + return out; +} + +const topOf = (parts: RawBuilding[]) => Math.max(...parts.map(p => p.y + p.height)); +const widestOf = (parts: RawBuilding[]) => + Math.max(...parts.map(p => Math.max(p.width, p.depth))); + +describe('generateMonument', () => { + it('produces something for every style', () => { + for (const parts of allStyles()) expect(parts.length).toBeGreaterThan(0); + }); + + it('stays at civic scale, not skyline scale', () => { + // The bug this module exists for: a landmark on a traffic island was a tower. + for (const parts of allStyles()) { + // A monument on a 20-unit island should not be a 15-storey tower. First cut at + // this passed a /3-of-a-landmark check at 54 units and still read as a building. + expect(topOf(parts)).toBeLessThan(SPAN * 2); + } + }); + + it('is dramatically shorter than a landmark on the same plot', () => { + // Pins the relationship rather than a number, so it survives retuning either side. + const landmark: RawBuilding[] = []; + generateLandmark(island, SPAN, SPAN, landmark, new SpatialGrid(), seededRng()); + const tallestMonument = Math.max(...allStyles().map(topOf)); + expect(tallestMonument).toBeLessThan(topOf(landmark) / 3); + }); + + it('fits within the island', () => { + // A monument wider than the disc would overhang the ring road. + for (const parts of allStyles()) { + expect(widestOf(parts)).toBeLessThanOrEqual(SPAN); + } + }); + + it('scales with the island rather than using fixed heights', () => { + // Absolute heights would go wrong the moment road widths are retuned. + const small = Math.max(...allStyles(10).map(topOf)); + const large = Math.max(...allStyles(40).map(topOf)); + expect(large).toBeGreaterThan(small * 3); + }); + + it('sits on the ground', () => { + for (const parts of allStyles()) { + expect(Math.min(...parts.map(p => p.y))).toBe(0); + } + }); + + it('stacks its parts without gaps or floating', () => { + // y is the bottom of a mesh, so a part resting on another has its y set to that + // one's height. Getting this wrong is what left skyscrapers hanging in mid-air. + for (const parts of allStyles()) { + for (const p of parts.slice(1)) { + const supported = parts.some(q => Math.abs(q.y + q.height - p.y) < 1e-6); + expect(supported, `part at y=${p.y}`).toBe(true); + } + } + }); + + it('emits one unparented root, with the rest grouped under it', () => { + // The caller groups children by parent_name once the root has a database id. + for (const parts of allStyles()) { + expect(parts.filter(p => !p.parent_name)).toHaveLength(1); + expect(parts[0].parent_name).toBeUndefined(); + for (const p of parts.slice(1)) expect(p.parent_name).toBe('ROOT'); + } + }); + + it('centres on the island', () => { + for (const parts of allStyles()) { + for (const p of parts) { + expect(p.x).toBeCloseTo(0); + expect(p.z).toBeCloseTo(0); + } + } + }); + + it('offers visibly different styles', () => { + // A run of roundabouts all carrying the same column would read worse than none. + const shapes = allStyles().map(parts => topOf(parts).toFixed(2)); + expect(new Set(shapes).size).toBeGreaterThan(1); + }); + + it('reproduces from a seed', () => { + const a: RawBuilding[] = []; + const b: RawBuilding[] = []; + generateMonument(island, SPAN, a, seededRng(12)); + generateMonument(island, SPAN, b, seededRng(12)); + expect(a).toEqual(b); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index edfd384..5fe925c 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -16,6 +16,7 @@ import { } from './zoning'; import { generatePark } from './parks'; import { shouldPlaceLandmark, generateLandmark } from './landmarks'; +import { generateMonument } from './monuments'; import { parseWaterBodies, pointInWater, footprintInWater, clipSegmentToBoundary } from './water'; import type { Polygon } from './water'; import { findBridges } from './bridges'; @@ -37,6 +38,7 @@ export { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks export * from './zoning'; export { generatePark } from './parks'; export { shouldPlaceLandmark, generateLandmark } from './landmarks'; +export * from './monuments'; export * from './water'; export * from './layouts'; export * from './rng'; @@ -276,7 +278,10 @@ export function generateCity( const wantsMonument = rng() < ISLAND_MONUMENT_CHANCE; const monument = wantsMonument && span >= MIN_MONUMENT_SPAN; if (monument) { - generateLandmark(island, span, span, buildings, grid, rng); + // Not generateLandmark: those are 150-to-220-unit hero buildings sized to anchor + // a skyline, and one on a traffic island is a tower growing out of a roundabout. + // A monument is sized against the island instead. + generateMonument(island, span, buildings, rng); } else { generatePark(island, span, span, buildings, isBlocked, rng, false); } diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts new file mode 100644 index 0000000..c12e7e6 --- /dev/null +++ b/frontend/src/cityGen/monuments.ts @@ -0,0 +1,116 @@ +import type { Block, RawBuilding, Rng } from './types'; + +/** + * Monuments — small civic ornaments for a traffic island. + * + * Separate from `landmarks.ts` on grounds of scale, which is the whole point. A + * landmark is a hero building 150 to 220 units tall, sized to anchor a skyline from + * across the city. Putting one on a roundabout produced a tower rising out of a traffic + * island, which is not what a roundabout has in the middle of it. + * + * What a roundabout actually has is a column, a statue, a fountain, or a clock — a + * couple of storeys at most, sized against the island rather than the city. Everything + * here is proportional to the island span, so a small circus gets a small ornament and + * a large one gets something worth looking at, with no absolute heights to go wrong + * when the road widths are next retuned. + */ + +/** Monument height as a multiple of the island span, per style. */ +const COLUMN_HEIGHT = 1.5; +const STATUE_HEIGHT = 1.0; +const FOUNTAIN_HEIGHT = 0.3; +const CLOCK_HEIGHT = 1.5; + +export const MONUMENT_STYLE_COUNT = 4; + +/** + * Place one monument centred on the island. + * + * Parts follow the same convention as the landmark styles: a single unparented root + * first, then `ROOT` children the caller groups under it once the root has an id. + * Colour is left empty — the renderer picks its own from whether the structure carries + * data, so anything set here would be discarded. + */ +export function generateMonument(block: Block, span: number, out: RawBuilding[], rng: Rng): void { + const style = Math.floor(rng() * MONUMENT_STYLE_COUNT); + const color = ''; + const { x, z } = block; + + if (style === 0) { + // Victory column: a stepped plinth carrying a slender shaft and a figure on top. + const baseW = span * 0.42; + const baseH = span * 0.16; + out.push({ + name: '', description: '', x, y: 0, z, + width: baseW, depth: baseW, height: baseH, + color, shape: 'box', + }); + const shaftW = span * 0.14; + const shaftH = span * COLUMN_HEIGHT * 0.42; + out.push({ + name: '', x, y: baseH, z, + width: shaftW, depth: shaftW, height: shaftH, + color, shape: 'cylinder', parent_name: 'ROOT', + }); + out.push({ + name: '', x, y: baseH + shaftH, z, + width: shaftW * 1.6, depth: shaftW * 1.6, height: span * 0.22, + color, shape: 'pyramid', parent_name: 'ROOT', + }); + return; + } + + if (style === 1) { + // Statue: a broad plinth and a figure, deliberately off-square so it reads as + // something facing a direction rather than a post. + const plinthW = span * 0.34; + const plinthH = span * STATUE_HEIGHT * 0.3; + out.push({ + name: '', description: '', x, y: 0, z, + width: plinthW, depth: plinthW, height: plinthH, + color, shape: 'box', + }); + const figureW = span * 0.16; + out.push({ + name: '', x, y: plinthH, z, + width: figureW, depth: figureW * 0.6, height: span * STATUE_HEIGHT * 0.7, + color, shape: 'box', parent_name: 'ROOT', + rotation: rng() * Math.PI * 2, + }); + return; + } + + if (style === 2) { + // Fountain: a wide, low basin with a small jet at the centre. The only style that + // is broader than it is tall, which is what keeps a run of roundabouts from all + // reading the same. + const basinW = span * 0.7; + out.push({ + name: '', description: '', x, y: 0, z, + width: basinW, depth: basinW, height: span * FOUNTAIN_HEIGHT, + color, shape: 'cylinder', + }); + out.push({ + name: '', x, y: span * FOUNTAIN_HEIGHT, z, + width: span * 0.12, depth: span * 0.12, height: span * 0.5, + color, shape: 'cylinder', parent_name: 'ROOT', + }); + return; + } + + // Clock tower: the tallest of the four, and still under one and a half island spans. + const towerW = span * 0.26; + const towerH = span * CLOCK_HEIGHT * 0.8; + out.push({ + name: '', description: '', x, y: 0, z, + width: towerW, depth: towerW, height: towerH, + color, shape: 'box', + }); + out.push({ + name: '', x, y: towerH, z, + width: towerW * 1.3, depth: towerW * 1.3, height: span * 0.25, + color, shape: 'pyramid', parent_name: 'ROOT', + }); +} + +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT }; From 89dd8aaae0076a9c7427249f8b5b2800a903e52e Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:50:17 -0500 Subject: [PATCH 29/40] feat(citygen): monuments with a silhouette, not box on box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version was two stacked boxes and read as exactly that. At this size silhouette is the whole thing — there is no facade detail to carry it. The renderer already supported far more than the box being used: sphere, rhombus (an octahedron), pyramid (a cone), cylinder, a polyCount that turns a cylinder into a hexagonal prism at 6 or a smooth column at 16, and all three rotation axes. These now use the lot. Six styles rather than four, each with its own outline: column 14 parts 1.06x span stepped plinth turned 45° between courses, fluted shaft, faceted finial, a ring of eight bollards statue 6 parts 0.81x plinth, torso, sphere head, two arms at different angles so it faces somewhere fountain 14 parts 0.80x three narrowing basins, a jet, six spouts around the rim — the only style broader than it is tall clock 10 parts 1.54x tapering stack, four faces stood on edge, belfry, spire, finial arch 9 parts 0.99x two piers and a lintel; the one you can see through obelisk 10 parts 1.18x three tapering stages, each turned against the last The anti-floating test had to be restated. It required every part to sit exactly on another, which was true of a stack of boxes and is not true of a clock face or a raised arm. It now asks whether a part is on the ground or has its base within another part's vertical extent — which is the actual invariant, and still catches the mid-air case that left skyscrapers hanging earlier. --- CHANGELOG.md | 2 +- README.md | 2 +- .../src/cityGen/__tests__/monuments.test.ts | 38 ++- frontend/src/cityGen/monuments.ts | 257 +++++++++++++----- 4 files changed, 225 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffbb15..7269458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of four small civic ornaments instead: a column, a statue, a fountain or a clock, each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/README.md b/README.md index 700c87f..feda6b0 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ CITY_NET/ │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp │ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule -│ │ │ ├── monuments.ts # Four small civic ornaments for a roundabout island — column, statue, fountain, clock — sized against the island rather than the skyline +│ │ │ ├── monuments.ts # Six small civic ornaments for a roundabout island — column, statue, fountain, clock tower, arch, obelisk — multi-part silhouettes using the renderer's full shape set, sized against the island rather than the skyline │ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) │ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts index a2ec7b6..5d70f78 100644 --- a/frontend/src/cityGen/__tests__/monuments.test.ts +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -84,13 +84,17 @@ describe('generateMonument', () => { } }); - it('stacks its parts without gaps or floating', () => { + it('leaves nothing floating', () => { // y is the bottom of a mesh, so a part resting on another has its y set to that // one's height. Getting this wrong is what left skyscrapers hanging in mid-air. + // A part is anchored if it stands on the ground or if its base falls within the + // vertical extent of another part — the second case covers what is attached to a + // side rather than stacked on top, such as a clock face or a raised arm. for (const parts of allStyles()) { - for (const p of parts.slice(1)) { - const supported = parts.some(q => Math.abs(q.y + q.height - p.y) < 1e-6); - expect(supported, `part at y=${p.y}`).toBe(true); + for (const p of parts) { + if (p.y === 0) continue; + const anchored = parts.some(q => q !== p && p.y >= q.y - 1e-6 && p.y <= q.y + q.height + 1e-6); + expect(anchored, `part at y=${p.y}`).toBe(true); } } }); @@ -104,15 +108,35 @@ describe('generateMonument', () => { } }); - it('centres on the island', () => { + it('keeps every part within the island', () => { + // Parts are no longer all on the centreline — bollards, spouts and arch piers sit + // out from it — so what matters is that the whole thing stays off the ring road. for (const parts of allStyles()) { for (const p of parts) { - expect(p.x).toBeCloseTo(0); - expect(p.z).toBeCloseTo(0); + const reach = Math.max(Math.abs(p.x), Math.abs(p.z)) + Math.max(p.width, p.depth) / 2; + expect(reach, p.shape).toBeLessThanOrEqual(SPAN / 2); } } }); + it('is built from more than stacked boxes', () => { + // The complaint that prompted this: two boxes on top of each other read as two + // boxes on top of each other. Silhouette is what carries an object this small. + for (const parts of allStyles()) { + const shapes = new Set(parts.map(p => p.shape)); + expect(shapes.size, [...shapes].join(',')).toBeGreaterThan(1); + expect(parts.length).toBeGreaterThanOrEqual(5); + } + }); + + it('varies its silhouette between styles', () => { + // If every style used the same shapes in the same proportions there would be no + // point having six of them. + const signatures = allStyles().map(parts => + [...parts.map(p => p.shape)].sort().join('|') + ':' + parts.length); + expect(new Set(signatures).size).toBe(MONUMENT_STYLE_COUNT); + }); + it('offers visibly different styles', () => { // A run of roundabouts all carrying the same column would read worse than none. const shapes = allStyles().map(parts => topOf(parts).toFixed(2)); diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts index c12e7e6..9e0bd16 100644 --- a/frontend/src/cityGen/monuments.ts +++ b/frontend/src/cityGen/monuments.ts @@ -8,11 +8,16 @@ import type { Block, RawBuilding, Rng } from './types'; * across the city. Putting one on a roundabout produced a tower rising out of a traffic * island, which is not what a roundabout has in the middle of it. * - * What a roundabout actually has is a column, a statue, a fountain, or a clock — a - * couple of storeys at most, sized against the island rather than the city. Everything - * here is proportional to the island span, so a small circus gets a small ornament and - * a large one gets something worth looking at, with no absolute heights to go wrong - * when the road widths are next retuned. + * Everything here is proportional to the island span, so a small circus gets a small + * ornament and a large one gets something worth looking at, with no absolute heights to + * go wrong when the road widths are next retuned. + * + * **On detail.** A first version was two stacked boxes and read as exactly that. The + * renderer supports more than a box — `cylinder`, `sphere`, `rhombus` (an octahedron), + * `pyramid` (a cone), a `polyCount` that turns a cylinder into a hexagonal prism at 6 + * or a smooth column at 16, and all three rotation axes. Silhouette is what carries a + * monument at this size, so these use the lot: stepped plinths turned 45° against each + * other, rings of bollards, tapered shafts, finials. */ /** Monument height as a multiple of the island span, per style. */ @@ -21,7 +26,19 @@ const STATUE_HEIGHT = 1.0; const FOUNTAIN_HEIGHT = 0.3; const CLOCK_HEIGHT = 1.5; -export const MONUMENT_STYLE_COUNT = 4; +export const MONUMENT_STYLE_COUNT = 6; + +/** Segments for a shape meant to read as round rather than faceted. */ +const SMOOTH = 16; + +/** Segments for a shape meant to read as cut stone. */ +const FACETED = 6; + +/** Right angle, for turning a flat cylinder into a disc facing sideways. */ +const QUARTER = Math.PI / 2; + +/** Eighth turn — a square rotated by this against another reads as an eight-pointed star. */ +const EIGHTH = Math.PI / 4; /** * Place one monument centred on the island. @@ -35,82 +52,192 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], const style = Math.floor(rng() * MONUMENT_STYLE_COUNT); const color = ''; const { x, z } = block; + let rooted = false; + + /** Emit a part, making the first one the unparented root. */ + const part = (p: Partial & { y: number; width: number; height: number }) => { + const base: RawBuilding = { + name: '', x, z, depth: p.width, color, shape: 'box', polyCount: 5, + ...(p as object), + } as RawBuilding; + if (!rooted) { + base.description = ''; + rooted = true; + } else { + base.parent_name = 'ROOT'; + } + out.push(base); + }; + + /** Repeat something evenly around a circle — bollards, spouts, corner posts. */ + const around = (count: number, radius: number, make: (px: number, pz: number, angle: number) => void) => { + for (let i = 0; i < count; i++) { + const a = (i / count) * Math.PI * 2; + make(x + Math.cos(a) * radius, z + Math.sin(a) * radius, a); + } + }; if (style === 0) { - // Victory column: a stepped plinth carrying a slender shaft and a figure on top. - const baseW = span * 0.42; - const baseH = span * 0.16; - out.push({ - name: '', description: '', x, y: 0, z, - width: baseW, depth: baseW, height: baseH, - color, shape: 'box', + // Victory column. Three plinth steps turned against each other, a fluted shaft, a + // capital, and a faceted finial — then a ring of bollards to give the base a skirt. + let y = 0; + const steps = [0.56, 0.46, 0.38]; + steps.forEach((w, i) => { + const h = span * 0.05; + part({ y, width: span * w, height: h, rotation: i % 2 ? EIGHTH : 0 }); + y += h; }); - const shaftW = span * 0.14; + + const shaftW = span * 0.13; const shaftH = span * COLUMN_HEIGHT * 0.42; - out.push({ - name: '', x, y: baseH, z, - width: shaftW, depth: shaftW, height: shaftH, - color, shape: 'cylinder', parent_name: 'ROOT', - }); - out.push({ - name: '', x, y: baseH + shaftH, z, - width: shaftW * 1.6, depth: shaftW * 1.6, height: span * 0.22, - color, shape: 'pyramid', parent_name: 'ROOT', - }); + part({ y, width: shaftW, height: shaftH, shape: 'cylinder', polyCount: SMOOTH }); + y += shaftH; + + part({ y, width: span * 0.2, height: span * 0.06, shape: 'cylinder', polyCount: SMOOTH }); + y += span * 0.06; + part({ y, width: span * 0.17, height: span * 0.22, shape: 'rhombus' }); + + around(8, span * 0.42, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder', polyCount: FACETED })); return; } if (style === 1) { - // Statue: a broad plinth and a figure, deliberately off-square so it reads as - // something facing a direction rather than a post. + // Statue: a plinth, then a figure assembled from a torso, a head and two arms, all + // turned to one bearing so it reads as facing somewhere rather than standing to + // attention. The arms are what stop it being a post on a box. + const facing = rng() * Math.PI * 2; const plinthW = span * 0.34; - const plinthH = span * STATUE_HEIGHT * 0.3; - out.push({ - name: '', description: '', x, y: 0, z, - width: plinthW, depth: plinthW, height: plinthH, - color, shape: 'box', + const plinthH = span * STATUE_HEIGHT * 0.26; + part({ y: 0, width: plinthW, height: plinthH, rotation: facing }); + part({ y: plinthH, width: plinthW * 1.12, height: span * 0.04, rotation: facing }); + + const deckY = plinthH + span * 0.04; + const torsoW = span * 0.13; + const torsoH = span * STATUE_HEIGHT * 0.42; + part({ y: deckY, width: torsoW, depth: torsoW * 0.62, height: torsoH, rotation: facing }); + + const headY = deckY + torsoH; + part({ y: headY, width: span * 0.09, height: span * 0.09, shape: 'sphere', polyCount: SMOOTH }); + + // One arm raised, one at rest — the asymmetry is most of the silhouette. + part({ + x: x + Math.cos(facing + QUARTER) * torsoW * 0.7, + z: z + Math.sin(facing + QUARTER) * torsoW * 0.7, + y: deckY + torsoH * 0.45, width: span * 0.045, height: torsoH * 0.75, + rotation: facing, rotation_z: -EIGHTH, }); - const figureW = span * 0.16; - out.push({ - name: '', x, y: plinthH, z, - width: figureW, depth: figureW * 0.6, height: span * STATUE_HEIGHT * 0.7, - color, shape: 'box', parent_name: 'ROOT', - rotation: rng() * Math.PI * 2, + part({ + x: x - Math.cos(facing + QUARTER) * torsoW * 0.7, + z: z - Math.sin(facing + QUARTER) * torsoW * 0.7, + y: deckY + torsoH * 0.2, width: span * 0.045, height: torsoH * 0.6, + rotation: facing, rotation_z: EIGHTH * 0.4, }); return; } if (style === 2) { - // Fountain: a wide, low basin with a small jet at the centre. The only style that - // is broader than it is tall, which is what keeps a run of roundabouts from all - // reading the same. - const basinW = span * 0.7; - out.push({ - name: '', description: '', x, y: 0, z, - width: basinW, depth: basinW, height: span * FOUNTAIN_HEIGHT, - color, shape: 'cylinder', - }); - out.push({ - name: '', x, y: span * FOUNTAIN_HEIGHT, z, - width: span * 0.12, depth: span * 0.12, height: span * 0.5, - color, shape: 'cylinder', parent_name: 'ROOT', - }); + // Fountain: three tiers of narrowing basins with a jet through the middle and + // spouts around the rim. The only style broader than it is tall, which is what + // keeps a run of roundabouts from all reading the same. + const basinW = span * 0.78; + const basinH = span * FOUNTAIN_HEIGHT * 0.4; + part({ y: 0, width: basinW, height: basinH, shape: 'cylinder', polyCount: SMOOTH }); + part({ y: basinH, width: basinW * 0.92, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH }); + + let y = basinH + span * 0.02; + const tiers = [0.36, 0.22]; + for (const w of tiers) { + part({ y, width: span * 0.08, height: span * 0.12, shape: 'cylinder', polyCount: FACETED }); + y += span * 0.12; + part({ y, width: span * w, height: span * 0.05, shape: 'cylinder', polyCount: SMOOTH }); + y += span * 0.05; + } + + part({ y, width: span * 0.05, height: span * 0.22, shape: 'cylinder', polyCount: SMOOTH }); + y += span * 0.22; + part({ y, width: span * 0.1, height: span * 0.1, shape: 'sphere', polyCount: SMOOTH }); + + around(6, basinW * 0.36, (px, pz) => + part({ x: px, z: pz, y: basinH, width: span * 0.05, height: span * 0.1, shape: 'cylinder', polyCount: FACETED })); return; } - // Clock tower: the tallest of the four, and still under one and a half island spans. - const towerW = span * 0.26; - const towerH = span * CLOCK_HEIGHT * 0.8; - out.push({ - name: '', description: '', x, y: 0, z, - width: towerW, depth: towerW, height: towerH, - color, shape: 'box', - }); - out.push({ - name: '', x, y: towerH, z, - width: towerW * 1.3, depth: towerW * 1.3, height: span * 0.25, - color, shape: 'pyramid', parent_name: 'ROOT', - }); + if (style === 3) { + // Clock tower: a tapering stack with a clock face on each side, a belfry and a + // spire. The faces are flat cylinders stood on edge — the one place the rotation + // axes earn their keep, since a disc has to face outward to read as a clock. + let y = 0; + part({ y, width: span * 0.34, height: span * 0.08 }); + y += span * 0.08; + part({ y, width: span * 0.28, height: span * 0.05, rotation: EIGHTH }); + y += span * 0.05; + + const shaftW = span * 0.24; + const shaftH = span * CLOCK_HEIGHT * 0.62; + part({ y, width: shaftW, height: shaftH }); + + const faceY = y + shaftH * 0.78; + const faceR = shaftW * 0.52; + const faceW = span * 0.15; + // Two on the X faces, two on the Z faces; a cylinder's axis is Y, so each is + // tipped a quarter turn about the axis that leaves it facing outward. + part({ x: x + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_z: QUARTER }); + part({ x: x - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_z: QUARTER }); + part({ z: z + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_x: QUARTER }); + part({ z: z - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_x: QUARTER }); + + y += shaftH; + part({ y, width: span * 0.3, height: span * 0.1 }); + y += span * 0.1; + part({ y, width: span * 0.32, height: span * 0.26, shape: 'pyramid', polyCount: 4, rotation: EIGHTH }); + y += span * 0.26; + part({ y, width: span * 0.07, height: span * 0.12, shape: 'rhombus' }); + return; + } + + if (style === 4) { + // Triumphal arch: two piers carrying a lintel, with an attic above. Reads as a gate + // rather than an object, which is a different silhouette from everything else here + // and the one you can see through. + const facing = Math.floor(rng() * 4) * QUARTER; + const gap = span * 0.24; + const pierW = span * 0.15; + const pierH = span * 0.52; + const dx = Math.cos(facing); + const dz = Math.sin(facing); + + part({ x: x + dx * gap, z: z + dz * gap, y: 0, width: pierW, height: pierH, rotation: facing }); + part({ x: x - dx * gap, z: z - dz * gap, y: 0, width: pierW, height: pierH, rotation: facing }); + + const spanW = gap * 2 + pierW; + part({ y: pierH, width: spanW, depth: pierW, height: span * 0.13, rotation: facing }); + part({ y: pierH + span * 0.13, width: spanW * 0.82, depth: pierW * 0.9, height: span * 0.16, rotation: facing }); + part({ y: pierH + span * 0.29, width: span * 0.13, height: span * 0.18, shape: 'rhombus' }); + + around(4, span * 0.4, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.1, shape: 'cylinder', polyCount: FACETED })); + return; + } + + // Obelisk: a squat base under a shaft that tapers in three turned stages to a point. + // The turn between stages is what keeps a plain taper from reading as one long box. + let y = 0; + part({ y, width: span * 0.36, height: span * 0.07 }); + y += span * 0.07; + part({ y, width: span * 0.26, height: span * 0.08, rotation: EIGHTH }); + y += span * 0.08; + + const stages = [0.17, 0.135, 0.1]; + for (let i = 0; i < stages.length; i++) { + const h = span * 0.29; + part({ y, width: span * stages[i], height: h, rotation: i % 2 ? EIGHTH : 0 }); + y += h; + } + part({ y, width: span * 0.1, height: span * 0.16, shape: 'pyramid', polyCount: 4 }); + + around(4, span * 0.34, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'rhombus' })); } -export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT }; +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, SMOOTH, FACETED }; From e9feb9033e4fbed5c0f397c619e08bd43965d764 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 22:58:49 -0500 Subject: [PATCH 30/40] fix(citygen): monuments glowed brighter than the city around them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer resolves a part's colour as `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`. Monuments left it empty, as the landmark styles do, so they landed on theme.primary at full brightness. That is survivable on a landmark, which is one or two large masses. A monument is six to fourteen parts inside a few units, and the coincident wireframe edges stack into a bright blob next to neighbours drawn from far fewer. Setting an explicit colour is the only way to opt out of the fallback, so they now carry a muted green. The test asserts the colour is set and is neither empty nor the '#00ff00' sentinel — the two values that would silently route back to theme.primary. --- CHANGELOG.md | 2 +- .../src/cityGen/__tests__/monuments.test.ts | 17 +++++++++++++++- frontend/src/cityGen/monuments.ts | 20 +++++++++++++++---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7269458..1f67a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They carry an explicit muted green rather than deferring to the theme, so a dozen parts packed into a few units do not stack their wireframe edges into a bright blob beside neighbours built from one or two. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts index 5d70f78..a37a454 100644 --- a/frontend/src/cityGen/__tests__/monuments.test.ts +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, SpatialGrid } from '../index'; +import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, SpatialGrid } from '../index'; import type { Block, RawBuilding } from '../types'; /** @@ -143,6 +143,21 @@ describe('generateMonument', () => { expect(new Set(shapes).size).toBeGreaterThan(1); }); + it('carries an explicit colour so it does not glow at theme brightness', () => { + // The renderer only honours a part's colour when it is set and is not the '#00ff00' + // sentinel; anything else falls through to theme.primary at full brightness. On a + // single large mass that is fine, but a monument packs a dozen parts into a few + // units and the stacked wireframe edges read as a bright blob beside its + // neighbours. + for (const parts of allStyles()) { + for (const p of parts) { + expect(p.color).toBe(MONUMENT_COLOR); + expect(p.color).not.toBe(''); + expect(p.color).not.toBe('#00ff00'); + } + } + }); + it('reproduces from a seed', () => { const a: RawBuilding[] = []; const b: RawBuilding[] = []; diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts index 9e0bd16..2bc7e08 100644 --- a/frontend/src/cityGen/monuments.ts +++ b/frontend/src/cityGen/monuments.ts @@ -28,6 +28,18 @@ const CLOCK_HEIGHT = 1.5; export const MONUMENT_STYLE_COUNT = 6; +/** + * Muted green, so a monument sits back into the city rather than glowing out of it. + * + * The renderer resolves a part's colour as `(p.color && p.color !== '#00ff00') ? p.color + * : district_color ?? theme.primary`. Leaving it empty — as the landmark styles do — + * lands on `theme.primary`, full brightness. That is survivable on a single large mass + * but not here: a monument packs six to fourteen parts into a few units, and the + * coincident wireframe edges stack into a bright blob beside neighbours built from one + * or two. An explicit colour is used verbatim, which is the only way to opt out. + */ +const MONUMENT_COLOR = '#00aa33'; + /** Segments for a shape meant to read as round rather than faceted. */ const SMOOTH = 16; @@ -45,12 +57,12 @@ const EIGHTH = Math.PI / 4; * * Parts follow the same convention as the landmark styles: a single unparented root * first, then `ROOT` children the caller groups under it once the root has an id. - * Colour is left empty — the renderer picks its own from whether the structure carries - * data, so anything set here would be discarded. + * Every part carries an explicit colour rather than deferring to the theme; see + * `MONUMENT_COLOR` for why. */ export function generateMonument(block: Block, span: number, out: RawBuilding[], rng: Rng): void { const style = Math.floor(rng() * MONUMENT_STYLE_COUNT); - const color = ''; + const color = MONUMENT_COLOR; const { x, z } = block; let rooted = false; @@ -240,4 +252,4 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'rhombus' })); } -export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, SMOOTH, FACETED }; +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, SMOOTH, FACETED, MONUMENT_COLOR }; From 20eec326f9d272ba1b897fc62a0a1282301a0694 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 23:12:40 -0500 Subject: [PATCH 31/40] fix(citygen): monuments opted themselves out of the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit gave monument parts an explicit '#00aa33' to stop them glowing. That was the wrong lever and made the real problem worse. '#00ff00' is not a colour in this app, it is the sentinel meaning "inherit the theme": the renderer resolves a part as `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`. Reading the live map settled it — SLUMS, URBAN, CORPO and INDUSTRIAL store '#00ff00' on some two thousand buildings between them. Naming a real colour is precisely how a structure stops rendering with the same settings as everything else, and it would also have ignored a theme switch. Monuments are back on the sentinel. The brightness was never the colour — it was density, a dozen parts inside a few units stacking their wireframe edges beside neighbours built from one or two. So the part counts came down instead: eight bollards to four, six fountain spouts to four, two basin tiers to one. Ten parts at most now, against fourteen. The colour test asserts the sentinel specifically, with the reason, so the next person to see '#00ff00' in a generator does not "fix" it again. --- CHANGELOG.md | 2 +- .../src/cityGen/__tests__/monuments.test.ts | 26 +++++++++++-------- frontend/src/cityGen/monuments.ts | 26 +++++++++++-------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f67a09..fdfe0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They carry an explicit muted green rather than deferring to the theme, so a dozen parts packed into a few units do not stack their wireframe edges into a bright blob beside neighbours built from one or two. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts index a37a454..39153ab 100644 --- a/frontend/src/cityGen/__tests__/monuments.test.ts +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -143,18 +143,22 @@ describe('generateMonument', () => { expect(new Set(shapes).size).toBeGreaterThan(1); }); - it('carries an explicit colour so it does not glow at theme brightness', () => { - // The renderer only honours a part's colour when it is set and is not the '#00ff00' - // sentinel; anything else falls through to theme.primary at full brightness. On a - // single large mass that is fine, but a monument packs a dozen parts into a few - // units and the stacked wireframe edges read as a bright blob beside its - // neighbours. + it('uses the same colour convention as every other structure', () => { + // '#00ff00' is not a colour here, it is the sentinel meaning "inherit the theme" — + // the renderer resolves anything else verbatim. The generated city stores it on + // some two thousand buildings. Setting a real colour instead opted monuments out of + // the theme system, so they matched nothing and would ignore a theme switch. for (const parts of allStyles()) { - for (const p of parts) { - expect(p.color).toBe(MONUMENT_COLOR); - expect(p.color).not.toBe(''); - expect(p.color).not.toBe('#00ff00'); - } + for (const p of parts) expect(p.color).toBe(MONUMENT_COLOR); + } + expect(MONUMENT_COLOR).toBe('#00ff00'); + }); + + it('keeps its part count modest', () => { + // Coincident wireframe edges are what made these glow beside neighbours built from + // one or two masses. Detail has to come from silhouette, not from part count. + for (const parts of allStyles()) { + expect(parts.length).toBeLessThanOrEqual(11); } }); diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts index 2bc7e08..8ef8914 100644 --- a/frontend/src/cityGen/monuments.ts +++ b/frontend/src/cityGen/monuments.ts @@ -29,16 +29,19 @@ const CLOCK_HEIGHT = 1.5; export const MONUMENT_STYLE_COUNT = 6; /** - * Muted green, so a monument sits back into the city rather than glowing out of it. + * The app-wide "inherit the theme" sentinel, which is what every other structure uses. * - * The renderer resolves a part's colour as `(p.color && p.color !== '#00ff00') ? p.color - * : district_color ?? theme.primary`. Leaving it empty — as the landmark styles do — - * lands on `theme.primary`, full brightness. That is survivable on a single large mass - * but not here: a monument packs six to fourteen parts into a few units, and the - * coincident wireframe edges stack into a bright blob beside neighbours built from one - * or two. An explicit colour is used verbatim, which is the only way to opt out. + * `#00ff00` is not a colour here. The renderer resolves a part as + * `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`, so + * this exact value is the way a structure says "no opinion, use the theme" — which is + * why the generated city stores it on some two thousand buildings. + * + * An earlier attempt to calm monuments down set an explicit muted green instead. That + * opted them out of the theme system altogether: they stopped matching their + * neighbours and would have ignored a theme switch entirely. Density, not colour, is + * what made them stand out, so that is what was reduced instead. */ -const MONUMENT_COLOR = '#00aa33'; +const MONUMENT_COLOR = '#00ff00'; /** Segments for a shape meant to read as round rather than faceted. */ const SMOOTH = 16; @@ -109,7 +112,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], y += span * 0.06; part({ y, width: span * 0.17, height: span * 0.22, shape: 'rhombus' }); - around(8, span * 0.42, (px, pz) => + around(4, span * 0.42, (px, pz) => part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder', polyCount: FACETED })); return; } @@ -158,7 +161,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y: basinH, width: basinW * 0.92, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH }); let y = basinH + span * 0.02; - const tiers = [0.36, 0.22]; + const tiers = [0.3]; for (const w of tiers) { part({ y, width: span * 0.08, height: span * 0.12, shape: 'cylinder', polyCount: FACETED }); y += span * 0.12; @@ -170,7 +173,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], y += span * 0.22; part({ y, width: span * 0.1, height: span * 0.1, shape: 'sphere', polyCount: SMOOTH }); - around(6, basinW * 0.36, (px, pz) => + around(4, basinW * 0.36, (px, pz) => part({ x: px, z: pz, y: basinH, width: span * 0.05, height: span * 0.1, shape: 'cylinder', polyCount: FACETED })); return; } @@ -250,6 +253,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], around(4, span * 0.34, (px, pz) => part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'rhombus' })); + } export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, SMOOTH, FACETED, MONUMENT_COLOR }; From 34e0f3633697b0eeedb17f10179e3542e7480631 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 23:19:12 -0500 Subject: [PATCH 32/40] fix(citygen): monuments used a segment count nothing else uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column read as a bright striped cage while the statue beside it looked correct, which is what finally identified this: the statue is boxes and a sphere, the column is cylinders, and the cylinders were being built at 16 segments. Everything in this app is drawn as a wireframe, so polyCount is not a quality setting — it is the look. At 5 a cylinder is a pentagonal prism with five vertical edges. At 16 it is a dense cage of lines that reads as a solid bright mass. Reading the live map settled which is correct: every structure on it uses polyCount 5, all 49 cylinders, all 46 spheres, all 21 boxes, without exception. Monuments are back on 5. The shape vocabulary stays — spheres, octahedra, cones, rotation axes, turned plinths — since none of that was the problem. Second time in two commits that overriding an app-wide default made a structure look foreign, colour being the first. The test now pins both against the shared constant. --- CHANGELOG.md | 2 +- .../src/cityGen/__tests__/monuments.test.ts | 13 +++- frontend/src/cityGen/monuments.ts | 68 ++++++++++--------- 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdfe0c9..fa9170a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes: spheres, octahedra, cones, cylinders faceted or smooth by `polyCount`, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes — spheres, octahedra, cones, cylinders, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes — while staying on the app-wide `polyCount` of 5, since everything is drawn as a wireframe and the segment count *is* the look rather than a quality setting. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts index 39153ab..701d802 100644 --- a/frontend/src/cityGen/__tests__/monuments.test.ts +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, SpatialGrid } from '../index'; +import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, POLY_COUNT, SpatialGrid } from '../index'; import type { Block, RawBuilding } from '../types'; /** @@ -154,6 +154,17 @@ describe('generateMonument', () => { expect(MONUMENT_COLOR).toBe('#00ff00'); }); + it('uses the same segment count as every other structure', () => { + // Everything is drawn as a wireframe, so polyCount is not a quality setting, it is + // the look. At 5 a cylinder is a pentagonal prism — what the whole city is built + // from. At 16 it is a dense cage of lines that reads as a bright striped mass, and + // a column at 16 stood out beside a statue made of boxes at 5. + for (const parts of allStyles()) { + for (const p of parts) expect(p.polyCount, p.shape).toBe(POLY_COUNT); + } + expect(POLY_COUNT).toBe(5); + }); + it('keeps its part count modest', () => { // Coincident wireframe edges are what made these glow beside neighbours built from // one or two masses. Detail has to come from silhouette, not from part count. diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts index 8ef8914..90fff57 100644 --- a/frontend/src/cityGen/monuments.ts +++ b/frontend/src/cityGen/monuments.ts @@ -14,10 +14,10 @@ import type { Block, RawBuilding, Rng } from './types'; * * **On detail.** A first version was two stacked boxes and read as exactly that. The * renderer supports more than a box — `cylinder`, `sphere`, `rhombus` (an octahedron), - * `pyramid` (a cone), a `polyCount` that turns a cylinder into a hexagonal prism at 6 - * or a smooth column at 16, and all three rotation axes. Silhouette is what carries a - * monument at this size, so these use the lot: stepped plinths turned 45° against each - * other, rings of bollards, tapered shafts, finials. + * `pyramid` (a cone) and all three rotation axes. Silhouette is what carries a monument + * at this size, so these use the lot: stepped plinths turned 45° against each other, + * rings of bollards, tapered shafts, finials. The segment count stays at the app's + * `POLY_COUNT` — raising it is what made these look foreign. */ /** Monument height as a multiple of the island span, per style. */ @@ -43,11 +43,16 @@ export const MONUMENT_STYLE_COUNT = 6; */ const MONUMENT_COLOR = '#00ff00'; -/** Segments for a shape meant to read as round rather than faceted. */ -const SMOOTH = 16; - -/** Segments for a shape meant to read as cut stone. */ -const FACETED = 6; +/** + * The app's segment count, used by every structure on the map. + * + * Everything is drawn as a wireframe, so `polyCount` is not a quality setting — it is + * the look. At 5 a cylinder is a pentagonal prism with five vertical edges, which is + * what the whole city is built from. At 16 it is a dense cage of lines that reads as a + * bright striped mass beside its neighbours, which is exactly how monuments ended up + * looking like they belonged to a different app. + */ +const POLY_COUNT = 5; /** Right angle, for turning a flat cylinder into a disc facing sideways. */ const QUARTER = Math.PI / 2; @@ -60,8 +65,9 @@ const EIGHTH = Math.PI / 4; * * Parts follow the same convention as the landmark styles: a single unparented root * first, then `ROOT` children the caller groups under it once the root has an id. - * Every part carries an explicit colour rather than deferring to the theme; see - * `MONUMENT_COLOR` for why. + * Colour and segment count both stay on the app-wide values — see `MONUMENT_COLOR` and + * `POLY_COUNT`. Both were overridden at some point and both times the result was a + * structure that did not look like it belonged to the same city. */ export function generateMonument(block: Block, span: number, out: RawBuilding[], rng: Rng): void { const style = Math.floor(rng() * MONUMENT_STYLE_COUNT); @@ -72,7 +78,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], /** Emit a part, making the first one the unparented root. */ const part = (p: Partial & { y: number; width: number; height: number }) => { const base: RawBuilding = { - name: '', x, z, depth: p.width, color, shape: 'box', polyCount: 5, + name: '', x, z, depth: p.width, color, shape: 'box', polyCount: POLY_COUNT, ...(p as object), } as RawBuilding; if (!rooted) { @@ -105,15 +111,15 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], const shaftW = span * 0.13; const shaftH = span * COLUMN_HEIGHT * 0.42; - part({ y, width: shaftW, height: shaftH, shape: 'cylinder', polyCount: SMOOTH }); + part({ y, width: shaftW, height: shaftH, shape: 'cylinder' }); y += shaftH; - part({ y, width: span * 0.2, height: span * 0.06, shape: 'cylinder', polyCount: SMOOTH }); + part({ y, width: span * 0.2, height: span * 0.06, shape: 'cylinder' }); y += span * 0.06; part({ y, width: span * 0.17, height: span * 0.22, shape: 'rhombus' }); around(4, span * 0.42, (px, pz) => - part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder', polyCount: FACETED })); + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder' })); return; } @@ -133,7 +139,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y: deckY, width: torsoW, depth: torsoW * 0.62, height: torsoH, rotation: facing }); const headY = deckY + torsoH; - part({ y: headY, width: span * 0.09, height: span * 0.09, shape: 'sphere', polyCount: SMOOTH }); + part({ y: headY, width: span * 0.09, height: span * 0.09, shape: 'sphere' }); // One arm raised, one at rest — the asymmetry is most of the silhouette. part({ @@ -157,24 +163,24 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], // keeps a run of roundabouts from all reading the same. const basinW = span * 0.78; const basinH = span * FOUNTAIN_HEIGHT * 0.4; - part({ y: 0, width: basinW, height: basinH, shape: 'cylinder', polyCount: SMOOTH }); - part({ y: basinH, width: basinW * 0.92, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH }); + part({ y: 0, width: basinW, height: basinH, shape: 'cylinder' }); + part({ y: basinH, width: basinW * 0.92, height: span * 0.02, shape: 'cylinder' }); let y = basinH + span * 0.02; const tiers = [0.3]; for (const w of tiers) { - part({ y, width: span * 0.08, height: span * 0.12, shape: 'cylinder', polyCount: FACETED }); + part({ y, width: span * 0.08, height: span * 0.12, shape: 'cylinder' }); y += span * 0.12; - part({ y, width: span * w, height: span * 0.05, shape: 'cylinder', polyCount: SMOOTH }); + part({ y, width: span * w, height: span * 0.05, shape: 'cylinder' }); y += span * 0.05; } - part({ y, width: span * 0.05, height: span * 0.22, shape: 'cylinder', polyCount: SMOOTH }); + part({ y, width: span * 0.05, height: span * 0.22, shape: 'cylinder' }); y += span * 0.22; - part({ y, width: span * 0.1, height: span * 0.1, shape: 'sphere', polyCount: SMOOTH }); + part({ y, width: span * 0.1, height: span * 0.1, shape: 'sphere' }); around(4, basinW * 0.36, (px, pz) => - part({ x: px, z: pz, y: basinH, width: span * 0.05, height: span * 0.1, shape: 'cylinder', polyCount: FACETED })); + part({ x: px, z: pz, y: basinH, width: span * 0.05, height: span * 0.1, shape: 'cylinder' })); return; } @@ -197,15 +203,15 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], const faceW = span * 0.15; // Two on the X faces, two on the Z faces; a cylinder's axis is Y, so each is // tipped a quarter turn about the axis that leaves it facing outward. - part({ x: x + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_z: QUARTER }); - part({ x: x - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_z: QUARTER }); - part({ z: z + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_x: QUARTER }); - part({ z: z - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', polyCount: SMOOTH, rotation_x: QUARTER }); + part({ x: x + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_z: QUARTER }); + part({ x: x - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_z: QUARTER }); + part({ z: z + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_x: QUARTER }); + part({ z: z - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_x: QUARTER }); y += shaftH; part({ y, width: span * 0.3, height: span * 0.1 }); y += span * 0.1; - part({ y, width: span * 0.32, height: span * 0.26, shape: 'pyramid', polyCount: 4, rotation: EIGHTH }); + part({ y, width: span * 0.32, height: span * 0.26, shape: 'pyramid', polyCount: POLY_COUNT, rotation: EIGHTH }); y += span * 0.26; part({ y, width: span * 0.07, height: span * 0.12, shape: 'rhombus' }); return; @@ -231,7 +237,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y: pierH + span * 0.29, width: span * 0.13, height: span * 0.18, shape: 'rhombus' }); around(4, span * 0.4, (px, pz) => - part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.1, shape: 'cylinder', polyCount: FACETED })); + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.1, shape: 'cylinder' })); return; } @@ -249,11 +255,11 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y, width: span * stages[i], height: h, rotation: i % 2 ? EIGHTH : 0 }); y += h; } - part({ y, width: span * 0.1, height: span * 0.16, shape: 'pyramid', polyCount: 4 }); + part({ y, width: span * 0.1, height: span * 0.16, shape: 'pyramid', polyCount: POLY_COUNT }); around(4, span * 0.34, (px, pz) => part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'rhombus' })); } -export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, SMOOTH, FACETED, MONUMENT_COLOR }; +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, POLY_COUNT, MONUMENT_COLOR }; From ec4d88807d7e4c1e94115a6656dd8ea45d291f3a Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 23:34:51 -0500 Subject: [PATCH 33/40] fix(citygen): monuments published fake tokens and hid themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monuments used shape 'rhombus' for their finials, on the assumption it was just an octahedron in the shape list. It is not. In this app a rhombus IS a player or NPC token — the server's TOKEN_SHAPES treats it as one, a region purge spares it as player content, and OverlapChecker registers it in activeRhombuses precisely so a structure containing a token can be turned transparent, which is how you see a token standing behind a wall. So every column, arch, clock and obelisk placed a fake token inside itself. The overlap check duly found a token standing in the structure and dropped its fill to zero opacity. The monument made itself invisible. That accounts for both reports. The see-through look was zero-opacity fill, not colour and not polyCount — and the statue and the fountain, the only two styles without a finial, were the only two that ever looked right, which was the clue in the screenshots all along. It also accounts for the twelve orphaned parts on the live map: a regenerate deleted each monument but spared its finial as player content, leaving a child pointing at a root that no longer existed. Every one of the twelve was a monument part; the other 2791 parented locations were intact. Finials are cones now, corner posts are cylinders. A SHAPES allow-list records which shapes a monument may use and the test asserts membership, so 'rhombus' cannot come back by looking like a reasonable choice. Found by exposing the scene and reading the actual material opacity at the monument's position, after two fixes from inference had missed. The temporary probe is removed. --- CHANGELOG.md | 3 +- .../src/cityGen/__tests__/monuments.test.ts | 21 +++++++++++- frontend/src/cityGen/monuments.ts | 32 +++++++++++++++---- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9170a..dcbae1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes — spheres, octahedra, cones, cylinders, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes — while staying on the app-wide `polyCount` of 5, since everything is drawn as a wireframe and the segment count *is* the look rather than a quality setting. +- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes — spheres, cones, cylinders, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes — while staying on the app-wide `polyCount` of 5, since everything is drawn as a wireframe and the segment count *is* the look rather than a quality setting. +- **Monuments turned themselves invisible.** They used `rhombus` for their finials, taking it for an octahedron. In this app a rhombus *is* a player or NPC token: the server's `TOKEN_SHAPES` treats it as one, a region purge spares it as player content, and `OverlapChecker` registers it in `activeRhombuses` so a structure containing a token can be made transparent — which is how you see a token standing behind a wall. So every monument published a fake token inside itself, the overlap check found it, and the fill dropped to zero opacity. The same finials then survived each regenerate as "player content", orphaning themselves from their deleted roots. The statue and the fountain, the only two styles with no finial, were the only ones that ever looked right. - **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. - **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. - **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts index 701d802..c1be1f2 100644 --- a/frontend/src/cityGen/__tests__/monuments.test.ts +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, POLY_COUNT, SpatialGrid } from '../index'; +import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, POLY_COUNT, SHAPES, SpatialGrid } from '../index'; import type { Block, RawBuilding } from '../types'; /** @@ -165,6 +165,25 @@ describe('generateMonument', () => { expect(POLY_COUNT).toBe(5); }); + it('never uses the rhombus shape, which the app reserves for tokens', () => { + // A rhombus *is* a player or NPC token here: the server's TOKEN_SHAPES treats it as + // one, a region purge spares it as player content, and OverlapChecker registers it + // in activeRhombuses so structures containing a token can be made transparent. + // + // Used as an octahedral finial it made each monument publish a fake token inside + // itself, so the overlap check dropped the structure's fill to zero opacity and the + // monument turned itself invisible — and the finial then survived every regenerate + // as "player content", orphaning itself from its deleted root. The statue and the + // fountain, the only two styles without a finial, were the only ones that looked + // right. + for (const parts of allStyles()) { + for (const p of parts) { + expect(p.shape, `style using ${p.shape}`).not.toBe('rhombus'); + expect(SHAPES).toContain(p.shape); + } + } + }); + it('keeps its part count modest', () => { // Coincident wireframe edges are what made these glow beside neighbours built from // one or two masses. Detail has to come from silhouette, not from part count. diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts index 90fff57..5e2c453 100644 --- a/frontend/src/cityGen/monuments.ts +++ b/frontend/src/cityGen/monuments.ts @@ -13,8 +13,8 @@ import type { Block, RawBuilding, Rng } from './types'; * go wrong when the road widths are next retuned. * * **On detail.** A first version was two stacked boxes and read as exactly that. The - * renderer supports more than a box — `cylinder`, `sphere`, `rhombus` (an octahedron), - * `pyramid` (a cone) and all three rotation axes. Silhouette is what carries a monument + * renderer supports more than a box — `cylinder`, `sphere`, `pyramid` (a cone) and all + * three rotation axes. Note which shape is *not* in that list: see `SHAPES` below. Silhouette is what carries a monument * at this size, so these use the lot: stepped plinths turned 45° against each other, * rings of bollards, tapered shafts, finials. The segment count stays at the app's * `POLY_COUNT` — raising it is what made these look foreign. @@ -43,6 +43,24 @@ export const MONUMENT_STYLE_COUNT = 6; */ const MONUMENT_COLOR = '#00ff00'; +/** + * Shapes a monument may use. + * + * `rhombus` is deliberately absent, and this is not a style preference. In this app a + * rhombus *is* a player or NPC token: `TOKEN_SHAPES` on the server treats it as one, a + * region purge spares it as player content, and `OverlapChecker` registers it in + * `activeRhombuses` so that structures containing it can be made transparent — which is + * how you see a token standing behind a wall. + * + * Using it as an octahedral finial therefore made each monument publish a fake token + * inside itself. The overlap check found it, concluded a token was standing in the + * structure, and dropped the fill to zero opacity — a monument that turned itself + * invisible. It also survived every regenerate as "player content", orphaning itself + * from the deleted root. The statue and the fountain, the two styles with no finial, + * were the only ones that ever looked right. + */ +const SHAPES = ['box', 'cylinder', 'sphere', 'pyramid'] as const; + /** * The app's segment count, used by every structure on the map. * @@ -116,7 +134,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y, width: span * 0.2, height: span * 0.06, shape: 'cylinder' }); y += span * 0.06; - part({ y, width: span * 0.17, height: span * 0.22, shape: 'rhombus' }); + part({ y, width: span * 0.17, height: span * 0.22, shape: 'pyramid' }); around(4, span * 0.42, (px, pz) => part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder' })); @@ -213,7 +231,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], y += span * 0.1; part({ y, width: span * 0.32, height: span * 0.26, shape: 'pyramid', polyCount: POLY_COUNT, rotation: EIGHTH }); y += span * 0.26; - part({ y, width: span * 0.07, height: span * 0.12, shape: 'rhombus' }); + part({ y, width: span * 0.07, height: span * 0.12, shape: 'pyramid' }); return; } @@ -234,7 +252,7 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], const spanW = gap * 2 + pierW; part({ y: pierH, width: spanW, depth: pierW, height: span * 0.13, rotation: facing }); part({ y: pierH + span * 0.13, width: spanW * 0.82, depth: pierW * 0.9, height: span * 0.16, rotation: facing }); - part({ y: pierH + span * 0.29, width: span * 0.13, height: span * 0.18, shape: 'rhombus' }); + part({ y: pierH + span * 0.29, width: span * 0.13, height: span * 0.18, shape: 'pyramid' }); around(4, span * 0.4, (px, pz) => part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.1, shape: 'cylinder' })); @@ -258,8 +276,8 @@ export function generateMonument(block: Block, span: number, out: RawBuilding[], part({ y, width: span * 0.1, height: span * 0.16, shape: 'pyramid', polyCount: POLY_COUNT }); around(4, span * 0.34, (px, pz) => - part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'rhombus' })); + part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'cylinder' })); } -export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, POLY_COUNT, MONUMENT_COLOR }; +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, POLY_COUNT, MONUMENT_COLOR, SHAPES }; From 6b54192e08c87ec4bebd06046a5e060050f2a21e Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 23:45:01 -0500 Subject: [PATCH 34/40] docs: record the conventions a generator must not override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three app-wide defaults were overridden while building the roundabout monuments, and each one produced a structure that did not look like it belonged to the same city. None of them is what its name suggests: color '#00ff00' not green — the sentinel meaning "inherit the theme" polyCount 5 not quality — everything is a wireframe, so the segment count is the look shape 'rhombus' not an octahedron — a player or NPC token The last one cost the most to find, so the README now carries it on the monuments.ts line where the next person writing a generator will meet it, and the changelog has all three under Technical. --- CHANGELOG.md | 1 + README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbae1e..967c259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **Three app-wide conventions a generator must not override.** All three were overridden while building the roundabout monuments and each produced a structure that did not look like it belonged to the same city, so they are recorded here and asserted in tests. `color: '#00ff00'` is not green, it is the sentinel meaning "inherit the theme" — the renderer resolves anything else verbatim, so naming a real colour opts a structure out of theming entirely. `polyCount: 5` is not a quality setting: everything is drawn as a wireframe, so the segment count *is* the look, and a 16-segment cylinder reads as a bright striped cage beside a city of pentagonal prisms. And `shape: 'rhombus'` is not an octahedron, it is a player or NPC token — `TOKEN_SHAPES` treats it as one on the server, a purge spares it as player content, and `OverlapChecker` publishes it in `activeRhombuses`. - **A roundabout island is a tiny lake, as far as roads are concerned.** `clipSegmentToLand` already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it — so trimming reuses the water clipper rather than a second implementation, and the ring reuses the arc sampling `RING` uses for its beltways. Siting has to handle two kinds of junction: a BSP or Voronoi network joins at shared endpoints, but `GRID` lays each street as one full-length span, so its crossings share no endpoint and are found only by intersecting segments — `segmentCrossing` was promoted out of the water clipper for that. The whole pass runs *after* `consolidateRoads`, which snaps nearby endpoints together and would otherwise snap a ring of short segments into a blob. - **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice — a perfect lattice gives a honeycomb as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. - **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means the road grid stops at the banks of its own accord and bridges are sited from the stubs left there — generating it afterwards would mean cutting finished roads, which is a different and worse problem. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. diff --git a/README.md b/README.md index feda6b0..c608cfe 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ CITY_NET/ │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp │ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule -│ │ │ ├── monuments.ts # Six small civic ornaments for a roundabout island — column, statue, fountain, clock tower, arch, obelisk — multi-part silhouettes using the renderer's full shape set, sized against the island rather than the skyline +│ │ │ ├── monuments.ts # Six small civic ornaments for a roundabout island — column, statue, fountain, clock tower, arch, obelisk — sized against the island rather than the skyline. Shapes come from a SHAPES allow-list that deliberately excludes `rhombus`: a rhombus is a player/NPC token here, so using one as a finial made a monument publish a fake token inside itself, which turned it transparent and made it survive a purge as player content │ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) │ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them @@ -425,7 +425,7 @@ CITY_NET/ │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks │ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned -│ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, stacking without floating, one root per monument +│ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, nothing floating, one root per monument, and the three app-wide conventions a generator must not override — the `#00ff00` theme sentinel, `polyCount` 5, and never the token-reserved `rhombus` │ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout │ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels │ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after From fab8c4a5c66cd3713888011bff2abb6513b363b6 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 1 Aug 2026 23:47:29 -0500 Subject: [PATCH 35/40] docs: drop 1.8.0 fixes that were never user facing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten of the eleven entries under Fixed described bugs introduced and fixed inside this branch — puddle-sized ponds, monuments carrying skyscrapers or turning themselves invisible, roundabouts in water, REGENERATE building around its own deleted river, seed handling. 1.8.0 has not shipped, so no one ever ran any of them. The end state is already described under Added, where a reader looking for what the release does will actually find it. The one that stays is the one that was real: water bridges shipped in 1.7.x and pierced the buildings they passed over. The conventions note under Technical is reworded as a standing rule rather than an account of breaking it three times. --- CHANGELOG.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 967c259..a037b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,17 +34,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed -- **Park ponds were puddles.** Measured on real output, ponds came out around 4 units across in plots of 50 and 65 — 6 to 9% of what you actually see. A pond was a circle sized off `Math.min(bw, bd)`, and blocks out of the split are frequently long thin rectangles, so the circle could only ever be as wide as the short side. Ponds are ellipses now, one radius per axis, spanning 35–55% of each. -- **Roundabout islands carried skyscrapers.** The island dressing reused the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline — so a traffic island grew a tower out of it. Islands now get one of six small civic ornaments instead — a column, a statue, a fountain, a clock tower, a triumphal arch or an obelisk — each proportional to the island rather than a fixed height, and none taller than about one and a half island spans. They use the same `#00ff00` colour sentinel as every other generated structure, which is how a structure defers to the active theme rather than naming a colour of its own, and their part counts are kept modest so coincident wireframe edges do not stack into a bright blob beside neighbours built from one or two masses. They are built from the renderer's full shape vocabulary rather than stacked boxes — spheres, cones, cylinders, plinth steps turned 45° against each other, rings of bollards, and clock faces stood on edge with the rotation axes — while staying on the app-wide `polyCount` of 5, since everything is drawn as a wireframe and the segment count *is* the look rather than a quality setting. -- **Monuments turned themselves invisible.** They used `rhombus` for their finials, taking it for an octahedron. In this app a rhombus *is* a player or NPC token: the server's `TOKEN_SHAPES` treats it as one, a region purge spares it as player content, and `OverlapChecker` registers it in `activeRhombuses` so a structure containing a token can be made transparent — which is how you see a token standing behind a wall. So every monument published a fake token inside itself, the overlap check found it, and the fill dropped to zero opacity. The same finials then survived each regenerate as "player content", orphaning themselves from their deleted roots. The statue and the fountain, the only two styles with no finial, were the only ones that ever looked right. -- **Roundabout islands were treated as GM-authored.** They were given a name of their own, and anything outside `ZONE_TYPE_NAMES` counts as authored by hand — so they rendered in the purple reserved for structures with data, and, less visibly, a region purge kept them, meaning every regenerate left its old islands behind and stacked new ones on them. They are named as what they already are, `LANDMARK` or `PARK`, rather than adding a name to a set the frontend and backend each keep their own copy of. -- **Roundabouts could sit half in the water.** Siting tested the junction point, but a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so those are what is tested now. A drawn boundary had the same defect and the same fix. -- **`REGENERATE` built around the river it had just deleted.** The purge re-read locations and roads before generating — so the new city would not avoid buildings that were gone — but not water. Generation therefore ran against the previous river as well as the new one, leaving a band of empty ground tracing where the old one used to run. -- **A typed seed is used as typed.** Parsing forced the value through `>>> 0`, which wrapped anything above 2³², so a long numeric seed silently became a different one. Seeds are hashed into range instead. -- **`REGENERATE` rolls a new seed** unless one is asked for. The seed field was doing double duty as both the request and the readout, so writing the used seed back into it meant every later regenerate rebuilt the identical city — which reads as the purge having failed. -- **Elevated arterials no longer run through buildings.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an arterial sterilising every block it crosses — but nothing then stopped a tower rising through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. -- **Skyscrapers floated above the ground near an overpass.** Capping a building under a deck scaled its `height` but not its `y`. A plot is usually several stacked parts, and a part sitting on another has its `y` set to that one's height, so shortening the bases left every upper storey hanging in mid-air. -- **Buildings still floated after that fix**, because the cap was applied per part rather than per plot: shortening one part of a stack and not its neighbours pulls the stack apart just as surely. A whole plot is now scaled by one factor, grouped by the `temp_block_id` the generator already stamps on every piece it emits. +- **Water bridges no longer pierce the buildings they pass over.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an elevated road sterilising every block it crosses — but nothing then stopped a tower rising straight through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. ### Technical @@ -55,7 +45,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. -- **Three app-wide conventions a generator must not override.** All three were overridden while building the roundabout monuments and each produced a structure that did not look like it belonged to the same city, so they are recorded here and asserted in tests. `color: '#00ff00'` is not green, it is the sentinel meaning "inherit the theme" — the renderer resolves anything else verbatim, so naming a real colour opts a structure out of theming entirely. `polyCount: 5` is not a quality setting: everything is drawn as a wireframe, so the segment count *is* the look, and a 16-segment cylinder reads as a bright striped cage beside a city of pentagonal prisms. And `shape: 'rhombus'` is not an octahedron, it is a player or NPC token — `TOKEN_SHAPES` treats it as one on the server, a purge spares it as player content, and `OverlapChecker` publishes it in `activeRhombuses`. +- **Three app-wide conventions a generator must not override.** None is what its name suggests, each is asserted in a test, and getting any of them wrong produces a structure that does not look like it belongs to the same city. `color: '#00ff00'` is not green, it is the sentinel meaning "inherit the theme" — the renderer resolves anything else verbatim, so naming a real colour opts a structure out of theming entirely. `polyCount: 5` is not a quality setting: everything is drawn as a wireframe, so the segment count *is* the look, and a 16-segment cylinder reads as a bright striped cage beside a city of pentagonal prisms. And `shape: 'rhombus'` is not an octahedron, it is a player or NPC token — `TOKEN_SHAPES` treats it as one on the server, a purge spares it as player content, and `OverlapChecker` publishes it in `activeRhombuses`, so a structure using one as a finial publishes a fake token inside itself. - **A roundabout island is a tiny lake, as far as roads are concerned.** `clipSegmentToLand` already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it — so trimming reuses the water clipper rather than a second implementation, and the ring reuses the arc sampling `RING` uses for its beltways. Siting has to handle two kinds of junction: a BSP or Voronoi network joins at shared endpoints, but `GRID` lays each street as one full-length span, so its crossings share no endpoint and are found only by intersecting segments — `segmentCrossing` was promoted out of the water clipper for that. The whole pass runs *after* `consolidateRoads`, which snaps nearby endpoints together and would otherwise snap a ring of short segments into a blob. - **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice — a perfect lattice gives a honeycomb as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. - **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means the road grid stops at the banks of its own accord and bridges are sited from the stubs left there — generating it afterwards would mean cutting finished roads, which is a different and worse problem. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. From 658cec943734d4fd24633a0189790e58e2d437a1 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 2 Aug 2026 11:05:41 -0500 Subject: [PATCH 36/40] =?UTF-8?q?feat(citygen):=20DOWNTOWN=20layout=20?= =?UTF-8?q?=E2=80=94=20blocks=20cut=20into=20street-facing=20lots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new layout rather than a change to SUPERBLOCK. The reference for this is Greenwich Village: a fine grid where each block is cut into many narrow lots, buildings sharing party walls into a continuous street wall, with back lots in the middle. SUPERBLOCK is the opposite idea — few roads, large plots, open ground between isolated towers — and packing it with buildings would have destroyed the one city type it exists to produce while duplicating GRID. Every layout until now handed the generator one block per city block, so a block got one structure. This one hands over the lots instead: 489 of them where GRID produces 121 blocks over the same ground, median footprint 332 against 2561. Blocks are 70 by 150 rather than square, because a Manhattan block is roughly three times longer than it is deep and that shape is most of why the city reads as it does — it is what puts towers on the avenues and terraces on the side streets. The one change reaching outside the layout is Block.lot. The generator trims road padding, clamps aspect toward square and applies a per-zone setback; all three turn a whole city block into one sensible plot, and all three undo a street wall when applied to lots within a block. A flagged block skips them. No existing layout sets it, and a test pins that. --- CHANGELOG.md | 2 + README.md | 4 +- .../src/cityGen/__tests__/layouts.test.ts | 2 +- .../src/cityGen/__tests__/perimeter.test.ts | 194 ++++++++++++++++++ frontend/src/cityGen/index.ts | 23 ++- frontend/src/cityGen/layouts.ts | 84 +++++++- frontend/src/cityGen/lots.ts | 113 ++++++++++ frontend/src/cityGen/types.ts | 11 + frontend/src/components/AdminPanel.tsx | 1 + .../components/__tests__/AdminPanel.test.tsx | 2 +- 10 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 frontend/src/cityGen/__tests__/perimeter.test.ts create mode 100644 frontend/src/cityGen/lots.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a037b7a..811e28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. - **`ORGANIC_CELLS`** — a Voronoi diagram with streets along the cell boundaries. The only layout with no right angles in it: streets meet at odd angles and blocks are wedges and pentagons, which reads as a town that grew around footpaths rather than one a surveyor set out. Long cell boundaries become avenues, so the network gets a hierarchy without one being invented — the long runs across the diagram are the ones that would carry traffic anyway. + - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. - **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. - **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. @@ -45,6 +46,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`RING` elevates its spokes but leaves its loops on the ground.** A closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. - Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. - `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **`Block.lot` lets a layout subdivide a block itself.** The generator trims road padding off a block, clamps its aspect toward square and applies a per-zone setback — three rules that exist to turn a whole city block into one sensible plot. Applied to lots inside a subdivided block they pad the neighbours apart, square up the narrow frontages and pull the row back off the street, which is every ingredient of a street wall undone. A block flagged `lot` skips all three, its footprint being already decided. No existing layout sets it, so all five are untouched, and there is a test asserting that. - **Three app-wide conventions a generator must not override.** None is what its name suggests, each is asserted in a test, and getting any of them wrong produces a structure that does not look like it belongs to the same city. `color: '#00ff00'` is not green, it is the sentinel meaning "inherit the theme" — the renderer resolves anything else verbatim, so naming a real colour opts a structure out of theming entirely. `polyCount: 5` is not a quality setting: everything is drawn as a wireframe, so the segment count *is* the look, and a 16-segment cylinder reads as a bright striped cage beside a city of pentagonal prisms. And `shape: 'rhombus'` is not an octahedron, it is a player or NPC token — `TOKEN_SHAPES` treats it as one on the server, a purge spares it as player content, and `OverlapChecker` publishes it in `activeRhombuses`, so a structure using one as a finial publishes a fake token inside itself. - **A roundabout island is a tiny lake, as far as roads are concerned.** `clipSegmentToLand` already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it — so trimming reuses the water clipper rather than a second implementation, and the ring reuses the arc sampling `RING` uses for its beltways. Siting has to handle two kinds of junction: a BSP or Voronoi network joins at shared endpoints, but `GRID` lays each street as one full-length span, so its crossings share no endpoint and are found only by intersecting segments — `segmentCrossing` was promoted out of the water clipper for that. The whole pass runs *after* `consolidateRoads`, which snaps nearby endpoints together and would otherwise snap a ring of short segments into a blob. - **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and Fortune's algorithm would be several hundred lines of beach line and event queue to save milliseconds nobody is waiting on. Seeds sit on a jittered lattice — a perfect lattice gives a honeycomb as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. diff --git a/README.md b/README.md index c608cfe..e122e6d 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,8 @@ CITY_NET/ │ │ │ ├── index.ts # generateCity orchestrator; selects a layout, caps buildings under decks; injected rng and fillPlot make it testable │ │ │ ├── types.ts # Bounds, Block, RawBuilding, Obstacle, options/context/result shapes │ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land and to any drawn boundary as they are laid; optional minimum block size -│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc), VORONOI (organic cells, streets on the cell boundaries) +│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc), VORONOI (organic cells, streets on the cell boundaries), PERIMETER (elongated blocks cut into street-facing lots) +│ │ │ ├── lots.ts # Cuts a block into building lots around its rim, back lots left open in the middle; each lot is flagged Block.lot so the generator takes the footprint as given instead of padding, squaring and setting it back │ │ │ ├── voronoi.ts # Voronoi cells by half-plane clipping, shared-edge dedup, and the inscribed rectangle that lets an irregular cell feed a rectangle-only plot filler │ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp @@ -424,6 +425,7 @@ CITY_NET/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks +│ │ │ ├── perimeter.test.ts # Lots that tile a block without overlapping, terrace gaps under two units, varied frontages, open back lots, an elongated street grid, and every other layout left unflagged │ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned │ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, nothing floating, one root per monument, and the three app-wide conventions a generator must not override — the `#00ff00` theme sentinel, `polyCount` 5, and never the token-reserved `rhombus` │ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts index ab93d5d..58f81c8 100644 --- a/frontend/src/cityGen/__tests__/layouts.test.ts +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -61,7 +61,7 @@ function distanceToSegment( describe('layout registry', () => { it('offers every layout type', () => { - expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'RING', 'SUPERBLOCK', 'VORONOI']); + expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'PERIMETER', 'RING', 'SUPERBLOCK', 'VORONOI']); }); it('every layout produces blocks for the same area', () => { diff --git a/frontend/src/cityGen/__tests__/perimeter.test.ts b/frontend/src/cityGen/__tests__/perimeter.test.ts new file mode 100644 index 0000000..48711dd --- /dev/null +++ b/frontend/src/cityGen/__tests__/perimeter.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { + perimeterLots, perimeterLayout, LAYOUTS, LOT_DEPTH, + generateCity, splitCity, +} from '../index'; +import type { Block } from '../types'; + +/** + * Downtown layout. + * + * The point of it is density and a street wall, so that is what these check: many lots + * per block, narrow frontages left narrow, neighbours close enough to read as a + * terrace, and the middle of the block left open. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +const bigBlock: Block = { x: 0, z: 0, w: 140, d: 80 }; + +/** Axis-aligned overlap between two lots, ignoring a hair of tolerance. */ +const overlaps = (a: Block, b: Block) => + Math.abs(a.x - b.x) < (a.w + b.w) / 2 - 0.01 && + Math.abs(a.z - b.z) < (a.d + b.d) / 2 - 0.01; + +describe('perimeterLots', () => { + it('cuts a block into many lots', () => { + const lots = perimeterLots(bigBlock, seededRng()); + expect(lots.length).toBeGreaterThan(8); + }); + + it('marks every lot as already sized', () => { + // Block.lot is what stops the generator padding, squaring and setting back a + // footprint the layout has already decided. Without it there is no terrace. + for (const lot of perimeterLots(bigBlock, seededRng())) { + expect(lot.lot).toBe(true); + } + }); + + it('keeps every lot inside the block', () => { + for (const lot of perimeterLots(bigBlock, seededRng())) { + expect(Math.abs(lot.x) + lot.w / 2).toBeLessThanOrEqual(bigBlock.w / 2 + 0.01); + expect(Math.abs(lot.z) + lot.d / 2).toBeLessThanOrEqual(bigBlock.d / 2 + 0.01); + } + }); + + it('does not overlap its own lots', () => { + // The runs along x take the corners, so the runs along z must fill only the gap + // between them. Getting that wrong stacks buildings on the corners. + const lots = perimeterLots(bigBlock, seededRng()); + for (let i = 0; i < lots.length; i++) { + for (let j = i + 1; j < lots.length; j++) { + expect(overlaps(lots[i], lots[j]), `lot ${i} vs ${j}`).toBe(false); + } + } + }); + + it('leaves the middle of the block open', () => { + // Back lots are half of what makes a perimeter block read as one. + const lots = perimeterLots(bigBlock, seededRng()); + const inMiddle = lots.filter(l => + Math.abs(l.x) < bigBlock.w / 2 - LOT_DEPTH && Math.abs(l.z) < bigBlock.d / 2 - LOT_DEPTH); + expect(inMiddle).toHaveLength(0); + }); + + it('puts neighbours close enough to read as a terrace', () => { + // The whole point. A gap of more than a metre or two and it is detached houses. + const lots = perimeterLots(bigBlock, seededRng()) + .filter(l => Math.abs(l.z - (bigBlock.z - bigBlock.d / 2 + LOT_DEPTH / 2)) < 0.01) + .sort((a, b) => a.x - b.x); + expect(lots.length).toBeGreaterThan(2); + for (let i = 1; i < lots.length; i++) { + const gap = (lots[i].x - lots[i].w / 2) - (lots[i - 1].x + lots[i - 1].w / 2); + expect(gap).toBeLessThan(2); + expect(gap).toBeGreaterThanOrEqual(0); + } + }); + + it('varies the frontages', () => { + // A row of identical widths reads as a barracks. + const widths = new Set(perimeterLots(bigBlock, seededRng()).map(l => l.w.toFixed(2))); + expect(widths.size).toBeGreaterThan(3); + }); + + it('builds a small block solid rather than cutting a hole in it', () => { + // A block with no room for a rim and a middle would otherwise become four slivers + // around a courtyard. + const small: Block = { x: 0, z: 0, w: 30, d: 26 }; + const lots = perimeterLots(small, seededRng()); + expect(lots).toHaveLength(1); + expect(lots[0].w).toBe(30); + expect(lots[0].d).toBe(26); + }); + + it('reproduces from a seed', () => { + expect(perimeterLots(bigBlock, seededRng(11))).toEqual(perimeterLots(bigBlock, seededRng(11))); + }); +}); + +describe('perimeterLayout', () => { + it('is registered', () => { + expect(LAYOUTS.PERIMETER).toBe(perimeterLayout); + }); + + it('is far denser than the grid it is built on', () => { + const grid = LAYOUTS.GRID(bounds(300), false, seededRng()); + const downtown = perimeterLayout(bounds(300), false, seededRng()); + expect(downtown.blocks.length).toBeGreaterThan(grid.blocks.length * 2); + }); + + it('lays an elongated street grid, not a square one', () => { + // A Manhattan block is roughly three times longer than it is deep, and that shape is + // most of why the city reads as it does. The blocks themselves never reach the + // caller — they are cut into lots first — so the grid that made them is what can be + // measured, via the spacing between parallel streets on each axis. + const { roads } = perimeterLayout(bounds(600), false, seededRng()); + const spacing = (vals: number[]) => { + const uniq = [...new Set(vals.map(v => Math.round(v)))].sort((a, b) => a - b); + const gaps = uniq.slice(1).map((v, i) => v - uniq[i]).filter(g => g > 5); + return gaps.sort((a, b) => a - b)[Math.floor(gaps.length / 2)]; + }; + const acrossX = spacing(roads.filter(r => Math.abs(r.x1 - r.x2) < 0.5).map(r => r.x1)); + const acrossZ = spacing(roads.filter(r => Math.abs(r.z1 - r.z2) < 0.5).map(r => r.z1)); + const ratio = Math.max(acrossX, acrossZ) / Math.min(acrossX, acrossZ); + expect(ratio).toBeGreaterThan(1.5); + }); + + it('still lays roads', () => { + expect(perimeterLayout(bounds(300), false, seededRng()).roads.length).toBeGreaterThan(0); + }); + + it('lays no roads when excluded', () => { + expect(perimeterLayout(bounds(300), true, seededRng()).roads).toHaveLength(0); + }); + + it('keeps roads out of the water', () => { + const lake = { points: [ + { x: -80, z: -80 }, { x: 80, z: -80 }, { x: 80, z: 80 }, { x: -80, z: 80 }, + ] }; + const { roads } = perimeterLayout(bounds(300), false, seededRng(), [lake]); + for (const r of roads) { + const mx = (r.x1 + r.x2) / 2; + const mz = (r.z1 + r.z2) / 2; + expect(Math.abs(mx) < 80 && Math.abs(mz) < 80).toBe(false); + } + }); + + it('drops lots outside a drawn boundary', () => { + const boundary = { points: [ + { x: -100, z: -100 }, { x: 100, z: -100 }, { x: 100, z: 100 }, { x: -100, z: 100 }, + ] }; + const { blocks } = perimeterLayout(bounds(300), true, seededRng(), [], boundary); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThan(200); + expect(Math.abs(b.z)).toBeLessThan(200); + } + }); + + it('reproduces from a seed', () => { + expect(perimeterLayout(bounds(300), false, seededRng(3))) + .toEqual(perimeterLayout(bounds(300), false, seededRng(3))); + }); +}); + +describe('Block.lot in the generator', () => { + it('leaves every other layout untouched', () => { + // No existing layout sets `lot`, so the padding, aspect clamp and setback all still + // apply exactly as before. + const { blocks } = splitCity(bounds(300), false, seededRng()); + expect(blocks.some(b => b.lot)).toBe(false); + }); + + it('builds a downtown', () => { + const res = generateCity( + bounds(400), { sectionType: 'MIXED', excludeRoads: false, layout: 'PERIMETER' }, + freshContext(), seededRng(5), deps + ); + expect(res.blocks.length).toBeGreaterThan(0); + expect(res.roads.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 5fe925c..dbc3737 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -173,8 +173,10 @@ export function generateCity( const plotId = `gen_${index}`; const startIndex = buildings.length; - let bw = block.w - PLOT_PADDING; - let bd = block.d - PLOT_PADDING; + // A lot arrives with its footprint already decided by the layout; a block gets the + // road margin trimmed off it here. See `Block.lot`. + let bw = block.lot ? block.w : block.w - PLOT_PADDING; + let bd = block.lot ? block.d : block.d - PLOT_PADDING; if (bw < MIN_PLOT_SIZE || bd < MIN_PLOT_SIZE) return; // A plot centred in water is open water — skip it outright. Plots that @@ -226,12 +228,17 @@ export function generateCity( block.x, block.z, centerX, centerZ, normDist, sectionType, sectors, rng ); const zonePrefix = zonePrefixFor(zoneTypeVal); - ({ bw, bd } = clampPlotAspect(bw, bd, zoneTypeVal)); - // Setback: corporate plots leave forecourts, slums and markets build to the lot - // line. Applied after the aspect clamp so it shrinks the plot actually used. - const coverage = lotCoverageFor(zoneTypeVal); - bw *= coverage; - bd *= coverage; + // A lot skips both: its narrow frontage is deliberate, and squaring it up or + // setting it back would pull a terrace apart into detached sheds. Both rules are + // about fitting one structure sensibly onto a whole city block. + if (!block.lot) { + ({ bw, bd } = clampPlotAspect(bw, bd, zoneTypeVal)); + // Setback: corporate plots leave forecourts, slums and markets build to the lot + // line. Applied after the aspect clamp so it shrinks the plot actually used. + const coverage = lotCoverageFor(zoneTypeVal); + bw *= coverage; + bd *= coverage; + } if (shouldPlaceLandmark(block, bw, bd, zoneTypeVal, isBlocked, rng)) { generateLandmark(block, bw, bd, buildings, grid, rng); diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index ce8f79e..09a5197 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -3,6 +3,7 @@ import type { OverpassSpec } from './bridges'; import { normalizeBounds, splitCity } from './bsp'; import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; import { seedPoints, voronoiCells, cellEdges, inscribedRect, VORONOI_SPACING } from './voronoi'; +import { perimeterLots } from './lots'; /** * Street layouts. @@ -19,7 +20,7 @@ export type LayoutFn = ( boundary?: Polygon ) => { blocks: Block[]; roads: RoadSegment[]; overpasses?: OverpassSpec[] }; -export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING' | 'VORONOI'; +export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING' | 'VORONOI' | 'PERIMETER'; /** Target block size for the regular grid, before jitter. */ const GRID_CELL = 55; @@ -72,6 +73,16 @@ const DECK_PILLAR_SPACING = 14; /** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ const ARC_STEP_DEG = 9; +/** + * Downtown block size, short axis by long axis. + * + * Deliberately not square. A Manhattan block is roughly three times longer than it is + * deep, which is why the avenues carry the towers and the side streets carry terraces — + * the shape of the block is most of why the city reads as it does. + */ +const DOWNTOWN_CELL_SHORT = 70; +const DOWNTOWN_CELL_LONG = 150; + /** A Voronoi edge longer than this many spacings is an avenue rather than a street. */ const VORONOI_AVENUE_RATIO = 1.15; @@ -83,8 +94,8 @@ const VORONOI_STREET_WIDTH = 4; * grid rather than a machine one. The outer edges stay put, since they are the boundary * of the generated area and should not wobble. */ -function gridLines(min: number, span: number, rng: Rng): number[] { - const count = Math.max(1, Math.round(span / GRID_CELL)); +function gridLines(min: number, span: number, rng: Rng, cellSize = GRID_CELL): number[] { + const count = Math.max(1, Math.round(span / cellSize)); const cell = span / count; const lines: number[] = []; for (let i = 0; i <= count; i++) { @@ -310,13 +321,78 @@ export const voronoiLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], b return { blocks, roads }; }; +/** + * Downtown — a street grid whose blocks are cut into building lots. + * + * Every other layout hands the generator one block per city block, so a block gets one + * structure. That is right for a tower in a park and wrong for a downtown: what makes a + * dense city look dense is many narrow buildings shouldering together along the street + * with back lots behind them, not one object per block. + * + * So the blocks come out subdivided — lots around the rim facing the street, the middle + * left open. Blocks are elongated rather than square because that is the shape that + * produces avenue frontages and side-street terraces. + * + * Distinct from `SUPERBLOCK`, which is the opposite idea and stays that way: few roads, + * large plots, open ground between isolated towers. + */ +export const perimeterLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const { minX, minZ, width, depth } = normalizeBounds(bounds); + + // The long axis of a block runs across the shorter axis of the region, so the streets + // that carry it read as the avenues. + const horizontal = width >= depth; + const xs = gridLines(minX, width, rng, horizontal ? DOWNTOWN_CELL_LONG : DOWNTOWN_CELL_SHORT); + const zs = gridLines(minZ, depth, rng, horizontal ? DOWNTOWN_CELL_SHORT : DOWNTOWN_CELL_LONG); + + const blocks: Block[] = []; + const roads: RoadSegment[] = []; + + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const widthAt = (i: number, count: number) => + i === 0 || i === count || i % AVENUE_EVERY === 0 ? GRID_AVENUE_WIDTH : GRID_STREET_WIDTH; + + for (let i = 0; i < xs.length; i++) { + layRoad({ x1: xs[i], z1: zs[0], x2: xs[i], z2: zs[zs.length - 1], width: widthAt(i, xs.length - 1) }); + } + for (let j = 0; j < zs.length; j++) { + layRoad({ x1: xs[0], z1: zs[j], x2: xs[xs.length - 1], z2: zs[j], width: widthAt(j, zs.length - 1) }); + } + + for (let i = 0; i < xs.length - 1; i++) { + for (let j = 0; j < zs.length - 1; j++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const cz = (zs[j] + zs[j + 1]) / 2; + if (boundary && !pointInPolygon(boundary, cx, cz)) continue; + // The road margin is taken off the block once, here, rather than off every lot + // inside it — otherwise neighbours would be padded apart and there is no terrace. + const block: Block = { + x: cx, z: cz, + w: Math.max(1, xs[i + 1] - xs[i] - GRID_AVENUE_WIDTH), + d: Math.max(1, zs[j + 1] - zs[j] - GRID_AVENUE_WIDTH), + }; + blocks.push(...perimeterLots(block, rng)); + } + } + + return { blocks, roads }; +}; + export const LAYOUTS: Record = { BSP: bspLayout, GRID: gridLayout, SUPERBLOCK: superblockLayout, RING: ringLayout, VORONOI: voronoiLayout, + PERIMETER: perimeterLayout, }; export * from './voronoi'; -export { VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; +export * from './lots'; +export { DOWNTOWN_CELL_SHORT, DOWNTOWN_CELL_LONG, VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; diff --git a/frontend/src/cityGen/lots.ts b/frontend/src/cityGen/lots.ts new file mode 100644 index 0000000..5cb2ebc --- /dev/null +++ b/frontend/src/cityGen/lots.ts @@ -0,0 +1,113 @@ +import type { Block, Rng } from './types'; + +/** + * Subdividing a block into building lots. + * + * Every layout until now handed the generator one block per city block, and the block + * got one structure. That is right for a tower in a park and wrong for a downtown: the + * thing that makes a dense city look dense is many narrow buildings sharing party walls + * along the street, with the middle of the block left as back lots. + * + * So this cuts a block into lots around its rim, facing the streets, and leaves the + * interior empty. Each lot comes back as a `Block` with `lot: true`, which tells the + * generator the footprint is already decided — no road padding, no aspect clamp, no + * per-zone setback, since all three exist to turn a whole city block into one sensible + * plot and would here just pull the neighbours apart again. + */ + +/** How deep a building lot is, from the street into the block. */ +export const LOT_DEPTH = 20; + +/** Street frontage per lot, before jitter. Narrow frontages are the look. */ +const LOT_FRONTAGE_MIN = 11; +const LOT_FRONTAGE_MAX = 24; + +/** A block with less than this left in the middle is built solid instead. */ +const MIN_COURTYARD = 14; + +/** Gap between neighbouring lots. Small — they are meant to share party walls. */ +const PARTY_WALL_GAP = 0.6; + +/** Lots below this frontage are dropped rather than built as slivers. */ +const MIN_FRONTAGE = 6; + +/** + * Split a run of street frontage into lots of varying width. + * + * Widths vary because a row of identical frontages reads as a barracks, and real + * frontages differ because they were sold off separately. + */ +function frontages(length: number, rng: Rng): number[] { + const out: number[] = []; + let used = 0; + while (used < length) { + const want = LOT_FRONTAGE_MIN + rng() * (LOT_FRONTAGE_MAX - LOT_FRONTAGE_MIN); + const remaining = length - used; + // Absorb a short remainder into the last lot rather than leaving a sliver. + if (remaining - want < MIN_FRONTAGE) { + out.push(remaining); + break; + } + out.push(want); + used += want; + } + return out.filter((w) => w >= MIN_FRONTAGE); +} + +/** + * Lots around the rim of a block, interior left as back lots. + * + * A block too small to have a rim and a middle is returned as a single lot — cutting a + * courtyard out of it would leave four slivers around a hole. + */ +export function perimeterLots(block: Block, rng: Rng): Block[] { + const depth = Math.min(LOT_DEPTH, Math.min(block.w, block.d) / 2); + const innerW = block.w - depth * 2; + const innerD = block.d - depth * 2; + + if (innerW < MIN_COURTYARD || innerD < MIN_COURTYARD) { + return [{ x: block.x, z: block.z, w: block.w, d: block.d, lot: true }]; + } + + const lots: Block[] = []; + const minX = block.x - block.w / 2; + const minZ = block.z - block.d / 2; + + // The two street-facing runs along x take the full width, so the corners belong to + // them; the runs along z then fill only the gap between, and nothing overlaps. + for (const side of [-1, 1]) { + let cursor = 0; + for (const front of frontages(block.w, rng)) { + const w = front - PARTY_WALL_GAP; + if (w >= MIN_FRONTAGE) { + lots.push({ + x: minX + cursor + front / 2, + z: block.z + side * (block.d / 2 - depth / 2), + w, + d: depth, + lot: true, + }); + } + cursor += front; + } + } + + for (const side of [-1, 1]) { + let cursor = 0; + for (const front of frontages(innerD, rng)) { + const d = front - PARTY_WALL_GAP; + if (d >= MIN_FRONTAGE) { + lots.push({ + x: block.x + side * (block.w / 2 - depth / 2), + z: minZ + depth + cursor + front / 2, + w: depth, + d, + lot: true, + }); + } + cursor += front; + } + } + + return lots; +} diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index 9d8f645..2bdfc7d 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -19,6 +19,17 @@ export interface Block { z: number; w: number; d: number; + /** + * This is a finished building lot, not a city block — take `w`/`d` as the footprint. + * + * The generator normally trims road padding off a block, clamps its aspect toward + * square and applies a per-zone setback. All three exist to turn a whole city block + * into one sensible plot. A layout that has already subdivided a block into lots has + * made those decisions itself, and leaving them on would pad neighbouring lots apart, + * square up the narrow frontages and pull the row back off the street — undoing the + * street wall that was the point of subdividing. + */ + lot?: boolean; } // Roads reuse the canonical shape from roadHelpers so consolidateRoads and diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 894ec70..71dfea7 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -13,6 +13,7 @@ const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ { value: 'SUPERBLOCK', label: 'SUPERBLOCK — TOWER IN PARK' }, { value: 'RING', label: 'RING — BELTWAYS AND SPOKES' }, { value: 'VORONOI', label: 'ORGANIC_CELLS — GREW, NOT PLANNED' }, + { value: 'PERIMETER', label: 'DOWNTOWN — DENSE BLOCKS, STREET WALL' }, ]; /** diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 3c63243..480b853 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -693,7 +693,7 @@ describe('AdminPanel layout selector', () => { it('offers every layout', () => { render(); const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; - expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING', 'VORONOI']); + expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING', 'VORONOI', 'PERIMETER']); }); it('defaults to the organic layout, so generation is unchanged out of the box', () => { From 3e2467d4f8432e79e245c73cad58b51cdf23a98c Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 2 Aug 2026 11:48:20 -0500 Subject: [PATCH 37/40] fix(citygen): downtown blocks were all the same size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout borrowed gridLines, which divides a span into equal cells and wobbles the seams. That is right for a planned grid and wrong here: it made every block the same size by construction, which is exactly what a downtown is not. The reference map has short blocks, long blocks and one enormous one where a street was never cut through. Cuts are now walked across the span at varying intervals, with a chance of stretching one — a street that never went through. Over a 800-unit region that gives blocks of 93 to 226 on the long axis and 43 to 114 on the short, around a 35% spread, against a fixed cell before. Rim depth varies per block too. Fixing that exposed a real gap: with a small drawn boundary the layout generated nothing. The boundary test ran on the whole block, and every other layout can do that because a block is its unit of output. A downtown block is large and holds a dozen lots, so dropping it whole discarded lots sitting well inside the shape, and a small area lost every block it touched. Lots are tested individually now. The terrace test located its row from the old constant rim depth, which no longer exists; it groups on the z the lots actually landed at instead. --- CHANGELOG.md | 2 +- .../src/cityGen/__tests__/perimeter.test.ts | 61 ++++++++++++++++--- frontend/src/cityGen/layouts.ts | 59 ++++++++++++++++-- frontend/src/cityGen/lots.ts | 8 ++- 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 811e28d..bb14d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. - **`ORGANIC_CELLS`** — a Voronoi diagram with streets along the cell boundaries. The only layout with no right angles in it: streets meet at odd angles and blocks are wedges and pentagons, which reads as a town that grew around footpaths rather than one a surveyor set out. Long cell boundaries become avenues, so the network gets a hierarchy without one being invented — the long runs across the diagram are the ones that would carry traffic anyway. - - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. + - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. Block sizes vary rather than repeating one cell — short blocks, long ones, and the occasional enormous one where a street was never cut through, Washington Square being the obvious example — and the depth of the built-up rim varies with them, since a constant depth reads as a machine even when the frontages differ. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. - **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. - **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. diff --git a/frontend/src/cityGen/__tests__/perimeter.test.ts b/frontend/src/cityGen/__tests__/perimeter.test.ts index 48711dd..708c86b 100644 --- a/frontend/src/cityGen/__tests__/perimeter.test.ts +++ b/frontend/src/cityGen/__tests__/perimeter.test.ts @@ -78,12 +78,18 @@ describe('perimeterLots', () => { it('puts neighbours close enough to read as a terrace', () => { // The whole point. A gap of more than a metre or two and it is detached houses. - const lots = perimeterLots(bigBlock, seededRng()) - .filter(l => Math.abs(l.z - (bigBlock.z - bigBlock.d / 2 + LOT_DEPTH / 2)) < 0.01) - .sort((a, b) => a.x - b.x); - expect(lots.length).toBeGreaterThan(2); - for (let i = 1; i < lots.length; i++) { - const gap = (lots[i].x - lots[i].w / 2) - (lots[i - 1].x + lots[i - 1].w / 2); + // The row is found by grouping on the z the lots actually landed at, rather than a + // computed one — rim depth varies per block, so there is no constant to predict it. + const lots = perimeterLots(bigBlock, seededRng()); + const rows = new Map(); + for (const l of lots) { + const key = l.z.toFixed(3); + rows.set(key, [...(rows.get(key) ?? []), l]); + } + const row = [...rows.values()].sort((a, b) => b.length - a.length)[0].sort((a, b) => a.x - b.x); + expect(row.length).toBeGreaterThan(2); + for (let i = 1; i < row.length; i++) { + const gap = (row[i].x - row[i].w / 2) - (row[i - 1].x + row[i - 1].w / 2); expect(gap).toBeLessThan(2); expect(gap).toBeGreaterThanOrEqual(0); } @@ -138,6 +144,36 @@ describe('perimeterLayout', () => { expect(ratio).toBeGreaterThan(1.5); }); + it('varies its block sizes rather than repeating one cell', () => { + // The first version divided the span evenly and wobbled the seams, which made every + // block the same size by construction. A real downtown has short blocks, long ones + // and the occasional enormous one where a street was never cut through. + const { roads } = perimeterLayout(bounds(600), false, seededRng()); + const gapsAlong = (vals: number[]) => { + const uniq = [...new Set(vals.map(v => Math.round(v)))].sort((a, b) => a - b); + return uniq.slice(1).map((v, i) => v - uniq[i]).filter(g => g > 5); + }; + const gaps = gapsAlong(roads.filter(r => Math.abs(r.z1 - r.z2) < 0.5).map(r => r.z1)); + expect(gaps.length).toBeGreaterThan(4); + const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length; + const spread = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length) / mean; + expect(spread).toBeGreaterThan(0.15); + // And the largest block is a different animal from the smallest, not a wobble. + expect(Math.max(...gaps)).toBeGreaterThan(Math.min(...gaps) * 1.8); + }); + + it('varies the rim depth between blocks', () => { + // A constant depth makes every terrace the same thickness, which reads as a machine + // even when the frontages differ. + const depths = new Set(); + const rng = seededRng(21); + for (let i = 0; i < 20; i++) { + const lots = perimeterLots({ x: 0, z: 0, w: 140, d: 90 }, rng); + depths.add(Math.min(...lots.map(l => Math.min(l.w, l.d))).toFixed(2)); + } + expect(depths.size).toBeGreaterThan(5); + }); + it('still lays roads', () => { expect(perimeterLayout(bounds(300), false, seededRng()).roads.length).toBeGreaterThan(0); }); @@ -158,14 +194,19 @@ describe('perimeterLayout', () => { } }); - it('drops lots outside a drawn boundary', () => { + it('keeps lots inside a drawn boundary, not whole blocks', () => { + // Every other layout drops a block whose centre falls outside the shape, which + // works when a block is the unit of output. A downtown block is large and holds a + // dozen lots, so dropping it whole discards lots well inside the boundary — and a + // small drawn area lost every block it touched and generated nothing at all. const boundary = { points: [ - { x: -100, z: -100 }, { x: 100, z: -100 }, { x: 100, z: 100 }, { x: -100, z: 100 }, + { x: -60, z: -60 }, { x: 60, z: -60 }, { x: 60, z: 60 }, { x: -60, z: 60 }, ] }; const { blocks } = perimeterLayout(bounds(300), true, seededRng(), [], boundary); + expect(blocks.length).toBeGreaterThan(0); for (const b of blocks) { - expect(Math.abs(b.x)).toBeLessThan(200); - expect(Math.abs(b.z)).toBeLessThan(200); + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); } }); diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts index 09a5197..f617bdb 100644 --- a/frontend/src/cityGen/layouts.ts +++ b/frontend/src/cityGen/layouts.ts @@ -83,6 +83,21 @@ const ARC_STEP_DEG = 9; const DOWNTOWN_CELL_SHORT = 70; const DOWNTOWN_CELL_LONG = 150; +/** + * How much a downtown block may differ from the target size, and how often a street is + * simply left out. + * + * `gridLines` divides a span into equal cells and wobbles the seams, which is right for + * a planned grid and wrong here: it makes every block the same size by construction. + * A real downtown has short blocks, long blocks and the occasional enormous one where a + * street was never cut through — Washington Square being the obvious example. So the + * cuts are walked across the span at varying intervals instead, with a chance of + * skipping one entirely. + */ +const DOWNTOWN_CELL_VARIANCE = 0.42; +const DOWNTOWN_MERGE_CHANCE = 0.16; +const DOWNTOWN_MERGE_FACTOR = 1.7; + /** A Voronoi edge longer than this many spacings is an avenue rather than a street. */ const VORONOI_AVENUE_RATIO = 1.15; @@ -106,6 +121,33 @@ function gridLines(min: number, span: number, rng: Rng, cellSize = GRID_CELL): n return lines; } +/** + * Cut positions walked across a span at varying intervals. + * + * Unlike `gridLines`, which divides evenly, this accumulates steps of differing size, so + * block sizes genuinely vary rather than all landing within a wobble of one another. A + * step is occasionally stretched, which reads as a street that was never cut through. + * + * A short remainder is absorbed into the last block rather than left as a sliver. + */ +function variedLines(min: number, span: number, rng: Rng, target: number): number[] { + const lines = [min]; + const end = min + span; + let cursor = min; + + while (true) { + const spread = 1 - DOWNTOWN_CELL_VARIANCE + rng() * DOWNTOWN_CELL_VARIANCE * 2; + const merged = rng() < DOWNTOWN_MERGE_CHANCE ? DOWNTOWN_MERGE_FACTOR : 1; + const next = cursor + target * spread * merged; + if (end - next < target * 0.5) break; + lines.push(next); + cursor = next; + } + + lines.push(end); + return lines; +} + /** * Regular street grid — Manhattan, Chicago, any planned city. * @@ -342,8 +384,8 @@ export const perimeterLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], // The long axis of a block runs across the shorter axis of the region, so the streets // that carry it read as the avenues. const horizontal = width >= depth; - const xs = gridLines(minX, width, rng, horizontal ? DOWNTOWN_CELL_LONG : DOWNTOWN_CELL_SHORT); - const zs = gridLines(minZ, depth, rng, horizontal ? DOWNTOWN_CELL_SHORT : DOWNTOWN_CELL_LONG); + const xs = variedLines(minX, width, rng, horizontal ? DOWNTOWN_CELL_LONG : DOWNTOWN_CELL_SHORT); + const zs = variedLines(minZ, depth, rng, horizontal ? DOWNTOWN_CELL_SHORT : DOWNTOWN_CELL_LONG); const blocks: Block[] = []; const roads: RoadSegment[] = []; @@ -369,7 +411,6 @@ export const perimeterLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], for (let j = 0; j < zs.length - 1; j++) { const cx = (xs[i] + xs[i + 1]) / 2; const cz = (zs[j] + zs[j + 1]) / 2; - if (boundary && !pointInPolygon(boundary, cx, cz)) continue; // The road margin is taken off the block once, here, rather than off every lot // inside it — otherwise neighbours would be padded apart and there is no terrace. const block: Block = { @@ -377,7 +418,15 @@ export const perimeterLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], w: Math.max(1, xs[i + 1] - xs[i] - GRID_AVENUE_WIDTH), d: Math.max(1, zs[j + 1] - zs[j] - GRID_AVENUE_WIDTH), }; - blocks.push(...perimeterLots(block, rng)); + // A drawn boundary is tested against the lots, not the block they came from. + // Every other layout drops a block whose centre falls outside, which works when a + // block *is* the unit of output. Here a block is large and holds a dozen lots, so + // dropping it whole discards lots that sit well inside the shape — a small drawn + // area could lose every block it touched and generate nothing at all. + for (const lot of perimeterLots(block, rng)) { + if (boundary && !pointInPolygon(boundary, lot.x, lot.z)) continue; + blocks.push(lot); + } } } @@ -395,4 +444,4 @@ export const LAYOUTS: Record = { export * from './voronoi'; export * from './lots'; -export { DOWNTOWN_CELL_SHORT, DOWNTOWN_CELL_LONG, VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; +export { DOWNTOWN_CELL_SHORT, DOWNTOWN_CELL_LONG, DOWNTOWN_CELL_VARIANCE, DOWNTOWN_MERGE_CHANCE, VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; diff --git a/frontend/src/cityGen/lots.ts b/frontend/src/cityGen/lots.ts index 5cb2ebc..25fe9f4 100644 --- a/frontend/src/cityGen/lots.ts +++ b/frontend/src/cityGen/lots.ts @@ -18,6 +18,9 @@ import type { Block, Rng } from './types'; /** How deep a building lot is, from the street into the block. */ export const LOT_DEPTH = 20; +/** How much that depth varies between blocks, as a fraction of it. */ +export const LOT_DEPTH_VARIANCE = 0.3; + /** Street frontage per lot, before jitter. Narrow frontages are the look. */ const LOT_FRONTAGE_MIN = 11; const LOT_FRONTAGE_MAX = 24; @@ -61,7 +64,10 @@ function frontages(length: number, rng: Rng): number[] { * courtyard out of it would leave four slivers around a hole. */ export function perimeterLots(block: Block, rng: Rng): Block[] { - const depth = Math.min(LOT_DEPTH, Math.min(block.w, block.d) / 2); + // Rim depth varies block to block. A constant depth makes every terrace the same + // thickness, which reads as a machine even when the frontages differ. + const wanted = LOT_DEPTH * (1 - LOT_DEPTH_VARIANCE + rng() * LOT_DEPTH_VARIANCE * 2); + const depth = Math.min(wanted, Math.min(block.w, block.d) / 2); const innerW = block.w - depth * 2; const innerD = block.d - depth * 2; From ed90e1065ddf7d56dffb093f633189ffbb5ab02c Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 2 Aug 2026 12:05:06 -0500 Subject: [PATCH 38/40] fix(citygen): downtown blocks were mostly empty in the middle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One ring of lots around a block leaves everything inside it open, and downtown blocks are large — a 20-deep rim on a 226 by 114 block leaves 186 by 74 of nothing, which is most of the block. Hence the screenshot: a lattice of buildings around a lot of void. The interior is now subdivided too, ringing inward while there is room for another ring and leaving the rest as yard. A block too thin for a rim and a middle becomes a run of lots along its length rather than a single lot the length of the block, which was a monolith where a terrace belongs. Two wrong tunings on the way, both worth recording because they bracket the answer. Filling the interior outright took a 226 by 114 block to 97% built — a solid slab with no back lot, the opposite mistake. Ringing only once left it at 47%. It now lands between 66% for the largest blocks and 96% for small ones, which is about right: big blocks have big yards, small blocks are solid. The test asserting a small block comes back as one lot was asserting the monolith, so it now asserts a terrace. The one guarding the open middle now brackets coverage from both sides rather than only checking the centre is clear, since only checking for a hole is what let the 97% version pass. --- CHANGELOG.md | 2 +- .../src/cityGen/__tests__/perimeter.test.ts | 36 ++++++----- frontend/src/cityGen/lots.ts | 63 ++++++++++++++++++- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb14d77..48f0289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. - **`ORGANIC_CELLS`** — a Voronoi diagram with streets along the cell boundaries. The only layout with no right angles in it: streets meet at odd angles and blocks are wedges and pentagons, which reads as a town that grew around footpaths rather than one a surveyor set out. Long cell boundaries become avenues, so the network gets a hierarchy without one being invented — the long runs across the diagram are the ones that would carry traffic anyway. - - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. Block sizes vary rather than repeating one cell — short blocks, long ones, and the occasional enormous one where a street was never cut through, Washington Square being the obvious example — and the depth of the built-up rim varies with them, since a constant depth reads as a machine even when the frontages differ. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. + - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. Block sizes vary rather than repeating one cell — short blocks, long ones, and the occasional enormous one where a street was never cut through, Washington Square being the obvious example — and the depth of the built-up rim varies with them, since a constant depth reads as a machine even when the frontages differ. A large block is ringed inward more than once so it reads as built out rather than hollow, stopping while there is still a back lot behind the buildings; a block too thin to have a rim and a middle becomes a terrace rather than one monolith the length of the block. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. - **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. Rivers and coastlines are most of why real cities look like themselves: they force asymmetry, cut districts apart, and give bridges a reason to exist, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. - **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. diff --git a/frontend/src/cityGen/__tests__/perimeter.test.ts b/frontend/src/cityGen/__tests__/perimeter.test.ts index 708c86b..0f09b8a 100644 --- a/frontend/src/cityGen/__tests__/perimeter.test.ts +++ b/frontend/src/cityGen/__tests__/perimeter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - perimeterLots, perimeterLayout, LAYOUTS, LOT_DEPTH, + perimeterLots, perimeterLayout, LAYOUTS, generateCity, splitCity, } from '../index'; import type { Block } from '../types'; @@ -68,12 +68,20 @@ describe('perimeterLots', () => { } }); - it('leaves the middle of the block open', () => { - // Back lots are half of what makes a perimeter block read as one. - const lots = perimeterLots(bigBlock, seededRng()); - const inMiddle = lots.filter(l => - Math.abs(l.x) < bigBlock.w / 2 - LOT_DEPTH && Math.abs(l.z) < bigBlock.d / 2 - LOT_DEPTH); - expect(inMiddle).toHaveLength(0); + it('leaves a yard in the middle but does not leave the block hollow', () => { + // Two failure modes, one on each side. One ring around a 226 by 114 block leaves + // 186 by 74 of nothing, which is most of the block; filling everything took it to + // 97% built, a solid slab with no back lot at all. So: the exact centre stays open, + // and the block is still substantially built out. + const big: Block = { x: 0, z: 0, w: 226, d: 114 }; + const lots = perimeterLots(big, seededRng()); + const coversCentre = lots.some(l => + Math.abs(l.x) < l.w / 2 && Math.abs(l.z) < l.d / 2); + expect(coversCentre).toBe(false); + + const built = lots.reduce((a, l) => a + l.w * l.d, 0) / (big.w * big.d); + expect(built).toBeGreaterThan(0.5); + expect(built).toBeLessThan(0.92); }); it('puts neighbours close enough to read as a terrace', () => { @@ -101,14 +109,14 @@ describe('perimeterLots', () => { expect(widths.size).toBeGreaterThan(3); }); - it('builds a small block solid rather than cutting a hole in it', () => { - // A block with no room for a rim and a middle would otherwise become four slivers - // around a courtyard. - const small: Block = { x: 0, z: 0, w: 30, d: 26 }; + it('builds a small block as a terrace, not one monolith', () => { + // A block with no room for a rim and a middle used to come back as a single lot the + // length of the block, which is a monolith where a row of buildings belongs. + const small: Block = { x: 0, z: 0, w: 60, d: 26 }; const lots = perimeterLots(small, seededRng()); - expect(lots).toHaveLength(1); - expect(lots[0].w).toBe(30); - expect(lots[0].d).toBe(26); + expect(lots.length).toBeGreaterThan(1); + for (const l of lots) expect(l.d).toBeCloseTo(26, 1); + expect(lots.reduce((a, l) => a + l.w, 0)).toBeLessThanOrEqual(60); }); it('reproduces from a seed', () => { diff --git a/frontend/src/cityGen/lots.ts b/frontend/src/cityGen/lots.ts index 25fe9f4..7c6d812 100644 --- a/frontend/src/cityGen/lots.ts +++ b/frontend/src/cityGen/lots.ts @@ -28,6 +28,17 @@ const LOT_FRONTAGE_MAX = 24; /** A block with less than this left in the middle is built solid instead. */ const MIN_COURTYARD = 14; +/** + * How much yard a block is allowed to keep. + * + * One ring of lots around a large block leaves an enormous void in the middle — on a + * 226 by 114 block a 20-deep rim leaves 186 by 74 of nothing, which is most of the + * block. Real dense blocks are built out to a modest yard, so the interior is + * subdivided again rather than abandoned: another ring if it is big enough to hold one, + * a single run of lots if it is only a slab, and left as yard once it is under this. + */ +const MAX_YARD = 40; + /** Gap between neighbouring lots. Small — they are meant to share party walls. */ const PARTY_WALL_GAP = 0.6; @@ -63,7 +74,7 @@ function frontages(length: number, rng: Rng): number[] { * A block too small to have a rim and a middle is returned as a single lot — cutting a * courtyard out of it would leave four slivers around a hole. */ -export function perimeterLots(block: Block, rng: Rng): Block[] { +export function perimeterLots(block: Block, rng: Rng, depthBudget = 2): Block[] { // Rim depth varies block to block. A constant depth makes every terrace the same // thickness, which reads as a machine even when the frontages differ. const wanted = LOT_DEPTH * (1 - LOT_DEPTH_VARIANCE + rng() * LOT_DEPTH_VARIANCE * 2); @@ -71,8 +82,10 @@ export function perimeterLots(block: Block, rng: Rng): Block[] { const innerW = block.w - depth * 2; const innerD = block.d - depth * 2; + // Too thin for a rim and a middle. Not one building the length of the block — that is + // a monolith where a terrace belongs — but a single run of lots along its length. if (innerW < MIN_COURTYARD || innerD < MIN_COURTYARD) { - return [{ x: block.x, z: block.z, w: block.w, d: block.d, lot: true }]; + return slabLots(block, rng); } const lots: Block[] = []; @@ -115,5 +128,51 @@ export function perimeterLots(block: Block, rng: Rng): Block[] { } } + lots.push(...fillInterior({ x: block.x, z: block.z, w: innerW, d: innerD }, rng, depthBudget)); return lots; } + +/** + * Build out what is left in the middle of a block, down to a modest yard. + * + * Rings inward while there is room for another one, then stops and leaves the rest as + * yard. That is what makes a very large block read as built out rather than hollow, + * without going to the other extreme of a solid slab with nothing behind it. + * + * `depthBudget` only guards against a pathological input; each ring removes at least + * two rim depths, so this terminates on its own. + */ +function fillInterior(inner: Block, rng: Rng, depthBudget: number): Block[] { + const short = Math.min(inner.w, inner.d); + const long = Math.max(inner.w, inner.d); + if (short <= MAX_YARD && long <= MAX_YARD) return []; + + if (depthBudget > 0 && short >= LOT_DEPTH * 2 + MIN_COURTYARD) { + return perimeterLots(inner, rng, depthBudget - 1); + } + + // Whatever is left once the rings run out is the yard, and it stays open. Filling it + // as well took a 226 by 114 block to 97% built — a solid slab with no back lot at + // all, which is the opposite mistake to the one this was fixing. + return []; +} + +/** A run of lots along the longer axis, each the full depth of the slab. */ +function slabLots(slab: Block, rng: Rng): Block[] { + const alongX = slab.w >= slab.d; + const long = alongX ? slab.w : slab.d; + if (Math.min(slab.w, slab.d) < MIN_FRONTAGE) return []; + + const out: Block[] = []; + let cursor = 0; + for (const front of frontages(long, rng)) { + const size = front - PARTY_WALL_GAP; + if (size >= MIN_FRONTAGE) { + out.push(alongX + ? { x: slab.x - slab.w / 2 + cursor + front / 2, z: slab.z, w: size, d: slab.d, lot: true } + : { x: slab.x, z: slab.z - slab.d / 2 + cursor + front / 2, w: slab.w, d: size, lot: true }); + } + cursor += front; + } + return out; +} From 957a8720e5f832a991a7220d909cebb26e134380 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 2 Aug 2026 12:12:39 -0500 Subject: [PATCH 39/40] docs: refresh the lots.ts entry in the project structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both lines were written for the first version. lots.ts now rings a block inward more than once and turns a block too thin for a rim and a middle into a terrace rather than one monolith, and the tests bracket coverage from both sides — hollow and solid are both wrong — rather than only checking the middle is clear. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e122e6d..cad37ea 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ CITY_NET/ │ │ │ ├── types.ts # Bounds, Block, RawBuilding, Obstacle, options/context/result shapes │ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land and to any drawn boundary as they are laid; optional minimum block size │ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc), VORONOI (organic cells, streets on the cell boundaries), PERIMETER (elongated blocks cut into street-facing lots) -│ │ │ ├── lots.ts # Cuts a block into building lots around its rim, back lots left open in the middle; each lot is flagged Block.lot so the generator takes the footprint as given instead of padding, squaring and setting it back +│ │ │ ├── lots.ts # Cuts a block into building lots: a ring around the rim facing the street, ringing inward again while there is room and leaving the rest as back lot, with a block too thin for a rim and a middle becoming a terrace rather than one monolith. Rim depth and frontages vary per block. Each lot is flagged Block.lot so the generator takes the footprint as given instead of padding, squaring and setting it back │ │ │ ├── voronoi.ts # Voronoi cells by half-plane clipping, shared-edge dedup, and the inscribed rectangle that lets an irregular cell feed a rectangle-only plot filler │ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp @@ -425,7 +425,7 @@ CITY_NET/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation │ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary │ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks -│ │ │ ├── perimeter.test.ts # Lots that tile a block without overlapping, terrace gaps under two units, varied frontages, open back lots, an elongated street grid, and every other layout left unflagged +│ │ │ ├── perimeter.test.ts # Lots that tile a block without overlapping, terrace gaps under two units, varied frontages and rim depths, block coverage bracketed from both sides (hollow and solid are both wrong), varied block sizes, a drawn boundary tested per lot rather than per block, and every other layout left unflagged │ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned │ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, nothing floating, one root per monument, and the three app-wide conventions a generator must not override — the `#00ff00` theme sentinel, `polyCount` 5, and never the token-reserved `rhombus` │ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout From 48feb771213321e2020edabba4528f07aa6dc23d Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 2 Aug 2026 12:13:12 -0500 Subject: [PATCH 40/40] docs: the layout selector offers six types, not four The count in the intro line was written when GRID, SUPERBLOCK and RING joined BSP. ORGANIC_CELLS and DOWNTOWN have landed since. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f0289..69aa43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - **Drawn generation bounds** — the city generator gains a `DRAG_RECT` / `DRAW_AREA` toggle. `DRAW_AREA` traces a boundary the way water is drawn, and generation is confined to that shape: blocks centred outside it are dropped, road seams are clipped to it, and a footprint straddling its edge is rejected. A concave shape generates nothing in its notch, so an L or a crescent works as drawn. The traced outline stays on screen until GENERATE, unlike water, which saves immediately. -- **Street layouts** — a `LAYOUT` selector offering four distinct city types. Everything downstream of the block list is layout-agnostic, so a layout only has to produce blocks and the roads between them. +- **Street layouts** — a `LAYOUT` selector offering six distinct city types. Everything downstream of the block list is layout-agnostic, so a layout only has to produce blocks and the roads between them. - **`GRID`** — two perpendicular families of streets with avenues every fourth line. Reads as Manhattan or Chicago, and is genuinely distinct from the default, which always produces *irregular* rectangles however it is tuned. That road hierarchy is most of what makes a grid look designed rather than generated. - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round.