Merge remote-tracking branch 'origin/layer5-tile-fill'
This commit is contained in:
@@ -318,6 +318,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
|
||||
- [D-250: Wall cutaway is client-side camera presentation, decoupled from fog-of-perception](decisions/perception.md#d-250-wall-cutaway-is-client-side-camera-presentation-decoupled-from-fog-of-perception) — _perception_
|
||||
- [D-251: Character asset route, 2026 reconfirmation — Quaternius rig, in-house wardrobe, purchased animation tiers](decisions/content.md#d-251-character-asset-route-2026-reconfirmation--quaternius-rig-in-house-wardrobe-purchased-animation-tiers) — _content_
|
||||
- [D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent](decisions/architecture.md#d-252-facing-is-view-only--movement-no-longer-writes-facing-npc-gaze-is-intent) — _architecture_
|
||||
- [D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)](decisions/architecture.md#d-253-region-transient-state-model--seasonaltidalweathersnow-phase-functions-resolves-q-105) — _architecture_
|
||||
|
||||
## Open questions
|
||||
|
||||
@@ -385,9 +386,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
|
||||
- [Q-093: Tile-based exploration map in player insert (Google Maps for the implant)](questions/architecture.md#q-093-tile-based-exploration-map-in-player-insert-google-maps-for-the-implant) — _architecture_
|
||||
- [Q-097: Strip "What They Don't Talk About" from corporation pages](questions/content.md#q-097-strip-what-they-dont-talk-about-from-corporation-pages) — _content_
|
||||
- [Q-099: Mod content catalog — body rows / terrain_reference overlay for systems.db](questions/architecture.md#q-099-mod-content-catalog--body-rows--terrain-reference-overlay-for-systemsdb) — _architecture_
|
||||
- [Q-102: Cohesion-matrix algorithm — seam-free continuous variation](questions/architecture.md#q-102-cohesion-matrix-algorithm--seam-free-continuous-variation) — _architecture_
|
||||
- [Q-103: Tile-mutator op schema](questions/architecture.md#q-103-tile-mutator-op-schema) — _architecture_
|
||||
- [Q-105: Region seasonal/clock state — the shared cheap-dynamism source](questions/architecture.md#q-105-region-seasonalclock-state--the-shared-cheap-dynamism-source) — _architecture_
|
||||
- [Q-107: Wiki → Atlas content-set consolidation](questions/architecture.md#q-107-wiki--atlas-content-set-consolidation) — _architecture_
|
||||
- [Q-108: Subterranean / domed / sealed-habitat settlement morphology — does the built-world fill model need a surface-vs-enclosed branch](questions/architecture.md#q-108-subterranean--domed--sealed-habitat-settlement-morphology--does-the-built-world-fill-model-need-a-surface-vs-enclosed-branch) — _architecture_
|
||||
- [Q-109: Cascade generation-source dispatch — planetary / station / mod-DLC-forked / save-only](questions/architecture.md#q-109-cascade-generation-source-dispatch--planetary--station--mod-dlc-forked--save-only) — _architecture_
|
||||
@@ -443,7 +442,9 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
|
||||
- [Q-098: Persistence of generated river/city mapping outputs](questions/architecture.md#q-098-persistence-of-generated-rivercity-mapping-outputs) — _architecture_
|
||||
- [Q-100: Biome authority — Python sim vs Rust cascade](questions/architecture.md#q-100-biome-authority--python-sim-vs-rust-cascade) — _architecture_
|
||||
- [Q-101: Refinement / derivation contract — coarse hint → fine geometry + morphology algorithm family](questions/architecture.md#q-101-refinement--derivation-contract--coarse-hint--fine-geometry--morphology-algorithm-family) — _architecture_
|
||||
- [Q-102: Cohesion-matrix algorithm — seam-free continuous variation](questions/architecture.md#q-102-cohesion-matrix-algorithm--seam-free-continuous-variation) — _architecture_
|
||||
- [Q-104: Floor-index ↔ absolute voxel-z coordinate mapping](questions/architecture.md#q-104-floor-index--absolute-voxel-z-coordinate-mapping) — _architecture_
|
||||
- [Q-105: Region seasonal/clock state — the shared cheap-dynamism source](questions/architecture.md#q-105-region-seasonalclock-state--the-shared-cheap-dynamism-source) — _architecture_
|
||||
- [Q-106: Era-band stacking depth for layered architecture-flavor](questions/architecture.md#q-106-era-band-stacking-depth-for-layered-architecture-flavor) — _architecture_
|
||||
- [Q-110: Region/chunk physical-scale anchoring — what is a region in metres, and what anchors a heightmap pixel?](questions/architecture.md#q-110-regionchunk-physical-scale-anchoring--what-is-a-region-in-metres-and-what-anchors-a-heightmap-pixel) — _architecture_
|
||||
|
||||
|
||||
@@ -1966,4 +1966,34 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*106 decisions (D-001 through D-252, excluding gaps). Last updated: 2026-07-06 (D-252 — Facing is view-only: movement no longer writes Facing, NPC gaze becomes AI intent, bump-to-turn retired; resolves the Q-084 walk-vs-aim split with T-1088 play evidence).*
|
||||
### D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)
|
||||
|
||||
- **Date:** 2026-07-08
|
||||
- **Resolves:** [Q-105](../questions/architecture.md#q-105).
|
||||
- **Decision:** The region ([D-243](#d-243)) is the single scale at which **transient (clock-bound) surface state** is resolved — "computed once per region per phase, inherited by every district and tile inside" ([D-243](#d-243) §3, the cheap-dynamism source). This record pins that model: **what** transient state a region carries, **how** its clock advances and recomputes, **how** tiles inherit it, and its **determinism** — but explicitly **not** the gameplay/rendering that later consumes it.
|
||||
|
||||
**(a) The transient bundle — four phase terms + their derived surface scalars.** A region carries a memoized `RegionPhase` (proposed carrier name) built from **four clock terms, one per natural clock rate** (fastest → slowest), each a pure function of the in-game clock ([D-031](#d-031)) and the region's static params:
|
||||
- **Diurnal** — day/night, from the [D-031](#d-031) day-phase clock; amplitude = the atmosphere heat-retention already defined in [D-239](#d-239) §2 (thick air → small swing). *This term is the one [D-239](#d-239) §2 already owns for temperature; D-253 folds it into the same region phase model as the other three, rather than leaving it a separate mechanism.*
|
||||
- **Tidal** — from the lunar/day clock; amplitude from the body's satellite config; **absent (zero) when the body has no moon**. Drives the tidal component of water-height.
|
||||
- **Weather** — a coarse, seeded, deterministic precipitation/condition term (a bounded pseudo-sequence keyed on `(region, weather-bucket)` — **not** a simulated advecting weather system; deliberately minimal to stay "nearly free"). Drives puddles + general conditions.
|
||||
- **Seasonal** — from the year clock, **phased continuously by latitude** (amplitude → 0 at the equator and sign inverting across it — a continuous function of latitude, not a binary hemisphere flag, so the equatorial seam never steps). The slow temperature term + the seasonal component of water-height + the snow/crop drivers.
|
||||
- From these terms the region derives its **transient surface scalars** (region-level, not per-tile): **temperature(time)** = the T-1078 / [D-240](#d-240) static baseline **+** seasonal offset **+** diurnal offset (this makes [D-239](#d-239) §2's "temperature(time)" concrete — the static baseline T-1078 ships is the mean-phase value); **water-height(time)** = mean water level **+** seasonal term **+** tidal term; **snow/ice depth** = `f(temperature(time), moisture)` feeding the **existing T-1030 scattered band** ([D-239](#d-239) §3) — D-253 supplies only the *depth over time*, T-1030 owns *where* the band scatters; **weather intensity/category**; and the **crop-cycle phase** (sown → growing → ripe → harvested → fallow) = `f(seasonal)`, the cadence clock for [D-228](#d-228)'s managed-`Vegetation` farmland override (D-253 owns the *cadence*; the settlement/economic layer owns crop *type* and farmland *placement*).
|
||||
|
||||
**(b) Phase-stepping — recompute on bucket rollover, never per tick, never integrated.** Each term declares a **clock-bucket size** (diurnal ≈ day-phase; tidal ≈ day-phase; weather ≈ a multi-phase block; seasonal ≈ a season step). `RegionPhase` is **memoized with the clock-buckets it was computed for**; a query recomputes a term **only when its bucket rolls over** — the "nearly free" requirement, and [D-226](#d-226)'s dynamic-state path. Crucially, every scalar is evaluated from the **absolute clock value** (`state = f(clock)`), **never integrated step-by-step** (`state += Δ`): absolute evaluation is drift-free, reproducible, and hands [D-226](#d-226) its **frozen-phase inspection** for free (freeze the clock → the whole world's transient state is stable and re-derivable). Default bucket sizes are source-canonical + tunable (the `climate_constants.toml` precedent), provisional pending calibration.
|
||||
|
||||
**(c) Inheritance — region computes the scalar, the tile realizes it locally, edge-fuzzed.** Districts and tiles **never compute their own phase** — they read the region scalars, blended across region neighbours by the **same [D-243](#d-243) §4 edge-fuzz** the static climate baseline uses (warp-perturbed bilinear across region centres), so a weather front / seasonal gradient / thaw line never steps on the ~205 km grid (and the equatorial seasonal seam is continuous by construction, per (a)). The **per-tile realization is a local comparison**, not an inherited value: a tile is **flooded** iff `region-water-height(time) > tile.elevation`; **snowed** to a depth capped by the region snow-depth, scattered by the T-1030 band and gated on its own material/moisture; **puddled** iff the weather term is wet and the tile sits in a micro-relief low (the T-1081 `voxel_relief` hollows) that diurnal evaporation has not burned off; its farmland shows the crop stage the region phase dictates. So floodplain / tidal-flat / seasonal-river **emerge** ([D-228](#d-228)) — the static world stays static; only the region scalars carry the clock. This directly discharges T-1082's deferred TidalFlat wet/dry alternation (`Shallow`/`Dry` = tidal water-height vs local elevation).
|
||||
|
||||
**(d) Determinism ([D-010](#d-010)).** Every term and scalar is **integer fixed-point** (phase positions as basis-points of their cycle; depths/heights in integer mm/cm; temperature offsets on the existing integer scale) and a **seeded pure function** of `(absolute clock, region params, body seed)`. **No per-tick float accumulation exists anywhere in the model** — see (b). Weather's pseudo-sequence is a seeded hash of `(region, weather-bucket)`, so it replays identically. Frozen clock → byte-identical state on every platform and every reload; this is what lets the transient overlay live in the [D-227](#d-227) derive-don't-store tier (the memo is **not persisted** — a save records only the clock and the state re-derives).
|
||||
|
||||
**(e) Scope boundary — this is the STATE MODEL, not its consumers.** D-253 defines *derivable transient state and the functions that produce it* — Phase-4-appropriate, viewable as an Atlas layer like every other cascade output, and completing the forward-contracts left open by T-1030 / T-1078 / T-1082. It does **NOT** build: weather/flood/snow **gameplay** (movement, perception, passability effects — [D-239](#d-239) §9 names the passability hook but its wiring is later), the **farming/crop simulation**, snow/water **rendering** (Phase 5), or any **verb** that reads this state. Those are later phases and inherit a fixed contract here. Adding a fifth term or a consumer is a future amendment, not a reinterpretation of this record.
|
||||
|
||||
- **Rationale:** The model is cheap *because* the four transient terms line up with the four natural clock rates and reuse the existing temperature primitive — temperature(time) and water-height(time) are just a static baseline plus the same seasonal/diurnal/tidal offsets, so **one clock model drives temperature, flooding, tides, snow, puddles, weather, and crops** with no per-system simulation. The single load-bearing choice — **absolute-clock evaluation rather than step integration** — satisfies three requirements at once: determinism ([D-010](#d-010), no drift), cheapness (recompute only on bucket rollover, and per-region not per-tile → O(regions)), and inspectability ([D-226](#d-226) frozen phase). Computing at the region and realizing per-tile by comparison is what keeps the dynamism O(regions) while still producing per-tile flood/snow/puddle/crop extent — the "computed once, inherited" contract [D-243](#d-243) §3 promised.
|
||||
- **Raised by:** Jeroen (Q-105 model constraints — clock + hemisphere bound, moon-gated tides, computed-once-per-region, "nearly free", 2026-05-25) + Tyre (T-1057 triage — the four-term phase-function model, the absolute-clock / memoized-by-bucket / edge-fuzzed-inheritance pins, 2026-07-08). **Authored as a proposed design for PR review** — the flagged judgment calls (below) are open for the lead/user to adjust.
|
||||
- **Design choices flagged for review:** (1) **diurnal promoted to a first-class term** — Q-105 enumerated three terms (seasonal/tidal/weather); D-253 adds diurnal as the fourth, since [D-239](#d-239) §2 already owns it for temperature and T-1030's dawn-frost already depends on it (a coherence unification, but it extends Q-105's framing). (2) **weather kept deliberately minimal** (a seeded coarse precipitation/condition term, no advecting fronts) — the depth is a taste call. (3) **recompute-bucket granularities** (diurnal/tidal ≈ day-phase, weather ≈ multi-phase, seasonal ≈ season-step) are provisional/tunable, not hard-pinned. (4) **memoized-not-persisted** reconciles D-228's "region property computed once per phase" with [D-227](#d-227) derive-don't-store (the region *caches* the bundle keyed by bucket; it is never durable state). (5) **"phased by hemisphere" read as continuous-in-latitude** (amplitude→0 at equator) rather than a north/south sign flag, so the seam edge-fuzzes cleanly.
|
||||
- **Implementation:** Phase 4+ (epic [T-750](../../.pql)), the forward contract from T-1030 (transient freeze/snow depth), T-1078 (region climate stack — shipped the static baseline + region-clock *structure*; D-253 adds the transient terms), and T-1082 (tidal-flat wet/dry). Proposed home: a `RegionPhase` carrier alongside `RegionProfile` in `BodyWorldState` ([D-203](#d-203) / [D-239](#d-239) §10 region storage), memoized by clock-bucket; proposed derivations `derive_region_phase(clock, region)` + per-tile realization helpers (flood / snow / puddle / crop) — names non-binding, the impl ticket fixes them. Constants in a source-canonical TOML (`climate_constants.toml` precedent). A new implementation ticket lands under T-750.
|
||||
- **Cross-reference:** [D-243](#d-243) (region = climate/weather/season lockdown scale; §3 computed-once-inherited; §4 edge-fuzz — the inheritance contract), [D-228](#d-228) (the seasonal water-height / snow-cover overlay / crop cycle this model drives — its open water-height sub-question resolved here), [D-239](#d-239) §2 (temperature(time) primitive — made concrete) / §3 (T-1030 scattered freeze-snow — given its clock) / §9 (seasonal passability — a later consumer), [D-240](#d-240) (`planet_class` temperature envelope — the baseline the seasonal/diurnal terms offset), [D-226](#d-226) (dynamic-state frozen-phase inspection), [D-031](#d-031) (game clock / day phases — the clock source), [D-010](#d-010) (determinism), [D-203](#d-203) (BodyWorldState region cache — the memo home), [D-227](#d-227) (derive-don't-store — the memo is not persisted). Tickets: [T-1057](../../.pql) (this triage), T-1030 / T-1078 / T-1082 (the discharged forward-contracts), T-1081 (`voxel_relief` micro-lows — puddle/flood sites).
|
||||
- **Dissent:** None
|
||||
|
||||
---
|
||||
|
||||
*107 decisions (D-001 through D-253, excluding gaps). Last updated: 2026-07-08 (D-253 — region transient state model: four clock-terms (diurnal/tidal/weather/seasonal), memoized-by-bucket absolute-clock evaluation, edge-fuzzed per-tile realization; resolves Q-105).*
|
||||
|
||||
@@ -370,15 +370,17 @@ Technical foundation questions: engine, protocols, data structures, performance,
|
||||
---
|
||||
|
||||
### Q-102: Cohesion-matrix algorithm — seam-free continuous variation
|
||||
- **Status:** Open — deferred from D-228 (2026-05-25)
|
||||
- **Status:** Resolved 2026-07-08 (T-1057) — resolved-in-practice by [D-246](../decisions/architecture.md#d-246) + [D-228](../decisions/architecture.md#d-228) (with T-1080 / T-1077); no dedicated cohesion-matrix D-record is needed.
|
||||
- **Resolution:** All five sub-questions are answered by shipped work, so the cohesion matrix needs no separate decision. **(1) noise basis + (2) position-keying + (3) threshold mapping + (4) determinism** are exactly [D-246](../decisions/architecture.md#d-246)'s `voxel_mosaic()`: an enveloped-fBm **value-noise** band (the T-1081 `detail_scatter` machinery, `[64, 32, 16, 8]` m octaves) keyed on **absolute world position** under a body-global `SeedDomain::VoxelMosaic` (never the chunk index — so it is continuous across every chunk/region boundary), whose field value indexes the class palette's **cumulative weights** to select a micro-habitat (the threshold map), all integer-only per [D-010](../decisions/architecture.md#d-010). The precondition that there is *something* for the field to vary is the [D-243](../decisions/architecture.md#d-243) ladder's per-district climate/moisture gradient — T-1080 (meso-scale moisture-gradient fix: a flat body constant became a latitude/elevation/continentality gradient) over T-1077 (the D-243 ladder wiring) — so the noise modulates a real gradient, not a uniform slab. **(5) authored linear features composite without bleeding** is [D-228](../decisions/architecture.md#d-228)'s categorical `FloorMaterial`/`Vegetation` **override stack** (wild/natural → economic/managed → user): the topmost *present* layer wins and layers are **never blended**, so an authored road/plaza/field-edge (economic layer) sits on the continuous wild scatter with a hard categorical edge — D-228's "a straight line must always have a placed cause." Seam-free variation (blended noise) and crisp authored lines (categorical, unblended override) are therefore **two distinct mechanisms**, not one algorithm — which is precisely why a standalone cohesion-matrix decision is unnecessary.
|
||||
- **Question:** D-228 mandates that intra-region material / sub-biome scatter come from a **global, position-keyed continuous noise field** so chunk/region boundaries never read as grid seams, while authored straight lines (roads, plazas) stay crisp. Concretely: the noise basis (value / Perlin / simplex / worley); keying on world position (not chunk index) to stay continuous across boundaries; how material / sub-biome thresholds map onto it; integer-only / determinism (D-010); and how authored linear features composite over the continuous field without bleeding.
|
||||
- **Context:** The anti-squaring principle pushed down to the material layer.
|
||||
- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010)
|
||||
- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-246](../decisions/architecture.md#d-246), [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010), [D-243](../decisions/architecture.md#d-243); tickets T-1080, T-1077, T-1081, T-1084
|
||||
|
||||
---
|
||||
|
||||
### Q-103: Tile-mutator op schema
|
||||
- **Status:** Open — deferred from D-227 (2026-05-25)
|
||||
- **Status:** Open — deferred from D-227 (2026-05-25); re-triaged 2026-07-08 (T-1057) → Phase-5 save-system kickoff.
|
||||
- **Triage (2026-07-08, T-1057):** DEFERRED, scope unchanged. The question's own text scopes it to Phase 5+ — it is the save-format op vocabulary, and mutators are [D-227](../decisions/architecture.md#d-227)'s sole persisted state, so it cannot be settled without a concrete save serializer to answer against. Confirmed an **empirical non-blocker** for the Phase-4 tile-fill spine: T-987 (child of T-959) shipped the Layer-5 shell derivation (`server/src/atlas/shell.rs` — `Void`/`Wall`/`FloorSlab`/`Roof`) with **no mutator layer at all** — the derive phase is a pure function of `(seed, tags, z)`, and `ChunkMutations`/`TileOverride` already exist as the frozen-base overlay a mutator log will later stack on. Revisit at Phase-5 save-system kickoff, where the typed-op-vs-full-override fork, replay ordering / conflict semantics, and cross-re-derivation validity are decided together.
|
||||
- **Question:** D-227 makes tile mutators the sole persisted state (save = `seed + mutator log`). What does one mutator record — a full per-voxel override, or typed ops (`Dig`, `Build(FloorMaterial)`, `Place(object)`, `Destroy(object)`, `SetMaterial`)? Typed ops are compact, semantic, replayable, but need a fixed op vocabulary; full overrides are simple but heavier and lose intent. Also: replay ordering / conflict semantics, and confirmation that a position-keyed mutator stays valid across re-derivation (determinism guarantees an identical base — to be pinned).
|
||||
- **Context:** Needed when the save system is built (Phase 5+).
|
||||
- **Cross-reference:** [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010)
|
||||
@@ -395,10 +397,11 @@ Technical foundation questions: engine, protocols, data structures, performance,
|
||||
---
|
||||
|
||||
### Q-105: Region seasonal/clock state — the shared cheap-dynamism source
|
||||
- **Status:** Open — committed in principle (D-228); model details open (2026-05-25)
|
||||
- **Status:** Resolved 2026-07-08 (T-1057) by [D-253](../decisions/architecture.md#d-253)
|
||||
- **Resolution:** Resolved by [D-253](../decisions/architecture.md#d-253) — a **four-term region transient-state model** (diurnal · tidal · weather · seasonal, one term per natural clock rate) whose derived surface scalars are **temperature(time)** (= the T-1078/D-240 static baseline + seasonal + diurnal offsets), **water-height(time)** (= mean + seasonal + tidal), **snow/ice depth**, **weather**, and the **crop-cycle phase**. The bundle is **memoized on the region keyed by clock-bucket**, recomputed **on bucket rollover, never per tick and never integrated** (state = `f(absolute clock, region)`, so it is drift-free, reproducible per [D-010](../decisions/architecture.md#d-010), and gives D-226 frozen-phase inspection for free). Districts/tiles **never compute their own phase** — they read the region scalars, **edge-fuzzed** by the D-243 §4 blend, and **realize per-tile state by local comparison** (flooded iff water-height > tile elevation; snowed via the existing T-1030 scatter band; puddled in T-1081 micro-lows; crop stage from the region phase). D-253 supplies the clock the three shipped punts were forward-contracts to (T-1030 transient freeze/snow depth, T-1082 tidal-flat wet/dry, T-1078 region-clock structure), and is a **state model only** — the gameplay/rendering consumers are Phase 5+.
|
||||
- **Question:** D-228 commits to a deliberately cheap, deterministic, **region-level seasonal/clock state** — computed *once* per region per phase, inherited by its tiles — from which a family of **transient surface conditions** derive — all computed from the region's seasonal + tidal + weather phase, none of it stored: **flooded tiles** (floodplain / tidal-flat / seasonal-river, where the water-height crosses local elevation), **snow & ice cover**, **puddles** (weather / rain), **weather** generally, and the **farmland crop cycle** (sown → growing → ripe → harvested → fallow). The model must stay "nearly free" — simple deterministic functions of the in-game clock + body/region parameters, no per-tile or per-frame simulation. Open: the exact phase functions — a **seasonal** term phased by hemisphere (latitude sign), driven by the year clock; a **tidal** term only when the body has a moon (no satellite → no tide), driven by the lunar/day clock with amplitude from the satellite config; a **weather** term (shorter-term precipitation → puddles + general conditions); how snow/ice depth, puddles, flood extent, weather, and the crop cycle each read the same state; the **snow/ice cover model** — D-228 removed `Snow`/`Ice` from `TerrainMaterial`, so snow/ice are *entirely* this seasonal cover overlay (permanent only where climate never melts them — poles, glaciers), depth rising/falling with the seasonal phase; determinism (state = `f(clock, region)` → reproducible) and its interaction with the cache + the D-226 pause/inspection (a frozen phase for stable inspection); and recompute cadence (per phase-change, not per frame).
|
||||
- **Context:** Grew from the floodplain dependency — "floodplain is only feasible if water heights move" — into a general cheap-dynamism source. Jeroen's constraints: clock + hemisphere bound, moon-gated tides, computed once per region, simple rules; snow / weather / crop cycle ride the same calculation.
|
||||
- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-227](../decisions/architecture.md#d-227), [D-226](../decisions/architecture.md#d-226) (dynamic-state inspection), [D-010](../decisions/architecture.md#d-010)
|
||||
- **Cross-reference:** [D-253](../decisions/architecture.md#d-253) (the resolving model), [D-228](../decisions/architecture.md#d-228), [D-243](../decisions/architecture.md#d-243) (region = lockdown scale), [D-239](../decisions/architecture.md#d-239) §2/§3 (temperature primitive; T-1030 scatter band), [D-227](../decisions/architecture.md#d-227), [D-226](../decisions/architecture.md#d-226) (dynamic-state inspection), [D-010](../decisions/architecture.md#d-010)
|
||||
|
||||
---
|
||||
|
||||
@@ -427,7 +430,8 @@ Technical foundation questions: engine, protocols, data structures, performance,
|
||||
---
|
||||
|
||||
### Q-109: Cascade generation-source dispatch — planetary / station / mod-DLC-forked / save-only
|
||||
- **Status:** Open — raised by Jeroen (2026-06-05, during T-957 zone-selection authoring)
|
||||
- **Status:** Open — raised by Jeroen (2026-06-05, during T-957 zone-selection authoring); re-triaged 2026-07-08 (T-1057) → revisit before stations / mods / save-only bodies are prioritized (Phase 5+).
|
||||
- **Triage (2026-07-08, T-1057):** DEFERRED. The **planetary path is the only live generation source** today, so the dispatch discriminator has exactly one branch to route to and nothing yet exercises the fork. The station-only interim is already carved by the [D-229](../decisions/architecture.md#d-229) amendment (2026-06-05): T-957's `(ZoningType × economic_role × setting)` zone-selection table deliberately excludes the station-only ids (`residential_station` / `extraction_space` / `port_space` / `rural_orbital`), reserving them for a separate station cascade — so the seam is *documented* without the dispatcher being built. Revisit before stations, mod/DLC-forked bodies, or save-only `player_base` bodies are prioritized (Phase 5+), when the discriminator's home (`SettingType` vs a dedicated `GenerationSource`), the mod/DLC external-code resolution, and the save-only cache-bypass (a `player_base` body must never enqueue an `AnalyzeBody` — [D-225](../decisions/architecture.md#d-225)/[D-203](../decisions/architecture.md#d-203)) are decided together with the savegame model.
|
||||
- **Question:** The generation cascade currently has a single implicit path: every body runs the planetary generator (`run_cascade` → Layers 0–5). But a body's `SettingType` (or a sibling `GenerationSource` discriminator) should **dispatch at the cascade entry** to one of several generation sources: (a) **planetary** — the default terrain→quarter→tile cascade (T-955/T-956/T-957…); (b) **station / orbital** — a *separate* cascade that owns the station-only zone types (`residential_station`, `extraction_space`, `port_space`, `rural_orbital`) and its own layout model, rather than branching the planetary code (this is why T-957's zone-selection table deliberately excludes those ids — see [D-229](../decisions/architecture.md#d-229) amendment 2026-06-05); (c) **mod / DLC-forked** — a value that routes generation to externally-provided code/templates (a mod or DLC name), so third-party content can supply a body's built form without patching core; (d) **save-only / `player_base`** — disables auto-generation entirely and loads the body's built world from a save file (player-constructed bases must persist, not regenerate). Where does this discriminator live — on `SettingType` itself (it already carries `Station`/`Orbital` variants) or a dedicated `GenerationSource` enum at the `run_cascade` entry? How does the mod/DLC fork resolve to external code (registry? trait object? content-pack manifest?)? And how does save-only compose with the D-203 hot cache and the D-225 compute-on-demand proxy (a `player_base` body must never enqueue an `AnalyzeBody`)?
|
||||
- **Context:** Surfaced while authoring T-957's `(ZoningType × economic_role × setting)` zone-selection table: `setting` is the right *tweaker* within the planetary path, but it is also the natural *dispatch* point above it — the two roles are distinct and only the tweaker belongs in T-957. The dispatch is a cross-cutting routing seam above any single layer; capturing it here so T-957 stays scoped to the planetary path. Not blocking — the planetary path is the only live source today. Wants Tyre (cascade architecture) + a look at modding/DLC strategy and the savegame model (Phase 5+) before it's decided.
|
||||
- **Cross-reference:** [D-200](../decisions/architecture.md#d-200) (runtime cascade), [D-225](../decisions/architecture.md#d-225) (compute-on-demand proxy — save-only must bypass), [D-203](../decisions/architecture.md#d-203) (hot cache), [D-229](../decisions/architecture.md#d-229) (zone-selection — excludes station ids for this reason), D-222 (Quarter/spatial tiers), and the development cascade (Phase 5 player control + savegame)
|
||||
|
||||
@@ -588,6 +588,36 @@ CREATE TABLE IF NOT EXISTS atlas_body_trait_bias (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag);
|
||||
|
||||
-- architecture_zone_bias: D-235 step-2 sparse bias table (T-988). Per
|
||||
-- (template, zone_type) axis-token weight overrides within that template's
|
||||
-- own visual_bundle; unlisted (template, zone_type) pairs — and unlisted
|
||||
-- tokens within a listed entry — fall back to a uniform draw. Baked from
|
||||
-- wiki/economics/architecture_zone_bias.toml. Importer-validated (V-TT-06):
|
||||
-- every referenced token must already be in the template's visual_bundle.
|
||||
CREATE TABLE IF NOT EXISTS architecture_zone_bias (
|
||||
template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE,
|
||||
zone_type_id TEXT NOT NULL, -- D-142 zone-type id (BuildingPropertyTag.zone_type_id)
|
||||
bias TEXT NOT NULL, -- JSON {axis: {token: weight_bps}}, D-010 integer bps
|
||||
PRIMARY KEY (template_tag, zone_type_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag);
|
||||
|
||||
-- color_register_bands: D-235 numeric HSV sampling band per trait-template
|
||||
-- `color_register` label (T-988). One band per register; the fill seed
|
||||
-- samples (hue, sat, val) uniformly within it per building. Baked from
|
||||
-- wiki/economics/color_register_bands.toml. Importer-validated (V-TT-07):
|
||||
-- every color_register referenced by trait_templates.visual_bundle must have
|
||||
-- a band here, and every band's ranges must be in-bounds with min < max.
|
||||
CREATE TABLE IF NOT EXISTS color_register_bands (
|
||||
color_register TEXT PRIMARY KEY,
|
||||
hue_min INTEGER NOT NULL, -- centidegrees (degrees x 100), 0..36000
|
||||
hue_max INTEGER NOT NULL,
|
||||
sat_min INTEGER NOT NULL, -- basis points, 0..10000
|
||||
sat_max INTEGER NOT NULL,
|
||||
val_min INTEGER NOT NULL, -- basis points, 0..10000
|
||||
val_max INTEGER NOT NULL
|
||||
);
|
||||
-- END TRAIT TEMPLATES (D-232, #993)
|
||||
|
||||
-- Indexes
|
||||
|
||||
Binary file not shown.
@@ -34,6 +34,7 @@ use crate::atlas::district_profile::BodyParams;
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::shell::{fill_chunk, FilledChunk};
|
||||
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
|
||||
use crate::atlas::trait_catalog_reader::ExteriorCatalog;
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState};
|
||||
|
||||
@@ -113,6 +114,15 @@ pub enum GenWorkItem {
|
||||
economic_role: String,
|
||||
population: i64,
|
||||
founding_age_years: u32,
|
||||
/// D-235 exterior-grammar content (T-988): the trait-template catalog's
|
||||
/// `visual_bundle`s plus the two sibling content tables
|
||||
/// (`architecture_zone_bias`, `color_register_bands`), pre-resolved at
|
||||
/// dispatch time — mirrors `context`'s own pre-resolution rationale, so
|
||||
/// `assign_block_tags` never touches `systems.db` (T-987/D-230 purity).
|
||||
/// A `Vec`/`BTreeMap`-backed struct is a handful of words on the stack
|
||||
/// regardless of its contents' size (same reasoning already documented
|
||||
/// for `FillChunk.block_tags` below), so this needs no `Box`.
|
||||
exterior_catalog: ExteriorCatalog,
|
||||
},
|
||||
/// Derive the building shell for one 64 m chunk of an existing quarter
|
||||
/// (D-230 derive phase, T-987).
|
||||
@@ -431,6 +441,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
economic_role,
|
||||
population,
|
||||
founding_age_years,
|
||||
exterior_catalog,
|
||||
} => {
|
||||
// Build the Phase 1 skeleton from the pre-resolved D-199 context.
|
||||
// `economic_role`, `population`, and `founding_age_years` are the
|
||||
@@ -445,13 +456,16 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
*chain,
|
||||
);
|
||||
// Step-3 building-property tags per footprint (D-229, #957): subdivide
|
||||
// each block into building plots and tag them.
|
||||
// each block into building plots and tag them. `exterior_catalog`
|
||||
// (D-235, T-988) resolves each tag's BuildingExteriorTag in the
|
||||
// same pass.
|
||||
let block_tags = assign_all_block_tags(
|
||||
&skeleton,
|
||||
context,
|
||||
economic_role,
|
||||
*founding_age_years,
|
||||
*chain,
|
||||
exterior_catalog,
|
||||
);
|
||||
GenCompletion::SkeletonGenerated {
|
||||
city_id: *city_id,
|
||||
@@ -591,6 +605,7 @@ mod tests {
|
||||
economic_role: "service_mixed".to_string(),
|
||||
population: 500_000,
|
||||
founding_age_years: 200,
|
||||
exterior_catalog: ExteriorCatalog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,8 +715,10 @@ mod tests {
|
||||
fn quarter_with_one_building() -> QuarterWorldState {
|
||||
use crate::atlas::tile_condition::TileCondition;
|
||||
use crate::simulation::generator::{
|
||||
ArchitectureFlavorRef, BuildingEntryClass, BuildingPropertyTag, ConstructionEra,
|
||||
EraCause, FloorExtent, FloorHeightProfile, QuarterSkeleton, TileRect, ZoneTypeId,
|
||||
ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, BuildingPropertyTag,
|
||||
ConstructionEra, EraCause, FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor,
|
||||
QuarterSkeleton, RoofForm, SetbackTier, StreetSurface, TileRect, WallMaterial,
|
||||
ZoneTypeId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -721,6 +738,18 @@ mod tests {
|
||||
era: ConstructionEra::Founding,
|
||||
era_cause: EraCause::Original,
|
||||
initial_condition: TileCondition::Intact,
|
||||
exterior: BuildingExteriorTag {
|
||||
wall_material: WallMaterial::Generic,
|
||||
roof_form: RoofForm::Generic,
|
||||
facade_rhythm: FacadeRhythm::Generic,
|
||||
setback_tier: SetbackTier::Standard,
|
||||
color: HsvColor {
|
||||
hue: 0,
|
||||
sat: 0,
|
||||
val: 5_000,
|
||||
},
|
||||
street_surface: StreetSurface::Generic,
|
||||
},
|
||||
doors: Vec::new(),
|
||||
}],
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod subbiome;
|
||||
pub mod tile_condition;
|
||||
pub mod trait_catalog_reader;
|
||||
pub mod trait_draw;
|
||||
pub mod trait_exterior;
|
||||
pub mod trait_swerve;
|
||||
pub mod voxel;
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ use crate::atlas::road_graph::{RoadGraph, RoadNode};
|
||||
use crate::atlas::scale;
|
||||
use crate::atlas::skeleton_gen::derive_complexity;
|
||||
use crate::atlas::source_resolver::BodySourceResolverResource;
|
||||
use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate};
|
||||
use crate::atlas::trait_catalog_reader::{
|
||||
ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate,
|
||||
};
|
||||
use crate::atlas::trait_draw::{
|
||||
complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type,
|
||||
VocabularyDrawInputs,
|
||||
@@ -177,6 +179,25 @@ fn drain_generation_completions(
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
// D-235 exterior-grammar content (T-988), read alongside the
|
||||
// D-232 catalog above — same L3→L4 dispatch-time rationale
|
||||
// (`assign_block_tags` stays DB-free downstream, T-987/D-230).
|
||||
// `templates` reuses the already-fetched `catalog` (itself
|
||||
// OnceLock-cached inside the reader) rather than re-querying.
|
||||
let exterior_catalog: ExteriorCatalog = match trait_catalog.as_ref() {
|
||||
Some(tc) => ExteriorCatalog {
|
||||
templates: catalog.clone(),
|
||||
zone_bias: tc.0.read_zone_bias().unwrap_or_else(|e| {
|
||||
tracing::warn!(body_id = %body_id, error = %e, "zone bias read failed — uniform draw everywhere");
|
||||
BTreeMap::new()
|
||||
}),
|
||||
color_bands: tc.0.read_color_register_bands().unwrap_or_else(|e| {
|
||||
tracing::warn!(body_id = %body_id, error = %e, "color register bands read failed — neutral color everywhere");
|
||||
BTreeMap::new()
|
||||
}),
|
||||
},
|
||||
None => ExteriorCatalog::default(),
|
||||
};
|
||||
let body_sector: Option<&str> = resolved
|
||||
.first()
|
||||
.and_then(|(_, rs)| rs.geographic_sector.as_deref());
|
||||
@@ -208,6 +229,7 @@ fn drain_generation_completions(
|
||||
catalog: &catalog,
|
||||
eligible: &eligible,
|
||||
swerve_pools: &swerve_pools,
|
||||
exterior_catalog: &exterior_catalog,
|
||||
};
|
||||
|
||||
for (placement, read_set) in resolved {
|
||||
@@ -364,6 +386,12 @@ struct BodyVocabularyContext<'a> {
|
||||
/// heritage-callback), cloned onto each settlement's context — the
|
||||
/// per-building wildcard draws from these at `assign_block_tags` time.
|
||||
swerve_pools: &'a SwervePools,
|
||||
/// D-235 exterior-grammar content (T-988): the catalog's `visual_bundle`s
|
||||
/// plus the two sibling content tables, read once per body and cloned
|
||||
/// verbatim onto every settlement's `GenerateSkeleton` work item — the
|
||||
/// per-building `BuildingExteriorTag` draw happens at `assign_block_tags`
|
||||
/// time (`atlas::trait_exterior`), never inside `FillChunk`.
|
||||
exterior_catalog: &'a ExteriorCatalog,
|
||||
}
|
||||
|
||||
/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's
|
||||
@@ -534,6 +562,7 @@ fn build_skeleton_work_item(
|
||||
economic_role,
|
||||
population,
|
||||
founding_age_years,
|
||||
exterior_catalog: vocab.exterior_catalog.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,12 +819,19 @@ mod tests {
|
||||
foreign: Vec::new(),
|
||||
heritage: Vec::new(),
|
||||
};
|
||||
// `ExteriorCatalog::default()` isn't a const fn (derived `Default`),
|
||||
// so a `static` binding isn't available the way it is for
|
||||
// `EMPTY_POOLS` above — leak a tiny one-off value instead (test-only,
|
||||
// matches this function's existing 'static-returning contract).
|
||||
let exterior_catalog: &'static ExteriorCatalog =
|
||||
Box::leak(Box::new(ExteriorCatalog::default()));
|
||||
BodyVocabularyContext {
|
||||
trait_selection: &[],
|
||||
body_district_type_mix: &[],
|
||||
catalog: &[],
|
||||
eligible: &[],
|
||||
swerve_pools: &EMPTY_POOLS,
|
||||
exterior_catalog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +938,7 @@ mod tests {
|
||||
base_weight: 10_000,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(),
|
||||
visual_bundle: Default::default(),
|
||||
}
|
||||
}
|
||||
let catalog = vec![tmpl("temp_a"), tmpl("temp_b")];
|
||||
@@ -912,12 +949,14 @@ mod tests {
|
||||
foreign: vec![("foreign_x".to_string(), 10_000)],
|
||||
heritage: vec![("herit_y".to_string(), 10_000)],
|
||||
};
|
||||
let exterior_catalog = ExteriorCatalog::default();
|
||||
let vocab = BodyVocabularyContext {
|
||||
trait_selection: &trait_selection,
|
||||
body_district_type_mix: &mix,
|
||||
catalog: &catalog,
|
||||
eligible: &eligible,
|
||||
swerve_pools: &pools,
|
||||
exterior_catalog: &exterior_catalog,
|
||||
};
|
||||
|
||||
// City 1 sits in the road graph with degree 2; city 2 has no node (degree 0).
|
||||
|
||||
+132
-6
@@ -54,7 +54,7 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::scale::{CHUNKS_PER_BLOCK, CHUNK_M, VOXELS_PER_CHUNK};
|
||||
use crate::simulation::generator::{BuildingPropertyTag, TileRect};
|
||||
use crate::simulation::generator::{BuildingPropertyTag, RoofForm, TileRect, WallMaterial};
|
||||
|
||||
/// One structural shell voxel material (D-230).
|
||||
///
|
||||
@@ -63,7 +63,8 @@ use crate::simulation::generator::{BuildingPropertyTag, TileRect};
|
||||
/// full four-way classification and so callers can match exhaustively.
|
||||
///
|
||||
/// Integer-discriminant, append-only (D-010). Surface materials (D-235 `WallMaterial`
|
||||
/// etc.) are a separate axis layered on top by T-988 — do not fold them in here.
|
||||
/// etc.) are a separate axis layered on top — see [`SurfaceMaterial`] / T-988/T-959 —
|
||||
/// do not fold them in here; this enum stays the pure structural shape.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum ShellVoxel {
|
||||
@@ -78,6 +79,18 @@ pub enum ShellVoxel {
|
||||
Roof = 3,
|
||||
}
|
||||
|
||||
/// The D-235 surface material "layered on top of" a [`ShellVoxel::Wall`] or
|
||||
/// [`ShellVoxel::Roof`] voxel (T-959/T-988) — read straight from the covering
|
||||
/// building's frozen `BuildingPropertyTag.exterior`, never re-derived here
|
||||
/// (this layer stays pure/cache-free, T-987). Absent for `Void`/`FloorSlab`
|
||||
/// positions: D-235's vocabulary has no wall/roof token for interior air or a
|
||||
/// floor slab's top surface (that is the separate D-228 `FloorMaterial` axis).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SurfaceMaterial {
|
||||
Wall(WallMaterial),
|
||||
Roof(RoofForm),
|
||||
}
|
||||
|
||||
/// Chunk-local voxel coordinate: `(x, y)` in `0..64`, `z` quarter-ground-relative.
|
||||
///
|
||||
/// `x`/`y` are chunk-local tile indices (D-243: a chunk is 64×64 voxels). `z` is the
|
||||
@@ -100,6 +113,14 @@ pub struct FilledChunk {
|
||||
pub sub_chunk: (u8, u8),
|
||||
/// Non-`Void` shell voxels, keyed by chunk-local position (D-010 ordered).
|
||||
pub voxels: BTreeMap<ShellVoxelPos, ShellVoxel>,
|
||||
/// D-235 surface material for every `Wall`/`Roof` entry in `voxels`
|
||||
/// (T-959/T-988) — keyed identically, so a consumer joins the two maps by
|
||||
/// position. Strictly additive over `voxels`: every key here has a
|
||||
/// matching `Wall`/`Roof` entry there, but not vice versa is NOT
|
||||
/// guaranteed to be exhaustive on a stale/foreign tag (see
|
||||
/// `shell_derive_into`'s doc comment) — a missing entry means "render the
|
||||
/// generic fallback for this axis," never a panic.
|
||||
pub surface_material: BTreeMap<ShellVoxelPos, SurfaceMaterial>,
|
||||
}
|
||||
|
||||
impl FilledChunk {
|
||||
@@ -142,6 +163,7 @@ pub fn fill_chunk(
|
||||
"sub_chunk {sub_chunk:?} outside the block's {CHUNKS_PER_BLOCK}×{CHUNKS_PER_BLOCK} chunk grid"
|
||||
);
|
||||
let mut voxels: BTreeMap<ShellVoxelPos, ShellVoxel> = BTreeMap::new();
|
||||
let mut surface_material: BTreeMap<ShellVoxelPos, SurfaceMaterial> = BTreeMap::new();
|
||||
|
||||
// Block-local tile range covered by this 64 m sub-chunk quadrant.
|
||||
let chunk_lo_x = sub_chunk.0 as i32 * CHUNK_M;
|
||||
@@ -152,6 +174,7 @@ pub fn fill_chunk(
|
||||
for tag in block_tags {
|
||||
shell_derive_into(
|
||||
&mut voxels,
|
||||
&mut surface_material,
|
||||
tag,
|
||||
(chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y),
|
||||
);
|
||||
@@ -162,17 +185,23 @@ pub fn fill_chunk(
|
||||
block_pos,
|
||||
sub_chunk,
|
||||
voxels,
|
||||
surface_material,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit one building's shell voxels into `voxels`, clipped to the chunk's block-local
|
||||
/// tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive).
|
||||
/// Emit one building's shell voxels into `voxels` (plus their D-235 surface
|
||||
/// material into `surface_material`, T-959/T-988), clipped to the chunk's
|
||||
/// block-local tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive).
|
||||
///
|
||||
/// Rectangle-containment (footprint ∩ chunk) × z-range (per-floor voxel bands from the
|
||||
/// [`FloorExtent`]), per D-230. Walls on the footprint perimeter for the full height,
|
||||
/// floor slabs on interior tiles at each floor base, a roof cap above the top floor.
|
||||
/// Every `Wall`/`Roof` voxel this pass inserts gets a matching `surface_material`
|
||||
/// entry from `tag.exterior` at the same position — the "layered on top of this
|
||||
/// shell" T-988 promised.
|
||||
fn shell_derive_into(
|
||||
voxels: &mut BTreeMap<ShellVoxelPos, ShellVoxel>,
|
||||
surface_material: &mut BTreeMap<ShellVoxelPos, SurfaceMaterial>,
|
||||
tag: &BuildingPropertyTag,
|
||||
window: (i32, i32, i32, i32),
|
||||
) {
|
||||
@@ -239,6 +268,14 @@ fn shell_derive_into(
|
||||
continue; // interior air → Void, not stored
|
||||
};
|
||||
voxels.insert((cx, cy, z), material);
|
||||
if perimeter {
|
||||
// D-235 (T-959/T-988): every Wall voxel carries the
|
||||
// building's frozen exterior wall material alongside it.
|
||||
surface_material.insert(
|
||||
(cx, cy, z),
|
||||
SurfaceMaterial::Wall(tag.exterior.wall_material),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +294,10 @@ fn shell_derive_into(
|
||||
let cx = (tx - win_lo_x) as u8;
|
||||
let cy = (ty - win_lo_y) as u8;
|
||||
voxels.insert((cx, cy, rz), ShellVoxel::Roof);
|
||||
// D-235 (T-959/T-988): every Roof voxel carries the building's
|
||||
// frozen exterior roof form alongside it.
|
||||
surface_material
|
||||
.insert((cx, cy, rz), SurfaceMaterial::Roof(tag.exterior.roof_form));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,8 +328,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::tile_condition::TileCondition;
|
||||
use crate::simulation::generator::{
|
||||
ArchitectureFlavorRef, BuildingEntryClass, ConstructionEra, EraCause, FloorExtent,
|
||||
FloorHeightProfile, ZoneTypeId,
|
||||
ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, ConstructionEra, EraCause,
|
||||
FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, RoofForm, SetbackTier,
|
||||
StreetSurface, WallMaterial, ZoneTypeId,
|
||||
};
|
||||
|
||||
/// Build a `BuildingPropertyTag` with the given block-local footprint and a
|
||||
@@ -312,6 +354,18 @@ mod tests {
|
||||
era: ConstructionEra::Founding,
|
||||
era_cause: EraCause::Original,
|
||||
initial_condition: TileCondition::Intact,
|
||||
exterior: BuildingExteriorTag {
|
||||
wall_material: WallMaterial::ConcreteWall,
|
||||
roof_form: RoofForm::FlatRoof,
|
||||
facade_rhythm: FacadeRhythm::RegularFacade,
|
||||
setback_tier: SetbackTier::Standard,
|
||||
color: HsvColor {
|
||||
hue: 0,
|
||||
sat: 0,
|
||||
val: 5_000,
|
||||
},
|
||||
street_surface: StreetSurface::Paved,
|
||||
},
|
||||
doors: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -454,4 +508,76 @@ mod tests {
|
||||
fc.voxel_count()
|
||||
);
|
||||
}
|
||||
|
||||
// ── D-235 surface material — "layered on top of this shell" (T-959/T-988) ──
|
||||
|
||||
#[test]
|
||||
fn wall_voxels_carry_the_buildings_wall_material() {
|
||||
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]);
|
||||
// Corner (2,2) is perimeter → Wall, per the existing shell test above.
|
||||
assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall);
|
||||
assert_eq!(
|
||||
fc.surface_material.get(&(2, 2, 0)),
|
||||
Some(&SurfaceMaterial::Wall(WallMaterial::ConcreteWall))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roof_voxels_carry_the_buildings_roof_form() {
|
||||
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]);
|
||||
assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof);
|
||||
assert_eq!(
|
||||
fc.surface_material.get(&(2, 2, 3)),
|
||||
Some(&SurfaceMaterial::Roof(RoofForm::FlatRoof))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_slab_and_void_carry_no_surface_material() {
|
||||
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]);
|
||||
// Interior tile (3,3): FloorSlab at the base, Void above — see the
|
||||
// shipped `single_storey_box_has_walls_floor_and_roof` test.
|
||||
assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab);
|
||||
assert!(!fc.surface_material.contains_key(&(3, 3, 0)));
|
||||
assert!(!fc.surface_material.contains_key(&(0, 0, 0))); // outside footprint (Void)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_material_count_matches_wall_plus_roof_voxels() {
|
||||
let fc = fill_chunk(
|
||||
1,
|
||||
(0, 0),
|
||||
(0, 0),
|
||||
&[tag((0, 0), (5, 5), 0, 3), tag((40, 40), (10, 8), -1, 2)],
|
||||
);
|
||||
let wall_and_roof = fc
|
||||
.voxels
|
||||
.values()
|
||||
.filter(|v| matches!(v, ShellVoxel::Wall | ShellVoxel::Roof))
|
||||
.count();
|
||||
assert_eq!(fc.surface_material.len(), wall_and_roof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_buildings_carry_their_own_distinct_materials() {
|
||||
// Two buildings in the same chunk with different exterior tags must
|
||||
// never bleed into each other's surface_material — this is per-tag
|
||||
// data, not a chunk-wide constant.
|
||||
let mut b1 = tag((0, 0), (5, 5), 0, 1);
|
||||
b1.exterior.wall_material = WallMaterial::StoneWall;
|
||||
b1.exterior.roof_form = RoofForm::VaultedRoof;
|
||||
let mut b2 = tag((40, 40), (5, 5), 0, 1);
|
||||
b2.exterior.wall_material = WallMaterial::TimberWall;
|
||||
b2.exterior.roof_form = RoofForm::PitchedRoof;
|
||||
|
||||
let fc = fill_chunk(1, (0, 0), (0, 0), &[b1, b2]);
|
||||
assert_eq!(
|
||||
fc.surface_material.get(&(0, 0, 0)),
|
||||
Some(&SurfaceMaterial::Wall(WallMaterial::StoneWall))
|
||||
);
|
||||
assert_eq!(
|
||||
fc.surface_material.get(&(40, 40, 0)),
|
||||
Some(&SurfaceMaterial::Wall(WallMaterial::TimberWall))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::tile_condition::{tile_condition, TileCondition};
|
||||
use crate::atlas::trait_catalog_reader::ExteriorCatalog;
|
||||
use crate::atlas::trait_exterior;
|
||||
use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates};
|
||||
use crate::seed::splitmix64;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
@@ -30,9 +32,9 @@ use crate::simulation::generator::{
|
||||
AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton,
|
||||
BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext,
|
||||
ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause,
|
||||
FloorExtent, FloorHeightProfile, FoundingOrientation, MorphologyZone, MultiBlockReservation,
|
||||
PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId,
|
||||
SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
|
||||
FloorExtent, FloorHeightProfile, FoundingOrientation, InterstitialCharacter, MorphologyZone,
|
||||
MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction,
|
||||
ReservationId, SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -104,7 +106,12 @@ pub fn generate_quarter_skeleton(
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
let mut blocks = build_block_grid(
|
||||
&mix.districts,
|
||||
&block_reservation,
|
||||
&primary_district_type,
|
||||
&context.dominant_bulk_class,
|
||||
);
|
||||
|
||||
// ── Street network (D-234) ────────────────────────────────────────────
|
||||
// Local lattice modulation per block (Grid vs Organic), then the quarter's
|
||||
@@ -322,15 +329,24 @@ fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
|
||||
///
|
||||
/// Blocks are assigned row-major (index = row * 4 + col).
|
||||
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
|
||||
///
|
||||
/// `dominant_bulk_class` sets every block's `interstitial_character` (D-233,
|
||||
/// T-1097): the settlement-wide bulk-class signal already drives
|
||||
/// `roofed_coverage_pct`/`subdivide_block_footprints` uniformly across every
|
||||
/// block regardless of that block's own zoning (an existing simplification,
|
||||
/// not a new one) — the ops-surface classification follows the identical
|
||||
/// granularity for consistency.
|
||||
fn build_block_grid(
|
||||
districts: &[DistrictType],
|
||||
block_reservation: &[[Option<ReservationId>; 4]; 4],
|
||||
primary: &DistrictType,
|
||||
dominant_bulk_class: &BulkClass,
|
||||
) -> [[BlockSkeleton; 4]; 4] {
|
||||
// Pad or truncate district list to exactly 16.
|
||||
let district_iter: Vec<&DistrictType> = (0..16)
|
||||
.map(|i| districts.get(i).unwrap_or(primary))
|
||||
.collect();
|
||||
let interstitial_character = interstitial_character_for(dominant_bulk_class);
|
||||
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| {
|
||||
@@ -356,11 +372,24 @@ fn build_block_grid(
|
||||
era_cause: None,
|
||||
density_pct: density,
|
||||
landmark: None,
|
||||
interstitial_character,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// D-233 ops-surface classification (T-1097): bulk-industry settlements read
|
||||
/// their blocks' non-roofed remainder as built economic infrastructure, never
|
||||
/// generic interstitial open space.
|
||||
fn interstitial_character_for(bulk: &BulkClass) -> InterstitialCharacter {
|
||||
match bulk {
|
||||
BulkClass::BulkSolid | BulkClass::BulkLiquid => InterstitialCharacter::OperationsSurface,
|
||||
BulkClass::PrecisionDense | BulkClass::Perishable | BulkClass::NonPhysical => {
|
||||
InterstitialCharacter::OpenSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default build density percentage for a zoning type.
|
||||
fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
match zoning {
|
||||
@@ -859,6 +888,10 @@ fn subdivide_block_footprints(
|
||||
|
||||
/// Tag every building footprint in one block (D-229). Reserved blocks (parks,
|
||||
/// terminals, plazas) are open/special-use and get no standard building fill.
|
||||
///
|
||||
/// `exterior_catalog` is the D-235 exterior-grammar content (T-988),
|
||||
/// pre-resolved at L3→L4 dispatch time alongside the rest of the D-232 catalog
|
||||
/// data — this function never touches `systems.db` (T-987/D-230 purity).
|
||||
fn assign_block_tags(
|
||||
block: &BlockSkeleton,
|
||||
skeleton: &QuarterSkeleton,
|
||||
@@ -867,6 +900,7 @@ fn assign_block_tags(
|
||||
founding_age_years: u32,
|
||||
waterfront: Option<Edge>,
|
||||
block_chain: SeedChain,
|
||||
exterior_catalog: &ExteriorCatalog,
|
||||
) -> Vec<BuildingPropertyTag> {
|
||||
if block.reservation.is_some() {
|
||||
return Vec::new();
|
||||
@@ -937,6 +971,35 @@ fn assign_block_tags(
|
||||
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
|
||||
None => district_dominant.clone(),
|
||||
};
|
||||
// D-235 (T-988): resolve the exterior grammar from this SAME
|
||||
// finalized flavor_ref, at the same plan-time point — "Swerve(tag)
|
||||
// refs resolve against the full catalog at the same point" (the
|
||||
// T-994 phase-timing precedent this ticket follows). InVocabulary
|
||||
// indexes into context.trait_selection; an out-of-bounds index or
|
||||
// a Swerve tag absent from the catalog (a content gap, or a test
|
||||
// fixture with no catalog wired) resolves to `None` — the
|
||||
// derivation degrades to Generic/neutral rather than panicking.
|
||||
let resolved_tag: Option<&str> = match &flavor_ref {
|
||||
ArchitectureFlavorRef::InVocabulary(idx) => context
|
||||
.trait_selection
|
||||
.get(*idx as usize)
|
||||
.map(String::as_str),
|
||||
ArchitectureFlavorRef::Swerve(tag) => Some(tag.as_str()),
|
||||
};
|
||||
let template = resolved_tag
|
||||
.and_then(|tag| exterior_catalog.templates.iter().find(|t| t.tag == tag));
|
||||
let zone_bias = resolved_tag.and_then(|tag| {
|
||||
exterior_catalog
|
||||
.zone_bias
|
||||
.get(&(tag.to_string(), zone_type_id.as_str().to_string()))
|
||||
});
|
||||
let exterior = trait_exterior::derive_building_exterior(
|
||||
template,
|
||||
zone_bias,
|
||||
&exterior_catalog.color_bands,
|
||||
block.density_pct,
|
||||
fp_chain,
|
||||
);
|
||||
BuildingPropertyTag {
|
||||
zone_type_id,
|
||||
footprint,
|
||||
@@ -946,6 +1009,7 @@ fn assign_block_tags(
|
||||
era,
|
||||
era_cause,
|
||||
initial_condition: initial,
|
||||
exterior,
|
||||
doors: Vec::new(), // D-231 door derivation is #979
|
||||
}
|
||||
})
|
||||
@@ -954,12 +1018,16 @@ fn assign_block_tags(
|
||||
|
||||
/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag
|
||||
/// every non-reserved block's footprints. Keyed by 4×4 block grid position.
|
||||
///
|
||||
/// `exterior_catalog` is the D-235 exterior-grammar content (T-988) —
|
||||
/// threaded straight through to [`assign_block_tags`]; see its doc comment.
|
||||
pub fn assign_all_block_tags(
|
||||
skeleton: &QuarterSkeleton,
|
||||
context: &CityGenerationContext,
|
||||
economic_role: &str,
|
||||
founding_age_years: u32,
|
||||
chain: SeedChain,
|
||||
exterior_catalog: &ExteriorCatalog,
|
||||
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
|
||||
// Water-facing quarter edge from the settlement's coastal founding
|
||||
// orientation (D-234b); blocks on it present flush to the quay.
|
||||
@@ -978,6 +1046,7 @@ pub fn assign_all_block_tags(
|
||||
founding_age_years,
|
||||
waterfront,
|
||||
block_chain,
|
||||
exterior_catalog,
|
||||
);
|
||||
if !tags.is_empty() {
|
||||
map.insert((row, col), tags);
|
||||
@@ -1685,7 +1754,14 @@ mod tests {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
let sk =
|
||||
generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 300, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
300,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
assert!(!tags.is_empty(), "quarter should produce building tags");
|
||||
// Park reservation blocks (1,2),(1,3),(2,2),(2,3) get no standard fill.
|
||||
for &reserved in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
@@ -1715,8 +1791,22 @@ mod tests {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk =
|
||||
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
|
||||
let a = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5));
|
||||
let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5));
|
||||
let a = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"manufacturing",
|
||||
250,
|
||||
SeedChain::root(5),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
let b = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"manufacturing",
|
||||
250,
|
||||
SeedChain::root(5),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
@@ -1738,7 +1828,14 @@ mod tests {
|
||||
);
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
|
||||
let mut found_residential = false;
|
||||
for (pos, block_tags) in &tags {
|
||||
@@ -1768,7 +1865,14 @@ mod tests {
|
||||
// the harmless pre-T-994 degenerate behaviour.
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
|
||||
@@ -1791,8 +1895,22 @@ mod tests {
|
||||
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags_a = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
let tags_b = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
|
||||
|
||||
let mut total = 0usize;
|
||||
@@ -1830,7 +1948,14 @@ mod tests {
|
||||
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
|
||||
// rates stay (0, 0) from make_context
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
assert!(matches!(
|
||||
@@ -1841,6 +1966,252 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── D-235 BuildingExteriorTag integration (T-988) ────────────────────────
|
||||
|
||||
use crate::atlas::trait_catalog_reader::{TraitTemplate, VisualBundle};
|
||||
use crate::simulation::generator::{FacadeRhythm, RoofForm, StreetSurface, WallMaterial};
|
||||
|
||||
fn exterior_template(tag: &str, wall: WallMaterial, roof: RoofForm) -> TraitTemplate {
|
||||
TraitTemplate {
|
||||
tag: tag.to_string(),
|
||||
corridor_pool: "cross_corridor".to_string(),
|
||||
geographic_sector: None,
|
||||
bulk_class_gate: Vec::new(),
|
||||
production_ubiquity_gate: Vec::new(),
|
||||
min_prosperity_bps: 0,
|
||||
base_weight: 10_000,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: BTreeMap::new(),
|
||||
visual_bundle: VisualBundle {
|
||||
wall: vec![wall],
|
||||
roof: vec![roof],
|
||||
facade: vec![FacadeRhythm::IndustrialGlazing],
|
||||
street: vec![StreetSurface::HeavyHaul],
|
||||
color_register: None,
|
||||
fallback: BTreeMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_block_tags_resolves_exterior_from_in_vocabulary_flavor_ref() {
|
||||
// The InVocabulary(0) path: flavor_ref indexes into trait_selection,
|
||||
// which must resolve against exterior_catalog.templates to drive the
|
||||
// frozen BuildingExteriorTag — the T-988 deliverable.
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
ctx.trait_selection = vec!["camp".to_string()];
|
||||
ctx.district_dominant_by_type.insert(
|
||||
DistrictType::Residential,
|
||||
ArchitectureFlavorRef::InVocabulary(0),
|
||||
);
|
||||
let exterior_catalog = ExteriorCatalog {
|
||||
templates: vec![exterior_template(
|
||||
"camp",
|
||||
WallMaterial::SteelFrame,
|
||||
RoofForm::CorrugatedRoof,
|
||||
)],
|
||||
zone_bias: BTreeMap::new(),
|
||||
color_bands: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&exterior_catalog,
|
||||
);
|
||||
|
||||
let mut found_residential = false;
|
||||
for (pos, block_tags) in &tags {
|
||||
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
|
||||
if block.district_type != DistrictType::Residential {
|
||||
continue;
|
||||
}
|
||||
found_residential = true;
|
||||
let expected_setback = trait_exterior::derive_setback_tier(block.density_pct);
|
||||
for tag in block_tags {
|
||||
assert_eq!(tag.exterior.wall_material, WallMaterial::SteelFrame);
|
||||
assert_eq!(tag.exterior.roof_form, RoofForm::CorrugatedRoof);
|
||||
assert_eq!(
|
||||
tag.exterior.setback_tier, expected_setback,
|
||||
"setback_tier must follow the block's own density_pct (D-235 step 3)"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_residential,
|
||||
"expected at least one Residential block (D-194 pop-tier guarantee)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_block_tags_resolves_exterior_for_swerved_buildings_too() {
|
||||
// "Swerve(tag) refs resolve against the full catalog at the same
|
||||
// point" — a swerved building's exterior must come from ITS OWN
|
||||
// (out-of-vocabulary) template, distinct from the district-dominant one.
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
ctx.trait_selection = vec!["own".to_string()];
|
||||
ctx.swerve_rates_bps = (10_000, 0); // always swerve, foreign only
|
||||
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
|
||||
|
||||
let exterior_catalog = ExteriorCatalog {
|
||||
templates: vec![
|
||||
exterior_template("own", WallMaterial::ConcreteWall, RoofForm::FlatRoof),
|
||||
exterior_template(
|
||||
"foreign_temple",
|
||||
WallMaterial::TimberWall,
|
||||
RoofForm::ClayTileRoof,
|
||||
),
|
||||
],
|
||||
zone_bias: BTreeMap::new(),
|
||||
color_bands: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&exterior_catalog,
|
||||
);
|
||||
|
||||
let mut any_swerved = false;
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple")
|
||||
{
|
||||
any_swerved = true;
|
||||
assert_eq!(tag.exterior.wall_material, WallMaterial::TimberWall);
|
||||
assert_eq!(tag.exterior.roof_form, RoofForm::ClayTileRoof);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
any_swerved,
|
||||
"swerve_rates_bps=10_000 must swerve every building"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_block_tags_falls_back_to_generic_when_template_unresolvable() {
|
||||
// An InVocabulary index with no matching catalog entry (empty
|
||||
// ExteriorCatalog, e.g. no catalog reader wired) must degrade to
|
||||
// Generic/neutral, never panic.
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"financial",
|
||||
200,
|
||||
SeedChain::root(42),
|
||||
&ExteriorCatalog::default(),
|
||||
);
|
||||
assert!(!tags.is_empty());
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
assert_eq!(tag.exterior.wall_material, WallMaterial::Generic);
|
||||
assert_eq!(tag.exterior.roof_form, RoofForm::Generic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_block_tags_is_deterministic_including_exterior() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
ctx.trait_selection = vec!["camp".to_string()];
|
||||
let exterior_catalog = ExteriorCatalog {
|
||||
templates: vec![exterior_template(
|
||||
"camp",
|
||||
WallMaterial::BrickWall,
|
||||
RoofForm::PitchedRoof,
|
||||
)],
|
||||
zone_bias: BTreeMap::new(),
|
||||
color_bands: BTreeMap::new(),
|
||||
};
|
||||
let sk =
|
||||
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
|
||||
let a = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"manufacturing",
|
||||
250,
|
||||
SeedChain::root(5),
|
||||
&exterior_catalog,
|
||||
);
|
||||
let b = assign_all_block_tags(
|
||||
&sk,
|
||||
&ctx,
|
||||
"manufacturing",
|
||||
250,
|
||||
SeedChain::root(5),
|
||||
&exterior_catalog,
|
||||
);
|
||||
assert_eq!(a, b, "same seeds must produce identical exterior tags too");
|
||||
}
|
||||
|
||||
// ── D-233 operations-surface (T-1097) ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bulk_industry_blocks_read_as_operations_surface() {
|
||||
assert_eq!(
|
||||
interstitial_character_for(&BulkClass::BulkSolid),
|
||||
InterstitialCharacter::OperationsSurface
|
||||
);
|
||||
assert_eq!(
|
||||
interstitial_character_for(&BulkClass::BulkLiquid),
|
||||
InterstitialCharacter::OperationsSurface
|
||||
);
|
||||
assert_eq!(
|
||||
interstitial_character_for(&BulkClass::PrecisionDense),
|
||||
InterstitialCharacter::OpenSpace
|
||||
);
|
||||
assert_eq!(
|
||||
interstitial_character_for(&BulkClass::Perishable),
|
||||
InterstitialCharacter::OpenSpace
|
||||
);
|
||||
assert_eq!(
|
||||
interstitial_character_for(&BulkClass::NonPhysical),
|
||||
InterstitialCharacter::OpenSpace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_block_in_a_bulk_industry_settlement_reads_operations_surface() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
ctx.dominant_bulk_class = BulkClass::BulkSolid;
|
||||
let sk =
|
||||
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
|
||||
for row in &sk.blocks {
|
||||
for block in row {
|
||||
assert_eq!(
|
||||
block.interstitial_character,
|
||||
InterstitialCharacter::OperationsSurface
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bulk_settlement_keeps_open_space() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// make_context defaults dominant_bulk_class to NonPhysical.
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
for row in &sk.blocks {
|
||||
for block in row {
|
||||
assert_eq!(
|
||||
block.interstitial_character,
|
||||
InterstitialCharacter::OpenSpace
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Street network (#957, D-234) ─────────────────────────────────────────
|
||||
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
@@ -14,17 +14,23 @@
|
||||
//! table; it never writes to `systems.db`.
|
||||
//!
|
||||
//! **Scope (T-994):** only the fields the three-phase draw mechanism needs are
|
||||
//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/
|
||||
//! `cultural_description` are D-235 (visual bundle resolution) territory and are
|
||||
//! left unparsed here.
|
||||
//! parsed — `allow_tags`/`block_tags`/`era_scope`/`cultural_description` stay
|
||||
//! unparsed (never consumed downstream of the draw). `visual_bundle` — D-235
|
||||
//! visual-bundle resolution territory — is parsed as of T-988, alongside the
|
||||
//! two sibling D-235 content tables (`architecture_zone_bias`,
|
||||
//! `color_register_bands`) this reader also owns.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity};
|
||||
use crate::simulation::generator::{
|
||||
BulkClass, DistrictType, FacadeRhythm, ProductionUbiquity, RoofForm, StreetSurface,
|
||||
WallMaterial,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
@@ -76,6 +82,35 @@ pub struct TraitTemplate {
|
||||
/// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole
|
||||
/// input (D-232: "district-dominant by zone_affinity").
|
||||
pub zone_affinity: std::collections::BTreeMap<DistrictType, u32>,
|
||||
/// D-235 visual bundle (T-988): the per-axis token set this template's
|
||||
/// buildings are filtered to, plus the `color_register` they sample from.
|
||||
/// Empty/`None` fields when the column is absent (bootstrap/test catalogs).
|
||||
pub visual_bundle: VisualBundle,
|
||||
}
|
||||
|
||||
/// Parsed `visual_bundle` column (D-235, T-988) — the D-235-step-1 filtered
|
||||
/// token set per axis, this template's color register, and the (illustrative,
|
||||
/// non-exhaustive — D-235 amendment) per-template asset fallback chain.
|
||||
///
|
||||
/// `wall`/`roof`/`facade`/`street` are the axes `BuildingExteriorTag` draws
|
||||
/// from (`atlas::trait_exterior`); never mixed across axes (a template is a
|
||||
/// coherent bundle, D-232).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct VisualBundle {
|
||||
pub wall: Vec<WallMaterial>,
|
||||
pub roof: Vec<RoofForm>,
|
||||
pub facade: Vec<FacadeRhythm>,
|
||||
pub street: Vec<StreetSurface>,
|
||||
/// Key into `color_register_bands` (T-988). `None` for a template with no
|
||||
/// authored register (bootstrap/test catalogs) — callers fall back to a
|
||||
/// neutral default color rather than treating it as an error.
|
||||
pub color_register: Option<String>,
|
||||
/// Specific token → generic-parent token (D-235 asset-resolution fallback
|
||||
/// chain). Illustrative/non-exhaustive per-template documentation, NOT the
|
||||
/// validated fallback graph (that lives in `object_tag_vocabulary.toml`,
|
||||
/// V-TT-04) — carried for completeness; Phase-4 generation never reads it
|
||||
/// (render-fidelity degrade is a Phase 5+ asset-resolution concern).
|
||||
pub fallback: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias).
|
||||
@@ -98,6 +133,45 @@ pub struct TraitBias {
|
||||
pub weight_multiplier_bps: Option<u32>,
|
||||
}
|
||||
|
||||
/// One `(template, zone_type)` D-235 step-2 bias entry (T-988): per-axis
|
||||
/// token weight overrides (basis points) within that template's own filtered
|
||||
/// `visual_bundle`. Only axes/tokens the wiki authored a lean for are
|
||||
/// present — an axis with no entry (or a token missing from a present axis
|
||||
/// entry) uses the uniform baseline (`architecture_zone_bias.toml`'s header).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ZoneBias {
|
||||
pub wall: BTreeMap<WallMaterial, u32>,
|
||||
pub roof: BTreeMap<RoofForm, u32>,
|
||||
pub facade: BTreeMap<FacadeRhythm, u32>,
|
||||
pub street: BTreeMap<StreetSurface, u32>,
|
||||
}
|
||||
|
||||
/// One integer HSV sampling band for a `color_register` value (D-235,
|
||||
/// T-988). `hue` is centidegrees (0..36000); `sat`/`val` are basis points
|
||||
/// (0..10000) — see `color_register_bands.toml`'s header.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ColorBand {
|
||||
pub hue_min: u32,
|
||||
pub hue_max: u32,
|
||||
pub sat_min: u32,
|
||||
pub sat_max: u32,
|
||||
pub val_min: u32,
|
||||
pub val_max: u32,
|
||||
}
|
||||
|
||||
/// The full D-235 exterior-grammar content bundle (T-988): the template
|
||||
/// catalog plus the two sibling tables that bias and color-sample within it.
|
||||
/// Read once per body dispatch ([`TraitCatalogReader::read_exterior_catalog`])
|
||||
/// and threaded verbatim through every settlement's `GenerateSkeleton` work
|
||||
/// item — `assign_block_tags` (`atlas::skeleton_gen`) never touches
|
||||
/// `systems.db` (T-987/D-230 purity).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ExteriorCatalog {
|
||||
pub templates: Vec<TraitTemplate>,
|
||||
pub zone_bias: BTreeMap<(String, String), ZoneBias>,
|
||||
pub color_bands: BTreeMap<String, ColorBand>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,6 +188,12 @@ pub struct TraitCatalogReader {
|
||||
/// every later body dispatch clones the parsed rows. Per-body bias is NOT
|
||||
/// cached — it legitimately varies per body.
|
||||
catalog_cache: OnceLock<Vec<TraitTemplate>>,
|
||||
/// One-shot cache for `architecture_zone_bias` (T-988) — same immutable-
|
||||
/// per-run rationale as `catalog_cache`.
|
||||
zone_bias_cache: OnceLock<BTreeMap<(String, String), ZoneBias>>,
|
||||
/// One-shot cache for `color_register_bands` (T-988) — same immutable-
|
||||
/// per-run rationale as `catalog_cache`.
|
||||
color_bands_cache: OnceLock<BTreeMap<String, ColorBand>>,
|
||||
}
|
||||
|
||||
impl TraitCatalogReader {
|
||||
@@ -124,6 +204,8 @@ impl TraitCatalogReader {
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
catalog_cache: OnceLock::new(),
|
||||
zone_bias_cache: OnceLock::new(),
|
||||
color_bands_cache: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +232,7 @@ impl TraitCatalogReader {
|
||||
.prepare(
|
||||
"SELECT tag, corridor_pool, geographic_sector, bulk_class_gate,
|
||||
production_ubiquity_gate, min_prosperity_bps, base_weight,
|
||||
weight_mods, zone_affinity
|
||||
weight_mods, zone_affinity, visual_bundle
|
||||
FROM trait_templates
|
||||
ORDER BY tag",
|
||||
)
|
||||
@@ -167,6 +249,7 @@ impl TraitCatalogReader {
|
||||
row.get::<_, i64>(6)?,
|
||||
row.get::<_, Option<String>>(7)?,
|
||||
row.get::<_, Option<String>>(8)?,
|
||||
row.get::<_, Option<String>>(9)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
@@ -183,6 +266,7 @@ impl TraitCatalogReader {
|
||||
base_weight,
|
||||
weight_mods_json,
|
||||
zone_affinity_json,
|
||||
visual_bundle_json,
|
||||
) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
out.push(TraitTemplate {
|
||||
@@ -195,6 +279,7 @@ impl TraitCatalogReader {
|
||||
base_weight: base_weight.max(0) as u32,
|
||||
weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag),
|
||||
zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag),
|
||||
visual_bundle: parse_visual_bundle(visual_bundle_json.as_deref(), &tag),
|
||||
tag,
|
||||
corridor_pool,
|
||||
geographic_sector,
|
||||
@@ -249,6 +334,131 @@ impl TraitCatalogReader {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the D-235 step-2 zone-type bias table (T-988), keyed by
|
||||
/// `(template_tag, zone_type_id)`. Sparse by design (`architecture_zone_bias.toml`'s
|
||||
/// own header): a missing key means "uniform draw", not an error — see
|
||||
/// `atlas::trait_exterior`. Cached after the first successful read (H5,
|
||||
/// same rationale as [`Self::read_catalog`]).
|
||||
pub fn read_zone_bias(
|
||||
&self,
|
||||
) -> Result<BTreeMap<(String, String), ZoneBias>, TraitCatalogReadError> {
|
||||
if let Some(cached) = self.zone_bias_cache.get() {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let map = self.read_zone_bias_uncached()?;
|
||||
let _ = self.zone_bias_cache.set(map.clone());
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn read_zone_bias_uncached(
|
||||
&self,
|
||||
) -> Result<BTreeMap<(String, String), ZoneBias>, TraitCatalogReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT template_tag, zone_type_id, bias
|
||||
FROM architecture_zone_bias
|
||||
ORDER BY template_tag, zone_type_id",
|
||||
)
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for r in rows {
|
||||
let (template_tag, zone_type_id, bias_json) =
|
||||
r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let key_label = format!("{template_tag}.{zone_type_id}");
|
||||
let bias = parse_zone_bias(&bias_json, &key_label);
|
||||
out.insert((template_tag, zone_type_id), bias);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the D-235 color-register HSV bands (T-988), keyed by
|
||||
/// `color_register`. Cached after the first successful read (H5, same
|
||||
/// rationale as [`Self::read_catalog`]).
|
||||
pub fn read_color_register_bands(
|
||||
&self,
|
||||
) -> Result<BTreeMap<String, ColorBand>, TraitCatalogReadError> {
|
||||
if let Some(cached) = self.color_bands_cache.get() {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let map = self.read_color_register_bands_uncached()?;
|
||||
let _ = self.color_bands_cache.set(map.clone());
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn read_color_register_bands_uncached(
|
||||
&self,
|
||||
) -> Result<BTreeMap<String, ColorBand>, TraitCatalogReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max
|
||||
FROM color_register_bands
|
||||
ORDER BY color_register",
|
||||
)
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, i64>(4)?,
|
||||
row.get::<_, i64>(5)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for r in rows {
|
||||
let (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) =
|
||||
r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
out.insert(
|
||||
color_register,
|
||||
ColorBand {
|
||||
hue_min: hue_min.max(0) as u32,
|
||||
hue_max: hue_max.max(0) as u32,
|
||||
sat_min: sat_min.max(0) as u32,
|
||||
sat_max: sat_max.max(0) as u32,
|
||||
val_min: val_min.max(0) as u32,
|
||||
val_max: val_max.max(0) as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the full D-235 exterior-grammar catalog in one call (T-988):
|
||||
/// the template catalog plus its two sibling content tables. Convenience
|
||||
/// wrapper for callers (`atlas::plugin`'s L3→L4 dispatch) that need all
|
||||
/// three to build an [`ExteriorCatalog`] to thread through a settlement's
|
||||
/// `GenerateSkeleton` work item.
|
||||
pub fn read_exterior_catalog(&self) -> Result<ExteriorCatalog, TraitCatalogReadError> {
|
||||
Ok(ExteriorCatalog {
|
||||
templates: self.read_catalog()?,
|
||||
zone_bias: self.read_zone_bias()?,
|
||||
color_bands: self.read_color_register_bands()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -394,6 +604,204 @@ fn parse_zone_affinity(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D-235 ObjectTag axis parsing (T-988) — patch tolerance: an unrecognized
|
||||
// registry string never fails the read, it degrades to the axis `Generic`
|
||||
// variant with a `tracing::warn!` (matches the wider "unknown -> safe
|
||||
// fallback" convention already used above for bias_kind/BulkClass/etc., but
|
||||
// these four are the ONLY axes where the safe fallback is a *type-level*
|
||||
// enum variant rather than an omission from a Vec/map).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_wall_material(s: &str, tag: &str) -> WallMaterial {
|
||||
match s {
|
||||
"concrete_wall" => WallMaterial::ConcreteWall,
|
||||
"steel_frame" => WallMaterial::SteelFrame,
|
||||
"brick_wall" => WallMaterial::BrickWall,
|
||||
"rendered_wall" => WallMaterial::RenderedWall,
|
||||
"stone_wall" => WallMaterial::StoneWall,
|
||||
"timber_wall" => WallMaterial::TimberWall,
|
||||
"stucco_wall" => WallMaterial::StuccoWall,
|
||||
"glass_curtain_wall" => WallMaterial::GlassCurtainWall,
|
||||
"composite_panel" => WallMaterial::CompositePanel,
|
||||
"rammed_earth_wall" => WallMaterial::RammedEarthWall,
|
||||
"generic_wall" => WallMaterial::Generic,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
tag,
|
||||
axis = "wall",
|
||||
value = other,
|
||||
"unrecognized WallMaterial token — falling back to Generic"
|
||||
);
|
||||
WallMaterial::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_roof_form(s: &str, tag: &str) -> RoofForm {
|
||||
match s {
|
||||
"flat_roof" => RoofForm::FlatRoof,
|
||||
"pitched_roof" => RoofForm::PitchedRoof,
|
||||
"corrugated_roof" => RoofForm::CorrugatedRoof,
|
||||
"clay_tile_roof" => RoofForm::ClayTileRoof,
|
||||
"terraced_roof" => RoofForm::TerracedRoof,
|
||||
"vaulted_roof" => RoofForm::VaultedRoof,
|
||||
"green_roof" => RoofForm::GreenRoof,
|
||||
"generic_roof" => RoofForm::Generic,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
tag,
|
||||
axis = "roof",
|
||||
value = other,
|
||||
"unrecognized RoofForm token — falling back to Generic"
|
||||
);
|
||||
RoofForm::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_facade_rhythm(s: &str, tag: &str) -> FacadeRhythm {
|
||||
match s {
|
||||
"regular_facade" => FacadeRhythm::RegularFacade,
|
||||
"ornamental_facade" => FacadeRhythm::OrnamentalFacade,
|
||||
"industrial_glazing" => FacadeRhythm::IndustrialGlazing,
|
||||
"arcade_facade" => FacadeRhythm::ArcadeFacade,
|
||||
"shuttered_facade" => FacadeRhythm::ShutteredFacade,
|
||||
"screen_facade" => FacadeRhythm::ScreenFacade,
|
||||
"colonnade" => FacadeRhythm::Colonnade,
|
||||
"lattice_screen" => FacadeRhythm::LatticeScreen,
|
||||
"generic_facade" => FacadeRhythm::Generic,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
tag,
|
||||
axis = "facade",
|
||||
value = other,
|
||||
"unrecognized FacadeRhythm token — falling back to Generic"
|
||||
);
|
||||
FacadeRhythm::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_street_surface(s: &str, tag: &str) -> StreetSurface {
|
||||
match s {
|
||||
"paved" => StreetSurface::Paved,
|
||||
"cobble" => StreetSurface::Cobble,
|
||||
"packed_earth" => StreetSurface::PackedEarth,
|
||||
"canal_way" => StreetSurface::CanalWay,
|
||||
"elevated_walkway" => StreetSurface::ElevatedWalkway,
|
||||
"heavy_haul" => StreetSurface::HeavyHaul,
|
||||
"boardwalk" => StreetSurface::Boardwalk,
|
||||
"generic_street" => StreetSurface::Generic,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
tag,
|
||||
axis = "street",
|
||||
value = other,
|
||||
"unrecognized StreetSurface token — falling back to Generic"
|
||||
);
|
||||
StreetSurface::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw shape of the `visual_bundle` JSON column — deserialized once, then
|
||||
/// each axis's string tokens are mapped through the `parse_*` functions above.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RawVisualBundle {
|
||||
#[serde(default)]
|
||||
wall: Vec<String>,
|
||||
#[serde(default)]
|
||||
roof: Vec<String>,
|
||||
#[serde(default)]
|
||||
facade: Vec<String>,
|
||||
#[serde(default)]
|
||||
street: Vec<String>,
|
||||
color_register: Option<String>,
|
||||
#[serde(default)]
|
||||
fallback: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn parse_visual_bundle(json: Option<&str>, tag: &str) -> VisualBundle {
|
||||
let Some(json) = json else {
|
||||
return VisualBundle::default();
|
||||
};
|
||||
let raw: RawVisualBundle = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(tag, error = %e, "malformed visual_bundle JSON — treating as empty");
|
||||
return VisualBundle::default();
|
||||
}
|
||||
};
|
||||
VisualBundle {
|
||||
wall: raw
|
||||
.wall
|
||||
.iter()
|
||||
.map(|s| parse_wall_material(s, tag))
|
||||
.collect(),
|
||||
roof: raw.roof.iter().map(|s| parse_roof_form(s, tag)).collect(),
|
||||
facade: raw
|
||||
.facade
|
||||
.iter()
|
||||
.map(|s| parse_facade_rhythm(s, tag))
|
||||
.collect(),
|
||||
street: raw
|
||||
.street
|
||||
.iter()
|
||||
.map(|s| parse_street_surface(s, tag))
|
||||
.collect(),
|
||||
color_register: raw.color_register,
|
||||
fallback: raw.fallback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw shape of one `architecture_zone_bias.bias` JSON column — each axis is
|
||||
/// optional (a template/zone_type entry rarely biases every axis).
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct RawZoneBias {
|
||||
#[serde(default)]
|
||||
wall: BTreeMap<String, u32>,
|
||||
#[serde(default)]
|
||||
roof: BTreeMap<String, u32>,
|
||||
#[serde(default)]
|
||||
facade: BTreeMap<String, u32>,
|
||||
#[serde(default)]
|
||||
street: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// `label` is `"{template_tag}.{zone_type_id}"`, for warning context only.
|
||||
fn parse_zone_bias(json: &str, label: &str) -> ZoneBias {
|
||||
let raw: RawZoneBias = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(label, error = %e, "malformed architecture_zone_bias JSON — treating as empty");
|
||||
return ZoneBias::default();
|
||||
}
|
||||
};
|
||||
ZoneBias {
|
||||
wall: raw
|
||||
.wall
|
||||
.into_iter()
|
||||
.map(|(k, w)| (parse_wall_material(&k, label), w))
|
||||
.collect(),
|
||||
roof: raw
|
||||
.roof
|
||||
.into_iter()
|
||||
.map(|(k, w)| (parse_roof_form(&k, label), w))
|
||||
.collect(),
|
||||
facade: raw
|
||||
.facade
|
||||
.into_iter()
|
||||
.map(|(k, w)| (parse_facade_rhythm(&k, label), w))
|
||||
.collect(),
|
||||
street: raw
|
||||
.street
|
||||
.into_iter()
|
||||
.map(|(k, w)| (parse_street_surface(&k, label), w))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bevy resource wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,12 +874,13 @@ mod tests {
|
||||
"INSERT INTO trait_templates
|
||||
(tag, label, corridor_pool, geographic_sector, bulk_class_gate,
|
||||
production_ubiquity_gate, min_prosperity_bps, base_weight,
|
||||
weight_mods, zone_affinity)
|
||||
weight_mods, zone_affinity, visual_bundle)
|
||||
VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL,
|
||||
'[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]',
|
||||
0, 12000,
|
||||
'{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}',
|
||||
'{\"Industrial\":20000,\"LogisticsHub\":13000}')",
|
||||
'{\"Industrial\":20000,\"LogisticsHub\":13000}',
|
||||
'{\"wall\":[\"steel_frame\",\"concrete_wall\"],\"roof\":[\"corrugated_roof\"],\"facade\":[\"industrial_glazing\"],\"street\":[\"heavy_haul\"],\"color_register\":\"oxide_and_dust\",\"fallback\":{\"steel_frame\":\"generic_wall\"}}')",
|
||||
[],
|
||||
)
|
||||
.expect("insert extraction_camp");
|
||||
@@ -489,6 +898,39 @@ mod tests {
|
||||
)
|
||||
.expect("insert bias 2");
|
||||
|
||||
// T-988: the two sibling D-235 content tables.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE architecture_zone_bias (
|
||||
template_tag TEXT NOT NULL,
|
||||
zone_type_id TEXT NOT NULL,
|
||||
bias TEXT NOT NULL,
|
||||
PRIMARY KEY (template_tag, zone_type_id)
|
||||
);
|
||||
CREATE TABLE color_register_bands (
|
||||
color_register TEXT PRIMARY KEY,
|
||||
hue_min INTEGER NOT NULL,
|
||||
hue_max INTEGER NOT NULL,
|
||||
sat_min INTEGER NOT NULL,
|
||||
sat_max INTEGER NOT NULL,
|
||||
val_min INTEGER NOT NULL,
|
||||
val_max INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create D-235 content tables");
|
||||
conn.execute(
|
||||
"INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias)
|
||||
VALUES ('extraction_camp', 'extraction_platform', '{\"wall\":{\"steel_frame\":18000}}')",
|
||||
[],
|
||||
)
|
||||
.expect("insert zone bias");
|
||||
conn.execute(
|
||||
"INSERT INTO color_register_bands
|
||||
(color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max)
|
||||
VALUES ('oxide_and_dust', 2000, 3000, 1800, 3200, 3200, 4800)",
|
||||
[],
|
||||
)
|
||||
.expect("insert color band");
|
||||
|
||||
drop(conn);
|
||||
path
|
||||
}
|
||||
@@ -580,4 +1022,117 @@ mod tests {
|
||||
.expect("read bias");
|
||||
assert!(bias.is_empty());
|
||||
}
|
||||
|
||||
// ── D-235 visual_bundle parsing (T-988) ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_catalog_parses_visual_bundle() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let catalog = reader.read_catalog().expect("read catalog");
|
||||
let camp = catalog
|
||||
.iter()
|
||||
.find(|t| t.tag == "extraction_camp")
|
||||
.expect("extraction_camp present");
|
||||
assert_eq!(
|
||||
camp.visual_bundle.wall,
|
||||
vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall]
|
||||
);
|
||||
assert_eq!(camp.visual_bundle.roof, vec![RoofForm::CorrugatedRoof]);
|
||||
assert_eq!(
|
||||
camp.visual_bundle.facade,
|
||||
vec![FacadeRhythm::IndustrialGlazing]
|
||||
);
|
||||
assert_eq!(camp.visual_bundle.street, vec![StreetSurface::HeavyHaul]);
|
||||
assert_eq!(
|
||||
camp.visual_bundle.color_register.as_deref(),
|
||||
Some("oxide_and_dust")
|
||||
);
|
||||
assert_eq!(
|
||||
camp.visual_bundle.fallback.get("steel_frame"),
|
||||
Some(&"generic_wall".to_string())
|
||||
);
|
||||
|
||||
// generic_baseline has no visual_bundle column value in this fixture —
|
||||
// must degrade to an empty bundle, never panic.
|
||||
let baseline = catalog
|
||||
.iter()
|
||||
.find(|t| t.tag == "generic_baseline")
|
||||
.expect("generic_baseline present");
|
||||
assert_eq!(baseline.visual_bundle, VisualBundle::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wall_material_unknown_falls_back_to_generic() {
|
||||
assert_eq!(
|
||||
parse_wall_material("nonexistent_token", "test"),
|
||||
WallMaterial::Generic
|
||||
);
|
||||
assert_eq!(
|
||||
parse_roof_form("nonexistent_token", "test"),
|
||||
RoofForm::Generic
|
||||
);
|
||||
assert_eq!(
|
||||
parse_facade_rhythm("nonexistent_token", "test"),
|
||||
FacadeRhythm::Generic
|
||||
);
|
||||
assert_eq!(
|
||||
parse_street_surface("nonexistent_token", "test"),
|
||||
StreetSurface::Generic
|
||||
);
|
||||
}
|
||||
|
||||
// ── architecture_zone_bias / color_register_bands readers (T-988) ───────
|
||||
|
||||
#[test]
|
||||
fn read_zone_bias_parses_sparse_table() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let zone_bias = reader.read_zone_bias().expect("read zone bias");
|
||||
let key = (
|
||||
"extraction_camp".to_string(),
|
||||
"extraction_platform".to_string(),
|
||||
);
|
||||
let entry = zone_bias.get(&key).expect("entry present");
|
||||
assert_eq!(entry.wall.get(&WallMaterial::SteelFrame), Some(&18000));
|
||||
assert!(entry.roof.is_empty(), "unauthored axis stays empty");
|
||||
// A (template, zone_type) pair never authored is simply absent.
|
||||
assert!(!zone_bias.contains_key(&(
|
||||
"extraction_camp".to_string(),
|
||||
"residential_surface".to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_color_register_bands_parses_table() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let bands = reader
|
||||
.read_color_register_bands()
|
||||
.expect("read color bands");
|
||||
let band = bands.get("oxide_and_dust").expect("band present");
|
||||
assert_eq!(
|
||||
*band,
|
||||
ColorBand {
|
||||
hue_min: 2000,
|
||||
hue_max: 3000,
|
||||
sat_min: 1800,
|
||||
sat_max: 3200,
|
||||
val_min: 3200,
|
||||
val_max: 4800,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_exterior_catalog_bundles_all_three() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let bundle = reader
|
||||
.read_exterior_catalog()
|
||||
.expect("read exterior catalog");
|
||||
assert_eq!(bundle.templates.len(), 2);
|
||||
assert_eq!(bundle.zone_bias.len(), 1);
|
||||
assert_eq!(bundle.color_bands.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +460,7 @@ mod tests {
|
||||
base_weight,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: zone_affinity.iter().cloned().collect(),
|
||||
visual_bundle: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! D-235 building-exterior visual grammar — the `BuildingExteriorTag`
|
||||
//! derivation (T-988).
|
||||
//!
|
||||
//! **3-step derivation, era-free** (D-235 amendment / D-232 reframe — era is
|
||||
//! never a material gate, it reads as maintenance/wear via the D-217
|
||||
//! condition layer instead):
|
||||
//!
|
||||
//! 1. The resolved trait template's `visual_bundle` filters each axis
|
||||
//! (`wall`/`roof`/`facade`/`street`) to its own coherent token set — a
|
||||
//! template is drawn together, never mixed across axes (D-232).
|
||||
//! 2. The building's own `zone_type_id` biases the pick within that filtered
|
||||
//! set (`architecture_zone_bias.toml`, sparse — uniform where unauthored).
|
||||
//! 3. The block's `density_pct` sets `setback_tier` ([`derive_setback_tier`]).
|
||||
//!
|
||||
//! `color` is seed-picked uniformly within the template's `color_register`
|
||||
//! band (`color_register_bands.toml`), independently of the four token axes.
|
||||
//!
|
||||
//! Like [`crate::atlas::trait_swerve`] and [`crate::atlas::trait_draw`], this
|
||||
//! module is pure: the template/zone-bias/color-band lookups are resolved by
|
||||
//! the caller ([`crate::atlas::skeleton_gen::assign_block_tags`]) from data
|
||||
//! already threaded onto the work item (`ExteriorCatalog`,
|
||||
//! [`crate::atlas::trait_catalog_reader`]) — this module never touches
|
||||
//! `systems.db`, and the per-building roll happens at `GenerateSkeleton` plan
|
||||
//! time, never inside `FillChunk` (T-987 purity).
|
||||
//!
|
||||
//! All numbers are integer (D-010): weights are basis points, color
|
||||
//! components are centidegrees/basis-points, and every RNG draw is an
|
||||
//! independent [`SeedDomain::TraitExterior`] sub-chain per axis — sharing one
|
||||
//! chain across axes previously correlated unrelated fields elsewhere in this
|
||||
//! cascade (`skeleton_gen::assign_block_tags`'s own doc comment), so each of
|
||||
//! wall/roof/facade/street/hue/sat/val gets its own derive here too.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::atlas::trait_catalog_reader::{ColorBand, TraitTemplate, ZoneBias};
|
||||
use crate::seed::{AtlasRng, SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{
|
||||
BuildingExteriorTag, FacadeRhythm, HsvColor, RoofForm, SetbackTier, StreetSurface, WallMaterial,
|
||||
};
|
||||
|
||||
/// Neutral fallback color (mid-grey, zero saturation) — used when a template
|
||||
/// has no resolvable `color_register` (missing template, unauthored register,
|
||||
/// or a register absent from `color_register_bands` — a content gap the
|
||||
/// importer's V-TT-07 guards against for real content, but generation must
|
||||
/// still degrade gracefully rather than panic on a stale/foreign template).
|
||||
const NEUTRAL_COLOR: HsvColor = HsvColor {
|
||||
hue: 0,
|
||||
sat: 0,
|
||||
val: 5_000,
|
||||
};
|
||||
|
||||
/// Step 3 (D-235): setback tier from a block's build density.
|
||||
///
|
||||
/// The five-tier vocabulary (`zero_lot | tight | standard | generous |
|
||||
/// campus`) is fixed by D-235; these density-band **thresholds are
|
||||
/// calibration placeholders** (T-988) pending Nigel/Araminta tuning — same
|
||||
/// status as the swerve-rate constants (`trait_swerve`). Bands mirror the
|
||||
/// density tables already used elsewhere in this cascade
|
||||
/// (`skeleton_gen::floor_extent`, `roofed_coverage_pct`): Dense/Compressed
|
||||
/// blocks (high `density_pct`) read tight/zero-lot; Frontier blocks (low
|
||||
/// `density_pct`) read campus.
|
||||
pub fn derive_setback_tier(density_pct: u8) -> SetbackTier {
|
||||
match density_pct {
|
||||
0..=20 => SetbackTier::Campus,
|
||||
21..=45 => SetbackTier::Generous,
|
||||
46..=65 => SetbackTier::Standard,
|
||||
66..=85 => SetbackTier::Tight,
|
||||
_ => SetbackTier::ZeroLot,
|
||||
}
|
||||
}
|
||||
|
||||
/// Weighted pick of one token from `tokens` (a template's filtered
|
||||
/// `visual_bundle.<axis>` list), biased by `bias` (the zone-type's authored
|
||||
/// overrides for this axis, `None`/missing-token defaulting to the uniform
|
||||
/// 10 000 bps baseline per `architecture_zone_bias.toml`'s own model).
|
||||
///
|
||||
/// `None` only when `tokens` is empty (a template with zero tokens on this
|
||||
/// axis — not expected from a well-formed catalog, but the caller must not
|
||||
/// panic on a content gap or an unresolved template).
|
||||
fn weighted_axis_pick<T: Copy + Ord>(
|
||||
tokens: &[T],
|
||||
bias: Option<&BTreeMap<T, u32>>,
|
||||
rng: &mut AtlasRng,
|
||||
) -> Option<T> {
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let weight_of =
|
||||
|t: &T| -> u64 { bias.and_then(|b| b.get(t)).copied().unwrap_or(10_000) as u64 };
|
||||
let total: u64 = tokens.iter().map(weight_of).sum();
|
||||
let mut roll = (rng.next_u32() as u64) % total.max(1);
|
||||
for t in tokens {
|
||||
let w = weight_of(t);
|
||||
if roll < w {
|
||||
return Some(*t);
|
||||
}
|
||||
roll -= w;
|
||||
}
|
||||
tokens.last().copied()
|
||||
}
|
||||
|
||||
/// Uniform integer sample within the inclusive range `[lo, hi]` (D-010
|
||||
/// integer-only). `lo` if the range is degenerate (`hi <= lo`).
|
||||
fn sample_in_range(lo: u32, hi: u32, rng: &mut AtlasRng) -> u32 {
|
||||
if hi <= lo {
|
||||
return lo;
|
||||
}
|
||||
let span = (hi - lo) as u64 + 1;
|
||||
lo + (rng.next_u32() as u64 % span) as u32
|
||||
}
|
||||
|
||||
/// Color sub-step: seed-pick a single (hue, sat, val) point uniformly within
|
||||
/// `color_register`'s band (D-235 — "always within the template's register").
|
||||
/// Falls back to [`NEUTRAL_COLOR`] when the template has no register, or the
|
||||
/// register isn't in `color_bands` (with a warning in the latter case — a
|
||||
/// genuine content gap the importer's V-TT-07 guards against).
|
||||
fn sample_color(
|
||||
color_register: Option<&str>,
|
||||
color_bands: &BTreeMap<String, ColorBand>,
|
||||
chain: SeedChain,
|
||||
) -> HsvColor {
|
||||
let Some(register) = color_register else {
|
||||
return NEUTRAL_COLOR;
|
||||
};
|
||||
let Some(band) = color_bands.get(register) else {
|
||||
tracing::warn!(
|
||||
register,
|
||||
"color_register not found in color_register_bands — falling back to neutral"
|
||||
);
|
||||
return NEUTRAL_COLOR;
|
||||
};
|
||||
let mut hue_rng = chain.derive(SeedDomain::TraitExterior, 4).atlas_rng();
|
||||
let mut sat_rng = chain.derive(SeedDomain::TraitExterior, 5).atlas_rng();
|
||||
let mut val_rng = chain.derive(SeedDomain::TraitExterior, 6).atlas_rng();
|
||||
HsvColor {
|
||||
hue: sample_in_range(band.hue_min, band.hue_max, &mut hue_rng),
|
||||
sat: sample_in_range(band.sat_min, band.sat_max, &mut sat_rng),
|
||||
val: sample_in_range(band.val_min, band.val_max, &mut val_rng),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the frozen [`BuildingExteriorTag`] for one building (T-988).
|
||||
///
|
||||
/// `template` is the building's resolved trait template — `None` when the
|
||||
/// `flavor_ref` couldn't be resolved against the catalog (empty catalog,
|
||||
/// out-of-bounds `InVocabulary` index, or a `Swerve` tag absent from the
|
||||
/// catalog): every token axis degrades to `Generic`, color to
|
||||
/// [`NEUTRAL_COLOR`], and `setback_tier` is still computed (it only needs
|
||||
/// `density_pct`, not the template). `zone_bias` is the pre-looked-up
|
||||
/// `(template_tag, zone_type_id)` entry from `ExteriorCatalog.zone_bias` —
|
||||
/// `None` for the overwhelming majority of pairs (the table is sparse by
|
||||
/// design), which is identical to an entry with no override for any token.
|
||||
///
|
||||
/// `chain` should be a per-footprint sub-chain (mirroring the `fp_chain` used
|
||||
/// for `flavor_ref`/`zone_type_id`/era/extent in `assign_block_tags`) — this
|
||||
/// function derives its own `SeedDomain::TraitExterior` streams, one per axis,
|
||||
/// from it.
|
||||
pub fn derive_building_exterior(
|
||||
template: Option<&TraitTemplate>,
|
||||
zone_bias: Option<&ZoneBias>,
|
||||
color_bands: &BTreeMap<String, ColorBand>,
|
||||
density_pct: u8,
|
||||
chain: SeedChain,
|
||||
) -> BuildingExteriorTag {
|
||||
let setback_tier = derive_setback_tier(density_pct);
|
||||
|
||||
let Some(template) = template else {
|
||||
tracing::debug!(
|
||||
"no resolved trait template for this building — exterior falls back to Generic/neutral"
|
||||
);
|
||||
return BuildingExteriorTag {
|
||||
wall_material: WallMaterial::default(),
|
||||
roof_form: RoofForm::default(),
|
||||
facade_rhythm: FacadeRhythm::default(),
|
||||
setback_tier,
|
||||
color: NEUTRAL_COLOR,
|
||||
street_surface: StreetSurface::default(),
|
||||
};
|
||||
};
|
||||
let vb = &template.visual_bundle;
|
||||
|
||||
let mut wall_rng = chain.derive(SeedDomain::TraitExterior, 0).atlas_rng();
|
||||
let wall_material =
|
||||
weighted_axis_pick(&vb.wall, zone_bias.map(|z| &z.wall), &mut wall_rng).unwrap_or_default();
|
||||
|
||||
let mut roof_rng = chain.derive(SeedDomain::TraitExterior, 1).atlas_rng();
|
||||
let roof_form =
|
||||
weighted_axis_pick(&vb.roof, zone_bias.map(|z| &z.roof), &mut roof_rng).unwrap_or_default();
|
||||
|
||||
let mut facade_rng = chain.derive(SeedDomain::TraitExterior, 2).atlas_rng();
|
||||
let facade_rhythm =
|
||||
weighted_axis_pick(&vb.facade, zone_bias.map(|z| &z.facade), &mut facade_rng)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut street_rng = chain.derive(SeedDomain::TraitExterior, 3).atlas_rng();
|
||||
let street_surface =
|
||||
weighted_axis_pick(&vb.street, zone_bias.map(|z| &z.street), &mut street_rng)
|
||||
.unwrap_or_default();
|
||||
|
||||
let color = sample_color(vb.color_register.as_deref(), color_bands, chain);
|
||||
|
||||
BuildingExteriorTag {
|
||||
wall_material,
|
||||
roof_form,
|
||||
facade_rhythm,
|
||||
setback_tier,
|
||||
color,
|
||||
street_surface,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn template_with_bundle(tag: &str, color_register: Option<&str>) -> TraitTemplate {
|
||||
use crate::atlas::trait_catalog_reader::VisualBundle;
|
||||
TraitTemplate {
|
||||
tag: tag.to_string(),
|
||||
corridor_pool: "cross_corridor".to_string(),
|
||||
geographic_sector: None,
|
||||
bulk_class_gate: Vec::new(),
|
||||
production_ubiquity_gate: Vec::new(),
|
||||
min_prosperity_bps: 0,
|
||||
base_weight: 10_000,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: BTreeMap::new(),
|
||||
visual_bundle: VisualBundle {
|
||||
wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall],
|
||||
roof: vec![RoofForm::CorrugatedRoof],
|
||||
facade: vec![FacadeRhythm::IndustrialGlazing, FacadeRhythm::RegularFacade],
|
||||
street: vec![StreetSurface::HeavyHaul, StreetSurface::Paved],
|
||||
color_register: color_register.map(str::to_string),
|
||||
fallback: BTreeMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn oxide_band() -> BTreeMap<String, ColorBand> {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert(
|
||||
"oxide_and_dust".to_string(),
|
||||
ColorBand {
|
||||
hue_min: 2000,
|
||||
hue_max: 3000,
|
||||
sat_min: 1800,
|
||||
sat_max: 3200,
|
||||
val_min: 3200,
|
||||
val_max: 4800,
|
||||
},
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
// ── derive_setback_tier ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn setback_tier_bands_match_density() {
|
||||
assert_eq!(derive_setback_tier(10), SetbackTier::Campus);
|
||||
assert_eq!(derive_setback_tier(30), SetbackTier::Generous);
|
||||
assert_eq!(derive_setback_tier(50), SetbackTier::Standard);
|
||||
assert_eq!(derive_setback_tier(75), SetbackTier::Tight);
|
||||
assert_eq!(derive_setback_tier(95), SetbackTier::ZeroLot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setback_tier_dense_to_frontier_orders_tight_to_campus() {
|
||||
// D-235: "density sets setback_tier (Dense -> zero_lot -> ... ->
|
||||
// Frontier -> campus)".
|
||||
assert!(derive_setback_tier(95) < derive_setback_tier(10));
|
||||
}
|
||||
|
||||
// ── derive_building_exterior: template present ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolved_template_picks_only_from_its_own_visual_bundle() {
|
||||
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
|
||||
let bands = oxide_band();
|
||||
for seed in 0..50u64 {
|
||||
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
|
||||
assert!(matches!(
|
||||
tag.wall_material,
|
||||
WallMaterial::SteelFrame | WallMaterial::ConcreteWall
|
||||
));
|
||||
assert_eq!(tag.roof_form, RoofForm::CorrugatedRoof);
|
||||
assert!(matches!(
|
||||
tag.facade_rhythm,
|
||||
FacadeRhythm::IndustrialGlazing | FacadeRhythm::RegularFacade
|
||||
));
|
||||
assert!(matches!(
|
||||
tag.street_surface,
|
||||
StreetSurface::HeavyHaul | StreetSurface::Paved
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_samples_within_the_registers_band() {
|
||||
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
|
||||
let bands = oxide_band();
|
||||
for seed in 0..50u64 {
|
||||
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
|
||||
assert!((2000..=3000).contains(&tag.color.hue));
|
||||
assert!((1800..=3200).contains(&tag.color.sat));
|
||||
assert!((3200..=4800).contains(&tag.color.val));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_color_register_falls_back_to_neutral() {
|
||||
let t = template_with_bundle("no_register", None);
|
||||
let tag =
|
||||
derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1));
|
||||
assert_eq!(tag.color, NEUTRAL_COLOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbanded_color_register_falls_back_to_neutral() {
|
||||
// color_register is set but color_bands is empty (content gap) — must
|
||||
// degrade, not panic.
|
||||
let t = template_with_bundle("orphan", Some("nonexistent_register"));
|
||||
let tag =
|
||||
derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1));
|
||||
assert_eq!(tag.color, NEUTRAL_COLOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_template_falls_back_to_generic_everywhere_but_still_computes_setback() {
|
||||
let tag = derive_building_exterior(None, None, &BTreeMap::new(), 90, SeedChain::root(1));
|
||||
assert_eq!(tag.wall_material, WallMaterial::Generic);
|
||||
assert_eq!(tag.roof_form, RoofForm::Generic);
|
||||
assert_eq!(tag.facade_rhythm, FacadeRhythm::Generic);
|
||||
assert_eq!(tag.street_surface, StreetSurface::Generic);
|
||||
assert_eq!(tag.color, NEUTRAL_COLOR);
|
||||
// setback_tier only needs density_pct, not the template.
|
||||
assert_eq!(tag.setback_tier, SetbackTier::ZeroLot);
|
||||
}
|
||||
|
||||
// ── zone bias skews the draw ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn zone_bias_skews_the_weighted_pick() {
|
||||
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
|
||||
let bands = oxide_band();
|
||||
let mut bias = ZoneBias::default();
|
||||
// Overwhelming weight toward SteelFrame (vs. ConcreteWall's uniform
|
||||
// 10_000 baseline) — the draw must skew heavily toward it.
|
||||
bias.wall.insert(WallMaterial::SteelFrame, 200_000);
|
||||
|
||||
let mut steel = 0;
|
||||
let mut concrete = 0;
|
||||
for seed in 0..200u64 {
|
||||
let tag =
|
||||
derive_building_exterior(Some(&t), Some(&bias), &bands, 70, SeedChain::root(seed));
|
||||
match tag.wall_material {
|
||||
WallMaterial::SteelFrame => steel += 1,
|
||||
WallMaterial::ConcreteWall => concrete += 1,
|
||||
other => panic!("unexpected wall material {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
steel > concrete * 5,
|
||||
"expected steel_frame to dominate under a 200_000 bps bias, got steel={steel} concrete={concrete}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_zone_bias_is_uniform_across_a_large_sample() {
|
||||
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
|
||||
let bands = oxide_band();
|
||||
let mut steel = 0;
|
||||
let mut concrete = 0;
|
||||
for seed in 0..400u64 {
|
||||
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
|
||||
match tag.wall_material {
|
||||
WallMaterial::SteelFrame => steel += 1,
|
||||
WallMaterial::ConcreteWall => concrete += 1,
|
||||
other => panic!("unexpected wall material {other:?}"),
|
||||
}
|
||||
}
|
||||
// Roughly even split expected (generous band — this is a uniformity
|
||||
// sanity check, not a distribution test).
|
||||
assert!(
|
||||
(steel as i64 - concrete as i64).abs() < 120,
|
||||
"expected roughly even split without bias, got steel={steel} concrete={concrete}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── determinism + independence ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn same_chain_same_inputs_is_deterministic() {
|
||||
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
|
||||
let bands = oxide_band();
|
||||
let a = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99));
|
||||
let b = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axes_draw_independent_entropy_not_a_shared_roll() {
|
||||
// Regression guard for the "one shared chain correlates every field"
|
||||
// bug class already fixed once in `assign_block_tags` — wall/roof/
|
||||
// facade/street must vary independently across seeds, not lock-step.
|
||||
let t = TraitTemplate {
|
||||
visual_bundle: crate::atlas::trait_catalog_reader::VisualBundle {
|
||||
wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall],
|
||||
roof: vec![RoofForm::FlatRoof, RoofForm::PitchedRoof],
|
||||
facade: vec![FacadeRhythm::RegularFacade, FacadeRhythm::Colonnade],
|
||||
street: vec![StreetSurface::Paved, StreetSurface::Cobble],
|
||||
color_register: None,
|
||||
fallback: BTreeMap::new(),
|
||||
},
|
||||
..template_with_bundle("mixed", None)
|
||||
};
|
||||
let mut saw_wall_a_roof_b = false;
|
||||
let mut saw_wall_a_roof_a = false;
|
||||
for seed in 0..100u64 {
|
||||
let tag = derive_building_exterior(
|
||||
Some(&t),
|
||||
None,
|
||||
&BTreeMap::new(),
|
||||
50,
|
||||
SeedChain::root(seed),
|
||||
);
|
||||
if tag.wall_material == WallMaterial::SteelFrame {
|
||||
if tag.roof_form == RoofForm::PitchedRoof {
|
||||
saw_wall_a_roof_b = true;
|
||||
} else {
|
||||
saw_wall_a_roof_a = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_wall_a_roof_b && saw_wall_a_roof_a,
|
||||
"wall and roof picks must vary independently, not track each other"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,7 @@ mod tests {
|
||||
base_weight,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: BTreeMap::new(),
|
||||
visual_bundle: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,13 @@ pub enum SeedDomain {
|
||||
/// domain so the rare-wildcard roll can never correlate with the zone/era/
|
||||
/// extent draws sharing that chain.
|
||||
TraitSwerve = 15,
|
||||
/// Per-building D-235 exterior-grammar draw (`BuildingExteriorTag`, T-988):
|
||||
/// wall/roof/facade/street token picks + color HSV sample. Derived off the
|
||||
/// footprint's own chain (like `TraitSwerve`), keyed per axis (0=wall,
|
||||
/// 1=roof, 2=facade, 3=street, 4=hue, 5=sat, 6=val) so each axis draws
|
||||
/// independent entropy — the `assign_block_tags` lesson (distinct
|
||||
/// sub-chains per field, not one shared roll) applies here too.
|
||||
TraitExterior = 16,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -303,6 +310,7 @@ mod tests {
|
||||
assert_eq!(SeedDomain::TraitVocabulary as u64, 13);
|
||||
assert_eq!(SeedDomain::TraitDistrict as u64, 14);
|
||||
assert_eq!(SeedDomain::TraitSwerve as u64, 15);
|
||||
assert_eq!(SeedDomain::TraitExterior as u64, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -850,6 +850,172 @@ pub enum ArchitectureFlavorRef {
|
||||
Swerve(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D-235 building exterior visual grammar (T-988)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The vocabulary below is the RATIFIED ObjectTag palette (T-995, resolving
|
||||
// Q-049) — `wiki/economics/object_tag_vocabulary.toml` — not the illustrative
|
||||
// example lists in D-235's original body text (superseded by the 2026-07-07
|
||||
// amendment). Each axis carries its specific tokens plus one `Generic`
|
||||
// fallback-terminal placeholder (the D-235 asset-resolution degrade target).
|
||||
//
|
||||
// Integer-discriminant, append-only (D-010) — these values round-trip through
|
||||
// `systems.db`-backed derivation today and are candidates for savegame
|
||||
// persistence in Phase 5+ (D-227), so historical discriminants must never be
|
||||
// renumbered, only appended to. Unknown registry strings parse to the axis's
|
||||
// `Generic` variant with a `tracing::warn!` (patch tolerance) — see
|
||||
// `atlas::trait_catalog_reader`.
|
||||
|
||||
/// Wall material token (D-235 `wall` axis, 10 specific + `Generic`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum WallMaterial {
|
||||
ConcreteWall = 0,
|
||||
SteelFrame = 1,
|
||||
BrickWall = 2,
|
||||
RenderedWall = 3,
|
||||
StoneWall = 4,
|
||||
TimberWall = 5,
|
||||
StuccoWall = 6,
|
||||
GlassCurtainWall = 7,
|
||||
CompositePanel = 8,
|
||||
RammedEarthWall = 9,
|
||||
/// Fallback-terminal placeholder (`generic_wall`) — renders until the
|
||||
/// specific asset ships (D-235), and the "unrecognized token" parse target.
|
||||
#[default]
|
||||
Generic = 10,
|
||||
}
|
||||
|
||||
/// Roof form token (D-235 `roof` axis, 7 specific + `Generic`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum RoofForm {
|
||||
FlatRoof = 0,
|
||||
PitchedRoof = 1,
|
||||
CorrugatedRoof = 2,
|
||||
ClayTileRoof = 3,
|
||||
TerracedRoof = 4,
|
||||
VaultedRoof = 5,
|
||||
GreenRoof = 6,
|
||||
/// Fallback-terminal placeholder (`generic_roof`) — see [`WallMaterial::Generic`].
|
||||
#[default]
|
||||
Generic = 7,
|
||||
}
|
||||
|
||||
/// Facade rhythm token (D-235 `facade` axis, 8 specific + `Generic`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum FacadeRhythm {
|
||||
RegularFacade = 0,
|
||||
OrnamentalFacade = 1,
|
||||
IndustrialGlazing = 2,
|
||||
ArcadeFacade = 3,
|
||||
ShutteredFacade = 4,
|
||||
ScreenFacade = 5,
|
||||
Colonnade = 6,
|
||||
LatticeScreen = 7,
|
||||
/// Fallback-terminal placeholder (`generic_facade`) — see [`WallMaterial::Generic`].
|
||||
#[default]
|
||||
Generic = 8,
|
||||
}
|
||||
|
||||
/// Street surface token (D-235 `street` axis, 7 specific + `Generic`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum StreetSurface {
|
||||
Paved = 0,
|
||||
Cobble = 1,
|
||||
PackedEarth = 2,
|
||||
CanalWay = 3,
|
||||
ElevatedWalkway = 4,
|
||||
HeavyHaul = 5,
|
||||
Boardwalk = 6,
|
||||
/// Fallback-terminal placeholder (`generic_street`) — see [`WallMaterial::Generic`].
|
||||
#[default]
|
||||
Generic = 7,
|
||||
}
|
||||
|
||||
/// Building setback tier (D-235 step 3): derived from `BlockSkeleton.density_pct`
|
||||
/// bands — Dense blocks read `ZeroLot`, Frontier blocks read `Campus`. Drives
|
||||
/// the interstitial-space character (`void`/`court`/`garden`/`plaza`/
|
||||
/// `dock_slip`/`market_pad`/`open_lawn`, D-235) a future fill pass reads.
|
||||
///
|
||||
/// The five-tier vocabulary is fixed by D-235 (`zero_lot | tight | standard |
|
||||
/// generous | campus`); the density-band **thresholds** that map onto it are
|
||||
/// **calibration placeholders** (T-988) pending Nigel/Araminta tuning, same
|
||||
/// status as the swerve-rate constants (`trait_swerve`). Declaration order is
|
||||
/// the tier's natural density ordering (tightest → loosest), so `Ord`
|
||||
/// comparisons read correctly; not currently used as a save-critical
|
||||
/// discriminant, so it is not `#[repr(u8)]`-pinned like the axis tokens above.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum SetbackTier {
|
||||
/// Buildings meet the lot line — no yard, shared walls common.
|
||||
ZeroLot,
|
||||
Tight,
|
||||
Standard,
|
||||
Generous,
|
||||
/// Buildings sit well back within generous open grounds.
|
||||
Campus,
|
||||
}
|
||||
|
||||
/// A single sampled point within a D-235 `color_register` HSV band (T-988).
|
||||
///
|
||||
/// Integer-only (D-010, save-critical under D-227): `hue` is centidegrees
|
||||
/// (degrees × 100, 0..36000); `sat`/`val` are basis points (0..10000). Sampled
|
||||
/// once per building at plan time and frozen — never re-derived, no drift.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HsvColor {
|
||||
pub hue: u32,
|
||||
pub sat: u32,
|
||||
pub val: u32,
|
||||
}
|
||||
|
||||
/// The D-235 visible-form layer over a building's tags (T-988) — the frozen
|
||||
/// output of the 3-step derivation: **(1)** the resolved trait template's
|
||||
/// `visual_bundle` filters each axis's available token set; **(2)** the
|
||||
/// building's `zone_type_id` biases the pick within that filtered set
|
||||
/// (`architecture_zone_bias.toml`, uniform where unauthored); **(3)** the
|
||||
/// block's `density_pct` sets `setback_tier`. `color` is seed-picked uniformly
|
||||
/// within the template's `color_register` band.
|
||||
///
|
||||
/// **Era is deliberately absent** (D-232 reframe, D-235 amendment): the Reach
|
||||
/// has no material-technology ladder, so era is never a material gate — it
|
||||
/// reads as maintenance/wear via the D-217 condition layer instead.
|
||||
///
|
||||
/// Resolved once inside `GenerateSkeleton` (the same plan-time phase as
|
||||
/// `flavor_ref`/`zone_type_id`) and stored frozen on `BuildingPropertyTag`;
|
||||
/// `FillChunk` only reads it (T-987 purity) — see `atlas::trait_exterior`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BuildingExteriorTag {
|
||||
pub wall_material: WallMaterial,
|
||||
pub roof_form: RoofForm,
|
||||
pub facade_rhythm: FacadeRhythm,
|
||||
pub setback_tier: SetbackTier,
|
||||
pub color: HsvColor,
|
||||
pub street_surface: StreetSurface,
|
||||
}
|
||||
|
||||
/// Character of a block's non-roofed remainder — the space between building
|
||||
/// footprints (D-233, T-1097).
|
||||
///
|
||||
/// Bulk-industry blocks (`BulkClass::BulkSolid`/`BulkLiquid`) read this space
|
||||
/// as **built economic infrastructure** (haul roads, ore pads, conveyor runs,
|
||||
/// tank berms) — functional ground the settlement's economy depends on, never
|
||||
/// generic open space. Every other `BulkClass` keeps the pre-T-1097 default:
|
||||
/// informal interstitial space (yards, parks, lots) whose specific character
|
||||
/// (`void`/`court`/`garden`/`plaza`/…) is the separate `SetbackTier`-driven
|
||||
/// axis (D-235, T-988) — the two systems are distinct derivation axes sharing
|
||||
/// the same physical space, not alternatives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InterstitialCharacter {
|
||||
/// Generic open space — yards, parks, informal lots.
|
||||
#[default]
|
||||
OpenSpace,
|
||||
/// Built economic infrastructure (D-233) — never open space.
|
||||
OperationsSurface,
|
||||
}
|
||||
|
||||
/// A single building footprint tag — the frozen step-3 output placed on every
|
||||
/// building footprint at plan time (D-229).
|
||||
///
|
||||
@@ -874,6 +1040,9 @@ pub struct BuildingPropertyTag {
|
||||
/// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217).
|
||||
/// The rolling condition overlay (D-198) paints over this; never mutates the tag.
|
||||
pub initial_condition: crate::atlas::tile_condition::TileCondition,
|
||||
/// D-235 visible-form layer (T-988): wall/roof/facade/street materials,
|
||||
/// setback tier, and color — resolved once here, frozen thereafter.
|
||||
pub exterior: BuildingExteriorTag,
|
||||
/// Doors into / out of this building (D-231). At least one `Main` door.
|
||||
///
|
||||
/// Using `Vec<DoorSpec>` rather than `SmallVec<[DoorSpec; 4]>` for now;
|
||||
@@ -1232,6 +1401,11 @@ pub struct BlockSkeleton {
|
||||
/// Integer to avoid f32 non-determinism (D-010).
|
||||
pub density_pct: u8,
|
||||
pub landmark: Option<LandmarkSlot>,
|
||||
/// Character of this block's non-roofed remainder (D-233, T-1097):
|
||||
/// `OperationsSurface` for bulk-industry blocks, `OpenSpace` otherwise.
|
||||
/// Derived once from `CityGenerationContext.dominant_bulk_class` at the
|
||||
/// same plan-time pass as the rest of this skeleton.
|
||||
pub interstitial_character: InterstitialCharacter,
|
||||
}
|
||||
|
||||
/// Floor zone within a multi-level reservation.
|
||||
|
||||
@@ -255,6 +255,26 @@ CREATE TABLE IF NOT EXISTS atlas_body_trait_bias (
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag);
|
||||
|
||||
-- D-235 exterior-grammar content (T-988). Mirrors the canonical DDL in
|
||||
-- systems-schema.sql; here so the migration path (existing DBs) gets the
|
||||
-- tables, not just fresh systems-schema.sql builds.
|
||||
CREATE TABLE IF NOT EXISTS architecture_zone_bias (
|
||||
template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE,
|
||||
zone_type_id TEXT NOT NULL,
|
||||
bias TEXT NOT NULL,
|
||||
PRIMARY KEY (template_tag, zone_type_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag);
|
||||
CREATE TABLE IF NOT EXISTS color_register_bands (
|
||||
color_register TEXT PRIMARY KEY,
|
||||
hue_min INTEGER NOT NULL,
|
||||
hue_max INTEGER NOT NULL,
|
||||
sat_min INTEGER NOT NULL,
|
||||
sat_max INTEGER NOT NULL,
|
||||
val_min INTEGER NOT NULL,
|
||||
val_max INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
|
||||
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
|
||||
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
|
||||
|
||||
@@ -11,6 +11,8 @@ from pathlib import Path
|
||||
from generator_sources import (
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
ARCHITECTURE_ZONE_BIAS_TOML,
|
||||
COLOR_REGISTER_BANDS_TOML,
|
||||
GENERATE_BRANDS_WRAPPER,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
REPO_ROOT,
|
||||
@@ -21,8 +23,10 @@ from generator_sources import (
|
||||
__all__ = [
|
||||
"ARCHITECTURE_TRAIT_BIAS_TOML",
|
||||
"ARCHITECTURE_TRAIT_CATALOG_TOML",
|
||||
"ARCHITECTURE_ZONE_BIAS_TOML",
|
||||
"BRANDS_TOML",
|
||||
"CHAINS_TOML",
|
||||
"COLOR_REGISTER_BANDS_TOML",
|
||||
"COMMODITIES_TOML",
|
||||
"CORPORATIONS_DIR",
|
||||
"CURRENCY_ZONES_TOML",
|
||||
|
||||
@@ -8,6 +8,8 @@ from .errors import ImportAborted
|
||||
from .paths import (
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
ARCHITECTURE_ZONE_BIAS_TOML,
|
||||
COLOR_REGISTER_BANDS_TOML,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
)
|
||||
|
||||
@@ -250,6 +252,202 @@ def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_architecture_zone_bias(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Bake the D-235 step-2 zone-type bias table (T-988, resolves the ticket's
|
||||
"REAL authored table" requirement).
|
||||
|
||||
Reads ARCHITECTURE_ZONE_BIAS_TOML (`[bias.<template_tag>.<zone_type_id>]`
|
||||
stanzas; each is an optional `{axis: {token: weight_bps}}` sub-table per
|
||||
axis — wall/roof/facade/street). Sparse/fallback model: a (template,
|
||||
zone_type) pair absent here — or a token absent within a listed entry —
|
||||
uses a uniform draw across the template's own `visual_bundle.<axis>` (the
|
||||
generator's job, not this baker's).
|
||||
|
||||
V-TT-06: every referenced token must already appear in THAT template's own
|
||||
`visual_bundle.<axis>` (checked against the just-baked `trait_templates`
|
||||
row, not the catalog TOML in memory, so the check can never silently pass
|
||||
against a stale in-process parse) — this file never expands a template's
|
||||
palette, only re-weights within it. Must run AFTER populate_trait_templates
|
||||
(FK + visual_bundle lookup). Absent source -> 0 rows.
|
||||
"""
|
||||
if not ARCHITECTURE_ZONE_BIAS_TOML.exists():
|
||||
if not dry_run:
|
||||
conn.execute("DELETE FROM architecture_zone_bias")
|
||||
return 0
|
||||
with open(ARCHITECTURE_ZONE_BIAS_TOML, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
bias_root = data.get("bias", {})
|
||||
|
||||
known_tags: set[str] = set()
|
||||
template_axis_tokens: dict[str, dict[str, set[str]]] = {}
|
||||
for tag, vb_json in conn.execute("SELECT tag, visual_bundle FROM trait_templates"):
|
||||
known_tags.add(tag)
|
||||
vb = json.loads(vb_json) if vb_json else {}
|
||||
template_axis_tokens[tag] = {axis: set(vb.get(axis) or []) for axis in _TAG_AXES}
|
||||
|
||||
errors: list[str] = []
|
||||
rows: list[tuple] = []
|
||||
for template_tag, zone_map in bias_root.items():
|
||||
if template_tag not in known_tags:
|
||||
errors.append(f"architecture_zone_bias '{template_tag}': not in trait_templates")
|
||||
continue
|
||||
if not isinstance(zone_map, dict):
|
||||
errors.append(
|
||||
f"architecture_zone_bias '{template_tag}': expected a table of zone_type_id entries"
|
||||
)
|
||||
continue
|
||||
for zone_type_id, axes in zone_map.items():
|
||||
if not isinstance(axes, dict):
|
||||
errors.append(
|
||||
f"architecture_zone_bias '{template_tag}.{zone_type_id}': expected a table of axis entries"
|
||||
)
|
||||
continue
|
||||
for axis, weights in axes.items():
|
||||
if axis not in _TAG_AXES:
|
||||
errors.append(
|
||||
f"architecture_zone_bias '{template_tag}.{zone_type_id}': axis '{axis}' "
|
||||
f"not one of {_TAG_AXES}"
|
||||
)
|
||||
continue
|
||||
if not isinstance(weights, dict):
|
||||
errors.append(
|
||||
f"V-TT-06: architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}': "
|
||||
f"each axis value must be a {{token = weight_bps}} table, got "
|
||||
f"{type(weights).__name__}"
|
||||
)
|
||||
continue
|
||||
allowed = template_axis_tokens.get(template_tag, {}).get(axis, set())
|
||||
for token, weight in weights.items():
|
||||
if token not in allowed:
|
||||
errors.append(
|
||||
f"V-TT-06: architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}': "
|
||||
f"token '{token}' not in this template's visual_bundle.{axis} {sorted(allowed)}"
|
||||
)
|
||||
# bool is an int subclass — exclude it explicitly, matching
|
||||
# populate_color_register_bands' guard on its own values.
|
||||
if isinstance(weight, bool) or not isinstance(weight, int) or weight <= 0:
|
||||
errors.append(
|
||||
f"architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}.{token}': "
|
||||
f"weight_bps must be a positive integer, got {weight!r}"
|
||||
)
|
||||
try:
|
||||
bias_json = json.dumps(axes)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(
|
||||
f"architecture_zone_bias '{template_tag}.{zone_type_id}': not JSON-serialisable"
|
||||
)
|
||||
continue
|
||||
rows.append((template_tag, zone_type_id, bias_json))
|
||||
|
||||
if errors:
|
||||
print(f" ARCHITECTURE ZONE BIAS ERRORS ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
raise ImportAborted()
|
||||
|
||||
if not dry_run:
|
||||
conn.execute("DELETE FROM architecture_zone_bias")
|
||||
conn.executemany(
|
||||
"INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias) VALUES (?,?,?)",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
# D-235 numeric HSV axis bounds (T-988): hue is centidegrees (0..36000 = 0-360
|
||||
# deg x 100); sat/val are basis points (0..10000 = 0-100.00%).
|
||||
_COLOR_AXIS_BOUNDS: dict[str, int] = {"hue": 36000, "sat": 10000, "val": 10000}
|
||||
|
||||
|
||||
def populate_color_register_bands(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Bake the D-235 numeric color-register HSV sampling bands (T-988).
|
||||
|
||||
Reads COLOR_REGISTER_BANDS_TOML (`[register.<name>]` stanzas; each of
|
||||
`hue`/`sat`/`val` a 2-element `[min, max]` integer array). The fill seed
|
||||
samples a single (hue, sat, val) point uniformly within a template's
|
||||
register band per building (D-235) — this baker only validates + stores
|
||||
the bands.
|
||||
|
||||
V-TT-07: (1) every band's ranges are in-bounds (hue 0..36000, sat/val
|
||||
0..10000) with `min < max`; (2) every `color_register` referenced by
|
||||
`trait_templates.visual_bundle.color_register` (checked against the
|
||||
just-baked table, same self-consistency reasoning as V-TT-06) has a band
|
||||
here — coverage, not just shape. Must run AFTER populate_trait_templates.
|
||||
Absent source -> 0 rows.
|
||||
"""
|
||||
if not COLOR_REGISTER_BANDS_TOML.exists():
|
||||
if not dry_run:
|
||||
conn.execute("DELETE FROM color_register_bands")
|
||||
return 0
|
||||
with open(COLOR_REGISTER_BANDS_TOML, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
registers = data.get("register", {})
|
||||
|
||||
errors: list[str] = []
|
||||
rows: list[tuple] = []
|
||||
for name, band in registers.items():
|
||||
values: dict[str, tuple[int, int]] = {}
|
||||
for axis, bound in _COLOR_AXIS_BOUNDS.items():
|
||||
pair = band.get(axis) if isinstance(band, dict) else None
|
||||
if not (isinstance(pair, list) and len(pair) == 2):
|
||||
errors.append(
|
||||
f"V-TT-07: color_register_bands '{name}': '{axis}' must be a 2-element "
|
||||
"[min, max] array"
|
||||
)
|
||||
continue
|
||||
lo, hi = pair
|
||||
if isinstance(lo, bool) or isinstance(hi, bool) or not (
|
||||
isinstance(lo, int) and isinstance(hi, int)
|
||||
):
|
||||
errors.append(
|
||||
f"V-TT-07: color_register_bands '{name}.{axis}': min/max must be integers, "
|
||||
f"got {pair!r}"
|
||||
)
|
||||
continue
|
||||
if not (0 <= lo < hi <= bound):
|
||||
errors.append(
|
||||
f"V-TT-07: color_register_bands '{name}.{axis}': range [{lo}, {hi}] must "
|
||||
f"satisfy 0 <= min < max <= {bound}"
|
||||
)
|
||||
continue
|
||||
values[axis] = (lo, hi)
|
||||
if len(values) == 3:
|
||||
rows.append((
|
||||
name,
|
||||
values["hue"][0], values["hue"][1],
|
||||
values["sat"][0], values["sat"][1],
|
||||
values["val"][0], values["val"][1],
|
||||
))
|
||||
|
||||
# Coverage half of V-TT-07: every color_register the catalog actually
|
||||
# references must have a band.
|
||||
referenced: set[str] = set()
|
||||
for (vb_json,) in conn.execute("SELECT visual_bundle FROM trait_templates"):
|
||||
vb = json.loads(vb_json) if vb_json else {}
|
||||
reg = vb.get("color_register")
|
||||
if reg:
|
||||
referenced.add(reg)
|
||||
banded = {r[0] for r in rows}
|
||||
for reg in sorted(referenced - banded):
|
||||
errors.append(f"V-TT-07: color_register '{reg}' referenced by trait_templates but has no band")
|
||||
|
||||
if errors:
|
||||
print(f" COLOR REGISTER BAND ERRORS ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
raise ImportAborted()
|
||||
|
||||
if not dry_run:
|
||||
conn.execute("DELETE FROM color_register_bands")
|
||||
conn.executemany(
|
||||
"""INSERT INTO color_register_bands
|
||||
(color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Bake the sparse per-body hero pins into atlas_body_trait_bias (#993).
|
||||
|
||||
|
||||
@@ -228,10 +228,22 @@ def main() -> None:
|
||||
print(f" {n_tilt} bodies updated with axial_tilt_deg")
|
||||
|
||||
# 17. biosphere_class from body frontmatter override + two-gate default (D-247, T-1085)
|
||||
print(" [17/17] Populating biosphere_class (D-247)...")
|
||||
print(" [17/19] Populating biosphere_class (D-247)...")
|
||||
n_bio = bodies.populate_biosphere_class(conn, args.dry_run)
|
||||
print(f" {n_bio} bodies updated with biosphere_class")
|
||||
|
||||
# 18. Architecture zone-type bias table (T-988, D-235 step 2) — must
|
||||
# follow trait_templates (V-TT-06 validates against its visual_bundle).
|
||||
print(" [18/19] Baking architecture_zone_bias table (D-235)...")
|
||||
n_zone_bias = traits.populate_architecture_zone_bias(conn, args.dry_run)
|
||||
print(f" {n_zone_bias} zone-bias rows")
|
||||
|
||||
# 19. Color register bands (T-988, D-235) — numeric HSV sampling bands
|
||||
# per trait-template color_register; also follows trait_templates (V-TT-07).
|
||||
print(" [19/19] Baking color_register_bands table (D-235)...")
|
||||
n_color_bands = traits.populate_color_register_bands(conn, args.dry_run)
|
||||
print(f" {n_color_bands} color register bands")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
Unit tests for economy_import.traits validation (T-995, PR #173 review H2).
|
||||
|
||||
Covers the failure branches of the ObjectTag registry loader/validator
|
||||
(V-TT-03 existence/axis, V-TT-04 fallback-graph) and the V-TT-05 pin bound —
|
||||
the `make test-tooling` dry-run only exercises the happy path against the
|
||||
committed, already-valid registry.
|
||||
(V-TT-03 existence/axis, V-TT-04 fallback-graph), the V-TT-05 pin bound, the
|
||||
V-TT-06 zone-bias token-existence check, and the V-TT-07 color-register band
|
||||
shape/coverage checks (T-988) — the `make test-tooling` dry-run only
|
||||
exercises the happy path against the committed, already-valid content.
|
||||
|
||||
Stdlib only (unittest) — run directly or via `make test-tooling`:
|
||||
python3 tooling/economy-db/test_traits.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -309,5 +311,191 @@ class PinBoundTests(unittest.TestCase):
|
||||
self.populate(self.bias_toml(6))
|
||||
|
||||
|
||||
class ZoneBiasValidationTests(unittest.TestCase):
|
||||
"""populate_architecture_zone_bias: V-TT-06 token-existence check (T-988)."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self._orig_zone_bias = traits.ARCHITECTURE_ZONE_BIAS_TOML
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)")
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE architecture_zone_bias (
|
||||
template_tag TEXT NOT NULL, zone_type_id TEXT NOT NULL, bias TEXT NOT NULL,
|
||||
PRIMARY KEY (template_tag, zone_type_id))"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)",
|
||||
(
|
||||
"test_template",
|
||||
json.dumps({
|
||||
"wall": ["brick_wall", "rendered_wall"],
|
||||
"roof": ["flat_roof"],
|
||||
"facade": ["regular_facade"],
|
||||
"street": ["paved"],
|
||||
"color_register": "neutral_grey",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
traits.ARCHITECTURE_ZONE_BIAS_TOML = self._orig_zone_bias
|
||||
self.conn.close()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def populate(self, body: str):
|
||||
path = self.tmp / "architecture_zone_bias.toml"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
traits.ARCHITECTURE_ZONE_BIAS_TOML = path
|
||||
return traits.populate_architecture_zone_bias(self.conn, dry_run=True)
|
||||
|
||||
def test_valid_bias_within_visual_bundle_passes(self):
|
||||
n = self.populate(
|
||||
'[bias.test_template.commercial_market]\n'
|
||||
'wall = { brick_wall = 15000 }\n'
|
||||
'street = { paved = 13000 }\n'
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
def test_token_outside_visual_bundle_is_rejected(self):
|
||||
# steel_frame is a real ObjectTag, but never appears in test_template's
|
||||
# own visual_bundle.wall above — V-TT-06 must reject it.
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[bias.test_template.industrial_freight]\n'
|
||||
'wall = { steel_frame = 16000 }\n'
|
||||
)
|
||||
self.assertIn("V-TT-06", out.getvalue())
|
||||
self.assertIn("steel_frame", out.getvalue())
|
||||
|
||||
def test_unknown_template_tag_is_rejected(self):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[bias.no_such_template.commercial_market]\n'
|
||||
'wall = { brick_wall = 15000 }\n'
|
||||
)
|
||||
|
||||
def test_non_positive_weight_is_rejected(self):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[bias.test_template.commercial_market]\n'
|
||||
'wall = { brick_wall = 0 }\n'
|
||||
)
|
||||
|
||||
def test_non_dict_axis_value_reports_vtt06_not_a_traceback(self):
|
||||
# A bare scalar or the array shape (a plausible copy-paste from the
|
||||
# sibling catalog's `visual_bundle.wall = [...]`) must produce a clean
|
||||
# V-TT-06 error, not an AttributeError crash on `.items()`.
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
for bad in ("wall = 15000\n", 'wall = ["brick_wall"]\n'):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(f"[bias.test_template.commercial_market]\n{bad}")
|
||||
self.assertIn("V-TT-06", out.getvalue())
|
||||
self.assertIn("must be a", out.getvalue())
|
||||
|
||||
def test_bool_weight_is_rejected(self):
|
||||
# bool is an int subclass; `true` must not slip through as weight 1.
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[bias.test_template.commercial_market]\n'
|
||||
'wall = { brick_wall = true }\n'
|
||||
)
|
||||
|
||||
def test_missing_source_yields_zero_rows(self):
|
||||
traits.ARCHITECTURE_ZONE_BIAS_TOML = self.tmp / "missing.toml"
|
||||
self.assertEqual(traits.populate_architecture_zone_bias(self.conn, dry_run=True), 0)
|
||||
|
||||
|
||||
class ColorRegisterBandTests(unittest.TestCase):
|
||||
"""populate_color_register_bands: V-TT-07 shape + coverage checks (T-988)."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self._orig_bands = traits.COLOR_REGISTER_BANDS_TOML
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)")
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE color_register_bands (
|
||||
color_register TEXT PRIMARY KEY, hue_min INTEGER NOT NULL, hue_max INTEGER NOT NULL,
|
||||
sat_min INTEGER NOT NULL, sat_max INTEGER NOT NULL,
|
||||
val_min INTEGER NOT NULL, val_max INTEGER NOT NULL)"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)",
|
||||
("test_template", json.dumps({"color_register": "neutral_grey"})),
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
traits.COLOR_REGISTER_BANDS_TOML = self._orig_bands
|
||||
self.conn.close()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def populate(self, body: str):
|
||||
path = self.tmp / "color_register_bands.toml"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
traits.COLOR_REGISTER_BANDS_TOML = path
|
||||
return traits.populate_color_register_bands(self.conn, dry_run=True)
|
||||
|
||||
def test_valid_band_covering_the_referenced_register_passes(self):
|
||||
n = self.populate(
|
||||
'[register.neutral_grey]\n'
|
||||
'hue = [20000, 21200]\n'
|
||||
'sat = [400, 1200]\n'
|
||||
'val = [4800, 6400]\n'
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
def test_missing_coverage_for_referenced_register_is_rejected(self):
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
with self.assertRaises(ImportAborted):
|
||||
# A band for a DIFFERENT register — neutral_grey (referenced by
|
||||
# test_template above) has no band at all.
|
||||
self.populate(
|
||||
'[register.some_other_register]\n'
|
||||
'hue = [0, 100]\n'
|
||||
'sat = [0, 100]\n'
|
||||
'val = [0, 100]\n'
|
||||
)
|
||||
self.assertIn("V-TT-07", out.getvalue())
|
||||
self.assertIn("neutral_grey", out.getvalue())
|
||||
|
||||
def test_hue_out_of_bounds_is_rejected(self):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[register.neutral_grey]\n'
|
||||
'hue = [30000, 40000]\n' # 40000 > 36000 bound
|
||||
'sat = [400, 1200]\n'
|
||||
'val = [4800, 6400]\n'
|
||||
)
|
||||
|
||||
def test_min_not_less_than_max_is_rejected(self):
|
||||
with self.assertRaises(ImportAborted):
|
||||
self.populate(
|
||||
'[register.neutral_grey]\n'
|
||||
'hue = [20000, 20000]\n' # min == max, not min < max
|
||||
'sat = [400, 1200]\n'
|
||||
'val = [4800, 6400]\n'
|
||||
)
|
||||
|
||||
def test_missing_source_yields_zero_rows(self):
|
||||
traits.COLOR_REGISTER_BANDS_TOML = self.tmp / "missing.toml"
|
||||
self.assertEqual(traits.populate_color_register_bands(self.conn, dry_run=True), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -88,6 +88,17 @@ ARCHITECTURE_TRAIT_BIAS_TOML: Path = (
|
||||
OBJECT_TAG_VOCABULARY_TOML: Path = (
|
||||
REPO_ROOT / "wiki" / "economics" / "object_tag_vocabulary.toml"
|
||||
)
|
||||
# D-235 exterior-grammar content (T-988): per-(template, zone_type) axis-token
|
||||
# bias overrides, and per-color_register integer HSV sampling bands. Both are
|
||||
# baked alongside trait_templates (traits.py: populate_architecture_zone_bias,
|
||||
# populate_color_register_bands), so an edit to either must flip the stamp
|
||||
# exactly like the catalog/registry above.
|
||||
ARCHITECTURE_ZONE_BIAS_TOML: Path = (
|
||||
REPO_ROOT / "wiki" / "economics" / "architecture_zone_bias.toml"
|
||||
)
|
||||
COLOR_REGISTER_BANDS_TOML: Path = (
|
||||
REPO_ROOT / "wiki" / "economics" / "color_register_bands.toml"
|
||||
)
|
||||
|
||||
|
||||
def _economy_import_modules() -> tuple[Path, ...]:
|
||||
@@ -124,6 +135,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
ARCHITECTURE_ZONE_BIAS_TOML,
|
||||
COLOR_REGISTER_BANDS_TOML,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# ==========================================================================
|
||||
# Architecture zone-type bias table (T-988, D-235 step 2)
|
||||
#
|
||||
# D-235's BuildingExteriorTag derivation is 3 steps: (1) the district's
|
||||
# dominant trait template (D-232) filters the available token set per axis
|
||||
# via its own `visual_bundle`; (2) THIS FILE biases which of those
|
||||
# already-filtered tokens a building is likely to draw, keyed on the
|
||||
# building's FUNCTION (D-142 zone_type) — "an industrial block stays stone,
|
||||
# not corrugated metal, if the template's allow-list says so"; (3) density
|
||||
# sets setback_tier. This table is step 2 only.
|
||||
#
|
||||
# SPARSE / FALLBACK MODEL. This is deliberately NOT a full 28-template x
|
||||
# 31-zone_type x 4-axis matrix (3,472 cells). It authors only the cases
|
||||
# where cultural or functional logic genuinely demands a lean — a civic
|
||||
# building reaching for stone and colonnade, a market stall staying in the
|
||||
# lighter/cheaper register, a fishing quay pulling boardwalk over cobble.
|
||||
# Every (template, zone_type) pair NOT listed here — and every token within
|
||||
# a listed entry not given a weight — falls back to a UNIFORM draw across
|
||||
# the template's visual_bundle for that axis. Absence of an entry is not an
|
||||
# oversight; it means the template's own visual_bundle already tells the
|
||||
# whole story for that function, or (rarely, see note at end) the template
|
||||
# has no per-axis choice left to bias.
|
||||
#
|
||||
# OWNERSHIP. Same co-maintenance split as object_tag_vocabulary.toml
|
||||
# (Q-049's resolution: split by concern, not by file) —
|
||||
# - Miri owns the CULTURAL/FUNCTIONAL rationale: which zone_type leans
|
||||
# toward which already-allowed token, and why (the inline comment above
|
||||
# each entry below).
|
||||
# - Araminta owns VISUAL-COHERENCE tuning of the bps weights themselves
|
||||
# (does the resulting draw distribution actually read well against the
|
||||
# shipped art, does a "boost" ever starve a token to invisibility).
|
||||
# This pass (T-988 content deliverable) is Miri's cultural-logic
|
||||
# authoring pass with initial weights; Araminta's visual-coherence review
|
||||
# of the weights is a expected follow-up, same pattern as her parallel
|
||||
# `color_register_bands.toml` pass in this same ticket.
|
||||
#
|
||||
# SCHEMA. Integer basis points (D-010): 10000 = 1.0x the uniform baseline.
|
||||
# A weight above 10000 boosts a token above uniform likelihood; a weight
|
||||
# below 10000 (never authored as 0 — no hard excludes here, that's what
|
||||
# the template's own allow/block already did) suppresses it toward rare.
|
||||
# Only tokens already present in the template's own visual_bundle.<axis>
|
||||
# may appear (importer-validated, V-TT check) — this file never expands a
|
||||
# template's palette, only re-weights within it. Unlisted tokens/axes/zones
|
||||
# keep the uniform baseline.
|
||||
#
|
||||
# [bias.<template_tag>.<zone_type_id>]
|
||||
# wall = { <wall_token> = <bps>, ... } # optional per axis
|
||||
# roof = { <roof_token> = <bps>, ... }
|
||||
# facade = { <facade_token> = <bps>, ... }
|
||||
# street = { <street_token> = <bps>, ... }
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# A. CROSS-CORRIDOR POOL
|
||||
# ==========================================================================
|
||||
|
||||
# mixed_market_vernacular: brick reads load-bearing/durable shopfront vs.
|
||||
# rendered's softer domestic finish upstairs.
|
||||
[bias.mixed_market_vernacular.commercial_market]
|
||||
wall = { brick_wall = 15000 }
|
||||
street = { cobble = 14000 }
|
||||
|
||||
[bias.mixed_market_vernacular.residential_surface]
|
||||
wall = { rendered_wall = 14000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# speculative_boomtown: freight gets the rawest register (first thing
|
||||
# thrown up); the market street is the town's "arrived" storefront.
|
||||
[bias.speculative_boomtown.industrial_freight]
|
||||
wall = { steel_frame = 16000 }
|
||||
roof = { corrugated_roof = 16000 }
|
||||
street = { packed_earth = 15000 }
|
||||
|
||||
[bias.speculative_boomtown.commercial_market]
|
||||
wall = { composite_panel = 14000 }
|
||||
roof = { flat_roof = 13000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# extraction_camp: the works itself is steel; worker housing is poured
|
||||
# concrete, not rig-grade steel.
|
||||
[bias.extraction_camp.extraction_platform]
|
||||
wall = { steel_frame = 18000 }
|
||||
|
||||
[bias.extraction_camp.residential_surface]
|
||||
wall = { concrete_wall = 14000 }
|
||||
|
||||
# industrial_utilitarian: the production floor is steel/corrugated/heavy-
|
||||
# haul; the district's few storefronts keep the older brick frontage.
|
||||
[bias.industrial_utilitarian.industrial_manufacturing]
|
||||
wall = { steel_frame = 15000 }
|
||||
roof = { corrugated_roof = 14000 }
|
||||
street = { heavy_haul = 15000 }
|
||||
|
||||
[bias.industrial_utilitarian.commercial_market]
|
||||
wall = { brick_wall = 13000 }
|
||||
roof = { flat_roof = 12000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# cold_chain_works: the plant is permanent concrete with truck access; the
|
||||
# produce-market front is the lighter composite-panel register.
|
||||
[bias.cold_chain_works.industrial_processing]
|
||||
wall = { concrete_wall = 14000 }
|
||||
street = { heavy_haul = 15000 }
|
||||
|
||||
[bias.cold_chain_works.commercial_market]
|
||||
wall = { composite_panel = 13000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# precision_arcology: labs get the showcase glass + green roof + clean
|
||||
# raised walkways; the production floor behind it stays flat/paved/panel.
|
||||
[bias.precision_arcology.research_station]
|
||||
roof = { green_roof = 15000 }
|
||||
street = { elevated_walkway = 14000 }
|
||||
|
||||
[bias.precision_arcology.industrial_manufacturing]
|
||||
wall = { composite_panel = 13000 }
|
||||
roof = { flat_roof = 14000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# information_spire: civic seats keep institutional restraint even at the
|
||||
# top of the spire; retail/market level gets the reactive screen facade.
|
||||
[bias.information_spire.administrative_civil]
|
||||
facade = { regular_facade = 15000 }
|
||||
street = { elevated_walkway = 13000 }
|
||||
|
||||
[bias.information_spire.commercial_market]
|
||||
facade = { screen_facade = 14000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# civic_monumental: the courts get the fullest ceremonial treatment of any
|
||||
# building in the catalog; a mere checkpoint in an Administrative district
|
||||
# is NOT a courthouse — utilitarian concrete, ornament suppressed hard.
|
||||
[bias.civic_monumental.administrative_civil]
|
||||
wall = { stone_wall = 16000 }
|
||||
roof = { vaulted_roof = 15000 }
|
||||
facade = { colonnade = 15000 }
|
||||
street = { cobble = 14000 }
|
||||
|
||||
[bias.civic_monumental.administrative_judicial]
|
||||
wall = { stone_wall = 16000 }
|
||||
roof = { vaulted_roof = 16000 }
|
||||
facade = { colonnade = 16000 }
|
||||
street = { cobble = 14000 }
|
||||
|
||||
[bias.civic_monumental.security_checkpoint]
|
||||
wall = { concrete_wall = 15000 }
|
||||
roof = { flat_roof = 15000 }
|
||||
facade = { ornamental_facade = 6000 }
|
||||
|
||||
# foreign_quarter: the transplant that draws a crowd gets the grand vaulted/
|
||||
# lattice register; the quiet domestic import stays plainer.
|
||||
[bias.foreign_quarter.entertainment_venue]
|
||||
roof = { vaulted_roof = 15000 }
|
||||
facade = { lattice_screen = 14000 }
|
||||
|
||||
[bias.foreign_quarter.residential_surface]
|
||||
roof = { clay_tile_roof = 13000 }
|
||||
facade = { screen_facade = 13000 }
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# B. PER-CORRIDOR BASELINE POOLS
|
||||
# ==========================================================================
|
||||
|
||||
# core_cosmopolitan: the old ministries stay stone/ornamental; modern retail
|
||||
# layers glass over the old core; ordinary old-town housing stays brick.
|
||||
[bias.core_cosmopolitan.administrative_civil]
|
||||
wall = { stone_wall = 15000 }
|
||||
facade = { ornamental_facade = 14000 }
|
||||
|
||||
[bias.core_cosmopolitan.commercial_market]
|
||||
wall = { glass_curtain_wall = 14000 }
|
||||
facade = { regular_facade = 13000 }
|
||||
|
||||
[bias.core_cosmopolitan.residential_surface]
|
||||
wall = { brick_wall = 13000 }
|
||||
roof = { pitched_roof = 13000 }
|
||||
|
||||
# north_anglo_frontier: civic restraint in brick vs. timber farm sheds with
|
||||
# corrugated roofs on packed-earth farm tracks.
|
||||
[bias.north_anglo_frontier.administrative_civil]
|
||||
wall = { brick_wall = 14000 }
|
||||
facade = { regular_facade = 14000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
[bias.north_anglo_frontier.rural_agricultural]
|
||||
wall = { timber_wall = 14000 }
|
||||
roof = { corrugated_roof = 15000 }
|
||||
street = { packed_earth = 15000 }
|
||||
|
||||
# south_lusophone: the praça-facing arcade for the market vs. the shuttered
|
||||
# private house.
|
||||
[bias.south_lusophone.commercial_market]
|
||||
facade = { arcade_facade = 15000 }
|
||||
street = { cobble = 14000 }
|
||||
|
||||
[bias.south_lusophone.residential_surface]
|
||||
facade = { shuttered_facade = 14000 }
|
||||
wall = { stucco_wall = 13000 }
|
||||
|
||||
# west_germanic_ordered: engineered brick precision for manufacturing vs.
|
||||
# the plain rendered residential street.
|
||||
[bias.west_germanic_ordered.industrial_manufacturing]
|
||||
wall = { brick_wall = 14000 }
|
||||
street = { paved = 14000 }
|
||||
|
||||
[bias.west_germanic_ordered.residential_surface]
|
||||
wall = { rendered_wall = 13000 }
|
||||
street = { cobble = 12000 }
|
||||
|
||||
# west_compact_cooperative: the civic hall is visibly held in common
|
||||
# (colonnade + green roof) vs. plain cooperative housing.
|
||||
[bias.west_compact_cooperative.administrative_civil]
|
||||
facade = { colonnade = 16000 }
|
||||
roof = { green_roof = 14000 }
|
||||
|
||||
[bias.west_compact_cooperative.residential_surface]
|
||||
facade = { regular_facade = 13000 }
|
||||
wall = { rendered_wall = 13000 }
|
||||
|
||||
# east_dense_utilitarian: signage-saturated screen facade at the retail/
|
||||
# transit level vs. lattice-screened concrete housing above.
|
||||
[bias.east_dense_utilitarian.commercial_market]
|
||||
facade = { screen_facade = 15000 }
|
||||
street = { elevated_walkway = 14000 }
|
||||
|
||||
[bias.east_dense_utilitarian.residential_surface]
|
||||
facade = { lattice_screen = 14000 }
|
||||
wall = { concrete_wall = 13000 }
|
||||
street = { paved = 13000 }
|
||||
|
||||
# frontier_surname_settlement: the founder-family's one formal stone
|
||||
# building vs. rammed-earth farm outbuildings on packed earth.
|
||||
[bias.frontier_surname_settlement.administrative_civil]
|
||||
wall = { stone_wall = 16000 }
|
||||
facade = { regular_facade = 14000 }
|
||||
street = { cobble = 15000 }
|
||||
|
||||
[bias.frontier_surname_settlement.rural_agricultural]
|
||||
wall = { rammed_earth_wall = 14000 }
|
||||
roof = { flat_roof = 13000 }
|
||||
street = { packed_earth = 15000 }
|
||||
|
||||
# frontier_hardscrabble: only the wall axis has any choice here — a
|
||||
# checkpoint gets the standardized defensive concrete; a frontier outpost
|
||||
# gets the locally hand-built rammed earth.
|
||||
[bias.frontier_hardscrabble.security_checkpoint]
|
||||
wall = { concrete_wall = 15000 }
|
||||
|
||||
[bias.frontier_hardscrabble.wilderness_frontier]
|
||||
wall = { rammed_earth_wall = 14000 }
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# C. HERITAGE SUB-POOLS
|
||||
# ==========================================================================
|
||||
|
||||
# scottish_highland_vernacular: scattered crofts (pitched roof, packed
|
||||
# earth track) vs. the one village market building (tile roof, cobble).
|
||||
[bias.scottish_highland_vernacular.residential_dispersed]
|
||||
roof = { pitched_roof = 14000 }
|
||||
street = { packed_earth = 14000 }
|
||||
|
||||
[bias.scottish_highland_vernacular.commercial_market]
|
||||
roof = { clay_tile_roof = 13000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
# west_african_compound: the market-facing arcade vs. the private family
|
||||
# compound behind its shuttered rammed-earth wall.
|
||||
[bias.west_african_compound.commercial_market]
|
||||
facade = { arcade_facade = 15000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
[bias.west_african_compound.residential_surface]
|
||||
facade = { shuttered_facade = 14000 }
|
||||
wall = { rammed_earth_wall = 13000 }
|
||||
street = { packed_earth = 14000 }
|
||||
|
||||
# iberian_hacienda: the estate-office colonnade reads institutional vs. the
|
||||
# veranda-fronted domestic hacienda.
|
||||
[bias.iberian_hacienda.administrative_civil]
|
||||
facade = { colonnade = 15000 }
|
||||
wall = { stone_wall = 14000 }
|
||||
|
||||
[bias.iberian_hacienda.residential_surface]
|
||||
facade = { arcade_facade = 14000 }
|
||||
wall = { stucco_wall = 13000 }
|
||||
|
||||
# atlantic_creole_maritime: the working waterfront (timber, boardwalk) vs.
|
||||
# inland creole housing (render, cobble).
|
||||
[bias.atlantic_creole_maritime.port_fishing]
|
||||
wall = { timber_wall = 14000 }
|
||||
street = { boardwalk = 15000 }
|
||||
|
||||
[bias.atlantic_creole_maritime.residential_surface]
|
||||
wall = { rendered_wall = 13000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
# nordic_timber: the coastal fishing quay pulls boardwalk; inland housing
|
||||
# keeps the turf/green-roof domestic register on cobble.
|
||||
[bias.nordic_timber.port_fishing]
|
||||
street = { boardwalk = 15000 }
|
||||
|
||||
[bias.nordic_timber.residential_surface]
|
||||
roof = { green_roof = 13000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
# central_european_blok: civic solidity keeps its ornament in reserve (but
|
||||
# still reaches for it) vs. the plain perimeter-block housing stock.
|
||||
[bias.central_european_blok.administrative_civil]
|
||||
facade = { ornamental_facade = 14000 }
|
||||
wall = { brick_wall = 14000 }
|
||||
|
||||
[bias.central_european_blok.residential_surface]
|
||||
facade = { regular_facade = 13000 }
|
||||
wall = { rendered_wall = 13000 }
|
||||
|
||||
# east_asian_temple_enclave: ceremonial reception gets the full vaulted-
|
||||
# colonnade register; the ordinary residential quarter around the temple
|
||||
# stays in the softer timber/lattice domestic register.
|
||||
[bias.east_asian_temple_enclave.diplomatic_elite]
|
||||
roof = { vaulted_roof = 16000 }
|
||||
facade = { colonnade = 15000 }
|
||||
|
||||
[bias.east_asian_temple_enclave.residential_surface]
|
||||
wall = { timber_wall = 14000 }
|
||||
roof = { clay_tile_roof = 13000 }
|
||||
facade = { lattice_screen = 13000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
# vietnamese_water_village: working water trade backs onto the canal;
|
||||
# land-fronting tube-houses face the boardwalk instead.
|
||||
[bias.vietnamese_water_village.port_fishing]
|
||||
wall = { timber_wall = 14000 }
|
||||
street = { canal_way = 15000 }
|
||||
|
||||
[bias.vietnamese_water_village.rural_aquaculture]
|
||||
street = { canal_way = 15000 }
|
||||
|
||||
[bias.vietnamese_water_village.residential_surface]
|
||||
wall = { rendered_wall = 13000 }
|
||||
street = { boardwalk = 13000 }
|
||||
|
||||
# afrikaans_kraal: the werf/farmstead register (render, pitched roof, dirt
|
||||
# track) vs. the more formal Cape civic building (stone, tile, cobble).
|
||||
[bias.afrikaans_kraal.rural_pastoral]
|
||||
wall = { rendered_wall = 14000 }
|
||||
roof = { pitched_roof = 14000 }
|
||||
street = { packed_earth = 14000 }
|
||||
|
||||
[bias.afrikaans_kraal.administrative_civil]
|
||||
wall = { stone_wall = 14000 }
|
||||
roof = { clay_tile_roof = 13000 }
|
||||
street = { cobble = 13000 }
|
||||
|
||||
# arab_oasis_qanat: the shaded souk-adjacent market lane vs. the courtyard
|
||||
# house with its wind-tower privacy screen.
|
||||
[bias.arab_oasis_qanat.commercial_market]
|
||||
wall = { stucco_wall = 14000 }
|
||||
facade = { lattice_screen = 14000 }
|
||||
street = { cobble = 14000 }
|
||||
|
||||
[bias.arab_oasis_qanat.residential_surface]
|
||||
wall = { rammed_earth_wall = 14000 }
|
||||
roof = { terraced_roof = 14000 }
|
||||
facade = { screen_facade = 14000 }
|
||||
street = { packed_earth = 14000 }
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# NOT BIASED — flagged, not an oversight
|
||||
# ==========================================================================
|
||||
# generic_baseline is the one template with ZERO degrees of freedom: every
|
||||
# axis (wall/roof/facade/street) carries exactly one token in its
|
||||
# visual_bundle. There is nothing left to bias — any zone_type drawing this
|
||||
# template gets that one token per axis regardless. This is correct: it is
|
||||
# the corridor-neutral fallback the draw can always reach (catalog line 57),
|
||||
# and a single-token axis is definitionally already "uniform."
|
||||
# ==========================================================================
|
||||
@@ -0,0 +1,330 @@
|
||||
# ==========================================================================
|
||||
# Color register bands (T-988, D-235) — numeric HSV bands per D-232
|
||||
# trait-template `color_register` label. Turns the free-form palette-cue
|
||||
# string documented in object_tag_vocabulary.toml ("a palette cue, NOT an
|
||||
# ObjectTag") into an authored, sampleable numeric range.
|
||||
#
|
||||
# THE MODEL: each color_register gets ONE integer HSV band. The fill seed
|
||||
# picks a single (hue, sat, val) point uniformly within the band per
|
||||
# building — same register, same family feel, different building each time.
|
||||
# This is the register acting as intended (D-235): "always within the
|
||||
# template's register," never a fixed single color, never unbounded.
|
||||
#
|
||||
# INTEGER UNITS (D-010 — no floats, ever; determinism is save-critical
|
||||
# under D-227):
|
||||
# hue 0..36000 centidegrees (degrees x 100; standard 0-360 deg wheel)
|
||||
# sat 0..10000 basis points (0-100.00%)
|
||||
# val 0..10000 basis points (0-100.00%)
|
||||
# All three axes are closed [min, max] integer ranges, min < max, sampled
|
||||
# uniformly (no wraparound support — no band crosses the hue 0/36000 seam).
|
||||
#
|
||||
# SATURATION CEILING (visual-hierarchy discipline, not a hard schema rule):
|
||||
# every band in this file stays at or below sat = 3600 (36%). This keeps
|
||||
# building material color — structure, the least important read — clearly
|
||||
# under the saturation the project reserves for foreground signal (entity /
|
||||
# relationship color has historically run 40-60% in this project's palette
|
||||
# work). Buildings should never visually compete with the things standing
|
||||
# in front of them. Even the two "brightest allowed" cultural-accent
|
||||
# registers (bright_painted, falu_red_and_pine) stay under this ceiling.
|
||||
#
|
||||
# OWNERSHIP (Q-049 split, same as object_tag_vocabulary.toml): Miri owns
|
||||
# whether a register's cultural placement is right for its template/corridor
|
||||
# (the WHY); Araminta owns the numeric HSV authoring (the WHAT it looks
|
||||
# like) and the cross-register visual discipline (distinctness, saturation
|
||||
# hierarchy, mood). Changes to a band's numbers are Araminta's call; changes
|
||||
# to which register a template carries are Miri's. Add both together when a
|
||||
# new template ships a new color_register value.
|
||||
#
|
||||
# COVERAGE: every color_register value referenced by
|
||||
# architecture_trait_catalog.toml's `visual_bundle.color_register` MUST have
|
||||
# a `[register.<value>]` entry here — the importer validates this (T-988).
|
||||
# 28 values are banded below, one per current template. Two are flagged in
|
||||
# the section comments as the most interpretive calls in the set — real
|
||||
# entries, not placeholders, but worth a cultural sanity-check from Miri.
|
||||
#
|
||||
# The fill seed samples (h, s, v) once per building and stores the result as
|
||||
# three plain integers on BuildingExteriorTag — no re-derivation, no drift.
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# I. COOL NEUTRAL / INSTITUTIONAL & TECH — cool-cast hues (~190-220 deg),
|
||||
# the lowest-saturation family in the file. Differ mainly by value
|
||||
# (how pale) and a hair of saturation (how "designed" vs "clinical").
|
||||
# ==========================================================================
|
||||
|
||||
[register.neutral_grey]
|
||||
hue = [20000, 21200] # centidegrees (200-212 deg, cool blue-grey)
|
||||
sat = [400, 1200] # bps (4-12%)
|
||||
val = [4800, 6400] # bps (48-64%)
|
||||
# generic_baseline — corridor-neutral concrete default; the fallback every
|
||||
# other register is a deviation FROM. Mid-value, barely-there cool tint.
|
||||
|
||||
[register.clean_pale]
|
||||
hue = [19000, 20200] # centidegrees (190-202 deg, near-neutral, faint cool)
|
||||
sat = [200, 800] # bps (2-8%) -- the lowest sat band in the catalog
|
||||
val = [7600, 9000] # bps (76-90%)
|
||||
# cold_chain_works — food/pharma hygiene register. Clinical pale, almost
|
||||
# no color at all; reads as "clean" through value and absence of tint.
|
||||
|
||||
[register.cool_clean]
|
||||
hue = [20000, 21400] # centidegrees (200-214 deg, cool blue)
|
||||
sat = [600, 1600] # bps (6-16%)
|
||||
val = [7000, 8400] # bps (70-84%)
|
||||
# precision_arcology — cleanroom/fab register. Quiet, controlled, a shade
|
||||
# more saturated and a shade darker than clean_pale (expensive, not sterile).
|
||||
|
||||
[register.glass_and_light]
|
||||
hue = [20400, 21800] # centidegrees (204-218 deg, cool blue, glazing tint)
|
||||
sat = [1000, 2200] # bps (10-22%)
|
||||
val = [7200, 8800] # bps (72-88%)
|
||||
# information_spire — glass curtain-wall office towers. Brightest, most
|
||||
# reflective register in the set; the sat bump over cool_clean is the
|
||||
# tinted-glazing read, not "more colorful," just "more glass."
|
||||
|
||||
[register.neon_over_grey]
|
||||
hue = [19200, 20400] # centidegrees (192-204 deg, cool cyan-grey)
|
||||
sat = [1200, 2600] # bps (12-26%)
|
||||
val = [4400, 6000] # bps (44-60%)
|
||||
# east_dense_utilitarian — cyberpunk-density baseline. The grey base carries
|
||||
# a restrained cool cast; actual neon is signage/lighting, a different
|
||||
# system (environmental neutrality) -- this band stays a paint color, not a
|
||||
# light source, so it never reads as garish.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# II. WARM CIVIC PALE — the warm-hue counterpart to section I. One entry:
|
||||
# civic authority reads warm and dignified, not cool and clinical.
|
||||
# ==========================================================================
|
||||
|
||||
[register.pale_stone]
|
||||
hue = [3800, 5000] # centidegrees (38-50 deg, warm stone tan)
|
||||
sat = [400, 1400] # bps (4-14%)
|
||||
val = [6800, 8200] # bps (68-82%)
|
||||
# civic_monumental — colonnades and institutional gravitas. Pale and low-sat
|
||||
# like section I, but warm instead of cool: authority, not sterility.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# III. INDUSTRIAL OXIDE & RAW — warm rust-to-greige hues, low-mid sat,
|
||||
# low-mid value. The heavy-process / unfinished / hostile-world family.
|
||||
# ==========================================================================
|
||||
|
||||
[register.oxide_and_dust]
|
||||
hue = [2000, 3000] # centidegrees (20-30 deg, rust orange)
|
||||
sat = [1800, 3200] # bps (18-32%)
|
||||
val = [3200, 4800] # bps (32-48%, dark and dusty)
|
||||
# extraction_camp — mine/rig/well-is-the-town register. Darkest and
|
||||
# grittiest of the industrial family; rust-streaked steel, ground dust.
|
||||
|
||||
[register.oxide_and_steel]
|
||||
hue = [1600, 2800] # centidegrees (16-28 deg, rust diluted toward neutral)
|
||||
sat = [1000, 2200] # bps (10-22%, lower than oxide_and_dust)
|
||||
val = [3600, 5200] # bps (36-52%)
|
||||
# industrial_utilitarian — large-span sheds, tank farms. Steel dilutes the
|
||||
# rust the extraction camp shows raw; less dust, more exposed structure.
|
||||
|
||||
[register.raw_provisional]
|
||||
hue = [3000, 4200] # centidegrees (30-42 deg, warm greige)
|
||||
sat = [600, 1600] # bps (6-16%)
|
||||
val = [5200, 6800] # bps (52-68%)
|
||||
# speculative_boomtown — thrown-up-fast composite/steel. Low commitment
|
||||
# color for a settlement still deciding what it is; unfinished, provisional.
|
||||
|
||||
[register.hardened_earth]
|
||||
hue = [2800, 3800] # centidegrees (28-38 deg, earth-neutral)
|
||||
sat = [600, 1600] # bps (6-16%)
|
||||
val = [3600, 5000] # bps (36-50%, dark)
|
||||
# frontier_hardscrabble — hardened against a hostile primary. Dense concrete
|
||||
# and rammed earth, minimal exposed surface; the darkest earth-family band.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# IV. EARTH & MASONRY BASELINES — warm brown-tan mid-tones, the "everyday
|
||||
# vernacular" family. Six corridor baselines that must read as siblings
|
||||
# (all timber/brick/render/stone) while staying individually legible.
|
||||
# ==========================================================================
|
||||
|
||||
[register.mixed_warm]
|
||||
hue = [1600, 3000] # centidegrees (16-30 deg, brick-orange)
|
||||
sat = [1200, 2400] # bps (12-24%)
|
||||
val = [4600, 6200] # bps (46-62%)
|
||||
# mixed_market_vernacular — organic ground-floor-trade townscape. Warmest
|
||||
# and most saturated of this section; decades of layered commercial signage.
|
||||
|
||||
[register.muted_practical]
|
||||
hue = [2600, 3800] # centidegrees (26-38 deg, muted brown)
|
||||
sat = [800, 1800] # bps (8-18%)
|
||||
val = [4600, 6000] # bps (46-60%)
|
||||
# north_anglo_frontier — Commonwealth-frontier civic restraint. Weatherboard
|
||||
# and brick, orderly and unshowy; the corridor's practical default.
|
||||
|
||||
[register.ordered_earth]
|
||||
hue = [3000, 4000] # centidegrees (30-40 deg, earth tan)
|
||||
sat = [900, 1900] # bps (9-19%)
|
||||
val = [5000, 6400] # bps (50-64%)
|
||||
# west_germanic_ordered — disciplined massing, engineered tidiness.
|
||||
# Everything has its place; the color is as restrained as the geometry.
|
||||
|
||||
[register.communal_warm]
|
||||
hue = [2800, 3800] # centidegrees (28-38 deg, warm, same family as ordered_earth)
|
||||
sat = [1200, 2400] # bps (12-24%)
|
||||
val = [5600, 7200] # bps (56-72%, brighter than ordered_earth)
|
||||
# west_compact_cooperative — shared halls, civic infrastructure held in
|
||||
# common. Brighter and a touch warmer than the Germanic baseline it sits
|
||||
# beside -- "legible to its members" reads as more open, not more muted.
|
||||
|
||||
[register.sober_masonry]
|
||||
hue = [2600, 3600] # centidegrees (26-36 deg, muted brick-brown)
|
||||
sat = [900, 1900] # bps (9-19%)
|
||||
val = [4400, 5800] # bps (44-58%)
|
||||
# central_european_blok — Polish/Czech masonry perimeter blocks. Ornament
|
||||
# held in reserve; so is the color -- solid, civic, unornamented.
|
||||
|
||||
[register.local_stone]
|
||||
hue = [3200, 4400] # centidegrees (32-44 deg, warm stone-earth)
|
||||
sat = [800, 1800] # bps (8-18%)
|
||||
val = [5200, 6800] # bps (52-68%)
|
||||
# frontier_surname_settlement — built from whatever the world offered.
|
||||
# Self-reliant and coherent across centuries; unglamorous by design.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# V. OCHRE / TERRACOTTA / SAND — warm orange-tan, mid-to-high sat and
|
||||
# value, the sun-baked family. Distinguished from section IV by being
|
||||
# more saturated (IV's earths are muted; these are declarative).
|
||||
# ==========================================================================
|
||||
|
||||
[register.warm_ochre]
|
||||
hue = [3200, 4200] # centidegrees (32-42 deg, ochre-orange)
|
||||
sat = [1800, 3000] # bps (18-30%)
|
||||
val = [5400, 7000] # bps (54-70%)
|
||||
# west_african_compound — rendered ochre walls around shared courtyards.
|
||||
# Warmer and more saturated than the frontier-earth registers in section IV.
|
||||
|
||||
[register.terracotta_and_lime]
|
||||
hue = [1800, 2800] # centidegrees (18-28 deg, terracotta red-orange)
|
||||
sat = [1600, 2800] # bps (16-28%)
|
||||
val = [6000, 7600] # bps (60-76%)
|
||||
# iberian_hacienda — stucco courtyards, clay-tile roofs, arcaded verandas.
|
||||
# The band carries the terracotta warmth; lime-washed brightness lives at
|
||||
# the high end of value. (One HSV point can't hold two named materials at
|
||||
# once -- terracotta is the primary read here, lime is the value lift.)
|
||||
|
||||
[register.sand_and_shade]
|
||||
hue = [3600, 4600] # centidegrees (36-46 deg, sandy tan)
|
||||
sat = [600, 1600] # bps (6-16%)
|
||||
val = [6200, 7600] # bps (62-76%)
|
||||
# arab_oasis_qanat — rammed-earth courtyard houses under harsh dryland
|
||||
# light. Paler and less saturated than warm_ochre/terracotta -- sun-bleached
|
||||
# rather than declarative.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# VI. DEEP SATURATED CULTURAL ACCENTS — the most saturated bands in the
|
||||
# file, still capped well under the entity-color saturation floor.
|
||||
# Each is a specific, real-world-anchored callback, used sparingly by
|
||||
# the heritage/import deviation system (D-232), not a corridor default.
|
||||
# ==========================================================================
|
||||
|
||||
[register.falu_red_and_pine]
|
||||
hue = [600, 1400] # centidegrees (6-14 deg, deep barn red)
|
||||
sat = [2200, 3400] # bps (22-34%)
|
||||
val = [3200, 4600] # bps (32-46%, dark and saturated)
|
||||
# nordic_timber — the iconic Scandinavian falu-red timber callback. Dark,
|
||||
# saturated, unmistakably itself; pine restraint keeps it from drifting
|
||||
# toward orange.
|
||||
|
||||
[register.lacquer_and_timber]
|
||||
hue = [200, 1000] # centidegrees (2-10 deg, deep lacquer red)
|
||||
sat = [2000, 3200] # bps (20-32%)
|
||||
val = [3000, 4400] # bps (30-44%, dark and lacquered)
|
||||
# east_asian_temple_enclave — CJK temple-precinct callback. Darker and a
|
||||
# touch closer to true red than falu_red_and_pine -- the two must not be
|
||||
# confused for each other despite the shared "deep saturated red" family.
|
||||
|
||||
[register.bright_painted]
|
||||
hue = [1400, 2400] # centidegrees (14-24 deg, warm coral-orange)
|
||||
sat = [2400, 3600] # bps (24-36%, the highest sat in the file)
|
||||
val = [6400, 8000] # bps (64-80%)
|
||||
# atlantic_creole_maritime — Cape-Verdean/Afro-Atlantic bright-painted
|
||||
# timber and deep porches. Deliberately the boldest register in the whole
|
||||
# set -- capped at 3600 bps (36%) to stay clear of the entity-color
|
||||
# saturation range, so it reads as "the brightest building" without ever
|
||||
# competing with a relationship-colored character standing in front of it.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# VII. PASTEL & WHITEWASH — very low saturation, high value, pale-painted
|
||||
# feel. Three whitewash-family callbacks that must stay distinct from
|
||||
# each other at a glance.
|
||||
# ==========================================================================
|
||||
|
||||
[register.whitewash_and_azulejo]
|
||||
hue = [3400, 4400] # centidegrees (34-44 deg, warm white cast)
|
||||
sat = [300, 1000] # bps (3-10%)
|
||||
val = [8000, 9200] # bps (80-92%, very pale)
|
||||
# south_lusophone — whitewashed render around the praça. The azulejo blue
|
||||
# tile trim is a facade/accent detail outside a single HSV band's scope;
|
||||
# this band is the wall's warm-white base.
|
||||
|
||||
[register.cape_whitewash]
|
||||
hue = [3800, 4600] # centidegrees (38-46 deg, warm-neutral white)
|
||||
sat = [300, 1000] # bps (3-10%)
|
||||
val = [7800, 9000] # bps (78-90%)
|
||||
# afrikaans_kraal — Cape gabled werf whitewash. Same mechanism as
|
||||
# whitewash_and_azulejo; hue nudged a few degrees warmer and value nudged
|
||||
# down slightly so the two whitewash registers stay tellable apart.
|
||||
|
||||
[register.weathered_pastel]
|
||||
hue = [16000, 18000] # centidegrees (160-180 deg, soft aqua-green pastel)
|
||||
sat = [800, 1800] # bps (8-18%)
|
||||
val = [6800, 8200] # bps (68-82%)
|
||||
# vietnamese_water_village — canalside tube-houses. The one green-family
|
||||
# register in the set, chosen for canal/water association and to stay
|
||||
# clearly distinct from the file's several warm-whitewash bands above.
|
||||
# FLAGGED FOR MIRI: real-world Vietnamese heritage townscapes (Hoi An) skew
|
||||
# warm ochre/yellow more often than aqua -- this is a legibility trade-off
|
||||
# (distinctness against 3 other pale-warm registers) over strict real-world
|
||||
# color-matching. Worth a cultural sanity check; easy to re-hue if wrong.
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# VIII. VIOLET-COOL OUTLIER — the only violet-hued band in the file. Used
|
||||
# once, deliberately, so it stays a signature rather than a habit.
|
||||
# ==========================================================================
|
||||
|
||||
[register.grey_stone_heather]
|
||||
hue = [26000, 28000] # centidegrees (260-280 deg, violet-grey)
|
||||
sat = [500, 1400] # bps (5-14%)
|
||||
val = [4200, 5800] # bps (42-58%)
|
||||
# scottish_highland_vernacular — drystone and slate against weather. The
|
||||
# violet lean is a literal read of "heather" over grey stone; low-sat
|
||||
# enough to stay a grey with a cast, not a purple building.
|
||||
# FLAGGED FOR MIRI: the most interpretive hue placement in the file (the
|
||||
# only band outside the neutral/warm-earth/cool-tech families) -- confirm
|
||||
# it reads as "Highland stone" and not just "purple."
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# IX. DELIBERATELY WIDE / PLURAL — the two registers whose whole cultural
|
||||
# point is variety, not a single tone. Hue bands here are 3-4x the width
|
||||
# used everywhere else in this file, on purpose.
|
||||
# ==========================================================================
|
||||
|
||||
[register.layered_patina]
|
||||
hue = [2000, 6000] # centidegrees (20-60 deg, warm brown through olive)
|
||||
sat = [1000, 2400] # bps (10-24%)
|
||||
val = [4800, 6800] # bps (48-68%)
|
||||
# core_cosmopolitan — centuries-deep, no single grammar dominant, old
|
||||
# infrastructure carrying new uses. The widest hue band in the file is the
|
||||
# point: a "layered patina" city block should NOT sample as one fixed tone.
|
||||
|
||||
[register.imported_accent]
|
||||
hue = [1000, 5000] # centidegrees (10-50 deg, warm red-orange through tan)
|
||||
sat = [1600, 3200] # bps (16-32%)
|
||||
val = [5200, 7200] # bps (52-72%)
|
||||
# foreign_quarter — the import-swerve template: a coherent transplant
|
||||
# carried whole from another corridor (the Shinto temple in Amsterdam).
|
||||
# Wide by design for the same reason as layered_patina -- it can arrive
|
||||
# from anywhere, so it shouldn't sample as anywhere in particular.
|
||||
Reference in New Issue
Block a user