perf: cache scale-safe isometric nudges

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 10:00:24 +03:00
parent 9d2256c090
commit 196fa09696
36 changed files with 513 additions and 457 deletions
+49 -27
View File
@@ -71,6 +71,8 @@ export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
/** Internal scene-builder fast path; arbitrary callers still resolve safely. */
ownerAlreadyResolved?: boolean;
resolvedOwner?: IsoOverlayOwner | null;
/** Internal zoom fast path. The hint is accepted only after an exact safety check. */
nudgeHintCss?: number;
}
export interface IsoOverlayTetherGeometry {
@@ -440,6 +442,7 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
let distanceCss = 0;
let nudgeScene: ScenePoint = [0, 0];
let knownNearWallAfter: boolean | null = null;
let capped = false;
let status: IsoOverlayPlacement['status'] = geometryValid ? 'ok' : 'degraded';
let reason: IsoOverlayPlacement['reason'] = geometryValid ? null : 'invalid-wall-geometry';
@@ -501,38 +504,57 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
plateNearSilhouette(plate, bounds, wall, gapUnits,
input.wallGeometryValidated === true));
};
let clearAt: number | null = null;
let previous = 0;
let ownerBoundary = false;
const samples = Math.ceil(searchLimitCss);
for (let sample = 1; sample <= samples; sample++) {
const candidate = Math.min(searchLimitCss, sample);
const ratio = candidate * unitsPerPixel / length;
const candidatePlan: PlanPoint = [
const hint = input.nudgeHintCss;
if (Number.isFinite(hint) && hint! >= 0 && hint! <= searchLimitCss) {
const ratio = hint! * unitsPerPixel / length;
const hintPlan: PlanPoint = [
floorAnchor[0] + (owner.safePoint[0] - floorAnchor[0]) * ratio,
floorAnchor[1] + (owner.safePoint[1] - floorAnchor[1]) * ratio,
];
if (!pathSafeToLimit
&& (!ownerRoom || !segmentStrictlyInRoom(floorAnchor, candidatePlan, ownerRoom))) {
ownerBoundary = true;
break;
if ((pathSafeToLimit
|| !!ownerRoom && segmentStrictlyInRoom(floorAnchor, hintPlan, ownerRoom))
&& !collidesAt(hint!)) {
// A wheel step changes only the CSS/scene scale. Reusing the last
// distance after proving it still clear avoids another 1 px scan
// plus binary search for every raised marker.
distanceCss = hint!;
knownNearWallAfter = false;
}
if (!collidesAt(candidate)) { clearAt = candidate; break; }
previous = candidate;
}
if (clearAt !== null) {
let low = previous, high = clearAt;
for (let iteration = 0; iteration < ISO_OVERLAY_NUDGE_SEARCH_ITERATIONS; iteration++) {
const middle = (low + high) / 2;
if (collidesAt(middle)) low = middle;
else high = middle;
if (knownNearWallAfter === null) {
let clearAt: number | null = null;
let previous = 0;
let ownerBoundary = false;
const samples = Math.ceil(searchLimitCss);
for (let sample = 1; sample <= samples; sample++) {
const candidate = Math.min(searchLimitCss, sample);
const ratio = candidate * unitsPerPixel / length;
const candidatePlan: PlanPoint = [
floorAnchor[0] + (owner.safePoint[0] - floorAnchor[0]) * ratio,
floorAnchor[1] + (owner.safePoint[1] - floorAnchor[1]) * ratio,
];
if (!pathSafeToLimit
&& (!ownerRoom || !segmentStrictlyInRoom(floorAnchor, candidatePlan, ownerRoom))) {
ownerBoundary = true;
break;
}
if (!collidesAt(candidate)) { clearAt = candidate; break; }
previous = candidate;
}
if (clearAt !== null) {
let low = previous, high = clearAt;
for (let iteration = 0; iteration < ISO_OVERLAY_NUDGE_SEARCH_ITERATIONS; iteration++) {
const middle = (low + high) / 2;
if (collidesAt(middle)) low = middle;
else high = middle;
}
distanceCss = high;
} else {
distanceCss = previous;
capped = true;
status = 'degraded';
reason = ownerBoundary ? 'owner-boundary' : 'nudge-cap';
}
distanceCss = high;
} else {
distanceCss = previous;
capped = true;
status = 'degraded';
reason = ownerBoundary ? 'owner-boundary' : 'nudge-cap';
}
nudgeScene = [ux * distanceCss * unitsPerPixel, uy * distanceCss * unitsPerPixel];
}
@@ -542,7 +564,7 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
const visualScene: ScenePoint = [raisedScene[0] + nudgeScene[0], raisedScene[1] + nudgeScene[1]];
const plate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
raisedHeight, camera, nudgeScene);
const nearWallAfter = geometryValid ? isNear(plate) : true;
const nearWallAfter = geometryValid ? knownNearWallAfter ?? isNear(plate) : true;
const nudged = distanceCss > EPS;
const tetherVisible = nudged || nearWallBefore || nearWallAfter
|| !!input.hovered || !!input.focused || !!input.selected;
+28 -4
View File
@@ -708,7 +708,12 @@ export interface IsoOverlaySceneInput {
type IsoOverlayRoomRow = { room: RoomCfg; overlayRoom: IsoOverlayRoom };
const isoOverlayRoomCache = new WeakMap<readonly RoomCfg[], readonly IsoOverlayRoomRow[]>();
type IsoOverlayPlacementCacheEntry = { signature: string; placement: IsoOverlayPlacement };
type IsoOverlayPlacementCacheEntry = {
signature: string;
shapeSignature: string;
unitsPerPixel: number;
placement: IsoOverlayPlacement;
};
type IsoOverlayOwnerCacheEntry = { signature: string; owner: IsoOverlayPlacement['owner'] };
export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;
const isoOverlayPlacementCache = new WeakMap<
@@ -797,11 +802,26 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
): IsoOverlayPlacement => {
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live';
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
const signature = [floorAnchor[0], floorAnchor[1], plateHalfSize[0], plateHalfSize[1],
preferredRoomId || '', wallHeight, visualOffset, unitsPerPixel,
const shapeSignature = [floorAnchor[0], floorAnchor[1], plateHalfSize[0], plateHalfSize[1],
preferredRoomId || '', wallHeight, visualOffset,
input.layers.shadows ? 1 : 0, selected ? 1 : 0].join('|');
const signature = `${shapeSignature}|${unitsPerPixel}`;
const cached = lruRead(placements!, cacheKey);
if (cached.hit && cached.value.signature === signature) return cached.value.placement;
if (cached.hit && cached.value.shapeSignature === shapeSignature
&& unitsPerPixel <= cached.value.unitsPerPixel) {
const previous = cached.value.placement;
const distanceAtNewScale = previous.nudgeDistanceCss
* cached.value.unitsPerPixel / unitsPerPixel;
// Zooming in only shrinks the required scene-unit safety gap. An
// unchanged plate that was clear therefore stays clear; an unchanged
// non-near plate stays non-near. Reuse the immutable scene placement so
// Lit also keeps the raised SVG subtree. Once its CSS displacement would
// exceed the public cap, fall through to the exact resolver.
if (!previous.nearWallBefore
|| previous.cleared && distanceAtNewScale <= ISO_OVERLAY_MAX_NUDGE_CSS_PX)
return previous;
}
const ownerKey = `${kind}\u0000${id}`;
const ownerSignature = [floorAnchor[0], floorAnchor[1], preferredRoomId || ''].join('|');
const cachedOwner = lruRead(owners!, ownerKey);
@@ -821,6 +841,9 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
preferredRoomId,
ownerAlreadyResolved: true,
resolvedOwner: owner,
nudgeHintCss: cached.hit && cached.value.shapeSignature === shapeSignature
? cached.value.placement.nudgeDistanceCss * cached.value.unitsPerPixel / unitsPerPixel
: undefined,
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallGeometryValidated: true,
@@ -834,7 +857,8 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
hovered: true,
selected,
});
lruWrite(placements!, cacheKey, { signature, placement }, ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
lruWrite(placements!, cacheKey, { signature, shapeSignature, unitsPerPixel, placement },
ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
return placement;
};