// Seat source-backed modules on the original DEM. // Flat footprints are seated as one slab. Sloped footprints are cut into strips perpendicular to // the measured downhill direction, so each step is a believable terrace that keeps the building's // facade proportions instead of an arbitrarily narrowed sliver. import { readFileSync, writeFileSync } from 'node:fs' import { Cartesian3, Matrix4 } from 'cesium' const dir = 'tools/city-massing/city/oldtown/' const plan = JSON.parse(readFileSync(dir + 'plan.json')) const sampled = JSON.parse(readFileSync(dir + 'terrain-samples.json')) if (plan.samples.length !== sampled.heights.length) throw new Error('Old-town sample length mismatch') const FLAT_RELIEF = 2.5 // relief a single slab may carry const STEP_RELIEF = 2.2 // relief one terrace step may carry const MAX_TERRACES = 5 // beyond this the footprint is a hillside, not a building // A stone plinth (台基/挡土墙) is normal hillside construction in this town, so a modest visible // foundation wall is allowed. It must stay well below the building's own height, otherwise the // "building" is really a platform and is refused instead. const MAX_PLINTH = 3.2 const PLINTH_HEIGHT_RATIO = 0.4 const FLOOR_HEIGHT_M = 3.2 const assetFit = JSON.parse(readFileSync('tools/city-massing/asset-fit.json', 'utf8')) const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(plan.transform), new Matrix4()) const local = (lon, lat, h) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat, h), new Cartesian3()) const buildings = [] const excluded = [...(plan.excluded || []).map((entry) => ({ ...entry, stage: 'source-footprint' }))] const terraced = [] /** Sample positions in the module's own metre frame plus the terrain height at each. */ function frameSamples(module) { const cosLat = Math.cos((module.lat * Math.PI) / 180) const u = [Math.cos(module.heading), Math.sin(module.heading)] const v = [-u[1], u[0]] return module.sampleIndices.map((index) => { const point = plan.samples[index] const dx = (point.lon - module.lon) * 111320 * cosLat const dy = (point.lat - module.lat) * 111320 return { u: dx * u[0] + dy * u[1], v: -dx * u[1] + dy * u[0], height: sampled.heights[index] } }) } function bounds(values) { return { min: Math.min(...values), max: Math.max(...values) } } function seat(module, heights, extra) { const min = Math.min(...heights) const max = Math.max(...heights) const floor = local(module.lon, module.lat, max - 0.25) const bottom = local(module.lon, module.lat, min - 3) return { ...module, ...extra, positionENU: [floor.x, floor.y, floor.z], foundationBottom: bottom.z, terrainMin: min, terrainMax: max, terrainRelief: max - min, floorHeight: max - 0.25, groundControls: module.sampleIndices.map((i) => [plan.samples[i].lon, plan.samples[i].lat, sampled.heights[i]]), } } /** Does a strip of this size still fit one of the module's planned assets? */ function fitsAsset(width, depth, families) { for (const family of families) { const limits = assetFit.variants[family] if (!limits) continue const [nominalWidth, nominalDepth] = limits.nominal_m for (const [w, d] of [[width, depth], [depth, width]]) { const sw = w / nominalWidth const sd = d / nominalDepth if (sw >= limits.minScale[0] && sw <= limits.maxScale[0] && sd >= limits.minScale[1] && sd <= limits.maxScale[1]) return true } } return false } for (const module of plan.modules) { const heights = module.sampleIndices.map((i) => sampled.heights[i]) if (heights.some((height) => !Number.isFinite(height) || height <= 1)) { excluded.push({ id: module.id, sourceId: module.sourceId, sourceMask: module.sourceMask, reason: 'missing or invalid DEM sample' }) continue } const relief = Math.max(...heights) - Math.min(...heights) if (relief <= FLAT_RELIEF) { buildings.push(seat(module, heights, { construction: 'flat' })) continue } // Gentle slopes: one building on a bounded stone plinth, with the visible plinth wall kept // below the proportion a real retaining base would show. const bodyHeight = (module.floors || 2) * FLOOR_HEIGHT_M if (relief <= MAX_PLINTH && relief <= bodyHeight * PLINTH_HEIGHT_RATIO) { buildings.push(seat(module, heights, { construction: 'plinth', plinthHeight: Number(relief.toFixed(2)) })) continue } const samples = frameSamples(module) // Steepest-descent direction from the sampled ground, expressed in the module frame. const us = bounds(samples.map((s) => s.u)) const vs = bounds(samples.map((s) => s.v)) const du = us.max - us.min const dv = vs.max - vs.min // Cut across the axis with the larger span-to-relief contribution; this keeps the other axis // (the facade) at full size so the terrace still matches the planned asset. const split = du >= dv ? 'u' : 'v' const spanAlong = split === 'u' ? du : dv const families = module.preferredAssets?.length ? Object.keys(assetFit.variants).filter((key) => module.preferredAssets.some((id) => id.startsWith(key))) : [module.archetype] let chosen = null for (let count = 2; count <= MAX_TERRACES; count++) { const strip = spanAlong / count const bins = Array.from({ length: count }, () => []) const lo = split === 'u' ? us.min : vs.min for (const sample of samples) { const position = split === 'u' ? sample.u : sample.v const bin = Math.min(count - 1, Math.max(0, Math.floor((position - lo) / strip))) bins[bin].push(sample) } if (bins.some((bin) => !bin.length)) continue const worst = Math.max(...bins.map((bin) => { const values = bin.map((sample) => sample.height) return Math.max(...values) - Math.min(...values) })) if (worst > STEP_RELIEF) continue const stripWidth = split === 'u' ? strip : module.width const stripDepth = split === 'u' ? module.depth : strip if (!fitsAsset(stripWidth, stripDepth, families)) continue chosen = { count, strip, bins, split, stripWidth, stripDepth } break } if (!chosen) { excluded.push({ id: module.id, sourceId: module.sourceId, sourceMask: module.sourceMask, reason: `relief ${relief.toFixed(2)}m cannot be terraced inside the approved asset proportions`, relief, }) continue } const { count, strip, bins, split: axis, stripWidth, stripDepth } = chosen const u = [Math.cos(module.heading), Math.sin(module.heading)] for (let index = 0; index < count; index++) { // Step centres move along the cut axis; the other axis stays centred on the source footprint. const offset = (index + 0.5) * strip - (axis === 'u' ? module.width : module.depth) / 2 const dx = axis === 'u' ? u[0] * offset : -u[1] * offset const dy = axis === 'u' ? u[1] * offset : u[0] * offset const stripHeights = bins[index].map((sample) => sample.height) buildings.push(seat(module, stripHeights, { id: `${module.id}-t${index}`, construction: 'terraced', terraceIndex: index, terraceCount: count, width: stripWidth, depth: stripDepth, lon: module.lon + dx / (111320 * Math.cos((module.lat * Math.PI) / 180)), lat: module.lat + dy / 111320, })) } terraced.push({ id: module.id, sourceId: module.sourceId, relief: Number(relief.toFixed(2)), terraces: count }) } const out = { ...plan, buildings, excluded, terraced, notice: plan.disclaimer + ' Sloped footprints use stepped terraces; hillside footprints stay excluded.', } writeFileSync(dir + 'seated-plan.json', JSON.stringify(out, null, 2)) console.log(JSON.stringify({ candidateModules: plan.modules.length, accepted: buildings.length, acceptedSources: new Set(buildings.map((b) => b.sourceId)).size, terracedModules: terraced.length, terracedParts: buildings.filter((b) => b.construction === 'terraced').length, excluded: excluded.length, maxRelief: Number(Math.max(...buildings.map((b) => b.terrainRelief), 0).toFixed(2)), }))