City labels were a 2D overlay that showed through buildings at street level. labels.js rasterises each label once into a shelf-packed atlas (partial texSubImage uploads, mipmapped for grazing angles), and a new instanced quad pass draws them depth-tested but not depth-writing. District names float above each district's tallest tower facing the camera, file names sit on roofs near the focus, walls facing a walker carry name signs repeated every 24 m, and street names are painted on nearby kerbs. Walking up to the building in the crosshair shows its source on the facing wall. Signs rebuild only when the focus, heading or selection changes; an overflowing atlas is cleared and refilled with the nearest labels first. Placement helpers are unit tested.
264 lines
13 KiB
JavaScript
264 lines
13 KiB
JavaScript
// Code as a city: folders become districts and blocks separated by roads, files become buildings.
|
|
// Everything here is plain data and maths in metres, so it can be tested without a browser.
|
|
|
|
// Squarified treemap: calls output(entry, x, y, w, h, depth) for each entry, largest first.
|
|
export function squarifiedLayout(entries, x, y, w, h, depth, output) {
|
|
if (!entries.length || w <= 0 || h <= 0) return;
|
|
const total = entries.reduce((sum, entry) => sum + Math.max(.0001, entry.weight), 0), scale = w * h / total;
|
|
const remaining = entries.map(entry => ({ entry, area: Math.max(.0001, entry.weight) * scale }));
|
|
const worst = (row, side) => {
|
|
if (!row.length) return Infinity;
|
|
let sum = 0, largest = 0, smallest = Infinity;
|
|
for (const item of row) { sum += item.area; largest = Math.max(largest, item.area); smallest = Math.min(smallest, item.area); }
|
|
return Math.max(side * side * largest / (sum * sum), (sum * sum) / (side * side * smallest));
|
|
};
|
|
const place = row => {
|
|
const area = row.reduce((value, item) => value + item.area, 0);
|
|
if (w >= h) {
|
|
const rowWidth = Math.min(w, area / Math.max(.0001, h)); let rowY = y;
|
|
for (const item of row) { const itemHeight = item.area / Math.max(.0001, rowWidth); output(item.entry, x, rowY, rowWidth, itemHeight, depth); rowY += itemHeight; }
|
|
x += rowWidth; w = Math.max(0, w - rowWidth);
|
|
} else {
|
|
const rowHeight = Math.min(h, area / Math.max(.0001, w)); let rowX = x;
|
|
for (const item of row) { const itemWidth = item.area / Math.max(.0001, rowHeight); output(item.entry, rowX, y, itemWidth, rowHeight, depth); rowX += itemWidth; }
|
|
y += rowHeight; h = Math.max(0, h - rowHeight);
|
|
}
|
|
};
|
|
let row = [];
|
|
while (remaining.length) {
|
|
const next = remaining[0], side = Math.max(.0001, Math.min(w, h));
|
|
if (!row.length || worst([...row, next], side) <= worst(row, side)) row.push(remaining.shift());
|
|
else { place(row); row = []; }
|
|
}
|
|
if (row.length) place(row);
|
|
}
|
|
|
|
export const CITY = {
|
|
squareMetresPerLine: 1.1, // building footprint per source line
|
|
minLines: 40, // small files still get a walkable kiosk
|
|
roadWidths: [18, 12, 8, 6], // avenues between districts, then narrower streets per folder depth
|
|
sidewalk: 2.5, // kerb inside each block before buildings start
|
|
setback: 1.2, // gap between neighbouring buildings
|
|
cellSize: 32, // spatial grid cell for collision and picking
|
|
};
|
|
|
|
// Storeys scale with complexity: a flat data file is a low warehouse, dense logic is a tower.
|
|
export function buildingHeight(file) {
|
|
return 6 + 10 * Math.log2(1 + Math.max(0, file.complexity || 0));
|
|
}
|
|
|
|
function buildTree(files, name) {
|
|
const root = { name, path: '', children: new Map(), files: [], weight: 0 };
|
|
for (const file of files) {
|
|
const parts = file.path.split('/'); parts.pop(); let node = root;
|
|
for (const part of parts) {
|
|
if (!node.children.has(part)) node.children.set(part, { name: part, path: node.path ? `${node.path}/${part}` : part, children: new Map(), files: [], weight: 0 });
|
|
node = node.children.get(part);
|
|
}
|
|
node.files.push(file);
|
|
}
|
|
const weigh = node => {
|
|
node.weight = node.files.reduce((sum, file) => sum + Math.max(CITY.minLines, file.lines || 0), 0);
|
|
for (const child of node.children.values()) node.weight += weigh(child);
|
|
return node.weight;
|
|
};
|
|
weigh(root);
|
|
return root;
|
|
}
|
|
|
|
// Returns { width, height, buildings: Map(fileId -> {x,y,w,h,height}), blocks: [{node,depth,x,y,w,h}] }.
|
|
export function layoutCity(files, name = '') {
|
|
const root = buildTree(files, name);
|
|
const area = root.weight * CITY.squareMetresPerLine * 1.9; // roads, sidewalks and setbacks
|
|
const width = Math.sqrt(area * 1000 / 680), height = area / width;
|
|
const buildings = new Map(), blocks = [];
|
|
const visit = (node, x, y, w, h, depth) => {
|
|
if (depth > 0) {
|
|
// Half a road on every side, so neighbouring blocks are a full road apart.
|
|
const half = Math.min(CITY.roadWidths[Math.min(depth - 1, CITY.roadWidths.length - 1)] / 2, w * .2, h * .2);
|
|
x += half; y += half; w -= half * 2; h -= half * 2;
|
|
}
|
|
blocks.push({ node, depth, x, y, w, h });
|
|
const kerb = Math.min(CITY.sidewalk, w * .15, h * .15);
|
|
const values = [...node.children.values(), ...node.files.map(file => ({ file, weight: Math.max(CITY.minLines, file.lines || 0) }))];
|
|
const total = values.reduce((sum, value) => sum + value.weight, 0), floor = total / Math.max(1, values.length * 8);
|
|
const entries = values.map(value => ({ value, weight: Math.max(value.weight, floor) })).sort((a, b) => b.weight - a.weight);
|
|
squarifiedLayout(entries, x + kerb, y + kerb, w - kerb * 2, h - kerb * 2, depth, (entry, rx, ry, rw, rh) => {
|
|
const value = entry.value;
|
|
if (!value.file) return visit(value, rx, ry, rw, rh, depth + 1);
|
|
const gap = Math.min(CITY.setback / 2, rw * .12, rh * .12);
|
|
buildings.set(value.file.id, { x: rx + gap, y: ry + gap, w: rw - gap * 2, h: rh - gap * 2, height: buildingHeight(value.file) });
|
|
});
|
|
};
|
|
visit(root, 0, 0, width, height, 0);
|
|
return { width, height, buildings, blocks };
|
|
}
|
|
|
|
// Uniform grid over building footprints, for collision and picking near a point or along a ray.
|
|
export function spatialIndex(entries, cellSize = CITY.cellSize) {
|
|
const cells = new Map();
|
|
for (const entry of entries) {
|
|
const x0 = Math.floor(entry.x / cellSize), x1 = Math.floor((entry.x + entry.w) / cellSize);
|
|
const y0 = Math.floor(entry.y / cellSize), y1 = Math.floor((entry.y + entry.h) / cellSize);
|
|
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
|
|
const key = cx * 65536 + cy, cell = cells.get(key);
|
|
if (cell) cell.push(entry); else cells.set(key, [entry]);
|
|
}
|
|
}
|
|
return {
|
|
cellSize,
|
|
near(x, y, radius) {
|
|
const found = new Set();
|
|
for (let cy = Math.floor((y - radius) / cellSize); cy <= Math.floor((y + radius) / cellSize); cy++)
|
|
for (let cx = Math.floor((x - radius) / cellSize); cx <= Math.floor((x + radius) / cellSize); cx++)
|
|
for (const entry of cells.get(cx * 65536 + cy) || []) found.add(entry);
|
|
return found;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Distance along the ray to an axis-aligned box from z=0 to z=height, or Infinity.
|
|
export function rayBox([ox, oy, oz], [dx, dy, dz], box) {
|
|
let near = 0, far = Infinity;
|
|
for (const [o, d, min, max] of [[ox, dx, box.x, box.x + box.w], [oy, dy, box.y, box.y + box.h], [oz, dz, 0, box.height]]) {
|
|
if (Math.abs(d) < 1e-9) { if (o < min || o > max) return Infinity; continue; }
|
|
let t0 = (min - o) / d, t1 = (max - o) / d;
|
|
if (t0 > t1) [t0, t1] = [t1, t0];
|
|
near = Math.max(near, t0); far = Math.min(far, t1);
|
|
if (near > far) return Infinity;
|
|
}
|
|
return near;
|
|
}
|
|
|
|
// Nearest building hit by a ray: { entry, distance } or null.
|
|
// ponytail: tests every building; walk the spatial grid along the ray if repos pass ~100k files.
|
|
export function pickRay(origin, dir, entries, maxDistance = Infinity) {
|
|
let best = null, distance = maxDistance;
|
|
for (const entry of entries) {
|
|
const t = rayBox(origin, dir, entry);
|
|
if (t < distance) { distance = t; best = entry; }
|
|
}
|
|
return best && { entry: best, distance };
|
|
}
|
|
|
|
// Pushes a circle of `radius` at (x, y) out of every building it overlaps.
|
|
export function collide(x, y, radius, index) {
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
let moved = false;
|
|
for (const b of index.near(x, y, radius + 1)) {
|
|
const nx = Math.max(b.x, Math.min(x, b.x + b.w)), ny = Math.max(b.y, Math.min(y, b.y + b.h));
|
|
let dx = x - nx, dy = y - ny;
|
|
const distance = Math.hypot(dx, dy);
|
|
if (distance >= radius) continue;
|
|
if (distance === 0) {
|
|
// Centre inside the footprint: leave through the nearest wall.
|
|
const exits = [[b.x - radius - x, 0], [b.x + b.w + radius - x, 0], [0, b.y - radius - y], [0, b.y + b.h + radius - y]];
|
|
[dx, dy] = exits.reduce((best, exit) => Math.hypot(...exit) < Math.hypot(...best) ? exit : best);
|
|
x += dx; y += dy;
|
|
} else {
|
|
x = nx + dx / distance * radius; y = ny + dy / distance * radius;
|
|
}
|
|
moved = true;
|
|
}
|
|
if (!moved) break;
|
|
}
|
|
return { x, y };
|
|
}
|
|
|
|
// Horizontal forward and right vectors for a heading; yaw 0 faces -y (north on the map).
|
|
export function heading(yaw) {
|
|
return { forward: [-Math.sin(yaw), -Math.cos(yaw)], right: [Math.cos(yaw), -Math.sin(yaw)] };
|
|
}
|
|
|
|
export const MOVE = { walk: 7, run: 18, fly: 40, flyFast: 160, radius: .45, eyeHeight: 1.7 };
|
|
|
|
// Advances a walk/fly camera by dt seconds. input: {forward, right, up} in -1..1 and run (bool).
|
|
export function stepCamera(camera, input, dt, index, bounds) {
|
|
// Sub-steps keep a long frame from carrying the walker through a thin building.
|
|
for (; dt > .05; dt -= .05) stepCamera(camera, input, .05, index, bounds);
|
|
const fly = camera.view === 'fly', speed = (fly ? (input.run ? MOVE.flyFast : MOVE.fly) : (input.run ? MOVE.run : MOVE.walk)) * dt;
|
|
const { forward, right } = heading(camera.lookYaw);
|
|
let mx = forward[0] * input.forward + right[0] * input.right, my = forward[1] * input.forward + right[1] * input.right;
|
|
const length = Math.hypot(mx, my);
|
|
if (length > 1) { mx /= length; my /= length; }
|
|
let x = camera.ex + mx * speed, y = camera.ey + my * speed;
|
|
if (fly) {
|
|
// Flying follows the view pitch, so looking down and pressing forward descends.
|
|
const vertical = Math.sin(camera.lookPitch) * input.forward + input.up;
|
|
camera.ez = Math.max(MOVE.eyeHeight, Math.min(3000, camera.ez + vertical * speed));
|
|
} else {
|
|
camera.ez = MOVE.eyeHeight;
|
|
}
|
|
if (camera.ez < 400) ({ x, y } = collide(x, y, MOVE.radius, { near: (px, py, r) => [...index.near(px, py, r)].filter(b => b.height + .5 > camera.ez - MOVE.eyeHeight) }));
|
|
camera.ex = Math.max(-150, Math.min(bounds.width + 150, x));
|
|
camera.ey = Math.max(-150, Math.min(bounds.height + 150, y));
|
|
return camera;
|
|
}
|
|
|
|
// Deepest folder block containing a point, for the "you are here" address.
|
|
export function blockAt(blocks, x, y) {
|
|
let best = null;
|
|
for (const block of blocks) if (block.depth > 0 && x >= block.x && x <= block.x + block.w && y >= block.y && y <= block.y + block.h && (!best || block.depth > best.depth)) best = block;
|
|
return best;
|
|
}
|
|
|
|
// Where a ray meets the ground plane (z = 0), or null if it points up.
|
|
export function groundHit([ox, oy, oz], [dx, dy, dz]) {
|
|
if (dz >= -1e-6) return null;
|
|
const t = -oz / dz;
|
|
return [ox + dx * t, oy + dy * t];
|
|
}
|
|
|
|
// ---- Sign placement. A quad is { c: centre, u: half-width axis, v: half-height axis } in metres. ----
|
|
|
|
// Text lying flat, reading left to right along +x with its top toward north (-y).
|
|
export function flatQuad(cx, cy, z, width, height) {
|
|
return { c: [cx, cy, z], u: [width / 2, 0, 0], v: [0, -height / 2, 0] };
|
|
}
|
|
|
|
// The four walls of a footprint: centre on the ground, outward normal, width.
|
|
function walls(b) {
|
|
return [
|
|
{ c: [b.x + b.w / 2, b.y + b.h], n: [0, 1], width: b.w },
|
|
{ c: [b.x + b.w / 2, b.y], n: [0, -1], width: b.w },
|
|
{ c: [b.x + b.w, b.y + b.h / 2], n: [1, 0], width: b.h },
|
|
{ c: [b.x, b.y + b.h / 2], n: [-1, 0], width: b.h },
|
|
];
|
|
}
|
|
|
|
// Vertical quad just outside a wall, reading left to right for someone facing that wall.
|
|
function wallQuad(wall, z, width, height) {
|
|
const offset = .06, right = [wall.n[1], -wall.n[0]];
|
|
return { c: [wall.c[0] + wall.n[0] * offset, wall.c[1] + wall.n[1] * offset, z], u: [right[0] * width / 2, right[1] * width / 2, 0], v: [0, 0, height / 2] };
|
|
}
|
|
|
|
// Name signs at first-floor height on the walls that face the viewer.
|
|
export function wallSigns(b, eye, aspect, height = .9, z = 3.2) {
|
|
const out = [];
|
|
for (const wall of walls(b)) {
|
|
if ((eye[0] - wall.c[0]) * wall.n[0] + (eye[1] - wall.c[1]) * wall.n[1] <= 0) continue;
|
|
// Long walls repeat the sign every ~24 m so one is always near someone walking past.
|
|
const count = Math.max(1, Math.floor(wall.width / 24)), segment = wall.width / count, along = [wall.n[1], -wall.n[0]];
|
|
const width = Math.min(segment * .85, aspect * height);
|
|
for (let i = 0; i < count; i++) {
|
|
const offset = (i + .5) * segment - wall.width / 2;
|
|
out.push(wallQuad({ ...wall, c: [wall.c[0] + along[0] * offset, wall.c[1] + along[1] * offset] }, Math.min(z, b.height * .5), width, width / aspect));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The wall most squarely facing the viewer, covered with the file's source at the texture's aspect.
|
|
export function facadeQuad(b, eye, aspect) {
|
|
let best = null, score = 0;
|
|
for (const wall of walls(b)) {
|
|
const dx = eye[0] - wall.c[0], dy = eye[1] - wall.c[1], facing = (dx * wall.n[0] + dy * wall.n[1]) / (Math.hypot(dx, dy) || 1);
|
|
if (facing > score) { score = facing; best = wall; }
|
|
}
|
|
if (!best) return null;
|
|
let width = best.width * .9, height = width / aspect;
|
|
const room = Math.max(1, (b.height - 1.5) * .92);
|
|
if (height > room) { height = room; width = height * aspect; }
|
|
return wallQuad(best, 1.2 + height / 2, width, height);
|
|
}
|