@jgengine/core 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/dist/ai/populationDirector.d.ts +103 -0
- package/dist/ai/populationDirector.js +190 -0
- package/dist/ai/pursuit.d.ts +56 -0
- package/dist/ai/pursuit.js +53 -0
- package/dist/authoring.d.ts +1 -1
- package/dist/authoring.js +1 -1
- package/dist/combat/effects.d.ts +11 -4
- package/dist/combat/effects.js +24 -17
- package/dist/combat/magazine.d.ts +13 -0
- package/dist/combat/magazine.js +27 -0
- package/dist/combat/vfxPresets.d.ts +176 -0
- package/dist/combat/vfxPresets.js +74 -0
- package/dist/combat/weaponFire.d.ts +114 -0
- package/dist/combat/weaponFire.js +96 -0
- package/dist/combat.d.ts +5 -2
- package/dist/combat.js +4 -1
- package/dist/devtools/controls.d.ts +38 -0
- package/dist/devtools/controls.js +181 -0
- package/dist/devtools/devtools.d.ts +5 -197
- package/dist/devtools/devtools.js +38 -774
- package/dist/devtools/discover.d.ts +32 -0
- package/dist/devtools/discover.js +240 -0
- package/dist/devtools/fallbackSeams.d.ts +50 -0
- package/dist/devtools/fallbackSeams.js +111 -0
- package/dist/devtools/frame.d.ts +15 -0
- package/dist/devtools/frame.js +247 -0
- package/dist/devtools/logs.d.ts +15 -0
- package/dist/devtools/logs.js +86 -0
- package/dist/devtools/overrides.d.ts +16 -0
- package/dist/devtools/overrides.js +66 -0
- package/dist/devtools/types.d.ts +212 -0
- package/dist/devtools/types.js +1 -0
- package/dist/devtools/urlFlags.d.ts +22 -0
- package/dist/devtools/urlFlags.js +62 -0
- package/dist/economy/auctionBook.d.ts +122 -0
- package/dist/economy/auctionBook.js +213 -0
- package/dist/economy/priceHistory.d.ts +44 -0
- package/dist/economy/priceHistory.js +89 -0
- package/dist/economy/unlockPoints.d.ts +92 -0
- package/dist/economy/unlockPoints.js +103 -0
- package/dist/editor/commandApply.d.ts +5 -0
- package/dist/editor/commandApply.js +755 -0
- package/dist/editor/commands.d.ts +48 -3
- package/dist/editor/commands.js +40 -754
- package/dist/editor/directives.d.ts +94 -0
- package/dist/editor/directives.js +206 -0
- package/dist/editor/document.d.ts +37 -2
- package/dist/editor/document.js +422 -6
- package/dist/editor/entityCatalog.d.ts +42 -0
- package/dist/editor/entityCatalog.js +101 -0
- package/dist/editor/environment.d.ts +74 -0
- package/dist/editor/environment.js +169 -0
- package/dist/editor/index.d.ts +26 -1
- package/dist/editor/index.js +8 -1
- package/dist/editor/minimap.d.ts +31 -0
- package/dist/editor/minimap.js +73 -0
- package/dist/editor/streamer.d.ts +66 -0
- package/dist/editor/streamer.js +68 -0
- package/dist/editor/types.d.ts +143 -0
- package/dist/editor/world.d.ts +136 -0
- package/dist/editor/world.js +303 -0
- package/dist/game/breeding.d.ts +161 -0
- package/dist/game/breeding.js +181 -0
- package/dist/game/cameraConfig.d.ts +451 -0
- package/dist/game/cameraConfig.js +1 -0
- package/dist/game/clipRoles.d.ts +51 -0
- package/dist/game/clipRoles.js +142 -0
- package/dist/game/defineGame.d.ts +16 -9
- package/dist/game/defineGame.js +14 -6
- package/dist/game/defineSystem.d.ts +2 -0
- package/dist/game/defineSystem.js +2 -0
- package/dist/game/events.d.ts +28 -0
- package/dist/game/partAnimation.d.ts +78 -0
- package/dist/game/partAnimation.js +147 -0
- package/dist/game/playableGame.d.ts +72 -440
- package/dist/game/playableGame.js +1 -1
- package/dist/game/progression.d.ts +35 -0
- package/dist/game/progression.js +22 -1
- package/dist/game/quest.js +1 -3
- package/dist/game/tribe.d.ts +184 -0
- package/dist/game/tribe.js +432 -0
- package/dist/gameplay.d.ts +3 -1
- package/dist/gameplay.js +3 -1
- package/dist/inventory/encumbrance.d.ts +69 -0
- package/dist/inventory/encumbrance.js +68 -0
- package/dist/inventory/inventoryModel.d.ts +24 -8
- package/dist/inventory/inventoryModel.js +47 -8
- package/dist/item/durability.js +1 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +48 -1
- package/dist/movement/movementModel.d.ts +15 -1
- package/dist/movement/movementModel.js +33 -15
- package/dist/movement/playerMovement.js +102 -2
- package/dist/multiplayer/combatSnapshot.js +13 -8
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +8 -0
- package/dist/physics/drivableVehicle.d.ts +2 -0
- package/dist/physics/drivableVehicle.js +1 -1
- package/dist/physics/flightDynamics.d.ts +82 -0
- package/dist/physics/flightDynamics.js +165 -0
- package/dist/physics/gravityField.d.ts +25 -0
- package/dist/physics/gravityField.js +41 -0
- package/dist/physics/kinematicVehicle.d.ts +87 -0
- package/dist/physics/kinematicVehicle.js +193 -11
- package/dist/physics/physicsWorld.d.ts +26 -0
- package/dist/physics/physicsWorld.js +109 -117
- package/dist/physics/vehicleObstacles.d.ts +44 -0
- package/dist/physics/vehicleObstacles.js +115 -0
- package/dist/random/rng.d.ts +4 -1
- package/dist/random/rng.js +4 -1
- package/dist/runtime/adapter.d.ts +13 -0
- package/dist/runtime/adapter.js +13 -0
- package/dist/runtime/context/combat.d.ts +51 -0
- package/dist/runtime/context/combat.js +131 -0
- package/dist/runtime/context/combatFx.js +15 -5
- package/dist/runtime/context/deathLoot.d.ts +30 -0
- package/dist/runtime/context/deathLoot.js +39 -0
- package/dist/runtime/context/player.d.ts +48 -0
- package/dist/runtime/context/player.js +125 -0
- package/dist/runtime/context/scene.d.ts +60 -0
- package/dist/runtime/context/scene.js +329 -0
- package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
- package/dist/runtime/context/snapshotCodecs.js +26 -0
- package/dist/runtime/descriptors/baseline.js +26 -3
- package/dist/runtime/descriptors/features.d.ts +5 -0
- package/dist/runtime/descriptors/features.js +20 -2
- package/dist/runtime/gameContext.d.ts +2 -428
- package/dist/runtime/gameContext.js +133 -500
- package/dist/runtime/gameContextTypes.d.ts +500 -0
- package/dist/runtime/gameContextTypes.js +1 -0
- package/dist/runtime/headlessRunner.d.ts +17 -2
- package/dist/runtime/headlessRunner.js +15 -0
- package/dist/runtime/hostPolicy.d.ts +51 -0
- package/dist/runtime/hostPolicy.js +68 -0
- package/dist/runtime/hostedGameRunner.d.ts +3 -1
- package/dist/runtime/hostedGameRunner.js +1 -0
- package/dist/runtime/hostedWorldSession.d.ts +3 -1
- package/dist/runtime/hostedWorldSession.js +1 -0
- package/dist/runtime/worldSnapshot.d.ts +13 -1
- package/dist/runtime/worldSnapshot.js +15 -3
- package/dist/scene/assetCatalog.d.ts +7 -0
- package/dist/scene/assetSpace.js +1 -3
- package/dist/scene/authoredTriggers.d.ts +50 -0
- package/dist/scene/authoredTriggers.js +114 -0
- package/dist/scene/builtinSceneKinds.js +8 -3
- package/dist/scene/colliders.d.ts +79 -0
- package/dist/scene/colliders.js +170 -0
- package/dist/scene/collisionMesh.d.ts +94 -0
- package/dist/scene/collisionMesh.js +699 -0
- package/dist/scene/companion.d.ts +147 -0
- package/dist/scene/companion.js +193 -0
- package/dist/scene/entityStats.d.ts +7 -9
- package/dist/scene/entityStats.js +19 -15
- package/dist/scene/sceneKinds.d.ts +16 -0
- package/dist/scene/sceneKinds.js +4 -0
- package/dist/scene/sceneRaycast.js +15 -0
- package/dist/stats/spawnLevelStats.d.ts +110 -0
- package/dist/stats/spawnLevelStats.js +140 -0
- package/dist/stats/statModifiers.d.ts +16 -0
- package/dist/stats/statModifiers.js +30 -1
- package/dist/stats/statPool.d.ts +70 -0
- package/dist/stats/statPool.js +62 -0
- package/dist/store/changeSignal.d.ts +3 -0
- package/dist/store/changeSignal.js +2 -0
- package/dist/store/observableKeyedStore.d.ts +2 -0
- package/dist/store/observableKeyedStore.js +1 -0
- package/dist/survival/decayMeter.js +1 -3
- package/dist/survival/regionHealth.js +1 -3
- package/dist/ui/hudLayout.d.ts +5 -0
- package/dist/ui/hudLayout.js +17 -1
- package/dist/ui/panelModel.d.ts +120 -0
- package/dist/ui/panelModel.js +168 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/dist/world/authoredEntities.d.ts +29 -0
- package/dist/world/authoredEntities.js +29 -0
- package/dist/world/authoredObjects.d.ts +15 -1
- package/dist/world/authoredObjects.js +13 -1
- package/dist/world/authoredSpawn.d.ts +39 -0
- package/dist/world/authoredSpawn.js +37 -0
- package/dist/world/buildSockets.d.ts +152 -0
- package/dist/world/buildSockets.js +188 -0
- package/dist/world/buildingLots.d.ts +84 -0
- package/dist/world/buildingLots.js +119 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/cityBlocks.d.ts +152 -0
- package/dist/world/cityBlocks.js +670 -0
- package/dist/world/cityContent.d.ts +77 -0
- package/dist/world/cityContent.js +261 -0
- package/dist/world/cityGenerator.d.ts +41 -0
- package/dist/world/cityGenerator.js +58 -0
- package/dist/world/cityGeometry.d.ts +114 -0
- package/dist/world/cityGeometry.js +573 -0
- package/dist/world/cityKind.d.ts +314 -0
- package/dist/world/cityKind.js +1578 -0
- package/dist/world/environmentSummary.js +57 -1
- package/dist/world/features.d.ts +53 -5
- package/dist/world/features.js +15 -3
- package/dist/world/grassKind.js +2 -2
- package/dist/world/markers.d.ts +50 -4
- package/dist/world/markers.js +49 -0
- package/dist/world/massing.js +1 -1
- package/dist/world/minimapBake.d.ts +78 -0
- package/dist/world/minimapBake.js +208 -0
- package/dist/world/minimapTrack.d.ts +17 -0
- package/dist/world/minimapTrack.js +22 -0
- package/dist/world/place.d.ts +166 -0
- package/dist/world/place.js +96 -0
- package/dist/world/placeAsset.js +3 -1
- package/dist/world/poleLineKind.d.ts +40 -0
- package/dist/world/poleLineKind.js +97 -0
- package/dist/world/polyline.js +9 -2
- package/dist/world/resourceNode.d.ts +109 -0
- package/dist/world/resourceNode.js +140 -0
- package/dist/world/roads.d.ts +19 -2
- package/dist/world/roads.js +60 -3
- package/dist/world/scatter.js +2 -20
- package/dist/world/streetGenerator.d.ts +159 -0
- package/dist/world/streetGenerator.js +684 -0
- package/dist/world/streets.d.ts +103 -1
- package/dist/world/streets.js +273 -0
- package/dist/world/terraform.js +58 -88
- package/dist/world/terrain.d.ts +65 -1
- package/dist/world/terrain.js +119 -2
- package/dist/world.d.ts +14 -5
- package/dist/world.js +12 -3
- package/package.json +1 -1
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-combat/SKILL.md +19 -1
- package/skills/jgengine-combat/api.md +75 -2
- package/skills/jgengine-combat/capabilities.md +61 -1
- package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
- package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
- package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
- package/skills/jgengine-combat/reference.md +33 -4
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +182 -26
- package/skills/jgengine-gameplay/capabilities.md +96 -2
- package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
- package/skills/jgengine-gameplay/reference-systems.md +10 -5
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +453 -21
- package/skills/jgengine-world/capabilities.md +112 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -0,0 +1,1578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `city` studio: a procedural district authored as a box volume — drop a volume and sliders tune
|
|
3
|
+
* the whole synthesis, from a rigid Manhattan grid (`gridness` 1, `curviness` 0) to winding
|
|
4
|
+
* hillside estates, a two-road crossroads farm town, or — at the loopiness-high / branching-and-
|
|
5
|
+
* connectivity-low corner — a closed RACE CIRCUIT. Streets come from the unified seed-driven
|
|
6
|
+
* {@link generateStreets} engine (city and track are the same generator at opposite slider
|
|
7
|
+
* extremes), carry a visible hierarchy (boulevards with medians, avenues, streets, lanes —
|
|
8
|
+
* optionally gravel), and connect or deliberately dead-end into cul-de-sac bulbs; water gaps become
|
|
9
|
+
* styled bridge decks and ridges become tunnel bores, both path features on continuous streets so a
|
|
10
|
+
* lap stays closed. The block/parcel/building FABRIC is optional (`fabric` off = bare roads or a
|
|
11
|
+
* plot-free track). Generation is BLOCK-FIRST (`cityBlocks`): the street graph's planar faces become closed
|
|
12
|
+
* block polygons (a curved street bounds a curved block), each inset to curb and land rings with
|
|
13
|
+
* the sidewalk band between them, then classified and subdivided into polygonal frontage PARCELS
|
|
14
|
+
* whose buildable polygons (after setbacks) place every building. Inside the district a ZONE
|
|
15
|
+
* layer (core/mid/edge bands, invertible or spatially uniform) drives what a parcel carries: a
|
|
16
|
+
* weighted class mix per band picks the building class (tower, slab, shop, rowhouse, house,
|
|
17
|
+
* mansion, farmhouse, barn, silo), and each class composes deterministic massing PIECES
|
|
18
|
+
* (`cityContent`) — setback tower tiers, gabled houses, gambrel barns, domed silos. Blocks left
|
|
19
|
+
* unbuilt become plazas/greens/meadows by band, or crop-row farm fields; slivers and block
|
|
20
|
+
* interiors classify as buffers and courtyards, never leftover rectangles. Street
|
|
21
|
+
* furniture (lights, species-mixed trees, estate hedges, driveways, parking lots) resolves as
|
|
22
|
+
* bounded, seeded data hooked to the network. Child `cityzone` volumes override the band/mix
|
|
23
|
+
* locally, so hand-placed intent wins over the profile. Named presets (Manhattan, Los Angeles,
|
|
24
|
+
* Beverly Hills, rural Ohio…) ship as plain slider bundles on the schema. Pure config + resolver
|
|
25
|
+
* here; the instanced renderer lives in the `shell`. Everything is deterministic per seed and
|
|
26
|
+
* persisted in `editor.scene.json`.
|
|
27
|
+
*
|
|
28
|
+
* @capability city-district editor-authorable procedural city district (streets, zoning, buildings, furniture)
|
|
29
|
+
*/
|
|
30
|
+
import { seededStreams } from "../random/rng.js";
|
|
31
|
+
import { parseParams, registerSceneKind, } from "../scene/sceneKinds.js";
|
|
32
|
+
import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_STYLE } from "./buildings.js";
|
|
33
|
+
import { buildLotPieces, pickClass, pickSpecies, rollClassPlacement, zoneBand, zoneMetric, } from "./cityContent.js";
|
|
34
|
+
import { furnitureSpots } from "./streets.js";
|
|
35
|
+
import { generateStreets, streetNetworkMode, } from "./streetGenerator.js";
|
|
36
|
+
import { buildablePolygon, carveCorridors, conformPolygonToRing, cutParcel, extractBlocks, isSliverBlock, pointsInPolygon, RingWalker, } from "./cityBlocks.js";
|
|
37
|
+
import { fitRectInPolygon, rayDistanceToRing, pointInPolygon, polygonArea, polygonCentroid, polygonMeanWidth, polygonsOverlap, insetRingUniform, ringBounds, } from "./cityGeometry.js";
|
|
38
|
+
/** The editor volume kind marking a box as a procedural city district. */
|
|
39
|
+
export const CITY_KIND = "city";
|
|
40
|
+
/** The editor volume kind that locally overrides a city's zone band/mix — a district in a district. */
|
|
41
|
+
export const CITY_ZONE_KIND = "cityzone";
|
|
42
|
+
/** City defaults: a zoned mixed metropolis — towers downtown, slabs mid-ring, houses at the edge. */
|
|
43
|
+
export const CITY_DEFAULTS = {
|
|
44
|
+
gridness: 0.85,
|
|
45
|
+
curviness: 0.15,
|
|
46
|
+
branching: 0.4,
|
|
47
|
+
loopiness: 0,
|
|
48
|
+
connectivity: 0.88,
|
|
49
|
+
deadEnds: 0.35,
|
|
50
|
+
minCurveRadius: 24,
|
|
51
|
+
minTurnAngle: 0,
|
|
52
|
+
maxTurnAngle: 150,
|
|
53
|
+
fabric: true,
|
|
54
|
+
tunnels: true,
|
|
55
|
+
blockSize: 48,
|
|
56
|
+
blockAspect: 1,
|
|
57
|
+
openSpace: 0.12,
|
|
58
|
+
roadsideOccupancy: 0.88,
|
|
59
|
+
blockDensity: 0.6,
|
|
60
|
+
buildingRoadSetback: 2.5,
|
|
61
|
+
buildingSpacing: 1.4,
|
|
62
|
+
clusterStrength: 0.5,
|
|
63
|
+
streetWidth: 7,
|
|
64
|
+
boulevards: 0.35,
|
|
65
|
+
gravelLanes: false,
|
|
66
|
+
surface: "auto",
|
|
67
|
+
profile: "core-out",
|
|
68
|
+
coreExtent: 0.35,
|
|
69
|
+
midExtent: 0.7,
|
|
70
|
+
coreMix: [
|
|
71
|
+
{ item: "tower", weight: 3 },
|
|
72
|
+
{ item: "slab", weight: 1 },
|
|
73
|
+
{ item: "shop", weight: 1 },
|
|
74
|
+
],
|
|
75
|
+
midMix: [
|
|
76
|
+
{ item: "slab", weight: 2 },
|
|
77
|
+
{ item: "rowhouse", weight: 2 },
|
|
78
|
+
{ item: "shop", weight: 1 },
|
|
79
|
+
],
|
|
80
|
+
edgeMix: [
|
|
81
|
+
{ item: "house", weight: 3 },
|
|
82
|
+
{ item: "rowhouse", weight: 1 },
|
|
83
|
+
],
|
|
84
|
+
lotScale: 1,
|
|
85
|
+
floorsMin: 1,
|
|
86
|
+
floorsMax: 30,
|
|
87
|
+
floorHeight: 3,
|
|
88
|
+
treeDensity: 0.5,
|
|
89
|
+
treeMix: [
|
|
90
|
+
{ item: "broadleaf", weight: 3 },
|
|
91
|
+
{ item: "conifer", weight: 1 },
|
|
92
|
+
],
|
|
93
|
+
lightDensity: 0.5,
|
|
94
|
+
hedges: true,
|
|
95
|
+
driveways: true,
|
|
96
|
+
parking: true,
|
|
97
|
+
fields: false,
|
|
98
|
+
maxSlope: 0.5,
|
|
99
|
+
minElevation: -2,
|
|
100
|
+
bridges: true,
|
|
101
|
+
bridgeStyle: "arch",
|
|
102
|
+
sidewalks: true,
|
|
103
|
+
sidewalkWidth: 1.9,
|
|
104
|
+
style: DEFAULT_BUILDING_STYLE,
|
|
105
|
+
seed: "",
|
|
106
|
+
};
|
|
107
|
+
/** The city parameter schema — drives the inspector sliders and `meta` parse via the studio seam. */
|
|
108
|
+
export const CITY_SCHEMA = {
|
|
109
|
+
groups: [
|
|
110
|
+
{ id: "layout", label: "Layout" },
|
|
111
|
+
{ id: "placement", label: "Placement" },
|
|
112
|
+
{ id: "zoning", label: "Zoning" },
|
|
113
|
+
{ id: "buildings", label: "Buildings" },
|
|
114
|
+
{ id: "greenery", label: "Greenery & furniture" },
|
|
115
|
+
],
|
|
116
|
+
fields: [
|
|
117
|
+
{ type: "range", key: "gridness", label: "grid-ness", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.gridness },
|
|
118
|
+
{ type: "range", key: "curviness", label: "curviness", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.curviness },
|
|
119
|
+
{ type: "range", key: "branching", label: "branching", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.branching },
|
|
120
|
+
{ type: "range", key: "loopiness", label: "loopiness", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.loopiness },
|
|
121
|
+
{ type: "range", key: "connectivity", label: "connectivity", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.connectivity },
|
|
122
|
+
{ type: "range", key: "deadEnds", label: "dead ends", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.deadEnds },
|
|
123
|
+
{ type: "range", key: "minCurveRadius", label: "min curve radius", group: "layout", min: 6, max: 120, step: 1, default: CITY_DEFAULTS.minCurveRadius, unit: "m" },
|
|
124
|
+
{ type: "range", key: "minTurnAngle", label: "min turn angle", group: "layout", min: 0, max: 60, step: 1, default: CITY_DEFAULTS.minTurnAngle, unit: "°" },
|
|
125
|
+
{ type: "range", key: "maxTurnAngle", label: "max turn angle", group: "layout", min: 30, max: 180, step: 1, default: CITY_DEFAULTS.maxTurnAngle, unit: "°" },
|
|
126
|
+
{ type: "range", key: "blockSize", label: "block size", group: "layout", min: 20, max: 140, step: 1, default: CITY_DEFAULTS.blockSize, unit: "m" },
|
|
127
|
+
{ type: "range", key: "blockAspect", label: "block aspect", group: "layout", min: 1, max: 3, step: 0.05, default: CITY_DEFAULTS.blockAspect },
|
|
128
|
+
{ type: "range", key: "openSpace", label: "open space", group: "layout", min: 0, max: 0.9, step: 0.01, default: CITY_DEFAULTS.openSpace },
|
|
129
|
+
{ type: "range", key: "streetWidth", label: "street width", group: "layout", min: 3, max: 16, step: 0.5, default: CITY_DEFAULTS.streetWidth, unit: "m" },
|
|
130
|
+
{ type: "range", key: "boulevards", label: "boulevards", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.boulevards },
|
|
131
|
+
{ type: "bool", key: "gravelLanes", label: "gravel lanes", group: "layout", default: CITY_DEFAULTS.gravelLanes },
|
|
132
|
+
{
|
|
133
|
+
type: "select",
|
|
134
|
+
key: "surface",
|
|
135
|
+
label: "surface",
|
|
136
|
+
group: "layout",
|
|
137
|
+
default: CITY_DEFAULTS.surface,
|
|
138
|
+
options: [{ value: "auto" }, { value: "gravel" }],
|
|
139
|
+
},
|
|
140
|
+
{ type: "bool", key: "fabric", label: "buildings & parcels", group: "layout", default: CITY_DEFAULTS.fabric },
|
|
141
|
+
{ type: "bool", key: "bridges", label: "bridges over water", group: "layout", default: CITY_DEFAULTS.bridges },
|
|
142
|
+
{ type: "bool", key: "tunnels", label: "tunnels through ridges", group: "layout", default: CITY_DEFAULTS.tunnels },
|
|
143
|
+
{
|
|
144
|
+
type: "select",
|
|
145
|
+
key: "bridgeStyle",
|
|
146
|
+
label: "bridge style",
|
|
147
|
+
group: "layout",
|
|
148
|
+
default: CITY_DEFAULTS.bridgeStyle,
|
|
149
|
+
options: [{ value: "arch" }, { value: "truss" }, { value: "beam" }],
|
|
150
|
+
},
|
|
151
|
+
{ type: "bool", key: "sidewalks", label: "sidewalks", group: "layout", default: CITY_DEFAULTS.sidewalks },
|
|
152
|
+
{ type: "range", key: "sidewalkWidth", label: "sidewalk width", group: "layout", min: 1, max: 4, step: 0.1, default: CITY_DEFAULTS.sidewalkWidth, unit: "m" },
|
|
153
|
+
{ type: "action", key: "layoutRandomize", label: "randomize layout", group: "layout", action: "randomize" },
|
|
154
|
+
{
|
|
155
|
+
type: "select",
|
|
156
|
+
key: "profile",
|
|
157
|
+
label: "zone profile",
|
|
158
|
+
group: "zoning",
|
|
159
|
+
default: CITY_DEFAULTS.profile,
|
|
160
|
+
options: [{ value: "core-out" }, { value: "inverted" }, { value: "uniform" }],
|
|
161
|
+
},
|
|
162
|
+
{ type: "range", key: "coreExtent", label: "core extent", group: "zoning", min: 0.05, max: 0.9, step: 0.01, default: CITY_DEFAULTS.coreExtent },
|
|
163
|
+
{ type: "range", key: "midExtent", label: "mid extent", group: "zoning", min: 0.1, max: 0.95, step: 0.01, default: CITY_DEFAULTS.midExtent },
|
|
164
|
+
{ type: "weightedList", key: "coreMix", label: "core mix", group: "zoning", itemLabel: "class", default: CITY_DEFAULTS.coreMix },
|
|
165
|
+
{ type: "weightedList", key: "midMix", label: "mid mix", group: "zoning", itemLabel: "class", default: CITY_DEFAULTS.midMix },
|
|
166
|
+
{ type: "weightedList", key: "edgeMix", label: "edge mix", group: "zoning", itemLabel: "class", default: CITY_DEFAULTS.edgeMix },
|
|
167
|
+
{ type: "range", key: "lotScale", label: "lot scale", group: "zoning", min: 0.5, max: 2.5, step: 0.05, default: CITY_DEFAULTS.lotScale },
|
|
168
|
+
{ type: "range", key: "roadsideOccupancy", label: "roadside occupancy", group: "placement", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.roadsideOccupancy },
|
|
169
|
+
{ type: "range", key: "blockDensity", label: "block density", group: "placement", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.blockDensity },
|
|
170
|
+
{ type: "range", key: "buildingRoadSetback", label: "road setback", group: "placement", min: 0, max: 12, step: 0.1, default: CITY_DEFAULTS.buildingRoadSetback, unit: "m" },
|
|
171
|
+
{ type: "range", key: "buildingSpacing", label: "building spacing", group: "placement", min: 0.2, max: 10, step: 0.1, default: CITY_DEFAULTS.buildingSpacing, unit: "m" },
|
|
172
|
+
{ type: "range", key: "clusterStrength", label: "cluster strength", group: "placement", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.clusterStrength },
|
|
173
|
+
{ type: "range", key: "floorsMin", label: "floors min", group: "buildings", min: 1, max: 60, step: 1, default: CITY_DEFAULTS.floorsMin },
|
|
174
|
+
{ type: "range", key: "floorsMax", label: "floors max", group: "buildings", min: 1, max: 60, step: 1, default: CITY_DEFAULTS.floorsMax },
|
|
175
|
+
{ type: "range", key: "floorHeight", label: "floor height", group: "buildings", min: 2, max: 5, step: 0.1, default: CITY_DEFAULTS.floorHeight, unit: "m" },
|
|
176
|
+
{ type: "range", key: "maxSlope", label: "max slope", group: "buildings", min: 0.05, max: 2, step: 0.05, default: CITY_DEFAULTS.maxSlope },
|
|
177
|
+
{ type: "number", key: "minElevation", label: "build above y", group: "buildings", step: 0.5, default: CITY_DEFAULTS.minElevation },
|
|
178
|
+
{
|
|
179
|
+
type: "select",
|
|
180
|
+
key: "style",
|
|
181
|
+
label: "style",
|
|
182
|
+
group: "buildings",
|
|
183
|
+
default: CITY_DEFAULTS.style,
|
|
184
|
+
options: Object.keys(BUILDING_STYLE_PALETTES).map((style) => ({ value: style })),
|
|
185
|
+
},
|
|
186
|
+
{ type: "range", key: "treeDensity", label: "tree density", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.treeDensity },
|
|
187
|
+
{ type: "weightedList", key: "treeMix", label: "tree mix", group: "greenery", itemLabel: "species", default: CITY_DEFAULTS.treeMix },
|
|
188
|
+
{ type: "range", key: "lightDensity", label: "street lights", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.lightDensity },
|
|
189
|
+
{ type: "bool", key: "hedges", label: "estate hedges", group: "greenery", default: CITY_DEFAULTS.hedges },
|
|
190
|
+
{ type: "bool", key: "driveways", label: "driveways", group: "greenery", default: CITY_DEFAULTS.driveways },
|
|
191
|
+
{ type: "bool", key: "parking", label: "parking lots", group: "greenery", default: CITY_DEFAULTS.parking },
|
|
192
|
+
{ type: "bool", key: "fields", label: "farm fields", group: "greenery", default: CITY_DEFAULTS.fields },
|
|
193
|
+
{ type: "seed", key: "seed", label: "seed", default: CITY_DEFAULTS.seed },
|
|
194
|
+
],
|
|
195
|
+
presets: [
|
|
196
|
+
{
|
|
197
|
+
id: "manhattan",
|
|
198
|
+
label: "Manhattan",
|
|
199
|
+
values: {
|
|
200
|
+
gridness: 1,
|
|
201
|
+
curviness: 0,
|
|
202
|
+
branching: 0.05,
|
|
203
|
+
blockSize: 32,
|
|
204
|
+
blockAspect: 2.4,
|
|
205
|
+
openSpace: 0.05,
|
|
206
|
+
roadsideOccupancy: 0.97,
|
|
207
|
+
blockDensity: 0.85,
|
|
208
|
+
buildingRoadSetback: 0.8,
|
|
209
|
+
buildingSpacing: 0.8,
|
|
210
|
+
clusterStrength: 0.25,
|
|
211
|
+
streetWidth: 9,
|
|
212
|
+
boulevards: 0.5,
|
|
213
|
+
gravelLanes: false,
|
|
214
|
+
profile: "core-out",
|
|
215
|
+
coreExtent: 0.6,
|
|
216
|
+
midExtent: 0.88,
|
|
217
|
+
coreMix: [
|
|
218
|
+
{ item: "tower", weight: 5 },
|
|
219
|
+
{ item: "slab", weight: 1 },
|
|
220
|
+
],
|
|
221
|
+
midMix: [
|
|
222
|
+
{ item: "tower", weight: 2 },
|
|
223
|
+
{ item: "slab", weight: 2 },
|
|
224
|
+
],
|
|
225
|
+
edgeMix: [
|
|
226
|
+
{ item: "slab", weight: 3 },
|
|
227
|
+
{ item: "rowhouse", weight: 2 },
|
|
228
|
+
],
|
|
229
|
+
lotScale: 1,
|
|
230
|
+
floorsMin: 5,
|
|
231
|
+
floorsMax: 55,
|
|
232
|
+
treeDensity: 0.3,
|
|
233
|
+
treeMix: [{ item: "broadleaf", weight: 1 }],
|
|
234
|
+
lightDensity: 0.8,
|
|
235
|
+
hedges: false,
|
|
236
|
+
driveways: false,
|
|
237
|
+
parking: false,
|
|
238
|
+
fields: false,
|
|
239
|
+
sidewalks: true,
|
|
240
|
+
style: "capital",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "losangeles",
|
|
245
|
+
label: "Los Angeles",
|
|
246
|
+
values: {
|
|
247
|
+
gridness: 0.7,
|
|
248
|
+
curviness: 0.25,
|
|
249
|
+
branching: 0.5,
|
|
250
|
+
blockSize: 62,
|
|
251
|
+
blockAspect: 1.2,
|
|
252
|
+
openSpace: 0.08,
|
|
253
|
+
roadsideOccupancy: 0.9,
|
|
254
|
+
blockDensity: 0.55,
|
|
255
|
+
buildingRoadSetback: 3,
|
|
256
|
+
buildingSpacing: 1.8,
|
|
257
|
+
clusterStrength: 0.5,
|
|
258
|
+
streetWidth: 8,
|
|
259
|
+
boulevards: 0.45,
|
|
260
|
+
gravelLanes: false,
|
|
261
|
+
profile: "core-out",
|
|
262
|
+
coreExtent: 0.25,
|
|
263
|
+
midExtent: 0.55,
|
|
264
|
+
coreMix: [
|
|
265
|
+
{ item: "tower", weight: 2 },
|
|
266
|
+
{ item: "slab", weight: 2 },
|
|
267
|
+
{ item: "shop", weight: 2 },
|
|
268
|
+
],
|
|
269
|
+
midMix: [
|
|
270
|
+
{ item: "shop", weight: 2 },
|
|
271
|
+
{ item: "house", weight: 3 },
|
|
272
|
+
{ item: "slab", weight: 1 },
|
|
273
|
+
],
|
|
274
|
+
edgeMix: [
|
|
275
|
+
{ item: "house", weight: 5 },
|
|
276
|
+
{ item: "shop", weight: 1 },
|
|
277
|
+
],
|
|
278
|
+
lotScale: 1.1,
|
|
279
|
+
floorsMin: 1,
|
|
280
|
+
floorsMax: 18,
|
|
281
|
+
treeDensity: 0.55,
|
|
282
|
+
treeMix: [
|
|
283
|
+
{ item: "palm", weight: 4 },
|
|
284
|
+
{ item: "broadleaf", weight: 1 },
|
|
285
|
+
{ item: "cypress", weight: 1 },
|
|
286
|
+
],
|
|
287
|
+
lightDensity: 0.6,
|
|
288
|
+
hedges: false,
|
|
289
|
+
driveways: true,
|
|
290
|
+
parking: true,
|
|
291
|
+
fields: false,
|
|
292
|
+
sidewalks: true,
|
|
293
|
+
style: "coastal",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "beverlyhills",
|
|
298
|
+
label: "Beverly Hills",
|
|
299
|
+
values: {
|
|
300
|
+
gridness: 0.2,
|
|
301
|
+
curviness: 0.8,
|
|
302
|
+
branching: 0.6,
|
|
303
|
+
blockSize: 90,
|
|
304
|
+
blockAspect: 1,
|
|
305
|
+
openSpace: 0.07,
|
|
306
|
+
roadsideOccupancy: 0.85,
|
|
307
|
+
blockDensity: 0.3,
|
|
308
|
+
buildingRoadSetback: 4.5,
|
|
309
|
+
buildingSpacing: 2.2,
|
|
310
|
+
clusterStrength: 0.35,
|
|
311
|
+
streetWidth: 6.5,
|
|
312
|
+
boulevards: 0,
|
|
313
|
+
gravelLanes: false,
|
|
314
|
+
profile: "uniform",
|
|
315
|
+
coreExtent: 0.35,
|
|
316
|
+
midExtent: 0.7,
|
|
317
|
+
coreMix: [
|
|
318
|
+
{ item: "mansion", weight: 3 },
|
|
319
|
+
{ item: "house", weight: 1 },
|
|
320
|
+
],
|
|
321
|
+
midMix: [
|
|
322
|
+
{ item: "mansion", weight: 3 },
|
|
323
|
+
{ item: "house", weight: 1 },
|
|
324
|
+
],
|
|
325
|
+
edgeMix: [
|
|
326
|
+
{ item: "mansion", weight: 2 },
|
|
327
|
+
{ item: "house", weight: 2 },
|
|
328
|
+
],
|
|
329
|
+
lotScale: 1.5,
|
|
330
|
+
floorsMin: 1,
|
|
331
|
+
floorsMax: 3,
|
|
332
|
+
treeDensity: 0.9,
|
|
333
|
+
treeMix: [
|
|
334
|
+
{ item: "palm", weight: 3 },
|
|
335
|
+
{ item: "cypress", weight: 2 },
|
|
336
|
+
{ item: "broadleaf", weight: 2 },
|
|
337
|
+
],
|
|
338
|
+
lightDensity: 0.35,
|
|
339
|
+
hedges: true,
|
|
340
|
+
driveways: true,
|
|
341
|
+
parking: false,
|
|
342
|
+
fields: false,
|
|
343
|
+
sidewalks: true,
|
|
344
|
+
style: "coastal",
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "ruralohio",
|
|
349
|
+
label: "Rural Ohio",
|
|
350
|
+
values: {
|
|
351
|
+
gridness: 1,
|
|
352
|
+
curviness: 0.06,
|
|
353
|
+
branching: 0.3,
|
|
354
|
+
blockSize: 120,
|
|
355
|
+
blockAspect: 1,
|
|
356
|
+
openSpace: 0.55,
|
|
357
|
+
roadsideOccupancy: 0.6,
|
|
358
|
+
blockDensity: 0.25,
|
|
359
|
+
buildingRoadSetback: 6,
|
|
360
|
+
buildingSpacing: 5,
|
|
361
|
+
clusterStrength: 0.8,
|
|
362
|
+
streetWidth: 5.5,
|
|
363
|
+
boulevards: 0,
|
|
364
|
+
gravelLanes: true,
|
|
365
|
+
profile: "core-out",
|
|
366
|
+
coreExtent: 0.22,
|
|
367
|
+
midExtent: 0.55,
|
|
368
|
+
coreMix: [
|
|
369
|
+
{ item: "shop", weight: 2 },
|
|
370
|
+
{ item: "house", weight: 2 },
|
|
371
|
+
{ item: "rowhouse", weight: 1 },
|
|
372
|
+
],
|
|
373
|
+
midMix: [
|
|
374
|
+
{ item: "farmhouse", weight: 2 },
|
|
375
|
+
{ item: "house", weight: 1 },
|
|
376
|
+
{ item: "barn", weight: 1 },
|
|
377
|
+
],
|
|
378
|
+
edgeMix: [
|
|
379
|
+
{ item: "farmhouse", weight: 2 },
|
|
380
|
+
{ item: "barn", weight: 2 },
|
|
381
|
+
{ item: "silo", weight: 1 },
|
|
382
|
+
],
|
|
383
|
+
lotScale: 1.4,
|
|
384
|
+
floorsMin: 1,
|
|
385
|
+
floorsMax: 2,
|
|
386
|
+
treeDensity: 0.35,
|
|
387
|
+
treeMix: [
|
|
388
|
+
{ item: "broadleaf", weight: 3 },
|
|
389
|
+
{ item: "conifer", weight: 2 },
|
|
390
|
+
],
|
|
391
|
+
lightDensity: 0.1,
|
|
392
|
+
hedges: false,
|
|
393
|
+
driveways: true,
|
|
394
|
+
parking: false,
|
|
395
|
+
fields: true,
|
|
396
|
+
sidewalks: false,
|
|
397
|
+
style: "village",
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: "circuit",
|
|
402
|
+
label: "Race circuit",
|
|
403
|
+
values: {
|
|
404
|
+
gridness: 0.4,
|
|
405
|
+
curviness: 0.55,
|
|
406
|
+
branching: 0.15,
|
|
407
|
+
loopiness: 1,
|
|
408
|
+
connectivity: 0.05,
|
|
409
|
+
deadEnds: 0,
|
|
410
|
+
minCurveRadius: 34,
|
|
411
|
+
minTurnAngle: 0,
|
|
412
|
+
maxTurnAngle: 95,
|
|
413
|
+
blockSize: 95,
|
|
414
|
+
blockAspect: 1,
|
|
415
|
+
openSpace: 0,
|
|
416
|
+
streetWidth: 12,
|
|
417
|
+
boulevards: 0,
|
|
418
|
+
gravelLanes: false,
|
|
419
|
+
surface: "auto",
|
|
420
|
+
fabric: false,
|
|
421
|
+
bridges: true,
|
|
422
|
+
tunnels: true,
|
|
423
|
+
sidewalks: false,
|
|
424
|
+
treeDensity: 0.2,
|
|
425
|
+
lightDensity: 0.25,
|
|
426
|
+
hedges: false,
|
|
427
|
+
driveways: false,
|
|
428
|
+
parking: false,
|
|
429
|
+
fields: false,
|
|
430
|
+
style: "generic",
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
id: "rally",
|
|
435
|
+
label: "Rally stage",
|
|
436
|
+
values: {
|
|
437
|
+
gridness: 0.1,
|
|
438
|
+
curviness: 0.95,
|
|
439
|
+
branching: 0.2,
|
|
440
|
+
loopiness: 0.15,
|
|
441
|
+
connectivity: 0.12,
|
|
442
|
+
deadEnds: 0.3,
|
|
443
|
+
minCurveRadius: 12,
|
|
444
|
+
minTurnAngle: 0,
|
|
445
|
+
maxTurnAngle: 150,
|
|
446
|
+
blockSize: 72,
|
|
447
|
+
blockAspect: 1,
|
|
448
|
+
openSpace: 0,
|
|
449
|
+
streetWidth: 6,
|
|
450
|
+
boulevards: 0,
|
|
451
|
+
gravelLanes: true,
|
|
452
|
+
surface: "gravel",
|
|
453
|
+
fabric: false,
|
|
454
|
+
bridges: true,
|
|
455
|
+
tunnels: true,
|
|
456
|
+
sidewalks: false,
|
|
457
|
+
treeDensity: 0.65,
|
|
458
|
+
lightDensity: 0.05,
|
|
459
|
+
hedges: false,
|
|
460
|
+
driveways: false,
|
|
461
|
+
parking: false,
|
|
462
|
+
fields: false,
|
|
463
|
+
style: "village",
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
id: "metropolis",
|
|
468
|
+
label: "Zoned metropolis",
|
|
469
|
+
values: {
|
|
470
|
+
gridness: 0.85,
|
|
471
|
+
curviness: 0.15,
|
|
472
|
+
branching: 0.4,
|
|
473
|
+
blockSize: 48,
|
|
474
|
+
blockAspect: 1.3,
|
|
475
|
+
openSpace: 0.12,
|
|
476
|
+
roadsideOccupancy: 0.92,
|
|
477
|
+
blockDensity: 0.7,
|
|
478
|
+
buildingRoadSetback: 1.5,
|
|
479
|
+
buildingSpacing: 1.2,
|
|
480
|
+
clusterStrength: 0.45,
|
|
481
|
+
streetWidth: 7.5,
|
|
482
|
+
boulevards: 0.4,
|
|
483
|
+
profile: "core-out",
|
|
484
|
+
coreExtent: 0.45,
|
|
485
|
+
midExtent: 0.75,
|
|
486
|
+
floorsMin: 1,
|
|
487
|
+
floorsMax: 34,
|
|
488
|
+
treeDensity: 0.55,
|
|
489
|
+
lightDensity: 0.6,
|
|
490
|
+
style: "generic",
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
};
|
|
495
|
+
/** Zone-override schema for `cityzone` volumes: pin a band and optionally a bespoke class mix. */
|
|
496
|
+
export const CITY_ZONE_SCHEMA = {
|
|
497
|
+
fields: [
|
|
498
|
+
{
|
|
499
|
+
type: "select",
|
|
500
|
+
key: "band",
|
|
501
|
+
label: "zone band",
|
|
502
|
+
default: "core",
|
|
503
|
+
options: [{ value: "core" }, { value: "mid" }, { value: "edge" }],
|
|
504
|
+
},
|
|
505
|
+
{ type: "weightedList", key: "mix", label: "class mix", itemLabel: "class", default: [] },
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
/** Parse a volume's `meta` into fully-defaulted city rules. @internal */
|
|
509
|
+
export function readCityRules(meta) {
|
|
510
|
+
const params = parseParams(CITY_SCHEMA, meta);
|
|
511
|
+
const floorsMin = params["floorsMin"];
|
|
512
|
+
const floorsMax = params["floorsMax"];
|
|
513
|
+
// Legacy documents tuned `buildingDensity`; honor it as the occupancy dial when the volume
|
|
514
|
+
// predates the placement params (and only then — presets/authors that set the new keys win).
|
|
515
|
+
const legacyDensity = meta?.["buildingDensity"];
|
|
516
|
+
const hasLegacy = typeof legacyDensity === "number" && Number.isFinite(legacyDensity) && meta?.["roadsideOccupancy"] === undefined;
|
|
517
|
+
return {
|
|
518
|
+
gridness: params["gridness"],
|
|
519
|
+
curviness: params["curviness"],
|
|
520
|
+
branching: params["branching"],
|
|
521
|
+
loopiness: params["loopiness"],
|
|
522
|
+
connectivity: params["connectivity"],
|
|
523
|
+
deadEnds: params["deadEnds"],
|
|
524
|
+
minCurveRadius: params["minCurveRadius"],
|
|
525
|
+
minTurnAngle: params["minTurnAngle"],
|
|
526
|
+
maxTurnAngle: params["maxTurnAngle"],
|
|
527
|
+
fabric: params["fabric"],
|
|
528
|
+
tunnels: params["tunnels"],
|
|
529
|
+
blockSize: params["blockSize"],
|
|
530
|
+
blockAspect: params["blockAspect"],
|
|
531
|
+
openSpace: params["openSpace"],
|
|
532
|
+
roadsideOccupancy: hasLegacy ? Math.max(0, Math.min(1, legacyDensity)) : params["roadsideOccupancy"],
|
|
533
|
+
blockDensity: hasLegacy ? Math.max(0, Math.min(1, legacyDensity * 0.8)) : params["blockDensity"],
|
|
534
|
+
buildingRoadSetback: params["buildingRoadSetback"],
|
|
535
|
+
buildingSpacing: params["buildingSpacing"],
|
|
536
|
+
clusterStrength: params["clusterStrength"],
|
|
537
|
+
streetWidth: params["streetWidth"],
|
|
538
|
+
boulevards: params["boulevards"],
|
|
539
|
+
gravelLanes: params["gravelLanes"],
|
|
540
|
+
surface: params["surface"],
|
|
541
|
+
profile: params["profile"],
|
|
542
|
+
coreExtent: params["coreExtent"],
|
|
543
|
+
midExtent: params["midExtent"],
|
|
544
|
+
coreMix: params["coreMix"],
|
|
545
|
+
midMix: params["midMix"],
|
|
546
|
+
edgeMix: params["edgeMix"],
|
|
547
|
+
lotScale: params["lotScale"],
|
|
548
|
+
floorsMin: Math.min(floorsMin, floorsMax),
|
|
549
|
+
floorsMax: Math.max(floorsMin, floorsMax),
|
|
550
|
+
floorHeight: params["floorHeight"],
|
|
551
|
+
treeDensity: params["treeDensity"],
|
|
552
|
+
treeMix: params["treeMix"],
|
|
553
|
+
lightDensity: params["lightDensity"],
|
|
554
|
+
hedges: params["hedges"],
|
|
555
|
+
driveways: params["driveways"],
|
|
556
|
+
parking: params["parking"],
|
|
557
|
+
fields: params["fields"],
|
|
558
|
+
maxSlope: params["maxSlope"],
|
|
559
|
+
minElevation: params["minElevation"],
|
|
560
|
+
bridges: params["bridges"],
|
|
561
|
+
bridgeStyle: params["bridgeStyle"],
|
|
562
|
+
sidewalks: params["sidewalks"],
|
|
563
|
+
sidewalkWidth: params["sidewalkWidth"],
|
|
564
|
+
style: params["style"],
|
|
565
|
+
seed: params["seed"],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Bounded-work caps so a huge volume can never generate unbounded content. */
|
|
569
|
+
const MAX_STREETS = 320;
|
|
570
|
+
const MAX_LOTS = 2600;
|
|
571
|
+
const MAX_INTERSECTIONS = 600;
|
|
572
|
+
const MAX_TREES = 3200;
|
|
573
|
+
const MAX_LIGHTS = 1200;
|
|
574
|
+
const MAX_FIELD_ROWS = 2400;
|
|
575
|
+
const MAX_PARKING = 380;
|
|
576
|
+
const TAU = Math.PI * 2;
|
|
577
|
+
function axisValue(value) {
|
|
578
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
579
|
+
}
|
|
580
|
+
/** Smooth two-octave sideways wander along a street, deterministic per street. */
|
|
581
|
+
function makeWander(rng, amplitude, wavelength) {
|
|
582
|
+
const f1 = TAU / (wavelength * (0.75 + rng() * 0.5));
|
|
583
|
+
const f2 = f1 * (2.3 + rng() * 0.8);
|
|
584
|
+
const p1 = rng() * TAU;
|
|
585
|
+
const p2 = rng() * TAU;
|
|
586
|
+
return (t) => amplitude * (Math.sin(t * f1 + p1) * 0.65 + Math.sin(t * f2 + p2) * 0.35);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Spatial hash of dense street centerline samples (with per-sample clearance radius). Serves both
|
|
590
|
+
* "is this point on/near a road" lot rejection and branch-to-street connection tests without
|
|
591
|
+
* quadratic polyline scans.
|
|
592
|
+
*/
|
|
593
|
+
class StreetIndex {
|
|
594
|
+
cells = new Map();
|
|
595
|
+
cell = 10;
|
|
596
|
+
key(x, z) {
|
|
597
|
+
return `${Math.floor(x / this.cell)}:${Math.floor(z / this.cell)}`;
|
|
598
|
+
}
|
|
599
|
+
addStreet(index, points, width) {
|
|
600
|
+
const r = width / 2;
|
|
601
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
602
|
+
const [ax, az] = points[i];
|
|
603
|
+
const [bx, bz] = points[i + 1];
|
|
604
|
+
const len = Math.hypot(bx - ax, bz - az);
|
|
605
|
+
const steps = Math.max(1, Math.ceil(len / 3));
|
|
606
|
+
for (let s = 0; s <= steps; s += 1) {
|
|
607
|
+
const t = s / steps;
|
|
608
|
+
const x = ax + (bx - ax) * t;
|
|
609
|
+
const z = az + (bz - az) * t;
|
|
610
|
+
const key = this.key(x, z);
|
|
611
|
+
const bucket = this.cells.get(key);
|
|
612
|
+
const sample = { x, z, r, street: index };
|
|
613
|
+
if (bucket === undefined)
|
|
614
|
+
this.cells.set(key, [sample]);
|
|
615
|
+
else
|
|
616
|
+
bucket.push(sample);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** Smallest (distance − clearance) to any street sample near the point; Infinity when clear. */
|
|
621
|
+
clearance(x, z, margin, excludeStreet = -1) {
|
|
622
|
+
const cx = Math.floor(x / this.cell);
|
|
623
|
+
const cz = Math.floor(z / this.cell);
|
|
624
|
+
let worst = Infinity;
|
|
625
|
+
for (let i = cx - 1; i <= cx + 1; i += 1) {
|
|
626
|
+
for (let j = cz - 1; j <= cz + 1; j += 1) {
|
|
627
|
+
const bucket = this.cells.get(`${i}:${j}`);
|
|
628
|
+
if (bucket === undefined)
|
|
629
|
+
continue;
|
|
630
|
+
for (const sample of bucket) {
|
|
631
|
+
if (sample.street === excludeStreet)
|
|
632
|
+
continue;
|
|
633
|
+
const d = Math.hypot(sample.x - x, sample.z - z) - sample.r - margin;
|
|
634
|
+
if (d < worst)
|
|
635
|
+
worst = d;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return worst;
|
|
640
|
+
}
|
|
641
|
+
/** Nearest centerline sample to a point (expanding ring search), or null when far from roads. */
|
|
642
|
+
nearest(x, z, maxStreet = Infinity) {
|
|
643
|
+
const cx = Math.floor(x / this.cell);
|
|
644
|
+
const cz = Math.floor(z / this.cell);
|
|
645
|
+
for (let radius = 1; radius <= 4; radius += 1) {
|
|
646
|
+
let best = null;
|
|
647
|
+
for (let i = cx - radius; i <= cx + radius; i += 1) {
|
|
648
|
+
for (let j = cz - radius; j <= cz + radius; j += 1) {
|
|
649
|
+
const bucket = this.cells.get(`${i}:${j}`);
|
|
650
|
+
if (bucket === undefined)
|
|
651
|
+
continue;
|
|
652
|
+
for (const sample of bucket) {
|
|
653
|
+
if (sample.street >= maxStreet)
|
|
654
|
+
continue;
|
|
655
|
+
const d = Math.hypot(sample.x - x, sample.z - z);
|
|
656
|
+
if (best === null || d < best.dist)
|
|
657
|
+
best = { x: sample.x, z: sample.z, street: sample.street, dist: d };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (best !== null)
|
|
662
|
+
return best;
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Separating-axis test for two rotated rectangles (center, half-extents, yaw): true when a gap exists. */
|
|
668
|
+
function rectsSeparated(a, b) {
|
|
669
|
+
const axes = [];
|
|
670
|
+
for (const angle of [a.angle, b.angle]) {
|
|
671
|
+
axes.push([Math.cos(angle), -Math.sin(angle)], [Math.sin(angle), Math.cos(angle)]);
|
|
672
|
+
}
|
|
673
|
+
const corners = (r) => {
|
|
674
|
+
const c = Math.cos(r.angle);
|
|
675
|
+
const s = Math.sin(r.angle);
|
|
676
|
+
const out = [];
|
|
677
|
+
for (const [dx, dz] of [
|
|
678
|
+
[r.hw, r.hd],
|
|
679
|
+
[r.hw, -r.hd],
|
|
680
|
+
[-r.hw, r.hd],
|
|
681
|
+
[-r.hw, -r.hd],
|
|
682
|
+
]) {
|
|
683
|
+
out.push([r.x + dx * c + dz * s, r.z - dx * s + dz * c]);
|
|
684
|
+
}
|
|
685
|
+
return out;
|
|
686
|
+
};
|
|
687
|
+
const ca = corners(a);
|
|
688
|
+
const cb = corners(b);
|
|
689
|
+
for (const [ax, az] of axes) {
|
|
690
|
+
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
|
|
691
|
+
for (const [x, z] of ca) {
|
|
692
|
+
const p = x * ax + z * az;
|
|
693
|
+
if (p < minA)
|
|
694
|
+
minA = p;
|
|
695
|
+
if (p > maxA)
|
|
696
|
+
maxA = p;
|
|
697
|
+
}
|
|
698
|
+
for (const [x, z] of cb) {
|
|
699
|
+
const p = x * ax + z * az;
|
|
700
|
+
if (p < minB)
|
|
701
|
+
minB = p;
|
|
702
|
+
if (p > maxB)
|
|
703
|
+
maxB = p;
|
|
704
|
+
}
|
|
705
|
+
if (maxA < minB || maxB < minA)
|
|
706
|
+
return true; // gap on this axis → no overlap
|
|
707
|
+
}
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
/** Broadphase grid over placed lots so overlap tests only touch neighbors. */
|
|
711
|
+
class LotIndex {
|
|
712
|
+
cells = new Map();
|
|
713
|
+
cell = 24;
|
|
714
|
+
key(x, z) {
|
|
715
|
+
return `${Math.floor(x / this.cell)}:${Math.floor(z / this.cell)}`;
|
|
716
|
+
}
|
|
717
|
+
overlapsAny(lot, gap) {
|
|
718
|
+
const grown = { ...lot, hw: lot.hw + gap / 2, hd: lot.hd + gap / 2 };
|
|
719
|
+
const cx = Math.floor(lot.x / this.cell);
|
|
720
|
+
const cz = Math.floor(lot.z / this.cell);
|
|
721
|
+
for (let i = cx - 1; i <= cx + 1; i += 1) {
|
|
722
|
+
for (let j = cz - 1; j <= cz + 1; j += 1) {
|
|
723
|
+
const bucket = this.cells.get(`${i}:${j}`);
|
|
724
|
+
if (bucket === undefined)
|
|
725
|
+
continue;
|
|
726
|
+
for (const other of bucket) {
|
|
727
|
+
if (!rectsSeparated(grown, other))
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
add(lot) {
|
|
735
|
+
const key = this.key(lot.x, lot.z);
|
|
736
|
+
const bucket = this.cells.get(key);
|
|
737
|
+
if (bucket === undefined)
|
|
738
|
+
this.cells.set(key, [lot]);
|
|
739
|
+
else
|
|
740
|
+
bucket.push(lot);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const LEVEL_RANK = { boulevard: 3, avenue: 2, street: 1, lane: 0 };
|
|
744
|
+
/**
|
|
745
|
+
* The block-first fabric: extract closed block polygons from the street graph, classify each
|
|
746
|
+
* block (park/plaza/field/buffer/buildable), subdivide buildable block frontage into polygonal
|
|
747
|
+
* parcels, and derive every building from its parcel's buildable polygon. A curved street bounds
|
|
748
|
+
* a curved block; a diagonal one yields diagonal parcels — placement never guesses from the
|
|
749
|
+
* nearest road again.
|
|
750
|
+
*/
|
|
751
|
+
function buildFabric(rules, streams, streets, index, hx, hz, slopeAt, heightAt, overrideAt, junctions) {
|
|
752
|
+
const result = { blocks: [], parcels: [], parks: [], lots: [], placed: new LotIndex(), hedges: [], driveways: [] };
|
|
753
|
+
const bandMix = { core: rules.coreMix, mid: rules.midMix, edge: rules.edgeMix };
|
|
754
|
+
const junctionBoost = (x, z) => {
|
|
755
|
+
let best = Infinity;
|
|
756
|
+
for (const j of junctions) {
|
|
757
|
+
const d = Math.hypot(j.x - x, j.z - z);
|
|
758
|
+
if (d < best)
|
|
759
|
+
best = d;
|
|
760
|
+
}
|
|
761
|
+
return best === Infinity ? 0 : Math.max(0, 1 - best / (rules.blockSize * 0.6));
|
|
762
|
+
};
|
|
763
|
+
const commercialCore = rules.coreMix.some((entry) => (entry.item === "tower" || entry.item === "slab" || entry.item === "shop") && entry.weight > 0);
|
|
764
|
+
const fabric = extractBlocks(streets.map((street) => ({ points: street.points, width: street.width, level: street.level, sidewalk: street.sidewalk })), hx, hz, { streetWidthBase: rules.streetWidth, sidewalkBase: rules.sidewalkWidth, curbMargin: 0.35 });
|
|
765
|
+
const bandAt = (x, z, roll) => {
|
|
766
|
+
const override = overrideAt === null ? null : overrideAt(x, z);
|
|
767
|
+
const band = override !== null ? override.band : zoneBand(zoneMetric(x, z, hx, hz), rules.profile, rules.coreExtent, rules.midExtent, roll);
|
|
768
|
+
const mix = override !== null && override.mix !== null && override.mix.length > 0 ? override.mix : bandMix[band];
|
|
769
|
+
return { band, mix };
|
|
770
|
+
};
|
|
771
|
+
const parkFromRing = (id, type, ring, jitter) => {
|
|
772
|
+
const bounds = ringBounds(ring);
|
|
773
|
+
return {
|
|
774
|
+
id,
|
|
775
|
+
center: [(bounds.minX + bounds.maxX) / 2, (bounds.minZ + bounds.maxZ) / 2],
|
|
776
|
+
size: [Math.max(2, bounds.maxX - bounds.minX), Math.max(2, bounds.maxZ - bounds.minZ)],
|
|
777
|
+
type,
|
|
778
|
+
jitter,
|
|
779
|
+
polygon: ring,
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
for (let bi = 0; bi < fabric.blocks.length; bi += 1) {
|
|
783
|
+
const rings = fabric.blocks[bi];
|
|
784
|
+
const rng = streams(`block:${bi}`);
|
|
785
|
+
const bandRoll = rng();
|
|
786
|
+
const openRoll = rng();
|
|
787
|
+
const parkJitter = rng();
|
|
788
|
+
const land = rings.land;
|
|
789
|
+
const blockId = result.blocks.length;
|
|
790
|
+
if (land.length < 3) {
|
|
791
|
+
// The inset consumed the face: whatever pavement-free scrap remains is a landscaped buffer.
|
|
792
|
+
if (rings.curb.length >= 3 && polygonArea(rings.curb) > 30) {
|
|
793
|
+
result.blocks.push({ id: `block:${bi}`, kind: "buffer", polygon: rings.curb, curb: rings.curb, area: polygonArea(rings.curb) });
|
|
794
|
+
result.parks.push(parkFromRing(`park:${bi}`, "buffer", rings.curb, parkJitter));
|
|
795
|
+
}
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const area = polygonArea(land);
|
|
799
|
+
const [bcx, bcz] = polygonCentroid(land);
|
|
800
|
+
const { band } = bandAt(bcx, bcz, bandRoll);
|
|
801
|
+
const sliver = isSliverBlock(land, Math.max(90, rules.blockSize * rules.blockSize * 0.028), Math.max(5.5, rules.streetWidth * 0.75));
|
|
802
|
+
if (sliver) {
|
|
803
|
+
result.blocks.push({ id: `block:${bi}`, kind: "buffer", polygon: land, curb: rings.curb, area });
|
|
804
|
+
result.parks.push(parkFromRing(`park:${bi}`, "buffer", land, parkJitter));
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
let fieldBlock = false;
|
|
808
|
+
if (openRoll < rules.openSpace) {
|
|
809
|
+
// Intentional open-space block. Pocket plazas only in a commercial core, and only pocket-sized.
|
|
810
|
+
const bounds = ringBounds(land);
|
|
811
|
+
const pocket = band === "core" && commercialCore && Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) <= 52;
|
|
812
|
+
const type = rules.fields && !pocket ? "field" : pocket ? "plaza" : band === "edge" ? "meadow" : "green";
|
|
813
|
+
result.blocks.push({ id: `block:${bi}`, kind: type === "plaza" ? "plaza" : type === "field" ? "field" : "park", polygon: land, curb: rings.curb, area });
|
|
814
|
+
result.parks.push(parkFromRing(`park:${bi}`, type, land, parkJitter));
|
|
815
|
+
// Farm fields never displace homesteads — the farmhouse standing in its own field IS the
|
|
816
|
+
// look, so field blocks still subdivide their road frontage; other park types stay clear.
|
|
817
|
+
if (type !== "field")
|
|
818
|
+
continue;
|
|
819
|
+
fieldBlock = true;
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
result.blocks.push({ id: `block:${bi}`, kind: "buildable", polygon: land, curb: rings.curb, area });
|
|
823
|
+
}
|
|
824
|
+
// --- parcel subdivision along the block's frontage ring ---
|
|
825
|
+
const walker = new RingWalker(land);
|
|
826
|
+
const halfW = Math.max(3, polygonMeanWidth(land) / 2);
|
|
827
|
+
const wave = makeWander(rng, 0.5, rules.blockSize * 2.6);
|
|
828
|
+
const blockParcels = [];
|
|
829
|
+
let cursor = rng() * 4;
|
|
830
|
+
const endStation = walker.total - 2;
|
|
831
|
+
let prevClass = null;
|
|
832
|
+
let maxDepthUsed = 0;
|
|
833
|
+
let guard = 0;
|
|
834
|
+
while (cursor < endStation && blockParcels.length < 64 && result.lots.length < MAX_LOTS && guard < 220) {
|
|
835
|
+
guard += 1;
|
|
836
|
+
const pBandRoll = rng();
|
|
837
|
+
const classRoll = rng();
|
|
838
|
+
const stickRoll = rng();
|
|
839
|
+
const occupancyRoll = rng();
|
|
840
|
+
const lotJitter = rng();
|
|
841
|
+
const lotRng = streams(`lot:${bi}:${Math.round(cursor * 10)}`);
|
|
842
|
+
const mid0 = walker.at(cursor);
|
|
843
|
+
// Only true street frontage grows parcels: an arc along the district rim (or facing a
|
|
844
|
+
// pruned dead end) has no road to face, so the cursor slides past it.
|
|
845
|
+
const curbProbe = [mid0.p[0] - mid0.normal[0] * 2, mid0.p[1] - mid0.normal[1] * 2];
|
|
846
|
+
const near = index.nearest(curbProbe[0], curbProbe[1], streets.length);
|
|
847
|
+
if (near === null || near.dist > streets[near.street].width / 2 + rules.sidewalkWidth * 1.6 + 3) {
|
|
848
|
+
cursor += 6;
|
|
849
|
+
prevClass = null;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const probe = [mid0.p[0] + mid0.normal[0] * 5, mid0.p[1] + mid0.normal[1] * 5];
|
|
853
|
+
const { band: pBand, mix } = bandAt(probe[0], probe[1], pBandRoll);
|
|
854
|
+
let cls = pickClass(mix, classRoll);
|
|
855
|
+
// Sticky class runs: with clustering, a house is followed by more houses — rows form.
|
|
856
|
+
if (prevClass !== null && stickRoll < 0.45 + rules.clusterStrength * 0.4 && mix.some((entry) => entry.item === prevClass && entry.weight > 0)) {
|
|
857
|
+
cls = prevClass;
|
|
858
|
+
}
|
|
859
|
+
const placement = rollClassPlacement(cls, lotRng, rules.lotScale, rules.floorsMin, rules.floorsMax, rules.buildingRoadSetback, rules.buildingSpacing);
|
|
860
|
+
const frontW = placement.width + placement.gap;
|
|
861
|
+
if (cursor + frontW > walker.total - 0.5)
|
|
862
|
+
break;
|
|
863
|
+
// Parcel depth: setback + class depth + rear yard, deepened toward the block spine by
|
|
864
|
+
// `blockDensity` (dense districts consume their block; loose ones keep green cores). The
|
|
865
|
+
// hard cap is a per-station ray cast — parcels from opposite frontages meet halfway.
|
|
866
|
+
const rayOrigin = [mid0.p[0] + mid0.normal[0] * 0.2, mid0.p[1] + mid0.normal[1] * 0.2];
|
|
867
|
+
const across = rayDistanceToRing(land, rayOrigin, mid0.normal);
|
|
868
|
+
const depthCap = Number.isFinite(across) ? Math.max(3, across * 0.5 - 0.3) : halfW;
|
|
869
|
+
const depth = Math.min(depthCap, placement.setback + placement.depth + 2 + rules.blockDensity * Math.max(0, depthCap - placement.depth - placement.setback - 2));
|
|
870
|
+
const s0 = cursor;
|
|
871
|
+
const s1 = cursor + frontW;
|
|
872
|
+
let usedDepth = depth;
|
|
873
|
+
let poly = cutParcel(walker, land, { s0, s1, depth });
|
|
874
|
+
const midS = walker.at((s0 + s1) / 2);
|
|
875
|
+
if (poly !== null && fabric.deadEnds.length > 0) {
|
|
876
|
+
// Cul-de-sac lanes were pruned from the face graph; carve their corridors out of parcels.
|
|
877
|
+
const keep = [midS.p[0] + midS.normal[0] * 1.5, midS.p[1] + midS.normal[1] * 1.5];
|
|
878
|
+
let carved = carveCorridors(poly, keep, fabric.deadEnds.map((d) => ({ pts: d.pts, width: d.width + 1 })), 0.8);
|
|
879
|
+
// Carve chords can cross a concave notch of the block; conform the result again.
|
|
880
|
+
if (carved.length >= 3 && carved.length !== poly.length)
|
|
881
|
+
carved = conformPolygonToRing(carved, land);
|
|
882
|
+
poly = carved.length >= 3 ? carved : null;
|
|
883
|
+
}
|
|
884
|
+
if (poly !== null) {
|
|
885
|
+
// Never overlap a neighbor parcel: retry shallower once, then give the arc up as yard.
|
|
886
|
+
let clash = blockParcels.some((other) => polygonsOverlap(poly, other.polygon, 0.18));
|
|
887
|
+
if (clash) {
|
|
888
|
+
const shallow = cutParcel(walker, land, { s0, s1, depth: depth * 0.55 });
|
|
889
|
+
if (shallow !== null && !blockParcels.some((other) => polygonsOverlap(shallow, other.polygon, 0.18))) {
|
|
890
|
+
poly = shallow;
|
|
891
|
+
usedDepth = depth * 0.55;
|
|
892
|
+
clash = false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (clash)
|
|
896
|
+
poly = null;
|
|
897
|
+
}
|
|
898
|
+
if (poly === null) {
|
|
899
|
+
cursor += frontW * 0.6;
|
|
900
|
+
prevClass = null;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const A = walker.at(s0).p;
|
|
904
|
+
const B = walker.at(s1).p;
|
|
905
|
+
const tangent = (() => {
|
|
906
|
+
const tx = B[0] - A[0];
|
|
907
|
+
const tz = B[1] - A[1];
|
|
908
|
+
const l = Math.hypot(tx, tz) || 1;
|
|
909
|
+
return [tx / l, tz / l];
|
|
910
|
+
})();
|
|
911
|
+
let isCorner = false;
|
|
912
|
+
for (const vi of walker.verticesBetween(s0, s1)) {
|
|
913
|
+
const angle = walker.interiorAngle(vi);
|
|
914
|
+
if (angle < 2.53 || angle > Math.PI * 2 - 2.53) {
|
|
915
|
+
isCorner = true;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const frontProbe = [midS.p[0] - midS.normal[0] * 2, midS.p[1] - midS.normal[1] * 2];
|
|
920
|
+
const nearestStreet = index.nearest(frontProbe[0], frontProbe[1], streets.length);
|
|
921
|
+
const frontage = nearestStreet === null ? [] : [{ street: nearestStreet.street, a: A, b: B, tangent }];
|
|
922
|
+
const parcelId = `parcel:${bi}:${blockParcels.length}`;
|
|
923
|
+
const parcel = {
|
|
924
|
+
id: parcelId,
|
|
925
|
+
block: blockId,
|
|
926
|
+
polygon: poly,
|
|
927
|
+
buildable: [],
|
|
928
|
+
frontage,
|
|
929
|
+
area: polygonArea(poly),
|
|
930
|
+
depth: usedDepth,
|
|
931
|
+
isCorner,
|
|
932
|
+
kind: "vacant",
|
|
933
|
+
};
|
|
934
|
+
// Local occupancy: the district dial, drifted by the smooth wave and boosted near
|
|
935
|
+
// junctions so intersections read as centers of development.
|
|
936
|
+
const local = rules.roadsideOccupancy * (1 - rules.clusterStrength * 0.3) +
|
|
937
|
+
rules.clusterStrength * (wave(cursor) + 0.5) * 0.22 +
|
|
938
|
+
rules.clusterStrength * junctionBoost(midS.p[0], midS.p[1]) * 0.6;
|
|
939
|
+
if (occupancyRoll > local) {
|
|
940
|
+
blockParcels.push(parcel);
|
|
941
|
+
cursor += frontW;
|
|
942
|
+
prevClass = null;
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
const sideGap = Math.max(0.3, placement.gap / 2);
|
|
946
|
+
const buildable = buildablePolygon(poly, A, B, placement.setback, sideGap, 1.2, usedDepth);
|
|
947
|
+
parcel.buildable = buildable;
|
|
948
|
+
let placedLot = false;
|
|
949
|
+
if (buildable.length >= 3) {
|
|
950
|
+
const outDir = [-midS.normal[0], -midS.normal[1]];
|
|
951
|
+
const rotationY = Math.atan2(outDir[0], outDir[1]);
|
|
952
|
+
const maxW = Math.max(3, Math.min(placement.width, frontW - placement.gap));
|
|
953
|
+
const availDepth = usedDepth - placement.setback - 1.2;
|
|
954
|
+
const maxD = Math.max(3, Math.min(placement.depth, availDepth));
|
|
955
|
+
const cx0 = midS.p[0] + midS.normal[0] * (placement.setback + maxD / 2);
|
|
956
|
+
const cz0 = midS.p[1] + midS.normal[1] * (placement.setback + maxD / 2);
|
|
957
|
+
const fit = fitRectInPolygon(buildable, cx0, cz0, maxW, maxD, rotationY, 0.45);
|
|
958
|
+
if (fit !== null && fit.w >= 3 && fit.d >= 2.6) {
|
|
959
|
+
const slopeRoll = rng();
|
|
960
|
+
const hw = fit.w / 2;
|
|
961
|
+
const hd = fit.d / 2;
|
|
962
|
+
const ca = Math.cos(rotationY);
|
|
963
|
+
const sa = Math.sin(rotationY);
|
|
964
|
+
let ok = true;
|
|
965
|
+
if (slopeAt !== null && slopeAt(fit.cx, fit.cz) > rules.maxSlope * (0.85 + slopeRoll * 0.3))
|
|
966
|
+
ok = false;
|
|
967
|
+
if (ok && heightAt !== null && heightAt(fit.cx, fit.cz) < rules.minElevation)
|
|
968
|
+
ok = false;
|
|
969
|
+
// Belt and braces against pavement (bridge approaches, cul-de-sac bulbs): the footprint
|
|
970
|
+
// corners keep clearance to every street centerline.
|
|
971
|
+
if (ok && index.clearance(fit.cx, fit.cz, 1) < 0)
|
|
972
|
+
ok = false;
|
|
973
|
+
if (ok) {
|
|
974
|
+
for (const [dx, dz] of [
|
|
975
|
+
[hw, hd],
|
|
976
|
+
[hw, -hd],
|
|
977
|
+
[-hw, hd],
|
|
978
|
+
[-hw, -hd],
|
|
979
|
+
]) {
|
|
980
|
+
if (index.clearance(fit.cx + dx * ca + dz * sa, fit.cz - dx * sa + dz * ca, 0.4) < 0) {
|
|
981
|
+
ok = false;
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const candidate = { x: fit.cx, z: fit.cz, hw, hd, angle: rotationY };
|
|
987
|
+
if (ok && !result.placed.overlapsAny(candidate, 0.8)) {
|
|
988
|
+
result.placed.add(candidate);
|
|
989
|
+
const pieces = buildLotPieces(cls, fit.w, fit.d, placement.floors, rules.floorHeight, lotRng);
|
|
990
|
+
const anchor = nearestStreet === null ? [frontProbe[0], frontProbe[1]] : [nearestStreet.x, nearestStreet.z];
|
|
991
|
+
result.lots.push({
|
|
992
|
+
id: `lot:${bi}:${result.lots.length}`,
|
|
993
|
+
center: [fit.cx, fit.cz],
|
|
994
|
+
size: [fit.w, fit.d],
|
|
995
|
+
rotationY,
|
|
996
|
+
floors: placement.floors,
|
|
997
|
+
jitter: lotJitter,
|
|
998
|
+
cls,
|
|
999
|
+
zone: pBand,
|
|
1000
|
+
anchor,
|
|
1001
|
+
pieces,
|
|
1002
|
+
parcel: parcelId,
|
|
1003
|
+
});
|
|
1004
|
+
parcel.kind = "built";
|
|
1005
|
+
placedLot = true;
|
|
1006
|
+
if (rules.driveways && (cls === "house" || cls === "mansion" || cls === "farmhouse" || cls === "barn" || cls === "silo")) {
|
|
1007
|
+
const dirX = fit.cx - anchor[0];
|
|
1008
|
+
const dirZ = fit.cz - anchor[1];
|
|
1009
|
+
const len = Math.hypot(dirX, dirZ) || 1;
|
|
1010
|
+
const ux = dirX / len;
|
|
1011
|
+
const uz = dirZ / len;
|
|
1012
|
+
const streetWidth = nearestStreet === null ? rules.streetWidth : streets[nearestStreet.street].width;
|
|
1013
|
+
const start = [anchor[0] + ux * (streetWidth / 2 - 0.4), anchor[1] + uz * (streetWidth / 2 - 0.4)];
|
|
1014
|
+
const end = [fit.cx - ux * (hd - 1), fit.cz - uz * (hd - 1)];
|
|
1015
|
+
const surface = cls === "farmhouse" || cls === "barn" || cls === "silo" || rules.gravelLanes ? "gravel" : "pavement";
|
|
1016
|
+
result.driveways.push({ points: [start, end], width: cls === "mansion" ? 3.2 : 2.6, surface });
|
|
1017
|
+
}
|
|
1018
|
+
if (rules.hedges && cls === "mansion") {
|
|
1019
|
+
// Perimeter hedge with a gated gap centered on the driveway mouth (street face).
|
|
1020
|
+
const m = 2.4;
|
|
1021
|
+
const hhw = hw + m;
|
|
1022
|
+
const hhd = hd + m;
|
|
1023
|
+
const th = 0.7;
|
|
1024
|
+
const gap = 2.2;
|
|
1025
|
+
const runs = [
|
|
1026
|
+
{ x: -hhw / 2 - gap / 2, z: hhd, len: hhw - gap, rot: 0 },
|
|
1027
|
+
{ x: hhw / 2 + gap / 2, z: hhd, len: hhw - gap, rot: 0 },
|
|
1028
|
+
{ x: 0, z: -hhd, len: hhw * 2, rot: 0 },
|
|
1029
|
+
{ x: hhw, z: 0, len: hhd * 2, rot: Math.PI / 2 },
|
|
1030
|
+
{ x: -hhw, z: 0, len: hhd * 2, rot: Math.PI / 2 },
|
|
1031
|
+
];
|
|
1032
|
+
for (const run of runs) {
|
|
1033
|
+
const wx = fit.cx + run.x * ca + run.z * sa;
|
|
1034
|
+
const wz = fit.cz - run.x * sa + run.z * ca;
|
|
1035
|
+
result.hedges.push({ center: [wx, wz], size: [run.len, th], rotationY: rotationY + run.rot });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (!placedLot && parcel.kind === "vacant")
|
|
1042
|
+
parcel.kind = "yard";
|
|
1043
|
+
blockParcels.push(parcel);
|
|
1044
|
+
if (placedLot)
|
|
1045
|
+
maxDepthUsed = Math.max(maxDepthUsed, usedDepth);
|
|
1046
|
+
// A pushed parcel owns its whole frontage arc — the cursor never re-enters it.
|
|
1047
|
+
cursor += frontW;
|
|
1048
|
+
prevClass = placedLot ? cls : null;
|
|
1049
|
+
}
|
|
1050
|
+
result.parcels.push(...blockParcels);
|
|
1051
|
+
if (fieldBlock)
|
|
1052
|
+
continue; // the field polygon already owns the interior
|
|
1053
|
+
if (blockParcels.length === 0) {
|
|
1054
|
+
// Nothing subdivided (tiny ring): the block is intentional green, not leftover.
|
|
1055
|
+
result.blocks[blockId].kind = "park";
|
|
1056
|
+
result.parks.push(parkFromRing(`park:${bi}`, band === "edge" ? "meadow" : "green", land, parkJitter));
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
// Block interior left behind the deepest parcels: classify it (courtyard downtown, green
|
|
1060
|
+
// elsewhere) when it is big enough to read as a place rather than back yards.
|
|
1061
|
+
if (maxDepthUsed > 0) {
|
|
1062
|
+
const core = insetRingUniform(land, maxDepthUsed + 0.5);
|
|
1063
|
+
if (core.length >= 3 && polygonArea(core) > 240) {
|
|
1064
|
+
const type = band === "core" ? "courtyard" : band === "mid" ? (parkJitter < 0.5 ? "courtyard" : "green") : "meadow";
|
|
1065
|
+
result.parks.push(parkFromRing(`court:${bi}`, type, core, parkJitter));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return result;
|
|
1070
|
+
}
|
|
1071
|
+
/** Crop rows for field parks, clipped to the park's road-derived polygon and around placed lots. */
|
|
1072
|
+
function buildFieldRows(parks, placed, streams) {
|
|
1073
|
+
let total = 0;
|
|
1074
|
+
const rng = streams("fields");
|
|
1075
|
+
for (const park of parks) {
|
|
1076
|
+
if (park.type !== "field")
|
|
1077
|
+
continue;
|
|
1078
|
+
const rows = [];
|
|
1079
|
+
const alongX = park.size[0] >= park.size[1];
|
|
1080
|
+
const runLength = alongX ? park.size[0] : park.size[1];
|
|
1081
|
+
const across = alongX ? park.size[1] : park.size[0];
|
|
1082
|
+
const spacing = 3.2 + rng() * 0.8;
|
|
1083
|
+
const count = Math.floor((across - 3) / spacing);
|
|
1084
|
+
for (let r = 0; r < count && total < MAX_FIELD_ROWS; r += 1) {
|
|
1085
|
+
const offset = -across / 2 + 2 + r * spacing;
|
|
1086
|
+
let current = [];
|
|
1087
|
+
const steps = Math.max(2, Math.ceil(runLength / 4));
|
|
1088
|
+
for (let sIdx = 0; sIdx <= steps; sIdx += 1) {
|
|
1089
|
+
const along = -runLength / 2 + 2 + ((runLength - 4) * sIdx) / steps;
|
|
1090
|
+
const x = park.center[0] + (alongX ? along : offset);
|
|
1091
|
+
const z = park.center[1] + (alongX ? offset : along);
|
|
1092
|
+
// Rows follow the field's road-derived polygon: a curved block edge ends the row early.
|
|
1093
|
+
const outside = park.polygon !== undefined && !pointInPolygon(park.polygon, x, z);
|
|
1094
|
+
const blocked = outside || placed.overlapsAny({ x, z, hw: 1.2, hd: 1.2, angle: 0 }, 1.5);
|
|
1095
|
+
if (blocked) {
|
|
1096
|
+
if (current.length >= 2)
|
|
1097
|
+
rows.push(current);
|
|
1098
|
+
current = [];
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
current.push([x, z]);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (current.length >= 2)
|
|
1105
|
+
rows.push(current);
|
|
1106
|
+
total += 1;
|
|
1107
|
+
}
|
|
1108
|
+
if (rows.length > 0)
|
|
1109
|
+
park.rows = rows;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Synthesize the deterministic city plan for one `city` volume: streets → bridges → parks → zoned
|
|
1114
|
+
* frontage lots with massing pieces → furniture, all in the volume's local frame and then
|
|
1115
|
+
* rotated/translated into world space. Same volume (id, footprint, meta) over the same terrain
|
|
1116
|
+
* always resolves to the identical plan. When `context` provides a ground sampler, lots respect
|
|
1117
|
+
* the `maxSlope` cliff rule — hillside and canyon districts keep their steep faces open. When
|
|
1118
|
+
* `context.zoneOverrides` carries sibling `cityzone` volumes, lots inside them adopt the override
|
|
1119
|
+
* band/mix. Returns null without a usable footprint.
|
|
1120
|
+
*
|
|
1121
|
+
* @capability city-district resolve a `city` volume into deterministic streets, zoned lots, parks, and furniture
|
|
1122
|
+
*/
|
|
1123
|
+
export function resolveCityObject(object, context) {
|
|
1124
|
+
const center = object.center ?? object.position;
|
|
1125
|
+
if (center === undefined)
|
|
1126
|
+
return null;
|
|
1127
|
+
const he = object.halfExtents;
|
|
1128
|
+
const hx = axisValue(he?.x) ?? object.radius ?? 0;
|
|
1129
|
+
const hz = axisValue(he?.z) ?? object.radius ?? 0;
|
|
1130
|
+
const rules = readCityRules(object.meta);
|
|
1131
|
+
const rotationY = object.rotationY ?? 0;
|
|
1132
|
+
const empty = {
|
|
1133
|
+
center: [center.x, center.y, center.z],
|
|
1134
|
+
size: [hx * 2, hz * 2],
|
|
1135
|
+
rotationY,
|
|
1136
|
+
rules,
|
|
1137
|
+
streets: [],
|
|
1138
|
+
intersections: [],
|
|
1139
|
+
bridges: [],
|
|
1140
|
+
tunnels: [],
|
|
1141
|
+
blocks: [],
|
|
1142
|
+
parcels: [],
|
|
1143
|
+
lots: [],
|
|
1144
|
+
parks: [],
|
|
1145
|
+
trees: [],
|
|
1146
|
+
lights: [],
|
|
1147
|
+
hedges: [],
|
|
1148
|
+
driveways: [],
|
|
1149
|
+
parkingLots: [],
|
|
1150
|
+
};
|
|
1151
|
+
if (hx < rules.blockSize * 0.75 || hz < rules.blockSize * 0.75)
|
|
1152
|
+
return empty;
|
|
1153
|
+
const cos = Math.cos(rotationY);
|
|
1154
|
+
const sin = Math.sin(rotationY);
|
|
1155
|
+
const toWorld = (x, z) => [center.x + x * cos + z * sin, center.z - x * sin + z * cos];
|
|
1156
|
+
const sampleHeight = context?.sampleHeight;
|
|
1157
|
+
const heightAt = sampleHeight === undefined
|
|
1158
|
+
? null
|
|
1159
|
+
: (x, z) => {
|
|
1160
|
+
const [wx, wz] = toWorld(x, z);
|
|
1161
|
+
return sampleHeight(wx, wz);
|
|
1162
|
+
};
|
|
1163
|
+
const slopeAt = sampleHeight === undefined
|
|
1164
|
+
? null
|
|
1165
|
+
: (x, z) => {
|
|
1166
|
+
const [wx, wz] = toWorld(x, z);
|
|
1167
|
+
const d = 4;
|
|
1168
|
+
const dx = sampleHeight(wx + d, wz) - sampleHeight(wx - d, wz);
|
|
1169
|
+
const dz = sampleHeight(wx, wz + d) - sampleHeight(wx, wz - d);
|
|
1170
|
+
return Math.hypot(dx, dz) / (2 * d);
|
|
1171
|
+
};
|
|
1172
|
+
// Child `cityzone` volumes override the band (and optionally the class mix) for lots inside them.
|
|
1173
|
+
const overrides = (context?.zoneOverrides ?? []).filter((volume) => volume.kind === CITY_ZONE_KIND);
|
|
1174
|
+
const overrideAt = overrides.length === 0
|
|
1175
|
+
? null
|
|
1176
|
+
: (x, z) => {
|
|
1177
|
+
const [wx, wz] = toWorld(x, z);
|
|
1178
|
+
for (const volume of overrides) {
|
|
1179
|
+
const vc = volume.center ?? volume.position;
|
|
1180
|
+
if (vc === undefined)
|
|
1181
|
+
continue;
|
|
1182
|
+
const vhx = axisValue(volume.halfExtents?.x) ?? volume.radius ?? 0;
|
|
1183
|
+
const vhz = axisValue(volume.halfExtents?.z) ?? volume.radius ?? 0;
|
|
1184
|
+
if (vhx <= 0 || vhz <= 0)
|
|
1185
|
+
continue;
|
|
1186
|
+
const yaw = volume.rotationY ?? 0;
|
|
1187
|
+
const dx = wx - vc.x;
|
|
1188
|
+
const dz = wz - vc.z;
|
|
1189
|
+
const lx = dx * Math.cos(yaw) - dz * Math.sin(yaw);
|
|
1190
|
+
const lz = dx * Math.sin(yaw) + dz * Math.cos(yaw);
|
|
1191
|
+
if (Math.abs(lx) > vhx || Math.abs(lz) > vhz)
|
|
1192
|
+
continue;
|
|
1193
|
+
const params = parseParams(CITY_ZONE_SCHEMA, volume.meta);
|
|
1194
|
+
const mix = params["mix"];
|
|
1195
|
+
return { band: params["band"], mix: mix.length > 0 ? mix : null };
|
|
1196
|
+
}
|
|
1197
|
+
return null;
|
|
1198
|
+
};
|
|
1199
|
+
const streams = seededStreams(`city:${rules.seed.length > 0 ? rules.seed : object.id}`);
|
|
1200
|
+
// The unified path-network engine grows the whole road/track graph from the sliders — a city street
|
|
1201
|
+
// net and a closed race circuit are the SAME generator at opposite slider extremes.
|
|
1202
|
+
const netRules = {
|
|
1203
|
+
seed: rules.seed.length > 0 ? rules.seed : object.id,
|
|
1204
|
+
gridness: rules.gridness,
|
|
1205
|
+
loopiness: rules.loopiness,
|
|
1206
|
+
connectivity: rules.connectivity,
|
|
1207
|
+
branching: rules.branching,
|
|
1208
|
+
deadEnds: rules.deadEnds,
|
|
1209
|
+
segmentLength: rules.blockSize,
|
|
1210
|
+
aspect: rules.blockAspect,
|
|
1211
|
+
winding: rules.curviness,
|
|
1212
|
+
minCurveRadius: rules.minCurveRadius,
|
|
1213
|
+
minTurnAngle: rules.minTurnAngle,
|
|
1214
|
+
maxTurnAngle: rules.maxTurnAngle,
|
|
1215
|
+
width: rules.streetWidth,
|
|
1216
|
+
boulevards: rules.boulevards,
|
|
1217
|
+
};
|
|
1218
|
+
const network = generateStreets(netRules, hx, hz, {
|
|
1219
|
+
heightAt: heightAt ?? undefined,
|
|
1220
|
+
minElevation: rules.minElevation,
|
|
1221
|
+
bridges: rules.bridges,
|
|
1222
|
+
tunnels: rules.tunnels,
|
|
1223
|
+
});
|
|
1224
|
+
// Resolve each generated street's surface + sidewalk from its hierarchy level.
|
|
1225
|
+
const surfaceFor = (level) => rules.surface === "gravel" || (level === "lane" && rules.gravelLanes) ? "gravel" : "asphalt";
|
|
1226
|
+
const local = network.streets.slice(0, MAX_STREETS).map((street) => {
|
|
1227
|
+
const surface = surfaceFor(street.level);
|
|
1228
|
+
return {
|
|
1229
|
+
points: street.points.map(([x, z]) => [x, z]),
|
|
1230
|
+
width: street.width,
|
|
1231
|
+
level: street.level,
|
|
1232
|
+
surface,
|
|
1233
|
+
sidewalk: rules.sidewalks && surface === "asphalt",
|
|
1234
|
+
...(street.bulb === undefined ? {} : { bulb: [street.bulb[0], street.bulb[1]] }),
|
|
1235
|
+
...(street.features.length === 0 ? {} : { features: street.features }),
|
|
1236
|
+
};
|
|
1237
|
+
});
|
|
1238
|
+
const index = new StreetIndex();
|
|
1239
|
+
local.forEach((street, i) => index.addStreet(i, street.points, street.width));
|
|
1240
|
+
const intersections = network.junctions
|
|
1241
|
+
.slice(0, MAX_INTERSECTIONS)
|
|
1242
|
+
.map((j) => ({ x: j.x, z: j.z, radius: j.radius, level: j.level, arms: j.arms.map((arm) => ({ ...arm })) }));
|
|
1243
|
+
const localBridges = network.bridges.map((b) => ({
|
|
1244
|
+
points: b.points.map(([x, z]) => [x, z]),
|
|
1245
|
+
width: b.width,
|
|
1246
|
+
bankHeight: b.bankHeight,
|
|
1247
|
+
}));
|
|
1248
|
+
const localTunnels = network.tunnels.map((t) => ({
|
|
1249
|
+
points: t.points.map(([x, z]) => [x, z]),
|
|
1250
|
+
width: t.width,
|
|
1251
|
+
bankHeight: t.bankHeight,
|
|
1252
|
+
}));
|
|
1253
|
+
// Fabric (block/parcel/building subdivision) is optional: a bare road or race track leaves it off,
|
|
1254
|
+
// so a circuit never carries forced-on plots — parcels and buildings appear only when you want it.
|
|
1255
|
+
const emptyFabric = { blocks: [], parcels: [], parks: [], lots: [], placed: new LotIndex(), hedges: [], driveways: [] };
|
|
1256
|
+
const lotResult = rules.fabric
|
|
1257
|
+
? buildFabric(rules, streams, local, index, hx, hz, slopeAt, heightAt, overrideAt, intersections)
|
|
1258
|
+
: emptyFabric;
|
|
1259
|
+
const parks = lotResult.parks;
|
|
1260
|
+
if (rules.fabric)
|
|
1261
|
+
buildFieldRows(parks, lotResult.placed, streams);
|
|
1262
|
+
// --- Street furniture: bounded, seeded, and hooked to the network. ---
|
|
1263
|
+
const trees = [];
|
|
1264
|
+
const lights = [];
|
|
1265
|
+
const nearIntersection = (x, z, pad) => {
|
|
1266
|
+
for (const cross of intersections) {
|
|
1267
|
+
if (Math.hypot(cross.x - x, cross.z - z) < cross.radius + pad)
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
1271
|
+
};
|
|
1272
|
+
const insideBounds = (x, z) => Math.abs(x) < hx - 1.5 && Math.abs(z) < hz - 1.5;
|
|
1273
|
+
const asDescriptor = (street) => ({
|
|
1274
|
+
kind: "road",
|
|
1275
|
+
path: street.points,
|
|
1276
|
+
width: street.width,
|
|
1277
|
+
color: "",
|
|
1278
|
+
markings: false,
|
|
1279
|
+
markingColor: "",
|
|
1280
|
+
elevation: 0,
|
|
1281
|
+
sidewalk: street.sidewalk ? { width: 1.9, color: "" } : false,
|
|
1282
|
+
});
|
|
1283
|
+
if (rules.lightDensity > 0) {
|
|
1284
|
+
const lightRng = streams("lights");
|
|
1285
|
+
// Density drives pole spacing directly (0.1 → a pole every ~120 m; 1 → every 18 m), and at low
|
|
1286
|
+
// densities only the avenue-and-up hierarchy is lit — a farm road keeps its dark sky.
|
|
1287
|
+
const spacing = Math.min(120, Math.max(14, 18 / rules.lightDensity));
|
|
1288
|
+
for (const street of local) {
|
|
1289
|
+
if (street.level === "lane" || street.surface === "gravel")
|
|
1290
|
+
continue;
|
|
1291
|
+
if (rules.lightDensity < 0.3 && LEVEL_RANK[street.level] < LEVEL_RANK.avenue)
|
|
1292
|
+
continue;
|
|
1293
|
+
const spots = furnitureSpots(asDescriptor(street), { spacing, outset: street.sidewalk ? 0.6 : 0.2 });
|
|
1294
|
+
for (const spot of spots) {
|
|
1295
|
+
if (lights.length >= MAX_LIGHTS)
|
|
1296
|
+
break;
|
|
1297
|
+
const [x, z] = spot.position;
|
|
1298
|
+
if (!insideBounds(x, z) || nearIntersection(x, z, 1.5))
|
|
1299
|
+
continue;
|
|
1300
|
+
if (index.clearance(x, z, 1.0) < 0)
|
|
1301
|
+
continue;
|
|
1302
|
+
if (lightRng() > 0.95)
|
|
1303
|
+
continue; // occasional missing pole so rows never read stamped
|
|
1304
|
+
// Lamp arm faces back over the road: flip the outward-facing spot heading.
|
|
1305
|
+
lights.push({ x, z, heading: spot.heading + Math.PI });
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (rules.treeDensity > 0) {
|
|
1310
|
+
const treeRng = streams("trees");
|
|
1311
|
+
const spacing = 30 - 21 * rules.treeDensity;
|
|
1312
|
+
for (const street of local) {
|
|
1313
|
+
if (street.surface === "gravel" && rules.treeDensity < 0.6)
|
|
1314
|
+
continue;
|
|
1315
|
+
const spots = furnitureSpots(asDescriptor(street), { spacing, stagger: false, outset: street.sidewalk ? 2.6 : 2.0 });
|
|
1316
|
+
for (const spot of spots) {
|
|
1317
|
+
if (trees.length >= MAX_TREES)
|
|
1318
|
+
break;
|
|
1319
|
+
if (treeRng() > rules.treeDensity * 0.85 + 0.15)
|
|
1320
|
+
continue;
|
|
1321
|
+
const x = spot.position[0] + (treeRng() - 0.5) * 1.2;
|
|
1322
|
+
const z = spot.position[1] + (treeRng() - 0.5) * 1.2;
|
|
1323
|
+
// Keep canopies out of the crossing sight-triangle and off the carriageway edge.
|
|
1324
|
+
if (!insideBounds(x, z) || nearIntersection(x, z, 5))
|
|
1325
|
+
continue;
|
|
1326
|
+
if (index.clearance(x, z, 2.6) < 0)
|
|
1327
|
+
continue;
|
|
1328
|
+
if (lotResult.placed.overlapsAny({ x, z, hw: 0.5, hd: 0.5, angle: 0 }, 0.5))
|
|
1329
|
+
continue;
|
|
1330
|
+
trees.push({ x, z, species: pickSpecies(rules.treeMix, treeRng()), scale: 0.85 + treeRng() * 0.5, jitter: treeRng() });
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// Park planting: formal corners on plazas, loose clusters on greens/courtyards, sparse
|
|
1334
|
+
// meadows and buffers — all sampled inside the park's road-derived polygon.
|
|
1335
|
+
const parkRng = streams("parkTrees");
|
|
1336
|
+
for (const park of parks) {
|
|
1337
|
+
if (trees.length >= MAX_TREES)
|
|
1338
|
+
break;
|
|
1339
|
+
const parkArea = park.polygon !== undefined ? polygonArea(park.polygon) : park.size[0] * park.size[1];
|
|
1340
|
+
const count = park.type === "plaza"
|
|
1341
|
+
? 4
|
|
1342
|
+
: park.type === "green" || park.type === "courtyard"
|
|
1343
|
+
? Math.min(10, Math.max(3, Math.floor(parkArea / 260)))
|
|
1344
|
+
: park.type === "meadow"
|
|
1345
|
+
? 2 + Math.floor(parkRng() * 4)
|
|
1346
|
+
: park.type === "buffer"
|
|
1347
|
+
? Math.min(4, Math.floor(parkArea / 320))
|
|
1348
|
+
: 0;
|
|
1349
|
+
if (count <= 0)
|
|
1350
|
+
continue;
|
|
1351
|
+
let spots;
|
|
1352
|
+
if (park.type === "plaza") {
|
|
1353
|
+
spots = [
|
|
1354
|
+
[park.center[0] - park.size[0] * 0.36, park.center[1] - park.size[1] * 0.36],
|
|
1355
|
+
[park.center[0] + park.size[0] * 0.36, park.center[1] - park.size[1] * 0.36],
|
|
1356
|
+
[park.center[0] - park.size[0] * 0.36, park.center[1] + park.size[1] * 0.36],
|
|
1357
|
+
[park.center[0] + park.size[0] * 0.36, park.center[1] + park.size[1] * 0.36],
|
|
1358
|
+
].filter(([x, z]) => park.polygon === undefined || pointInPolygon(park.polygon, x, z));
|
|
1359
|
+
}
|
|
1360
|
+
else if (park.polygon !== undefined) {
|
|
1361
|
+
spots = pointsInPolygon(park.polygon, count, parkRng, 1.2);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
const random = [];
|
|
1365
|
+
for (let i = 0; i < count; i += 1) {
|
|
1366
|
+
random.push([park.center[0] + (parkRng() - 0.5) * park.size[0] * 0.8, park.center[1] + (parkRng() - 0.5) * park.size[1] * 0.8]);
|
|
1367
|
+
}
|
|
1368
|
+
spots = random;
|
|
1369
|
+
}
|
|
1370
|
+
for (const [x, z] of spots) {
|
|
1371
|
+
if (trees.length >= MAX_TREES)
|
|
1372
|
+
break;
|
|
1373
|
+
if (!insideBounds(x, z))
|
|
1374
|
+
continue;
|
|
1375
|
+
if (index.clearance(x, z, 0.4) < 0)
|
|
1376
|
+
continue;
|
|
1377
|
+
if (lotResult.placed.overlapsAny({ x, z, hw: 0.5, hd: 0.5, angle: 0 }, 0.5))
|
|
1378
|
+
continue;
|
|
1379
|
+
trees.push({ x, z, species: pickSpecies(rules.treeMix, parkRng()), scale: 0.9 + parkRng() * 0.6, jitter: parkRng() });
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// Parking pads behind commercial lots, collision-checked against every placed footprint.
|
|
1384
|
+
const parking = [];
|
|
1385
|
+
if (rules.parking) {
|
|
1386
|
+
const parkingRng = streams("parking");
|
|
1387
|
+
const padIndex = new LotIndex();
|
|
1388
|
+
for (const lot of lotResult.lots) {
|
|
1389
|
+
if (parking.length >= MAX_PARKING)
|
|
1390
|
+
break;
|
|
1391
|
+
if (lot.cls !== "shop" && lot.cls !== "slab")
|
|
1392
|
+
continue;
|
|
1393
|
+
if (parkingRng() > 0.6)
|
|
1394
|
+
continue;
|
|
1395
|
+
const depth = 9 + parkingRng() * 5;
|
|
1396
|
+
const ca = Math.cos(lot.rotationY);
|
|
1397
|
+
const sa = Math.sin(lot.rotationY);
|
|
1398
|
+
// Behind the lot: away from the street face (-z in lot-local space).
|
|
1399
|
+
const off = lot.size[1] / 2 + depth / 2 + 1.2;
|
|
1400
|
+
const x = lot.center[0] - sa * off;
|
|
1401
|
+
const z = lot.center[1] - ca * off;
|
|
1402
|
+
const pad = { x, z, hw: (lot.size[0] * 1.05) / 2, hd: depth / 2, angle: lot.rotationY };
|
|
1403
|
+
if (Math.abs(x) > hx - 3 || Math.abs(z) > hz - 3)
|
|
1404
|
+
continue;
|
|
1405
|
+
if (index.clearance(x, z, 0.5) < 0)
|
|
1406
|
+
continue;
|
|
1407
|
+
if (lotResult.placed.overlapsAny(pad, 0.5) || padIndex.overlapsAny(pad, 0.5))
|
|
1408
|
+
continue;
|
|
1409
|
+
padIndex.add(pad);
|
|
1410
|
+
parking.push({ center: [x, z], size: [lot.size[0] * 1.05, depth], rotationY: lot.rotationY });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
center: [center.x, center.y, center.z],
|
|
1415
|
+
size: [hx * 2, hz * 2],
|
|
1416
|
+
rotationY,
|
|
1417
|
+
rules,
|
|
1418
|
+
streets: local.map((street, i) => ({
|
|
1419
|
+
id: `street:${i}`,
|
|
1420
|
+
points: street.points.map(([x, z]) => toWorld(x, z)),
|
|
1421
|
+
width: street.width,
|
|
1422
|
+
level: street.level,
|
|
1423
|
+
surface: street.surface,
|
|
1424
|
+
sidewalk: street.sidewalk,
|
|
1425
|
+
...(street.bulb === undefined ? {} : { bulb: toWorld(street.bulb[0], street.bulb[1]) }),
|
|
1426
|
+
...(street.features === undefined ? {} : { features: street.features.map((f) => ({ ...f })) }),
|
|
1427
|
+
})),
|
|
1428
|
+
intersections: intersections.map((cross, i) => {
|
|
1429
|
+
const [x, z] = toWorld(cross.x, cross.z);
|
|
1430
|
+
return {
|
|
1431
|
+
id: `cross:${i}`,
|
|
1432
|
+
x,
|
|
1433
|
+
z,
|
|
1434
|
+
radius: cross.radius,
|
|
1435
|
+
level: cross.level,
|
|
1436
|
+
arms: cross.arms.map((arm) => ({ angle: arm.angle - rotationY, width: arm.width })),
|
|
1437
|
+
};
|
|
1438
|
+
}),
|
|
1439
|
+
bridges: localBridges.map((bridge, i) => ({
|
|
1440
|
+
id: `bridge:${i}`,
|
|
1441
|
+
points: bridge.points.map(([x, z]) => toWorld(x, z)),
|
|
1442
|
+
width: bridge.width,
|
|
1443
|
+
style: rules.bridgeStyle,
|
|
1444
|
+
bankHeight: bridge.bankHeight,
|
|
1445
|
+
})),
|
|
1446
|
+
tunnels: localTunnels.map((tunnel, i) => ({
|
|
1447
|
+
id: `tunnel:${i}`,
|
|
1448
|
+
points: tunnel.points.map(([x, z]) => toWorld(x, z)),
|
|
1449
|
+
width: tunnel.width,
|
|
1450
|
+
bankHeight: tunnel.bankHeight,
|
|
1451
|
+
})),
|
|
1452
|
+
blocks: lotResult.blocks.map((block) => ({
|
|
1453
|
+
id: block.id,
|
|
1454
|
+
kind: block.kind,
|
|
1455
|
+
polygon: block.polygon.map(([x, z]) => toWorld(x, z)),
|
|
1456
|
+
curb: block.curb.map(([x, z]) => toWorld(x, z)),
|
|
1457
|
+
area: block.area,
|
|
1458
|
+
})),
|
|
1459
|
+
parcels: lotResult.parcels.map((parcel) => ({
|
|
1460
|
+
id: parcel.id,
|
|
1461
|
+
block: lotResult.blocks[parcel.block]?.id ?? `block:${parcel.block}`,
|
|
1462
|
+
polygon: parcel.polygon.map(([x, z]) => toWorld(x, z)),
|
|
1463
|
+
buildable: parcel.buildable.map(([x, z]) => toWorld(x, z)),
|
|
1464
|
+
frontage: parcel.frontage.map((front) => ({
|
|
1465
|
+
road: `street:${front.street}`,
|
|
1466
|
+
edgeStart: toWorld(front.a[0], front.a[1]),
|
|
1467
|
+
edgeEnd: toWorld(front.b[0], front.b[1]),
|
|
1468
|
+
tangent: [
|
|
1469
|
+
front.tangent[0] * Math.cos(rotationY) + front.tangent[1] * Math.sin(rotationY),
|
|
1470
|
+
-front.tangent[0] * Math.sin(rotationY) + front.tangent[1] * Math.cos(rotationY),
|
|
1471
|
+
],
|
|
1472
|
+
})),
|
|
1473
|
+
area: parcel.area,
|
|
1474
|
+
depth: parcel.depth,
|
|
1475
|
+
isCorner: parcel.isCorner,
|
|
1476
|
+
kind: parcel.kind,
|
|
1477
|
+
})),
|
|
1478
|
+
lots: lotResult.lots.map((lot) => ({
|
|
1479
|
+
id: lot.id,
|
|
1480
|
+
center: toWorld(lot.center[0], lot.center[1]),
|
|
1481
|
+
size: lot.size,
|
|
1482
|
+
rotationY: lot.rotationY - rotationY,
|
|
1483
|
+
floors: lot.floors,
|
|
1484
|
+
jitter: lot.jitter,
|
|
1485
|
+
class: lot.cls,
|
|
1486
|
+
zone: lot.zone,
|
|
1487
|
+
anchor: toWorld(lot.anchor[0], lot.anchor[1]),
|
|
1488
|
+
pieces: lot.pieces,
|
|
1489
|
+
parcel: lot.parcel,
|
|
1490
|
+
})),
|
|
1491
|
+
parks: parks.map((park) => ({
|
|
1492
|
+
id: park.id,
|
|
1493
|
+
center: toWorld(park.center[0], park.center[1]),
|
|
1494
|
+
size: park.size,
|
|
1495
|
+
rotationY: -rotationY,
|
|
1496
|
+
type: park.type,
|
|
1497
|
+
jitter: park.jitter,
|
|
1498
|
+
...(park.polygon === undefined ? {} : { polygon: park.polygon.map(([x, z]) => toWorld(x, z)) }),
|
|
1499
|
+
...(park.rows === undefined ? {} : { rows: park.rows.map((row) => row.map(([x, z]) => toWorld(x, z))) }),
|
|
1500
|
+
})),
|
|
1501
|
+
trees: trees.map((tree) => {
|
|
1502
|
+
const [x, z] = toWorld(tree.x, tree.z);
|
|
1503
|
+
return { x, z, species: tree.species, scale: tree.scale, jitter: tree.jitter };
|
|
1504
|
+
}),
|
|
1505
|
+
lights: lights.map((light) => {
|
|
1506
|
+
const [x, z] = toWorld(light.x, light.z);
|
|
1507
|
+
return { x, z, heading: light.heading - rotationY };
|
|
1508
|
+
}),
|
|
1509
|
+
hedges: lotResult.hedges.map((hedge) => {
|
|
1510
|
+
const [x, z] = toWorld(hedge.center[0], hedge.center[1]);
|
|
1511
|
+
return { center: [x, z], size: hedge.size, rotationY: hedge.rotationY - rotationY };
|
|
1512
|
+
}),
|
|
1513
|
+
driveways: lotResult.driveways.map((drive) => ({
|
|
1514
|
+
points: drive.points.map(([x, z]) => toWorld(x, z)),
|
|
1515
|
+
width: drive.width,
|
|
1516
|
+
surface: drive.surface,
|
|
1517
|
+
})),
|
|
1518
|
+
parkingLots: parking.map((pad) => {
|
|
1519
|
+
const [x, z] = toWorld(pad.center[0], pad.center[1]);
|
|
1520
|
+
return { center: [x, z], size: pad.size, rotationY: pad.rotationY - rotationY };
|
|
1521
|
+
}),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
/** Registers the `city` + `cityzone` scene kinds (schema + resolver). Called by {@link registerBuiltinSceneKinds}. @internal */
|
|
1525
|
+
export function registerCityKind() {
|
|
1526
|
+
registerSceneKind({
|
|
1527
|
+
kind: CITY_KIND,
|
|
1528
|
+
target: "volume",
|
|
1529
|
+
label: "City / track",
|
|
1530
|
+
addCategory: "Studios",
|
|
1531
|
+
accent: "#8fa8c9",
|
|
1532
|
+
schema: CITY_SCHEMA,
|
|
1533
|
+
resolve: (object, _params, context) => resolveCityObject(object, context),
|
|
1534
|
+
note: (object) => {
|
|
1535
|
+
const resolved = resolveCityObject(object);
|
|
1536
|
+
if (resolved === null)
|
|
1537
|
+
return "Give the volume a box footprint.";
|
|
1538
|
+
if (resolved.streets.length === 0)
|
|
1539
|
+
return `Footprint too small — needs at least ${Math.ceil(resolved.rules.blockSize * 1.5)} m across.`;
|
|
1540
|
+
const mode = streetNetworkMode({
|
|
1541
|
+
seed: resolved.rules.seed,
|
|
1542
|
+
gridness: resolved.rules.gridness,
|
|
1543
|
+
loopiness: resolved.rules.loopiness,
|
|
1544
|
+
connectivity: resolved.rules.connectivity,
|
|
1545
|
+
branching: resolved.rules.branching,
|
|
1546
|
+
deadEnds: resolved.rules.deadEnds,
|
|
1547
|
+
segmentLength: resolved.rules.blockSize,
|
|
1548
|
+
aspect: resolved.rules.blockAspect,
|
|
1549
|
+
winding: resolved.rules.curviness,
|
|
1550
|
+
minCurveRadius: resolved.rules.minCurveRadius,
|
|
1551
|
+
minTurnAngle: resolved.rules.minTurnAngle,
|
|
1552
|
+
maxTurnAngle: resolved.rules.maxTurnAngle,
|
|
1553
|
+
width: resolved.rules.streetWidth,
|
|
1554
|
+
boulevards: resolved.rules.boulevards,
|
|
1555
|
+
});
|
|
1556
|
+
const feats = [
|
|
1557
|
+
resolved.bridges.length > 0 ? `${resolved.bridges.length} bridges` : "",
|
|
1558
|
+
resolved.tunnels.length > 0 ? `${resolved.tunnels.length} tunnels` : "",
|
|
1559
|
+
].filter((s) => s.length > 0);
|
|
1560
|
+
const head = mode === "circuit" ? `circuit · ${resolved.streets.length} streets` : `${resolved.streets.length} streets`;
|
|
1561
|
+
const fabric = resolved.rules.fabric ? ` · ${resolved.lots.length} buildings · ${resolved.parks.length} parks` : " · no fabric";
|
|
1562
|
+
return `${head}${fabric}${feats.length > 0 ? ` · ${feats.join(" · ")}` : ""}`;
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
registerSceneKind({
|
|
1566
|
+
kind: CITY_ZONE_KIND,
|
|
1567
|
+
target: "volume",
|
|
1568
|
+
label: "City zone override",
|
|
1569
|
+
addCategory: "Studios",
|
|
1570
|
+
accent: "#c9b88f",
|
|
1571
|
+
schema: CITY_ZONE_SCHEMA,
|
|
1572
|
+
note: (object) => {
|
|
1573
|
+
const params = parseParams(CITY_ZONE_SCHEMA, object.meta);
|
|
1574
|
+
const mix = params["mix"];
|
|
1575
|
+
return `Pins the "${params["band"]}" band${mix.length > 0 ? " with a custom mix" : ""} inside any city district it overlaps.`;
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
}
|