| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- // 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)),
- }))
|