mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Restore ground, picking and horizon parity through the globe contract
Draw launch-pad zones as radial ground discs with the original fill, rim, transitions, emission and depth bias. Drape Transit history and its backing on ground corridors. Keep the current age bands and once-per-second updates; advance the active head with a reveal fraction and rebuild only at segment crossings. Its wider dark edge and colour core blend once, as on Cesium. Navara composes these looks after tone mapping. Build reveal corridors and dynamic Transit history synchronously once shared terrain bounds are loaded. Keep requesting frames until the visible globe tiles and committed primitives are ready: classification needs current surface depth after a camera move. Retain the reference radial material, its alpha envelope and the head's material-only reveal updates. Remove the empty Navara ground effect from the render chain when the last shown set is hidden or destroyed. It otherwise remains as the insertion anchor for later pulse effects after its packed textures have been disposed. Cover reveal, destruction and replacement with fresh-state comparisons for corridors, polygons, discs and cells. Cesium drawing is unchanged by this fix. Keep the radial pixel check at a 500 metre radius and 4 kilometre view, sample the brightest of six inner points around the native fill gap, and sample across the rim width. Restore its view during arrival flights and run it last among ground checks. Both suite launchers wait for the linked view to arrive before checking. Keep these checks with the radial look. Pass rectangular pick windows and drill limits through input subscriptions. Street Level uses its 9 pixel window and CCTV uses its 14 pixel window and six-result drill. Down picks stay synchronous, moves coalesce per frame and Navara clicks keep the GPU answer from their gesture. Add a projector ellipsoid option and use WGS84 radii reduced by 1,000 metres for Street Level. These changes restore the original Cesium pad fill and ground trail rather than the temporary ring and raised lines. Navara follows the same appearance and horizon policy, and now honours explicit CPU pick windows. Existing callers without these options retain their defaults. The four debts share the contract, suite and engine implementations, so keep them together. No coupling count grew or fell: renderer lines remain 0 and 189, external Cesium imports 3, external viewer lines 891, engine-kind checks 1, engine resolution calls 0, extension reads 3, ground passes 1, SDK loads 3, screen handler files 4 and engine-name lines 32. The contract and ground suites now wait until the camera has reached the view in their link before checking, because the app's restore flight could land in the middle of a check; that was also the cause of the intermittent camera-events failure. The radial check uses a launch pad's size and samples the fill at several inner points: Cesium's own old pad leaves the same gap in a small ground disc's fill. The pulse-band check takes its base frame once the surface has settled. Checked in the browser, keyless: the contract suite passes on both engines (84 and 85 checks), the ground suite passes on both, CCTV v2 passes 58 checks, and the space, transit, Street Level and CCTV engine switches pass. The transit and Street Level scripts fail as they do before this change.
This commit is contained in:
1 parent
be08163caa
commit
aa6959c97b
49 files changed
+1727
-455
No files matched your search
@@ -2143,7 +2143,8 @@
|
|||||||
"src/geo/",
|
"src/geo/",
|
||||||
"src/layers/shared/color.js",
|
"src/layers/shared/color.js",
|
||||||
"src/globe/cameraMath.js",
|
"src/globe/cameraMath.js",
|
||||||
"src/layers/streetLevel/render/display.js"
|
"src/layers/streetLevel/render/display.js",
|
||||||
|
"src/globe/contract.js"
|
||||||
],
|
],
|
||||||
"external": ["pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,9 +66,17 @@ async function runOn(browser, kind) {
|
|||||||
);
|
);
|
||||||
// The suite owns the globe: no camera flight running, and the app's panels
|
// The suite owns the globe: no camera flight running, and the app's panels
|
||||||
// let the pointer through to the canvas.
|
// let the pointer through to the canvas.
|
||||||
|
// The app restores the view in the link with a flight that can start
|
||||||
|
// after the loading screen goes: wait until the camera is there and still,
|
||||||
|
// or that flight lands in the middle of a check.
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
() => {
|
||||||
{ timeout: 60_000, polling: 250 },
|
const { camera } = window.__godsEyeView.engine;
|
||||||
|
return (
|
||||||
|
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 120_000, polling: 250 },
|
||||||
);
|
);
|
||||||
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
||||||
// canvas of its own above it.
|
// canvas of its own above it.
|
||||||
|
|||||||
@@ -47,9 +47,17 @@ async function runOn(browser, kind) {
|
|||||||
{ timeout: 300_000, polling: 250 },
|
{ timeout: 300_000, polling: 250 },
|
||||||
kind,
|
kind,
|
||||||
);
|
);
|
||||||
|
// The app restores the view in the link with a flight that can start
|
||||||
|
// after the loading screen goes: wait until the camera is there and still,
|
||||||
|
// or that flight lands in the middle of a check.
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
() => {
|
||||||
{ timeout: 60_000, polling: 250 },
|
const { camera } = window.__godsEyeView.engine;
|
||||||
|
return (
|
||||||
|
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 120_000, polling: 250 },
|
||||||
);
|
);
|
||||||
// The globe drawn and its tiles loaded: a restored view hides the globe
|
// The globe drawn and its tiles loaded: a restored view hides the globe
|
||||||
// for a moment after the loading screen goes.
|
// for a moment after the loading screen goes.
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ try {
|
|||||||
),
|
),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||||
});
|
});
|
||||||
// The trail draws through engine.lines (render/display.js); its renderer
|
// The trail draws through engine.ground (render/display.js); its renderer
|
||||||
// is the handle that shows and hides the whole trail. Keep the one the
|
// is the handle that shows and hides the whole trail. Keep the one the
|
||||||
// selection below creates.
|
// selection below creates.
|
||||||
let trailRenderer = null;
|
let trailRenderer = null;
|
||||||
@@ -320,8 +320,8 @@ try {
|
|||||||
scene.globe.tilesLoaded &&
|
scene.globe.tilesLoaded &&
|
||||||
parts.trails.diagnostics().body === trail.body,
|
parts.trails.diagnostics().body === trail.body,
|
||||||
elapsedMs: performance.now() - started,
|
elapsedMs: performance.now() - started,
|
||||||
// The trail's engine lines (age bands and head, each with a backing).
|
// The trail's ground corridors (age bands and head, each with a backing).
|
||||||
lines: app.engine.lines
|
lines: app.engine.ground
|
||||||
.list()
|
.list()
|
||||||
.filter(({ id }) => id.startsWith('transit-trail-')),
|
.filter(({ id }) => id.startsWith('transit-trail-')),
|
||||||
};
|
};
|
||||||
@@ -337,9 +337,8 @@ try {
|
|||||||
const pass =
|
const pass =
|
||||||
result.before?.shown === false &&
|
result.before?.shown === false &&
|
||||||
result.before?.phase === 'playing' &&
|
result.before?.phase === 'playing' &&
|
||||||
// preparedAtSelection is reported, not required: the trail is engine
|
// Ground history is prepared even while marker floors are unresolved.
|
||||||
// lines in the air now (render/display.js), not a draped ground
|
result.preparedAtSelection &&
|
||||||
// polyline, so it has no geometry until the floors it waits on arrive.
|
|
||||||
result.markerShow &&
|
result.markerShow &&
|
||||||
result.body &&
|
result.body &&
|
||||||
result.bodyShow &&
|
result.bodyShow &&
|
||||||
|
|||||||
@@ -655,12 +655,12 @@ export async function runTrailVisibility({
|
|||||||
),
|
),
|
||||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||||
});
|
});
|
||||||
// The trail draws through engine.lines (render/display.js); keep the
|
// The trail draws through engine.ground (render/display.js); keep the
|
||||||
// renderer the selection below creates, the handle that shows and
|
// renderer the selection below creates, the handle that shows and
|
||||||
// hides the whole trail.
|
// hides the whole trail.
|
||||||
const createLine = app.engine.lines.createLine.bind(app.engine.lines);
|
const createLine = app.engine.ground.createSet.bind(app.engine.ground);
|
||||||
window.__qaTransitLineKinds = new Map();
|
window.__qaTransitLineKinds = new Map();
|
||||||
app.engine.lines.createLine = (options) => {
|
app.engine.ground.createSet = (options) => {
|
||||||
const line = createLine(options);
|
const line = createLine(options);
|
||||||
if (options.id.startsWith('transit-trail-'))
|
if (options.id.startsWith('transit-trail-'))
|
||||||
window.__qaTransitLineKinds.set(
|
window.__qaTransitLineKinds.set(
|
||||||
@@ -670,7 +670,7 @@ export async function runTrailVisibility({
|
|||||||
return line;
|
return line;
|
||||||
};
|
};
|
||||||
window.__restoreTransitLines = () => {
|
window.__restoreTransitLines = () => {
|
||||||
app.engine.lines.createLine = createLine;
|
app.engine.ground.createSet = createLine;
|
||||||
};
|
};
|
||||||
const createTrailRenderer = parts.renderer.createTrailRenderer;
|
const createTrailRenderer = parts.renderer.createTrailRenderer;
|
||||||
parts.renderer.createTrailRenderer = (...args) => {
|
parts.renderer.createTrailRenderer = (...args) => {
|
||||||
@@ -796,12 +796,13 @@ export async function runTrailVisibility({
|
|||||||
const walk = (collection) => {
|
const walk = (collection) => {
|
||||||
for (let i = 0; i < collection.length; i++) {
|
for (let i = 0; i < collection.length; i++) {
|
||||||
const primitive = collection.get(i);
|
const primitive = collection.get(i);
|
||||||
const kind = window.__qaTransitLineKinds.get(primitive?.gevLine);
|
const kind = window.__qaTransitLineKinds.get(primitive?.gevGroundSet);
|
||||||
if (kind) wrap(primitive, kind);
|
if (kind) wrap(primitive, kind);
|
||||||
else if (typeof primitive?.get === 'function') walk(primitive);
|
else if (typeof primitive?.get === 'function') walk(primitive);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(scene.primitives);
|
walk(scene.primitives);
|
||||||
|
walk(scene.groundPrimitives);
|
||||||
});
|
});
|
||||||
const post = scene.postRender.addEventListener(() => {
|
const post = scene.postRender.addEventListener(() => {
|
||||||
frames++;
|
frames++;
|
||||||
@@ -907,7 +908,7 @@ export async function runTrailVisibility({
|
|||||||
const item = collection.get(i);
|
const item = collection.get(i);
|
||||||
if (
|
if (
|
||||||
String(item?.gevMoverSet ?? '').startsWith('transit') ||
|
String(item?.gevMoverSet ?? '').startsWith('transit') ||
|
||||||
String(item?.gevLine ?? '').startsWith('transit-trail-')
|
String(item?.gevGroundSet ?? '').startsWith('transit-trail-')
|
||||||
)
|
)
|
||||||
drawnByTransit.push(item);
|
drawnByTransit.push(item);
|
||||||
else if (typeof item?.get === 'function' && 'length' in item)
|
else if (typeof item?.get === 'function' && 'length' in item)
|
||||||
@@ -915,6 +916,7 @@ export async function runTrailVisibility({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(scene.primitives);
|
walk(scene.primitives);
|
||||||
|
walk(scene.groundPrimitives);
|
||||||
const lengths = [0];
|
const lengths = [0];
|
||||||
for (let i = 1; i < points.length; i++)
|
for (let i = 1; i < points.length; i++)
|
||||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||||
|
|||||||
@@ -165,11 +165,15 @@ export const LAYER_MANIFEST = Object.freeze({
|
|||||||
'alpr-cameras': display('createAlprDisplay', ['movers']),
|
'alpr-cameras': display('createAlprDisplay', ['movers']),
|
||||||
'street-level': display('createStreetLevelDisplay', ['movers', 'ground']),
|
'street-level': display('createStreetLevelDisplay', ['movers', 'ground']),
|
||||||
satellites: display('createSatellitesDisplay', ['movers', 'lines']),
|
satellites: display('createSatellitesDisplay', ['movers', 'lines']),
|
||||||
'rocket-launches': display('createLaunchesDisplay', ['movers', 'lines']),
|
'rocket-launches': display('createLaunchesDisplay', [
|
||||||
|
'movers',
|
||||||
|
'lines',
|
||||||
|
'ground',
|
||||||
|
]),
|
||||||
traffic: display('createTrafficDisplay', ['movers', 'ground']),
|
traffic: display('createTrafficDisplay', ['movers', 'ground']),
|
||||||
cctv: display('createCctvDisplay', ['movers', 'lines', 'meshes']),
|
cctv: display('createCctvDisplay', ['movers', 'lines', 'meshes']),
|
||||||
radio: display('createRadioDisplay', ['movers']),
|
radio: display('createRadioDisplay', ['movers']),
|
||||||
transit: display('createTransitDisplay', ['movers', 'lines']),
|
transit: display('createTransitDisplay', ['movers', 'ground']),
|
||||||
bikeshare: display('createBikeshareDisplay', ['movers']),
|
bikeshare: display('createBikeshareDisplay', ['movers']),
|
||||||
directions: display('createDirectionsDisplay', ['movers', 'ground']),
|
directions: display('createDirectionsDisplay', ['movers', 'ground']),
|
||||||
'recent-imagery': display('createRecentImageryDisplay', [
|
'recent-imagery': display('createRecentImageryDisplay', [
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function createCalibrationGizmo({
|
|||||||
const entities = new Map(); // part -> Entity
|
const entities = new Map(); // part -> Entity
|
||||||
|
|
||||||
const { pickRay, setCursor } = renderer;
|
const { pickRay, setCursor } = renderer;
|
||||||
const pickGizmoPart = (position) => renderer.pickPart(position);
|
const pickGizmoPart = (event) => renderer.pickPart(event);
|
||||||
const polylinePart = (part, ...args) =>
|
const polylinePart = (part, ...args) =>
|
||||||
entities.set(part, renderer.createLine(part, ...args));
|
entities.set(part, renderer.createLine(part, ...args));
|
||||||
const pointPart = (part, ...args) =>
|
const pointPart = (part, ...args) =>
|
||||||
@@ -530,7 +530,7 @@ export function createCalibrationGizmo({
|
|||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
|
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
|
||||||
if (!isPointerFree()) return;
|
if (!isPointerFree()) return;
|
||||||
if (!enabled || drag) return;
|
if (!enabled || drag) return;
|
||||||
const part = pickGizmoPart(event.position);
|
const part = pickGizmoPart(event);
|
||||||
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
||||||
if (!part) return;
|
if (!part) return;
|
||||||
const started = beginDrag(part, event.position);
|
const started = beginDrag(part, event.position);
|
||||||
@@ -563,7 +563,7 @@ export function createCalibrationGizmo({
|
|||||||
}
|
}
|
||||||
if (now - lastHoverAt < HOVER_THROTTLE_MS) return;
|
if (now - lastHoverAt < HOVER_THROTTLE_MS) return;
|
||||||
lastHoverAt = now;
|
lastHoverAt = now;
|
||||||
setHovered(pickGizmoPart(event.endPosition));
|
setHovered(pickGizmoPart(event));
|
||||||
});
|
});
|
||||||
|
|
||||||
renderer.on('up', () => {
|
renderer.on('up', () => {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ test('every scene click handler consults ownership before it picks', () => {
|
|||||||
'src/layers/infrastructure/render/display.js',
|
'src/layers/infrastructure/render/display.js',
|
||||||
'const picked = event.picked;',
|
'const picked = event.picked;',
|
||||||
],
|
],
|
||||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
|
['src/data/cctvGizmo.js', 'pickGizmoPart(event)'],
|
||||||
// ALPR and bikeshare take the engine's click with its pick.
|
// ALPR and bikeshare take the engine's click with its pick.
|
||||||
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
||||||
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
|
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// keeps Navara's camera on the same pose and fov.
|
// keeps Navara's camera on the same pose and fov.
|
||||||
|
|
||||||
import * as geo from '../geo/index.js';
|
import * as geo from '../geo/index.js';
|
||||||
|
import { horizonEllipsoidRadii } from './contract.js';
|
||||||
|
|
||||||
const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo;
|
const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo;
|
||||||
const WGS84 = geo.ellipsoid.WGS84;
|
const WGS84 = geo.ellipsoid.WGS84;
|
||||||
@@ -238,18 +239,19 @@ const horizonTarget = cartesian3.create();
|
|||||||
const horizonVt = cartesian3.create();
|
const horizonVt = cartesian3.create();
|
||||||
|
|
||||||
/** Cesium's EllipsoidalOccluder.isPointVisible from `eye`. */
|
/** Cesium's EllipsoidalOccluder.isPointVisible from `eye`. */
|
||||||
export function horizonVisible(eye, position) {
|
export function horizonVisible(eye, position, options) {
|
||||||
|
const radii = horizonEllipsoidRadii(options) ?? RADII;
|
||||||
const camera = cartesian3.fromElements(
|
const camera = cartesian3.fromElements(
|
||||||
eye.x / RADII.x,
|
eye.x / radii.x,
|
||||||
eye.y / RADII.y,
|
eye.y / radii.y,
|
||||||
eye.z / RADII.z,
|
eye.z / radii.z,
|
||||||
horizonCamera,
|
horizonCamera,
|
||||||
);
|
);
|
||||||
const limbSquared = cartesian3.magnitudeSquared(camera) - 1;
|
const limbSquared = cartesian3.magnitudeSquared(camera) - 1;
|
||||||
const target = cartesian3.fromElements(
|
const target = cartesian3.fromElements(
|
||||||
position.x / RADII.x,
|
position.x / radii.x,
|
||||||
position.y / RADII.y,
|
position.y / radii.y,
|
||||||
position.z / RADII.z,
|
position.z / radii.z,
|
||||||
horizonTarget,
|
horizonTarget,
|
||||||
);
|
);
|
||||||
const vt = cartesian3.subtract(target, camera, horizonVt);
|
const vt = cartesian3.subtract(target, camera, horizonVt);
|
||||||
|
|||||||
+92
-12
@@ -78,6 +78,7 @@ const CORRIDOR_SOURCE = /* glsl */ `
|
|||||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||||
vec2 st = materialInput.st;
|
vec2 st = materialInput.st;
|
||||||
|
if (st.s > reveal) discard;
|
||||||
vec3 rgb = color.rgb;
|
vec3 rgb = color.rgb;
|
||||||
float alpha = color.a;
|
float alpha = color.a;
|
||||||
if (pulsePeriod > 0.0)
|
if (pulsePeriod > 0.0)
|
||||||
@@ -95,7 +96,11 @@ czm_material czm_getMaterial(czm_materialInput materialInput) {
|
|||||||
rgb *= 1.0 + dashEmission * on;
|
rgb *= 1.0 + dashEmission * on;
|
||||||
alpha *= max(on, dashGap);
|
alpha *= max(on, dashGap);
|
||||||
}
|
}
|
||||||
material.emission = rgb;
|
if (abs(st.t - 0.5) > coreFraction * 0.5) {
|
||||||
|
rgb = backingColor.rgb;
|
||||||
|
alpha = backingColor.a;
|
||||||
|
}
|
||||||
|
material.emission = coreFraction < 1.0 ? czm_gammaCorrect(rgb) : rgb;
|
||||||
material.alpha = alpha;
|
material.alpha = alpha;
|
||||||
return material;
|
return material;
|
||||||
}`;
|
}`;
|
||||||
@@ -106,6 +111,10 @@ export function corridorUniforms(item) {
|
|||||||
return {
|
return {
|
||||||
color: colorOf(item.fill),
|
color: colorOf(item.fill),
|
||||||
time: 0,
|
time: 0,
|
||||||
|
reveal: 1,
|
||||||
|
coreFraction:
|
||||||
|
item.widthPx / (item.animation.backing?.widthPx ?? item.widthPx),
|
||||||
|
backingColor: colorOf(item.animation.backing?.color ?? [0, 0, 0, 0]),
|
||||||
glowPower: glow?.power ?? 0,
|
glowPower: glow?.power ?? 0,
|
||||||
taperPower: glow?.taper ?? 1,
|
taperPower: glow?.taper ?? 1,
|
||||||
pulsePeriod: pulse ? pulse.periodMs / 1000 : 0,
|
pulsePeriod: pulse ? pulse.periodMs / 1000 : 0,
|
||||||
@@ -136,6 +145,28 @@ const corridorMaterial = (item) =>
|
|||||||
translucent: true,
|
translucent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The radial disc material, including its soft outside edge. */
|
||||||
|
export function radialMaterial(item) {
|
||||||
|
return new Cesium.Material({
|
||||||
|
fabric: {
|
||||||
|
type: 'GevGroundRadial',
|
||||||
|
uniforms: { color: colorOf(item.fill), ...item.radial },
|
||||||
|
source: `
|
||||||
|
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||||
|
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||||
|
float radius = length((materialInput.st - vec2(0.5)) * 2.0);
|
||||||
|
float inside = 1.0 - smoothstep(rimEnd, 1.0, radius);
|
||||||
|
float rim = smoothstep(rimStart, rimEnd, radius) * inside;
|
||||||
|
material.diffuse = color.rgb;
|
||||||
|
material.emission = color.rgb * rim * emission;
|
||||||
|
material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim);
|
||||||
|
return material;
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
translucent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Items of one look and colour share a primitive.
|
// Items of one look and colour share a primitive.
|
||||||
const lookKey = (item) =>
|
const lookKey = (item) =>
|
||||||
JSON.stringify([Array.from(item.fill), item.animation]);
|
JSON.stringify([Array.from(item.fill), item.animation]);
|
||||||
@@ -151,12 +182,15 @@ const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
|||||||
|
|
||||||
/** The fill geometry of one item of a set of `kind` (pure, for tests). */
|
/** The fill geometry of one item of a set of `kind` (pure, for tests). */
|
||||||
export function fillGeometry(kind, item) {
|
export function fillGeometry(kind, item) {
|
||||||
const vertexFormat = Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT;
|
const vertexFormat = item.radial
|
||||||
|
? Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat
|
||||||
|
: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT;
|
||||||
if (kind === 'disc')
|
if (kind === 'disc')
|
||||||
return new Cesium.EllipseGeometry({
|
return new Cesium.EllipseGeometry({
|
||||||
center: Cesium.Cartesian3.fromDegrees(item.center[0], item.center[1]),
|
center: Cesium.Cartesian3.fromDegrees(item.center[0], item.center[1]),
|
||||||
semiMajorAxis: item.radiusM,
|
semiMajorAxis: item.radiusM,
|
||||||
semiMinorAxis: item.radiusM,
|
semiMinorAxis: item.radiusM,
|
||||||
|
...(item.radial ? { granularity: Cesium.Math.toRadians(0.08) } : {}),
|
||||||
vertexFormat,
|
vertexFormat,
|
||||||
});
|
});
|
||||||
if (kind === 'cell') {
|
if (kind === 'cell') {
|
||||||
@@ -198,6 +232,7 @@ export function groundInstances(options, items) {
|
|||||||
const fills = [];
|
const fills = [];
|
||||||
const lines = [];
|
const lines = [];
|
||||||
const looks = new Map();
|
const looks = new Map();
|
||||||
|
const radial = [];
|
||||||
items.forEach((item, instance) => {
|
items.forEach((item, instance) => {
|
||||||
const id = { gevGroundSet: options.id, instance };
|
const id = { gevGroundSet: options.id, instance };
|
||||||
const attributes = (rgba) => ({
|
const attributes = (rgba) => ({
|
||||||
@@ -211,7 +246,7 @@ export function groundInstances(options, items) {
|
|||||||
new Cesium.GeometryInstance({
|
new Cesium.GeometryInstance({
|
||||||
geometry: new Cesium.GroundPolylineGeometry({
|
geometry: new Cesium.GroundPolylineGeometry({
|
||||||
positions: positionsOf(item.path),
|
positions: positionsOf(item.path),
|
||||||
width: item.widthPx,
|
width: item.animation.backing?.widthPx ?? item.widthPx,
|
||||||
}),
|
}),
|
||||||
id,
|
id,
|
||||||
}),
|
}),
|
||||||
@@ -237,7 +272,15 @@ export function groundInstances(options, items) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.fill)
|
if (item.radial)
|
||||||
|
radial.push({
|
||||||
|
item,
|
||||||
|
instance: new Cesium.GeometryInstance({
|
||||||
|
geometry: fillGeometry(options.kind, item),
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
else if (item.fill)
|
||||||
fills.push(
|
fills.push(
|
||||||
new Cesium.GeometryInstance({
|
new Cesium.GeometryInstance({
|
||||||
geometry: fillGeometry(options.kind, item),
|
geometry: fillGeometry(options.kind, item),
|
||||||
@@ -261,7 +304,7 @@ export function groundInstances(options, items) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { fills, lines, animated: [...looks.values()] };
|
return { fills, lines, animated: [...looks.values()], radial };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,12 +329,15 @@ export function createCesiumGround(
|
|||||||
const ordered = [];
|
const ordered = [];
|
||||||
const building = new Set();
|
const building = new Set();
|
||||||
let removeWatch = null;
|
let removeWatch = null;
|
||||||
|
// Classification reads the current globe depth, including after a camera move.
|
||||||
|
const surfaceReady = () =>
|
||||||
|
scene.globe?.show === false || scene.globe?.tilesLoaded !== false;
|
||||||
// Frames until every committed primitive has been built and drawn.
|
// Frames until every committed primitive has been built and drawn.
|
||||||
const watch = () => {
|
const watch = () => {
|
||||||
if (removeWatch || !scene.postRender) return;
|
if (removeWatch || !scene.postRender) return;
|
||||||
removeWatch = scene.postRender.addEventListener(() => {
|
removeWatch = scene.postRender.addEventListener(() => {
|
||||||
for (const primitive of building)
|
for (const primitive of building)
|
||||||
if (primitive.ready || primitive.isDestroyed?.())
|
if ((primitive.ready && surfaceReady()) || primitive.isDestroyed?.())
|
||||||
building.delete(primitive);
|
building.delete(primitive);
|
||||||
if (building.size) requestRender('ground-build');
|
if (building.size) requestRender('ground-build');
|
||||||
else {
|
else {
|
||||||
@@ -341,8 +387,10 @@ export function createCesiumGround(
|
|||||||
rootCollection().add(slot, at);
|
rootCollection().add(slot, at);
|
||||||
let drawn = [];
|
let drawn = [];
|
||||||
let looks = [];
|
let looks = [];
|
||||||
|
let fraction = 1;
|
||||||
const classificationType = classificationOf(options.drape);
|
const classificationType = classificationOf(options.drape);
|
||||||
const allowPicking = options.pickable;
|
const allowPicking = options.pickable;
|
||||||
|
const synchronous = options.dynamic || options.reveal;
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let waiting = false;
|
let waiting = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
@@ -355,7 +403,7 @@ export function createCesiumGround(
|
|||||||
const part = {
|
const part = {
|
||||||
commit(items) {
|
commit(items) {
|
||||||
pending = items;
|
pending = items;
|
||||||
if (options.dynamic && !heightsReady) {
|
if (synchronous && !heightsReady) {
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
waiting = true;
|
waiting = true;
|
||||||
heightsPromise ??=
|
heightsPromise ??=
|
||||||
@@ -376,7 +424,10 @@ export function createCesiumGround(
|
|||||||
}
|
}
|
||||||
forget();
|
forget();
|
||||||
slot.removeAll();
|
slot.removeAll();
|
||||||
const { fills, lines, animated } = groundInstances(options, items);
|
const { fills, lines, animated, radial } = groundInstances(
|
||||||
|
options,
|
||||||
|
items,
|
||||||
|
);
|
||||||
if (fills.length)
|
if (fills.length)
|
||||||
drawn.push(
|
drawn.push(
|
||||||
make.fills({
|
make.fills({
|
||||||
@@ -387,7 +438,29 @@ export function createCesiumGround(
|
|||||||
}),
|
}),
|
||||||
classificationType,
|
classificationType,
|
||||||
allowPicking,
|
allowPicking,
|
||||||
asynchronous: !options.dynamic,
|
asynchronous: !synchronous,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const { item, instance } of radial)
|
||||||
|
drawn.push(
|
||||||
|
make.fills({
|
||||||
|
geometryInstances: instance,
|
||||||
|
appearance: new Cesium.MaterialAppearance({
|
||||||
|
material: radialMaterial(item),
|
||||||
|
translucent: true,
|
||||||
|
closed: false,
|
||||||
|
faceForward: true,
|
||||||
|
flat: true,
|
||||||
|
renderState: {
|
||||||
|
depthTest: { enabled: true },
|
||||||
|
depthMask: false,
|
||||||
|
polygonOffset: { enabled: true, factor: -1, units: -4 },
|
||||||
|
blending: Cesium.BlendingState.ALPHA_BLEND,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
classificationType,
|
||||||
|
allowPicking,
|
||||||
|
asynchronous: !synchronous,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (lines.length)
|
if (lines.length)
|
||||||
@@ -397,12 +470,13 @@ export function createCesiumGround(
|
|||||||
appearance: new Cesium.PolylineColorAppearance(),
|
appearance: new Cesium.PolylineColorAppearance(),
|
||||||
classificationType,
|
classificationType,
|
||||||
allowPicking,
|
allowPicking,
|
||||||
asynchronous: !options.dynamic,
|
asynchronous: !synchronous,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const { item, instances } of animated) {
|
for (const { item, instances } of animated) {
|
||||||
const material = corridorMaterial(item);
|
const material = corridorMaterial(item);
|
||||||
material.uniforms.time = groundSeconds();
|
material.uniforms.time = groundSeconds();
|
||||||
|
material.uniforms.reveal = fraction;
|
||||||
looks.push(material);
|
looks.push(material);
|
||||||
materials.add(material);
|
materials.add(material);
|
||||||
drawn.push(
|
drawn.push(
|
||||||
@@ -411,7 +485,7 @@ export function createCesiumGround(
|
|||||||
appearance: new Cesium.PolylineMaterialAppearance({ material }),
|
appearance: new Cesium.PolylineMaterialAppearance({ material }),
|
||||||
classificationType,
|
classificationType,
|
||||||
allowPicking,
|
allowPicking,
|
||||||
asynchronous: !options.dynamic,
|
asynchronous: !synchronous,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -424,14 +498,20 @@ export function createCesiumGround(
|
|||||||
animate();
|
animate();
|
||||||
requestRender('ground');
|
requestRender('ground');
|
||||||
},
|
},
|
||||||
|
reveal(value) {
|
||||||
|
fraction = value;
|
||||||
|
for (const material of looks) material.uniforms.reveal = value;
|
||||||
|
requestRender('ground-reveal');
|
||||||
|
},
|
||||||
show(visible) {
|
show(visible) {
|
||||||
slot.show = visible;
|
slot.show = visible;
|
||||||
animate();
|
animate();
|
||||||
requestRender('ground');
|
requestRender('ground');
|
||||||
},
|
},
|
||||||
ready: () =>
|
ready: () =>
|
||||||
(!options.dynamic || heightsReady) &&
|
(!synchronous || heightsReady) &&
|
||||||
!waiting &&
|
!waiting &&
|
||||||
|
(drawn.length === 0 || surfaceReady()) &&
|
||||||
drawn.every((primitive) => primitive.ready === true),
|
drawn.every((primitive) => primitive.ready === true),
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ for (const name of [
|
|||||||
const items = (kind, list) =>
|
const items = (kind, list) =>
|
||||||
list.map((item, index) => normalizeGroundItem(kind, item, index));
|
list.map((item, index) => normalizeGroundItem(kind, item, index));
|
||||||
|
|
||||||
test('dynamic reveals wait for height bounds, draw the latest path and cancel on release', async (t) => {
|
test('reveal and dynamic sets wait for height bounds, draw the latest path and cancel on release', async (t) => {
|
||||||
const viewer = createFakeViewer();
|
const viewer = createFakeViewer();
|
||||||
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
let resolve;
|
let resolve;
|
||||||
@@ -44,7 +44,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on
|
|||||||
const reveal = part.ground.createSet({
|
const reveal = part.ground.createSet({
|
||||||
id: 'reveal',
|
id: 'reveal',
|
||||||
kind: 'corridor',
|
kind: 'corridor',
|
||||||
dynamic: true,
|
reveal: true,
|
||||||
});
|
});
|
||||||
const cancelled = part.ground.createSet({
|
const cancelled = part.ground.createSet({
|
||||||
id: 'cancelled',
|
id: 'cancelled',
|
||||||
@@ -69,6 +69,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on
|
|||||||
assert.equal(reveal.ready(), false);
|
assert.equal(reveal.ready(), false);
|
||||||
reveal.replace(path(2));
|
reveal.replace(path(2));
|
||||||
reveal.commit();
|
reveal.commit();
|
||||||
|
reveal.setReveal(0.25);
|
||||||
cancelled.destroy();
|
cancelled.destroy();
|
||||||
resolve();
|
resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -80,6 +81,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on
|
|||||||
);
|
);
|
||||||
const primitive = root.get(0).get(0);
|
const primitive = root.get(0).get(0);
|
||||||
assert.equal(primitive.asynchronous, false);
|
assert.equal(primitive.asynchronous, false);
|
||||||
|
assert.equal(primitive.appearance.material.uniforms.reveal, 0.25);
|
||||||
const end = primitive.geometryInstances[0].geometry._positions.at(-1);
|
const end = primitive.geometryInstances[0].geometry._positions.at(-1);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Math.abs(
|
Math.abs(
|
||||||
@@ -89,6 +91,89 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const kind of ['disc', 'corridor']) {
|
||||||
|
test(`${kind} material readiness includes the globe depth after a camera move`, () => {
|
||||||
|
const viewer = createFakeViewer();
|
||||||
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
|
viewer.scene.globe.tilesLoaded = false;
|
||||||
|
const made = [];
|
||||||
|
const make = (options) => {
|
||||||
|
const primitive = {
|
||||||
|
...options,
|
||||||
|
ready: true,
|
||||||
|
destroy() {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
made.push(primitive);
|
||||||
|
return primitive;
|
||||||
|
};
|
||||||
|
const renders = [];
|
||||||
|
const port = createCesiumGround(viewer, {
|
||||||
|
requestRender: (reason) => renders.push(reason),
|
||||||
|
factories: { fills: make, lines: make },
|
||||||
|
});
|
||||||
|
const set = port.ground.createSet({
|
||||||
|
id: kind,
|
||||||
|
kind,
|
||||||
|
...(kind === 'corridor' ? { reveal: true } : {}),
|
||||||
|
});
|
||||||
|
assert.equal(set.ready(), true, 'an empty set needs no surface');
|
||||||
|
set.replace([
|
||||||
|
kind === 'disc'
|
||||||
|
? {
|
||||||
|
center: [20, 10],
|
||||||
|
radiusM: 40000,
|
||||||
|
fill: [0, 1, 1, 1],
|
||||||
|
radial: {
|
||||||
|
fillAlpha: 0.105,
|
||||||
|
rimAlpha: 0.72,
|
||||||
|
rimStart: 0.952,
|
||||||
|
rimEnd: 0.985,
|
||||||
|
emission: 0.35,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
positions: [
|
||||||
|
[19.7, 10],
|
||||||
|
[20.3, 10],
|
||||||
|
],
|
||||||
|
color: [1, 0, 1, 1],
|
||||||
|
widthPx: 12,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
set.commit();
|
||||||
|
if (kind === 'corridor') set.setReveal(0.25);
|
||||||
|
const material = made[0].appearance.material;
|
||||||
|
assert.equal(set.ready(), false, 'built geometry needs a loaded surface');
|
||||||
|
viewer.scene.postRender.raiseEvent();
|
||||||
|
assert.ok(renders.includes('ground-build'));
|
||||||
|
viewer.scene.globe.tilesLoaded = true;
|
||||||
|
viewer.scene.postRender.raiseEvent();
|
||||||
|
assert.equal(set.ready(), true);
|
||||||
|
assert.ok(renders.includes('ground-ready'));
|
||||||
|
renders.length = 0;
|
||||||
|
viewer.scene.postRender.raiseEvent();
|
||||||
|
assert.deepEqual(
|
||||||
|
renders,
|
||||||
|
[],
|
||||||
|
'a ready static material stops requesting frames',
|
||||||
|
);
|
||||||
|
viewer.scene.globe.tilesLoaded = false;
|
||||||
|
assert.equal(set.ready(), false, 'camera changes can load another surface');
|
||||||
|
viewer.scene.globe.tilesLoaded = true;
|
||||||
|
assert.equal(set.ready(), true);
|
||||||
|
assert.equal(made.length, 1, 'surface loading never rebuilds geometry');
|
||||||
|
assert.equal(made[0].appearance.material, material);
|
||||||
|
if (kind === 'corridor') assert.equal(material.uniforms.reveal, 0.25);
|
||||||
|
else {
|
||||||
|
assert.equal(material.uniforms.rimAlpha, 0.72);
|
||||||
|
assert.equal(material.uniforms.fillAlpha, 0.105);
|
||||||
|
}
|
||||||
|
port.destroy();
|
||||||
|
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('fills batch into GroundPrimitive instances, outlines into ground polylines', () => {
|
test('fills batch into GroundPrimitive instances, outlines into ground polylines', () => {
|
||||||
const options = { id: 'zones', kind: 'polygon' };
|
const options = { id: 'zones', kind: 'polygon' };
|
||||||
const { fills, lines } = groundInstances(
|
const { fills, lines } = groundInstances(
|
||||||
@@ -316,3 +401,49 @@ test('a glow, a pulse or a dash draws through its own material, timed every fram
|
|||||||
assert.ok(!renders.includes('ground-animate'), 'none once destroyed');
|
assert.ok(!renders.includes('ground-animate'), 'none once destroyed');
|
||||||
part.destroy();
|
part.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('corridor reveal mutates the live material and retains it through a geometry commit', () => {
|
||||||
|
const viewer = createFakeViewer();
|
||||||
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
|
const built = [];
|
||||||
|
const make = (options) => {
|
||||||
|
const primitive = {
|
||||||
|
...options,
|
||||||
|
ready: true,
|
||||||
|
destroy() {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
built.push(primitive);
|
||||||
|
return primitive;
|
||||||
|
};
|
||||||
|
const port = createCesiumGround(viewer, {
|
||||||
|
factories: { lines: make, fills: make },
|
||||||
|
});
|
||||||
|
const set = port.ground.createSet({
|
||||||
|
id: 'head',
|
||||||
|
kind: 'corridor',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
positions: [
|
||||||
|
[20, 10],
|
||||||
|
[20.001, 10],
|
||||||
|
],
|
||||||
|
widthPx: 5,
|
||||||
|
color: [0, 1, 0, 0.8],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
set.replace(items);
|
||||||
|
set.commit();
|
||||||
|
const geometry = built[0].geometryInstances;
|
||||||
|
set.setReveal(0.25);
|
||||||
|
set.setReveal(0.5);
|
||||||
|
assert.equal(built.length, 1);
|
||||||
|
assert.equal(built[0].geometryInstances, geometry);
|
||||||
|
assert.equal(built[0].appearance.material.uniforms.reveal, 0.5);
|
||||||
|
set.replace(items);
|
||||||
|
set.commit();
|
||||||
|
assert.equal(built[1].appearance.material.uniforms.reveal, 0.5);
|
||||||
|
port.destroy();
|
||||||
|
});
|
||||||
@@ -68,15 +68,20 @@ export function createCesiumInput(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const delivery = createPickDelivery({
|
const delivery = createPickDelivery({
|
||||||
pick: (position) => picker?.pick(position),
|
pick: (position, type, window) =>
|
||||||
drill: (position, limit) => picker?.drillPick(position, limit) ?? [],
|
picker?.pick(position, window?.width, window?.height),
|
||||||
|
drill: (position, limit, window) =>
|
||||||
|
picker?.drillPick(position, limit, window?.width, window?.height) ?? [],
|
||||||
schedule: (run) => scheduleFrame(() => !destroyed && run()),
|
schedule: (run) => scheduleFrame(() => !destroyed && run()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
acquirePointer: () => pointer.acquire(),
|
acquirePointer: () => pointer.acquire(),
|
||||||
on(type, listener, options = {}) {
|
on(type, listener, options = {}) {
|
||||||
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
const { modifier, pick, drill, pickWindow } = normalizeInputOptions(
|
||||||
|
type,
|
||||||
|
options,
|
||||||
|
);
|
||||||
if (destroyed) return () => {};
|
if (destroyed) return () => {};
|
||||||
handler ??= createEventHandler(scene.canvas);
|
handler ??= createEventHandler(scene.canvas);
|
||||||
const key = `${type}:${modifier}`;
|
const key = `${type}:${modifier}`;
|
||||||
@@ -95,7 +100,13 @@ export function createCesiumInput(
|
|||||||
KEYS[modifier],
|
KEYS[modifier],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const entry = { listener, pick, drill, pointer: options.pointer };
|
const entry = {
|
||||||
|
listener,
|
||||||
|
pick,
|
||||||
|
drill,
|
||||||
|
pickWindow,
|
||||||
|
pointer: options.pointer,
|
||||||
|
};
|
||||||
listeners.add(entry);
|
listeners.add(entry);
|
||||||
return () => {
|
return () => {
|
||||||
if (!listeners.delete(entry) || listeners.size) return;
|
if (!listeners.delete(entry) || listeners.size) return;
|
||||||
|
|||||||
@@ -264,3 +264,40 @@ test('exclusive pointer suppresses ambient and stock clicks, restores exact acti
|
|||||||
assert.equal(actions.get(Type.LEFT_DOUBLE_CLICK), double);
|
assert.equal(actions.get(Type.LEFT_DOUBLE_CLICK), double);
|
||||||
assert.equal(input.acquirePointer(), null);
|
assert.equal(input.acquirePointer(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('input forwards each rectangular pick window and drill limit synchronously on down', () => {
|
||||||
|
const handlers = createFakeEventHandlers();
|
||||||
|
const calls = [],
|
||||||
|
seen = [];
|
||||||
|
const { input, destroy } = createCesiumInput(createFakeViewer(), {
|
||||||
|
createEventHandler: handlers.create,
|
||||||
|
picker: {
|
||||||
|
pick: (...args) => {
|
||||||
|
calls.push(['pick', ...args]);
|
||||||
|
return { id: 'handle' };
|
||||||
|
},
|
||||||
|
drillPick: (...args) => {
|
||||||
|
calls.push(['drill', ...args]);
|
||||||
|
return [{ id: 'handle' }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pickWindow = { width: 11, height: 7 };
|
||||||
|
input.on('down', (event) => seen.push(event), {
|
||||||
|
pick: true,
|
||||||
|
drill: 8,
|
||||||
|
pickWindow,
|
||||||
|
});
|
||||||
|
pickWindow.width = 99;
|
||||||
|
handlers.handlers[0].fire(Type.LEFT_DOWN, { position: { x: 1, y: 2 } });
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.slice(2)),
|
||||||
|
[
|
||||||
|
[11, 7],
|
||||||
|
[8, 11, 7],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(seen[0].picked.id, 'handle');
|
||||||
|
destroy();
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { horizonEllipsoidRadii } from '../contract.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Cesium.Viewer} viewer
|
* @param {Cesium.Viewer} viewer
|
||||||
@@ -25,6 +26,7 @@ export function createCesiumProjector(
|
|||||||
let occluderY = Number.NaN;
|
let occluderY = Number.NaN;
|
||||||
let occluderZ = Number.NaN;
|
let occluderZ = Number.NaN;
|
||||||
|
|
||||||
|
let custom = null;
|
||||||
const projector = {
|
const projector = {
|
||||||
worldToWindow(position, result) {
|
worldToWindow(position, result) {
|
||||||
// Cesium returns undefined in front of the near plane or behind the camera.
|
// Cesium returns undefined in front of the near plane or behind the camera.
|
||||||
@@ -47,8 +49,25 @@ export function createCesiumProjector(
|
|||||||
result[index] = scratchMatrix[index];
|
result[index] = scratchMatrix[index];
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
horizonVisible(position) {
|
horizonVisible(position, options) {
|
||||||
const { positionWC } = camera;
|
const { positionWC } = camera;
|
||||||
|
const radii = horizonEllipsoidRadii(options);
|
||||||
|
if (radii) {
|
||||||
|
if (
|
||||||
|
!custom ||
|
||||||
|
custom.radii.x !== radii.x ||
|
||||||
|
custom.radii.y !== radii.y ||
|
||||||
|
custom.radii.z !== radii.z
|
||||||
|
)
|
||||||
|
custom = {
|
||||||
|
radii: { ...radii },
|
||||||
|
occluder: new Cesium.EllipsoidalOccluder(
|
||||||
|
new Cesium.Ellipsoid(radii.x, radii.y, radii.z),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
custom.occluder.cameraPosition = positionWC;
|
||||||
|
return custom.occluder.isPointVisible(position);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
positionWC.x !== occluderX ||
|
positionWC.x !== occluderX ||
|
||||||
positionWC.y !== occluderY ||
|
positionWC.y !== occluderY ||
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { createFakeEngine } from '../testing/fakeEngine.js';
|
||||||
|
import { horizonVisible } from '../cameraMath.js';
|
||||||
import { createCesiumProjector } from './projector.js';
|
import { createCesiumProjector } from './projector.js';
|
||||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||||
|
|
||||||
@@ -86,3 +88,63 @@ test('horizonVisible uses the WGS84 occluder from the camera position', () => {
|
|||||||
'follows the camera',
|
'follows the camera',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom horizon radii match the ellipsoidal occluder at ground and orbit heights', () => {
|
||||||
|
const { viewer, projector } = setup();
|
||||||
|
const fake = createFakeEngine();
|
||||||
|
const radii = geo.ellipsoid.WGS84.radii;
|
||||||
|
const ellipsoid = geo.ellipsoid.create(
|
||||||
|
radii.x - 1000,
|
||||||
|
radii.y - 1000,
|
||||||
|
radii.z - 1000,
|
||||||
|
);
|
||||||
|
const options = { ellipsoid };
|
||||||
|
const reference = new Cesium.EllipsoidalOccluder(
|
||||||
|
new Cesium.Ellipsoid(
|
||||||
|
ellipsoid.radii.x,
|
||||||
|
ellipsoid.radii.y,
|
||||||
|
ellipsoid.radii.z,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const at = geo.cartesian3.fromDegrees;
|
||||||
|
const cases = [
|
||||||
|
[at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22), true],
|
||||||
|
[at(79.8612, 6.9271, -21), at(79.8612, 6.92782, -95), true],
|
||||||
|
[at(79.8612, 6.9271, -21), at(79.86192, 6.9271, -95), true],
|
||||||
|
[at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130), true],
|
||||||
|
[at(0, 0, 20000000), at(10, 10, 0), true],
|
||||||
|
[at(0, 0, 20000000), at(180, 0, 0), false],
|
||||||
|
];
|
||||||
|
for (const height of [-1100, -20, 10, 10000, 20000000])
|
||||||
|
for (const latitude of [-80, 0, 65])
|
||||||
|
for (const separation of [0, 0.01, 0.1, 1, 5, 30, 90, 180])
|
||||||
|
cases.push([at(0, latitude, height), at(separation, latitude, -22)]);
|
||||||
|
for (const [eye, point, known] of cases) {
|
||||||
|
viewer.camera.positionWC = new Cesium.Cartesian3(eye.x, eye.y, eye.z);
|
||||||
|
fake.camera.setView({ destination: eye });
|
||||||
|
reference.cameraPosition = eye;
|
||||||
|
const expected = reference.isPointVisible(point);
|
||||||
|
if (known !== undefined) assert.equal(expected, known);
|
||||||
|
assert.equal(projector.horizonVisible(point, options), expected);
|
||||||
|
assert.equal(fake.projector.horizonVisible(point, options), expected);
|
||||||
|
assert.equal(horizonVisible(eye, point, options), expected);
|
||||||
|
const normal = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, eye);
|
||||||
|
assert.equal(projector.horizonVisible(point), normal.isPointVisible(point));
|
||||||
|
}
|
||||||
|
for (const bad of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ radii: { x: 0, y: 1, z: 1 } },
|
||||||
|
{ radii: { x: 1, y: NaN, z: 1 } },
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => projector.horizonVisible(at(0, 0), { ellipsoid: bad }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => fake.projector.horizonVisible(at(0, 0), { ellipsoid: bad }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fake.destroy();
|
||||||
|
});
|
||||||
+74
-8
@@ -182,9 +182,12 @@
|
|||||||
* @typedef {object} InputOptions
|
* @typedef {object} InputOptions
|
||||||
* @property {{ release: () => void }} [pointer] The live exclusive pointer claim.
|
* @property {{ release: () => void }} [pointer] The live exclusive pointer claim.
|
||||||
* @property {InputModifier} [modifier]
|
* @property {InputModifier} [modifier]
|
||||||
* @property {boolean} [pick] click, doubleClick and move only. A move
|
* @property {boolean} [pick] click, doubleClick, down and move only. A move
|
||||||
* listener with pick gets at most one payload per frame (the latest).
|
* listener with pick gets at most one payload per frame (the latest).
|
||||||
* @property {number} [drill] A positive integer (click and doubleClick).
|
* @property {number} [drill] A positive integer; moves coalesce with their picks.
|
||||||
|
* @property {{ width: number, height: number }} [pickWindow] Positive integer
|
||||||
|
* pick dimensions in pixels, passed to the picker. Omitted: engine default.
|
||||||
|
* Down picks are synchronous; click delivery keeps the engine's gesture pick.
|
||||||
*
|
*
|
||||||
* @typedef {object} GlobeProjector
|
* @typedef {object} GlobeProjector
|
||||||
* @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow
|
* @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow
|
||||||
@@ -193,9 +196,10 @@
|
|||||||
* World → clip matrix consistent with worldToWindow: clip = M·[x, y, z, 1],
|
* World → clip matrix consistent with worldToWindow: clip = M·[x, y, z, 1],
|
||||||
* in front when clip.w > 0, window = ((clip.x/clip.w + 1)/2 · width,
|
* in front when clip.w > 0, window = ((clip.x/clip.w + 1)/2 · width,
|
||||||
* (1 − clip.y/clip.w)/2 · height).
|
* (1 − clip.y/clip.w)/2 · height).
|
||||||
* @property {(position: Cartesian3) => boolean} horizonVisible
|
* @property {(position: Cartesian3, options?: { ellipsoid?: { radii: Cartesian3 } }) => boolean} horizonVisible
|
||||||
* False when the WGS84 ellipsoid hides the point from the camera
|
* False when the WGS84 ellipsoid hides the point from the camera
|
||||||
* (Cesium's EllipsoidalOccluder.isPointVisible).
|
* (Cesium's EllipsoidalOccluder.isPointVisible). An optional ellipsoid
|
||||||
|
* supplies positive finite radii in metres; omitted means WGS84.
|
||||||
*
|
*
|
||||||
* @typedef {object} GlobeSurface
|
* @typedef {object} GlobeSurface
|
||||||
* @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight
|
* @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight
|
||||||
@@ -513,6 +517,7 @@
|
|||||||
* following their relief, under every mover, line and model (Cesium:
|
* following their relief, under every mover, line and model (Cesium:
|
||||||
* GroundPrimitive and GroundPolylinePrimitive; src/globe/ground.js checks
|
* GroundPrimitive and GroundPolylinePrimitive; src/globe/ground.js checks
|
||||||
* and defaults these). Nothing is extruded.
|
* and defaults these). Nothing is extruded.
|
||||||
|
* @property {boolean} [reveal] Unpickable pixel corridors support a mutable reveal fraction.
|
||||||
* @property {string} id Unique among the engine's live ground sets.
|
* @property {string} id Unique among the engine's live ground sets.
|
||||||
* @property {'polygon'|'disc'|'cell'|'corridor'} kind One kind of item per
|
* @property {'polygon'|'disc'|'cell'|'corridor'} kind One kind of item per
|
||||||
* set (GroundItem).
|
* set (GroundItem).
|
||||||
@@ -528,6 +533,14 @@
|
|||||||
* (Cesium's classificationType). Navara drapes on whatever surface it
|
* (Cesium's classificationType). Navara drapes on whatever surface it
|
||||||
* draws.
|
* draws.
|
||||||
*
|
*
|
||||||
|
* @typedef {object} GroundRadial
|
||||||
|
* @property {number} fillAlpha Interior alpha multiplier, 0..1.
|
||||||
|
* @property {number} rimAlpha Rim alpha multiplier, 0..1.
|
||||||
|
* @property {number} rimStart Start of the smooth rim, fraction of radius.
|
||||||
|
* @property {number} rimEnd End of the smooth rim; 0 <= rimStart < rimEnd < 1.
|
||||||
|
* The outer edge fades smoothly from rimEnd to 1.
|
||||||
|
* @property {number} emission Nonnegative rim colour gain.
|
||||||
|
*
|
||||||
* @typedef {object} GroundItem
|
* @typedef {object} GroundItem
|
||||||
* One fill, by its set's kind. Positions are [longitude, latitude] in
|
* One fill, by its set's kind. Positions are [longitude, latitude] in
|
||||||
* degrees (GeoJSON order); edges follow great circles; colours are rgba in
|
* degrees (GeoJSON order); edges follow great circles; colours are rgba in
|
||||||
@@ -551,6 +564,9 @@
|
|||||||
* alpha times `gapAlpha`; a lit dash brightened by `emission` times its
|
* alpha times `gapAlpha`; a lit dash brightened by `emission` times its
|
||||||
* colour). A shown set with a pulse or a dash keeps the engine drawing
|
* colour). A shown set with a pulse or a dash keeps the engine drawing
|
||||||
* a frame after every frame, as Cesium's continuous render did.
|
* a frame after every frame, as Cesium's continuous render did.
|
||||||
|
* A pixel corridor may take `backing: { widthPx, color }`, wider than its
|
||||||
|
* core. The edge uses the backing colour and alpha; the core is blended
|
||||||
|
* once, without a second layer underneath it.
|
||||||
* A corridor may instead take `dashPattern` ({ lengthPx, pattern = 255 }):
|
* A corridor may instead take `dashPattern` ({ lengthPx, pattern = 255 }):
|
||||||
* a static 16-bit mask repeated every lengthPx CSS pixels, with clear
|
* a static 16-bit mask repeated every lengthPx CSS pixels, with clear
|
||||||
* gaps, as PolylineDash. It cannot combine with the other looks.
|
* gaps, as PolylineDash. It cannot combine with the other looks.
|
||||||
@@ -559,8 +575,12 @@
|
|||||||
* omitted: none). Every kind takes `pickId`: what a pick of the item
|
* omitted: none). Every kind takes `pickId`: what a pick of the item
|
||||||
* returns (an item without one is not picked).
|
* returns (an item without one is not picked).
|
||||||
*
|
*
|
||||||
|
* @property {GroundRadial} [radial] Disc with fill: radial alpha and emission.
|
||||||
|
*
|
||||||
* @typedef {object} GroundSet
|
* @typedef {object} GroundSet
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
|
* @property {(fraction: number) => void} setReveal Reveal each corridor from 0..1
|
||||||
|
* of its length without rebuilding geometry; requires reveal: true.
|
||||||
* @property {(items: GroundItem[]) => void} replace Checks and stores every
|
* @property {(items: GroundItem[]) => void} replace Checks and stores every
|
||||||
* item at once (the previous items go); drawn after commit().
|
* item at once (the previous items go); drawn after commit().
|
||||||
* @property {() => void} commit Draws the stored items in place of the last
|
* @property {() => void} commit Draws the stored items in place of the last
|
||||||
@@ -768,14 +788,19 @@ export const INPUT_TYPES = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
|
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
|
||||||
/** Input types whose listeners may ask for picks. */
|
/** Input types whose listeners may ask for picks. */
|
||||||
const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']);
|
const PICK_INPUT_TYPES = Object.freeze([
|
||||||
|
'click',
|
||||||
|
'doubleClick',
|
||||||
|
'move',
|
||||||
|
'down',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* input.on options checked and defaulted: { modifier, pick, drill } (drill
|
* input.on options checked and defaulted: { modifier, pick, drill } (drill
|
||||||
* 0 when not asked), or a TypeError.
|
* 0 when not asked), or a TypeError.
|
||||||
*/
|
*/
|
||||||
export function normalizeInputOptions(type, options = {}) {
|
export function normalizeInputOptions(type, options = {}) {
|
||||||
const { modifier, pick = false, drill = 0 } = options ?? {};
|
const { modifier, pick = false, drill = 0, pickWindow } = options ?? {};
|
||||||
if (!INPUT_TYPES.includes(type))
|
if (!INPUT_TYPES.includes(type))
|
||||||
throw new TypeError(`Unknown input type: ${type}`);
|
throw new TypeError(`Unknown input type: ${type}`);
|
||||||
if (modifier !== undefined && !MODIFIERS.includes(modifier))
|
if (modifier !== undefined && !MODIFIERS.includes(modifier))
|
||||||
@@ -786,8 +811,49 @@ export function normalizeInputOptions(type, options = {}) {
|
|||||||
throw new TypeError(`Input drill must be a whole number: ${drill}`);
|
throw new TypeError(`Input drill must be a whole number: ${drill}`);
|
||||||
if ((pick || drill) && !PICK_INPUT_TYPES.includes(type))
|
if ((pick || drill) && !PICK_INPUT_TYPES.includes(type))
|
||||||
throw new TypeError(`Input ${type} cannot pick`);
|
throw new TypeError(`Input ${type} cannot pick`);
|
||||||
if (drill && type === 'move') throw new TypeError('A move cannot drill');
|
if (
|
||||||
return { modifier, pick: pick || drill > 0, drill };
|
pickWindow !== undefined &&
|
||||||
|
(!pickWindow ||
|
||||||
|
!Number.isInteger(pickWindow.width) ||
|
||||||
|
pickWindow.width <= 0 ||
|
||||||
|
!Number.isInteger(pickWindow.height) ||
|
||||||
|
pickWindow.height <= 0)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
'Input pickWindow needs positive integer width and height',
|
||||||
|
);
|
||||||
|
if (pickWindow && !(pick || drill))
|
||||||
|
throw new TypeError('Input pickWindow needs pick or drill');
|
||||||
|
return {
|
||||||
|
modifier,
|
||||||
|
pick: pick || drill > 0,
|
||||||
|
drill,
|
||||||
|
...(pickWindow
|
||||||
|
? {
|
||||||
|
pickWindow: Object.freeze({
|
||||||
|
width: pickWindow.width,
|
||||||
|
height: pickWindow.height,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validated optional horizon ellipsoid radii; undefined selects WGS84. */
|
||||||
|
export function horizonEllipsoidRadii(options) {
|
||||||
|
if (options?.ellipsoid === undefined) return undefined;
|
||||||
|
const radii = options.ellipsoid?.radii;
|
||||||
|
if (
|
||||||
|
!radii ||
|
||||||
|
!Number.isFinite(radii.x) ||
|
||||||
|
radii.x <= 0 ||
|
||||||
|
!Number.isFinite(radii.y) ||
|
||||||
|
radii.y <= 0 ||
|
||||||
|
!Number.isFinite(radii.z) ||
|
||||||
|
radii.z <= 0
|
||||||
|
)
|
||||||
|
throw new TypeError('Horizon ellipsoid needs positive finite radii');
|
||||||
|
return radii;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINE_EVENT = Symbol('gev.globe.engineEvent');
|
const ENGINE_EVENT = Symbol('gev.globe.engineEvent');
|
||||||
|
|||||||
+84
-9
@@ -67,6 +67,46 @@ export function distanceScale(scaleByDistance, distance) {
|
|||||||
return nearScale + (farScale - nearScale) * t;
|
return nearScale + (farScale - nearScale) * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distance from a projected segment to a rectangular pick window.
|
||||||
|
function distanceToWindow(at, a, b, halfWidth, halfHeight) {
|
||||||
|
if (!halfWidth && !halfHeight) return distanceToSegment(at, a, b);
|
||||||
|
let enter = 0,
|
||||||
|
leave = 1;
|
||||||
|
for (const [axis, half] of [
|
||||||
|
['x', halfWidth],
|
||||||
|
['y', halfHeight],
|
||||||
|
]) {
|
||||||
|
const start = a[axis] - at[axis],
|
||||||
|
delta = b[axis] - a[axis];
|
||||||
|
if (delta === 0) {
|
||||||
|
if (Math.abs(start) > half) {
|
||||||
|
enter = 1;
|
||||||
|
leave = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const first = (-half - start) / delta,
|
||||||
|
last = (half - start) / delta;
|
||||||
|
enter = Math.max(enter, Math.min(first, last));
|
||||||
|
leave = Math.min(leave, Math.max(first, last));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enter <= leave) return 0;
|
||||||
|
const distance = (point) =>
|
||||||
|
Math.hypot(
|
||||||
|
Math.max(0, Math.abs(point.x - at.x) - halfWidth),
|
||||||
|
Math.max(0, Math.abs(point.y - at.y) - halfHeight),
|
||||||
|
);
|
||||||
|
let nearest = Math.min(distance(a), distance(b));
|
||||||
|
for (const dx of [-halfWidth, halfWidth])
|
||||||
|
for (const dy of [-halfHeight, halfHeight])
|
||||||
|
nearest = Math.min(
|
||||||
|
nearest,
|
||||||
|
distanceToSegment({ x: at.x + dx, y: at.y + dy }, a, b),
|
||||||
|
);
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
function moverRadius(options, scale, distance, modelRadiusPx = null) {
|
function moverRadius(options, scale, distance, modelRadiusPx = null) {
|
||||||
if (options.kind === 'model' && modelRadiusPx != null)
|
if (options.kind === 'model' && modelRadiusPx != null)
|
||||||
return Math.max(modelRadiusPx, PICK_RADIUS_PX) + PICK_RADIUS_PX;
|
return Math.max(modelRadiusPx, PICK_RADIUS_PX) + PICK_RADIUS_PX;
|
||||||
@@ -117,7 +157,7 @@ export function createCpuPicker({
|
|||||||
const depthOf = (position) =>
|
const depthOf = (position) =>
|
||||||
geo.cartesian3.distance(position, camera.livePosition);
|
geo.cartesian3.distance(position, camera.livePosition);
|
||||||
|
|
||||||
function moverHits(at, hits) {
|
function moverHits(at, hits, halfWidth, halfHeight) {
|
||||||
for (const { options, store, pickRadiusPx } of movers()) {
|
for (const { options, store, pickRadiusPx } of movers()) {
|
||||||
if (!options.pickable) continue;
|
if (!options.pickable) continue;
|
||||||
const onTop = options.depthTest === 'always' ? 1 : 0;
|
const onTop = options.depthTest === 'always' ? 1 : 0;
|
||||||
@@ -133,7 +173,10 @@ export function createCpuPicker({
|
|||||||
);
|
);
|
||||||
const screen = projector.worldToWindow(scratch, scratchScreen);
|
const screen = projector.worldToWindow(scratch, scratchScreen);
|
||||||
if (!screen || !projector.horizonVisible(scratch)) continue;
|
if (!screen || !projector.horizonVisible(scratch)) continue;
|
||||||
const distance = Math.hypot(screen.x - at.x, screen.y - at.y);
|
const distance = Math.hypot(
|
||||||
|
Math.max(0, Math.abs(screen.x - at.x) - halfWidth),
|
||||||
|
Math.max(0, Math.abs(screen.y - at.y) - halfHeight),
|
||||||
|
);
|
||||||
const depth = depthOf(scratch);
|
const depth = depthOf(scratch);
|
||||||
if (
|
if (
|
||||||
distance >
|
distance >
|
||||||
@@ -150,7 +193,7 @@ export function createCpuPicker({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineHits(at, hits) {
|
function lineHits(at, hits, halfWidth, halfHeight) {
|
||||||
for (const { options, flat, modelMatrix } of lines()) {
|
for (const { options, flat, modelMatrix } of lines()) {
|
||||||
if (options.pickId === undefined) continue;
|
if (options.pickId === undefined) continue;
|
||||||
const limit = Math.max(
|
const limit = Math.max(
|
||||||
@@ -166,7 +209,8 @@ export function createCpuPicker({
|
|||||||
const sa = projector.worldToWindow(a, scratchScreen);
|
const sa = projector.worldToWindow(a, scratchScreen);
|
||||||
const sb = sa && projector.worldToWindow(b, scratchScreenB);
|
const sb = sa && projector.worldToWindow(b, scratchScreenB);
|
||||||
if (!sa || !sb) continue;
|
if (!sa || !sb) continue;
|
||||||
if (distanceToSegment(at, sa, sb) > limit) continue;
|
if (distanceToWindow(at, sa, sb, halfWidth, halfHeight) > limit)
|
||||||
|
continue;
|
||||||
const t = segmentT(at, sa, sb);
|
const t = segmentT(at, sa, sb);
|
||||||
const depth = depthOf(
|
const depth = depthOf(
|
||||||
geo.cartesian3.lerp(a, b, t, geo.cartesian3.create()),
|
geo.cartesian3.lerp(a, b, t, geo.cartesian3.create()),
|
||||||
@@ -187,16 +231,47 @@ export function createCpuPicker({
|
|||||||
const drill = (
|
const drill = (
|
||||||
at,
|
at,
|
||||||
limit = Infinity,
|
limit = Infinity,
|
||||||
{ includeMovers = true, includeLines = true, includeMeshes = true } = {},
|
{
|
||||||
|
includeMovers = true,
|
||||||
|
includeLines = true,
|
||||||
|
includeMeshes = true,
|
||||||
|
width = 1,
|
||||||
|
height = 1,
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const hits = [];
|
const hits = [];
|
||||||
if (includeMovers) moverHits(at, hits);
|
const halfWidth = (width - 1) / 2,
|
||||||
if (includeLines) lineHits(at, hits);
|
halfHeight = (height - 1) / 2;
|
||||||
if (includeMeshes) hits.push(...meshes(at));
|
if (includeMovers) moverHits(at, hits, halfWidth, halfHeight);
|
||||||
|
if (includeLines) lineHits(at, hits, halfWidth, halfHeight);
|
||||||
|
// Mesh and ground pickers answer points, so sample each pixel of the
|
||||||
|
// requested window; the projected movers and lines above are tested once.
|
||||||
|
if (includeMeshes)
|
||||||
|
for (let y = 0; y < height; y++)
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const sample =
|
||||||
|
width === 1 && height === 1
|
||||||
|
? at
|
||||||
|
: { x: at.x + x - halfWidth, y: at.y + y - halfHeight };
|
||||||
|
hits.push(...meshes(sample));
|
||||||
|
}
|
||||||
hits.sort(
|
hits.sort(
|
||||||
(a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth,
|
(a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth,
|
||||||
);
|
);
|
||||||
return hits.slice(0, limit).map(({ result }) => result);
|
const results = [];
|
||||||
|
for (const { result } of hits) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
if (
|
||||||
|
!results.some(
|
||||||
|
(hit) =>
|
||||||
|
hit.owner === result.owner &&
|
||||||
|
hit.instance === result.instance &&
|
||||||
|
hit.id === result.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Whether a mover instance is drawn near a window position: within its
|
* Whether a mover instance is drawn near a window position: within its
|
||||||
|
|||||||
@@ -71,3 +71,33 @@ test('distanceScale is Cesium.NearFarScalar interpolation', async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rectangular windows include nearby hits once and keep draw order and drill limit', () => {
|
||||||
|
const picker = createCpuPicker({
|
||||||
|
projector: {},
|
||||||
|
camera: {},
|
||||||
|
movers: () => [],
|
||||||
|
lines: () => [],
|
||||||
|
meshes: (at) =>
|
||||||
|
Math.abs(at.x - 4) < 0.1 && Math.abs(at.y) < 0.1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
onTop: 0,
|
||||||
|
order: 0,
|
||||||
|
depth: 1,
|
||||||
|
result: { id: 'behind', owner: 'b' },
|
||||||
|
},
|
||||||
|
{ onTop: 1, order: 0, depth: 2, result: { id: 'top', owner: 'a' } },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(picker({ x: 0, y: 0 }), []);
|
||||||
|
assert.deepEqual(picker({ x: 0, y: 0 }, 1, { width: 9, height: 3 }), [
|
||||||
|
{ id: 'top', owner: 'a' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
picker({ x: 0, y: 0 }, 8, { width: 9, height: 3 }).map((hit) => hit.id),
|
||||||
|
['top', 'behind'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(picker({ x: 0, y: 0 }, 8, { width: 3, height: 9 }), []);
|
||||||
|
});
|
||||||
+75
-7
@@ -47,6 +47,13 @@ export function normalizeGroundSetOptions(options) {
|
|||||||
fail('pickable must be a boolean');
|
fail('pickable must be a boolean');
|
||||||
if (options.dynamic !== undefined && typeof options.dynamic !== 'boolean')
|
if (options.dynamic !== undefined && typeof options.dynamic !== 'boolean')
|
||||||
fail('dynamic must be a boolean');
|
fail('dynamic must be a boolean');
|
||||||
|
if (
|
||||||
|
options.reveal !== undefined &&
|
||||||
|
(typeof options.reveal !== 'boolean' ||
|
||||||
|
kind !== 'corridor' ||
|
||||||
|
options.pickable)
|
||||||
|
)
|
||||||
|
fail('reveal needs an unpickable corridor set');
|
||||||
const drape = options.drape ?? 'both';
|
const drape = options.drape ?? 'both';
|
||||||
if (!GROUND_DRAPES.includes(drape))
|
if (!GROUND_DRAPES.includes(drape))
|
||||||
fail(`drape must be one of ${GROUND_DRAPES.join(', ')}: ${drape}`);
|
fail(`drape must be one of ${GROUND_DRAPES.join(', ')}: ${drape}`);
|
||||||
@@ -57,6 +64,7 @@ export function normalizeGroundSetOptions(options) {
|
|||||||
pickable: options.pickable === true,
|
pickable: options.pickable === true,
|
||||||
drape,
|
drape,
|
||||||
...(options.dynamic ? { dynamic: true } : {}),
|
...(options.dynamic ? { dynamic: true } : {}),
|
||||||
|
...(options.reveal ? { reveal: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +125,22 @@ function outlineOf(item) {
|
|||||||
return { color: Float32Array.from(outline.color), widthPx: outline.widthPx };
|
return { color: Float32Array.from(outline.color), widthPx: outline.widthPx };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A disc's radius fractions and alpha envelope, independent of its colour.
|
||||||
|
function radialOf(item) {
|
||||||
|
if (item.radial == null) return null;
|
||||||
|
const { fillAlpha, rimAlpha, rimStart, rimEnd, emission } = item.radial;
|
||||||
|
if (
|
||||||
|
!item.fill ||
|
||||||
|
![fillAlpha, rimAlpha, rimStart, rimEnd].every(unit) ||
|
||||||
|
!(rimStart < rimEnd && rimEnd < 1) ||
|
||||||
|
!(isFiniteNumber(emission) && emission >= 0)
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
'radial needs fill, alpha in 0..1, 0 <= rimStart < rimEnd < 1 and emission >= 0',
|
||||||
|
);
|
||||||
|
return Object.freeze({ fillAlpha, rimAlpha, rimStart, rimEnd, emission });
|
||||||
|
}
|
||||||
|
|
||||||
function boundsOf(paths) {
|
function boundsOf(paths) {
|
||||||
let west = Infinity;
|
let west = Infinity;
|
||||||
let east = -Infinity;
|
let east = -Infinity;
|
||||||
@@ -144,6 +168,7 @@ const DEGREE_M = (Math.PI / 180) * EARTH_RADIUS;
|
|||||||
export function normalizeGroundItem(kind, item, index = 0) {
|
export function normalizeGroundItem(kind, item, index = 0) {
|
||||||
if (!isObject(item)) fail(`item ${index} must be an object`);
|
if (!isObject(item)) fail(`item ${index} must be an object`);
|
||||||
const base = { pickId: item.pickId };
|
const base = { pickId: item.pickId };
|
||||||
|
if (item.radial != null && kind !== 'disc') fail('radial needs a disc');
|
||||||
if (kind === 'polygon') {
|
if (kind === 'polygon') {
|
||||||
if (!Array.isArray(item.rings) || !item.rings.length)
|
if (!Array.isArray(item.rings) || !item.rings.length)
|
||||||
fail(`item ${index} needs rings: [outer, ...holes]`);
|
fail(`item ${index} needs rings: [outer, ...holes]`);
|
||||||
@@ -173,6 +198,7 @@ export function normalizeGroundItem(kind, item, index = 0) {
|
|||||||
...base,
|
...base,
|
||||||
center: [center[0], center[1]],
|
center: [center[0], center[1]],
|
||||||
radiusM: item.radiusM,
|
radiusM: item.radiusM,
|
||||||
|
radial: radialOf(item),
|
||||||
fill: fillOf(item),
|
fill: fillOf(item),
|
||||||
outline: outlineOf(item),
|
outline: outlineOf(item),
|
||||||
bounds: {
|
bounds: {
|
||||||
@@ -244,8 +270,14 @@ const unit = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
|||||||
// alpha going up and down, `dash` the annotation route's flowing dashes.
|
// alpha going up and down, `dash` the annotation route's flowing dashes.
|
||||||
// Null without any.
|
// Null without any.
|
||||||
function animationOf(item, index) {
|
function animationOf(item, index) {
|
||||||
const { glow, pulse, dash, dashPattern } = item;
|
const { glow, pulse, dash, dashPattern, backing } = item;
|
||||||
if (glow == null && pulse == null && dash == null && dashPattern == null)
|
if (
|
||||||
|
glow == null &&
|
||||||
|
pulse == null &&
|
||||||
|
dash == null &&
|
||||||
|
dashPattern == null &&
|
||||||
|
backing == null
|
||||||
|
)
|
||||||
return null;
|
return null;
|
||||||
if (dashPattern != null) {
|
if (dashPattern != null) {
|
||||||
if (
|
if (
|
||||||
@@ -257,7 +289,7 @@ function animationOf(item, index) {
|
|||||||
const pattern = dashPattern.pattern ?? 255;
|
const pattern = dashPattern.pattern ?? 255;
|
||||||
if (!Number.isInteger(pattern) || pattern < 0 || pattern > 65535)
|
if (!Number.isInteger(pattern) || pattern < 0 || pattern > 65535)
|
||||||
fail(`item ${index} dashPattern.pattern must be a 16-bit integer`);
|
fail(`item ${index} dashPattern.pattern must be a 16-bit integer`);
|
||||||
if (glow != null || pulse != null || dash != null)
|
if (glow != null || pulse != null || dash != null || backing != null)
|
||||||
fail(
|
fail(
|
||||||
`item ${index} dashPattern cannot combine with other corridor looks`,
|
`item ${index} dashPattern cannot combine with other corridor looks`,
|
||||||
);
|
);
|
||||||
@@ -306,7 +338,28 @@ function animationOf(item, index) {
|
|||||||
fail(`${label} dash.emission must be a number >= 0: ${emission}`);
|
fail(`${label} dash.emission must be a number >= 0: ${emission}`);
|
||||||
dashOut = Object.freeze({ cells, duty, speed, gapAlpha, emission });
|
dashOut = Object.freeze({ cells, duty, speed, gapAlpha, emission });
|
||||||
}
|
}
|
||||||
return Object.freeze({ glow: glowOut, pulse: pulseOut, dash: dashOut });
|
let backingOut = null;
|
||||||
|
if (backing != null) {
|
||||||
|
if (
|
||||||
|
!isObject(backing) ||
|
||||||
|
!isColor(backing.color) ||
|
||||||
|
!isFiniteNumber(backing.widthPx) ||
|
||||||
|
!(backing.widthPx > item.widthPx)
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
`${label} backing needs a colour and a widthPx larger than the core`,
|
||||||
|
);
|
||||||
|
backingOut = Object.freeze({
|
||||||
|
color: Object.freeze([...backing.color]),
|
||||||
|
widthPx: backing.widthPx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
glow: glowOut,
|
||||||
|
pulse: pulseOut,
|
||||||
|
dash: dashOut,
|
||||||
|
...(backingOut ? { backing: backingOut } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a checked item changes over time (a pulse or a dash). */
|
/** Whether a checked item changes over time (a pulse or a dash). */
|
||||||
@@ -423,6 +476,7 @@ export function createGroundRegistry(build, { pulseField } = {}) {
|
|||||||
// What the last commit drew: picks read it.
|
// What the last commit drew: picks read it.
|
||||||
items: [],
|
items: [],
|
||||||
shown: true,
|
shown: true,
|
||||||
|
reveal: 1,
|
||||||
// Whether the last commit has a pulse or a dash.
|
// Whether the last commit has a pulse or a dash.
|
||||||
animated: false,
|
animated: false,
|
||||||
destroyed: false,
|
destroyed: false,
|
||||||
@@ -435,9 +489,15 @@ export function createGroundRegistry(build, { pulseField } = {}) {
|
|||||||
replace(items) {
|
replace(items) {
|
||||||
alive();
|
alive();
|
||||||
if (!Array.isArray(items)) fail('items must be an array');
|
if (!Array.isArray(items)) fail('items must be an array');
|
||||||
state.pending = items.map((item, index) =>
|
state.pending = items.map((item, index) => {
|
||||||
normalizeGroundItem(options.kind, item, index),
|
const checked = normalizeGroundItem(options.kind, item, index);
|
||||||
);
|
if (options.reveal) {
|
||||||
|
if (!checked.widthPx || checked.animation?.pattern)
|
||||||
|
fail('reveal needs pixel corridors without dashPattern');
|
||||||
|
checked.animation ??= {};
|
||||||
|
}
|
||||||
|
return checked;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
commit() {
|
commit() {
|
||||||
alive();
|
alive();
|
||||||
@@ -450,6 +510,14 @@ export function createGroundRegistry(build, { pulseField } = {}) {
|
|||||||
state.shown = Boolean(visible);
|
state.shown = Boolean(visible);
|
||||||
part.show(state.shown);
|
part.show(state.shown);
|
||||||
},
|
},
|
||||||
|
setReveal(fraction) {
|
||||||
|
alive();
|
||||||
|
if (!options.reveal || !unit(fraction))
|
||||||
|
fail('setReveal needs a reveal set and fraction in 0..1');
|
||||||
|
if (state.reveal === fraction) return;
|
||||||
|
state.reveal = fraction;
|
||||||
|
part.reveal?.(fraction);
|
||||||
|
},
|
||||||
shown: () => state.shown,
|
shown: () => state.shown,
|
||||||
count: () => state.items.length,
|
count: () => state.items.length,
|
||||||
ready: () =>
|
ready: () =>
|
||||||
|
|||||||
@@ -401,3 +401,69 @@ test('CPU picks find the item under the picked ground, on top first', () => {
|
|||||||
low.show(false);
|
low.show(false);
|
||||||
assert.deepEqual(ids(), []);
|
assert.deepEqual(ids(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('radial discs copy and validate their alpha envelope', () => {
|
||||||
|
const radial = {
|
||||||
|
fillAlpha: 0.105,
|
||||||
|
rimAlpha: 0.72,
|
||||||
|
rimStart: 0.952,
|
||||||
|
rimEnd: 0.985,
|
||||||
|
emission: 0.35,
|
||||||
|
};
|
||||||
|
const item = { center: [20, 10], radiusM: 100, fill: [0, 1, 1, 1], radial };
|
||||||
|
const checked = normalizeGroundItem('disc', item);
|
||||||
|
assert.deepEqual(checked.radial, radial);
|
||||||
|
assert.notEqual(checked.radial, radial);
|
||||||
|
for (const patch of [
|
||||||
|
{ fillAlpha: -1 },
|
||||||
|
{ rimAlpha: 2 },
|
||||||
|
{ rimStart: 0.99 },
|
||||||
|
{ rimEnd: 1 },
|
||||||
|
{ emission: NaN },
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeGroundItem('disc', {
|
||||||
|
...item,
|
||||||
|
radial: { ...radial, ...patch },
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeGroundItem('disc', { ...item, fill: null }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(() => normalizeGroundItem('cell', item), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pixel corridor backing keeps a wider edge and rejects invalid combinations', () => {
|
||||||
|
const item = {
|
||||||
|
positions: [
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
],
|
||||||
|
widthPx: 3,
|
||||||
|
color: [0, 1, 0, 0.8],
|
||||||
|
backing: { widthPx: 5, color: [0.02, 0.03, 0.05, 0.8] },
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeGroundItem('corridor', item).animation.backing,
|
||||||
|
item.backing,
|
||||||
|
);
|
||||||
|
for (const backing of [
|
||||||
|
{ widthPx: 3, color: [0, 0, 0, 1] },
|
||||||
|
{ widthPx: 5, color: [0, 0, 0, 2] },
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeGroundItem('corridor', { ...item, backing }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeGroundItem('corridor', {
|
||||||
|
...item,
|
||||||
|
dashPattern: { lengthPx: 16 },
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -420,7 +420,8 @@ export function createCameraModel({
|
|||||||
worldToWindow: (position, result) =>
|
worldToWindow: (position, result) =>
|
||||||
projectToWindow(view(), position, result),
|
projectToWindow(view(), position, result),
|
||||||
viewProjection: (result) => viewProjection(view(), result),
|
viewProjection: (result) => viewProjection(view(), result),
|
||||||
horizonVisible: (position) => horizonVisible(state.position, position),
|
horizonVisible: (position, options) =>
|
||||||
|
horizonVisible(state.position, position, options),
|
||||||
destroy() {
|
destroy() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
cancelCurrentFlight();
|
cancelCurrentFlight();
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ export function createNavaraEngine(
|
|||||||
projector: {
|
projector: {
|
||||||
worldToWindow: (position, result) =>
|
worldToWindow: (position, result) =>
|
||||||
model.worldToWindow(position, result),
|
model.worldToWindow(position, result),
|
||||||
horizonVisible: (position) => model.horizonVisible(position),
|
horizonVisible: (position, options) =>
|
||||||
|
model.horizonVisible(position, options),
|
||||||
},
|
},
|
||||||
camera: model.camera,
|
camera: model.camera,
|
||||||
movers: () => moversPort?.pickables?.() ?? [],
|
movers: () => moversPort?.pickables?.() ?? [],
|
||||||
@@ -285,8 +286,9 @@ export function createNavaraEngine(
|
|||||||
registerMeshPicker: (pick) => picking.registerMeshPicker(pick),
|
registerMeshPicker: (pick) => picking.registerMeshPicker(pick),
|
||||||
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
|
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
|
||||||
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
|
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
|
||||||
pick: (screen) => picking.pick(screen),
|
pick: (screen, width, height) => picking.pick(screen, width, height),
|
||||||
drillPick: (screen, limit) => picking.drillPick(screen, limit),
|
drillPick: (screen, limit, width, height) =>
|
||||||
|
picking.drillPick(screen, limit, width, height),
|
||||||
pickPosition(screen) {
|
pickPosition(screen) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const hit = view.pickDepthPosition(
|
const hit = view.pickDepthPosition(
|
||||||
@@ -416,7 +418,8 @@ export function createNavaraEngine(
|
|||||||
worldToWindow: (position, result) =>
|
worldToWindow: (position, result) =>
|
||||||
model.worldToWindow(position, result),
|
model.worldToWindow(position, result),
|
||||||
viewProjection: (result) => model.viewProjection(result),
|
viewProjection: (result) => model.viewProjection(result),
|
||||||
horizonVisible: (position) => model.horizonVisible(position),
|
horizonVisible: (position, options) =>
|
||||||
|
model.horizonVisible(position, options),
|
||||||
},
|
},
|
||||||
frames,
|
frames,
|
||||||
render: {
|
render: {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const E2 =
|
|||||||
/** Item looks as the shader reads them: glow, pulse and dash bits. */
|
/** Item looks as the shader reads them: glow, pulse and dash bits. */
|
||||||
export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4, pattern: 8 });
|
export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4, pattern: 8 });
|
||||||
/** RGBA32F texels per corridor item. */
|
/** RGBA32F texels per corridor item. */
|
||||||
export const ITEM_TEXELS = 5;
|
export const ITEM_TEXELS = 7;
|
||||||
|
|
||||||
const wrap180 = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
const wrap180 = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
||||||
const geodetic = (latcDeg) =>
|
const geodetic = (latcDeg) =>
|
||||||
@@ -54,7 +54,7 @@ const geodetic = (latcDeg) =>
|
|||||||
? latcDeg
|
? latcDeg
|
||||||
: Math.atan(Math.tan(latcDeg * DEG) / (1 - E2)) / DEG;
|
: Math.atan(Math.tan(latcDeg * DEG) / (1 - E2)) / DEG;
|
||||||
|
|
||||||
/** One item's look: five texels (colour, widths, glow/pulse, dash). */
|
/** One item's look: colour, widths, glow/pulse, dash and reveal. */
|
||||||
function itemTexels(item, lengthM) {
|
function itemTexels(item, lengthM) {
|
||||||
const { glow, pulse, dash, pattern } = item.animation ?? {};
|
const { glow, pulse, dash, pattern } = item.animation ?? {};
|
||||||
const flags =
|
const flags =
|
||||||
@@ -64,7 +64,7 @@ function itemTexels(item, lengthM) {
|
|||||||
(pattern ? LOOK.pattern : 0);
|
(pattern ? LOOK.pattern : 0);
|
||||||
return [
|
return [
|
||||||
...item.fill,
|
...item.fill,
|
||||||
item.widthPx ?? 0,
|
item.animation?.backing?.widthPx ?? item.widthPx ?? 0,
|
||||||
item.widthM ?? 0,
|
item.widthM ?? 0,
|
||||||
lengthM,
|
lengthM,
|
||||||
flags,
|
flags,
|
||||||
@@ -80,6 +80,11 @@ function itemTexels(item, lengthM) {
|
|||||||
dash?.emission ?? 0,
|
dash?.emission ?? 0,
|
||||||
pattern?.lengthPx ?? 0,
|
pattern?.lengthPx ?? 0,
|
||||||
pattern?.pattern ?? 0,
|
pattern?.pattern ?? 0,
|
||||||
|
1,
|
||||||
|
item.widthPx / (item.animation?.backing?.widthPx ?? item.widthPx) || 1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
...(item.animation?.backing?.color ?? [0, 0, 0, 0]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { createGroundRegistry } from '../../ground.js';
|
import { createGroundRegistry } from '../../ground.js';
|
||||||
import { composeGround, packHeights, packSet } from './pack.js';
|
import { composeGround, packHeights, packSet } from './pack.js';
|
||||||
|
import { ITEM_TEXELS } from './corridors.js';
|
||||||
|
|
||||||
// Frames before the first new try at a set's missing terrain heights; each
|
// Frames before the first new try at a set's missing terrain heights; each
|
||||||
// later try waits twice as long, up to RETRIES tries.
|
// later try waits twice as long, up to RETRIES tries.
|
||||||
@@ -60,6 +61,8 @@ export function createNavaraGround(
|
|||||||
};
|
};
|
||||||
const pack = (entry) => {
|
const pack = (entry) => {
|
||||||
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
|
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
|
||||||
|
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4)
|
||||||
|
entry.pack.items[at] = entry.fraction;
|
||||||
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
|
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
|
||||||
};
|
};
|
||||||
const sampleAgain = (entry) => {
|
const sampleAgain = (entry) => {
|
||||||
@@ -70,6 +73,12 @@ export function createNavaraGround(
|
|||||||
const drawn = [...entries]
|
const drawn = [...entries]
|
||||||
.filter((entry) => entry.shown && entry.pack?.count)
|
.filter((entry) => entry.shown && entry.pack?.count)
|
||||||
.sort((a, b) => a.options.order - b.options.order);
|
.sort((a, b) => a.options.order - b.options.order);
|
||||||
|
for (const entry of entries) entry.offset = null;
|
||||||
|
let offset = 0;
|
||||||
|
for (const entry of drawn) {
|
||||||
|
entry.offset = offset;
|
||||||
|
offset += entry.pack.items.length;
|
||||||
|
}
|
||||||
pass.set(
|
pass.set(
|
||||||
drawn.length ? composeGround(drawn.map(({ pack }) => pack)) : null,
|
drawn.length ? composeGround(drawn.map(({ pack }) => pack)) : null,
|
||||||
);
|
);
|
||||||
@@ -94,6 +103,8 @@ export function createNavaraGround(
|
|||||||
const entry = {
|
const entry = {
|
||||||
options,
|
options,
|
||||||
shown: true,
|
shown: true,
|
||||||
|
fraction: 1,
|
||||||
|
offset: null,
|
||||||
items: [],
|
items: [],
|
||||||
pack: null,
|
pack: null,
|
||||||
tries: 0,
|
tries: 0,
|
||||||
@@ -107,6 +118,21 @@ export function createNavaraGround(
|
|||||||
pack(entry);
|
pack(entry);
|
||||||
compose();
|
compose();
|
||||||
},
|
},
|
||||||
|
reveal(fraction) {
|
||||||
|
if (entry.fraction === fraction) return;
|
||||||
|
entry.fraction = fraction;
|
||||||
|
if (entry.pack) {
|
||||||
|
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4)
|
||||||
|
entry.pack.items[at] = fraction;
|
||||||
|
if (entry.offset !== null)
|
||||||
|
pass.reveal(
|
||||||
|
entry.offset,
|
||||||
|
entry.pack.items.length / (ITEM_TEXELS * 4),
|
||||||
|
fraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
view.forceUpdate();
|
||||||
|
},
|
||||||
show(visible) {
|
show(visible) {
|
||||||
if (entry.shown === visible) return;
|
if (entry.shown === visible) return;
|
||||||
entry.shown = visible;
|
entry.shown = visible;
|
||||||
|
|||||||
@@ -1,7 +1,43 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
import { createNavaraGround } from './index.js';
|
import { createNavaraGround } from './index.js';
|
||||||
|
|
||||||
|
// The pass owns real data textures; the view below does not create GPU effects.
|
||||||
|
const hooks = registerHooks({
|
||||||
|
resolve(specifier, context, nextResolve) {
|
||||||
|
return specifier === '@navaramap/three'
|
||||||
|
? {
|
||||||
|
url: 'data:text/javascript,export class EffectDesc {} export class Effect {}',
|
||||||
|
shortCircuit: true,
|
||||||
|
}
|
||||||
|
: nextResolve(specifier, context);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { createGroundPass } = await import('./pass.js');
|
||||||
|
hooks.deregister();
|
||||||
|
|
||||||
|
function passView() {
|
||||||
|
const effects = new Set();
|
||||||
|
return Object.assign(fakeView(), {
|
||||||
|
effects,
|
||||||
|
registerEffect() {},
|
||||||
|
addEffect(config) {
|
||||||
|
const handle = {
|
||||||
|
config,
|
||||||
|
update(next) {
|
||||||
|
Object.assign(config, next);
|
||||||
|
},
|
||||||
|
delete() {
|
||||||
|
effects.delete(handle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
effects.add(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fakeView({ height = () => 12 } = {}) {
|
function fakeView({ height = () => 12 } = {}) {
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
return {
|
return {
|
||||||
@@ -125,3 +161,116 @@ test('while a shown set pulses or dashes, every frame asks for the next', () =>
|
|||||||
queued.shift()();
|
queued.shift()();
|
||||||
assert.equal(view.frames, destroyed, 'none after destroy');
|
assert.equal(view.frames, destroyed, 'none after destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('corridor reveal updates item data without repacking geometry, including hide and commit', () => {
|
||||||
|
const pass = fakePass(),
|
||||||
|
updates = [];
|
||||||
|
pass.reveal = (...args) => updates.push(args);
|
||||||
|
const port = createNavaraGround(fakeView(), { createPass: () => pass });
|
||||||
|
const set = port.ground.createSet({
|
||||||
|
id: 'head',
|
||||||
|
kind: 'corridor',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
positions: [
|
||||||
|
[20, 10],
|
||||||
|
[20.001, 10],
|
||||||
|
],
|
||||||
|
widthPx: 5,
|
||||||
|
color: [0, 1, 0, 0.8],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
set.replace(items);
|
||||||
|
set.commit();
|
||||||
|
const state = pass.owner.state;
|
||||||
|
const count = pass.states.length;
|
||||||
|
set.setReveal(0.25);
|
||||||
|
set.setReveal(0.5);
|
||||||
|
assert.equal(pass.states.length, count);
|
||||||
|
assert.equal(pass.owner.state.segments, state.segments);
|
||||||
|
assert.deepEqual(updates, [
|
||||||
|
[0, 1, 0.25],
|
||||||
|
[0, 1, 0.5],
|
||||||
|
]);
|
||||||
|
set.show(false);
|
||||||
|
set.setReveal(0.75);
|
||||||
|
set.show(true);
|
||||||
|
assert.equal(pass.owner.state.items.data[20], 0.75);
|
||||||
|
set.replace(items);
|
||||||
|
set.commit();
|
||||||
|
assert.equal(pass.owner.state.items.data[20], 0.75);
|
||||||
|
set.destroy();
|
||||||
|
port.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroying a revealed corridor leaves the next ground set with fresh pass data', () => {
|
||||||
|
const corridor = {
|
||||||
|
positions: [
|
||||||
|
[20, 10],
|
||||||
|
[20.1, 10],
|
||||||
|
],
|
||||||
|
widthPx: 12,
|
||||||
|
color: [1, 0, 1, 1],
|
||||||
|
};
|
||||||
|
const view = passView();
|
||||||
|
const pass = createGroundPass(view);
|
||||||
|
const port = createNavaraGround(view, { createPass: () => pass });
|
||||||
|
const set = port.ground.createSet({
|
||||||
|
id: 'reveal',
|
||||||
|
kind: 'corridor',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
set.replace([corridor]);
|
||||||
|
set.commit();
|
||||||
|
set.setReveal(0.25);
|
||||||
|
assert.equal(pass.owner.state.items.data[20], 0.25);
|
||||||
|
set.destroy();
|
||||||
|
assert.equal(pass.owner.state, null);
|
||||||
|
assert.equal(pass.owner.textures, null);
|
||||||
|
assert.equal(
|
||||||
|
view.effects.size,
|
||||||
|
0,
|
||||||
|
'no retired ground effect remains in the pulse insertion path',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [kind, item] of [
|
||||||
|
['corridor', corridor],
|
||||||
|
['polygon', square(20)],
|
||||||
|
['disc', { center: [20, 10], radiusM: 500, fill: [0, 1, 0, 1] }],
|
||||||
|
['cell', { west: 20, east: 21, south: 10, north: 11, fill: [0, 0, 1, 1] }],
|
||||||
|
]) {
|
||||||
|
const freshView = passView();
|
||||||
|
const freshPass = createGroundPass(freshView);
|
||||||
|
const fresh = createNavaraGround(freshView, {
|
||||||
|
createPass: () => freshPass,
|
||||||
|
});
|
||||||
|
const next = port.ground.createSet({ id: 'next', kind });
|
||||||
|
const expected = fresh.ground.createSet({ id: 'next', kind });
|
||||||
|
for (const target of [next, expected]) {
|
||||||
|
target.replace([item]);
|
||||||
|
target.commit();
|
||||||
|
}
|
||||||
|
for (const key of [
|
||||||
|
'styles',
|
||||||
|
'segments',
|
||||||
|
'bins',
|
||||||
|
'runs',
|
||||||
|
'items',
|
||||||
|
'frames',
|
||||||
|
'grid',
|
||||||
|
'candidates',
|
||||||
|
])
|
||||||
|
assert.deepEqual(
|
||||||
|
pass.owner.state[key],
|
||||||
|
freshPass.owner.state[key],
|
||||||
|
`${kind}: ${key}`,
|
||||||
|
);
|
||||||
|
assert.throws(() => set.setReveal(0.9), /destroyed/);
|
||||||
|
assert.deepEqual(pass.owner.state.items, freshPass.owner.state.items);
|
||||||
|
next.destroy();
|
||||||
|
fresh.destroy();
|
||||||
|
}
|
||||||
|
port.destroy();
|
||||||
|
});
|
||||||
@@ -195,7 +195,7 @@ const OPEN = 1e30;
|
|||||||
// anti-aliasing in pixels; a metre one its half width in metres and the
|
// anti-aliasing in pixels; a metre one its half width in metres and the
|
||||||
// half pixel.
|
// half pixel.
|
||||||
const reachOf = (item) => [
|
const reachOf = (item) => [
|
||||||
(item.widthPx ?? 0) / 2 + 0.5,
|
(item.animation?.backing?.widthPx ?? item.widthPx ?? 0) / 2 + 0.5,
|
||||||
(item.widthM ?? 0) / 2,
|
(item.widthM ?? 0) / 2,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -351,6 +351,13 @@ export function packSet(
|
|||||||
const frame = frameAt(item.center[0], item.center[1]);
|
const frame = frameAt(item.center[0], item.center[1]);
|
||||||
const radius = frame.radius * Math.tan(item.radiusM / frame.radius);
|
const radius = frame.radius * Math.tan(item.radiusM / frame.radius);
|
||||||
style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius];
|
style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius];
|
||||||
|
if (item.radial) {
|
||||||
|
const { fillAlpha, rimAlpha, rimStart, rimEnd, emission } =
|
||||||
|
item.radial;
|
||||||
|
style.shape[2] = 1;
|
||||||
|
style.bins = [fillAlpha, rimAlpha, rimStart, rimEnd];
|
||||||
|
style.origin = [emission, 0, 0, 0];
|
||||||
|
}
|
||||||
emit(
|
emit(
|
||||||
frame,
|
frame,
|
||||||
style,
|
style,
|
||||||
|
|||||||
@@ -480,3 +480,25 @@ test('screen-space dash masks keep their CSS length and all sixteen bits in the
|
|||||||
assert.deepEqual(Array.from(pack.items.slice(18, 20)), [18, 0xa55a]);
|
assert.deepEqual(Array.from(pack.items.slice(18, 20)), [18, 0xa55a]);
|
||||||
assert.equal(pack.items.length, ITEM_TEXELS * 4);
|
assert.equal(pack.items.length, ITEM_TEXELS * 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('radial discs pack both transitions, alpha and emission without segment data', () => {
|
||||||
|
const radial = {
|
||||||
|
fillAlpha: 0.105,
|
||||||
|
rimAlpha: 0.72,
|
||||||
|
rimStart: 0.952,
|
||||||
|
rimEnd: 0.985,
|
||||||
|
emission: 0.35,
|
||||||
|
};
|
||||||
|
const pack = packSet(
|
||||||
|
'disc',
|
||||||
|
items('disc', [
|
||||||
|
{ center: [20, 10], radiusM: 100, fill: [0, 1, 1, 1], radial },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
assert.equal(pack.styles[10], 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(pack.styles.slice(16, 21)),
|
||||||
|
Array.from(Float32Array.of(0.105, 0.72, 0.952, 0.985, 0.35)),
|
||||||
|
);
|
||||||
|
assert.equal(pack.segments.length, 0);
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// blended in display space, in draw order, as Cesium blends translucent
|
// blended in display space, in draw order, as Cesium blends translucent
|
||||||
// ground primitives; a corridor's glow, pulse and dash follow Cesium's
|
// ground primitives; a corridor's glow, pulse and dash follow Cesium's
|
||||||
// materials, from the time uniform (src/globe/ground.js groundSeconds).
|
// materials, from the time uniform (src/globe/ground.js groundSeconds).
|
||||||
// Costs nothing while no item is drawn (the effect is hidden).
|
// No effect remains in the chain while no item is drawn.
|
||||||
|
|
||||||
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
|
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
|
||||||
import { Effect, EffectAttribute } from 'postprocessing';
|
import { Effect, EffectAttribute } from 'postprocessing';
|
||||||
@@ -72,6 +72,7 @@ vec3 toLinear(vec3 c) {
|
|||||||
vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along, vec2 localDx, vec2 localDy) {
|
vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along, vec2 localDx, vec2 localDy) {
|
||||||
int at = item * ${ITEM_TEXELS};
|
int at = item * ${ITEM_TEXELS};
|
||||||
vec4 shape = atSlot(tItems, at + 1);
|
vec4 shape = atSlot(tItems, at + 1);
|
||||||
|
if (along / max(shape.z, 1e-6) > atSlot(tItems, at + 5).x) return color;
|
||||||
// Metres a pixel spans across the line (the screen gradient of the
|
// Metres a pixel spans across the line (the screen gradient of the
|
||||||
// distance to it): its width in pixels the same at any angle, as Cesium's.
|
// distance to it): its width in pixels the same at any angle, as Cesium's.
|
||||||
float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4);
|
float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4);
|
||||||
@@ -110,6 +111,12 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
|
|||||||
alpha *= max(on, dash.x);
|
alpha *= max(on, dash.x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
float core = atSlot(tItems, at + 5).y;
|
||||||
|
if (core < 1.0 && nearest / max(halfWidth, 1e-6) > core) {
|
||||||
|
vec4 backing = atSlot(tItems, at + 6);
|
||||||
|
rgb = backing.rgb;
|
||||||
|
alpha = backing.a * cover;
|
||||||
|
}
|
||||||
return min(color * (1.0 - alpha) + min(rgb * alpha, vec3(1.0)), vec3(1.0));
|
return min(color * (1.0 - alpha) + min(rgb * alpha, vec3(1.0)), vec3(1.0));
|
||||||
}
|
}
|
||||||
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
||||||
@@ -301,6 +308,15 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
|||||||
// Metres one pixel spans across the boundary.
|
// Metres one pixel spans across the boundary.
|
||||||
float pixel = max(abs(dot(normal, localDx)) + abs(dot(normal, localDy)), 1e-4);
|
float pixel = max(abs(dot(normal, localDx)) + abs(dot(normal, localDy)), 1e-4);
|
||||||
float inward = inside ? nearest : -nearest;
|
float inward = inside ? nearest : -nearest;
|
||||||
|
if (kind == ${KIND.disc} && shape.z > 0.5) {
|
||||||
|
vec4 radial = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
|
||||||
|
float emission = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).x;
|
||||||
|
float radius = length(local) / shape.w;
|
||||||
|
float inside = 1.0 - smoothstep(radial.w, 1.0, radius);
|
||||||
|
float rim = smoothstep(radial.z, radial.w, radius) * inside;
|
||||||
|
float alpha = fillColor.a * inside * mix(radial.x, radial.y, rim);
|
||||||
|
color = min(color * (1.0 - alpha) + fillColor.rgb * (1.0 + rim * emission) * alpha, vec3(1.0));
|
||||||
|
} else
|
||||||
if (fillColor.a > 0.0)
|
if (fillColor.a > 0.0)
|
||||||
color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0));
|
color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0));
|
||||||
if (lineColor.a > 0.0 && shape.y > 0.0)
|
if (lineColor.a > 0.0 && shape.y > 0.0)
|
||||||
@@ -465,7 +481,7 @@ const registered = new WeakSet();
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The pass of one view: set(state) shows a composed state (null: nothing,
|
* The pass of one view: set(state) shows a composed state (null: nothing,
|
||||||
* the effect hidden) and disposes the one before.
|
* the effect removed) and disposes the one before.
|
||||||
*/
|
*/
|
||||||
export function createGroundPass(view) {
|
export function createGroundPass(view) {
|
||||||
if (!registered.has(view)) {
|
if (!registered.has(view)) {
|
||||||
@@ -481,7 +497,10 @@ export function createGroundPass(view) {
|
|||||||
owner.state = state && state.count ? state : null;
|
owner.state = state && state.count ? state : null;
|
||||||
owner.textures = owner.state ? texturesFor(owner.state) : null;
|
owner.textures = owner.state ? texturesFor(owner.state) : null;
|
||||||
const visible = owner.state !== null;
|
const visible = owner.state !== null;
|
||||||
if (visible && !handle)
|
if (!visible) {
|
||||||
|
handle?.delete();
|
||||||
|
handle = null;
|
||||||
|
} else if (!handle)
|
||||||
handle = view.addEffect({
|
handle = view.addEffect({
|
||||||
[GROUND_EFFECT_KEY]: { owner },
|
[GROUND_EFFECT_KEY]: { owner },
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -489,6 +508,12 @@ export function createGroundPass(view) {
|
|||||||
else handle?.update({ visible });
|
else handle?.update({ visible });
|
||||||
previous?.dispose();
|
previous?.dispose();
|
||||||
},
|
},
|
||||||
|
reveal(offset, count, fraction) {
|
||||||
|
if (!owner.state) return;
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
owner.state.items.data[offset + i * ITEM_TEXELS * 4 + 20] = fraction;
|
||||||
|
owner.textures.items.needsUpdate = true;
|
||||||
|
},
|
||||||
textureBytes: () => textureBytes(owner.state),
|
textureBytes: () => textureBytes(owner.state),
|
||||||
destroy() {
|
destroy() {
|
||||||
handle?.delete();
|
handle?.delete();
|
||||||
|
|||||||
@@ -50,10 +50,27 @@ export function createNavaraInput(
|
|||||||
let last = null;
|
let last = null;
|
||||||
|
|
||||||
const pointer = createExclusivePointer();
|
const pointer = createExclusivePointer();
|
||||||
|
const gestures = new WeakMap();
|
||||||
const delivery = createPickDelivery({
|
const delivery = createPickDelivery({
|
||||||
pick: (position, type) =>
|
pick: (position, type, window) => {
|
||||||
type === 'move' ? picker?.pick(position) : picker?.clickPick(position),
|
if (type === 'move' || type === 'down')
|
||||||
drill: (position, limit) => picker?.drillPick(position, limit) ?? [],
|
return picker?.pick(position, window?.width, window?.height);
|
||||||
|
let gesture = gestures.get(position);
|
||||||
|
if (!gesture) {
|
||||||
|
gesture = Promise.resolve(picker?.clickPick(position));
|
||||||
|
gestures.set(position, gesture);
|
||||||
|
}
|
||||||
|
if (!window) return gesture;
|
||||||
|
return gesture.then(
|
||||||
|
(hit) =>
|
||||||
|
hit ??
|
||||||
|
(window
|
||||||
|
? picker?.pick(position, window.width, window.height)
|
||||||
|
: undefined),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
drill: (position, limit, window) =>
|
||||||
|
picker?.drillPick(position, limit, window?.width, window?.height) ?? [],
|
||||||
schedule: (run) => scheduleFrame(() => !destroyed && run()),
|
schedule: (run) => scheduleFrame(() => !destroyed && run()),
|
||||||
});
|
});
|
||||||
const positionOf = (event) => {
|
const positionOf = (event) => {
|
||||||
@@ -126,7 +143,10 @@ export function createNavaraInput(
|
|||||||
const input = {
|
const input = {
|
||||||
acquirePointer: () => pointer.acquire(),
|
acquirePointer: () => pointer.acquire(),
|
||||||
on(type, listener, options = {}) {
|
on(type, listener, options = {}) {
|
||||||
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
const { modifier, pick, drill, pickWindow } = normalizeInputOptions(
|
||||||
|
type,
|
||||||
|
options,
|
||||||
|
);
|
||||||
if (destroyed) return () => {};
|
if (destroyed) return () => {};
|
||||||
if (!attached) {
|
if (!attached) {
|
||||||
attached = true;
|
attached = true;
|
||||||
@@ -138,6 +158,7 @@ export function createNavaraInput(
|
|||||||
modifier,
|
modifier,
|
||||||
pick,
|
pick,
|
||||||
drill,
|
drill,
|
||||||
|
pickWindow,
|
||||||
pointer: options.pointer,
|
pointer: options.pointer,
|
||||||
};
|
};
|
||||||
listeners.get(type).add(entry);
|
listeners.get(type).add(entry);
|
||||||
|
|||||||
@@ -265,3 +265,63 @@ test('exclusive pointer drops deferred picks across ownership changes and releas
|
|||||||
next.release();
|
next.release();
|
||||||
assert.equal(input.acquirePointer(), null);
|
assert.equal(input.acquirePointer(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom windows keep down synchronous, moves coalesced and clicks on one GPU gesture', async () => {
|
||||||
|
const canvas = fakeCanvas();
|
||||||
|
const calls = [],
|
||||||
|
seen = [];
|
||||||
|
let resolve,
|
||||||
|
frame,
|
||||||
|
clicks = 0;
|
||||||
|
const { input, destroy } = createNavaraInput(
|
||||||
|
{ canvas, camera: {} },
|
||||||
|
{
|
||||||
|
picker: {
|
||||||
|
pick: (at, w, h) => {
|
||||||
|
calls.push(['pick', w, h]);
|
||||||
|
return { id: 'cpu' };
|
||||||
|
},
|
||||||
|
drillPick: (at, limit, w, h) => {
|
||||||
|
calls.push(['drill', limit, w, h]);
|
||||||
|
return [{ id: 'cpu' }];
|
||||||
|
},
|
||||||
|
clickPick: () => {
|
||||||
|
clicks++;
|
||||||
|
return new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scheduleFrame: (run) => {
|
||||||
|
frame = run;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const options = { drill: 6, pickWindow: { width: 11, height: 7 } };
|
||||||
|
input.on('down', (event) => seen.push(event), options);
|
||||||
|
input.on('move', (event) => seen.push(event), options);
|
||||||
|
input.on('click', (event) => seen.push(event), options);
|
||||||
|
input.on('click', (event) => seen.push(event), { pick: true });
|
||||||
|
canvas.dispatchEvent(pointer('pointerdown', 110, 60));
|
||||||
|
assert.equal(seen[0].picked.id, 'cpu');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['pick', 11, 7],
|
||||||
|
['drill', 6, 11, 7],
|
||||||
|
]);
|
||||||
|
canvas.dispatchEvent(pointer('pointermove', 111, 60));
|
||||||
|
canvas.dispatchEvent(pointer('pointermove', 112, 60));
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
frame();
|
||||||
|
assert.equal(seen.length, 2);
|
||||||
|
assert.equal(seen[1].endPosition.x, 12);
|
||||||
|
canvas.dispatchEvent(pointer('pointerup', 112, 60));
|
||||||
|
assert.equal(clicks, 1);
|
||||||
|
assert.equal(seen.length, 2);
|
||||||
|
resolve({ id: 'gpu' });
|
||||||
|
await new Promise((done) => setTimeout(done, 0));
|
||||||
|
assert.deepEqual(
|
||||||
|
seen.slice(2).map((event) => event.picked.id),
|
||||||
|
['gpu', 'gpu'],
|
||||||
|
);
|
||||||
|
destroy();
|
||||||
|
});
|
||||||
@@ -102,8 +102,9 @@ export function createNavaraPicking({
|
|||||||
meshPickers.add(pick);
|
meshPickers.add(pick);
|
||||||
return () => meshPickers.delete(pick);
|
return () => meshPickers.delete(pick);
|
||||||
},
|
},
|
||||||
pick: (at) => drillPick(at, 1)[0],
|
pick: (at, width, height) => drillPick(at, 1, { width, height })[0],
|
||||||
drillPick: (at, limit) => drillPick(at, limit ?? Infinity),
|
drillPick: (at, limit, width, height) =>
|
||||||
|
drillPick(at, limit ?? Infinity, { width, height }),
|
||||||
/** The answer for a click at `at`: the GPU pick of its gesture. */
|
/** The answer for a click at `at`: the GPU pick of its gesture. */
|
||||||
clickPick(at) {
|
clickPick(at) {
|
||||||
const gesture = early;
|
const gesture = early;
|
||||||
|
|||||||
+35
-15
@@ -1,5 +1,5 @@
|
|||||||
// Delivers input events to listeners that asked for picks (input.on with
|
// Delivers input events to listeners that asked for picks (input.on with
|
||||||
// { pick, drill }), for every engine: one pick (and one drill, as deep as
|
// { pick, drill }), for every engine: one pick per window (and one drill, as deep as
|
||||||
// the deepest listener asked) per event, shared by its listeners; moves are
|
// the deepest listener asked) per event, shared by its listeners; moves are
|
||||||
// coalesced to one delivery per frame with the latest position. Picks may
|
// coalesced to one delivery per frame with the latest position. Picks may
|
||||||
// be asynchronous (Navara answers clicks from its GPU pick of the gesture):
|
// be asynchronous (Navara answers clicks from its GPU pick of the gesture):
|
||||||
@@ -16,25 +16,45 @@ const settle = (value, then) =>
|
|||||||
export function createPickDelivery({ pick, drill, schedule }) {
|
export function createPickDelivery({ pick, drill, schedule }) {
|
||||||
let pendingMove = null;
|
let pendingMove = null;
|
||||||
|
|
||||||
function run(type, event, entries, isLive) {
|
function runWindow(type, event, entries, isLive) {
|
||||||
const at = event.position ?? event.endPosition;
|
const at = event.position ?? event.endPosition;
|
||||||
const depth = entries.reduce(
|
const depth = entries.reduce(
|
||||||
(most, entry) => Math.max(most, entry.drill),
|
(most, entry) => Math.max(most, entry.drill),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return settle(pick(at, type), (picked) =>
|
return settle(pick(at, type, entries[0].pickWindow), (picked) =>
|
||||||
settle(depth ? drill(at, depth) : undefined, (drilled) => {
|
settle(
|
||||||
for (const entry of entries) {
|
depth ? drill(at, depth, entries[0].pickWindow) : undefined,
|
||||||
if (!isLive(entry)) continue;
|
(drilled) => {
|
||||||
entry.listener({
|
for (const entry of entries) {
|
||||||
...event,
|
if (!isLive(entry)) continue;
|
||||||
picked,
|
entry.listener({
|
||||||
...(entry.drill
|
...event,
|
||||||
? { drilled: (drilled ?? []).slice(0, entry.drill) }
|
picked,
|
||||||
: {}),
|
...(entry.drill
|
||||||
});
|
? { drilled: (drilled ?? []).slice(0, entry.drill) }
|
||||||
}
|
: {}),
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(type, event, entries, isLive) {
|
||||||
|
const windows = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!isLive(entry)) continue;
|
||||||
|
const key = entry.pickWindow
|
||||||
|
? `${entry.pickWindow.width}:${entry.pickWindow.height}`
|
||||||
|
: 'default';
|
||||||
|
if (!windows.has(key)) windows.set(key, []);
|
||||||
|
windows.get(key).push(entry);
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
[...windows.values()].map((group) =>
|
||||||
|
runWindow(type, event, group, isLive),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,48 @@ test('asynchronous picks resolve before delivery; removed listeners get nothing'
|
|||||||
);
|
);
|
||||||
assert.deepEqual(seen[0][1].drilled, [{ id: 'later' }]);
|
assert.deepEqual(seen[0][1].drilled, [{ id: 'later' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pick windows partition sharing, preserve drill limits and copy the latest move', async () => {
|
||||||
|
const picks = [],
|
||||||
|
drills = [],
|
||||||
|
seen = [];
|
||||||
|
let frame;
|
||||||
|
const delivery = createPickDelivery({
|
||||||
|
pick: (at, type, window) => {
|
||||||
|
picks.push([at.x, window]);
|
||||||
|
return { id: window?.width };
|
||||||
|
},
|
||||||
|
drill: (at, limit, window) => {
|
||||||
|
drills.push([limit, window]);
|
||||||
|
return Array.from({ length: limit }, (_, id) => ({ id }));
|
||||||
|
},
|
||||||
|
schedule: (run) => {
|
||||||
|
frame = run;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const small = { width: 9, height: 3 },
|
||||||
|
large = { width: 17, height: 11 };
|
||||||
|
const listeners = [
|
||||||
|
entry(seen, 'a', { pick: true, drill: 2, pickWindow: small }),
|
||||||
|
entry(seen, 'b', { pick: true, drill: 4, pickWindow: { ...small } }),
|
||||||
|
entry(seen, 'c', { pick: true, drill: 1, pickWindow: large }),
|
||||||
|
];
|
||||||
|
await delivery.deliver('down', { position: { x: 1, y: 2 } }, listeners);
|
||||||
|
assert.equal(picks.length, 2);
|
||||||
|
assert.deepEqual(drills, [
|
||||||
|
[4, small],
|
||||||
|
[1, large],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
seen.map(([, event]) => event.drilled.length),
|
||||||
|
[2, 4, 1],
|
||||||
|
);
|
||||||
|
picks.length = 0;
|
||||||
|
delivery.deliver('move', { endPosition: { x: 3, y: 0 } }, listeners);
|
||||||
|
delivery.deliver('move', { endPosition: { x: 4, y: 0 } }, listeners);
|
||||||
|
frame();
|
||||||
|
assert.deepEqual(
|
||||||
|
picks.map(([x]) => x),
|
||||||
|
[4, 4],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -28,6 +28,11 @@ class ContractError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The radial ground look's disc in the contract checks, a launch pad's radius.
|
||||||
|
const RADIAL_RADIUS_M = 500;
|
||||||
|
// The camera height that shows that disc and its rim a few pixels wide.
|
||||||
|
const RADIAL_VIEW_M = 4000;
|
||||||
|
|
||||||
function ensure(condition, message) {
|
function ensure(condition, message) {
|
||||||
if (!condition) throw new ContractError(message);
|
if (!condition) throw new ContractError(message);
|
||||||
}
|
}
|
||||||
@@ -926,6 +931,7 @@ export function createContractSuite(
|
|||||||
engine.input.on('click', (event) => clicks.push(event), {
|
engine.input.on('click', (event) => clicks.push(event), {
|
||||||
pick: true,
|
pick: true,
|
||||||
drill: 8,
|
drill: 8,
|
||||||
|
pickWindow: { width: 9, height: 9 },
|
||||||
}),
|
}),
|
||||||
engine.input.on('move', (event) => moves.push(event), {
|
engine.input.on('move', (event) => moves.push(event), {
|
||||||
pick: true,
|
pick: true,
|
||||||
@@ -3028,6 +3034,81 @@ function groundChecks(
|
|||||||
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
|
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
|
||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
|
[
|
||||||
|
'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible',
|
||||||
|
async () => {
|
||||||
|
const eye = cartesian3.fromDegrees(-74.006, 40.7128, -19.6);
|
||||||
|
engine.camera.setView({ destination: eye });
|
||||||
|
await settle();
|
||||||
|
const point = cartesian3.fromDegrees(-74.006, 40.71289, -22);
|
||||||
|
const options = {
|
||||||
|
ellipsoid: {
|
||||||
|
radii: { x: 6377137, y: 6377137, z: 6355752.314245179 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ensure(
|
||||||
|
engine.projector.horizonVisible(point, options),
|
||||||
|
'nearby ground below WGS84 stays visible',
|
||||||
|
);
|
||||||
|
ensure(
|
||||||
|
!engine.projector.horizonVisible(
|
||||||
|
cartesian3.fromDegrees(105.994, -40.7128),
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
'far side stays hidden',
|
||||||
|
);
|
||||||
|
throwsType(
|
||||||
|
() =>
|
||||||
|
engine.projector.horizonVisible(point, {
|
||||||
|
ellipsoid: { radii: { x: 0, y: 1, z: 1 } },
|
||||||
|
}),
|
||||||
|
'zero horizon radius',
|
||||||
|
);
|
||||||
|
await nadirView(1e6);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ground: corridor reveals advance without replacing their path',
|
||||||
|
async () => {
|
||||||
|
const set = ground.createSet({
|
||||||
|
id: 'contract-reveal',
|
||||||
|
kind: 'corridor',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
set.replace([
|
||||||
|
{
|
||||||
|
positions: [
|
||||||
|
[19.7, 10],
|
||||||
|
[20.3, 10],
|
||||||
|
],
|
||||||
|
widthPx: 12,
|
||||||
|
color: [1, 0, 1, 1],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
set.commit();
|
||||||
|
set.setReveal(0.25);
|
||||||
|
throwsType(() => set.setReveal(-1), 'negative reveal');
|
||||||
|
throwsType(() => set.setReveal(2), 'large reveal');
|
||||||
|
if (!readRegion) return;
|
||||||
|
await nadirView(350000);
|
||||||
|
await until(() => set.ready(), 'reveal ground ready');
|
||||||
|
await settle();
|
||||||
|
const magenta = (lon) => {
|
||||||
|
const { x, y } = screenOf(lon, 10);
|
||||||
|
const p = readRegion(Math.round(x), Math.round(y), 1, 1);
|
||||||
|
return Math.min(p[0], p[2]) - p[1];
|
||||||
|
};
|
||||||
|
ensure(magenta(19.8) > 60, 'revealed start draws');
|
||||||
|
ensure(magenta(20.2) < 60, 'unrevealed end is hidden');
|
||||||
|
set.setReveal(0.9);
|
||||||
|
await settle();
|
||||||
|
ensure(magenta(20.2) > 60, 'reveal advances over the existing path');
|
||||||
|
} finally {
|
||||||
|
set.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'ground: pulse fields update fronts, move, hide and release without a clock',
|
'ground: pulse fields update fronts, move, hide and release without a clock',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -3078,6 +3159,13 @@ function groundChecks(
|
|||||||
async () => {
|
async () => {
|
||||||
if (!readRegion) return;
|
if (!readRegion) return;
|
||||||
await nadirView(1e6);
|
await nadirView(1e6);
|
||||||
|
// The base frame must be the settled surface: a check before this one
|
||||||
|
// can leave tiles of another view still loading.
|
||||||
|
await until(
|
||||||
|
() => engine.surface.tilesLoaded?.() !== false,
|
||||||
|
'surface settles',
|
||||||
|
);
|
||||||
|
await settle(5);
|
||||||
const at = engine.projector.worldToWindow(
|
const at = engine.projector.worldToWindow(
|
||||||
cartesian3.fromDegrees(20.9, 10, 2500),
|
cartesian3.fromDegrees(20.9, 10, 2500),
|
||||||
);
|
);
|
||||||
@@ -3775,6 +3863,108 @@ function groundChecks(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'ground: radial discs retain their look and draw a brighter rim than fill',
|
||||||
|
async () => {
|
||||||
|
const background = ground.createSet({
|
||||||
|
id: 'contract-radial-background',
|
||||||
|
kind: 'cell',
|
||||||
|
});
|
||||||
|
const disc = ground.createSet({
|
||||||
|
id: 'contract-radial',
|
||||||
|
kind: 'disc',
|
||||||
|
order: 1,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
background.replace([
|
||||||
|
{ west: 19, south: 9, east: 21, north: 11, fill: [0, 0, 0, 1] },
|
||||||
|
]);
|
||||||
|
background.commit();
|
||||||
|
disc.replace([
|
||||||
|
{
|
||||||
|
center: [20, 10],
|
||||||
|
// A launch pad's size: Cesium maps a large ground disc's
|
||||||
|
// texture coordinates differently, and only pads use this look.
|
||||||
|
radiusM: RADIAL_RADIUS_M,
|
||||||
|
fill: [0, 1, 1, 1],
|
||||||
|
radial: {
|
||||||
|
fillAlpha: 0.105,
|
||||||
|
rimAlpha: 0.72,
|
||||||
|
rimStart: 0.952,
|
||||||
|
rimEnd: 0.985,
|
||||||
|
emission: 0.35,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
disc.commit();
|
||||||
|
ensure(
|
||||||
|
ground.list().find((set) => set.id === 'contract-radial').count ===
|
||||||
|
1,
|
||||||
|
'radial disc committed',
|
||||||
|
);
|
||||||
|
if (!readRegion) return;
|
||||||
|
await nadirView(RADIAL_VIEW_M);
|
||||||
|
await until(
|
||||||
|
() => disc.ready() && background.ready(),
|
||||||
|
'radial ground ready',
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180);
|
||||||
|
const at = (radius, angle) => {
|
||||||
|
const { x, y } = screenOf(
|
||||||
|
20 + (radius * Math.cos(angle)) / metresPerDegree,
|
||||||
|
10 + (radius * Math.sin(angle)) / 110574,
|
||||||
|
);
|
||||||
|
const bytes = readRegion(Math.round(x), Math.round(y), 1, 1);
|
||||||
|
return bytes[1] + bytes[2];
|
||||||
|
};
|
||||||
|
// Cesium leaves part of a small ground disc's fill undrawn (its
|
||||||
|
// old launch pad shows the same gap): the brightest inner sample.
|
||||||
|
const fillAt = () =>
|
||||||
|
Math.max(
|
||||||
|
...[0, 1, 2, 3, 4, 5].map((k) =>
|
||||||
|
at(RADIAL_RADIUS_M * 0.5, (k * Math.PI) / 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Cesium classifies onto the terrain loaded at this height, which
|
||||||
|
// can take longer than the usual frame budget after the descent.
|
||||||
|
// The app's own arrival flight can land after the view was set:
|
||||||
|
// the view is set again until the disc draws.
|
||||||
|
// Pixels come from the last drawn frame, screen positions from the
|
||||||
|
// camera now: both must be at the view, with no flight under way.
|
||||||
|
const away = () =>
|
||||||
|
engine.camera.flightActive() ||
|
||||||
|
engine.camera.height() > RADIAL_VIEW_M * 1.5;
|
||||||
|
for (
|
||||||
|
let frame = 0;
|
||||||
|
frame < 1200 && (away() || fillAt() <= 5);
|
||||||
|
frame++
|
||||||
|
) {
|
||||||
|
if (away()) {
|
||||||
|
engine.camera.cancelFlight();
|
||||||
|
await nadirView(RADIAL_VIEW_M);
|
||||||
|
}
|
||||||
|
await settle(1);
|
||||||
|
}
|
||||||
|
const fill = fillAt();
|
||||||
|
// The rim is a few pixels wide: its brightest sample across it.
|
||||||
|
let rim = 0;
|
||||||
|
for (let step = 0; step <= 25; step++)
|
||||||
|
rim = Math.max(rim, at(RADIAL_RADIUS_M * (0.85 + step * 0.01), 0));
|
||||||
|
ensure(fill > 5, `radial fill is visible: ${fill}`);
|
||||||
|
ensure(
|
||||||
|
rim > fill + 50,
|
||||||
|
`radial rim ${rim} is brighter than fill ${fill}`,
|
||||||
|
);
|
||||||
|
disc.show(false);
|
||||||
|
await settle();
|
||||||
|
ensure(fillAt() < fill, 'hidden radial fill clears');
|
||||||
|
} finally {
|
||||||
|
disc.destroy();
|
||||||
|
background.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ export function createFakeEngine(options = {}) {
|
|||||||
destroy() {},
|
destroy() {},
|
||||||
}));
|
}));
|
||||||
const groundRegistry = createGroundRegistry(() => ({
|
const groundRegistry = createGroundRegistry(() => ({
|
||||||
|
reveal() {
|
||||||
|
fake.renderRequests++;
|
||||||
|
},
|
||||||
commit() {
|
commit() {
|
||||||
fake.renderRequests++;
|
fake.renderRequests++;
|
||||||
},
|
},
|
||||||
@@ -716,12 +719,16 @@ export function createFakeEngine(options = {}) {
|
|||||||
picker: {
|
picker: {
|
||||||
pick(screen, w, h) {
|
pick(screen, w, h) {
|
||||||
record('picker.pick', [screen, w, h]);
|
record('picker.pick', [screen, w, h]);
|
||||||
return scripted('pick', [screen, w, h], () => cpuPick(screen, 1)[0]);
|
return scripted(
|
||||||
|
'pick',
|
||||||
|
[screen, w, h],
|
||||||
|
() => cpuPick(screen, 1, { width: w, height: h })[0],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
drillPick(screen, limit, w, h) {
|
drillPick(screen, limit, w, h) {
|
||||||
record('picker.drillPick', [screen, limit, w, h]);
|
record('picker.drillPick', [screen, limit, w, h]);
|
||||||
return scripted('drillPick', [screen, limit, w, h], () =>
|
return scripted('drillPick', [screen, limit, w, h], () =>
|
||||||
cpuPick(screen, limit ?? Infinity),
|
cpuPick(screen, limit ?? Infinity, { width: w, height: h }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
pickPosition(screen) {
|
pickPosition(screen) {
|
||||||
@@ -748,7 +755,8 @@ export function createFakeEngine(options = {}) {
|
|||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
viewProjection: (result) => viewProjection(cameraView(), result),
|
viewProjection: (result) => viewProjection(cameraView(), result),
|
||||||
horizonVisible: (position) => horizonVisible(state.position, position),
|
horizonVisible: (position, options) =>
|
||||||
|
horizonVisible(state.position, position, options),
|
||||||
},
|
},
|
||||||
movers: moverRegistry.movers,
|
movers: moverRegistry.movers,
|
||||||
lines: lineRegistry.lines,
|
lines: lineRegistry.lines,
|
||||||
@@ -824,7 +832,7 @@ export function createFakeEngine(options = {}) {
|
|||||||
acquirePointer: () => pointer.acquire(),
|
acquirePointer: () => pointer.acquire(),
|
||||||
on(type, listener, inputOptions = {}) {
|
on(type, listener, inputOptions = {}) {
|
||||||
record('input.on', [type, inputOptions]);
|
record('input.on', [type, inputOptions]);
|
||||||
const { modifier, pick, drill } = normalizeInputOptions(
|
const { modifier, pick, drill, pickWindow } = normalizeInputOptions(
|
||||||
type,
|
type,
|
||||||
inputOptions,
|
inputOptions,
|
||||||
);
|
);
|
||||||
@@ -834,6 +842,7 @@ export function createFakeEngine(options = {}) {
|
|||||||
modifier,
|
modifier,
|
||||||
pick,
|
pick,
|
||||||
drill,
|
drill,
|
||||||
|
pickWindow,
|
||||||
pointer: inputOptions.pointer,
|
pointer: inputOptions.pointer,
|
||||||
};
|
};
|
||||||
listeners.add(entry);
|
listeners.add(entry);
|
||||||
@@ -978,9 +987,20 @@ export function createFakeEngine(options = {}) {
|
|||||||
const at = event.position ?? event.endPosition;
|
const at = event.position ?? event.endPosition;
|
||||||
entry.listener({
|
entry.listener({
|
||||||
...event,
|
...event,
|
||||||
picked: engine.picker.pick(at),
|
picked: engine.picker.pick(
|
||||||
|
at,
|
||||||
|
entry.pickWindow?.width,
|
||||||
|
entry.pickWindow?.height,
|
||||||
|
),
|
||||||
...(entry.drill
|
...(entry.drill
|
||||||
? { drilled: engine.picker.drillPick(at, entry.drill) }
|
? {
|
||||||
|
drilled: engine.picker.drillPick(
|
||||||
|
at,
|
||||||
|
entry.drill,
|
||||||
|
entry.pickWindow?.width,
|
||||||
|
entry.pickWindow?.height,
|
||||||
|
),
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -998,6 +1018,7 @@ export function createFakeEngine(options = {}) {
|
|||||||
/** A live line's options, positions, colour, matrix and visibility, by id. */
|
/** A live line's options, positions, colour, matrix and visibility, by id. */
|
||||||
line: (id) => fakeLines.get(id),
|
line: (id) => fakeLines.get(id),
|
||||||
/** A live mover set's options, store and commit count, by id. */
|
/** A live mover set's options, store and commit count, by id. */
|
||||||
|
groundSet: (id) => groundRegistry.live.get(id),
|
||||||
groundPulse(id) {
|
groundPulse(id) {
|
||||||
return groundRegistry.pulses.get(id);
|
return groundRegistry.pulses.get(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -431,7 +431,40 @@ test('input listeners asking for picks get the pick and the drill in the payload
|
|||||||
TypeError,
|
TypeError,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => engine.input.on('down', () => {}, { pick: true }),
|
() => engine.input.on('up', () => {}, { pick: true }),
|
||||||
TypeError,
|
TypeError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('input rejects invalid pick windows and forwards copied windows on down and move', () => {
|
||||||
|
const engine = createFakeEngine();
|
||||||
|
for (const pickWindow of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ width: 0, height: 9 },
|
||||||
|
{ width: 9, height: 0.5 },
|
||||||
|
{ width: Infinity, height: 1 },
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() => engine.input.on('click', () => {}, { pick: true, pickWindow }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
engine.input.on('click', () => {}, {
|
||||||
|
pickWindow: { width: 9, height: 9 },
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
const picked = [];
|
||||||
|
engine.fake.script('pick', (at, w, h) => ({ id: `${w}:${h}` }));
|
||||||
|
const pickWindow = { width: 11, height: 7 };
|
||||||
|
engine.input.on('down', (event) => picked.push(event.picked.id), {
|
||||||
|
pick: true,
|
||||||
|
pickWindow,
|
||||||
|
});
|
||||||
|
pickWindow.width = 1;
|
||||||
|
engine.fake.input('down', { position: { x: 2, y: 3 } });
|
||||||
|
assert.deepEqual(picked, ['11:7']);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
@@ -56,8 +56,6 @@ const sameColor = (a, b) =>
|
|||||||
a.green === b.green &&
|
a.green === b.green &&
|
||||||
a.blue === b.blue &&
|
a.blue === b.blue &&
|
||||||
a.alpha === b.alpha;
|
a.alpha === b.alpha;
|
||||||
const gizmoDebug = () =>
|
|
||||||
typeof window !== 'undefined' && window.__gevGizmoDebug;
|
|
||||||
|
|
||||||
const scratchRotation = geo.matrix3.create();
|
const scratchRotation = geo.matrix3.create();
|
||||||
const scratchCorner = geo.cartesian3.create();
|
const scratchCorner = geo.cartesian3.create();
|
||||||
@@ -504,46 +502,12 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
|
|||||||
},
|
},
|
||||||
// Handles draw over the scene: the single pick usually finds one,
|
// Handles draw over the scene: the single pick usually finds one,
|
||||||
// and a drill covers what overlaps them.
|
// and a drill covers what overlaps them.
|
||||||
pickPart(position) {
|
pickPart(event) {
|
||||||
try {
|
return (
|
||||||
const part = partFrom(
|
partFrom(event.picked) ||
|
||||||
engine.picker.pick(position, GIZMO_PICK_PX, GIZMO_PICK_PX),
|
(event.drilled ?? []).map(partFrom).find(Boolean) ||
|
||||||
);
|
null
|
||||||
if (part) return part;
|
);
|
||||||
} catch (error) {
|
|
||||||
if (gizmoDebug())
|
|
||||||
console.debug('[CCTV:gizmo] pick threw:', error?.message || error);
|
|
||||||
}
|
|
||||||
let results = [];
|
|
||||||
try {
|
|
||||||
results =
|
|
||||||
engine.picker.drillPick(
|
|
||||||
position,
|
|
||||||
GIZMO_DRILL_LIMIT,
|
|
||||||
GIZMO_PICK_PX,
|
|
||||||
GIZMO_PICK_PX,
|
|
||||||
) || [];
|
|
||||||
} catch (error) {
|
|
||||||
if (gizmoDebug())
|
|
||||||
console.debug(
|
|
||||||
'[CCTV:gizmo] drillPick threw:',
|
|
||||||
error?.message || error,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (gizmoDebug())
|
|
||||||
console.debug(
|
|
||||||
'[CCTV:gizmo] drillPick @',
|
|
||||||
position?.x,
|
|
||||||
position?.y,
|
|
||||||
'→',
|
|
||||||
JSON.stringify(results.map((result) => String(result?.id))),
|
|
||||||
);
|
|
||||||
for (const picked of results) {
|
|
||||||
const part = partFrom(picked);
|
|
||||||
if (part) return part;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
pickRay(position) {
|
pickRay(position) {
|
||||||
try {
|
try {
|
||||||
@@ -556,7 +520,19 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
|
|||||||
on(type, listener) {
|
on(type, listener) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
if (type !== 'cancel') {
|
if (type !== 'cancel') {
|
||||||
gizmoRemovers.push(engine.input.on(type, listener));
|
gizmoRemovers.push(
|
||||||
|
engine.input.on(
|
||||||
|
type,
|
||||||
|
listener,
|
||||||
|
type === 'down' || type === 'move'
|
||||||
|
? {
|
||||||
|
pick: true,
|
||||||
|
drill: GIZMO_DRILL_LIMIT,
|
||||||
|
pickWindow: { width: GIZMO_PICK_PX, height: GIZMO_PICK_PX },
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = canvas();
|
const target = canvas();
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ for (const makeHarness of CCTV_HARNESSES) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(`gizmo input uses its window and drill to reach an overlapping handle (${name})`, (t) => {
|
||||||
|
const h = setup(makeHarness, t);
|
||||||
|
h.choose();
|
||||||
|
const calls = [];
|
||||||
|
const drill = h.engine.picker.drillPick;
|
||||||
|
h.engine.picker.pick = (at, width, height) => {
|
||||||
|
calls.push(['pick', width, height]);
|
||||||
|
return { id: 'foreign', owner: 'foreign' };
|
||||||
|
};
|
||||||
|
h.engine.picker.drillPick = (at, limit, width, height) => {
|
||||||
|
calls.push(['drill', limit, width, height]);
|
||||||
|
return drill(at, limit, width, height);
|
||||||
|
};
|
||||||
|
h.event('down');
|
||||||
|
assert.equal(h.gizmo.isDragging(), true);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['pick', 14, 14],
|
||||||
|
['drill', 6, 14, 14],
|
||||||
|
]);
|
||||||
|
h.event('up');
|
||||||
|
});
|
||||||
|
|
||||||
test(`the gizmo needs its own handle, not a sibling with its id (${name})`, (t) => {
|
test(`the gizmo needs its own handle, not a sibling with its id (${name})`, (t) => {
|
||||||
const h = setup(makeHarness, t);
|
const h = setup(makeHarness, t);
|
||||||
const sibling = h.engine.lines.createLine({
|
const sibling = h.engine.lines.createLine({
|
||||||
|
|||||||
@@ -91,9 +91,8 @@ export const LAUNCH_PAD_ZONE_RADIUS_M = 500;
|
|||||||
/**
|
/**
|
||||||
* The pad zone's look, as a fraction of the radius: a faint fill, a bright
|
* The pad zone's look, as a fraction of the radius: a faint fill, a bright
|
||||||
* rim between rimStart and rimEnd whose colour gains the emission, and a soft
|
* rim between rimStart and rimEnd whose colour gains the emission, and a soft
|
||||||
* edge from rimEnd to the radius. The display draws the rim (render/display.js
|
* edge from rimEnd to the radius. The ground disc drapes the whole zone
|
||||||
* padRimColor, PAD_RIM_RADIUS_M) as a ring on the terrain under the pad, over
|
* on terrain and tiles.
|
||||||
* the scene; the engine contract has no surface fill for the rest.
|
|
||||||
*/
|
*/
|
||||||
export const LAUNCH_PAD_ZONE_STYLE = Object.freeze({
|
export const LAUNCH_PAD_ZONE_STYLE = Object.freeze({
|
||||||
color: '#22e6e6',
|
color: '#22e6e6',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// engine, through the engine contract only: engine.movers (the launch-site,
|
// engine, through the engine contract only: engine.movers (the launch-site,
|
||||||
// recovery and payload dots, the hover reticle), engine.lines (trajectories,
|
// recovery and payload dots, the hover reticle), engine.lines (trajectories,
|
||||||
// recovery and re-entry paths, the transfer, the orbits in the tactical
|
// recovery and re-entry paths, the transfer, the orbits in the tactical
|
||||||
// pattern, the pad zone's rim), the selection dispatcher on engine.input
|
// pattern), engine.ground (the pad zones), the selection dispatcher on engine.input
|
||||||
// (clicks), engine.picker (the hover cursor), engine.projector and the frame
|
// (clicks), engine.picker (the hover cursor), engine.projector and the frame
|
||||||
// events. The layer keeps its own records and moves the camera itself
|
// events. The layer keeps its own records and moves the camera itself
|
||||||
// (engine.camera); the engine draws.
|
// (engine.camera); the engine draws.
|
||||||
@@ -25,50 +25,11 @@ const HOVER_PICK_INTERVAL_MS = 120;
|
|||||||
const PICK_DEPTH = 12;
|
const PICK_DEPTH = 12;
|
||||||
const RETICLE_SIZE_PX = 24;
|
const RETICLE_SIZE_PX = 24;
|
||||||
const RETICLE_CELL = 'reticle';
|
const RETICLE_CELL = 'reticle';
|
||||||
// The pad zone's rim: a closed ring through the middle of the style's rim
|
|
||||||
// band, in the rim's colour with its emission and alpha.
|
|
||||||
const PAD_RING_SEGMENTS = 128;
|
|
||||||
const PAD_RING_WIDTH_PX = 2;
|
|
||||||
|
|
||||||
const rgbaOf = (color) =>
|
const rgbaOf = (color) =>
|
||||||
Array.isArray(color)
|
Array.isArray(color)
|
||||||
? [...color]
|
? [...color]
|
||||||
: [color?.red ?? 1, color?.green ?? 1, color?.blue ?? 1, color?.alpha ?? 1];
|
: [color?.red ?? 1, color?.green ?? 1, color?.blue ?? 1, color?.alpha ?? 1];
|
||||||
|
|
||||||
/** The pad zone's rim colour: Cesium's rim emission over the zone colour. */
|
|
||||||
export function padRimColor() {
|
|
||||||
const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba();
|
|
||||||
const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission;
|
|
||||||
return [
|
|
||||||
Math.min(1, red * gain),
|
|
||||||
Math.min(1, green * gain),
|
|
||||||
Math.min(1, blue * gain),
|
|
||||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pad zone's rim radius: the middle of the style's rim band, metres. */
|
|
||||||
export const PAD_RIM_RADIUS_M =
|
|
||||||
(LAUNCH_PAD_ZONE_RADIUS_M *
|
|
||||||
(LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd)) /
|
|
||||||
2;
|
|
||||||
|
|
||||||
/** The pad zone's rim positions around `launch` at `heightM`. */
|
|
||||||
export function padRingPositions(launch, heightM = 0) {
|
|
||||||
const frame = geo.transforms.eastNorthUpToFixedFrame(
|
|
||||||
geo.cartesian3.fromDegrees(launch.lon, launch.lat, heightM),
|
|
||||||
geo.ellipsoid.WGS84,
|
|
||||||
);
|
|
||||||
const local = geo.cartesian3.create();
|
|
||||||
return Array.from({ length: PAD_RING_SEGMENTS + 1 }, (_, index) => {
|
|
||||||
const angle = (index / PAD_RING_SEGMENTS) * geo.math.TWO_PI;
|
|
||||||
local.x = PAD_RIM_RADIUS_M * Math.cos(angle);
|
|
||||||
local.y = PAD_RIM_RADIUS_M * Math.sin(angle);
|
|
||||||
local.z = 0;
|
|
||||||
return geo.matrix4.multiplyByPoint(frame, local, geo.cartesian3.create());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bound hover picking independently of precise click selection. A move inside
|
* Bound hover picking independently of precise click selection. A move inside
|
||||||
* the throttle window is not dropped: its position is kept and one trailing
|
* the throttle window is not dropped: its position is kept and one trailing
|
||||||
@@ -194,6 +155,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
|
|||||||
let lineSeq = 0;
|
let lineSeq = 0;
|
||||||
let frameNumber = 0;
|
let frameNumber = 0;
|
||||||
const removers = [];
|
const removers = [];
|
||||||
|
const pads = new Set();
|
||||||
|
|
||||||
// --- commits: once per frame listener, or in a microtask -----------------
|
// --- commits: once per frame listener, or in a microtask -----------------
|
||||||
const pending = new Set();
|
const pending = new Set();
|
||||||
@@ -425,6 +387,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
|
|||||||
next = Boolean(next) && !destroyed;
|
next = Boolean(next) && !destroyed;
|
||||||
if (next === visible) return;
|
if (next === visible) return;
|
||||||
visible = next;
|
visible = next;
|
||||||
|
for (const pad of pads) pad.refresh();
|
||||||
frame(() => {
|
frame(() => {
|
||||||
for (const bank of banks.values()) bank.setVisible(visible);
|
for (const bank of banks.values()) bank.setVisible(visible);
|
||||||
if (visible) for (const marker of markers) refresh(marker);
|
if (visible) for (const marker of markers) refresh(marker);
|
||||||
@@ -455,35 +418,39 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
|
|||||||
* modelMatrix?, depthFailAlpha? } (engine.lines options).
|
* modelMatrix?, depthFailAlpha? } (engine.lines options).
|
||||||
*/
|
*/
|
||||||
addLine: (options) => new LineHandle(options),
|
addLine: (options) => new LineHandle(options),
|
||||||
/**
|
/** The launch pad's radial fill, draped on terrain and tiles. */
|
||||||
* The pad zone of a launch: its rim on the terrain under the pad, drawn
|
|
||||||
* over the scene. Picks pass through it, as Cesium's clicks and hover
|
|
||||||
* passed over the zone.
|
|
||||||
*/
|
|
||||||
createPad(launch) {
|
createPad(launch) {
|
||||||
const terrain = engine.surface.terrainHeightSupported
|
const set = engine.ground.createSet({
|
||||||
? engine.surface.terrainHeight(
|
|
||||||
geo.cartographic.fromDegrees(launch.lon, launch.lat, 0),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const heightM = Number.isFinite(terrain) ? terrain : 0;
|
|
||||||
const ring = new LineHandle({
|
|
||||||
id: `rocket-pad-zone:${launch.id}`,
|
id: `rocket-pad-zone:${launch.id}`,
|
||||||
positions: padRingPositions(launch, heightM),
|
kind: 'disc',
|
||||||
widthPx: PAD_RING_WIDTH_PX,
|
|
||||||
color: padRimColor(),
|
|
||||||
depthFailAlpha: 1,
|
|
||||||
});
|
});
|
||||||
return {
|
const { color, ...radial } = LAUNCH_PAD_ZONE_STYLE;
|
||||||
heightM,
|
set.replace([
|
||||||
|
{
|
||||||
|
center: [launch.lon, launch.lat],
|
||||||
|
radiusM: LAUNCH_PAD_ZONE_RADIUS_M,
|
||||||
|
fill: rgbaFromCss(color).rgba(),
|
||||||
|
radial,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
set.commit();
|
||||||
|
let shown = true;
|
||||||
|
const pad = {
|
||||||
get show() {
|
get show() {
|
||||||
return ring.show;
|
return shown;
|
||||||
},
|
},
|
||||||
set show(value) {
|
set show(value) {
|
||||||
ring.show = value;
|
shown = value !== false;
|
||||||
|
pad.refresh();
|
||||||
|
},
|
||||||
|
refresh: () => set.show(visible && shown),
|
||||||
|
destroy() {
|
||||||
|
if (pads.delete(pad)) set.destroy();
|
||||||
},
|
},
|
||||||
destroy: () => ring.remove(),
|
|
||||||
};
|
};
|
||||||
|
pads.add(pad);
|
||||||
|
pad.refresh();
|
||||||
|
return pad;
|
||||||
},
|
},
|
||||||
project: (position, result) =>
|
project: (position, result) =>
|
||||||
engine.projector.worldToWindow(position, result),
|
engine.projector.worldToWindow(position, result),
|
||||||
@@ -531,6 +498,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
|
|||||||
banks.clear();
|
banks.clear();
|
||||||
pending.clear();
|
pending.clear();
|
||||||
for (const handle of [...lines]) handle.remove();
|
for (const handle of [...lines]) handle.remove();
|
||||||
|
for (const pad of [...pads]) pad.destroy();
|
||||||
pickIds.clear();
|
pickIds.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngin
|
|||||||
import { dashPattern } from '../../../globe/cesium/lines.js';
|
import { dashPattern } from '../../../globe/cesium/lines.js';
|
||||||
import { rgbaFromCss } from '../../shared/color.js';
|
import { rgbaFromCss } from '../../shared/color.js';
|
||||||
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
||||||
import {
|
import { createLaunchesDisplay } from './display.js';
|
||||||
PAD_RIM_RADIUS_M,
|
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
|
||||||
createLaunchesDisplay,
|
|
||||||
padRimColor,
|
|
||||||
} from './display.js';
|
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const reticle = { width: 48, height: 48, getContext: () => null };
|
const reticle = { width: 48, height: 48, getContext: () => null };
|
||||||
@@ -35,7 +32,19 @@ function fakeHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cesiumHarness() {
|
function cesiumHarness() {
|
||||||
const { engine, viewer, ...rest } = createCesiumMoversEngine();
|
const { engine, viewer, ...rest } = createCesiumMoversEngine({
|
||||||
|
engineOptions: {
|
||||||
|
groundFactories: {
|
||||||
|
fills: (options) => ({
|
||||||
|
...options,
|
||||||
|
ready: true,
|
||||||
|
destroy() {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
viewer.scene.globe.getHeight = () => 7;
|
viewer.scene.globe.getHeight = () => 7;
|
||||||
return {
|
return {
|
||||||
name: 'the Cesium engine',
|
name: 'the Cesium engine',
|
||||||
@@ -74,23 +83,6 @@ test('the display needs a globe engine', () => {
|
|||||||
assert.throws(() => createLaunchesDisplay(null), TypeError);
|
assert.throws(() => createLaunchesDisplay(null), TypeError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the pad zone's rim is the middle of the style's rim band in the rim's colour", () => {
|
|
||||||
near(
|
|
||||||
PAD_RIM_RADIUS_M,
|
|
||||||
LAUNCH_PAD_ZONE_RADIUS_M *
|
|
||||||
((LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd) / 2),
|
|
||||||
'radius',
|
|
||||||
);
|
|
||||||
const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba();
|
|
||||||
const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission;
|
|
||||||
assert.deepEqual(padRimColor(), [
|
|
||||||
Math.min(1, red * gain),
|
|
||||||
Math.min(1, green * gain),
|
|
||||||
Math.min(1, blue * gain),
|
|
||||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const makeHarness of HARNESSES) {
|
for (const makeHarness of HARNESSES) {
|
||||||
const { name } = makeHarness();
|
const { name } = makeHarness();
|
||||||
|
|
||||||
@@ -329,73 +321,6 @@ for (const makeHarness of HARNESSES) {
|
|||||||
h.destroy();
|
h.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`the pad zone is its rim on the terrain, drawn over the scene, not picked (${name})`, () => {
|
|
||||||
const h = setup(makeHarness);
|
|
||||||
const { engine, display } = h;
|
|
||||||
if (!h.cesium) engine.fake.script('terrainHeight', 7);
|
|
||||||
display.setVisible(true);
|
|
||||||
const zone = display.createPad({ id: 'a', lon: -80.6, lat: 28.6 });
|
|
||||||
assert.equal(zone.heightM, 7);
|
|
||||||
assert.equal(
|
|
||||||
engine.fake?.callsOf('surface.sampleHeight').length ?? 0,
|
|
||||||
0,
|
|
||||||
'no tile height is sampled',
|
|
||||||
);
|
|
||||||
const [listed] = engine.lines.list();
|
|
||||||
assert.equal(listed.points, 129, 'a closed ring');
|
|
||||||
const center = geo.cartesian3.fromDegrees(-80.6, 28.6, 7);
|
|
||||||
const ringPoint = (flat, index) =>
|
|
||||||
geo.cartesian3.create(
|
|
||||||
flat[index * 3],
|
|
||||||
flat[index * 3 + 1],
|
|
||||||
flat[index * 3 + 2],
|
|
||||||
);
|
|
||||||
if (h.cesium) {
|
|
||||||
const primitive = h.primitive(listed.id);
|
|
||||||
assert.equal(primitive.allowPicking, false);
|
|
||||||
const positions = primitive.geometryInstances.geometry._positions;
|
|
||||||
near(
|
|
||||||
Cesium.Cartesian3.distance(positions[0], center),
|
|
||||||
PAD_RIM_RADIUS_M,
|
|
||||||
'rim radius',
|
|
||||||
1e-3,
|
|
||||||
);
|
|
||||||
const { color } = primitive.appearance.material.uniforms;
|
|
||||||
near(color.alpha, LAUNCH_PAD_ZONE_STYLE.rimAlpha, 'rim alpha');
|
|
||||||
near(
|
|
||||||
primitive.depthFailAppearance.material.uniforms.color.alpha,
|
|
||||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
|
||||||
'drawn where the scene hides it',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const line = engine.fake.line(listed.id);
|
|
||||||
assert.equal(line.options.pickId, undefined);
|
|
||||||
assert.equal(line.options.depthFailAlpha, 1);
|
|
||||||
assert.deepEqual(line.options.color, padRimColor());
|
|
||||||
near(
|
|
||||||
geo.cartesian3.distance(ringPoint(line.positions, 0), center),
|
|
||||||
PAD_RIM_RADIUS_M,
|
|
||||||
'rim radius',
|
|
||||||
1e-3,
|
|
||||||
);
|
|
||||||
near(
|
|
||||||
geo.cartesian3.distance(ringPoint(line.positions, 64), center),
|
|
||||||
PAD_RIM_RADIUS_M,
|
|
||||||
'rim radius',
|
|
||||||
1e-3,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
zone.show = false;
|
|
||||||
assert.equal(engine.lines.list()[0].shown, false);
|
|
||||||
zone.show = true;
|
|
||||||
assert.equal(engine.lines.list()[0].shown, true);
|
|
||||||
zone.destroy();
|
|
||||||
zone.destroy();
|
|
||||||
assert.deepEqual(engine.lines.list(), []);
|
|
||||||
display.destroy();
|
|
||||||
h.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`teardown releases every set, line and frame listener (${name})`, async () => {
|
test(`teardown releases every set, line and frame listener (${name})`, async () => {
|
||||||
const h = setup(makeHarness);
|
const h = setup(makeHarness);
|
||||||
const { engine, display } = h;
|
const { engine, display } = h;
|
||||||
@@ -417,6 +342,7 @@ for (const makeHarness of HARNESSES) {
|
|||||||
display.destroy();
|
display.destroy();
|
||||||
display.destroy();
|
display.destroy();
|
||||||
assert.deepEqual(engine.movers.list(), []);
|
assert.deepEqual(engine.movers.list(), []);
|
||||||
|
assert.deepEqual(engine.ground.list(), []);
|
||||||
assert.deepEqual(engine.lines.list(), []);
|
assert.deepEqual(engine.lines.list(), []);
|
||||||
h.frame();
|
h.frame();
|
||||||
assert.equal(frames, 1);
|
assert.equal(frames, 1);
|
||||||
@@ -478,3 +404,54 @@ test("the hover cursor is the engine canvas's pointer over a mission", () => {
|
|||||||
display.destroy();
|
display.destroy();
|
||||||
engine.destroy();
|
engine.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const makeHarness of GROUND_HARNESSES) {
|
||||||
|
test(`launch pads retain radial fills, draping and lifecycle (${makeHarness.name})`, () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const display = createLaunchesDisplay(h.engine);
|
||||||
|
const pad = display.createPad({ id: 'a', lon: -80.6, lat: 28.6 });
|
||||||
|
const id = 'rocket-pad-zone:a';
|
||||||
|
const record = h.set(id);
|
||||||
|
const { color, ...radial } = LAUNCH_PAD_ZONE_STYLE;
|
||||||
|
assert.deepEqual(record.items, [
|
||||||
|
{
|
||||||
|
center: [-80.6, 28.6],
|
||||||
|
radiusM: LAUNCH_PAD_ZONE_RADIUS_M,
|
||||||
|
fill: rgbaFromCss(color).rgba(),
|
||||||
|
radial,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(record.options.drape, 'both');
|
||||||
|
assert.equal(record.options.pickable, false);
|
||||||
|
assert.equal(record.shown, false);
|
||||||
|
display.setVisible(true);
|
||||||
|
assert.equal(h.set(id).shown, true);
|
||||||
|
if (h.cesium) {
|
||||||
|
const [primitive] = h.primitives(id).fills;
|
||||||
|
assert.equal(
|
||||||
|
primitive.options.classificationType,
|
||||||
|
Cesium.ClassificationType.BOTH,
|
||||||
|
);
|
||||||
|
assert.equal(primitive.options.allowPicking, false);
|
||||||
|
const appearance = primitive.options.appearance;
|
||||||
|
assert.equal(appearance.flat, true);
|
||||||
|
assert.equal(appearance.renderState.polygonOffset.factor, -1);
|
||||||
|
for (const [key, value] of Object.entries(radial))
|
||||||
|
assert.equal(appearance.material.uniforms[key], value);
|
||||||
|
}
|
||||||
|
pad.show = false;
|
||||||
|
display.setVisible(false);
|
||||||
|
display.setVisible(true);
|
||||||
|
assert.equal(h.set(id).shown, false);
|
||||||
|
pad.show = true;
|
||||||
|
assert.equal(h.set(id).shown, true);
|
||||||
|
assert.equal(h.set(id).commits, 1);
|
||||||
|
pad.destroy();
|
||||||
|
pad.destroy();
|
||||||
|
assert.equal(h.set(id), undefined);
|
||||||
|
display.createPad({ id: 'b', lon: 1, lat: 2 });
|
||||||
|
display.destroy();
|
||||||
|
assert.equal(h.sets(), 0);
|
||||||
|
h.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,10 +30,12 @@ const WHITE = Object.freeze([1, 1, 1, 1]);
|
|||||||
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
||||||
*/
|
*/
|
||||||
const HORIZON_CULL_DEPTH_M = 1000;
|
const HORIZON_CULL_DEPTH_M = 1000;
|
||||||
const CULL_RADII = Object.freeze({
|
const HORIZON_OPTIONS = Object.freeze({
|
||||||
x: geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
ellipsoid: geo.ellipsoid.create(
|
||||||
y: geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
|
geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
||||||
z: geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M,
|
geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
|
||||||
|
geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const finitePoint = (point) =>
|
const finitePoint = (point) =>
|
||||||
@@ -48,28 +50,6 @@ export function lineColor(css, alpha) {
|
|||||||
return [rgba.red, rgba.green, rgba.blue, alpha ?? rgba.alpha];
|
return [rgba.red, rgba.green, rgba.blue, alpha ?? rgba.alpha];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether `position` is in front of the horizon seen from `eye`, for an
|
|
||||||
* ellipsoid HORIZON_CULL_DEPTH_M under WGS84 (Cesium's
|
|
||||||
* EllipsoidalOccluder.isPointVisible on that ellipsoid).
|
|
||||||
*/
|
|
||||||
export function aboveHorizon(eye, position) {
|
|
||||||
const cx = eye.x / CULL_RADII.x;
|
|
||||||
const cy = eye.y / CULL_RADII.y;
|
|
||||||
const cz = eye.z / CULL_RADII.z;
|
|
||||||
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
|
|
||||||
const vx = position.x / CULL_RADII.x - cx;
|
|
||||||
const vy = position.y / CULL_RADII.y - cy;
|
|
||||||
const vz = position.z / CULL_RADII.z - cz;
|
|
||||||
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
|
|
||||||
const occluded =
|
|
||||||
limbSquared < 0
|
|
||||||
? vtDotVc > 0
|
|
||||||
: vtDotVc > limbSquared &&
|
|
||||||
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared;
|
|
||||||
return !occluded;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scratchEnu = geo.matrix4.create();
|
const scratchEnu = geo.matrix4.create();
|
||||||
const scratchLocal = geo.cartesian3.create();
|
const scratchLocal = geo.cartesian3.create();
|
||||||
const scratchWorld = geo.cartesian3.create();
|
const scratchWorld = geo.cartesian3.create();
|
||||||
@@ -215,7 +195,6 @@ export function createStreetLevelDisplay(engine) {
|
|||||||
tiles = nowTiles;
|
tiles = nowTiles;
|
||||||
if (!rows.size && !dirty) return;
|
if (!rows.size && !dirty) return;
|
||||||
const pose = engine.camera.pose();
|
const pose = engine.camera.pose();
|
||||||
const eye = pose.position;
|
|
||||||
const signature = [
|
const signature = [
|
||||||
pose.position.x,
|
pose.position.x,
|
||||||
pose.position.y,
|
pose.position.y,
|
||||||
@@ -260,7 +239,10 @@ export function createStreetLevelDisplay(engine) {
|
|||||||
color: WHITE,
|
color: WHITE,
|
||||||
cell: 'glyph',
|
cell: 'glyph',
|
||||||
pickId: row.pickId,
|
pickId: row.pickId,
|
||||||
show: aboveHorizon(eye, row.position),
|
show: engine.projector.horizonVisible(
|
||||||
|
row.position,
|
||||||
|
HORIZON_OPTIONS,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
bank.setCount(group.length);
|
bank.setCount(group.length);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as geo from '../../../geo/index.js';
|
import * as geo from '../../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
aboveHorizon,
|
|
||||||
createStreetLevelDisplay,
|
createStreetLevelDisplay,
|
||||||
lineColor,
|
lineColor,
|
||||||
screenRotation,
|
screenRotation,
|
||||||
@@ -314,31 +313,6 @@ test('a bearing straight into the screen keeps the last rotation', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const at = (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height);
|
|
||||||
|
|
||||||
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
|
|
||||||
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
|
|
||||||
assert.equal(
|
|
||||||
aboveHorizon(at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22)),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
// Colombo framing view: camera at -21 m, cones at -95 m about 80 m away.
|
|
||||||
const colombo = at(79.8612, 6.9271, -21);
|
|
||||||
assert.equal(aboveHorizon(colombo, at(79.8612, 6.92782, -95)), true);
|
|
||||||
assert.equal(aboveHorizon(colombo, at(79.86192, 6.9271, -95)), true);
|
|
||||||
// Austin, ground well above the ellipsoid.
|
|
||||||
assert.equal(
|
|
||||||
aboveHorizon(at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130)),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the horizon cull still hides the far side of the Earth from orbit', () => {
|
|
||||||
const orbit = at(0, 0, 20_000_000);
|
|
||||||
assert.equal(aboveHorizon(orbit, at(10, 10, 0)), true);
|
|
||||||
assert.equal(aboveHorizon(orbit, at(180, 0, 0)), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('destroying the display takes everything it still draws', () => {
|
test('destroying the display takes everything it still draws', () => {
|
||||||
const harness = streetLevelMap({ view: true });
|
const harness = streetLevelMap({ view: true });
|
||||||
harness.map.sequenceLines([{ id: 'a', parts: [line(0)] }], STYLE);
|
harness.map.sequenceLines([{ id: 'a', parts: [line(0)] }], STYLE);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One click handler for every provider's lines and cones, and the marker,
|
* One click handler for every provider's lines and cones, and the marker,
|
||||||
* on the display's engine (engine.input for the click, engine.picker for
|
* on the display's engine, with a wider input pick window for thin lines.
|
||||||
* the pick: the input's own pick has no pick window).
|
|
||||||
*/
|
*/
|
||||||
export function createSelection({ state, parts }) {
|
export function createSelection({ state, parts }) {
|
||||||
const { picking, input } = state.services;
|
const { picking, input } = state.services;
|
||||||
@@ -14,11 +13,7 @@ export function createSelection({ state, parts }) {
|
|||||||
const engine = state.map?.engine;
|
const engine = state.map?.engine;
|
||||||
if (!engine || !state.enabled || !click?.position) return;
|
if (!engine || !state.enabled || !click?.position) return;
|
||||||
if (input?.isPointerFree && !input.isPointerFree()) return;
|
if (input?.isPointerFree && !input.isPointerFree()) return;
|
||||||
const picked = engine.picker.pick(
|
const picked = click.picked;
|
||||||
click.position,
|
|
||||||
CLICK_PICK_WINDOW_PX,
|
|
||||||
CLICK_PICK_WINDOW_PX,
|
|
||||||
);
|
|
||||||
const id = picking?.resolvePickId
|
const id = picking?.resolvePickId
|
||||||
? picking.resolvePickId(picked)
|
? picking.resolvePickId(picked)
|
||||||
: picked?.id;
|
: picked?.id;
|
||||||
@@ -72,7 +67,10 @@ export function createSelection({ state, parts }) {
|
|||||||
|
|
||||||
function install(map) {
|
function install(map) {
|
||||||
if (state.clickHandler || !map?.engine) return;
|
if (state.clickHandler || !map?.engine) return;
|
||||||
state.clickHandler = map.engine.input.on('click', onClick);
|
state.clickHandler = map.engine.input.on('click', onClick, {
|
||||||
|
pick: true,
|
||||||
|
pickWindow: { width: CLICK_PICK_WINDOW_PX, height: CLICK_PICK_WINDOW_PX },
|
||||||
|
});
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
picking?.registerPickOwner?.(
|
picking?.registerPickOwner?.(
|
||||||
STREET_LEVEL_LAYER_ID,
|
STREET_LEVEL_LAYER_ID,
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ test('a click picks in a window a few pixels wide, through engine.input and engi
|
|||||||
const { fake } = h.map.engine;
|
const { fake } = h.map.engine;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
fake.callsOf('input.on').map(({ args }) => args),
|
fake.callsOf('input.on').map(({ args }) => args),
|
||||||
[['click', {}]],
|
[['click', { pick: true, pickWindow: { width: 9, height: 9 } }]],
|
||||||
'one click listener, without its own pick',
|
'one click listener with its pick window',
|
||||||
);
|
);
|
||||||
h.scene.under = 'mly:seq:abc';
|
h.scene.under = 'mly:seq:abc';
|
||||||
h.click(30);
|
h.click(30);
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ test('disable aborts in-flight work and a response that lands late is ignored',
|
|||||||
'a poll from the disabled generation never lands',
|
'a poll from the disabled generation never lands',
|
||||||
);
|
);
|
||||||
assert.equal(app.state()._inFlight.size, 0);
|
assert.equal(app.state()._inFlight.size, 0);
|
||||||
|
assert.deepEqual(app.engine.ground.list(), []);
|
||||||
assert.equal(app.holds().includes('transit'), false);
|
assert.equal(app.holds().includes('transit'), false);
|
||||||
|
|
||||||
// Re-enabling starts a clean generation and does take data.
|
// Re-enabling starts a clean generation and does take data.
|
||||||
@@ -3184,7 +3185,7 @@ for (const count of [800, 3000]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('click handler selects a migrated scripted billboard and prepares a valid depth-fail trail', async (t) => {
|
test('click handler selects a migrated scripted billboard and prepares a draped trail', async (t) => {
|
||||||
const app = harness(t, { floorAt: () => undefined });
|
const app = harness(t, { floorAt: () => undefined });
|
||||||
app.layer.enable(app.viewer);
|
app.layer.enable(app.viewer);
|
||||||
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
|
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
|
||||||
@@ -3199,11 +3200,12 @@ test('click handler selects a migrated scripted billboard and prepares a valid d
|
|||||||
const { body } = app.layer._transitPartsForTest().trails.diagnostics();
|
const { body } = app.layer._transitPartsForTest().trails.diagnostics();
|
||||||
assert.ok(body.lines > 0);
|
assert.ok(body.lines > 0);
|
||||||
app.frame();
|
app.frame();
|
||||||
// Where the scene hides the trail, its colour lane still draws, dimmed.
|
const lanes = app.engine.ground
|
||||||
const lanes = app.engine.lines
|
|
||||||
.list()
|
.list()
|
||||||
.map(({ id }) => app.engine.fake.line(id));
|
.map(({ id }) => app.engine.fake.groundSet(id));
|
||||||
assert.ok(lanes.some((line) => line.options.depthFailAlpha > 0));
|
assert.ok(lanes.length > 0);
|
||||||
|
assert.ok(lanes.every((lane) => lane.options.drape === 'both'));
|
||||||
|
assert.deepEqual(app.engine.lines.list(), []);
|
||||||
assert.ok(entry.trailSegments.length > 0);
|
assert.ok(entry.trailSegments.length > 0);
|
||||||
app.run(1000, 16);
|
app.run(1000, 16);
|
||||||
assert.equal(entry.marker.show, true);
|
assert.equal(entry.marker.show, true);
|
||||||
@@ -3839,7 +3841,7 @@ test('selected history is prepared while its head is hidden and remains shown ou
|
|||||||
app.state()._vehicles.set(entry.key, entry);
|
app.state()._vehicles.set(entry.key, entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selected history waits for its surface heights: lines are not draped', async (t) => {
|
test('selected history drapes before floors resolve while marker corridors still wait', async (t) => {
|
||||||
const app = harness(t, { floorAt: () => undefined });
|
const app = harness(t, { floorAt: () => undefined });
|
||||||
app.layer.enable(app.viewer);
|
app.layer.enable(app.viewer);
|
||||||
app.layer._loadTransitFleetForTest(1, BOSTON);
|
app.layer._loadTransitFleetForTest(1, BOSTON);
|
||||||
@@ -3850,9 +3852,9 @@ test('selected history waits for its surface heights: lines are not draped', asy
|
|||||||
entry.heightPending = true;
|
entry.heightPending = true;
|
||||||
parts.selection.selectVehicle(entry.key);
|
parts.selection.selectVehicle(entry.key);
|
||||||
const trail = parts.trails.diagnostics();
|
const trail = parts.trails.diagnostics();
|
||||||
assert.equal(trail.ground, false);
|
assert.equal(trail.ground, true);
|
||||||
assert.equal(trail.segments, 0, 'no history before its floors resolve');
|
assert.ok(trail.segments > 0, 'ground history does not wait for floors');
|
||||||
assert.equal(trail.body, null);
|
assert.ok(trail.body);
|
||||||
assert.deepEqual(app.engine.lines.list(), []);
|
assert.deepEqual(app.engine.lines.list(), []);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
entry.displayPaths.size,
|
entry.displayPaths.size,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// (position, image, width, color, rotation, show); the writes of a frame
|
// (position, image, width, color, rotation, show); the writes of a frame
|
||||||
// are committed once, at the end of the frame listener that made them (in
|
// are committed once, at the end of the frame listener that made them (in
|
||||||
// a microtask for writes outside a frame);
|
// a microtask for writes outside a frame);
|
||||||
// - the selected vehicle's trail: engine.lines, its completed history in
|
// - the selected vehicle's trail: engine.ground, its completed history in
|
||||||
// alpha bands by age and its live head up to the marker;
|
// alpha bands by age and its live head up to the marker;
|
||||||
// - the camera, surface and credits through engine.camera, engine.surface
|
// - the camera, surface and credits through engine.camera, engine.surface
|
||||||
// and engine.credits; clicks through engine.input and engine.picker.
|
// and engine.credits; clicks through engine.input and engine.picker.
|
||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
TRAIL_BACKING_WIDTH_PX,
|
TRAIL_BACKING_WIDTH_PX,
|
||||||
TRAIL_DEFAULT_CSS,
|
TRAIL_DEFAULT_CSS,
|
||||||
TRAIL_HEAD_ALPHA,
|
TRAIL_HEAD_ALPHA,
|
||||||
TRAIL_OCCLUDED_ALPHA,
|
|
||||||
TRAIL_WIDTH_PX,
|
TRAIL_WIDTH_PX,
|
||||||
markerOrder,
|
markerOrder,
|
||||||
trailAlpha,
|
trailAlpha,
|
||||||
@@ -316,7 +315,7 @@ const samePosition = (a, b) =>
|
|||||||
(a === b || geo.cartesian3.distanceSquared(a, b) < JOIN_DISTANCE_SQ);
|
(a === b || geo.cartesian3.distanceSquared(a, b) < JOIN_DISTANCE_SQ);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The selected vehicle's trail on engine.lines. The history is the
|
* The selected vehicle's trail on engine.ground. The history is the
|
||||||
* layer's prepared segments ({ fromT, toT, fromSeq, toSeq, positions: [a,
|
* layer's prepared segments ({ fromT, toT, fromSeq, toSeq, positions: [a,
|
||||||
* b] }, in time order); the segments the display clock has passed draw in
|
* b] }, in time order); the segments the display clock has passed draw in
|
||||||
* two lanes (a dark edge under the colour), one line per lane for each run
|
* two lanes (a dark edge under the colour), one line per lane for each run
|
||||||
@@ -346,37 +345,53 @@ function createTrail(engine, { id }) {
|
|||||||
positions: [],
|
positions: [],
|
||||||
width: TRAIL_BACKING_WIDTH_PX,
|
width: TRAIL_BACKING_WIDTH_PX,
|
||||||
};
|
};
|
||||||
const lineOf = (widthPx, color, depthFailAlpha, order) => {
|
const lineOf = (widthPx, color, order, reveal = false, backing) => {
|
||||||
const line = engine.lines.createLine({
|
const set = engine.ground.createSet({
|
||||||
id: `${id}-${++seq}`,
|
id: `${id}-${++seq}`,
|
||||||
widthPx,
|
kind: 'corridor',
|
||||||
color,
|
dynamic: true,
|
||||||
depthFailAlpha,
|
|
||||||
order,
|
order,
|
||||||
|
reveal,
|
||||||
});
|
});
|
||||||
line.show(visible);
|
let positions = [];
|
||||||
return line;
|
const commit = () => {
|
||||||
|
set.replace(
|
||||||
|
positions.length
|
||||||
|
? [{ positions, widthPx, color, ...(backing ? { backing } : {}) }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
set.commit();
|
||||||
|
};
|
||||||
|
set.show(visible);
|
||||||
|
return {
|
||||||
|
show: set.show,
|
||||||
|
destroy: set.destroy,
|
||||||
|
setReveal: set.setReveal,
|
||||||
|
setPositions(points) {
|
||||||
|
positions = points.map((point) => {
|
||||||
|
const carto = geo.cartographic.fromCartesian(point);
|
||||||
|
return [
|
||||||
|
geo.math.toDegrees(carto.longitude),
|
||||||
|
geo.math.toDegrees(carto.latitude),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
commit();
|
||||||
|
},
|
||||||
|
setColor(next) {
|
||||||
|
color = next;
|
||||||
|
commit();
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const laneColor = (alpha) => [...rgb, alpha];
|
const laneColor = (alpha) => [...rgb, alpha];
|
||||||
const backingColor = (alpha) => [...TRAIL_BACKING_RGB, alpha];
|
const backingColor = (alpha) => [...TRAIL_BACKING_RGB, alpha];
|
||||||
// Where the scene hides the trail, its colour at TRAIL_OCCLUDED_ALPHA.
|
|
||||||
const occluded = (alpha) => Math.min(1, TRAIL_OCCLUDED_ALPHA / alpha);
|
|
||||||
let headLines = null;
|
let headLines = null;
|
||||||
const ensureHead = () =>
|
const ensureHead = () =>
|
||||||
(headLines ??= {
|
(headLines ??= {
|
||||||
backing: lineOf(
|
line: lineOf(TRAIL_WIDTH_PX, laneColor(TRAIL_HEAD_ALPHA), 3, true, {
|
||||||
TRAIL_BACKING_WIDTH_PX,
|
widthPx: TRAIL_BACKING_WIDTH_PX,
|
||||||
backingColor(TRAIL_HEAD_ALPHA),
|
color: backingColor(TRAIL_HEAD_ALPHA),
|
||||||
0,
|
}),
|
||||||
2,
|
|
||||||
),
|
|
||||||
line: lineOf(
|
|
||||||
TRAIL_WIDTH_PX,
|
|
||||||
laneColor(TRAIL_HEAD_ALPHA),
|
|
||||||
occluded(TRAIL_HEAD_ALPHA),
|
|
||||||
3,
|
|
||||||
),
|
|
||||||
drawn: [],
|
|
||||||
});
|
});
|
||||||
const dropGroup = (group) => {
|
const dropGroup = (group) => {
|
||||||
group.backing.destroy();
|
group.backing.destroy();
|
||||||
@@ -417,8 +432,8 @@ function createTrail(engine, { id }) {
|
|||||||
const alpha = trailBandAlpha(level);
|
const alpha = trailBandAlpha(level);
|
||||||
group = {
|
group = {
|
||||||
level,
|
level,
|
||||||
backing: lineOf(TRAIL_BACKING_WIDTH_PX, backingColor(alpha), 0, 0),
|
backing: lineOf(TRAIL_BACKING_WIDTH_PX, backingColor(alpha), 0),
|
||||||
line: lineOf(TRAIL_WIDTH_PX, laneColor(alpha), occluded(alpha), 1),
|
line: lineOf(TRAIL_WIDTH_PX, laneColor(alpha), 1),
|
||||||
from: -1,
|
from: -1,
|
||||||
to: -1,
|
to: -1,
|
||||||
};
|
};
|
||||||
@@ -439,7 +454,6 @@ function createTrail(engine, { id }) {
|
|||||||
function hideHead() {
|
function hideHead() {
|
||||||
head.show = backing.show = false;
|
head.show = backing.show = false;
|
||||||
if (headLines) {
|
if (headLines) {
|
||||||
headLines.backing.show(false);
|
|
||||||
headLines.line.show(false);
|
headLines.line.show(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,22 +515,23 @@ function createTrail(engine, { id }) {
|
|||||||
hideHead();
|
hideHead();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
active = segment;
|
|
||||||
const lines = ensureHead();
|
const lines = ensureHead();
|
||||||
lines.backing.show(true);
|
|
||||||
lines.line.show(true);
|
lines.line.show(true);
|
||||||
const positions = [segment.positions[0], renderedPosition];
|
const positions = [segment.positions[0], renderedPosition];
|
||||||
head.positions = backing.positions = positions;
|
head.positions = backing.positions = positions;
|
||||||
if (
|
if (active !== segment) {
|
||||||
lines.drawn.length !== 2 ||
|
active = segment;
|
||||||
!geo.cartesian3.equals(lines.drawn[0], positions[0]) ||
|
lines.line.setPositions(segment.positions);
|
||||||
!geo.cartesian3.equals(lines.drawn[1], positions[1])
|
|
||||||
) {
|
|
||||||
lines.drawn = positions.map((p) => geo.cartesian3.clone(p));
|
|
||||||
lines.backing.setPositions(lines.drawn);
|
|
||||||
lines.line.setPositions(lines.drawn);
|
|
||||||
headRebuilds++;
|
headRebuilds++;
|
||||||
}
|
}
|
||||||
|
const fraction = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
lines.line.setReveal(fraction);
|
||||||
},
|
},
|
||||||
setStyle(next) {
|
setStyle(next) {
|
||||||
if (destroyed || next === css) return;
|
if (destroyed || next === css) return;
|
||||||
@@ -536,7 +551,6 @@ function createTrail(engine, { id }) {
|
|||||||
group.line.show(next);
|
group.line.show(next);
|
||||||
}
|
}
|
||||||
const headShown = next && head.show;
|
const headShown = next && head.show;
|
||||||
headLines?.backing.show(headShown);
|
|
||||||
headLines?.line.show(headShown);
|
headLines?.line.show(headShown);
|
||||||
engine.render.request('transit-trail-visible');
|
engine.render.request('transit-trail-visible');
|
||||||
},
|
},
|
||||||
@@ -545,7 +559,6 @@ function createTrail(engine, { id }) {
|
|||||||
destroyed = true;
|
destroyed = true;
|
||||||
clearBody();
|
clearBody();
|
||||||
if (headLines) {
|
if (headLines) {
|
||||||
headLines.backing.destroy();
|
|
||||||
headLines.line.destroy();
|
headLines.line.destroy();
|
||||||
headLines = null;
|
headLines = null;
|
||||||
}
|
}
|
||||||
@@ -561,8 +574,8 @@ function createTrail(engine, { id }) {
|
|||||||
backing,
|
backing,
|
||||||
headPrimitive: null,
|
headPrimitive: null,
|
||||||
active,
|
active,
|
||||||
ground: false,
|
ground: true,
|
||||||
headStrategy: 'engine lines: age bands and a head to the marker',
|
headStrategy: 'ground corridors: age bands and a segment reveal head',
|
||||||
headRebuilds,
|
headRebuilds,
|
||||||
entitiesAdded: 0,
|
entitiesAdded: 0,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -67,12 +67,28 @@ function fakeHarness(options = {}) {
|
|||||||
|
|
||||||
function cesiumHarness() {
|
function cesiumHarness() {
|
||||||
const handlers = createFakeEventHandlers();
|
const handlers = createFakeEventHandlers();
|
||||||
|
const groundPrimitives = [];
|
||||||
|
const groundPrimitive = (options) => {
|
||||||
|
const primitive = {
|
||||||
|
...options,
|
||||||
|
ready: true,
|
||||||
|
destroy() {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
groundPrimitives.push(primitive);
|
||||||
|
return primitive;
|
||||||
|
};
|
||||||
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
|
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
|
||||||
engineOptions: {
|
engineOptions: {
|
||||||
createEventHandler: handlers.create,
|
createEventHandler: handlers.create,
|
||||||
scheduleFrame: (run) => run(),
|
scheduleFrame: (run) => run(),
|
||||||
|
groundFactories: {
|
||||||
|
lines: groundPrimitive,
|
||||||
|
fills: groundPrimitive,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
let native;
|
let native;
|
||||||
let picked = [];
|
let picked = [];
|
||||||
viewer.scene.pick = (position) => {
|
viewer.scene.pick = (position) => {
|
||||||
@@ -86,6 +102,7 @@ function cesiumHarness() {
|
|||||||
cesium: true,
|
cesium: true,
|
||||||
engine,
|
engine,
|
||||||
viewer,
|
viewer,
|
||||||
|
groundPrimitives,
|
||||||
frame,
|
frame,
|
||||||
primitive,
|
primitive,
|
||||||
picked: () => picked,
|
picked: () => picked,
|
||||||
@@ -139,6 +156,40 @@ function setup(
|
|||||||
{ vehicles = new Map(), credits = [], mesh = [] } = {},
|
{ vehicles = new Map(), credits = [], mesh = [] } = {},
|
||||||
) {
|
) {
|
||||||
const h = makeHarness();
|
const h = makeHarness();
|
||||||
|
const groundRecords = new Map();
|
||||||
|
const createSet = h.engine.ground.createSet;
|
||||||
|
h.engine.ground.createSet = (options) => {
|
||||||
|
const set = createSet(options);
|
||||||
|
const record = { options, commits: 0, fraction: 1 };
|
||||||
|
groundRecords.set(options.id, record);
|
||||||
|
const { replace, commit, setReveal } = set;
|
||||||
|
set.replace = (items) => {
|
||||||
|
record.items = items;
|
||||||
|
replace(items);
|
||||||
|
};
|
||||||
|
set.commit = () => {
|
||||||
|
record.commits++;
|
||||||
|
commit();
|
||||||
|
};
|
||||||
|
set.setReveal = (fraction) => {
|
||||||
|
record.fraction = fraction;
|
||||||
|
setReveal(fraction);
|
||||||
|
};
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
h.trailLines = () =>
|
||||||
|
h.engine.ground.list().map((set) => ({
|
||||||
|
...set,
|
||||||
|
points: groundRecords.get(set.id).items?.[0]?.positions.length ?? 0,
|
||||||
|
}));
|
||||||
|
h.trailLine = (id) => {
|
||||||
|
const record = groundRecords.get(id);
|
||||||
|
return {
|
||||||
|
...record.items[0],
|
||||||
|
...record,
|
||||||
|
points: record.items[0].positions.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
h.engine.camera.setView({
|
h.engine.camera.setView({
|
||||||
destination: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2000),
|
destination: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2000),
|
||||||
});
|
});
|
||||||
@@ -559,7 +610,7 @@ for (const makeHarness of HARNESSES) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
f.engine.lines.list(),
|
f.trailLines(),
|
||||||
[],
|
[],
|
||||||
'nothing before the clock passes a segment',
|
'nothing before the clock passes a segment',
|
||||||
);
|
);
|
||||||
@@ -571,11 +622,19 @@ for (const makeHarness of HARNESSES) {
|
|||||||
);
|
);
|
||||||
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
|
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
|
||||||
for (let i = 0; i < 120; i++) trail.setDisplaySample(sample, marker);
|
for (let i = 0; i < 120; i++) trail.setDisplaySample(sample, marker);
|
||||||
const lines = f.engine.lines.list();
|
const lines = f.trailLines();
|
||||||
// The first segment (two lanes) and the head (two lanes).
|
// The first segment (two lanes) and one backed reveal head.
|
||||||
assert.equal(lines.length, 4);
|
assert.equal(lines.length, 3);
|
||||||
const lanes = lines.map(({ id }) => ({ id, ...f.line(id) }));
|
const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) }));
|
||||||
const [bodyBacking, bodyLane, headBacking, headLane] = lanes;
|
assert.ok(lanes.every((lane) => lane.options.dynamic));
|
||||||
|
if (f.cesium) {
|
||||||
|
assert.equal(f.groundPrimitives.length, 3);
|
||||||
|
assert.ok(
|
||||||
|
f.groundPrimitives.every((primitive) => !primitive.asynchronous),
|
||||||
|
'history and head build immediately without worker jobs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [bodyBacking, bodyLane, headLane] = lanes;
|
||||||
assert.equal(bodyBacking.points, 2);
|
assert.equal(bodyBacking.points, 2);
|
||||||
assert.equal(bodyLane.points, 2);
|
assert.equal(bodyLane.points, 2);
|
||||||
near(bodyBacking.color[0], 0.02, 'dark edge');
|
near(bodyBacking.color[0], 0.02, 'dark edge');
|
||||||
@@ -587,18 +646,13 @@ for (const makeHarness of HARNESSES) {
|
|||||||
'age alpha',
|
'age alpha',
|
||||||
);
|
);
|
||||||
near(headLane.color[3], 0.8, 'head alpha');
|
near(headLane.color[3], 0.8, 'head alpha');
|
||||||
near(headLane.depthFailAlpha, 0.55 / 0.8, 'hidden head at 0.55');
|
assert.deepEqual(headLane.backing, {
|
||||||
near(
|
widthPx: 5,
|
||||||
bodyLane.depthFailAlpha * bodyLane.color[3],
|
color: [0.02, 0.03, 0.05, 0.8],
|
||||||
0.55,
|
});
|
||||||
'hidden body at 0.55',
|
assert.equal(headLane.fraction, 0.5);
|
||||||
1e-3,
|
assert.equal(headLane.commits, 1, 'head geometry built once');
|
||||||
);
|
assert.equal(headLane.options.pickable, undefined, 'picks pass through');
|
||||||
assert.equal(
|
|
||||||
headBacking.depthFailAlpha,
|
|
||||||
0,
|
|
||||||
'the dark edge does not draw hidden',
|
|
||||||
);
|
|
||||||
if (!f.cesium) {
|
if (!f.cesium) {
|
||||||
assert.equal(bodyBacking.widthPx, 5);
|
assert.equal(bodyBacking.widthPx, 5);
|
||||||
assert.equal(bodyLane.widthPx, 3);
|
assert.equal(bodyLane.widthPx, 3);
|
||||||
@@ -614,20 +668,23 @@ for (const makeHarness of HARNESSES) {
|
|||||||
assert.equal(result.backing.width, 5);
|
assert.equal(result.backing.width, 5);
|
||||||
assert.equal(result.head.positions.length, 2);
|
assert.equal(result.head.positions.length, 2);
|
||||||
assert.ok(geo.cartesian3.equals(result.head.positions.at(-1), marker));
|
assert.ok(geo.cartesian3.equals(result.head.positions.at(-1), marker));
|
||||||
assert.equal(result.ground, false);
|
assert.equal(result.ground, true);
|
||||||
|
trail.setDisplaySample({ ...sample, displayT: 16000 }, marker);
|
||||||
|
assert.equal(f.trailLine(headLane.id).commits, 1);
|
||||||
|
assert.equal(f.trailLine(headLane.id).fraction, 0.6);
|
||||||
// Rewind: the passed segment is future again.
|
// Rewind: the passed segment is future again.
|
||||||
trail.setDisplaySample(
|
trail.setDisplaySample(
|
||||||
{ ...sample, displayT: 5000, fraction: 0.25 },
|
{ ...sample, displayT: 5000, fraction: 0.25 },
|
||||||
positions[0],
|
positions[0],
|
||||||
);
|
);
|
||||||
assert.equal(f.engine.lines.list().length, 2, 'only the head');
|
assert.equal(f.trailLines().length, 1, 'only the head');
|
||||||
// Past the end the head goes too; long after, both segments share the
|
// Past the end the head goes too; long after, both segments share the
|
||||||
// oldest band.
|
// oldest band.
|
||||||
trail.setDisplaySample(
|
trail.setDisplaySample(
|
||||||
{ ...sample, displayT: 2_000_000, fraction: 1, fromSeq: 1, toSeq: 1 },
|
{ ...sample, displayT: 2_000_000, fraction: 1, fromSeq: 1, toSeq: 1 },
|
||||||
positions[2],
|
positions[2],
|
||||||
);
|
);
|
||||||
const done = f.engine.lines.list();
|
const done = f.trailLines();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
done.filter((line) => line.shown).length,
|
done.filter((line) => line.shown).length,
|
||||||
2,
|
2,
|
||||||
@@ -639,11 +696,11 @@ for (const makeHarness of HARNESSES) {
|
|||||||
'joined segments are one line',
|
'joined segments are one line',
|
||||||
);
|
);
|
||||||
trail.setVisible(false);
|
trail.setVisible(false);
|
||||||
assert.ok(f.engine.lines.list().every((line) => !line.shown));
|
assert.ok(f.trailLines().every((line) => !line.shown));
|
||||||
trail.setVisible(true);
|
trail.setVisible(true);
|
||||||
trail.destroy();
|
trail.destroy();
|
||||||
trail.destroy();
|
trail.destroy();
|
||||||
assert.deepEqual(f.engine.lines.list(), []);
|
assert.deepEqual(f.trailLines(), []);
|
||||||
f.display.destroy();
|
f.display.destroy();
|
||||||
f.engine.destroy();
|
f.engine.destroy();
|
||||||
});
|
});
|
||||||
@@ -666,7 +723,7 @@ for (const makeHarness of HARNESSES) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
trail.replaceHistory({ revision: 1, segments });
|
trail.replaceHistory({ revision: 1, segments });
|
||||||
const shownLines = () => f.engine.lines.list().filter((line) => line.shown);
|
const shownLines = () => f.trailLines().filter((line) => line.shown);
|
||||||
// Ten minutes on, the passed history spans several bands; across the gap
|
// Ten minutes on, the passed history spans several bands; across the gap
|
||||||
// it is two runs.
|
// it is two runs.
|
||||||
const display = (displayT) =>
|
const display = (displayT) =>
|
||||||
@@ -678,7 +735,7 @@ for (const makeHarness of HARNESSES) {
|
|||||||
const bands = new Set();
|
const bands = new Set();
|
||||||
let points = 0;
|
let points = 0;
|
||||||
for (const { id, points: count } of shownLines()) {
|
for (const { id, points: count } of shownLines()) {
|
||||||
const { color } = f.line(id);
|
const { color } = f.trailLine(id);
|
||||||
if (color[0] > 0.1) {
|
if (color[0] > 0.1) {
|
||||||
bands.add(color[3]);
|
bands.add(color[3]);
|
||||||
points += count;
|
points += count;
|
||||||
@@ -686,18 +743,18 @@ for (const makeHarness of HARNESSES) {
|
|||||||
}
|
}
|
||||||
assert.ok(bands.size >= 1);
|
assert.ok(bands.size >= 1);
|
||||||
assert.equal(points, 101 + 11 + (bands.size - 1) * 1, 'each band one span');
|
assert.equal(points, 101 + 11 + (bands.size - 1) * 1, 'each band one span');
|
||||||
const listed = f.engine.lines.list().map(({ id }) => id);
|
const listed = f.trailLines().map(({ id }) => id);
|
||||||
// Within the same second nothing is rewritten.
|
// Within the same second nothing is rewritten.
|
||||||
display(55000 + 120400);
|
display(55000 + 120400);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
f.engine.lines.list().map(({ id }) => id),
|
f.trailLines().map(({ id }) => id),
|
||||||
listed,
|
listed,
|
||||||
);
|
);
|
||||||
// A new style recolours the colour lanes, not the dark edges.
|
// A new style recolours the colour lanes, not the dark edges.
|
||||||
trail.setStyle('#FF4538');
|
trail.setStyle('#FF4538');
|
||||||
const red = rgbaFromCss('#FF4538').rgba();
|
const red = rgbaFromCss('#FF4538').rgba();
|
||||||
for (const { id } of shownLines()) {
|
for (const { id } of shownLines()) {
|
||||||
const { color } = f.line(id);
|
const { color } = f.trailLine(id);
|
||||||
if (color[0] > 0.1) near(color[0], red[0], 'restyled');
|
if (color[0] > 0.1) near(color[0], red[0], 'restyled');
|
||||||
else near(color[0], 0.02, 'dark edge kept');
|
else near(color[0], 0.02, 'dark edge kept');
|
||||||
}
|
}
|
||||||
@@ -705,7 +762,8 @@ for (const makeHarness of HARNESSES) {
|
|||||||
display(55000 + 2_000_000);
|
display(55000 + 2_000_000);
|
||||||
const old = shownLines();
|
const old = shownLines();
|
||||||
assert.equal(old.length, 4, 'two runs, two lanes each');
|
assert.equal(old.length, 4, 'two runs, two lanes each');
|
||||||
for (const { id } of old) near(f.line(id).color[3], 0.08, 'oldest alpha');
|
for (const { id } of old)
|
||||||
|
near(f.trailLine(id).color[3], 0.08, 'oldest alpha');
|
||||||
trail.destroy();
|
trail.destroy();
|
||||||
f.display.destroy();
|
f.display.destroy();
|
||||||
f.engine.destroy();
|
f.engine.destroy();
|
||||||
|
|||||||
@@ -104,8 +104,13 @@ const CESIUM_TYPES = {
|
|||||||
export function cesiumCctvHarness() {
|
export function cesiumCctvHarness() {
|
||||||
const handler = testEventHandler();
|
const handler = testEventHandler();
|
||||||
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
|
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
|
||||||
engineOptions: { createEventHandler: () => handler },
|
engineOptions: {
|
||||||
|
createEventHandler: () => handler,
|
||||||
|
scheduleFrame: (run) => run(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
viewer.scene.pick = () => undefined;
|
||||||
|
viewer.scene.drillPick = () => [];
|
||||||
const canvas = viewer.scene.canvas;
|
const canvas = viewer.scene.canvas;
|
||||||
canvas.style = {};
|
canvas.style = {};
|
||||||
canvas.ownerDocument = { defaultView: new EventTarget() };
|
canvas.ownerDocument = { defaultView: new EventTarget() };
|
||||||
|
|||||||
Reference in new issue
Block a user