feat: boards load before you ask for them
The owner asked for one map, with detail arriving in the background rather than
behind a wait. The interface already said one place (4bd8481: one ladder, no tab
strip, no boot card). This is the world catching up: on a desktop both metros are
now resident within seconds of landing, and the switch that used to be a build
becomes a fog dip over a cache hit.
**Measured, three runs each, California -> The Bay after an 8 s dwell:**
the longest blocking task falls from **521 ms to 58 ms** (before 700/501/521,
after 58/58/61) and long tasks from 2 to 1.
**And the cost, stated rather than omitted.** Over ten seconds sitting on the
state board: 601 frames -> ~571, p95 frame interval 18.0 -> 18.7 ms, and **one
long task of ~500 ms that did not exist before**. The build is not chunked yet,
so this moves a freeze from the moment of interaction to a moment nobody asked
for. That is a real trade and the follow-on that removes it is chunking the
build. `PREFETCH_ENABLED` in main.ts turns the whole lane off in one line.
A parked board draws nothing: 371 draws per frame before the lane lands a board
and 371 after, patched at the GL entry points. The performance budget reproduces
every cell to the digit.
**Three defects found on the way, two of them latent for a while.**
1. `environmentRig` had no idea what off-stage meant. `createScene` calls
`apply()` unconditionally, two hundred lines above the `present` check, and
the rig holds one probe per kind: on a key miss it convolves a new one,
disposes the one the visible board is using, and repoints every applied scene
at the replacement. A board built ahead of the camera is observed at its own
centre — about forty minutes of apparent solar time across the state — so its
key differs by construction. The board on screen would have changed colour
because something invisible finished loading. Harmless until today only
because every build was followed within ~800 ms by the swap that presented it.
Fixed with `ApplyOptions.offstage`; the regression test was written first and
failed first.
2. `mounting` was assigned and never cleared. Harmless while the only reader was
the next `mountCity` wanting something to abort. It stopped being harmless the
moment a second lane asked "is a foreground build in flight?", because the
honest answer after the first mount of the session was permanently yes — the
background lane armed exactly never, and the only symptom was a feature that
silently did nothing. Found by measuring, not by reading.
3. `buildBoard`'s `onProgress` ends in `bootProgress`, which raises the switch
pill. A prefetch would have put "Building The Bay Area... terrain 42%" over a
visitor who did nothing — the exact chrome that removing the tab strip was
for. `quiet` closes it.
**The policy is pure and lives with the eviction policy it has to agree with.**
`prefetchTarget` and `canAdmit` in boards.ts take scalars, never a camera, so
boards.ts keeps CONTRACT section 1's no-DOM/no-WebGL/no-three promise and
prefetchPolicy.test.ts can assert against the real pack bounds. Two regimes,
because collapsing them was the first version's mistake: with free room the gate
is simply "is there a slot", since the common path is to land on the state at
1,551 km and click a metro, and on that path a proximity trigger fires never;
with the cache full — the handheld case, capacity two with California pinned —
proximity is the only thing that justifies an eviction. The discs are asserted
non-overlapping against the shipped bounds (92 + 146 km of reach across a 314 km
gap), which is the covering-set argument residentCapacity already rests on.
**Also fixed, and separately load-bearing: reconcile's flag parser lied.** It
prefix-matched, so `?reconcile=palette` selected nothing and produced an empty
set — indistinguishable from the flag being absent. Every photograph taken to
judge a rule could have been a photograph of the unreconciled board with no way
to tell. Exact names now, with a warning that says "the flag is NOT off".
And two consumers were reading the raw packs beside a reconciled World:
`buildLadder` derives every rung's stand-off from `focus`, and `createMinimap`
was handed `entry.city` next to `handle.world`. Both now read `world.city`.
Latent with the flag off — `reconciledCity` returns by identity — which is what
kept it alive: it corrupts the measurement rather than announcing itself.
**What I did NOT ship, having tried it.** `roads` on by default. The rule exists
to stop California drawing 1,919 m freeways, and `city.roads` is not what draws
them: California is the one board with `roadTraffic`, so `scene.ts:816` takes the
`createFreewayWorld` branch and `createRoads` — the only reader of `Road.width` —
is never called for it. The visible corridor is a deliberate atlas glyph sized so
DRIVE mode can drive down it. Photographed at three chapters the rule moved
Downtown LA not at all (empty diff bbox), FiDi by RMSE 0.0006, California by
0.001% of pixels; the only measurable effect anywhere was -112 triangles on
Southern California. See DEFAULT_RULES for the whole argument.
1,688 + 295 tests, every gate, chapter-identity OK against the unmodified
fixture, ui-smoke PASS, budget PASS with private-request checks clean on all ten
cells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+290
-19
@@ -41,11 +41,18 @@ import {
|
||||
type Atmosphere,
|
||||
type WeatherObservation,
|
||||
} from "./engine/atmosphere.ts";
|
||||
import { createBoardCache, residentCapacity, type BoardCache } from "./engine/boards.ts";
|
||||
import {
|
||||
createBoardCache,
|
||||
prefetchTarget,
|
||||
residentCapacity,
|
||||
type BoardCache,
|
||||
type PrefetchTier,
|
||||
} from "./engine/boards.ts";
|
||||
import {
|
||||
activeRung,
|
||||
buildLadder,
|
||||
handover,
|
||||
HANDOVER_STANDOFF_M,
|
||||
placesRows,
|
||||
REGION_LABELS,
|
||||
type LadderRung,
|
||||
@@ -66,6 +73,7 @@ import { createStage, deviceProfile } from "./engine/stage.ts";
|
||||
import { daylightPhase } from "./engine/solar.ts";
|
||||
import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts";
|
||||
import CALIFORNIA from "./cities/california.ts";
|
||||
import { reconciledCity } from "./cities/reconcile.ts";
|
||||
import SAN_FRANCISCO from "./cities/sf.ts";
|
||||
import SOCAL from "./cities/socal.ts";
|
||||
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
|
||||
@@ -243,7 +251,24 @@ const CALIFORNIA_DESTINATIONS = new Map<string, { cityId: string; officeId: stri
|
||||
* guards in `scripts/brand-assets` aim at. The ladder is a **view** over that
|
||||
* data and nothing more.
|
||||
*/
|
||||
const LADDER = buildLadder(CITIES.map((entry) => ({ id: entry.id, city: entry.city })));
|
||||
/**
|
||||
* The ladder is built from the **reconciled** packs, because `World` is.
|
||||
*
|
||||
* `world.ts:116` is `this.city = reconciledCity(city)`, so every number the
|
||||
* engine draws with — `lngScale`, `verticalExaggeration`, road widths — is the
|
||||
* reconciled one. The ladder reads `focus` to derive each rung's stand-off, and
|
||||
* a ladder built from the raw packs is a ladder measuring a world nobody is
|
||||
* looking at. With every rule off that is the same object by identity and this
|
||||
* line is a no-op; with a rule on it is the difference between the rail agreeing
|
||||
* with the camera and quietly disagreeing with it.
|
||||
*
|
||||
* Deliberately calling `reconciledCity` here rather than reading it off a
|
||||
* `World`: the ladder is built at module load, before any board is mounted, and
|
||||
* it must be — `PLACES` is the navigation and it exists before the first frame.
|
||||
*/
|
||||
const LADDER = buildLadder(
|
||||
CITIES.map((entry) => ({ id: entry.id, city: reconciledCity(entry.city) })),
|
||||
);
|
||||
const PLACES = placesRows(LADDER);
|
||||
|
||||
const JOURNEY_SESSION_KEY = "tera:journey:v1";
|
||||
@@ -699,6 +724,23 @@ let satellitesVisible = true;
|
||||
* allocated no WebGL context for the abandoned board.
|
||||
*/
|
||||
let mounting: AbortController | null = null;
|
||||
/**
|
||||
* The build nobody asked for.
|
||||
*
|
||||
* A strictly lower-priority second lane. It exists because "one map" is a claim
|
||||
* about continuity, and continuity is paid for by having the detail already on
|
||||
* the GPU when the camera gets there: CONTRACT §1.1 keeps three boards resident
|
||||
* on a desktop and pins California, and until now nothing ever *filled* that
|
||||
* residency except a visitor waiting through a build.
|
||||
*
|
||||
* Held separately from `mounting` rather than reusing it, because the two have
|
||||
* opposite rights. A foreground build may cancel a background one; a background
|
||||
* build may never cancel or queue in front of a foreground one, and it must be
|
||||
* abandonable the instant somebody touches anything. Keeping one controller for
|
||||
* both would make "abort the build" ambiguous at exactly the moment it matters.
|
||||
*/
|
||||
let prefetching: AbortController | null = null;
|
||||
|
||||
let godmode: Godmode | null = null;
|
||||
let poseEditor: PoseEditor | null = null;
|
||||
/**
|
||||
@@ -1517,6 +1559,16 @@ async function mountCity(id: string): Promise<void> {
|
||||
* build, and a built board belongs to the cache from the moment it lands in
|
||||
* it.
|
||||
*/
|
||||
/*
|
||||
* The background lane goes first, and the order is the whole guard.
|
||||
*
|
||||
* `createScene` awaits a heightfield the terrain worker is building; if the
|
||||
* prefetch is still holding that worker when a real request arrives, the
|
||||
* foreground build queues behind work nobody asked for. Aborting it here —
|
||||
* before `mounting?.abort()`, before anything else — is what makes a visitor's
|
||||
* click always the most important thing in the process.
|
||||
*/
|
||||
cancelPrefetch();
|
||||
mounting?.abort();
|
||||
const mount = new AbortController();
|
||||
mounting = mount;
|
||||
@@ -1534,22 +1586,41 @@ async function mountCity(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
showSwitchProgress(entry.label, null);
|
||||
const record = await buildBoard(entry, mount);
|
||||
if (record === null) {
|
||||
pendingPlace = null;
|
||||
hideSwitchProgress();
|
||||
return;
|
||||
try {
|
||||
const record = await buildBoard(entry, mount);
|
||||
if (record === null) {
|
||||
pendingPlace = null;
|
||||
hideSwitchProgress();
|
||||
return;
|
||||
}
|
||||
if (mount.signal.aborted || wantedCity !== id) {
|
||||
// Somebody changed their mind while this was in the air. It is a complete,
|
||||
// correct board — but nothing asked for it, and parking an unrequested
|
||||
// board in a cache with a ceiling would evict one somebody did ask for.
|
||||
disposeBoard(record);
|
||||
pendingPlace = null;
|
||||
hideSwitchProgress();
|
||||
return;
|
||||
}
|
||||
await presentBoard(record);
|
||||
} finally {
|
||||
/*
|
||||
* A finished mount is not a mount in flight.
|
||||
*
|
||||
* `mounting` used to be replaced and never cleared, which was harmless while
|
||||
* the only thing that read it was the next `mountCity` wanting something to
|
||||
* abort — an already-resolved controller aborts nothing and costs nothing.
|
||||
* It stopped being harmless the moment a second lane asked the obvious
|
||||
* question "is a foreground build happening right now?", because the honest
|
||||
* answer after the first mount of the session was permanently "yes". The
|
||||
* background lane armed exactly never, and the only symptom was a feature
|
||||
* that silently did nothing.
|
||||
*
|
||||
* Guarded on identity: a build that was superseded must not clear the
|
||||
* controller belonging to the build that superseded it.
|
||||
*/
|
||||
if (mounting === mount) mounting = null;
|
||||
}
|
||||
if (mount.signal.aborted || wantedCity !== id) {
|
||||
// Somebody changed their mind while this was in the air. It is a complete,
|
||||
// correct board — but nothing asked for it, and parking an unrequested
|
||||
// board in a cache with a ceiling would evict one somebody did ask for.
|
||||
disposeBoard(record);
|
||||
pendingPlace = null;
|
||||
hideSwitchProgress();
|
||||
return;
|
||||
}
|
||||
await presentBoard(record);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1565,6 +1636,19 @@ async function mountCity(id: string): Promise<void> {
|
||||
async function buildBoard(
|
||||
entry: { id: string; label: string; city: City },
|
||||
mount: AbortController,
|
||||
options: {
|
||||
/**
|
||||
* Build without saying so.
|
||||
*
|
||||
* The background lane needs this and nothing else does. `onProgress` below
|
||||
* ends in `bootProgress`, which raises the switch pill — so a board loaded
|
||||
* ahead of the camera would put "Building The Bay Area… terrain 42%" on
|
||||
* screen over a visitor who did nothing, which is exactly the "you are
|
||||
* somewhere else now" chrome that removing the tab strip and the boot card
|
||||
* was for. A prefetch that announces itself is worse than no prefetch.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
} = {},
|
||||
): Promise<MountedBoard | null> {
|
||||
const id = entry.id;
|
||||
/**
|
||||
@@ -1804,6 +1888,7 @@ async function buildBoard(
|
||||
// An abandoned build keeps its worker running for a tick or two after the
|
||||
// abort; its percentages must not land on the card the new city is using.
|
||||
onProgress: (p) => {
|
||||
if (options.quiet) return;
|
||||
if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
|
||||
},
|
||||
...(access.subject !== null
|
||||
@@ -1890,9 +1975,13 @@ async function buildBoard(
|
||||
|
||||
const minimap = createMinimap({
|
||||
world: handle.world,
|
||||
city: entry.city,
|
||||
// The pack the *World* is drawing, not the one the module imported. These
|
||||
// are the same object whenever no reconciliation rule is on, and when one
|
||||
// is, the plan view drawn from the raw pack is a plan of a different
|
||||
// California than the one under the camera.
|
||||
city: handle.world.city,
|
||||
// The plan stops changing shape when the board does. See `planFrame`.
|
||||
frame: planFrame(entry.city),
|
||||
frame: planFrame(handle.world.city),
|
||||
camera: handle.stageScene.camera,
|
||||
controls: handle.stageScene.controls,
|
||||
markerPalette: palette,
|
||||
@@ -2029,6 +2118,15 @@ async function presentBoard(record: MountedBoard): Promise<void> {
|
||||
|
||||
activateBoard(record);
|
||||
hideSwitchProgress();
|
||||
/*
|
||||
* Arriving is itself a reason to look ahead.
|
||||
*
|
||||
* The camera listeners below arm the lane as the visitor moves, but landing on
|
||||
* a board is a pose change nothing fired a `change` event for — and it is the
|
||||
* single most likely moment for the next board to already be worth having. The
|
||||
* old prefetch, if any, was cancelled by `mountCity` before this build began.
|
||||
*/
|
||||
maybePrefetch(record);
|
||||
|
||||
// Before the lift, so the incoming board's first drawn frame is already inside
|
||||
// the haze the outgoing one ended in rather than snapping to clear and then
|
||||
@@ -2310,6 +2408,7 @@ function activateBoard(record: MountedBoard): void {
|
||||
lastAltitude = altitude;
|
||||
applyCameraFog();
|
||||
maybeHandover(record);
|
||||
maybePrefetch(record);
|
||||
};
|
||||
const onDragStart = () => { dragging = true; };
|
||||
const onDragEnd = () => {
|
||||
@@ -2318,6 +2417,7 @@ function activateBoard(record: MountedBoard): void {
|
||||
// refuses while a pointer is down, so without this a descent that ends
|
||||
// below a threshold would sit there until the next event.
|
||||
maybeHandover(record);
|
||||
maybePrefetch(record);
|
||||
};
|
||||
controls.addEventListener("change", onCameraMoved);
|
||||
controls.addEventListener("start", onDragStart);
|
||||
@@ -2362,6 +2462,17 @@ let dragging = false;
|
||||
*/
|
||||
const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") === "1";
|
||||
|
||||
/**
|
||||
* Whether a board may be built before anybody asks for it.
|
||||
*
|
||||
* On, and this is the single constant that turns it off — the rollback for the
|
||||
* whole background lane, per the plan this was built from. It is a constant and
|
||||
* not a query flag deliberately: a query flag is a thing that ships in one state
|
||||
* and is measured in another, and every number quoted for this lane was measured
|
||||
* with it in the state it ships in.
|
||||
*/
|
||||
const PREFETCH_ENABLED = true;
|
||||
|
||||
function maybeHandover(record: MountedBoard): void {
|
||||
if (!FREE_HANDOVER) return;
|
||||
if (inside || fogDip !== null || record !== visibleBoard) return;
|
||||
@@ -2383,6 +2494,166 @@ function maybeHandover(record: MountedBoard): void {
|
||||
if (next !== null && next !== wantedCity) switchCity(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The metro tiers the background lane may load, and the stand-off each takes
|
||||
* over at. Derived from the ladder's own handover table, so there is exactly one
|
||||
* place in the product that says where a board stops being the right one.
|
||||
*/
|
||||
const PREFETCH_TIERS: readonly PrefetchTier[] = CITIES.flatMap((entry) => {
|
||||
const handoverStandoffM = HANDOVER_STANDOFF_M[entry.id];
|
||||
return handoverStandoffM === undefined
|
||||
? []
|
||||
: [{ id: entry.id, handoverStandoffM, bounds: entry.city.bounds }];
|
||||
});
|
||||
|
||||
/**
|
||||
* Drop whatever the background lane is doing.
|
||||
*
|
||||
* Called before every foreground abort and on every board present, because a
|
||||
* prefetch that outlives the reason it started is a build competing with a
|
||||
* visitor for one terrain worker. `createScene` honours the signal by dropping
|
||||
* the heightfield and resolving `null` without allocating a renderer, so an
|
||||
* abandoned prefetch costs nothing to abandon.
|
||||
*/
|
||||
/**
|
||||
* How long a board must be on screen and undisturbed before anything loads
|
||||
* behind it, in milliseconds.
|
||||
*
|
||||
* The board that just arrived is still linking shaders, uploading buffers and
|
||||
* settling its first frames; starting another build inside that window is the
|
||||
* one way this feature can make the product measurably worse. Deliberately not
|
||||
* `requestIdleCallback`: Safari did not ship it until recently and it is exactly
|
||||
* the browser most likely to be on the tightest budget, so the deferral is a
|
||||
* timer everywhere rather than a good mechanism on some platforms and none on
|
||||
* others.
|
||||
*/
|
||||
const PREFETCH_IDLE_MS = 2_000;
|
||||
|
||||
let prefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cancelPrefetch(): void {
|
||||
if (prefetchTimer !== null) {
|
||||
clearTimeout(prefetchTimer);
|
||||
prefetchTimer = null;
|
||||
}
|
||||
prefetching?.abort();
|
||||
prefetching = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the board the camera is heading for, before it is asked for.
|
||||
*
|
||||
* ## Why this is the whole of "loads in the background"
|
||||
*
|
||||
* Everything else was already there. `present: false` already exists on
|
||||
* `SceneOptions` and is already honoured; `boards` already holds three on a
|
||||
* desktop with California pinned; `presentBoard` already runs a fog dip over a
|
||||
* cache hit at zero shader links and zero blocked milliseconds. The only missing
|
||||
* piece was something that decides to build a board nobody has clicked on, and
|
||||
* the reason it was missing is that it is the piece that can hurt: it competes
|
||||
* for the terrain worker and it can relight the board on screen.
|
||||
*
|
||||
* Both hazards are closed rather than hoped about. The competition is closed by
|
||||
* `cancelPrefetch()` running before every foreground abort. The relighting is
|
||||
* closed in `environmentRig.ts` — an off-stage apply borrows the cached probe
|
||||
* instead of convolving a new one and disposing the visible board's — and by
|
||||
* `scene.ts` passing `offstage: !presented` on every clock-driven apply.
|
||||
*
|
||||
* ## Why the decision is not made here
|
||||
*
|
||||
* `prefetchTarget` is pure, takes scalars, and lives in `boards.ts` with the
|
||||
* eviction policy it has to agree with. This function is the effects half: read
|
||||
* three numbers off the camera, ask, and act. Keeping the two apart is what lets
|
||||
* `prefetchPolicy.test.ts` assert against the real pack bounds — including that
|
||||
* the two metros' trigger discs provably cannot overlap — with no DOM, no WebGL
|
||||
* and no `three` anywhere near it.
|
||||
*/
|
||||
function maybePrefetch(record: MountedBoard): void {
|
||||
if (!PREFETCH_ENABLED) return;
|
||||
// Never behind a visitor. A foreground build, an office, or a dip in progress
|
||||
// all mean something is already happening that matters more than this.
|
||||
if (inside || mounting !== null || fogDip !== null) return;
|
||||
if (record !== visibleBoard) return;
|
||||
if (prefetching !== null) return;
|
||||
|
||||
const target = record.handle.stageScene.controls.target;
|
||||
const [lat, lng] = record.handle.world.unproject(target.x, target.z);
|
||||
const id = prefetchTarget({
|
||||
tiers: PREFETCH_TIERS,
|
||||
lat,
|
||||
lng,
|
||||
standoffM: record.handle.cameraStandoffMetres(),
|
||||
resident: boards.ids(),
|
||||
visible: visibleBoard?.id ?? null,
|
||||
capacity: residentCapacity(deviceProfile().handheld),
|
||||
pinned: ["california"],
|
||||
});
|
||||
if (id === null) return;
|
||||
const entry = CITIES.find((candidate) => candidate.id === id);
|
||||
if (entry === undefined) return;
|
||||
|
||||
/*
|
||||
* Armed now, started later.
|
||||
*
|
||||
* The timer is what separates "the camera is somewhere a load would be useful"
|
||||
* from "and it has been there long enough that nothing else needs the machine".
|
||||
* Every gesture cancels it through `cancelPrefetch`, so a visitor sweeping the
|
||||
* camera across the state re-arms it repeatedly and starts nothing until they
|
||||
* stop — which is the behaviour a background lane has to have to be invisible.
|
||||
*/
|
||||
if (prefetchTimer !== null) clearTimeout(prefetchTimer);
|
||||
prefetchTimer = setTimeout(() => {
|
||||
prefetchTimer = null;
|
||||
startPrefetch(entry);
|
||||
}, PREFETCH_IDLE_MS);
|
||||
}
|
||||
|
||||
/** The build itself, once the deferral above has decided it is safe to start. */
|
||||
function startPrefetch(entry: { id: string; label: string; city: City }): void {
|
||||
const id = entry.id;
|
||||
// Re-checked after the wait, because two seconds is long enough for every one
|
||||
// of these to have changed.
|
||||
if (inside || mounting !== null || fogDip !== null) return;
|
||||
if (prefetching !== null || boards.has(id) || visibleBoard?.id === id) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
prefetching = controller;
|
||||
void (async () => {
|
||||
const built = await buildBoard(entry, controller, { quiet: true });
|
||||
// Four ways this stops mattering between the ask and the answer, and all
|
||||
// four have to be checked *after* the await rather than before it: the
|
||||
// visitor may have clicked something, the lane may have been cancelled, the
|
||||
// board may have arrived by the front door in the meantime, or the cache may
|
||||
// no longer have room for it without evicting what is on screen.
|
||||
if (built === null) {
|
||||
if (prefetching === controller) cancelPrefetch();
|
||||
return;
|
||||
}
|
||||
const stale =
|
||||
controller.signal.aborted ||
|
||||
prefetching !== controller ||
|
||||
boards.has(id) ||
|
||||
visibleBoard?.id === id;
|
||||
if (stale) {
|
||||
disposeBoard(built);
|
||||
if (prefetching === controller) prefetching = null;
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* Parked, not presented.
|
||||
*
|
||||
* `presentBoard` writes the cache itself at the moment a board becomes
|
||||
* visible; this one never becomes visible, so it writes its own entry — and
|
||||
* it goes in as the *least* recently shown, which is what `put` already does
|
||||
* for a board that has never been shown. Anything evicted to make room is
|
||||
* disposed here, exactly as `presentBoard` does, because the rig's ledger is
|
||||
* a strong reference and a board left in it retains the whole scene graph.
|
||||
*/
|
||||
for (const evicted of boards.put(id, built)) disposeBoard(evicted);
|
||||
prefetching = null;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle the plan view draws, which is no longer the board's own.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user