mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
## Changes Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles. - **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone. - **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes. - **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too). ## How it was verified - `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles. - Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof. - New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette. - Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`). - Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions. Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes.
666 lines
26 KiB
JavaScript
666 lines
26 KiB
JavaScript
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
|
|
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
|
|
const evaluate = page.evaluate.bind(page);
|
|
page.evaluate = (fn, ...args) => {
|
|
// The two long trace collectors pass their duration explicitly.
|
|
const duration =
|
|
args.find((v) => typeof v === 'number' && v >= 6000 && v <= 120000) || 0;
|
|
const timeoutMs = Math.max(
|
|
defaultTimeoutMs,
|
|
duration ? duration + 5000 : 0,
|
|
);
|
|
const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
|
|
return evaluate(
|
|
async (source, values, timeoutMs, label) => {
|
|
let timer;
|
|
try {
|
|
return await Promise.race([
|
|
// Puppeteer callbacks are standalone functions, just as in evaluate.
|
|
Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
`Transit QA page wait timed out after ${timeoutMs} ms: ${label}`,
|
|
),
|
|
),
|
|
timeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
},
|
|
String(fn),
|
|
args,
|
|
timeoutMs,
|
|
label,
|
|
);
|
|
};
|
|
}
|
|
|
|
export async function reportTransitVisibility(page, label) {
|
|
const result = await page.evaluate(() => {
|
|
const app = window.__godsEyeView;
|
|
const layer = app.dataManager.layers.get('transit').module;
|
|
const state = layer._transitStateForTest();
|
|
return {
|
|
total: state._vehicles.size,
|
|
visible: state._visible.size,
|
|
moving: state._moving.size,
|
|
held: state._renderHeld,
|
|
requestRenderMode: app.viewer.scene.requestRenderMode,
|
|
renderLoop: app.viewer.useDefaultRenderLoop,
|
|
renderErrors: window.__transitRenderErrors || [],
|
|
bounds: state._viewBounds,
|
|
hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
|
|
};
|
|
});
|
|
console.log(` ${label}: ${JSON.stringify(result)}`);
|
|
if (result.renderLoop === false || result.renderErrors.length)
|
|
throw new Error(
|
|
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
/** Intersect primed histories with the new snapshot; disappearance is availability. */
|
|
export async function restoreRetainedTransit(retained) {
|
|
if (!retained.length)
|
|
return {
|
|
error: 'No multi-fix history observed during the probe',
|
|
unexercised: true,
|
|
};
|
|
const app = window.__godsEyeView;
|
|
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
|
const layer = app.dataManager.layers.get('transit').module;
|
|
const state = layer._transitStateForTest();
|
|
// enable() schedules proximity discovery; an empty pre-discovery map is not
|
|
// evidence that every primed vehicle departed. Wait for this feed's poll.
|
|
const deadline = Date.now() + 12000;
|
|
while (state._activeFeeds && !state._vehicles.size && Date.now() < deadline)
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
await layer.update();
|
|
if (
|
|
retained[0]?.primedAt &&
|
|
Math.floor(Date.now() / 60000) !== Math.floor(retained[0].primedAt / 60000)
|
|
)
|
|
return {
|
|
error: 'Reload crossed the priming minute; same-minute proof unavailable',
|
|
unexercised: true,
|
|
};
|
|
const history = retained.find((h) =>
|
|
state._vehicles.has(`${h.feedId || 'mbta'}:${h.vehicleId}`),
|
|
);
|
|
if (!history)
|
|
return {
|
|
error: `All ${retained.length} primed vehicles left the current snapshot`,
|
|
unexercised: true,
|
|
};
|
|
const entry = state._vehicles.get(
|
|
`${history.feedId || 'mbta'}:${history.vehicleId}`,
|
|
);
|
|
const camera = app.viewer.camera,
|
|
C = camera.positionCartographic.constructor;
|
|
camera.setView({
|
|
destination: C.toCartesian(
|
|
C.fromDegrees(entry.record.lon, entry.record.lat, 600),
|
|
),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
layer._transitPartsForTest().selection.selectVehicle(entry.key);
|
|
return { key: entry.key, oldest: history.oldestT };
|
|
}
|
|
|
|
export async function sampleTransitPixels(page, palette) {
|
|
return page.evaluate(async (palette) => {
|
|
const app = window.__godsEyeView;
|
|
const state = app.dataManager.layers
|
|
.get('transit')
|
|
.module._transitStateForTest();
|
|
// Decode the actual billboard rasters before the paint being judged.
|
|
const overlay = window.location
|
|
? await import('/src/overlays/worldOverlay.js')
|
|
: null;
|
|
const rasters = new Map();
|
|
await Promise.all(
|
|
[
|
|
...new Set(
|
|
[...state._vehicles.values()]
|
|
.map((e) => e.marker?.image)
|
|
.filter(Boolean),
|
|
),
|
|
].map(async (url) => {
|
|
const img = new Image();
|
|
img.src = url;
|
|
await img.decode();
|
|
const c = document.createElement('canvas');
|
|
c.width = img.width;
|
|
c.height = img.height;
|
|
const ctx = c.getContext('2d');
|
|
ctx.drawImage(img, 0, 0);
|
|
rasters.set(url, {
|
|
width: c.width,
|
|
height: c.height,
|
|
data: ctx.getImageData(0, 0, c.width, c.height).data,
|
|
});
|
|
}),
|
|
);
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
remove();
|
|
reject(
|
|
new Error('Transit pixel readback: no postRender within 5000 ms'),
|
|
);
|
|
}, 5000);
|
|
const remove =
|
|
window.__godsEyeView.viewer.scene.postRender.addEventListener(() => {
|
|
const app = window.__godsEyeView;
|
|
const layer = app.dataManager.layers.get('transit').module;
|
|
const state = layer._transitStateForTest();
|
|
const scene = app.viewer.scene;
|
|
const canvas = scene.canvas;
|
|
const view = scene.camera.viewMatrix;
|
|
const proj = scene.camera.frustum.projectionMatrix;
|
|
const mul = (m, v) => [
|
|
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
|
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
|
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
|
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
|
];
|
|
const width = canvas.clientWidth;
|
|
const height = canvas.clientHeight;
|
|
const sprites = [];
|
|
const footprints = [];
|
|
const intersects = (a, b) =>
|
|
a.x < b.x + b.w &&
|
|
a.x + a.w > b.x &&
|
|
a.y < b.y + b.h &&
|
|
a.y + a.h > b.y;
|
|
const plates =
|
|
app.styleManager
|
|
?.getDetectionDiagnostics?.()
|
|
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
|
|
if (state._selectedKey) {
|
|
const card = overlay?.getOverlayPaintRect(
|
|
'transit-selected',
|
|
state._selectedKey,
|
|
);
|
|
if (card) plates.push(card);
|
|
}
|
|
const panels = (overlay?.WORLD_OVERLAY_OCCLUDER_SELECTORS || [])
|
|
.flatMap((selector) => [...document.querySelectorAll(selector)])
|
|
.filter(
|
|
(el) =>
|
|
!el.hidden &&
|
|
el.checkVisibility({
|
|
checkOpacity: true,
|
|
checkVisibilityCSS: true,
|
|
}),
|
|
)
|
|
.map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
});
|
|
const skipped = [];
|
|
const style = state._stylePreset;
|
|
const uniforms = app.styleManager?.stages?.[style]?.uniforms || {};
|
|
const intensity = uniforms.intensity ?? 1;
|
|
const pixelation =
|
|
uniforms.pixelation ?? (style === 'surveillance' ? 2.5 : 1.5);
|
|
const sensor = style === 'surveillance' || style === 'thermal';
|
|
// Invert NVG's barrel transform; thermal only quantizes the source.
|
|
const screenPoint = (sx, sy) => {
|
|
let x = sx,
|
|
y = sy;
|
|
if (style === 'surveillance') {
|
|
const tx = (sx / width) * 2 - 1,
|
|
ty = (sy / height) * 2 - 1;
|
|
let cx = tx,
|
|
cy = ty;
|
|
for (let i = 0; i < 16; i++) {
|
|
const r2 = cx * cx + cy * cy;
|
|
const d =
|
|
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
|
cx = tx / d;
|
|
cy = ty / d;
|
|
}
|
|
x = ((cx + 1) * width) / 2;
|
|
y = ((cy + 1) * height) / 2;
|
|
}
|
|
const offset = sensor
|
|
? ((1 + (pixelation - 1) * intensity) * intensity) /
|
|
2 /
|
|
(canvas.width / width)
|
|
: 0;
|
|
return { x: x + offset, y: y - offset };
|
|
};
|
|
const uiBlocked = (x, y) => {
|
|
const el = document.elementFromPoint?.(x, y);
|
|
return (
|
|
el &&
|
|
el !== canvas &&
|
|
!el.closest?.(
|
|
'#world-overlay-root, #world-overlay-detection-surface',
|
|
) &&
|
|
el.tagName !== 'BODY' &&
|
|
el.tagName !== 'HTML'
|
|
);
|
|
};
|
|
for (const entry of state._vehicles.values()) {
|
|
if (!entry.marker || entry.marker.show === false) continue;
|
|
const p = entry.marker.position;
|
|
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
|
if (!(clip[3] > 0)) continue;
|
|
const screen = entry.marker.computeScreenSpacePosition?.(scene) || {
|
|
x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
|
|
y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
|
|
};
|
|
const rect = entry.marker.constructor.getScreenSpaceBoundingBox?.(
|
|
entry.marker,
|
|
screen,
|
|
) || { x: screen.x - 16, y: screen.y - 16, width: 32, height: 32 };
|
|
const { x, y } = screenPoint(screen.x, screen.y);
|
|
const c = Math.abs(Math.cos(entry.marker.rotation || 0)),
|
|
sn = Math.abs(Math.sin(entry.marker.rotation || 0));
|
|
const w = rect.width * c + rect.height * sn,
|
|
h = rect.height * c + rect.width * sn;
|
|
const footprint = { x: x - w / 2, y: y - h / 2, w, h };
|
|
footprints.push({ key: entry.key, ...footprint });
|
|
let reason = null;
|
|
if (
|
|
rect.x < 0 ||
|
|
rect.y < 0 ||
|
|
rect.x + rect.width > width ||
|
|
rect.y + rect.height > height
|
|
)
|
|
reason = 'billboard rectangle outside frame';
|
|
if (
|
|
entry.marker.color?.alpha < 0.95 ||
|
|
entry.markerCollection?.show === false
|
|
)
|
|
reason = 'billboard not opaque/collection hidden';
|
|
const raster = rasters.get(entry.marker.image);
|
|
if (!raster) reason = 'billboard alpha unavailable';
|
|
else if (
|
|
raster.data[
|
|
(Math.floor(raster.height / 2) * raster.width +
|
|
Math.floor(raster.width / 2)) *
|
|
4 +
|
|
3
|
|
] < 242
|
|
)
|
|
reason = 'billboard centre is transparent';
|
|
if (
|
|
sensor &&
|
|
Math.hypot(
|
|
(((x / width) * 2 - 1) * width) / height,
|
|
(y / height) * 2 - 1,
|
|
) > 0.6
|
|
)
|
|
reason = 'outside unmasked sensor lens';
|
|
if (style === 'noir') {
|
|
// Noir deliberately darkens the frame edges. Judge the core in
|
|
// its unmasked field, independently of the measured brightness.
|
|
const u = x / width,
|
|
v = y / height;
|
|
const transmission = Math.pow(
|
|
16 * u * (1 - u) * v * (1 - v),
|
|
0.3 + 0.4 * (uniforms.vignetteAmt ?? 0.5) * intensity,
|
|
);
|
|
if (1 - intensity + transmission * intensity < 0.9)
|
|
reason = 'outside unmasked noir field';
|
|
}
|
|
|
|
if (
|
|
[
|
|
[x, y],
|
|
[rect.x, rect.y],
|
|
[rect.x + rect.width, rect.y + rect.height],
|
|
[rect.x + rect.width, rect.y],
|
|
[rect.x, rect.y + rect.height],
|
|
].some(([px, py]) => uiBlocked(px, py))
|
|
)
|
|
reason = 'UI panel overlaps billboard';
|
|
if (panels.some((r) => intersects(footprint, r)))
|
|
reason = 'UI panel rectangle overlaps billboard';
|
|
if (plates.some((r) => intersects(footprint, r)))
|
|
reason = 'DETECT label/card rectangle overlaps billboard';
|
|
if (reason) {
|
|
skipped.push({ key: entry.key, reason });
|
|
continue;
|
|
}
|
|
if (x < 40 || y < 40 || x > width - 40 || y > height - 40) continue;
|
|
sprites.push({
|
|
key: entry.key,
|
|
mode: entry.mode,
|
|
x,
|
|
y,
|
|
selected: state._selectedKey === entry.key,
|
|
screenX: screen.x,
|
|
screenY: screen.y,
|
|
rect,
|
|
footprint,
|
|
bracketX: entry.detectContact?._candidateScreenX ?? screen.x,
|
|
bracketY: entry.detectContact?._candidateScreenY ?? screen.y,
|
|
raster,
|
|
rotation: entry.marker.rotation || 0,
|
|
halfW: Number.isFinite(entry.marker.width)
|
|
? Math.ceil(entry.marker.width / 2) + 2
|
|
: 11,
|
|
halfH: Number.isFinite(entry.marker.height)
|
|
? Math.ceil(entry.marker.height / 2) + 2
|
|
: 7,
|
|
});
|
|
}
|
|
const isolated = sprites.filter(
|
|
(a) =>
|
|
!a.selected &&
|
|
footprints.every(
|
|
(b) => b.key === a.key || !intersects(a.footprint, b),
|
|
),
|
|
);
|
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
|
const scale = canvas.width / canvas.clientWidth;
|
|
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
const readLuma = (cx, cy) => {
|
|
const px = Math.floor(cx * scale);
|
|
const py = canvas.height - 1 - Math.floor(cy * scale);
|
|
const buf = new Uint8Array(4);
|
|
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
|
return luma(buf[0], buf[1], buf[2]);
|
|
};
|
|
const surface = document.getElementById('world-overlay-canvas');
|
|
const ctx = surface?.getContext('2d');
|
|
// A hidden surface has no client size; nothing to read there.
|
|
const sscale =
|
|
surface && surface.clientWidth > 0
|
|
? surface.width / surface.clientWidth
|
|
: 0;
|
|
const out = [];
|
|
for (const s of isolated) {
|
|
if (out.length >= 12) break;
|
|
// The normal canvas includes DETECT plates and selected cards.
|
|
// A plate over the body invalidates this contact, even if WebGL below is hot.
|
|
if (ctx && sscale > 0) {
|
|
const body = ctx.getImageData(
|
|
Math.floor((s.x - 3) * sscale),
|
|
Math.floor((s.y - 3) * sscale),
|
|
Math.max(1, Math.ceil(6 * sscale)),
|
|
Math.max(1, Math.ceil(6 * sscale)),
|
|
).data;
|
|
if (body.some((v, i) => i % 4 === 3 && v > 0)) {
|
|
skipped.push({
|
|
key: s.key,
|
|
reason: 'DETECT label/card overlaps core',
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
// Pick nine points from a solid patch of the source silhouette,
|
|
// before examining framebuffer brightness. Avoid panels/window gaps.
|
|
let corePoints = null,
|
|
widestBand = -1;
|
|
const cosCore = Math.cos(s.rotation),
|
|
sinCore = Math.sin(s.rotation);
|
|
// A tram's parallel sides have equally wide bands. Prefer its upper
|
|
// body band over the geometric centre when those widths tie.
|
|
const offsets =
|
|
s.mode === 'tram'
|
|
? [-0.2, 0.2, -0.1, 0.1, 0]
|
|
: [0, -0.1, 0.1, -0.2, 0.2];
|
|
for (const offset of offsets) {
|
|
const candidates = [];
|
|
let solid = true,
|
|
bandWidth = Infinity;
|
|
// Rank source geometry, never the observed framebuffer brightness.
|
|
// A thin tram needs the widest opaque white band, with room for
|
|
// the pixel filter on each side of the sampled patch.
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
const ry = Math.floor(
|
|
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
|
|
s.raster.height,
|
|
);
|
|
const middle = Math.floor(s.raster.width / 2);
|
|
const white = (x) =>
|
|
x >= 0 &&
|
|
x < s.raster.width &&
|
|
[0, 1, 2, 3].every(
|
|
(c) =>
|
|
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
|
|
);
|
|
let left = middle,
|
|
right = middle;
|
|
while (white(left - 1)) left--;
|
|
while (white(right + 1)) right++;
|
|
bandWidth = Math.min(
|
|
bandWidth,
|
|
white(middle) ? right - left + 1 : 0,
|
|
);
|
|
}
|
|
for (let dx = -1; dx <= 1; dx++)
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
const localY = dy + offset * s.rect.height;
|
|
const rx = Math.floor(
|
|
(dx / s.rect.width + 0.5) * s.raster.width,
|
|
);
|
|
const ry = Math.floor(
|
|
(localY / s.rect.height + 0.5) * s.raster.height,
|
|
);
|
|
const i = (ry * s.raster.width + rx) * 4;
|
|
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
|
|
solid = false;
|
|
candidates.push(
|
|
screenPoint(
|
|
s.screenX + dx * cosCore + localY * sinCore,
|
|
s.screenY - dx * sinCore + localY * cosCore,
|
|
),
|
|
);
|
|
}
|
|
if (solid && bandWidth > widestBand) {
|
|
corePoints = candidates;
|
|
widestBand = bandWidth;
|
|
}
|
|
}
|
|
if (!corePoints) {
|
|
skipped.push({
|
|
key: s.key,
|
|
reason: 'no solid silhouette interior for nine samples',
|
|
});
|
|
continue;
|
|
}
|
|
// Picking is independent of rendered brightness: an overlapping
|
|
// label or sprite must not masquerade as a dim sensor signature.
|
|
// Check the whole sampled core, not just its centre pick.
|
|
let coreOwned = true;
|
|
if (scene.pick) {
|
|
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
|
|
for (const core of corePoints) {
|
|
let u = core.x / width,
|
|
v = 1 - core.y / height;
|
|
if (style === 'surveillance') {
|
|
const x = u * 2 - 1,
|
|
y = v * 2 - 1,
|
|
r2 = x * x + y * y;
|
|
const d =
|
|
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
|
u = (x * d + 1) / 2;
|
|
v = (y * d + 1) / 2;
|
|
}
|
|
if (sensor) {
|
|
const grid = 1 + (pixelation - 1) * intensity;
|
|
u +=
|
|
((Math.floor((u * canvas.width) / grid) * grid) /
|
|
canvas.width -
|
|
u) *
|
|
intensity;
|
|
v +=
|
|
((Math.floor((v * canvas.height) / grid) * grid) /
|
|
canvas.height -
|
|
v) *
|
|
intensity;
|
|
}
|
|
const picked = scene.pick(
|
|
{ x: u * width, y: (1 - v) * height },
|
|
1,
|
|
1,
|
|
);
|
|
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
|
coreOwned = false;
|
|
}
|
|
if (gl.bindFramebuffer)
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
}
|
|
if (!coreOwned) {
|
|
skipped.push({
|
|
key: s.key,
|
|
reason:
|
|
'sampled core is partially obscured or outside the sprite',
|
|
});
|
|
continue;
|
|
}
|
|
let centre = 0;
|
|
for (const core of corePoints)
|
|
centre += readLuma(core.x, core.y) / corePoints.length;
|
|
let ringMin = 1;
|
|
let ringMax = 0;
|
|
// Sample the actual rotated raster halo, not a fixed-radius circle
|
|
// that can hit the hot body or terrain after a display-size change.
|
|
const raster = s.raster;
|
|
const cos = Math.cos(s.rotation),
|
|
sin = Math.sin(s.rotation);
|
|
for (let ry = 1; ry < raster.height - 1; ry++)
|
|
for (let rx = 1; rx < raster.width - 1; rx++) {
|
|
const i = (ry * raster.width + rx) * 4;
|
|
if (
|
|
raster.data[i + 3] < 200 ||
|
|
raster.data[i] > 30 ||
|
|
raster.data[i + 1] > 30 ||
|
|
raster.data[i + 2] > 30
|
|
)
|
|
continue;
|
|
const dx = ((rx + 0.5) / raster.width - 0.5) * s.rect.width;
|
|
const dy = ((ry + 0.5) / raster.height - 0.5) * s.rect.height;
|
|
const q = screenPoint(
|
|
s.screenX + dx * cos + dy * sin,
|
|
s.screenY - dx * sin + dy * cos,
|
|
);
|
|
const v = readLuma(q.x, q.y);
|
|
ringMin = Math.min(ringMin, v);
|
|
ringMax = Math.max(ringMax, v);
|
|
}
|
|
let bracket = null;
|
|
if (ctx && sscale > 0) {
|
|
// Transit brackets follow the padded sprite extent and snapped
|
|
// stroke centres. Sample the middle of each straight segment,
|
|
// never a colour histogram containing antialiased edges/callouts.
|
|
const cx = Math.floor(s.bracketX) + 0.5,
|
|
cy = Math.floor(s.bracketY) + 0.5;
|
|
const hw = s.halfW,
|
|
hh = s.halfH,
|
|
mid = Math.max(4, Math.floor(Math.min(hw, hh) * 0.55)) / 2;
|
|
const points = [
|
|
[cx - hw + mid, cy - hh],
|
|
[cx + hw - mid, cy - hh],
|
|
[cx - hw + mid, cy + hh],
|
|
[cx + hw - mid, cy + hh],
|
|
[cx - hw, cy - hh + mid],
|
|
[cx + hw, cy - hh + mid],
|
|
[cx - hw, cy + hh - mid],
|
|
[cx + hw, cy + hh - mid],
|
|
];
|
|
let bestAlpha = 0;
|
|
for (const [index, [x, y]] of points.entries()) {
|
|
if (
|
|
uiBlocked(x, y) ||
|
|
plates.some((r) =>
|
|
intersects({ x: x - 1, y: y - 1, w: 2, h: 2 }, r),
|
|
)
|
|
)
|
|
continue;
|
|
// A label plate fills both sides of a stroke. Require empty
|
|
// pixels beyond its dark backing on the perpendicular axis.
|
|
const normal = index < 4 ? [0, 3] : [3, 0];
|
|
if (
|
|
[-1, 1].some(
|
|
(sign) =>
|
|
ctx.getImageData(
|
|
Math.floor((x + sign * normal[0]) * sscale),
|
|
Math.floor((y + sign * normal[1]) * sscale),
|
|
1,
|
|
1,
|
|
).data[3] > 0,
|
|
)
|
|
)
|
|
continue;
|
|
const data = ctx.getImageData(
|
|
Math.floor(x * sscale),
|
|
Math.floor(y * sscale),
|
|
1,
|
|
1,
|
|
).data;
|
|
if (data[3] > bestAlpha) {
|
|
bestAlpha = data[3];
|
|
bracket = [data[0], data[1], data[2]];
|
|
}
|
|
}
|
|
}
|
|
out.push({
|
|
key: s.key,
|
|
mode: s.mode,
|
|
verified: true,
|
|
rectangle: s.rect,
|
|
centre,
|
|
background:
|
|
[
|
|
readLuma(s.x + 25, s.y),
|
|
readLuma(s.x - 25, s.y),
|
|
readLuma(s.x, s.y + 25),
|
|
readLuma(s.x, s.y - 25),
|
|
].reduce((a, b) => a + b, 0) / 4,
|
|
ringMin,
|
|
ringMax,
|
|
bracket,
|
|
bracketTier: `transit_${s.mode}`,
|
|
bracketExpected: palette[s.mode]
|
|
? [1, 3, 5].map((i) =>
|
|
parseInt(palette[s.mode].slice(i, i + 2), 16),
|
|
)
|
|
: null,
|
|
x: s.x,
|
|
y: s.y,
|
|
});
|
|
}
|
|
clearTimeout(timer);
|
|
remove();
|
|
console.log(
|
|
`TRANSIT_SAMPLER ${style}: ${out.length} verified; skipped=${JSON.stringify(skipped)}`,
|
|
);
|
|
resolve(out);
|
|
});
|
|
window.__godsEyeView.viewer.scene.requestRender();
|
|
});
|
|
}, palette);
|
|
}
|
|
|
|
/** A fleet-to-fleet comparison must not inherit the city's selected marker. */
|
|
export function readTransitFleetStyle({ key, resetSelection = false } = {}) {
|
|
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
|
|
const state = layer._transitStateForTest();
|
|
if (resetSelection) layer._transitPartsForTest().selection.clearSelection();
|
|
const entry = key
|
|
? state._vehicles.get(key)
|
|
: [...state._vehicles.values()].find((e) => e.marker?.show);
|
|
return entry
|
|
? {
|
|
key: entry.key,
|
|
selected: state._selectedKey === entry.key,
|
|
color: entry.marker.color.toCssColorString(),
|
|
width: entry.marker.width,
|
|
image: entry.marker.image,
|
|
}
|
|
: null;
|
|
}
|