California gets roads, traffic, and a car to follow
This commit is contained in:
+61
-6
@@ -73,6 +73,20 @@ interface Box {
|
||||
commercial: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named building's claim on the anonymous city scatter, in scene units.
|
||||
*
|
||||
* Circles are deliberately conservative. Anonymous buildings rotate with
|
||||
* their districts and named glyphs rotate with their streets; a circle is the
|
||||
* one cheap overlap test that cannot leave a corner poking through, and there
|
||||
* are only a handful of reservations to test.
|
||||
*/
|
||||
export interface BuildingReservation {
|
||||
x: number;
|
||||
z: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
function polygonBounds(poly: [number, number][]) {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
@@ -87,7 +101,10 @@ function polygonBounds(poly: [number, number][]) {
|
||||
return { minLat, maxLat, minLng, maxLng };
|
||||
}
|
||||
|
||||
export function createBlocks(world: World): THREE.InstancedMesh {
|
||||
export function createBlocks(
|
||||
world: World,
|
||||
reservations: readonly BuildingReservation[] = [],
|
||||
): THREE.InstancedMesh {
|
||||
const boxes: Box[] = [];
|
||||
let seedBase = 1337;
|
||||
|
||||
@@ -165,15 +182,40 @@ export function createBlocks(world: World): THREE.InstancedMesh {
|
||||
// towers assemble their sites, and Salesforce Tower is about 5:1.
|
||||
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
|
||||
|
||||
const width = LOT * fill;
|
||||
const depth = LOT * fill * (0.85 + rand() * 0.3);
|
||||
const rotation = angle + (rand() - 0.5) * 0.03;
|
||||
const color = new THREE.Color(
|
||||
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep a named building legible instead of drawing it inside a random
|
||||
* one at the same address.
|
||||
*
|
||||
* Every random property is drawn before this test. The sequence is
|
||||
* load-bearing: skipping those calls for one reserved lot would
|
||||
* reshuffle every anonymous building after it and turn a local change
|
||||
* into a whole new skyline.
|
||||
*/
|
||||
const radius = Math.hypot(width, depth) / 2;
|
||||
if (
|
||||
reservations.some(
|
||||
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < radius + reserved.radius,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
boxes.push({
|
||||
x,
|
||||
z,
|
||||
y: world.groundAt(lat, lng),
|
||||
w: LOT * fill,
|
||||
d: LOT * fill * (0.85 + rand() * 0.3),
|
||||
w: width,
|
||||
d: depth,
|
||||
h: world.metres(heightM),
|
||||
rot: angle + (rand() - 0.5) * 0.03,
|
||||
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
|
||||
rot: rotation,
|
||||
color,
|
||||
// A tower is an office whatever district it landed in.
|
||||
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
|
||||
});
|
||||
@@ -230,12 +272,25 @@ export function createBlocks(world: World): THREE.InstancedMesh {
|
||||
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
||||
* Hill, the red tripod on the ridge — and a box would not do.
|
||||
*/
|
||||
export function createLandmarks(world: World): THREE.Group {
|
||||
export function createLandmarks(
|
||||
world: World,
|
||||
reservations: readonly BuildingReservation[] = [],
|
||||
): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "landmarks";
|
||||
|
||||
for (const lm of world.city.landmarks) {
|
||||
const [x, z] = world.project(lm.lat, lm.lng);
|
||||
// A richer stable glyph at this address supersedes the coarse landmark
|
||||
// primitive. Drawing both would hide the glyph inside the old mesh and
|
||||
// leave two different sources claiming the same real building.
|
||||
if (
|
||||
reservations.some(
|
||||
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < reserved.radius,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const base = world.groundAt(lm.lat, lm.lng);
|
||||
const h = world.metres(lm.height);
|
||||
const w = lm.footprint * world.lngScale * 2;
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Map-scale buildings for destinations that deserve more than a pin.
|
||||
*
|
||||
* This is not a general building generator and it intentionally loads no kit.
|
||||
* At Tera's camera distances a one-metre façade module is sub-pixel; reproducing
|
||||
* it as geometry would turn three destinations into thousands of triangles and
|
||||
* several draw calls per material. What survives at map scale is the grammar:
|
||||
* silhouette, repeated window bays, a distinct ground-floor door and a roof
|
||||
* line. Those are generated here from a small data shape and one cached unit
|
||||
* box, with all windows in one InstancedMesh.
|
||||
*
|
||||
* The placement idea was researched against achrefelouafi's MIT-licensed
|
||||
* BasicProceduralBuilding, which classifies façade cells into ground, window,
|
||||
* corner and roof pieces. No source or binary asset from that project is copied
|
||||
* here: its GLB part kit and Blender-coordinate port solve a different-scale
|
||||
* problem. See ASSET_RESEARCH.md.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { BuildingGlyph } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
export interface BuildingGlyphHandle {
|
||||
group: THREE.Group;
|
||||
/** Solid shell pieces only; windows do not need thousands of ray targets. */
|
||||
pickables: THREE.Object3D[];
|
||||
/** World-space height above the group's ground datum. */
|
||||
anchorY: number;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface BuildingSegment {
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
base: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface WindowPlacement {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
yaw: number;
|
||||
lit: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_BODY = 0x8d9aa4;
|
||||
const WINDOW_DAY = new THREE.Color(0x334c5d);
|
||||
const WINDOW_LIT = new THREE.Color(0xd7c68d);
|
||||
|
||||
/**
|
||||
* The silhouette in metres, kept pure so the contract can be unit-tested with
|
||||
* no WebGL context. Segments never overlap on the same vertical plane except
|
||||
* where a tower deliberately steps inward above its podium.
|
||||
*/
|
||||
export function layoutBuildingGlyph(glyph: BuildingGlyph): BuildingSegment[] {
|
||||
const width = Math.max(4, glyph.width);
|
||||
const depth = Math.max(4, glyph.depth);
|
||||
const height = Math.max(3, glyph.height);
|
||||
|
||||
switch (glyph.profile) {
|
||||
case "tower": {
|
||||
const podium = height * 0.13;
|
||||
const shaft = height * 0.72;
|
||||
return [
|
||||
{ x: 0, z: 0, width, depth, base: 0, height: podium },
|
||||
{
|
||||
x: 0,
|
||||
z: 0,
|
||||
width: width * 0.74,
|
||||
depth: depth * 0.78,
|
||||
base: podium,
|
||||
height: shaft,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
z: 0,
|
||||
width: width * 0.56,
|
||||
depth: depth * 0.6,
|
||||
base: podium + shaft,
|
||||
height: height - podium - shaft,
|
||||
},
|
||||
];
|
||||
}
|
||||
case "courtyard": {
|
||||
const wing = Math.max(3.5, Math.min(width, depth) * 0.28);
|
||||
return [
|
||||
{ x: 0, z: -(depth - wing) / 2, width, depth: wing, base: 0, height },
|
||||
{ x: 0, z: (depth - wing) / 2, width, depth: wing, base: 0, height },
|
||||
{
|
||||
x: -(width - wing) / 2,
|
||||
z: 0,
|
||||
width: wing,
|
||||
depth: Math.max(wing, depth - wing * 2),
|
||||
base: 0,
|
||||
height,
|
||||
},
|
||||
{
|
||||
x: (width - wing) / 2,
|
||||
z: 0,
|
||||
width: wing,
|
||||
depth: Math.max(wing, depth - wing * 2),
|
||||
base: 0,
|
||||
height,
|
||||
},
|
||||
];
|
||||
}
|
||||
case "hangar":
|
||||
return [{ x: 0, z: 0, width, depth, base: 0, height: height * 0.72 }];
|
||||
case "block":
|
||||
default:
|
||||
return [{ x: 0, z: 0, width, depth, base: 0, height }];
|
||||
}
|
||||
}
|
||||
|
||||
export function createBuildingGlyph(
|
||||
world: World,
|
||||
glyph: BuildingGlyph,
|
||||
accentColor: number,
|
||||
): BuildingGlyphHandle {
|
||||
const group = new THREE.Group();
|
||||
group.name = `building-glyph:${glyph.profile}`;
|
||||
// A positive three.js yaw turns local north west. Compass headings increase
|
||||
// eastward, so the sign is reversed once at the world/building boundary.
|
||||
const heading = Number.isFinite(glyph.heading) ? glyph.heading : 0;
|
||||
group.rotation.y = -(heading * Math.PI) / 180;
|
||||
|
||||
const pickables: THREE.Object3D[] = [];
|
||||
const segments = layoutBuildingGlyph(glyph);
|
||||
const horizontal = 1 / world.metresPerUnit;
|
||||
const vertical = (m: number) => world.metres(m);
|
||||
const bodyColor = new THREE.Color(glyph.bodyColor ?? DEFAULT_BODY);
|
||||
const body = new THREE.MeshLambertMaterial({ color: bodyColor });
|
||||
body.name = "building shell";
|
||||
const trim = new THREE.MeshLambertMaterial({ color: bodyColor.clone().multiplyScalar(0.72) });
|
||||
trim.name = "building roof and trim";
|
||||
const glass = new THREE.MeshLambertMaterial({
|
||||
// Instanced colours are multiplied by the base material colour, so white
|
||||
// is the neutral carrier for the cool and warm pane colours below.
|
||||
color: 0xffffff,
|
||||
emissive: new THREE.Color(accentColor).multiplyScalar(0.34),
|
||||
emissiveIntensity: 0.36,
|
||||
});
|
||||
glass.name = "building windows";
|
||||
const accent = new THREE.MeshLambertMaterial({
|
||||
color: accentColor,
|
||||
emissive: accentColor,
|
||||
emissiveIntensity: 0.42,
|
||||
});
|
||||
accent.name = "building door";
|
||||
|
||||
const unit = new THREE.BoxGeometry(1, 1, 1);
|
||||
unit.translate(0, 0.5, 0);
|
||||
const windows: WindowPlacement[] = [];
|
||||
const random = mulberry32(glyph.seed ?? hashGlyph(glyph));
|
||||
|
||||
for (const segment of segments) {
|
||||
const shell = new THREE.Mesh(unit, body);
|
||||
shell.name = "building shell segment";
|
||||
shell.position.set(segment.x * horizontal, vertical(segment.base), segment.z * horizontal);
|
||||
shell.scale.set(segment.width * horizontal, vertical(segment.height), segment.depth * horizontal);
|
||||
shell.castShadow = true;
|
||||
shell.receiveShadow = true;
|
||||
group.add(shell);
|
||||
pickables.push(shell);
|
||||
|
||||
// A slightly proud cap makes each setback legible from above, where the
|
||||
// camera spends nearly all its time. It is one centimetre in the data and a
|
||||
// deliberately larger fraction of a pixel after vertical exaggeration.
|
||||
const cap = new THREE.Mesh(unit, trim);
|
||||
cap.name = "building roof line";
|
||||
cap.position.set(
|
||||
segment.x * horizontal,
|
||||
vertical(segment.base + segment.height),
|
||||
segment.z * horizontal,
|
||||
);
|
||||
cap.scale.set(
|
||||
(segment.width + 0.7) * horizontal,
|
||||
Math.max(0.012, vertical(0.28)),
|
||||
(segment.depth + 0.7) * horizontal,
|
||||
);
|
||||
cap.castShadow = true;
|
||||
group.add(cap);
|
||||
pickables.push(cap);
|
||||
|
||||
collectWindows(windows, segment, glyph, horizontal, vertical, random);
|
||||
}
|
||||
|
||||
const windowGeometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const windowMesh = new THREE.InstancedMesh(windowGeometry, glass, windows.length);
|
||||
windowMesh.name = "building window bays";
|
||||
windowMesh.castShadow = false;
|
||||
windowMesh.receiveShadow = false;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
windows.forEach((window, i) => {
|
||||
position.set(window.x, window.y, window.z);
|
||||
quaternion.setFromAxisAngle(up, window.yaw);
|
||||
scale.set(window.width, window.height, 0.012);
|
||||
matrix.compose(position, quaternion, scale);
|
||||
windowMesh.setMatrixAt(i, matrix);
|
||||
// A few warm panes stop the repeated grid reading as graph paper. The
|
||||
// choice is seeded, so the same office has the same lights after a reload.
|
||||
windowMesh.setColorAt(i, window.lit ? WINDOW_LIT : WINDOW_DAY);
|
||||
});
|
||||
windowMesh.instanceMatrix.needsUpdate = true;
|
||||
windowMesh.instanceColor!.needsUpdate = true;
|
||||
windowMesh.computeBoundingSphere();
|
||||
group.add(windowMesh);
|
||||
|
||||
let gable: THREE.BufferGeometry | null = null;
|
||||
if (glyph.profile === "hangar") {
|
||||
const bodyHeight = glyph.height * 0.72;
|
||||
const rise = glyph.height - bodyHeight;
|
||||
// The triangular prism completes the upper 28% of the silhouette; its two
|
||||
// sloped faces are what turn a low grey block into a hangar at map scale.
|
||||
gable = gableGeometry(
|
||||
Math.max(4, glyph.width) * horizontal,
|
||||
Math.max(4, glyph.depth) * horizontal,
|
||||
vertical(rise),
|
||||
);
|
||||
const roof = new THREE.Mesh(gable, trim);
|
||||
roof.name = "hangar roof";
|
||||
roof.position.y = vertical(bodyHeight);
|
||||
roof.castShadow = true;
|
||||
roof.receiveShadow = true;
|
||||
group.add(roof);
|
||||
pickables.push(roof);
|
||||
}
|
||||
|
||||
// One bright address on the south/front façade. It is navigation, not a
|
||||
// physically accurate entrance schedule, and stays readable after the
|
||||
// repeated bays have merged into their average at distance.
|
||||
const first = segments[0] as BuildingSegment;
|
||||
const door = new THREE.Mesh(unit, accent);
|
||||
door.name = "building entrance";
|
||||
const doorWidth = glyph.profile === "hangar" ? first.width * 0.42 : Math.min(5, first.width * 0.22);
|
||||
const doorHeight = glyph.profile === "hangar" ? first.height * 0.6 : Math.min(5, first.height * 0.55);
|
||||
door.position.set(
|
||||
first.x * horizontal,
|
||||
vertical(first.base),
|
||||
(first.z + first.depth / 2 + 0.18) * horizontal,
|
||||
);
|
||||
door.scale.set(doorWidth * horizontal, vertical(doorHeight), 0.028);
|
||||
door.castShadow = false;
|
||||
group.add(door);
|
||||
pickables.push(door);
|
||||
|
||||
const anchorY = vertical(glyph.height) + Math.max(0.18, vertical(2));
|
||||
|
||||
return {
|
||||
group,
|
||||
pickables,
|
||||
anchorY,
|
||||
dispose() {
|
||||
windowMesh.dispose();
|
||||
unit.dispose();
|
||||
windowGeometry.dispose();
|
||||
gable?.dispose();
|
||||
body.dispose();
|
||||
trim.dispose();
|
||||
glass.dispose();
|
||||
accent.dispose();
|
||||
pickables.length = 0;
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectWindows(
|
||||
out: WindowPlacement[],
|
||||
segment: BuildingSegment,
|
||||
glyph: BuildingGlyph,
|
||||
horizontal: number,
|
||||
vertical: (m: number) => number,
|
||||
random: () => number,
|
||||
) {
|
||||
const floorShare = segment.height / Math.max(1, glyph.height);
|
||||
const rows =
|
||||
glyph.profile === "hangar"
|
||||
? 1
|
||||
: Math.max(1, Math.min(18, Math.round(Math.max(1, glyph.storeys) * floorShare)));
|
||||
const rowPitch = segment.height / (rows + 0.35);
|
||||
const windowHeightM = Math.max(1.1, rowPitch * (glyph.profile === "tower" ? 0.5 : 0.58));
|
||||
const y0 = segment.base + rowPitch * 0.6;
|
||||
|
||||
const addFacade = (
|
||||
spanM: number,
|
||||
face: "north" | "south" | "east" | "west",
|
||||
) => {
|
||||
const cols = Math.max(2, Math.min(10, Math.round(spanM / 6.5)));
|
||||
const bay = spanM / cols;
|
||||
const width = bay * (0.5 + random() * 0.1) * horizontal;
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
const along = -spanM / 2 + bay * (col + 0.5);
|
||||
const y = vertical(y0 + rowPitch * row + windowHeightM / 2);
|
||||
const lit = random() > 0.77;
|
||||
switch (face) {
|
||||
case "north":
|
||||
out.push({
|
||||
x: (segment.x + along) * horizontal,
|
||||
y,
|
||||
z: (segment.z - segment.depth / 2 - 0.08) * horizontal,
|
||||
width,
|
||||
height: vertical(windowHeightM),
|
||||
yaw: 0,
|
||||
lit,
|
||||
});
|
||||
break;
|
||||
case "south":
|
||||
out.push({
|
||||
x: (segment.x - along) * horizontal,
|
||||
y,
|
||||
z: (segment.z + segment.depth / 2 + 0.08) * horizontal,
|
||||
width,
|
||||
height: vertical(windowHeightM),
|
||||
yaw: Math.PI,
|
||||
lit,
|
||||
});
|
||||
break;
|
||||
case "east":
|
||||
out.push({
|
||||
x: (segment.x + segment.width / 2 + 0.08) * horizontal,
|
||||
y,
|
||||
z: (segment.z + along) * horizontal,
|
||||
width,
|
||||
height: vertical(windowHeightM),
|
||||
yaw: Math.PI / 2,
|
||||
lit,
|
||||
});
|
||||
break;
|
||||
case "west":
|
||||
out.push({
|
||||
x: (segment.x - segment.width / 2 - 0.08) * horizontal,
|
||||
y,
|
||||
z: (segment.z - along) * horizontal,
|
||||
width,
|
||||
height: vertical(windowHeightM),
|
||||
yaw: -Math.PI / 2,
|
||||
lit,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addFacade(segment.width, "north");
|
||||
addFacade(segment.width, "south");
|
||||
addFacade(segment.depth, "east");
|
||||
addFacade(segment.depth, "west");
|
||||
}
|
||||
|
||||
/** A triangular prism whose ridge runs along local X. */
|
||||
function gableGeometry(width: number, depth: number, rise: number): THREE.BufferGeometry {
|
||||
const x = width / 2;
|
||||
const z = depth / 2;
|
||||
const positions = new Float32Array([
|
||||
-x, 0, -z,
|
||||
-x, 0, z,
|
||||
-x, rise, 0,
|
||||
x, 0, -z,
|
||||
x, 0, z,
|
||||
x, rise, 0,
|
||||
]);
|
||||
const indices = [
|
||||
0, 1, 2,
|
||||
3, 5, 4,
|
||||
0, 3, 4, 0, 4, 1,
|
||||
1, 4, 5, 1, 5, 2,
|
||||
2, 5, 3, 2, 3, 0,
|
||||
];
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function hashGlyph(glyph: BuildingGlyph): number {
|
||||
const text = `${glyph.profile}:${glyph.width}:${glyph.depth}:${glyph.height}:${glyph.heading}`;
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value = (value + 0x6d2b79f5) >>> 0;
|
||||
let next = Math.imul(value ^ (value >>> 15), 1 | value);
|
||||
next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
|
||||
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createBuildingGlyph, type BuildingGlyphHandle } from "./buildingGlyph.ts";
|
||||
import type { Marker, MarkerPalette } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
@@ -38,6 +39,7 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
||||
group.name = "markers";
|
||||
const pickables: THREE.Object3D[] = [];
|
||||
const anchors = new Map<string, THREE.Vector3>();
|
||||
const buildings: BuildingGlyphHandle[] = [];
|
||||
|
||||
// One shared geometry per shape; colour varies per instance material, which
|
||||
// is cheap enough at the scale markers live at (hundreds, not tens of
|
||||
@@ -61,6 +63,8 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
||||
};
|
||||
|
||||
function clear() {
|
||||
for (const building of buildings) building.dispose();
|
||||
buildings.length = 0;
|
||||
for (const child of [...group.children]) group.remove(child);
|
||||
pickables.length = 0;
|
||||
anchors.clear();
|
||||
@@ -73,6 +77,23 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
||||
const [x, z] = world.project(m.lat, m.lng);
|
||||
const base = world.groundAt(m.lat, m.lng);
|
||||
|
||||
if (located && m.glyph?.kind === "building") {
|
||||
const building = createBuildingGlyph(
|
||||
world,
|
||||
m.glyph,
|
||||
palette[m.colorKey] ?? FALLBACK_COLOR,
|
||||
);
|
||||
building.group.position.set(x, base, z);
|
||||
for (const target of building.pickables) {
|
||||
target.userData.marker = m;
|
||||
pickables.push(target);
|
||||
}
|
||||
anchors.set(m.id, new THREE.Vector3(x, base + building.anchorY, z));
|
||||
buildings.push(building);
|
||||
group.add(building.group);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pin = new THREE.Group();
|
||||
pin.position.set(x, base + PIN_LIFT, z);
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* The road-traffic rendering layer.
|
||||
*
|
||||
* Simulation remains geographic and three-free in `transport/vehicleSim.ts`.
|
||||
* This layer projects those poses onto one `World`, draws one articulated hero
|
||||
* car, and batches every background car into instanced asset parts. Route
|
||||
* switching and follow-camera state are imperative because they are viewer
|
||||
* choices, not properties of the open transport pack.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import {
|
||||
buildModelX,
|
||||
disposeModelX,
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
} from "../assets/vehicles/index.ts";
|
||||
import type { TransportPack } from "../transport/types.ts";
|
||||
import {
|
||||
NEUTRAL_VEHICLE_ACTIONS,
|
||||
VehicleController,
|
||||
normalizeVehicleActions,
|
||||
type VehicleActionSnapshot,
|
||||
type VehicleControllerState,
|
||||
} from "../transport/vehicleController.ts";
|
||||
import { VehicleSimulation, type VehiclePose } from "../transport/vehicleSim.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
export interface RoadTrafficOptions {
|
||||
pack: TransportPack;
|
||||
routeId: string;
|
||||
count?: number;
|
||||
seed?: number;
|
||||
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
|
||||
scale?: number;
|
||||
/** Route-distance compression for playable corridor travel. Defaults to 900. */
|
||||
travelScale?: number;
|
||||
}
|
||||
|
||||
export interface RoadTrafficLayer {
|
||||
group: THREE.Group;
|
||||
routeId(): string;
|
||||
setRoute(routeId: string): void;
|
||||
setFollowing(following: boolean): void;
|
||||
following(): boolean;
|
||||
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
|
||||
setCameraMode(mode: VehicleCameraMode): void;
|
||||
cameraMode(): VehicleCameraMode;
|
||||
hero(): Readonly<VehicleControllerState>;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type VehicleCameraMode = "chase" | "driver";
|
||||
|
||||
interface BatchPart {
|
||||
mesh: THREE.InstancedMesh;
|
||||
local: THREE.Matrix4;
|
||||
}
|
||||
|
||||
type RenderPose = Pick<VehiclePose, "lat" | "lng" | "headingDeg" | "wheelRadians"> & {
|
||||
lane?: number;
|
||||
lateralOffsetM?: number;
|
||||
};
|
||||
|
||||
export function createRoadTrafficLayer(
|
||||
world: World,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
controls: OrbitControls,
|
||||
options: RoadTrafficOptions,
|
||||
): RoadTrafficLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "road-traffic";
|
||||
const heroScale = options.scale ?? 0.18;
|
||||
// Background traffic is deliberately quieter. One oversized convoy reads as
|
||||
// map symbols; one detailed hero against smaller traffic reads as a camera.
|
||||
const backgroundScale = heroScale * 0.62;
|
||||
const count = Math.max(2, Math.min(40, Math.floor(options.count ?? 14)));
|
||||
const simulation = new VehicleSimulation(options.pack, {
|
||||
routeId: options.routeId,
|
||||
count,
|
||||
seed: options.seed,
|
||||
});
|
||||
const controller = new VehicleController(options.pack, {
|
||||
routeId: options.routeId,
|
||||
mode: "assisted",
|
||||
initialSpeedMps: 24,
|
||||
travelScale: options.travelScale ?? 900,
|
||||
});
|
||||
|
||||
// One articulated, higher-detail car for the follow camera.
|
||||
const heroRig = buildModelX({ detail: "follow" });
|
||||
heroRig.root.name = "model-x-hero";
|
||||
group.add(heroRig.root);
|
||||
|
||||
// Background traffic is one draw call per asset part, not per car. The
|
||||
// neutral prototype itself is never attached to the scene.
|
||||
const backgroundPrototype = buildModelX({ detail: "corridor" });
|
||||
const backgroundCount = count - 1;
|
||||
const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
|
||||
const material = Array.isArray(part.material) ? part.material[0] : part.material;
|
||||
if (!material) throw new Error(`road traffic: asset part "${part.name}" has no material`);
|
||||
const mesh = new THREE.InstancedMesh(part.geometry, material, backgroundCount);
|
||||
mesh.name = `traffic:${part.name}`;
|
||||
mesh.castShadow = part.castShadow;
|
||||
mesh.receiveShadow = part.receiveShadow;
|
||||
mesh.frustumCulled = false;
|
||||
group.add(mesh);
|
||||
return { mesh, local: part.matrix };
|
||||
});
|
||||
|
||||
const rootMatrix = new THREE.Matrix4();
|
||||
const instanceMatrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scaleVector = new THREE.Vector3(backgroundScale, backgroundScale, backgroundScale);
|
||||
const yawEuler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
const followPosition = new THREE.Vector3();
|
||||
const followTarget = new THREE.Vector3();
|
||||
const followOffset = new THREE.Vector3();
|
||||
let isFollowing = false;
|
||||
let activeCameraMode: VehicleCameraMode = "chase";
|
||||
let vehicleActions: VehicleActionSnapshot = { ...NEUTRAL_VEHICLE_ACTIONS };
|
||||
|
||||
function scenePose(pose: RenderPose, out: THREE.Vector3): THREE.Vector3 {
|
||||
const [x, z] = world.project(pose.lat, pose.lng);
|
||||
const heading = (pose.headingDeg * Math.PI) / 180;
|
||||
// Lane 0 hugs the median. Southbound traffic's right side naturally moves
|
||||
// to the other carriageway because its heading is reversed.
|
||||
const laneOffset =
|
||||
0.2 + (pose.lane ?? 0) * 0.2 + (pose.lateralOffsetM ?? 0) * 0.018;
|
||||
out.set(
|
||||
x + Math.cos(heading) * laneOffset,
|
||||
// The asset origin is on the tyre contact plane. Keep only a tiny lift
|
||||
// above the generated road to avoid z-fighting; `0.22` here made the
|
||||
// vehicle hover more than its own rendered height at corridor scale.
|
||||
world.groundAt(pose.lat, pose.lng) + 0.155,
|
||||
z + Math.sin(heading) * laneOffset,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyRoot(pose: RenderPose, steering = 0): void {
|
||||
scenePose(pose, position);
|
||||
heroRig.root.position.copy(position);
|
||||
heroRig.root.rotation.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
|
||||
heroRig.root.scale.setScalar(heroScale);
|
||||
setModelXWheelRotation(heroRig, -pose.wheelRadians);
|
||||
setModelXSteering(heroRig, steering);
|
||||
}
|
||||
|
||||
function applyBatches(poses: readonly VehiclePose[]): void {
|
||||
for (let index = 1; index < poses.length; index += 1) {
|
||||
const pose = poses[index];
|
||||
if (!pose) continue;
|
||||
scenePose(pose, position);
|
||||
yawEuler.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
|
||||
quaternion.setFromEuler(yawEuler);
|
||||
rootMatrix.compose(position, quaternion, scaleVector);
|
||||
for (const batch of batches) {
|
||||
instanceMatrix.multiplyMatrices(rootMatrix, batch.local);
|
||||
batch.mesh.setMatrixAt(index - 1, instanceMatrix);
|
||||
}
|
||||
}
|
||||
for (const batch of batches) batch.mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
function applyFollow(pose: RenderPose, dt: number): void {
|
||||
const heading = (pose.headingDeg * Math.PI) / 180;
|
||||
const forwardX = Math.sin(heading);
|
||||
const forwardZ = -Math.cos(heading);
|
||||
if (activeCameraMode === "driver") {
|
||||
// A hood/driver-height view. The procedural corridor asset has no cabin
|
||||
// texture to hide, so the camera sits just above its glass and looks far
|
||||
// enough ahead that route curvature reads before the car reaches it.
|
||||
followTarget
|
||||
.copy(heroRig.root.position)
|
||||
.add(followOffset.set(forwardX * 2.2, 0.14, forwardZ * 2.2));
|
||||
followPosition
|
||||
.copy(heroRig.root.position)
|
||||
.add(followOffset.set(forwardX * 0.03, 0.32, forwardZ * 0.03));
|
||||
} else {
|
||||
const rightX = Math.cos(heading);
|
||||
const rightZ = Math.sin(heading);
|
||||
followTarget
|
||||
.copy(heroRig.root.position)
|
||||
.add(followOffset.set(forwardX * 0.32, 0.1, forwardZ * 0.32));
|
||||
followPosition
|
||||
.copy(heroRig.root.position)
|
||||
.add(
|
||||
followOffset.set(
|
||||
-forwardX * 0.9 - rightX * 0.3,
|
||||
0.4,
|
||||
-forwardZ * 0.9 - rightZ * 0.3,
|
||||
),
|
||||
);
|
||||
}
|
||||
const blend = 1 - Math.exp(-Math.max(0, dt) * 4.5);
|
||||
camera.position.lerp(followPosition, blend);
|
||||
controls.target.lerp(followTarget, blend);
|
||||
camera.lookAt(controls.target);
|
||||
}
|
||||
|
||||
function refresh(dt: number): void {
|
||||
const poses = simulation.poses();
|
||||
const hero = controller.state();
|
||||
applyRoot(hero, hero.steering * 0.55);
|
||||
applyBatches(poses);
|
||||
if (isFollowing) applyFollow(hero, dt);
|
||||
}
|
||||
|
||||
refresh(0);
|
||||
|
||||
return {
|
||||
group,
|
||||
routeId: () => simulation.routeId(),
|
||||
setRoute(routeId) {
|
||||
simulation.setRoute(routeId);
|
||||
controller.setRoute(routeId);
|
||||
refresh(0);
|
||||
},
|
||||
setFollowing(following) {
|
||||
isFollowing = following;
|
||||
controls.enabled = !following;
|
||||
if (following) refresh(1);
|
||||
},
|
||||
following: () => isFollowing,
|
||||
setVehicleActions(actions) {
|
||||
vehicleActions = normalizeVehicleActions(actions);
|
||||
},
|
||||
setCameraMode(mode) {
|
||||
activeCameraMode = mode;
|
||||
if (isFollowing) refresh(1);
|
||||
},
|
||||
cameraMode: () => activeCameraMode,
|
||||
hero: () => controller.state(),
|
||||
tick(dt) {
|
||||
simulation.tick(dt);
|
||||
controller.tick(dt, vehicleActions);
|
||||
// Mode/reset requests are edges. Analogue axes remain held until the
|
||||
// input adapter publishes a changed snapshot.
|
||||
vehicleActions.modeRequest = "none";
|
||||
vehicleActions.reset = false;
|
||||
refresh(dt);
|
||||
},
|
||||
dispose() {
|
||||
controls.enabled = true;
|
||||
for (const batch of batches) group.remove(batch.mesh);
|
||||
group.remove(heroRig.root);
|
||||
disposeModelX(backgroundPrototype);
|
||||
disposeModelX(heroRig);
|
||||
},
|
||||
};
|
||||
}
|
||||
+61
-4
@@ -28,7 +28,7 @@
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createBlocks, createLandmarks } from "./blocks.ts";
|
||||
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
|
||||
import { createNightLights, type NightLights } from "./nightlights.ts";
|
||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
@@ -41,7 +41,17 @@ import {
|
||||
type SatelliteLayer,
|
||||
} from "./satellites.ts";
|
||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||
import {
|
||||
createRoadTrafficLayer,
|
||||
type RoadTrafficLayer,
|
||||
type RoadTrafficOptions,
|
||||
type VehicleCameraMode,
|
||||
} from "./roadTraffic.ts";
|
||||
import type { Stage, StageScene } from "./stage.ts";
|
||||
import type {
|
||||
VehicleActionSnapshot,
|
||||
VehicleControllerState,
|
||||
} from "../transport/vehicleController.ts";
|
||||
import { createBridges, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
@@ -58,6 +68,17 @@ import { World, type FieldProgress } from "./world.ts";
|
||||
export interface SceneOptions {
|
||||
city: City;
|
||||
markerPalette?: MarkerPalette;
|
||||
/**
|
||||
* Markers available at construction time.
|
||||
*
|
||||
* Building glyphs in this first set reserve their footprints in the
|
||||
* anonymous block scatter. Later `setMarkers()` calls remain cheap and do
|
||||
* not rebuild a city, so callers should put stable destinations here and use
|
||||
* updates for genuinely live marker feeds.
|
||||
*/
|
||||
markers?: Marker[];
|
||||
/** Optional deterministic road traffic for a state/corridor-scale board. */
|
||||
roadTraffic?: RoadTrafficOptions;
|
||||
flights?: FlightSource;
|
||||
/**
|
||||
* Element sets to propagate, if this deployment has any.
|
||||
@@ -148,6 +169,12 @@ export interface SceneHandle {
|
||||
flyTo(chapterId: string): void;
|
||||
current(): string;
|
||||
onChapterChange(fn: (id: string) => void): void;
|
||||
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
|
||||
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
|
||||
/** Current playable corridor state, or null on a city-scale board. */
|
||||
vehicleState(): Readonly<VehicleControllerState> | null;
|
||||
setVehicleCamera(mode: VehicleCameraMode): void;
|
||||
vehicleCamera(): VehicleCameraMode | null;
|
||||
setMarkers(markers: Marker[]): void;
|
||||
/** Take this city off the stage and release everything it built. */
|
||||
dispose(): void;
|
||||
@@ -283,9 +310,19 @@ export async function createScene(
|
||||
scene.add(createShorePlates(world));
|
||||
scene.add(createTerrain(world));
|
||||
scene.add(createRoads(world));
|
||||
const blocks = createBlocks(world);
|
||||
const buildingReservations: BuildingReservation[] = [];
|
||||
for (const marker of options.markers ?? []) {
|
||||
const glyph = marker.glyph;
|
||||
if (marker.located === false || glyph?.kind !== "building") continue;
|
||||
const [x, z] = world.project(marker.lat, marker.lng);
|
||||
// Five metres of breathing room keeps an anonymous wall from sitting
|
||||
// exactly on the authored façade after both reservation circles touch.
|
||||
const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
|
||||
buildingReservations.push({ x, z, radius });
|
||||
}
|
||||
const blocks = createBlocks(world, buildingReservations);
|
||||
scene.add(blocks);
|
||||
scene.add(createLandmarks(world));
|
||||
scene.add(createLandmarks(world, buildingReservations));
|
||||
scene.add(createBridges(world));
|
||||
|
||||
/**
|
||||
@@ -300,8 +337,14 @@ export async function createScene(
|
||||
scene.add(clouds.group);
|
||||
|
||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||
markerLayer.setMarkers(options.markers ?? []);
|
||||
scene.add(markerLayer.group);
|
||||
|
||||
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
|
||||
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
||||
: null;
|
||||
if (roadTraffic) scene.add(roadTraffic.group);
|
||||
|
||||
let flightLayer: FlightLayer | null = null;
|
||||
let flightTimer = 0;
|
||||
if (options.flights) {
|
||||
@@ -365,7 +408,15 @@ export async function createScene(
|
||||
function flyTo(chapterId: string) {
|
||||
const ch = chapterById[chapterId];
|
||||
if (!ch) return;
|
||||
kit.flyTo(chapterPose(ch));
|
||||
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
|
||||
if (route && roadTraffic) {
|
||||
roadTraffic.setRoute(route.id);
|
||||
roadTraffic.setFollowing(true);
|
||||
} else {
|
||||
roadTraffic?.setFollowing(false);
|
||||
roadTraffic?.setVehicleActions({});
|
||||
kit.flyTo(chapterPose(ch));
|
||||
}
|
||||
if (currentChapter !== chapterId) {
|
||||
currentChapter = chapterId;
|
||||
for (const fn of chapterListeners) fn(chapterId);
|
||||
@@ -396,6 +447,7 @@ export async function createScene(
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
kit.tick(dt);
|
||||
roadTraffic?.tick(dt);
|
||||
clouds.tick(dt);
|
||||
if (options.flights && flightLayer) {
|
||||
flightTimer -= dt;
|
||||
@@ -444,6 +496,7 @@ export async function createScene(
|
||||
clouds.dispose();
|
||||
nightLights.dispose();
|
||||
markerLayer.dispose();
|
||||
roadTraffic?.dispose();
|
||||
kit.dispose();
|
||||
scene.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
@@ -481,6 +534,10 @@ export async function createScene(
|
||||
onChapterChange(fn) {
|
||||
chapterListeners.push(fn);
|
||||
},
|
||||
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
|
||||
vehicleState: () => roadTraffic?.hero() ?? null,
|
||||
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
||||
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
|
||||
setMarkers(markers) {
|
||||
markerLayer.setMarkers(markers);
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Bridge, LatLng } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
|
||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
|
||||
const out: THREE.Vector3[] = [];
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const from = path[i];
|
||||
@@ -40,12 +40,64 @@ function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Me
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||
function roadRibbon(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
color: number,
|
||||
lift = 0,
|
||||
): THREE.Mesh {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
const previous = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
if (!point || !previous || !next) continue;
|
||||
const dx = next.x - previous.x;
|
||||
const dz = next.z - previous.z;
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
positions.push(
|
||||
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||
);
|
||||
normals.push(0, 1, 0, 0, 1, 0);
|
||||
if (index < points.length - 1) {
|
||||
const a = index * 2;
|
||||
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeBoundingSphere();
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
export function createRoads(world: World): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "roads";
|
||||
for (const road of world.city.roads) {
|
||||
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||
group.add(ribbon(drapePath(world, road.path), road.width, color));
|
||||
const path = drapePath(world, road.path);
|
||||
group.add(roadRibbon(path, road.width, color));
|
||||
if (road.kind === "freeway") {
|
||||
// One warm median stroke is enough at corridor scale to read as divided
|
||||
// highway without spending a textured asset or a draw call per lane.
|
||||
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,37 @@ export interface Pin {
|
||||
blurb?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small, map-scale building drawn in place of a pin.
|
||||
*
|
||||
* This is deliberately a glyph rather than an architectural model. A city is
|
||||
* normally viewed from kilometres away, so loading a façade kit with one mesh
|
||||
* per window buys triangles nobody can see and gives up the one-draw-call city
|
||||
* that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar
|
||||
* — a ground floor, repeated bays, a roof line and a deterministic silhouette
|
||||
* — and expresses it in a handful of procedural meshes.
|
||||
*
|
||||
* Metres are used here because these values describe a real building even
|
||||
* though the city scene does not: `World` converts horizontal metres with
|
||||
* `metresPerUnit` and vertical metres with the city's exaggeration.
|
||||
*/
|
||||
export interface BuildingGlyph {
|
||||
kind: "building";
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
/** Approximate occupied floors; used to choose the façade rhythm. */
|
||||
storeys: number;
|
||||
/** Compass bearing of local −Z, degrees clockwise from true north. */
|
||||
heading: number;
|
||||
/** The silhouette family, not a tenant or product category. */
|
||||
profile: "tower" | "hangar" | "courtyard" | "block";
|
||||
/** Stable variation for bay widths and lit panes. */
|
||||
seed?: number;
|
||||
/** Neutral shell colour. The marker palette still supplies the door/accent. */
|
||||
bodyColor?: number;
|
||||
}
|
||||
|
||||
/** A `Pin` placed on a city, in degrees. */
|
||||
export interface Marker extends Pin {
|
||||
lat: number;
|
||||
@@ -265,6 +296,8 @@ export interface Marker extends Pin {
|
||||
* is that it is real is worse than admitting the gap.
|
||||
*/
|
||||
located?: boolean;
|
||||
/** Optional map-scale representation. Omit it for the ordinary pin. */
|
||||
glyph?: BuildingGlyph;
|
||||
}
|
||||
|
||||
/** Caller-supplied `colorKey` -> colour. */
|
||||
|
||||
Reference in New Issue
Block a user