perf(iso): ускорить точный граничный поиск (#585)

Issue: #585
User-Visible: no
This commit is contained in:
Claude
2026-09-20 01:51:19 +03:00
parent 3c2ab9b764
commit e793993c66
49 changed files with 739 additions and 606 deletions
+287 -180
View File
@@ -117,6 +117,8 @@ export interface IsoOverlayCollisionItem {
placement: IsoOverlayPlacement;
/** Axis-aligned screen-facing half-size in scene units. */
screenHalfSize: PlanPoint;
/** Previous exact result as an upper bound; every nearer event is still checked. */
nudgeHintCss?: ScenePoint;
}
export interface IsoOverlayCollisionInput {
@@ -201,15 +203,18 @@ function validRoom(room: IsoOverlayRoom): boolean {
&& roomArea(room) > EPS;
}
function pointStrictlyInRoom(point: PlanPoint, room: IsoOverlayRoom): boolean {
if (!validRoom(room) || pointOnRing(point, room.outer) || !pointInRing(point, room.outer))
return false;
function pointStrictlyInValidatedRoom(point: PlanPoint, room: IsoOverlayRoom): boolean {
if (pointOnRing(point, room.outer) || !pointInRing(point, room.outer)) return false;
for (const hole of room.holes || []) {
if (pointOnRing(point, hole) || pointInRing(point, hole)) return false;
}
return true;
}
function pointStrictlyInRoom(point: PlanPoint, room: IsoOverlayRoom): boolean {
return validRoom(room) && pointStrictlyInValidatedRoom(point, room);
}
function roomBoundaryDistance(point: PlanPoint, room: IsoOverlayRoom): number {
let distance = Infinity;
for (const ring of [room.outer, ...(room.holes || [])]) {
@@ -364,6 +369,20 @@ function ringBounds(ring: readonly (readonly number[])[]): Bounds | null {
return [minX, minY, maxX, maxY];
}
function axisAlignedRoomBox(room: IsoOverlayRoom): Bounds | null {
if (room.outer.length !== 4 || (room.holes?.length || 0) !== 0) return null;
const bounds = ringBounds(room.outer);
if (!bounds) return null;
const corners = new Set(room.outer.map((point) => {
const x = Math.abs(point[0] - bounds[0]) <= EPS ? 0
: Math.abs(point[0] - bounds[2]) <= EPS ? 1 : -1;
const y = Math.abs(point[1] - bounds[1]) <= EPS ? 0
: Math.abs(point[1] - bounds[3]) <= EPS ? 1 : -1;
return x < 0 || y < 0 ? '' : `${x}:${y}`;
}));
return corners.size === 4 && !corners.has('') ? bounds : null;
}
function silhouetteBounds(silhouette: IsoWallSilhouette, cache: boolean): Bounds | null {
if (!cache) return ringBounds(silhouette.outer);
if (silhouetteBoundsCache.has(silhouette)) return silhouetteBoundsCache.get(silhouette) ?? null;
@@ -419,6 +438,12 @@ function validSilhouette(silhouette: IsoWallSilhouette): boolean {
/** A straight nudge must never cut through a concavity or an island hole. */
function segmentStrictlyInRoom(start: PlanPoint, end: PlanPoint, room: IsoOverlayRoom): boolean {
if (!pointStrictlyInRoom(start, room) || !pointStrictlyInRoom(end, room)) return false;
return segmentBetweenStrictRoomPoints(start, end, room);
}
function segmentBetweenStrictRoomPoints(
start: PlanPoint, end: PlanPoint, room: IsoOverlayRoom,
): boolean {
for (const ring of [room.outer, ...(room.holes || [])]) {
for (let index = 0; index < ring.length; index++) {
if (segmentsIntersect(start, end, ring[index], ring[(index + 1) % ring.length]))
@@ -640,9 +665,22 @@ const ISO_OVERLAY_GROUP_CELL_CSS_PX = 64;
/** Candidate displacement together with its stable distance ordering. */
interface GroupOffset { readonly offset: ScenePoint; readonly distance: number }
type BoundaryCandidateMap = Map<string, ScenePoint>;
type BoundaryCandidateMap = {
readonly points: Map<number | string, ScenePoint>;
readonly pending: ScenePoint[];
};
function boundaryCandidateKey(x: number, y: number): string {
function createBoundaryCandidates(): BoundaryCandidateMap {
return { points: new Map(), pending: [] };
}
function boundaryCandidateKey(x: number, y: number): number | string {
// Production is bounded by 48 CSS px. Keep that hot path on V8's numeric
// Map representation; the string fallback preserves the helper's general
// finite-input contract without admitting packed-key collisions.
if (Number.isInteger(x) && Number.isInteger(y)
&& x >= -32768 && x <= 32767 && y >= -32768 && y <= 32767)
return (x + 32768) * 65536 + y + 32768;
return `${x}:${y}`;
}
@@ -664,7 +702,11 @@ function addBoundaryCandidate(
if (candidateX * candidateX + candidateY * candidateY > maxNudge * maxNudge + EPS)
continue;
const key = boundaryCandidateKey(candidateX, candidateY);
if (!candidates.has(key)) candidates.set(key, [candidateX, candidateY]);
if (!candidates.points.has(key)) {
const point: ScenePoint = [candidateX, candidateY];
candidates.points.set(key, point);
candidates.pending.push(point);
}
}
}
}
@@ -695,23 +737,26 @@ function clipBoundarySegment(
];
}
function addBoundaryRectangleEvents(
candidates: BoundaryCandidateMap, rectangles: readonly Bounds[], maxNudge: number,
function addBoundaryRectangle(
candidates: BoundaryCandidateMap, bounds: Bounds, maxNudge: number,
): void {
addBoundaryCandidate(candidates,
[bounds[0], Math.max(bounds[1], Math.min(0, bounds[3]))], maxNudge);
addBoundaryCandidate(candidates,
[bounds[2], Math.max(bounds[1], Math.min(0, bounds[3]))], maxNudge);
addBoundaryCandidate(candidates,
[Math.max(bounds[0], Math.min(0, bounds[2])), bounds[1]], maxNudge);
addBoundaryCandidate(candidates,
[Math.max(bounds[0], Math.min(0, bounds[2])), bounds[3]], maxNudge);
addBoundaryCandidate(candidates, [bounds[0], bounds[1]], maxNudge);
addBoundaryCandidate(candidates, [bounds[2], bounds[1]], maxNudge);
addBoundaryCandidate(candidates, [bounds[2], bounds[3]], maxNudge);
addBoundaryCandidate(candidates, [bounds[0], bounds[3]], maxNudge);
}
function addBoundaryRectangleIntersections(
candidates: BoundaryCandidateMap, left: Bounds, right: Bounds, maxNudge: number,
): void {
for (const bounds of rectangles) {
addBoundaryCandidate(candidates,
[bounds[0], Math.max(bounds[1], Math.min(0, bounds[3]))], maxNudge);
addBoundaryCandidate(candidates,
[bounds[2], Math.max(bounds[1], Math.min(0, bounds[3]))], maxNudge);
addBoundaryCandidate(candidates,
[Math.max(bounds[0], Math.min(0, bounds[2])), bounds[1]], maxNudge);
addBoundaryCandidate(candidates,
[Math.max(bounds[0], Math.min(0, bounds[2])), bounds[3]], maxNudge);
addBoundaryCandidate(candidates, [bounds[0], bounds[1]], maxNudge);
addBoundaryCandidate(candidates, [bounds[2], bounds[1]], maxNudge);
addBoundaryCandidate(candidates, [bounds[2], bounds[3]], maxNudge);
addBoundaryCandidate(candidates, [bounds[0], bounds[3]], maxNudge);
}
// Only intersections of FINITE rectangle edges are critical. Combining
// every X with every unrelated Y forms an artificial O(n²) interior grid;
// on the 200-device benchmark it produced almost five thousand candidates
@@ -725,10 +770,19 @@ function addBoundaryRectangleEvents(
}
}
};
addIntersections(left, right);
addIntersections(right, left);
}
function addBoundaryRectangleEvents(
candidates: BoundaryCandidateMap, rectangles: readonly Bounds[], maxNudge: number,
): void {
for (const bounds of rectangles) addBoundaryRectangle(candidates, bounds, maxNudge);
for (let left = 0; left < rectangles.length; left++) {
for (let right = left + 1; right < rectangles.length; right++) {
addIntersections(rectangles[left], rectangles[right]);
addIntersections(rectangles[right], rectangles[left]);
addBoundaryRectangleIntersections(
candidates, rectangles[left], rectangles[right], maxNudge,
);
}
}
}
@@ -743,7 +797,7 @@ export function buildIsoOverlayBoundaryCandidates(
): readonly ScenePoint[] {
if (!(maxNudge >= 0) || !Number.isFinite(maxNudge))
throw new Error('invalid isometric overlay boundary input');
const candidates: BoundaryCandidateMap = new Map();
const candidates = createBoundaryCandidates();
addBoundaryRectangleEvents(candidates, rectangles, maxNudge);
return Object.freeze(sortedBoundaryCandidates(candidates).map(({ offset }) =>
Object.freeze(offset) as ScenePoint));
@@ -780,55 +834,57 @@ function addCriticalBoundaryRectangle(
type BoundarySegment = readonly [start: ScenePoint, end: ScenePoint];
function addBoundarySegmentRectangleIntersections(
function addBoundarySegmentRectangleIntersection(
candidates: BoundaryCandidateMap, segment: BoundarySegment,
rectangles: readonly Bounds[], maxNudge: number,
bounds: Bounds, maxNudge: number,
): void {
const [start, end] = segment;
const dx = end[0] - start[0], dy = end[1] - start[1];
for (const bounds of rectangles) {
if (Math.abs(dx) > EPS) {
for (const x of [bounds[0], bounds[2]]) {
const ratio = (x - start[0]) / dx;
if (ratio < -EPS || ratio > 1 + EPS) continue;
const y = start[1] + dy * ratio;
if (y >= bounds[1] - EPS && y <= bounds[3] + EPS)
addBoundaryCandidate(candidates, [x, y], maxNudge);
}
if (Math.abs(dx) > EPS) {
for (const x of [bounds[0], bounds[2]]) {
const ratio = (x - start[0]) / dx;
if (ratio < -EPS || ratio > 1 + EPS) continue;
const y = start[1] + dy * ratio;
if (y >= bounds[1] - EPS && y <= bounds[3] + EPS)
addBoundaryCandidate(candidates, [x, y], maxNudge);
}
if (Math.abs(dy) > EPS) {
for (const y of [bounds[1], bounds[3]]) {
const ratio = (y - start[1]) / dy;
if (ratio < -EPS || ratio > 1 + EPS) continue;
const x = start[0] + dx * ratio;
if (x >= bounds[0] - EPS && x <= bounds[2] + EPS)
addBoundaryCandidate(candidates, [x, y], maxNudge);
}
}
if (Math.abs(dy) > EPS) {
for (const y of [bounds[1], bounds[3]]) {
const ratio = (y - start[1]) / dy;
if (ratio < -EPS || ratio > 1 + EPS) continue;
const x = start[0] + dx * ratio;
if (x >= bounds[0] - EPS && x <= bounds[2] + EPS)
addBoundaryCandidate(candidates, [x, y], maxNudge);
}
}
}
function addBoundaryLineIntersections(
candidates: BoundaryCandidateMap, segments: readonly BoundarySegment[], maxNudge: number,
function addBoundarySegmentRectangleIntersections(
candidates: BoundaryCandidateMap, segment: BoundarySegment,
rectangles: readonly Bounds[], maxNudge: number,
): void {
for (let left = 0; left < segments.length; left++) {
const [a, b] = segments[left];
const ab: ScenePoint = [b[0] - a[0], b[1] - a[1]];
for (let right = left + 1; right < segments.length; right++) {
const [c, d] = segments[right];
const cd: ScenePoint = [d[0] - c[0], d[1] - c[1]];
const denominator = ab[0] * cd[1] - ab[1] * cd[0];
if (Math.abs(denominator) <= EPS) continue;
const ac: ScenePoint = [c[0] - a[0], c[1] - a[1]];
const ratio = (ac[0] * cd[1] - ac[1] * cd[0]) / denominator;
const otherRatio = (ac[0] * ab[1] - ac[1] * ab[0]) / denominator;
if (ratio < -EPS || ratio > 1 + EPS
|| otherRatio < -EPS || otherRatio > 1 + EPS) continue;
const point: ScenePoint = [a[0] + ab[0] * ratio, a[1] + ab[1] * ratio];
if (Math.hypot(point[0], point[1]) <= maxNudge + 2)
addBoundaryCandidate(candidates, point, maxNudge);
}
}
for (const bounds of rectangles)
addBoundarySegmentRectangleIntersection(candidates, segment, bounds, maxNudge);
}
function addBoundaryLineIntersection(
candidates: BoundaryCandidateMap, left: BoundarySegment,
right: BoundarySegment, maxNudge: number,
): void {
const [a, b] = left, [c, d] = right;
const ab: ScenePoint = [b[0] - a[0], b[1] - a[1]];
const cd: ScenePoint = [d[0] - c[0], d[1] - c[1]];
const denominator = ab[0] * cd[1] - ab[1] * cd[0];
if (Math.abs(denominator) <= EPS) return;
const ac: ScenePoint = [c[0] - a[0], c[1] - a[1]];
const ratio = (ac[0] * cd[1] - ac[1] * cd[0]) / denominator;
const otherRatio = (ac[0] * ab[1] - ac[1] * ab[0]) / denominator;
if (ratio < -EPS || ratio > 1 + EPS
|| otherRatio < -EPS || otherRatio > 1 + EPS) return;
const point: ScenePoint = [a[0] + ab[0] * ratio, a[1] + ab[1] * ratio];
if (Math.hypot(point[0], point[1]) <= maxNudge + 2)
addBoundaryCandidate(candidates, point, maxNudge);
}
/**
@@ -837,11 +893,20 @@ function addBoundaryLineIntersections(
* the straight part of the Minkowski boundary; the final polygon predicate
* still decides legality around vertices and concavities.
*/
function addExpandedWallBoundarySegments(
candidates: BoundaryCandidateMap, wall: IsoWallSilhouette,
footprint: readonly ScenePoint[], gapUnits: number, unitsPerPixel: number,
maxNudge: number, rectangles: readonly Bounds[], segments?: BoundarySegment[],
): void {
type WallBoundaryEdge = {
readonly tangent: ScenePoint;
readonly normal: ScenePoint;
readonly tangentMin: number;
readonly tangentMax: number;
readonly normalOffset: number;
};
const wallBoundaryEdgeCache = new WeakMap<IsoWallSilhouette, readonly WallBoundaryEdge[]>();
function wallBoundaryEdges(wall: IsoWallSilhouette): readonly WallBoundaryEdge[] {
const cached = wallBoundaryEdgeCache.get(wall);
if (cached) return cached;
const result: WallBoundaryEdge[] = [];
for (const ring of [wall.outer, ...(wall.holes || [])]) {
for (let index = 0; index < ring.length; index++) {
const wallStart = ring[index], wallEnd = ring[(index + 1) % ring.length];
@@ -850,20 +915,43 @@ function addExpandedWallBoundarySegments(
if (edgeLength <= EPS) continue;
const tangent: ScenePoint = [edgeX / edgeLength, edgeY / edgeLength];
const normal: ScenePoint = [-tangent[1], tangent[0]];
const tangentStart = wallStart[0] * tangent[0] + wallStart[1] * tangent[1];
const tangentEnd = wallEnd[0] * tangent[0] + wallEnd[1] * tangent[1];
result.push({
tangent, normal,
tangentMin: Math.min(tangentStart, tangentEnd),
tangentMax: Math.max(tangentStart, tangentEnd),
normalOffset: wallStart[0] * normal[0] + wallStart[1] * normal[1],
});
}
}
wallBoundaryEdgeCache.set(wall, result);
return result;
}
function addExpandedWallBoundarySegments(
candidates: BoundaryCandidateMap, wall: IsoWallSilhouette,
footprint: readonly ScenePoint[], gapUnits: number, unitsPerPixel: number,
maxNudge: number, rectangles: readonly Bounds[], segments?: BoundarySegment[],
): void {
for (const edge of wallBoundaryEdges(wall)) {
const { tangent, normal } = edge;
const dot = (point: readonly number[], axis: ScenePoint): number =>
point[0] * axis[0] + point[1] * axis[1];
const wallTangentStart = dot(wallStart, tangent);
const wallTangentEnd = dot(wallEnd, tangent);
const wallTangentMin = Math.min(wallTangentStart, wallTangentEnd);
const wallTangentMax = Math.max(wallTangentStart, wallTangentEnd);
const wallNormal = dot(wallStart, normal);
const footprintTangents = footprint.map((point) => dot(point, tangent));
const footprintNormals = footprint.map((point) => dot(point, normal));
const tangentMin = wallTangentMin - Math.max(...footprintTangents) - gapUnits;
const tangentMax = wallTangentMax - Math.min(...footprintTangents) + gapUnits;
let footprintTangentMin = Infinity, footprintTangentMax = -Infinity;
let footprintNormalMin = Infinity, footprintNormalMax = -Infinity;
for (const point of footprint) {
const tangentOffset = dot(point, tangent), normalOffset = dot(point, normal);
footprintTangentMin = Math.min(footprintTangentMin, tangentOffset);
footprintTangentMax = Math.max(footprintTangentMax, tangentOffset);
footprintNormalMin = Math.min(footprintNormalMin, normalOffset);
footprintNormalMax = Math.max(footprintNormalMax, normalOffset);
}
const tangentMin = edge.tangentMin - footprintTangentMax - gapUnits;
const tangentMax = edge.tangentMax - footprintTangentMin + gapUnits;
const normalOffsets = [
wallNormal - gapUnits - Math.max(...footprintNormals),
wallNormal + gapUnits - Math.min(...footprintNormals),
edge.normalOffset - gapUnits - footprintNormalMax,
edge.normalOffset + gapUnits - footprintNormalMin,
];
for (const normalOffset of normalOffsets) {
const toPoint = (tangentOffset: number): ScenePoint => [
@@ -880,7 +968,6 @@ function addExpandedWallBoundarySegments(
candidates, clipped, rectangles, maxNudge,
);
}
}
}
}
@@ -902,31 +989,12 @@ function addBoundaryCircle(
}
function sortedBoundaryCandidates(candidates: BoundaryCandidateMap): GroupOffset[] {
return [...candidates.values()].map((offset) => ({
return [...candidates.points.values()].map((offset) => ({
offset, distance: Math.hypot(offset[0], offset[1]),
})).sort((a, b) => a.distance - b.distance
|| a.offset[1] - b.offset[1] || a.offset[0] - b.offset[0]);
}
/**
* Snap a continuous boundary event back to the exact integer-pixel result.
* The bounded 9x9 neighbourhood replaces the old complete 7,238-point disk.
*/
function localRefinementOffsets(
around: ScenePoint, maxNudge: number, reach = 4,
): readonly GroupOffset[] {
const offsets: GroupOffset[] = [];
for (let y = Math.floor(around[1] - reach); y <= Math.ceil(around[1] + reach); y++) {
for (let x = Math.floor(around[0] - reach); x <= Math.ceil(around[0] + reach); x++) {
const distance = Math.hypot(x, y);
if (distance <= maxNudge + EPS) offsets.push({ offset: [x, y], distance });
}
}
offsets.sort((a, b) => a.distance - b.distance
|| a.offset[1] - b.offset[1] || a.offset[0] - b.offset[0]);
return offsets;
}
function overlayRootBounds(
item: IsoOverlayCollisionItem, center: ScenePoint,
): Bounds {
@@ -1024,14 +1092,29 @@ export function resolveIsoOverlayCollisions(
const gapUnits = safetyGap * unitsPerPixel;
const cellUnits = ISO_OVERLAY_GROUP_CELL_CSS_PX * unitsPerPixel;
const rooms = new Map(input.rooms.filter(validRoom).map((room) => [room.id, room]));
const roomBounds = new Map([...rooms].map(([id, room]) => [
id, ringBounds(room.outer as readonly ScenePoint[]),
]));
const axisAlignedRoomBoxes = new Map([...rooms].flatMap(([id, room]) => {
const bounds = axisAlignedRoomBox(room);
return bounds ? [[id, bounds] as const] : [];
}));
const wallsValid = input.wallSilhouettes.every(validSilhouette);
const wallRows = input.wallSilhouettes.map((wall) => ({
wall, bounds: silhouetteBounds(wall, true),
}));
const accepted: AcceptedOverlay[] = [];
const cells = new Map<string, number[]>();
const cells = new Map<number | string, number[]>();
const placements = new Map<string, IsoOverlayPlacement>();
const residualPairs: Array<readonly [string, string]> = [];
const stableItems = [...input.items].sort((a, b) =>
a.placement.nudgeDistanceCss - b.placement.nudgeDistanceCss
|| isoOverlayCollisionKey(a.kind, a.id).localeCompare(isoOverlayCollisionKey(b.kind, b.id)));
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const inverseX = 1 / camera.xyScale;
const inverseY = 1 / (camera.xyScale * Math.cos(tilt));
const cosRot = Math.cos(rot), sinRot = Math.sin(rot);
const cellRange = (bounds: Bounds): readonly [number, number, number, number] => [
Math.floor(bounds[0] / cellUnits), Math.floor(bounds[1] / cellUnits),
@@ -1042,7 +1125,7 @@ export function resolveIsoOverlayCollisions(
const result = new Set<number>();
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
for (const index of cells.get(`${x}:${y}`) || []) result.add(index);
for (const index of cells.get(boundaryCandidateKey(x, y)) || []) result.add(index);
}
}
return [...result].sort((a, b) => a - b);
@@ -1052,7 +1135,7 @@ export function resolveIsoOverlayCollisions(
const [minX, minY, maxX, maxY] = cellRange(value.bounds);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const key = `${x}:${y}`;
const key = boundaryCandidateKey(x, y);
const list = cells.get(key) || [];
list.push(index);
cells.set(key, list);
@@ -1072,16 +1155,15 @@ export function resolveIsoOverlayCollisions(
] as ScenePoint);
const ownerRoom = base.owner ? rooms.get(base.owner.id) || null : null;
const currentPlan = raisedSceneToPlan(base.visualScene, visualOffset, camera);
const baseFootprintBounds = ringBounds(baseFootprint);
const wallCandidates = wallsValid && baseFootprint.length
? input.wallSilhouettes.filter((wall) => {
const wallBounds = silhouetteBounds(wall, true);
? wallRows.filter(({ bounds: wallBounds }) => {
if (!wallBounds) return false;
const radius = maxNudge * unitsPerPixel;
const footprintBounds = ringBounds(baseFootprint);
return !!footprintBounds && boundsNear(expandedBounds(footprintBounds, radius),
return !!baseFootprintBounds && boundsNear(expandedBounds(baseFootprintBounds, radius),
wallBounds, gapUnits);
})
: input.wallSilhouettes;
: wallRows;
/**
* Дешёвая половина кандидата: где он окажется и с кем столкнётся.
@@ -1147,8 +1229,16 @@ export function resolveIsoOverlayCollisions(
* съедала 8.5 из 9.6 секунд группового прохода. Подавляющее большинство
* этих точек лежит ВНЕ комнаты — узнать это можно сравнением четырёх чисел,
* а не обходом колец полигона и силуэтов стен.
*/
const ownerBox = ownerRoom ? ringBounds(ownerRoom.outer as readonly ScenePoint[]) : null;
*/
const ownerBox = ownerRoom ? roomBounds.get(ownerRoom.id) || null : null;
const ownerAxisBox = ownerRoom ? axisAlignedRoomBoxes.get(ownerRoom.id) || null : null;
const currentStrictlyInOwner = !!ownerRoom
&& (ownerAxisBox
? currentPlan[0] > ownerAxisBox[0] + EPS
&& currentPlan[0] < ownerAxisBox[2] - EPS
&& currentPlan[1] > ownerAxisBox[1] + EPS
&& currentPlan[1] < ownerAxisBox[3] - EPS
: pointStrictlyInValidatedRoom(currentPlan, ownerRoom));
/**
* Широкая фаза по стенам — то, чего требовал §6.4 ТЗ и чего не было.
@@ -1159,14 +1249,14 @@ export function resolveIsoOverlayCollisions(
* Стены раскладываются по тем же ячейкам, что и принятые оверлеи, и каждый
* кандидат смотрит только свои ячейки.
*/
const wallCells = new Map<string, number[]>();
const wallCells = new Map<number | string, number[]>();
for (let index = 0; index < wallCandidates.length; index++) {
const wallBounds = silhouetteBounds(wallCandidates[index], true);
const wallBounds = wallCandidates[index].bounds;
if (!wallBounds) continue;
const [minX, minY, maxX, maxY] = cellRange(expandedBounds(wallBounds, gapUnits));
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const key = `${x}:${y}`;
const key = boundaryCandidateKey(x, y);
const list = wallCells.get(key) || [];
list.push(index);
wallCells.set(key, list);
@@ -1177,52 +1267,79 @@ export function resolveIsoOverlayCollisions(
// стоит дороже самой проверки.
const wallSeen = new Int32Array(wallCandidates.length);
let wallSeenGeneration = 0;
const wallsNear = (bounds: Bounds): number[] => {
const [minX, minY, maxX, maxY] = cellRange(expandedBounds(bounds, gapUnits));
const result: number[] = [];
const translatedFootprint = baseFootprint.map(() => [0, 0] as [number, number]);
const footprintAt = (offset: ScenePoint): readonly ScenePoint[] => {
for (let index = 0; index < baseFootprint.length; index++) {
translatedFootprint[index][0] = baseFootprint[index][0] + offset[0];
translatedFootprint[index][1] = baseFootprint[index][1] + offset[1];
}
return translatedFootprint;
};
const firstWallNear = (
bounds: Bounds, footprint: () => readonly ScenePoint[],
): number | undefined => {
const minX = Math.floor((bounds[0] - gapUnits) / cellUnits);
const minY = Math.floor((bounds[1] - gapUnits) / cellUnits);
const maxX = Math.floor((bounds[2] + gapUnits) / cellUnits);
const maxY = Math.floor((bounds[3] + gapUnits) / cellUnits);
wallSeenGeneration += 1;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
for (const index of wallCells.get(`${x}:${y}`) || []) {
for (const index of wallCells.get(boundaryCandidateKey(x, y)) || []) {
if (wallSeen[index] === wallSeenGeneration) continue;
wallSeen[index] = wallSeenGeneration;
result.push(index);
if (footprintNearSilhouette(
footprint(), bounds, wallCandidates[index].wall, gapUnits, true,
)) return index;
}
}
}
return result;
return undefined;
};
const INSPECTION_ALLOWED = -2;
const INSPECTION_ROOM = -1;
const inspectCandidate = (shape: {
offsetScene: ScenePoint; sameAsBase: boolean; visualScene: ScenePoint;
}): { allowed: boolean; walls: readonly number[]; room: boolean } => {
if (shape.sameAsBase) return { allowed: true, walls: [], room: false };
if (!wallsValid || !ownerRoom) return { allowed: false, walls: [], room: true };
const plan = raisedSceneToPlan(shape.visualScene, visualOffset, camera);
if (ownerBox && (plan[0] < ownerBox[0] || plan[0] > ownerBox[2]
|| plan[1] < ownerBox[1] || plan[1] > ownerBox[3]))
return { allowed: false, walls: [], room: true };
if (!pointStrictlyInRoom(plan, ownerRoom))
return { allowed: false, walls: [], room: true };
if (pointStrictlyInRoom(currentPlan, ownerRoom)
&& !segmentStrictlyInRoom(currentPlan, plan, ownerRoom))
return { allowed: false, walls: [], room: true };
const footprint = baseFootprint.map((point) => [
point[0] + shape.offsetScene[0], point[1] + shape.offsetScene[1],
] as ScenePoint);
const footprintBounds = ringBounds(footprint);
if (!footprintBounds) return { allowed: false, walls: [], room: true };
const wall = wallsNear(footprintBounds).find((index) =>
footprintNearSilhouette(
footprint, footprintBounds, wallCandidates[index], gapUnits, true,
));
return wall === undefined
? { allowed: true, walls: [], room: false }
: { allowed: false, walls: [wall], room: false };
}): number => {
if (shape.sameAsBase) return INSPECTION_ALLOWED;
if (!wallsValid || !ownerRoom) return INSPECTION_ROOM;
const sceneDx = shape.visualScene[0] - base.visualScene[0];
const sceneDy = shape.visualScene[1] - base.visualScene[1];
const rx = sceneDx * inverseX, ry = sceneDy * inverseY;
const planX = currentPlan[0] + rx * cosRot + ry * sinRot;
const planY = currentPlan[1] - rx * sinRot + ry * cosRot;
if (ownerBox && (planX < ownerBox[0] || planX > ownerBox[2]
|| planY < ownerBox[1] || planY > ownerBox[3]))
return INSPECTION_ROOM;
if (ownerAxisBox) {
if (!(planX > ownerAxisBox[0] + EPS && planX < ownerAxisBox[2] - EPS
&& planY > ownerAxisBox[1] + EPS && planY < ownerAxisBox[3] - EPS))
return INSPECTION_ROOM;
} else {
const plan: PlanPoint = [planX, planY];
if (!pointStrictlyInValidatedRoom(plan, ownerRoom))
return INSPECTION_ROOM;
if (currentStrictlyInOwner
&& !segmentBetweenStrictRoomPoints(currentPlan, plan, ownerRoom))
return INSPECTION_ROOM;
}
if (!baseFootprintBounds) return INSPECTION_ROOM;
const footprintBounds: Bounds = [
baseFootprintBounds[0] + shape.offsetScene[0],
baseFootprintBounds[1] + shape.offsetScene[1],
baseFootprintBounds[2] + shape.offsetScene[0],
baseFootprintBounds[3] + shape.offsetScene[1],
];
let footprint: readonly ScenePoint[] | null = null;
const wall = firstWallNear(
footprintBounds, () => footprint ||= footprintAt(shape.offsetScene),
);
return wall === undefined ? INSPECTION_ALLOWED : wall;
};
const candidateAllowed = (shape: {
offsetScene: ScenePoint; sameAsBase: boolean; visualScene: ScenePoint;
}): boolean => inspectCandidate(shape).allowed;
}): boolean => inspectCandidate(shape) === INSPECTION_ALLOWED;
const candidate = (offsetCss: ScenePoint): {
placement: IsoOverlayPlacement;
@@ -1238,8 +1355,13 @@ export function resolveIsoOverlayCollisions(
};
};
let best = candidate(baseOffsetCss);
const baseCandidate = candidate(baseOffsetCss);
let best = baseCandidate;
if (!best || best.conflicts.length) {
const hinted = item.nudgeHintCss ? candidate(item.nudgeHintCss) : null;
if (hinted && !hinted.conflicts.length) best = hinted;
}
if (!baseCandidate || baseCandidate.conflicts.length) {
/**
* A nearest free point must touch the boundary of the forbidden union.
* Enumerate those one-dimensional boundaries instead of the 7238 points
@@ -1247,12 +1369,12 @@ export function resolveIsoOverlayCollisions(
* preserve the old integer result even when a legal slit is narrower
* than the former 4 px coarse grid.
*/
const boundaryCandidates: BoundaryCandidateMap = new Map();
const boundaryCandidates = createBoundaryCandidates();
const overlayBoundaryRectangles: Bounds[] = [];
const boundaryRectangles: Bounds[] = [];
const boundarySegments: BoundarySegment[] = [];
const expandedOverlayBoundaries = new Set<number>();
const encounteredOverlayBoundaries = new Set<number>(best?.conflicts || []);
const encounteredOverlayBoundaries = new Set<number>(baseCandidate?.conflicts || []);
const addOverlayBoundary = (index: number): void => {
if (expandedOverlayBoundaries.has(index)) return;
expandedOverlayBoundaries.add(index);
@@ -1271,15 +1393,13 @@ export function resolveIsoOverlayCollisions(
boundaryRectangles.push(rectangle);
};
const footprintBounds = ringBounds(baseFootprint);
const footprintBounds = baseFootprintBounds;
const expandedWalls = new Set<number>();
const pendingWalls = new Set<number>();
const evaluated = new Set<string>();
const addWallBoundary = (index: number): void => {
if (expandedWalls.has(index) || !footprintBounds) return;
expandedWalls.add(index);
const wall = wallCandidates[index];
const wallBounds = silhouetteBounds(wall, true);
const { wall, bounds: wallBounds } = wallCandidates[index];
if (wallBounds) {
const expandedWallBounds: Bounds = [
(wallBounds[0] - gapUnits - footprintBounds[2]) / unitsPerPixel,
@@ -1333,24 +1453,26 @@ export function resolveIsoOverlayCollisions(
boundaryCandidates, [boundaryRectangles[index]], maxNudge,
);
for (let previous = 0; previous < index; previous++)
addBoundaryRectangleEvents(boundaryCandidates, [
boundaryRectangles[previous], boundaryRectangles[index],
], maxNudge);
addBoundaryRectangleIntersections(
boundaryCandidates, boundaryRectangles[previous],
boundaryRectangles[index], maxNudge,
);
}
// Concave silhouettes and the safety-gap rounding meet where finite
// boundary segments cross. Exact polygons still make the final call.
for (let index = processedSegments; index < boundarySegments.length; index++) {
for (let previous = 0; previous < index; previous++)
addBoundaryLineIntersections(boundaryCandidates, [
boundarySegments[previous], boundarySegments[index],
], maxNudge);
addBoundaryLineIntersection(
boundaryCandidates, boundarySegments[previous],
boundarySegments[index], maxNudge,
);
}
for (let index = processedOverlayRectangles;
index < overlayBoundaryRectangles.length; index++) {
for (let segment = 0; segment < processedSegments; segment++)
addBoundarySegmentRectangleIntersections(
addBoundarySegmentRectangleIntersection(
boundaryCandidates, boundarySegments[segment],
[overlayBoundaryRectangles[index]], maxNudge,
overlayBoundaryRectangles[index], maxNudge,
);
}
for (let index = processedSegments; index < boundarySegments.length; index++)
@@ -1364,9 +1486,8 @@ export function resolveIsoOverlayCollisions(
};
const evaluateNewCandidates = (): void => {
const entries = [...boundaryCandidates.entries()]
.filter(([key]) => !evaluated.has(key))
.map(([, offset]) => ({
const entries = boundaryCandidates.pending.splice(0)
.map((offset) => ({
offset, distance: Math.hypot(offset[0], offset[1]),
}))
.sort((a, b) => a.distance - b.distance
@@ -1379,9 +1500,6 @@ export function resolveIsoOverlayCollisions(
? best.placement.nudgeDistanceCss : Infinity;
if (entry.distance > maxNudge + EPS || entry.distance > bestFreeDistance + EPS) break;
const offset = entry.offset;
const key = boundaryCandidateKey(offset[0], offset[1]);
if (evaluated.has(key)) continue;
evaluated.add(key);
if (Math.abs(offset[0] - baseOffsetCss[0]) <= EPS
&& Math.abs(offset[1] - baseOffsetCss[1]) <= EPS) continue;
const shape = candidateShape(offset);
@@ -1394,8 +1512,9 @@ export function resolveIsoOverlayCollisions(
|| best.conflicts.length && shape.penalty < best.penalty - EPS;
if (!canWin) continue;
const inspection = inspectCandidate(shape);
if (!inspection.allowed) {
for (const wallIndex of inspection.walls) {
if (inspection !== INSPECTION_ALLOWED) {
if (inspection >= 0) {
const wallIndex = inspection;
if (expandedWalls.has(wallIndex)) continue;
const witness = {
index: wallIndex,
@@ -1461,18 +1580,6 @@ export function resolveIsoOverlayCollisions(
refreshBoundaryEvents();
evaluateNewCandidates();
}
if (best && !best.conflicts.length) {
const around = best.placement.nudgeCss;
for (const entry of localRefinementOffsets(around, maxNudge)) {
if (entry.distance >= best.placement.nudgeDistanceCss - EPS) break;
const shape = candidateShape(entry.offset);
if (!shape || shape.conflicts.length || !candidateAllowed(shape)) continue;
best = {
placement: placementAtGroupOffset(base, entry.offset, unitsPerPixel, false),
bounds: shape.bounds, conflicts: shape.conflicts, penalty: shape.penalty,
};
}
}
}
if (!best) {
const placement = placementAtGroupOffset(base, baseOffsetCss, unitsPerPixel, true);
+24 -6
View File
@@ -974,13 +974,31 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
if (previous?.collisionSignature === collisionSignature
&& samePlacementMap(previous.rooms, roomPlacements)) return previous;
const previousEntries = new Map(previous?.entries.map((entry) => [
`${entry.kind}\u0000${entry.id}`, entry,
]) || []);
const collision = mode === 'live' ? resolveIsoOverlayCollisions({
items: entries.flatMap((entry) => entry.kind === 'room-label' ? [] : [{
id: entry.id,
kind: entry.kind,
placement: entry.placement,
screenHalfSize: entry.screenHalfSize,
}]),
items: entries.flatMap((entry) => {
if (entry.kind === 'room-label') return [];
const before = previousEntries.get(`${entry.kind}\u0000${entry.id}`);
const sameShape = !!before
&& before.screenHalfSize[0] === entry.screenHalfSize[0]
&& before.screenHalfSize[1] === entry.screenHalfSize[1]
&& before.placement.floorAnchor[0] === entry.placement.floorAnchor[0]
&& before.placement.floorAnchor[1] === entry.placement.floorAnchor[1]
&& (before.placement.owner?.id || '') === (entry.placement.owner?.id || '');
return [{
id: entry.id,
kind: entry.kind,
placement: entry.placement,
screenHalfSize: entry.screenHalfSize,
...(sameShape ? { nudgeHintCss: [
before!.placement.nudgeScene[0] / unitsPerPixel,
before!.placement.nudgeScene[1] / unitsPerPixel,
] as ScenePoint } : {}),
}];
}),
rooms,
wallSilhouettes: input.wallSilhouettes,
sceneUnitsPerCssPixel: unitsPerPixel,