Kaynağa Gözat

chore(tools): 移除旧城区与过期诊断脚本

ChenJZ 15 saat önce
ebeveyn
işleme
8a492d595f

+ 0 - 72
tools/city-massing/draw-model-coverage.mjs

@@ -1,72 +0,0 @@
-// Draw the generated city footprints over the local satellite atlas, coloured by evidence source,
-// so a human can see at a glance whether models sit on the imagery instead of on roads or fields.
-import { readFileSync, writeFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { PNG } from 'pngjs'
-import { candidateFootprint } from './oldtown-geometry.mjs'
-
-const cityDir = resolve('tools/city-massing/city')
-const atlas = JSON.parse(readFileSync(resolve(cityDir, 'city-inventory.json')))
-const image = PNG.sync.read(readFileSync(resolve(cityDir, 'city-satellite.png')))
-const seated = JSON.parse(readFileSync(resolve(cityDir, 'oldtown/seated-plan.json')))
-const scale = 2 ** atlas.imagery.zoom * 256
-function project([lon, lat]) {
-    const x = ((lon + 180) / 360) * scale - atlas.imagery.westTile * 256
-    const y = ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * scale - atlas.imagery.googleY0 * 256
-    return [x, y]
-}
-
-const COLORS = {
-    'imagery-roof-segmentation': [255, 225, 0],
-    'osm-building-footprint': [0, 235, 255],
-}
-
-function draw(a, b, color, width) {
-    const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
-    for (let i = 0; i <= steps; i++) {
-        const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps)
-        const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps)
-        for (let dy = -width; dy <= width; dy++)
-            for (let dx = -width; dx <= width; dx++) {
-                const px = x + dx, py = y + dy
-                if (px < 0 || py < 0 || px >= image.width || py >= image.height) continue
-                const k = (py * image.width + px) * 4
-                image.data[k] = color[0]
-                image.data[k + 1] = color[1]
-                image.data[k + 2] = color[2]
-                image.data[k + 3] = 255
-            }
-    }
-}
-
-let drawn = 0
-const counts = {}
-for (const building of seated.buildings) {
-    const lon = building.lon ?? building.center?.lon
-    const lat = building.lat ?? building.center?.lat
-    if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue
-    const color = COLORS[building.placementSource] || [255, 0, 0]
-    counts[building.placementSource] = (counts[building.placementSource] || 0) + 1
-    const ring = candidateFootprint({
-        lon, lat, width: building.width, depth: building.depth, heading: building.heading,
-    }).geometry.coordinates[0].map(project)
-    for (let i = 0; i < ring.length - 1; i++) draw(ring[i], ring[i + 1], color, 0)
-    drawn++
-}
-
-const out = resolve(cityDir, 'city-model-coverage.png')
-writeFileSync(out, PNG.sync.write(image))
-// A 1:1 crop of the city core, where the user reported missing and misplaced buildings.
-const cropSize = 1400
-const [cx, cy] = project([110.3965, 28.4570])
-const crop = new PNG({ width: cropSize, height: cropSize })
-PNG.bitblt(image, crop, Math.round(cx - cropSize / 2), Math.round(cy - cropSize / 2), cropSize, cropSize, 0, 0)
-const cropOut = resolve(cityDir, 'city-model-coverage-core.png')
-writeFileSync(cropOut, PNG.sync.write(crop))
-console.log(JSON.stringify({
-    drawn,
-    bySource: counts,
-    full: { path: out, size: [image.width, image.height] },
-    coreCrop: { path: cropOut, size: [cropSize, cropSize] },
-    legend: 'yellow = satellite roof segmentation, cyan = mapped OSM footprint',
-}))

+ 0 - 345
tools/city-massing/generate-oldtown-fill.mjs

@@ -1,345 +0,0 @@
-// Source-backed building plan. A building is only created where an independent footprint source
-// says there is one: mapped OSM polygons and imagery-segmented roofs. Road lines are used purely
-// as exclusion geometry (never as evidence that a building exists).
-import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { createHash } from 'node:crypto'
-import * as turf from '@turf/turf'
-import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
-import { candidateFootprint, isSafeOldTownPlacement } from './oldtown-geometry.mjs'
-
-const dir = resolve('tools/city-massing/city')
-const out = resolve(dir, 'oldtown')
-mkdirSync(out, { recursive: true })
-const masks = JSON.parse(readFileSync(resolve(dir, 'residential-clipped.geojson'))).features
-const roads = JSON.parse(readFileSync(resolve(dir, 'roads-clipped.geojson'))).features
-const water = JSON.parse(readFileSync(resolve(dir, 'water-clipped.geojson'))).features
-const osmBuildings = JSON.parse(readFileSync(resolve(dir, 'osm-buildings-clipped.geojson'))).features
-const modeledRegular = JSON.parse(readFileSync(resolve(dir, 'regular-estate-roofs.geojson'))).features
-const districtSources = new Set(
-    JSON.parse(readFileSync(resolve('tools/city-massing/district/lod-preview/manifest.json')))
-        .cells.flatMap((cell) => cell.sourceIds)
-)
-const imageryPath = resolve(dir, 'imagery-footprints.geojson')
-const imagery = existsSync(imageryPath)
-    ? JSON.parse(readFileSync(imageryPath)).features
-    : []
-
-// Mask reliability, decided by reading the 1:1 audit crops
-// (tools/city-massing/city/imagery-footprints-verify.png), not by the segmentation itself.
-// Mask 63 is a riverbank sliver dominated by greenhouses and field strips; mask 130 is an
-// industry park where bright concrete yards read as roofs. Neither is trustworthy as housing, so
-// their imagery footprints are excluded instead of being turned into buildings.
-const IMAGERY_TRUSTED_MASKS = new Set([65, 66, 126])
-
-// Carriageway exclusion: a built module may sit beside a street but never on the mapped roadway.
-const ROAD_BUFFER_M = 3.5
-const roadCorridors = roads.map((road) => {
-    try {
-        return turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' })
-    } catch {
-        return null
-    }
-}).filter(Boolean)
-
-// Families the approved library can carry without distorting facades. Nominal footprints and the
-// allowed scale window come from asset-fit.json, the same contract the Blender exporter enforces.
-const assetFit = JSON.parse(readFileSync(resolve('tools/city-massing/asset-fit.json'), 'utf8'))
-const FAMILIES = Object.entries(assetFit.variants).map(([key, value]) => ({ key, ...value }))
-const LATTICE = assetFit.lattice
-const VARIANTS = {
-    house: ['house-2f', 'house-4f', 'house'],
-    slab: ['slab-4f', 'slab'],
-    midrise: ['midrise-8f', 'midrise'],
-    'midrise-wide': ['midrise-wide', 'midrise'],
-}
-
-const roots = []
-const excluded = []
-
-const digest = (value) => Number.parseInt(createHash('sha1').update(String(value)).digest('hex').slice(0, 8), 16)
-
-function orientedShape(feature) {
-    const ring = feature.geometry.coordinates[0].slice(0, -1)
-    const center = ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length], [0, 0])
-    const cosLat = Math.cos((center[1] * Math.PI) / 180)
-    const metresPerLon = 111320 * cosLat
-    const points = ring.map(([lon, lat]) => [lon * metresPerLon, lat * 111320])
-    let best = null
-    for (let i = 0; i < points.length; i++) {
-        const a = points[i], b = points[(i + 1) % points.length]
-        const dx = b[0] - a[0], dy = b[1] - a[1], length = Math.hypot(dx, dy)
-        if (!length) continue
-        const u = [dx / length, dy / length], v = [-u[1], u[0]]
-        const projected = points.map((p) => [p[0] * u[0] + p[1] * u[1], p[0] * v[0] + p[1] * v[1]])
-        const minX = Math.min(...projected.map((p) => p[0])), maxX = Math.max(...projected.map((p) => p[0]))
-        const minY = Math.min(...projected.map((p) => p[1])), maxY = Math.max(...projected.map((p) => p[1]))
-        const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]]
-        const error = Math.max(...corners.map((corner) => Math.min(
-            ...projected.map((p) => Math.hypot(p[0] - corner[0], p[1] - corner[1]))
-        )))
-        if (!best || error < best.error)
-            best = { center, width: maxX - minX, depth: maxY - minY, heading: Math.atan2(u[1], u[0]), error, u, v, metresPerLon }
-    }
-    return best
-}
-
-function familyFor(width, depth) {
-    const ranked = []
-    for (const family of FAMILIES) {
-        const [nominalAlong, nominalAcross] = family.nominal_m
-        for (let rows = 1; rows <= 6; rows++) {
-            for (let cols = 1; cols <= 10; cols++) {
-                if (rows * cols > LATTICE.maxModulesPerFootprint) continue
-                const moduleAlong = (width - 2 * LATTICE.inset_m - LATTICE.gap_m * (cols - 1)) / cols
-                const moduleAcross = (depth - 2 * LATTICE.inset_m - LATTICE.gap_m * (rows - 1)) / rows
-                if (moduleAlong <= 0 || moduleAcross <= 0) continue
-                const alongScale = moduleAlong / nominalAlong
-                const acrossScale = moduleAcross / nominalAcross
-                if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) continue
-                if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) continue
-                // Prefer the family whose natural proportions match the module, then the tiling
-                // that uses the fewest, least-stretched modules.
-                ranked.push({
-                    family, rows, cols, moduleAlong, moduleAcross,
-                    alongScale, acrossScale,
-                    penalty: Math.abs(Math.log((nominalAlong / nominalAcross) / (moduleAlong / moduleAcross))) * 0.6 +
-                        Math.abs(Math.log(alongScale)) + Math.abs(Math.log(acrossScale)) +
-                        (rows * cols - 1) * 0.12,
-                })
-            }
-        }
-    }
-    ranked.sort((a, b) => a.penalty - b.penalty)
-    return ranked[0] || null
-}
-
-function modulesFor(feature, shape, seed) {
-    const width = Math.max(shape.width, shape.depth)
-    const depth = Math.min(shape.width, shape.depth)
-    const alongIsWidth = shape.width >= shape.depth
-    const choice = familyFor(width, depth)
-    if (!choice) return null
-    const { family, rows, cols } = choice
-    const gap = LATTICE.gap_m
-    const heading = alongIsWidth ? shape.heading : shape.heading + Math.PI / 2
-    // The lattice is laid out inside the source polygon, not just inside its bounding rectangle:
-    // the measured outline error is removed from the usable area so modules cannot poke outside.
-    const margin = Math.min(2.5, Math.max(LATTICE.inset_m, shape.error + LATTICE.inset_m))
-    const usableWidth = width - 2 * margin
-    const usableDepth = depth - 2 * margin
-    if (usableWidth <= 0 || usableDepth <= 0) return null
-    const moduleAlong = (usableWidth - gap * (cols - 1)) / cols
-    const moduleAcross = (usableDepth - gap * (rows - 1)) / rows
-    if (moduleAlong <= 0 || moduleAcross <= 0) return null
-    const alongScale = moduleAlong / family.nominal_m[0]
-    const acrossScale = moduleAcross / family.nominal_m[1]
-    if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) return null
-    if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) return null
-    const modules = []
-    for (let row = 0; row < rows; row++) {
-        for (let col = 0; col < cols; col++) {
-            const alongOffset = -usableWidth / 2 + moduleAlong / 2 + col * (moduleAlong + gap)
-            const acrossOffset = -usableDepth / 2 + moduleAcross / 2 + row * (moduleAcross + gap)
-            const ux = alongIsWidth ? shape.u[0] : shape.v[0]
-            const uy = alongIsWidth ? shape.u[1] : shape.v[1]
-            const vx = alongIsWidth ? shape.v[0] : shape.u[0]
-            const vy = alongIsWidth ? shape.v[1] : shape.u[1]
-            const localX = ux * alongOffset + vx * acrossOffset
-            const localY = uy * alongOffset + vy * acrossOffset
-            const lon = shape.center[0] + localX / shape.metresPerLon
-            const lat = shape.center[1] + localY / 111320
-            let footprint = null
-            // A module whose corners still escape the source polygon is shrunk before being refused.
-            for (const shrink of [0, 0.6, 1.2, 1.8]) {
-                const candidate = candidateFootprint({
-                    lon, lat, width: moduleAlong - shrink, depth: moduleAcross - shrink, heading,
-                })
-                if (turf.booleanWithin(candidate, feature)) {
-                    footprint = candidate
-                    break
-                }
-            }
-            if (!footprint) return null
-            modules.push({
-                id: `${feature.properties.id}-m${row}-${col}`,
-                sourceId: feature.properties.id,
-                sourceKind: feature.properties.sourceKind,
-                placementSource: feature.properties.sourceKind === 'imagery-segmentation'
-                    ? 'imagery-roof-segmentation'
-                    : 'osm-building-footprint',
-                sourceMask: null,
-                sourceEvidence: feature.properties.evidence || 'mapped OSM building polygon',
-                roadSource: null,
-                roadType: null,
-                lon,
-                lat,
-                width: moduleAlong,
-                depth: moduleAcross,
-                heading,
-                archetype: family.key,
-                preferredAssets: VARIANTS[family.key],
-                floors: family.key === 'house' ? [2, 3, 4][seed % 3]
-                    : family.key === 'slab' ? (seed % 2 ? 4 : 6)
-                        : family.key === 'midrise' ? (seed % 2 ? 8 : 10)
-                            : 10,
-                heightSource: 'estimated',
-                sourceFootprintError: shape.error,
-                sourcePart: row * cols + col,
-                sourceModuleCount: rows * cols,
-            })
-        }
-    }
-    return modules
-}
-
-function footprintCandidates() {
-    const list = []
-    for (const feature of osmBuildings) {
-        const id = `osm-${feature.properties.osm_id}`
-        if (districtSources.has(id)) {
-            excluded.push({ id, reason: 'already represented by the audited district sample' })
-            continue
-        }
-        if (feature.geometry.coordinates[0].length - 1 > 8) {
-            excluded.push({ id, reason: 'irregular OSM outline; needs custom geometry rather than a rectangle' })
-            continue
-        }
-        list.push({
-            ...feature,
-            properties: {
-                ...feature.properties,
-                id,
-                sourceKind: 'osm-building',
-                evidence: `mapped OSM building polygon ${feature.properties.osm_id}`,
-            },
-        })
-    }
-    for (const feature of imagery) {
-        if (!IMAGERY_TRUSTED_MASKS.has(feature.properties.maskId)) {
-            excluded.push({
-                id: feature.properties.id,
-                reason: `imagery mask ${feature.properties.maskId} is not a trusted building area (see imagery-footprints-verify.png)`,
-            })
-            continue
-        }
-        list.push({
-            ...feature,
-            properties: {
-                ...feature.properties,
-                sourceKind: 'imagery-segmentation',
-                evidence: `satellite roof segmentation ${feature.properties.id}`,
-            },
-        })
-    }
-    return list
-}
-
-const candidates = footprintCandidates()
-const accepted = []
-for (const feature of candidates) {
-    const id = feature.properties.id
-    if (!isWithinPrimaryArea(feature)) {
-        excluded.push({ id, reason: 'footprint outside the configured primary area' })
-        continue
-    }
-    const mask = masks.find((candidate) => turf.booleanWithin(feature, candidate))
-    if (!mask) {
-        excluded.push({ id, reason: 'footprint is not fully inside a verified residential mask' })
-        continue
-    }
-    if (water.some((polygon) => turf.booleanIntersects(feature, polygon))) {
-        excluded.push({ id, reason: 'footprint intersects mapped water' })
-        continue
-    }
-    if (modeledRegular.some((roof) => turf.booleanIntersects(feature, roof))) {
-        excluded.push({ id, reason: 'overlaps the separately traced regular estate' })
-        continue
-    }
-    const shape = orientedShape(feature)
-    if (!shape || shape.error > 1.75) {
-        excluded.push({ id, reason: 'source outline is not rectangular enough for the approved assets' })
-        continue
-    }
-    const modules = modulesFor(feature, shape, digest(id))
-    if (!modules) {
-        excluded.push({ id, reason: 'approved asset library cannot carry this footprint without distortion' })
-        continue
-    }
-    const placed = []
-    let blocked = null
-    for (const module of modules) {
-        const footprint = candidateFootprint(module)
-        if (roadCorridors.some((corridor) => turf.booleanIntersects(footprint, corridor))) {
-            blocked = 'module would sit on the mapped roadway'
-            break
-        }
-        // Imagery segments are new geometry, so they must also avoid already mapped OSM buildings.
-        const already = feature.properties.sourceKind === 'imagery-segmentation' ? osmBuildings : []
-        if (!isSafeOldTownPlacement(footprint, {
-            mask,
-            roads,
-            water,
-            existing: already,
-            modeled: modeledRegular,
-            accepted: accepted.map((entry) => entry.footprint),
-        })) {
-            blocked = 'module collides with mapped road, water, building or an accepted module'
-            break
-        }
-        placed.push({ module, footprint })
-    }
-    if (blocked || placed.length !== modules.length) {
-        excluded.push({ id, reason: blocked || 'part of the source footprint could not be placed safely' })
-        continue
-    }
-    for (const entry of placed)
-        accepted.push({ ...entry.module, sourceMask: `resa-${mask.properties.ORIG_FID}`, footprint: entry.footprint })
-    roots.push({
-        id,
-        sourceKind: feature.properties.sourceKind,
-        maskId: mask.properties.ORIG_FID,
-        moduleIds: placed.map((entry) => entry.module.id),
-        areaSqm: Math.round(turf.area(feature)),
-        longSideM: Math.round(Math.max(shape.width, shape.depth) * 10) / 10,
-        shortSideM: Math.round(Math.min(shape.width, shape.depth) * 10) / 10,
-        headingDeg: Math.round(((shape.heading * 180) / Math.PI) * 10) / 10,
-        outlineErrorM: Math.round(shape.error * 100) / 100,
-    })
-}
-
-const reasonCounts = {}
-for (const entry of excluded) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1
-const plan = {
-    version: 4,
-    seed: 20260924,
-    scope: 'source-backed urban fill from mapped OSM polygons and satellite roof segmentation',
-    bounds: [...PRIMARY_AREA_BOUNDS],
-    origin: { lon: 110.395, lat: 28.454 },
-    source: 'mapped OSM building polygons + satellite roof segmentation; road/water/mask exclusion',
-    policy: 'A building exists only where a footprint source says so. Roads are never building evidence.',
-    buildings: accepted.map(({ footprint, ...module }) => module),
-    sources: roots,
-    excluded,
-    exclusionReasons: reasonCounts,
-    sourceMasks: masks.map((mask) => mask.properties.ORIG_FID),
-    disclaimer:
-        'Illustrative massing on source footprints; heights/facades estimated. Missing or irregular source footprints remain unmodeled rather than guessed.',
-}
-plan.sha256 = createHash('sha256').update(JSON.stringify(plan.buildings)).digest('hex')
-writeFileSync(resolve(out, 'plan.json'), JSON.stringify(plan, null, 2))
-console.log(JSON.stringify({
-    footprintCandidates: candidates.length,
-    osmCandidates: osmBuildings.length,
-    imageryCandidates: imagery.length,
-    sourceBuildings: roots.length,
-    modules: plan.buildings.length,
-    byKind: {
-        osm: roots.filter((r) => r.sourceKind === 'osm-building').length,
-        imagery: roots.filter((r) => r.sourceKind === 'imagery-segmentation').length,
-    },
-    byAsset: Object.fromEntries([...new Set(plan.buildings.map((b) => b.archetype))]
-        .map((key) => [key, plan.buildings.filter((b) => b.archetype === key).length])),
-    exclusionReasons: reasonCounts,
-    sha256: plan.sha256,
-}))

+ 0 - 27
tools/city-massing/make-root-anchor.mjs

@@ -1,27 +0,0 @@
-// Build a valid empty-scene GLB so Cesium can traverse a contentless spatial root reliably.
-import { Buffer } from 'node:buffer'
-import { mkdirSync, writeFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-const json = Buffer.from(JSON.stringify({
-    asset: { version: '2.0', generator: 'ticket35 root anchor' },
-    scene: 0,
-    scenes: [{ nodes: [] }],
-}))
-const padded = Buffer.concat([json, Buffer.alloc((4 - json.length % 4) % 4, 0x20)])
-const header = Buffer.alloc(12)
-header.write('glTF')
-header.writeUInt32LE(2, 4)
-header.writeUInt32LE(12 + 8 + padded.length, 8)
-const chunk = Buffer.alloc(8)
-chunk.writeUInt32LE(padded.length)
-chunk.write('JSON', 4)
-const glb = Buffer.concat([header, chunk, padded])
-if (glb.readUInt32LE(8) !== glb.length || glb.toString('ascii', 16, 20) !== 'JSON') {
-    throw new Error('Invalid GLB header')
-}
-JSON.parse(glb.toString('utf8', 20, 20 + glb.readUInt32LE(12)))
-for (const dir of ['tools/city-massing/district/lod-preview', 'tools/city-massing/city/regular-estate/lod-preview', 'tools/city-massing/city/oldtown/lod-preview']) {
-    mkdirSync(resolve(dir), { recursive: true })
-    writeFileSync(resolve(dir, 'root-anchor.glb'), glb)
-}
-console.log({ bytes: glb.length, outputs: 3 })

+ 0 - 26
tools/city-massing/oldtown-geometry.mjs

@@ -1,26 +0,0 @@
-import * as turf from '@turf/turf'
-
-export function candidateFootprint({ lon, lat, width, depth, heading }) {
-    const cosLat = Math.cos((lat * Math.PI) / 180)
-    const dx = (Math.cos(heading) * width) / 111320
-    const dy = (Math.sin(heading) * width) / (111320 * cosLat)
-    const ex = (-Math.sin(heading) * depth) / (111320 * cosLat)
-    const ey = (Math.cos(heading) * depth) / 111320
-    return turf.polygon([[
-        [lon - dx / 2 - ex / 2, lat - dy / 2 - ey / 2],
-        [lon + dx / 2 - ex / 2, lat + dy / 2 - ey / 2],
-        [lon + dx / 2 + ex / 2, lat + dy / 2 + ey / 2],
-        [lon - dx / 2 + ex / 2, lat - dy / 2 + ey / 2],
-        [lon - dx / 2 - ex / 2, lat - dy / 2 - ey / 2],
-    ]])
-}
-
-export function isSafeOldTownPlacement(footprint, { mask, roads, water, existing, modeled, accepted }) {
-    if (!turf.booleanWithin(footprint, mask)) return false
-    if (roads.some(road => turf.booleanIntersects(footprint, road))) return false
-    if (water.some(polygon => turf.booleanIntersects(footprint, polygon))) return false
-    if (existing.some(building => turf.booleanIntersects(footprint, building))) return false
-    if (modeled.some(building => turf.booleanIntersects(footprint, building))) return false
-    if (accepted.some(building => turf.booleanIntersects(footprint, building))) return false
-    return true
-}

+ 0 - 40
tools/city-massing/oldtown-geometry.test.mjs

@@ -1,40 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { polygon, lineString, featureCollection } from '@turf/turf'
-import { candidateFootprint, isSafeOldTownPlacement } from './oldtown-geometry.mjs'
-
-const mask = polygon([[[0,0],[.01,0],[.01,.01],[0,.01],[0,0]]])
-const at = (lon, lat, width=8, depth=7) => ({lon,lat,width,depth,heading:0})
-
-test('rejects a rotated candidate footprint crossing the residential mask edge', () => {
-  const footprint = candidateFootprint(at(.00999,.005,30,12))
-  assert.equal(isSafeOldTownPlacement(footprint,{mask,roads:[],water:[],existing:[],modeled:[],accepted:[]}),false)
-})
-
-test('rejects a footprint intersecting an existing building even if its center lies outside', () => {
-  const footprint = candidateFootprint(at(.005,.005,16,8))
-  const existing = polygon([[[.00505,.00498],[.00512,.00498],[.00512,.00502],[.00505,.00502],[.00505,.00498]]])
-  assert.equal(isSafeOldTownPlacement(footprint,{mask,roads:[],water:[],existing:[existing],modeled:[],accepted:[]}),false)
-})
-
-test('rejects collision with a previously accepted procedural footprint', () => {
-  const first = candidateFootprint(at(.005,.005,10,8))
-  const second = candidateFootprint(at(.00504,.005,10,8))
-  assert.equal(isSafeOldTownPlacement(second,{mask,roads:[],water:[],existing:[],modeled:[],accepted:[first]}),false)
-})
-
-test('rejects roads, waterways and already-modeled regular-estate roofs', () => {
-  const footprint = candidateFootprint(at(.005,.005))
-  const road = lineString([[.004,.005],[.006,.005]])
-  const water = polygon([[[.004,.004],[.006,.004],[.006,.006],[.004,.006],[.004,.004]]])
-  for (const options of [
-    {roads:[road]},
-    {water:[water]},
-    {modeled:[water]},
-  ]) assert.equal(isSafeOldTownPlacement(footprint,{mask,roads:[],water:[],existing:[],modeled:[],accepted:[],...options}),false)
-})
-
-test('accepts a non-overlapping candidate inside a valid residential mask', () => {
-  const footprint = candidateFootprint(at(.005,.005))
-  assert.equal(isSafeOldTownPlacement(footprint,{mask,roads:[],water:[],existing:[],modeled:[],accepted:[]}),true)
-})

+ 0 - 61
tools/city-massing/prepare-oldtown-dem.mjs

@@ -1,61 +0,0 @@
-// Expand old-town candidates into actual ENU footprints and bind them to original DEM samples.
-import { readFileSync, writeFileSync } from 'node:fs'
-import { Cartesian3, Cartographic, Matrix4, Transforms } from 'cesium'
-const dir = 'tools/city-massing/city/oldtown/',
-    plan = JSON.parse(readFileSync(dir + 'plan.json'))
-const origin = { lon: 110.395, lat: 28.454 },
-    transform = Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(origin.lon, origin.lat)),
-    inverse = Matrix4.inverseTransformation(transform, new Matrix4())
-const toLocal = (lon, lat) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3())
-const toGeo = (q) => {
-    const c = Cartographic.fromCartesian(Matrix4.multiplyByPoint(transform, new Cartesian3(...q), new Cartesian3()))
-    return { lon: (c.longitude * 180) / Math.PI, lat: (c.latitude * 180) / Math.PI }
-}
-const modules = []
-const samples = []
-for (const b of plan.buildings) {
-    const center = toLocal(b.lon, b.lat)
-    const u = [Math.cos(b.heading), Math.sin(b.heading)],
-        v = [-u[1], u[0]]
-    const corners = [
-        [-b.width / 2, -b.depth / 2],
-        [b.width / 2, -b.depth / 2],
-        [b.width / 2, b.depth / 2],
-        [-b.width / 2, b.depth / 2],
-    ].map(([x, y]) => [center.x + u[0] * x + v[0] * y, center.y + u[1] * x + v[1] * y, 0])
-    const pts = [...corners, [center.x, center.y, 0]]
-    // Interior samples at 5 m spacing so terrain relief is measured across the real footprint,
-    // not inferred from four corners.
-    const cols = Math.max(1, Math.ceil(b.width / 5))
-    const rows = Math.max(1, Math.ceil(b.depth / 5))
-    for (let row = 0; row <= rows; row++)
-        for (let col = 0; col <= cols; col++) {
-            const lx = -b.width / 2 + (b.width * col) / cols
-            const ly = -b.depth / 2 + (b.depth * row) / rows
-            pts.push([center.x + u[0] * lx + v[0] * ly, center.y + u[1] * lx + v[1] * ly, 0])
-        }
-    const sampleIndices = pts.map((p) => {
-        samples.push({ ...toGeo(p), building: b.id })
-        return samples.length - 1
-    })
-    modules.push({
-        ...b,
-        sourceId: b.sourceId || b.id,
-        center: toGeo([center.x, center.y, 0]),
-        centerENU: [center.x, center.y, 0],
-        footprintENU: corners,
-        archetype: b.archetype || 'house',
-        sampleIndices,
-    })
-}
-const out = {
-    ...plan,
-    origin,
-    transform: Matrix4.toArray(transform),
-    modules,
-    samples,
-    source: 'residential masks + bounded road/water exclusion + direct original DEM corners/center',
-    disclaimer: plan.disclaimer + ' Terrain seating is validated separately against the original quantized mesh.',
-}
-writeFileSync(dir + 'plan.json', JSON.stringify(out, null, 2))
-console.log({ modules: modules.length, samples: samples.length })

+ 0 - 179
tools/city-massing/preview-district.mjs

@@ -1,179 +0,0 @@
-// Real Cesium/DEM screenshots, with camera range readback. No live config is modified.
-import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { PNG } from 'pngjs'
-const BASE = process.env.MODEL_REVIEW_BASE || 'http://127.0.0.1:8092'
-const DIR = resolve('tools/city-massing/district/generated')
-const OUT = resolve('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/district')
-mkdirSync(OUT, { recursive: true })
-const plan = JSON.parse(readFileSync(resolve(DIR, 'placement-report.json')))
-const puppeteer = (
-    await import(
-        'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
-    )
-).default
-const browser = await puppeteer.launch({
-    executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
-    headless: true,
-    defaultViewport: { width: 1500, height: 1000 },
-    args: ['--no-sandbox'],
-})
-const images = [],
-    requests = [],
-    errors = []
-const page = await browser.newPage()
-page.on('pageerror', (e) => errors.push(e.message))
-page.on('response', (r) => {
-    if (r.url().endsWith('.glb')) requests.push({ url: r.url(), status: r.status() })
-})
-try {
-    await page.goto(BASE + '/preview.html#automation', { waitUntil: 'domcontentloaded' })
-    await page.waitForFunction(() => typeof window.startPreview === 'function')
-    await page.evaluate(() => window.startPreview({
-        models: [
-            '/review-assets/district/lod-preview/tileset.json',
-            '/review-assets/city/regular-estate/lod-preview/tileset.json',
-            '/review-assets/city/oldtown/lod-preview/tileset.json',
-        ],
-    }))
-    const apartment =
-        plan.buildings.find((b) => b.sourceId === 'osm-1285336673') ||
-        plan.buildings.find((b) => b.asset.includes('slab'))
-    const tower = plan.buildings.find((b) => b.asset.includes('midrise'))
-    const cases = [
-        {
-            id: '01-satellite-control',
-            lon: 110.395,
-            lat: 28.454,
-            height: 135,
-            range: 1500,
-            pitch: -89,
-            heading: 0,
-            show: false,
-            label: '原始卫星影像 · 屋顶对照',
-        },
-        {
-            id: '02-neighborhood-overview',
-            lon: 110.395,
-            lat: 28.454,
-            height: 135,
-            range: 1300,
-            pitch: -50,
-            heading: 10,
-            show: true,
-            label: '示范街区 · 有来源的逐栋布置 · 未覆盖区域保留原影像',
-        },
-        {
-            id: '03-near-apartment',
-            ...apartment.center,
-            height: apartment.floorHeight + 9,
-            range: 230,
-            pitch: -30,
-            heading: 200,
-            show: true,
-            label: '多层住宅 · 230m 实际视距 · 门窗 / 阳台 / 屋檐',
-        },
-        {
-            id: '04-near-tower',
-            ...tower.center,
-            height: tower.floorHeight + 18,
-            range: 300,
-            pitch: -25,
-            heading: 170,
-            show: true,
-            label: '较高住宅 · 300m 实际视距 · 台阶屋顶 / 层次变化',
-        },
-        {
-            id: '05-top-built',
-            lon: 110.395,
-            lat: 28.454,
-            height: 135,
-            range: 1500,
-            pitch: -89,
-            heading: 0,
-            show: true,
-            label: '同视角顶视 · 与原始影像轮廓核对',
-        },
-        {
-            id: '06-near-facade',
-            ...apartment.center,
-            height: apartment.floorHeight + 8,
-            range: 150,
-            pitch: -22,
-            heading: 200,
-            show: true,
-            label: '住宅立面 · 150m 实际视距 · 非特写近景',
-        },
-        {
-            id: '07-mobile-near',
-            ...apartment.center,
-            height: apartment.floorHeight + 8,
-            range: 230,
-            pitch: -26,
-            heading: 200,
-            show: true,
-            mobile: true,
-            label: '窄屏样片 · 230m 实际视距',
-        },
-    ]
-    for (const a of cases) {
-        if (a.mobile) {
-            await page.setViewport({ width: 430, height: 932 })
-            await page.evaluate(() => window.scene35.v.resize())
-        }
-        await page.evaluate((a) => {
-            const { C, v, tiles } = window.scene35
-            tiles.forEach((t) => (t.show = a.show))
-            v.camera.lookAtTransform(
-                C.Transforms.eastNorthUpToFixedFrame(C.Cartesian3.fromDegrees(a.lon, a.lat, a.height)),
-                new C.HeadingPitchRange(C.Math.toRadians(a.heading), C.Math.toRadians(a.pitch), a.range)
-            )
-            document.querySelector('#caption').textContent = a.label + ' | 立面与楼层为估计,非测绘数据'
-        }, a)
-        await page.waitForFunction(() => window.scene35.v.scene.globe._surface._tilesToRender.length > 0, {
-            timeout: 45000,
-        })
-        if (a.show)
-            await page.waitForFunction(() => window.scene35.tiles.some(tile => tile.root?.content && tile.tilesLoaded), {
-                timeout: 45000,
-            })
-        const f = await page.evaluate(() => window.frameCount)
-        await page.waitForFunction((f) => window.frameCount > f + 60, { timeout: 20000 }, f)
-        const state = await page.evaluate(() => {
-            const { v, tiles, E, C } = window.scene35
-            return {
-                range: C.Cartesian3.magnitude(v.camera.position),
-                depth: v.scene.globe.depthTestAgainstTerrain,
-                groundTiles: v.scene.globe._surface._tilesToRender.length,
-                tiles: E.readTilesetDiagnostics(tiles),
-            }
-        })
-        if (Math.abs(state.range - a.range) > 1) throw Error('Camera range was not applied')
-        const file = resolve(OUT, a.id + '.png')
-        const png = PNG.sync.read(Buffer.from(await page.screenshot({ path: file })))
-        // Capture the same canvas with the model hidden; actual model pixels must differ in the central scene.
-        let modelPixels = null
-        if (a.show) {
-            await page.evaluate(() => window.scene35.tiles.forEach((t) => (t.show = false)))
-            const hiddenFrame = await page.evaluate(() => window.frameCount)
-            await page.waitForFunction((f) => window.frameCount > f + 3, {}, hiddenFrame)
-            const hidden = PNG.sync.read(Buffer.from(await page.screenshot()))
-            modelPixels = 0
-            for (let y = 60; y < png.height - 100; y++) for (let x = 10; x < png.width - 10; x++) {
-                const i = (y * png.width + x) * 4
-                if (Math.abs(png.data[i] - hidden.data[i]) + Math.abs(png.data[i + 1] - hidden.data[i + 1]) + Math.abs(png.data[i + 2] - hidden.data[i + 2]) > 45) modelPixels++
-            }
-            if (modelPixels < 100) throw Error('Model has no visible rendered canvas pixels at ' + a.id)
-            await page.evaluate(() => window.scene35.tiles.forEach((t) => (t.show = true)))
-        }
-        images.push({ ...a, ...state, modelPixels, file })
-        console.log(a.id, JSON.stringify(state))
-    }
-    writeFileSync(resolve(OUT, 'report.json'), JSON.stringify({ images, requests, errors }, null, 2))
-    if (errors.length) throw Error(errors.join('\n'))
-} catch (error) {
-    await page.screenshot({ path: resolve(OUT, 'failure.png') })
-    throw error
-} finally {
-    await browser.close()
-}

+ 0 - 116
tools/city-massing/probe-gis-sources.mjs

@@ -1,116 +0,0 @@
-// Identify the untouched local sources with bounded memory:
-//  1) raster header (resolution / extent) without loading the file,
-//  2) attribute text inside the large geodatabase tables.
-import { openSync, readSync, closeSync, statSync } from 'node:fs'
-
-function tiffSummary(path) {
-    const size = statSync(path).size
-    const fd = openSync(path, 'r')
-    const head = Buffer.alloc(8)
-    readSync(fd, head, 0, 8, 0)
-    const little = head.toString('latin1', 0, 2) === 'II'
-    const ifdOffset = little ? head.readUInt32LE(4) : head.readUInt32BE(4)
-    const countBuf = Buffer.alloc(2)
-    readSync(fd, countBuf, 0, 2, ifdOffset)
-    const count = little ? countBuf.readUInt16LE(0) : countBuf.readUInt16BE(0)
-    const entries = Buffer.alloc(count * 12)
-    readSync(fd, entries, 0, entries.length, ifdOffset + 2)
-    const NAMES = { 256: 'width', 257: 'height', 258: 'bits', 259: 'compression', 33550: 'pixelScale', 33922: 'tiepoint', 34735: 'geoKeys', 34737: 'geoAscii', 42112: 'gdaldoc', 42113: 'nodata' }
-    const tags = {}
-    const data = []
-    for (let i = 0; i < count; i++) {
-        const base = i * 12
-        const tag = little ? entries.readUInt16LE(base) : entries.readUInt16BE(base)
-        const type = little ? entries.readUInt16LE(base + 2) : entries.readUInt16BE(base + 2)
-        const num = little ? entries.readUInt32LE(base + 4) : entries.readUInt32BE(base + 4)
-        const sizes = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8 }
-        const total = (sizes[type] || 1) * num
-        if (!NAMES[tag]) continue
-        if (total <= 4) {
-            const raw = entries.subarray(base + 8, base + 12)
-            tags[NAMES[tag]] = type === 3 ? (little ? raw.readUInt16LE(0) : raw.readUInt16BE(0)) : (little ? raw.readUInt32LE(0) : raw.readUInt32BE(0))
-        } else {
-            const pointer = little ? entries.readUInt32LE(base + 8) : entries.readUInt32BE(base + 8)
-            const payload = Buffer.alloc(Math.min(total, 4096))
-            readSync(fd, payload, 0, payload.length, pointer)
-            if (type === 12) {
-                const values = []
-                for (let v = 0; v + 8 <= payload.length && values.length < 16; v += 8)
-                    values.push(little ? payload.readDoubleLE(v) : payload.readDoubleBE(v))
-                tags[NAMES[tag]] = values
-            } else if (type === 2) {
-                tags[NAMES[tag]] = payload.toString('latin1').replace(/\0+$/, '').slice(0, 300)
-            } else if (type === 3) {
-                const values = []
-                for (let v = 0; v + 2 <= payload.length && values.length < 64; v += 2)
-                    values.push(little ? payload.readUInt16LE(v) : payload.readUInt16BE(v))
-                tags[NAMES[tag]] = values
-            }
-        }
-    }
-    closeSync(fd)
-    const scale = tags.pixelScale
-    const tie = tags.tiepoint
-    const resolution = scale ? Math.round(scale[0] * 1e6) / 1e6 : null
-    return {
-        sizeGB: Math.round((size / 1024 ** 3) * 100) / 100,
-        littleEndian: little,
-        width: tags.width,
-        height: tags.height,
-        bitsPerSample: tags.bits,
-        compression: tags.compression,
-        metresPerPixel: resolution,
-        upperLeftLonLat: tie && scale ? [tie[3], tie[4]] : null,
-        extentKm: width2(tags.width, resolution),
-    }
-    function width2(w, r) { return w && r ? [Math.round((w * r) / 100) / 10, Math.round((tags.height * r) / 100) / 10] : null }
-}
-
-const HINTS = /(建筑|房屋|砖混|砖木|框架|结构|层数|楼层|居民|住宅|楼房|厂房|building|floor|storey|structure)/
-
-/** Scan a big table in chunks, keeping only strings that look like real attribute values. */
-function scanTable(path, chunkSize = 4 * 1024 * 1024) {
-    const size = statSync(path).size
-    const fd = openSync(path, 'r')
-    const buffer = Buffer.alloc(chunkSize)
-    const hints = new Map()
-    const chinese = new Map()
-    let offset = 0
-    let carry = Buffer.alloc(0)
-    while (offset < size) {
-        const read = readSync(fd, buffer, 0, chunkSize, offset)
-        if (read <= 0) break
-        const data = carry.length ? Buffer.concat([carry, buffer.subarray(0, read)]) : buffer.subarray(0, read)
-        for (let i = 0; i + 2 <= data.length; i += 2) {
-            for (const [enc, readCode] of [['be', (k) => data.readUInt16BE(k)], ['le', (k) => data.readUInt16LE(k)]]) {
-                let s = '', j = i
-                while (j + 1 < data.length) {
-                    const code = readCode(j)
-                    if (code === 0 || code < 0x20 || code > 0xffe0) break
-                    s += String.fromCharCode(code); j += 2
-                    if (s.length > 24) { s = ''; break }
-                }
-                if (s.length < 2 || !/[\u4e00-\u9fff]/.test(s)) continue
-                if (HINTS.test(s)) hints.set(s, (hints.get(s) || 0) + 1)
-                else if (chinese.size < 400) chinese.set(s, (chinese.get(s) || 0) + 1)
-            }
-        }
-        carry = data.subarray(Math.max(0, data.length - 4))
-        offset += read
-    }
-    closeSync(fd)
-    return {
-        sizeMB: Math.round(size / 1024 / 1024),
-        buildingHints: [...hints.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30),
-        sampleChinese: [...chinese.keys()].slice(0, 40),
-    }
-}
-
-console.log('===== RASTER D:/GIS/ArcGIS/yuanling/yuanling20.tif')
-console.log(JSON.stringify(tiffSummary('D:/GIS/ArcGIS/yuanling/yuanling20.tif'), null, 1))
-
-for (const name of ['a00000009', 'a0000000a', 'a0000000b', 'a0000000c', 'a0000000d']) {
-    const path = `D:/GIS/ArcGIS/yuanling/DB/YUANLING.gdb/${name}.gdbtable`
-    console.log(`\n===== GDB TABLE ${name}`)
-    console.log(JSON.stringify(scanTable(path), null, 1))
-}

+ 0 - 7
tools/city-massing/sample-oldtown-browser.mjs

@@ -1,7 +0,0 @@
-// Runtime DEM sampler for the road-grouped old-town plan. Missing heights fail; no fallback.
-import { readFileSync, writeFileSync } from 'node:fs'
-const BASE=process.env.COCKPIT_BASE||'http://localhost:8001'
-const path='tools/city-massing/city/oldtown/plan.json',plan=JSON.parse(readFileSync(path))
-const puppeteer=(await import('file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js')).default
-const browser=await puppeteer.launch({executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true,args:['--no-sandbox']})
-try{const page=await browser.newPage();await page.goto(BASE,{waitUntil:'domcontentloaded'});const heights=await page.evaluate(async points=>{window.CESIUM_BASE_URL='/cesium/';const raw=await(await fetch('/src/modules/cockpit/services/cesiumEngine.js')).text();const url=raw.match(/from\s*["']([^"']*\/cesium\.js[^"']*)["']/)?.[1];if(!url)throw Error('Cesium import URL missing');const C=await import(url),provider=await C.CesiumTerrainProvider.fromUrl('/gis/dem',{requestVertexNormals:false});const out=[];for(let i=0;i<points.length;i+=256){const c=points.slice(i,i+256).map(p=>C.Cartographic.fromDegrees(p.lon,p.lat));const s=await C.sampleTerrainMostDetailed(provider,c,true);out.push(...s.map(p=>p.height))}if(out.some(h=>!Number.isFinite(h)||h<=1))throw Error(`Missing or invalid DEM height at ${out.findIndex(h=>!Number.isFinite(h)||h<=1)}`);return out},plan.samples);writeFileSync('tools/city-massing/city/oldtown/terrain-samples.json',JSON.stringify({provider:'/gis/dem',method:'runtime sampleTerrainMostDetailed, no fallback',planPoints:plan.samples,heights},null,2));console.log({points:heights.length,min:Math.min(...heights),max:Math.max(...heights)})}finally{await browser.close()}

+ 0 - 180
tools/city-massing/seat-oldtown.mjs

@@ -1,180 +0,0 @@
-// 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)),
-}))

+ 0 - 87
tools/city-massing/summarize-city-coverage.mjs

@@ -1,87 +0,0 @@
-// Truthful coverage ledger: source-backed, approximate and excluded artifacts stay separate.
-import { readFileSync, writeFileSync, existsSync } from 'node:fs'
-const read = (p) => JSON.parse(readFileSync(p, 'utf8'))
-const inventory = read('tools/city-massing/city/city-inventory.json')
-const regular = read('tools/city-massing/city/regular-estate/lod-preview/manifest.json')
-const oldtown = read('tools/city-massing/city/oldtown/lod-preview/manifest.json')
-const sample = read('tools/city-massing/district/lod-preview/manifest.json')
-const estatePlan = read('tools/city-massing/city/regular-estate/plan.json')
-const oldPlan = read('tools/city-massing/city/oldtown/plan.json')
-const seated = read('tools/city-massing/city/oldtown/seated-plan.json')
-const verification = existsSync('tools/city-massing/city/city-model-verification.json')
-    ? read('tools/city-massing/city/city-model-verification.json')
-    : null
-const imageryReport = existsSync('tools/city-massing/city/imagery-footprints-report.json')
-    ? read('tools/city-massing/city/imagery-footprints-report.json')
-    : null
-const placementSources = {}
-for (const building of oldPlan.buildings)
-    placementSources[building.placementSource] = (placementSources[building.placementSource] || 0) + 1
-const construction = {}
-for (const building of seated.buildings)
-    construction[building.construction || 'flat'] = (construction[building.construction || 'flat'] || 0) + 1
-const exclusionReasons = {}
-for (const entry of oldPlan.excluded) exclusionReasons[entry.reason] = (exclusionReasons[entry.reason] || 0) + 1
-const output = {
-    bounds: inventory.bounds,
-    sourceInventory: inventory.originalSources,
-    districts: inventory.districts,
-    sourceBacked: {
-        scope: regular.scope,
-        roofTraces: estatePlan.roofs.length,
-        acceptedModules: regular.count,
-        excludedModules: estatePlan.modules.length - regular.count,
-        excludedReasons: 'original DEM relief >2.5m',
-    },
-    oldTownBacked: {
-        scope: oldtown.scope,
-        placementSources,
-        footprintPlanned: oldPlan.buildings.length,
-        footprintSources: oldPlan.sources.length,
-        seated: seated.buildings.length,
-        seatedSources: new Set(seated.buildings.map((b) => b.sourceId)).size,
-        construction,
-        excludedFootprints: oldPlan.excluded.length,
-        excludedModules: seated.excluded.length,
-        exclusionReasons,
-        imagerySegmentation: imageryReport
-            ? {
-                accepted: imageryReport.totals.accepted,
-                trustedMasks: [65, 66, 126],
-                untrustedMasks: { 63: 'river/farmland sliver; greenhouse roofs', 130: 'industry park; bright yards read as roof' },
-                limitations: imageryReport.limitations,
-            }
-            : null,
-    },
-    previousSample: { scope: sample.scope, accepted: sample.count },
-    runtimeVerification: verification
-        ? {
-            passed: verification.passed,
-            checkedAt: verification.checkedAt,
-            trees: verification.summary,
-            sourceConflictCount: verification.sourceConflictCount,
-        }
-        : null,
-    runtime: {
-        defaultModelEnabled: false,
-        reviewUrl: 'http://127.0.0.1:8092/preview.html',
-        lightingFlag: 'map.tilesets[].lighting=true',
-        lod: 'coarse spatial parents with REPLACE LOD1/LOD0 children',
-    },
-    disclaimer:
-        'This ledger counts generated artifacts, not surveyed coverage. Heights and facades are estimated; imagery-segmented footprints are per-roof rectangles, not survey geometry; the estate source conflicts listed in city-model-verification.json still need a human decision.',
-}
-writeFileSync('tools/city-massing/city/coverage-summary.json', JSON.stringify(output, null, 2))
-console.log(
-    JSON.stringify({
-        regular: regular.count,
-        oldtownPlaced: oldtown.count,
-        oldtownSeatedSources: output.oldTownBacked.seatedSources,
-        construction,
-        imageryTrustedMasks: [65, 66, 126],
-        omittedEstate: output.sourceBacked.excludedModules,
-        omittedOldtownModules: seated.excluded.length,
-        verificationPassed: verification?.passed ?? null,
-        conflicts: verification?.sourceConflictCount ?? null,
-    })
-)

+ 0 - 144
tools/city-massing/verify-city-models.mjs

@@ -1,144 +0,0 @@
-// Acceptance checks for the generated city models: primary-area containment, road clearance and
-// source provenance. Reads the exported LOD manifests so it validates what Cesium actually loads.
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-import * as turf from '@turf/turf'
-import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
-import { candidateFootprint } from './oldtown-geometry.mjs'
-
-const cityDir = resolve('tools/city-massing/city')
-const ROAD_BUFFER_M = 3.5
-// Roads that carry traffic: a building overlapping one of these is an outright defect.
-const HARD_ROAD_TYPES = new Set(['trunk', 'trunk_link', 'primary', 'primary_link', 'secondary', 'secondary_link', 'tertiary', 'tertiary_link', 'motorway', 'motorway_link'])
-// Hand-mapped estate lanes and service ways disagree with imagery-traced roofs in this dataset.
-// Those are recorded as source conflicts for a human to settle, not silently resolved either way.
-const CONFLICT_ROAD_TYPES = new Set(['residential', 'unclassified', 'service', 'living_street', 'pedestrian', 'track'])
-const failures = []
-const conflicts = []
-const summary = {}
-
-function feature(building) {
-    // Plans differ in where the centre lives: city plans use lon/lat, the district plan uses center.
-    const lon = building.lon ?? building.center?.lon
-    const lat = building.lat ?? building.center?.lat
-    if (!Number.isFinite(lon) || !Number.isFinite(lat))
-        throw new Error(`Building ${building.id} has no usable centre coordinate`)
-    return candidateFootprint({ lon, lat, width: building.width, depth: building.depth, heading: building.heading })
-}
-
-function checkTrees() {
-    const trees = [
-        { id: 'oldtown', dir: resolve(cityDir, 'oldtown/lod-preview'), plan: resolve(cityDir, 'oldtown/seated-plan.json') },
-        { id: 'regular-estate', dir: resolve(cityDir, 'regular-estate/lod-preview'), plan: resolve(cityDir, 'regular-estate/seated-plan.json') },
-        { id: 'district', dir: resolve('tools/city-massing/district/lod-preview'), plan: resolve('tools/city-massing/district/seated-plan.json') },
-    ]
-    const roads = JSON.parse(readFileSync(resolve(cityDir, 'roads-clipped.geojson'))).features
-    const buffersOf = (types) => roads
-        .filter((road) => types.has(road.properties?.type || 'unclassified'))
-        .map((road) => {
-            try {
-                return { road, area: turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' }) }
-            } catch {
-                return null
-            }
-        })
-        .filter((entry) => entry && entry.area)
-    const hardRoads = buffersOf(HARD_ROAD_TYPES)
-    const conflictRoads = buffersOf(CONFLICT_ROAD_TYPES)
-
-    for (const tree of trees) {
-        const tileset = JSON.parse(readFileSync(resolve(tree.dir, 'tileset.json')))
-        const manifest = JSON.parse(readFileSync(resolve(tree.dir, 'manifest.json')))
-        const plan = JSON.parse(readFileSync(tree.plan))
-        const placedIds = new Set(manifest.cells.flatMap((cell) => cell.buildingIds))
-        const byId = new Map(plan.buildings.map((b) => [b.id, b]))
-        // Every reported count must be traceable to a seated building.
-        const missing = [...placedIds].filter((id) => !byId.has(id))
-        if (missing.length) failures.push(`${tree.id}: ${missing.length} placed ids have no seated building`)
-
-        let outsideArea = 0
-        let onRoad = 0
-        let offMask = 0
-        const masks = tree.id === 'district'
-            ? null
-            : JSON.parse(readFileSync(resolve(cityDir, 'residential-clipped.geojson'))).features
-        for (const id of placedIds) {
-            const building = byId.get(id)
-            if (!building) continue
-            const foot = feature(building)
-            if (!isWithinPrimaryArea(foot)) {
-                outsideArea++
-                if (outsideArea <= 3) failures.push(`${tree.id}: ${id} footprint outside the primary area`)
-            }
-            if (hardRoads.some((entry) => turf.booleanIntersects(foot, entry.area))) {
-                onRoad++
-                if (onRoad <= 3) failures.push(`${tree.id}: ${id} footprint sits on a mapped traffic road`)
-            }
-            for (const entry of conflictRoads) {
-                if (!turf.booleanIntersects(foot, entry.area)) continue
-                const overlap = turf.intersect(turf.featureCollection([foot, entry.area]))
-                const overlapArea = overlap ? turf.area(overlap) : 0
-                const share = overlapArea / turf.area(foot)
-                // Only meaningful overlap counts: a graze against a lane edge is noise.
-                if (share >= 0.15)
-                    conflicts.push({
-                        tree: tree.id,
-                        building: id,
-                        roadOsmId: entry.road.properties?.osm_id,
-                        roadType: entry.road.properties?.type,
-                        overlapPercent: Math.round(share * 100),
-                        note: 'imagery-traced footprint overlaps a mapped residential/service lane',
-                    })
-            }
-            if (masks && tree.id === 'oldtown' && !masks.some((mask) => turf.booleanWithin(foot, mask))) {
-                offMask++
-                if (offMask <= 3) failures.push(`${tree.id}: ${id} footprint is outside every verified residential mask`)
-            }
-        }
-        summary[tree.id] = {
-            placed: placedIds.size,
-            seatedInPlan: plan.buildings.length,
-            unfitted: manifest.excludedUnfittedIds?.length ?? 0,
-            cells: manifest.cellCount,
-            coarseCells: manifest.hierarchy?.coarseCellCount ?? 0,
-            outsidePrimaryArea: outsideArea,
-            onMappedTrafficRoad: onRoad,
-            outsideResidentialMask: offMask,
-            rootAnchor: tileset.root?.content?.uri || null,
-        }
-    }
-}
-
-function checkSourceProvenance() {
-    const plan = JSON.parse(readFileSync(resolve(cityDir, 'oldtown/plan.json')))
-    const kinds = {}
-    for (const building of plan.buildings) kinds[building.placementSource] = (kinds[building.placementSource] || 0) + 1
-    const roadDerived = plan.buildings.filter((b) => /road/i.test(b.placementSource) && !/footprint|segmentation/.test(b.placementSource))
-    if (roadDerived.length) failures.push(`oldtown: ${roadDerived.length} buildings are still road-derived rather than source-backed`)
-    // Every module must name the footprint source it came from, and that source must be recorded.
-    const sourceIds = new Set(plan.sources.map((s) => s.id))
-    const orphans = plan.buildings.filter((b) => !b.sourceId || !sourceIds.has(b.sourceId))
-    if (orphans.length) failures.push(`oldtown: ${orphans.length} modules have no recorded footprint source`)
-    summary.oldtownProvenance = {
-        placementSources: kinds,
-        footprintSources: plan.sources.length,
-        modulesWithRecordedSource: plan.buildings.length - orphans.length,
-    }
-}
-
-checkTrees()
-checkSourceProvenance()
-const report = {
-    passed: failures.length === 0,
-    primaryAreaBounds: [...PRIMARY_AREA_BOUNDS],
-    roadBufferMeters: ROAD_BUFFER_M,
-    checkedAt: new Date().toISOString(),
-    summary,
-    sourceConflicts: conflicts.slice(0, 40),
-    sourceConflictCount: conflicts.length,
-    failures: failures.slice(0, 20),
-}
-const out = resolve(cityDir, 'city-model-verification.json')
-import('node:fs').then(({ writeFileSync }) => writeFileSync(out, JSON.stringify(report, null, 2)))
-console.log(JSON.stringify(report, null, 2))
-if (failures.length) process.exitCode = 1