mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Разрешение коллизий 2.5D-подписей сканировало диск смещений с шагом 1 px и для каждого кандидата спрашивало пространственный хэш и все стены. Замер показал: на large-house-isometric-v1 580 размещений дают 2 523 652 смещения и 1 742 740 проверок отпечатка против стен, 98 % позиций отвергают стены. Поиск переведён на решётку 4 px с уточнением 1 px вокруг найденного места (и вокруг лучшего конфликтующего кандидата — без второго уточнения качество разрешения падало с 308 до 304). Дешёвая часть кандидата (позиция, габариты, конфликты, штраф) отделена от дорогой (комната-владелец, непрерывность сегмента, зазор до стены); соседи считаются один раз на элемент; расстояния таблицы смещений предвычислены; добавлены AABB комнаты-владельца и широкая фаза по стенам. Групповой проход 9584 → 839 мс, смещений 2 523 652 → 157 618, разрешено 308 / остаток 272 — как до правки. Issue: #583 User-Visible: no
1074 lines
46 KiB
TypeScript
1074 lines
46 KiB
TypeScript
import {
|
|
ISO_CAMERA,
|
|
ISO_OVERLAY_VISUAL_OFFSET,
|
|
ISO_WALL_HEIGHT,
|
|
projectPlanPoint,
|
|
unprojectFloorPoint,
|
|
type IsoCamera,
|
|
type PlanPoint,
|
|
type ScenePoint,
|
|
} from './iso-projection';
|
|
|
|
export const ISO_OVERLAY_SAFETY_GAP_CSS_PX = 4;
|
|
export const ISO_OVERLAY_MAX_NUDGE_CSS_PX = 48;
|
|
// 48 / 2^14 < 0.003 CSS px: well below raster precision, without paying for
|
|
// the former 28 exact wall tests per raised overlay on every zoom level.
|
|
const ISO_OVERLAY_NUDGE_SEARCH_ITERATIONS = 14;
|
|
|
|
export type IsoRaisedOverlayKind = 'device' | 'room-label' | 'opening-lock';
|
|
export type IsoFloorOverlayKind = 'vacuum' | 'vacuum-trail' | 'glow' | 'room-fill'
|
|
| 'room-hover' | 'sunlight' | 'decor' | 'furniture' | 'backdrop';
|
|
export type IsoOverlayKind = IsoRaisedOverlayKind | IsoFloorOverlayKind;
|
|
export type IsoOverlayPlane = 'floor' | 'raised';
|
|
|
|
export interface IsoOverlayRoom {
|
|
id: string;
|
|
outer: readonly PlanPoint[];
|
|
holes?: readonly (readonly PlanPoint[])[];
|
|
/** A cached, proven inner control point is preferred when the host has one. */
|
|
safePoint?: PlanPoint;
|
|
}
|
|
|
|
/** A projected, canonical physical-wall surface. No union is done per marker. */
|
|
export interface IsoWallSilhouette {
|
|
outer: readonly ScenePoint[];
|
|
holes?: readonly (readonly ScenePoint[])[];
|
|
}
|
|
|
|
export interface IsoOverlayOwner {
|
|
id: string;
|
|
area: number;
|
|
safePoint: PlanPoint | null;
|
|
}
|
|
|
|
export interface IsoOverlayOwnerInput {
|
|
kind: IsoRaisedOverlayKind;
|
|
floorAnchor: PlanPoint;
|
|
rooms: readonly IsoOverlayRoom[];
|
|
/** Device binding, room label owner, or the room selected by opening-host geometry. */
|
|
preferredRoomId?: string | null;
|
|
/** Internal fast path for room rows already normalised by isoOverlayRooms(). */
|
|
roomsValidated?: boolean;
|
|
}
|
|
|
|
export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
|
|
showBorders: boolean;
|
|
wallSilhouettes: readonly IsoWallSilhouette[];
|
|
/** Internal fast path for silhouettes produced by the cached structural scene. */
|
|
wallGeometryValidated?: boolean;
|
|
/** Half-size of the invisible floor-parallel safety footprint in plan units. */
|
|
footprintHalfSize: PlanPoint;
|
|
wallHeight?: number;
|
|
visualOffset?: number;
|
|
/** Uniform viewBox units represented by one CSS pixel at the current viewport. */
|
|
sceneUnitsPerCssPixel?: number;
|
|
safetyGapCssPx?: number;
|
|
maxNudgeCssPx?: number;
|
|
filtersSupported?: boolean;
|
|
hovered?: boolean;
|
|
focused?: boolean;
|
|
selected?: boolean;
|
|
camera?: IsoCamera;
|
|
/** 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 {
|
|
from: ScenePoint;
|
|
to: ScenePoint;
|
|
visible: boolean;
|
|
length: number;
|
|
angleDeg: number;
|
|
}
|
|
|
|
export interface IsoOverlayPlacement {
|
|
plane: IsoOverlayPlane;
|
|
owner: IsoOverlayOwner | null;
|
|
/** This is always the input logical point; runtime nudge never mutates it. */
|
|
floorAnchor: PlanPoint;
|
|
floorScene: ScenePoint;
|
|
raisedScene: ScenePoint;
|
|
visualScene: ScenePoint;
|
|
/** Invisible collision/fit footprint; never render it as a surface. */
|
|
footprint: readonly ScenePoint[];
|
|
nudgeScene: ScenePoint;
|
|
nudgeCss: ScenePoint;
|
|
nudgeDistanceCss: number;
|
|
nudged: boolean;
|
|
nearWallBefore: boolean;
|
|
nearWallAfter: boolean;
|
|
cleared: boolean;
|
|
capped: boolean;
|
|
grounding: { center: ScenePoint; visible: boolean };
|
|
tether: IsoOverlayTetherGeometry;
|
|
status: 'ok' | 'degraded';
|
|
reason: 'invalid-wall-geometry' | 'missing-owner' | 'invalid-safe-point'
|
|
| 'owner-boundary' | 'nudge-cap' | 'overlay-collision' | null;
|
|
}
|
|
|
|
export type IsoOverlayCollisionKind = Exclude<IsoRaisedOverlayKind, 'room-label'>;
|
|
|
|
export interface IsoOverlayCollisionItem {
|
|
id: string;
|
|
kind: IsoOverlayCollisionKind;
|
|
placement: IsoOverlayPlacement;
|
|
/** Axis-aligned screen-facing half-size in scene units. */
|
|
screenHalfSize: PlanPoint;
|
|
}
|
|
|
|
export interface IsoOverlayCollisionInput {
|
|
items: readonly IsoOverlayCollisionItem[];
|
|
rooms: readonly IsoOverlayRoom[];
|
|
wallSilhouettes: readonly IsoWallSilhouette[];
|
|
sceneUnitsPerCssPixel: number;
|
|
visualOffset?: number;
|
|
safetyGapCssPx?: number;
|
|
maxNudgeCssPx?: number;
|
|
camera?: IsoCamera;
|
|
}
|
|
|
|
export interface IsoOverlayCollisionResult {
|
|
placements: ReadonlyMap<string, IsoOverlayPlacement>;
|
|
residualPairs: readonly (readonly [string, string])[];
|
|
}
|
|
|
|
const EPS = 1e-9;
|
|
|
|
/** Stable identity shared by the pure resolver and the render-scene maps. */
|
|
export const isoOverlayCollisionKey = (
|
|
kind: IsoOverlayCollisionKind, id: string,
|
|
): string => `${kind}\u0000${id}`;
|
|
|
|
const finitePoint = (point: readonly number[]): boolean =>
|
|
point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
|
|
|
function ringArea(ring: readonly (readonly number[])[]): number {
|
|
let area = 0;
|
|
for (let index = 0; index < ring.length; index++) {
|
|
const point = ring[index], next = ring[(index + 1) % ring.length];
|
|
area += point[0] * next[1] - next[0] * point[1];
|
|
}
|
|
return area / 2;
|
|
}
|
|
|
|
function validRing(ring: readonly (readonly number[])[]): boolean {
|
|
return ring.length >= 3 && ring.every(finitePoint) && Math.abs(ringArea(ring)) > EPS;
|
|
}
|
|
|
|
function pointSegmentDistance(
|
|
point: readonly number[], start: readonly number[], end: readonly number[],
|
|
): number {
|
|
const dx = end[0] - start[0], dy = end[1] - start[1];
|
|
const lengthSquared = dx * dx + dy * dy;
|
|
const t = lengthSquared
|
|
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx
|
|
+ (point[1] - start[1]) * dy) / lengthSquared))
|
|
: 0;
|
|
return Math.hypot(point[0] - start[0] - t * dx, point[1] - start[1] - t * dy);
|
|
}
|
|
|
|
function pointOnRing(
|
|
point: readonly number[], ring: readonly (readonly number[])[], epsilon = 1e-7,
|
|
): boolean {
|
|
for (let index = 0; index < ring.length; index++) {
|
|
if (pointSegmentDistance(point, ring[index], ring[(index + 1) % ring.length]) <= epsilon)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function pointInRing(point: readonly number[], ring: readonly (readonly number[])[]): boolean {
|
|
let inside = false;
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
const a = ring[index], b = ring[previous];
|
|
if ((a[1] > point[1]) !== (b[1] > point[1])
|
|
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
|
|
inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
function roomArea(room: IsoOverlayRoom): number {
|
|
return Math.max(0, Math.abs(ringArea(room.outer))
|
|
- (room.holes || []).reduce((sum, hole) => sum + Math.abs(ringArea(hole)), 0));
|
|
}
|
|
|
|
function validRoom(room: IsoOverlayRoom): boolean {
|
|
return !!room.id && validRing(room.outer) && (room.holes || []).every(validRing)
|
|
&& roomArea(room) > EPS;
|
|
}
|
|
|
|
function pointStrictlyInRoom(point: PlanPoint, room: IsoOverlayRoom): boolean {
|
|
if (!validRoom(room) || 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 roomBoundaryDistance(point: PlanPoint, room: IsoOverlayRoom): number {
|
|
let distance = Infinity;
|
|
for (const ring of [room.outer, ...(room.holes || [])]) {
|
|
for (let index = 0; index < ring.length; index++)
|
|
distance = Math.min(distance, pointSegmentDistance(point, ring[index], ring[(index + 1) % ring.length]));
|
|
}
|
|
return distance;
|
|
}
|
|
|
|
/** Deterministic fallback when the host cannot provide its cached visual centre. */
|
|
export function isoRoomSafePoint(room: IsoOverlayRoom): PlanPoint | null {
|
|
if (!validRoom(room)) return null;
|
|
if (room.safePoint && pointStrictlyInRoom(room.safePoint, room))
|
|
return [room.safePoint[0], room.safePoint[1]];
|
|
|
|
const xs = room.outer.map((point) => point[0]);
|
|
const ys = room.outer.map((point) => point[1]);
|
|
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
|
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
const candidates: PlanPoint[] = [
|
|
[(minX + maxX) / 2, (minY + maxY) / 2],
|
|
[xs.reduce((sum, value) => sum + value, 0) / xs.length,
|
|
ys.reduce((sum, value) => sum + value, 0) / ys.length],
|
|
];
|
|
for (let index = 0; index < room.outer.length; index++) {
|
|
const before = room.outer[(index + room.outer.length - 1) % room.outer.length];
|
|
const point = room.outer[index], after = room.outer[(index + 1) % room.outer.length];
|
|
candidates.push([(before[0] + point[0] + after[0]) / 3,
|
|
(before[1] + point[1] + after[1]) / 3]);
|
|
}
|
|
|
|
let best: PlanPoint | null = null;
|
|
let bestClearance = -Infinity;
|
|
const consider = (candidate: PlanPoint): void => {
|
|
if (!pointStrictlyInRoom(candidate, room)) return;
|
|
const clearance = roomBoundaryDistance(candidate, room);
|
|
if (clearance > bestClearance + EPS) {
|
|
best = candidate;
|
|
bestClearance = clearance;
|
|
}
|
|
};
|
|
candidates.forEach(consider);
|
|
// A bounded grid also covers concave rooms and rooms with holes without any
|
|
// random sampling. Hosts should normally supply their cached inner point.
|
|
const steps = 16;
|
|
for (let xIndex = 1; xIndex < steps; xIndex++) {
|
|
for (let yIndex = 1; yIndex < steps; yIndex++) {
|
|
consider([
|
|
minX + ((maxX - minX) * xIndex) / steps,
|
|
minY + ((maxY - minY) * yIndex) / steps,
|
|
]);
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
const stableIdCompare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
|
|
|
|
export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayOwner | null {
|
|
if (!finitePoint(input.floorAnchor)) return null;
|
|
const rooms = input.roomsValidated ? input.rooms : input.rooms.filter(validRoom);
|
|
const preferred = input.preferredRoomId
|
|
? rooms.find((room) => room.id === input.preferredRoomId) || null
|
|
: null;
|
|
let room: IsoOverlayRoom | null = null;
|
|
if (input.kind === 'device') {
|
|
if (preferred && pointStrictlyInRoom(input.floorAnchor, preferred)) room = preferred;
|
|
if (!room) {
|
|
room = rooms.filter((candidate) => pointStrictlyInRoom(input.floorAnchor, candidate))
|
|
.sort((a, b) => roomArea(a) - roomArea(b) || stableIdCompare(a.id, b.id))[0] || null;
|
|
}
|
|
} else {
|
|
// Room labels and lock badges inherit their owner from room/host geometry;
|
|
// a saved label may legitimately lie outside that room.
|
|
room = preferred;
|
|
}
|
|
return room ? {
|
|
id: room.id,
|
|
area: roomArea(room),
|
|
safePoint: input.roomsValidated && room.safePoint
|
|
? [room.safePoint[0], room.safePoint[1]] : isoRoomSafePoint(room),
|
|
} : null;
|
|
}
|
|
|
|
export function isoOverlayPlane(kind: IsoOverlayKind, showBorders: boolean): IsoOverlayPlane {
|
|
return showBorders && (kind === 'device' || kind === 'room-label' || kind === 'opening-lock')
|
|
? 'raised' : 'floor';
|
|
}
|
|
|
|
export function buildIsoFootprintPolygon(
|
|
center: PlanPoint,
|
|
halfSize: PlanPoint,
|
|
zUnits: number,
|
|
camera: IsoCamera = ISO_CAMERA,
|
|
sceneOffset: ScenePoint = [0, 0],
|
|
): readonly ScenePoint[] {
|
|
if (!finitePoint(center) || !finitePoint(halfSize) || halfSize[0] < 0 || halfSize[1] < 0
|
|
|| !Number.isFinite(zUnits) || !finitePoint(sceneOffset))
|
|
throw new Error('invalid isometric overlay footprint');
|
|
return ([
|
|
[center[0] - halfSize[0], center[1] - halfSize[1]],
|
|
[center[0] + halfSize[0], center[1] - halfSize[1]],
|
|
[center[0] + halfSize[0], center[1] + halfSize[1]],
|
|
[center[0] - halfSize[0], center[1] + halfSize[1]],
|
|
] as PlanPoint[]).map((point) => {
|
|
const projected = projectPlanPoint(point, zUnits, camera);
|
|
return [projected[0] + sceneOffset[0], projected[1] + sceneOffset[1]] as ScenePoint;
|
|
});
|
|
}
|
|
|
|
function orientation(a: readonly number[], b: readonly number[], c: readonly number[]): number {
|
|
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
}
|
|
|
|
function segmentsIntersect(
|
|
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
|
|
): boolean {
|
|
const o1 = orientation(a, b, c), o2 = orientation(a, b, d);
|
|
const o3 = orientation(c, d, a), o4 = orientation(c, d, b);
|
|
if (((o1 > EPS && o2 < -EPS) || (o1 < -EPS && o2 > EPS))
|
|
&& ((o3 > EPS && o4 < -EPS) || (o3 < -EPS && o4 > EPS))) return true;
|
|
return Math.abs(o1) <= EPS && pointSegmentDistance(c, a, b) <= EPS
|
|
|| Math.abs(o2) <= EPS && pointSegmentDistance(d, a, b) <= EPS
|
|
|| Math.abs(o3) <= EPS && pointSegmentDistance(a, c, d) <= EPS
|
|
|| Math.abs(o4) <= EPS && pointSegmentDistance(b, c, d) <= EPS;
|
|
}
|
|
|
|
function segmentDistance(
|
|
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
|
|
): number {
|
|
if (segmentsIntersect(a, b, c, d)) return 0;
|
|
return Math.min(pointSegmentDistance(a, c, d), pointSegmentDistance(b, c, d),
|
|
pointSegmentDistance(c, a, b), pointSegmentDistance(d, a, b));
|
|
}
|
|
|
|
function pointInSilhouette(point: ScenePoint, silhouette: IsoWallSilhouette): boolean {
|
|
if (!pointInRing(point, silhouette.outer)) return false;
|
|
return !(silhouette.holes || []).some((hole) => pointInRing(point, hole));
|
|
}
|
|
|
|
type Bounds = readonly [minX: number, minY: number, maxX: number, maxY: number];
|
|
const silhouetteBoundsCache = new WeakMap<IsoWallSilhouette, Bounds | null>();
|
|
|
|
function ringBounds(ring: readonly (readonly number[])[]): Bounds | null {
|
|
if (!ring.length) return null;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const point of ring) {
|
|
if (!finitePoint(point)) return null;
|
|
minX = Math.min(minX, point[0]); minY = Math.min(minY, point[1]);
|
|
maxX = Math.max(maxX, point[0]); maxY = Math.max(maxY, point[1]);
|
|
}
|
|
return [minX, minY, maxX, maxY];
|
|
}
|
|
|
|
function silhouetteBounds(silhouette: IsoWallSilhouette, cache: boolean): Bounds | null {
|
|
if (!cache) return ringBounds(silhouette.outer);
|
|
if (silhouetteBoundsCache.has(silhouette)) return silhouetteBoundsCache.get(silhouette) ?? null;
|
|
const bounds = ringBounds(silhouette.outer);
|
|
silhouetteBoundsCache.set(silhouette, bounds);
|
|
return bounds;
|
|
}
|
|
|
|
function boundsNear(a: Bounds, b: Bounds, gap: number): boolean {
|
|
return a[0] <= b[2] + gap && a[2] >= b[0] - gap
|
|
&& a[1] <= b[3] + gap && a[3] >= b[1] - gap;
|
|
}
|
|
|
|
function segmentBoundsNear(
|
|
bounds: Bounds, a: readonly number[], b: readonly number[], gap: number,
|
|
): boolean {
|
|
return Math.min(a[0], b[0]) <= bounds[2] + gap
|
|
&& Math.max(a[0], b[0]) >= bounds[0] - gap
|
|
&& Math.min(a[1], b[1]) <= bounds[3] + gap
|
|
&& Math.max(a[1], b[1]) >= bounds[1] - gap;
|
|
}
|
|
|
|
function footprintNearSilhouette(
|
|
footprint: readonly ScenePoint[], footprintBounds: Bounds,
|
|
silhouette: IsoWallSilhouette, gap: number, cacheBounds: boolean,
|
|
): boolean {
|
|
const wallBounds = silhouetteBounds(silhouette, cacheBounds);
|
|
if (!wallBounds || !boundsNear(footprintBounds, wallBounds, gap)) return false;
|
|
if (footprint.some((point) => pointInSilhouette(point, silhouette))) return true;
|
|
if (silhouette.outer.some((point) =>
|
|
point[0] >= footprintBounds[0] && point[0] <= footprintBounds[2]
|
|
&& point[1] >= footprintBounds[1] && point[1] <= footprintBounds[3]
|
|
&& pointInRing(point, footprint))) return true;
|
|
for (const wallRing of [silhouette.outer, ...(silhouette.holes || [])]) {
|
|
for (let footprintIndex = 0; footprintIndex < footprint.length; footprintIndex++) {
|
|
const footprintNext = (footprintIndex + 1) % footprint.length;
|
|
for (let wallIndex = 0; wallIndex < wallRing.length; wallIndex++) {
|
|
const wallNext = (wallIndex + 1) % wallRing.length;
|
|
if (!segmentBoundsNear(footprintBounds, wallRing[wallIndex], wallRing[wallNext], gap))
|
|
continue;
|
|
if (segmentDistance(footprint[footprintIndex], footprint[footprintNext],
|
|
wallRing[wallIndex], wallRing[wallNext]) <= gap + EPS) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function validSilhouette(silhouette: IsoWallSilhouette): boolean {
|
|
return validRing(silhouette.outer) && (silhouette.holes || []).every(validRing);
|
|
}
|
|
|
|
/** 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;
|
|
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]))
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function tetherGeometry(
|
|
from: ScenePoint, to: ScenePoint, visible: boolean,
|
|
): IsoOverlayTetherGeometry {
|
|
const dx = to[0] - from[0], dy = to[1] - from[1];
|
|
return { from, to, visible, length: Math.hypot(dx, dy), angleDeg: Math.atan2(dy, dx) * 180 / Math.PI };
|
|
}
|
|
|
|
/**
|
|
* Resolve one overlay without mutating its saved coordinate. Collision data is
|
|
* already projected/cached structural data, so HA and interaction updates do
|
|
* not repeat wall unions.
|
|
*/
|
|
export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): IsoOverlayPlacement {
|
|
const camera = input.camera || ISO_CAMERA;
|
|
const wallHeight = input.wallHeight ?? ISO_WALL_HEIGHT;
|
|
const visualOffset = input.visualOffset ?? ISO_OVERLAY_VISUAL_OFFSET;
|
|
const unitsPerPixel = input.sceneUnitsPerCssPixel ?? 1;
|
|
const safetyGap = input.safetyGapCssPx ?? ISO_OVERLAY_SAFETY_GAP_CSS_PX;
|
|
const maxNudge = input.maxNudgeCssPx ?? ISO_OVERLAY_MAX_NUDGE_CSS_PX;
|
|
if (!finitePoint(input.floorAnchor) || !finitePoint(input.footprintHalfSize)
|
|
|| input.footprintHalfSize[0] < 0 || input.footprintHalfSize[1] < 0
|
|
|| !Number.isFinite(wallHeight) || wallHeight < 0
|
|
|| !Number.isFinite(visualOffset) || visualOffset < 0
|
|
|| !Number.isFinite(unitsPerPixel) || unitsPerPixel <= 0
|
|
|| !Number.isFinite(safetyGap) || safetyGap < 0
|
|
|| !Number.isFinite(maxNudge) || maxNudge < 0)
|
|
throw new Error('invalid isometric overlay input');
|
|
|
|
const floorAnchor: PlanPoint = [input.floorAnchor[0], input.floorAnchor[1]];
|
|
const floorScene = projectPlanPoint(floorAnchor, 0, camera);
|
|
const plane = isoOverlayPlane(input.kind, input.showBorders);
|
|
if (plane === 'floor') {
|
|
const tether = tetherGeometry(floorScene, floorScene, false);
|
|
return {
|
|
plane, owner: null, floorAnchor, floorScene, raisedScene: floorScene,
|
|
visualScene: floorScene, footprint: [], nudgeScene: [0, 0], nudgeCss: [0, 0],
|
|
nudgeDistanceCss: 0, nudged: false, nearWallBefore: false, nearWallAfter: false,
|
|
cleared: true, capped: false,
|
|
grounding: { center: floorScene, visible: false }, tether,
|
|
status: 'ok', reason: null,
|
|
};
|
|
}
|
|
|
|
// Stage 4 keeps the canonical floor anchor and collision footprint, but the
|
|
// screen-facing content sits just above the floor instead of on a tall
|
|
// wall-height mast. The wall height is still validated because the same
|
|
// placement consumes wall silhouettes built from that physical height.
|
|
const raisedHeight = visualOffset;
|
|
const raisedScene = projectPlanPoint(floorAnchor, raisedHeight, camera);
|
|
const owner = input.ownerAlreadyResolved
|
|
? input.resolvedOwner ?? null
|
|
: resolveIsoOverlayOwner(input);
|
|
const geometryValid = input.wallGeometryValidated ?? input.wallSilhouettes.every(validSilhouette);
|
|
const gapUnits = safetyGap * unitsPerPixel;
|
|
const baseFootprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize,
|
|
raisedHeight, camera);
|
|
const isNear = (footprint: readonly ScenePoint[]): boolean => {
|
|
const bounds = ringBounds(footprint);
|
|
return !!bounds && input.wallSilhouettes.some((wall) =>
|
|
footprintNearSilhouette(footprint, bounds, wall, gapUnits,
|
|
input.wallGeometryValidated === true));
|
|
};
|
|
const nearWallBefore = geometryValid ? isNear(baseFootprint) : true;
|
|
|
|
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';
|
|
|
|
if (geometryValid && nearWallBefore) {
|
|
if (!owner) {
|
|
status = 'degraded';
|
|
reason = 'missing-owner';
|
|
} else if (!owner.safePoint) {
|
|
status = 'degraded';
|
|
reason = 'invalid-safe-point';
|
|
} else {
|
|
const ownerRoom = input.rooms.find((room) => room.id === owner.id) || null;
|
|
const safeScene = projectPlanPoint(owner.safePoint, raisedHeight, camera);
|
|
const dx = safeScene[0] - raisedScene[0], dy = safeScene[1] - raisedScene[1];
|
|
const length = Math.hypot(dx, dy);
|
|
if (length <= EPS) {
|
|
status = 'degraded';
|
|
reason = 'invalid-safe-point';
|
|
} else {
|
|
const ux = dx / length, uy = dy / length;
|
|
// The visual point may approach the proven inner control point but
|
|
// must never run past it and leave the owning-room direction again.
|
|
const searchLimitCss = Math.min(maxNudge, length / unitsPerPixel);
|
|
const searchRatio = searchLimitCss * unitsPerPixel / length;
|
|
const searchEndPlan: PlanPoint = [
|
|
floorAnchor[0] + (owner.safePoint[0] - floorAnchor[0]) * searchRatio,
|
|
floorAnchor[1] + (owner.safePoint[1] - floorAnchor[1]) * searchRatio,
|
|
];
|
|
// The structural scene may contain hundreds of faces. Only silhouettes
|
|
// intersecting the complete swept footprint envelope can affect this nudge;
|
|
// filter them once instead of repeating the whole-scene scan for every
|
|
// coarse and binary probe.
|
|
const maxOffset: ScenePoint = [
|
|
ux * searchLimitCss * unitsPerPixel,
|
|
uy * searchLimitCss * unitsPerPixel,
|
|
];
|
|
const sweptBounds = ringBounds([
|
|
...baseFootprint,
|
|
...baseFootprint.map((point) => [
|
|
point[0] + maxOffset[0], point[1] + maxOffset[1],
|
|
] as ScenePoint),
|
|
]);
|
|
const nearbyWalls = sweptBounds
|
|
? input.wallSilhouettes.filter((wall) => {
|
|
const wallBounds = silhouetteBounds(wall, input.wallGeometryValidated === true);
|
|
return !!wallBounds && boundsNear(sweptBounds, wallBounds, gapUnits);
|
|
})
|
|
: input.wallSilhouettes;
|
|
const pathSafeToLimit = !!ownerRoom
|
|
&& segmentStrictlyInRoom(floorAnchor, searchEndPlan, ownerRoom);
|
|
const collidesAt = (candidateCss: number): boolean => {
|
|
const offset: ScenePoint = [ux * candidateCss * unitsPerPixel,
|
|
uy * candidateCss * unitsPerPixel];
|
|
const footprint = baseFootprint.map((point) =>
|
|
[point[0] + offset[0], point[1] + offset[1]] as ScenePoint);
|
|
const bounds = ringBounds(footprint);
|
|
return !!bounds && nearbyWalls.some((wall) =>
|
|
footprintNearSilhouette(footprint, bounds, wall, gapUnits,
|
|
input.wallGeometryValidated === true));
|
|
};
|
|
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, 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 (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';
|
|
}
|
|
}
|
|
nudgeScene = [ux * distanceCss * unitsPerPixel, uy * distanceCss * unitsPerPixel];
|
|
}
|
|
}
|
|
}
|
|
|
|
const visualScene: ScenePoint = [raisedScene[0] + nudgeScene[0], raisedScene[1] + nudgeScene[1]];
|
|
const footprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize,
|
|
raisedHeight, camera, nudgeScene);
|
|
const nearWallAfter = geometryValid ? knownNearWallAfter ?? isNear(footprint) : true;
|
|
const nudged = distanceCss > EPS;
|
|
// Ownership remains encoded by the immutable anchor and invisible bounded
|
|
// footprint. Stage 4 deliberately removes the visible ground dot and long
|
|
// tether which made the architectural view look like a debug overlay.
|
|
const tetherVisible = false;
|
|
return {
|
|
plane, owner, floorAnchor, floorScene, raisedScene, visualScene, footprint,
|
|
nudgeScene,
|
|
nudgeCss: [nudgeScene[0] / unitsPerPixel, nudgeScene[1] / unitsPerPixel],
|
|
nudgeDistanceCss: distanceCss,
|
|
nudged, nearWallBefore, nearWallAfter,
|
|
cleared: !nearWallAfter, capped,
|
|
grounding: { center: floorScene, visible: false },
|
|
tether: tetherGeometry(floorScene, visualScene, tetherVisible),
|
|
status, reason,
|
|
};
|
|
}
|
|
|
|
const ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX = 1;
|
|
const ISO_OVERLAY_GROUP_CELL_CSS_PX = 64;
|
|
|
|
/**
|
|
* One bounded, order-independent candidate lattice. It is allocated once and
|
|
* reused by every card. The radius is the public absolute displacement cap,
|
|
* not a per-collision allowance.
|
|
*/
|
|
/** Смещение вместе с его расстоянием: hypot на 7238 кандидатов считался заново. */
|
|
interface GroupOffset { readonly offset: ScenePoint; readonly distance: number }
|
|
|
|
const ISO_OVERLAY_GROUP_OFFSETS_CSS: readonly ScenePoint[] = Object.freeze((() => {
|
|
const offsets: ScenePoint[] = [];
|
|
const limit = Math.floor(ISO_OVERLAY_MAX_NUDGE_CSS_PX / ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX);
|
|
for (let y = -limit; y <= limit; y++) {
|
|
for (let x = -limit; x <= limit; x++) {
|
|
if (x * x + y * y > limit * limit) continue;
|
|
offsets.push(Object.freeze([
|
|
x * ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX,
|
|
y * ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX,
|
|
]) as ScenePoint);
|
|
}
|
|
}
|
|
offsets.sort((a, b) => a[0] * a[0] + a[1] * a[1]
|
|
- (b[0] * b[0] + b[1] * b[1])
|
|
|| a[1] - b[1] || a[0] - b[0]);
|
|
return offsets;
|
|
})());
|
|
|
|
const ISO_OVERLAY_GROUP_OFFSET_TABLE: readonly GroupOffset[] = Object.freeze(
|
|
ISO_OVERLAY_GROUP_OFFSETS_CSS.map((offset) => Object.freeze({
|
|
offset, distance: Math.hypot(offset[0], offset[1]),
|
|
})) as GroupOffset[],
|
|
);
|
|
|
|
/**
|
|
* Шаг грубого прохода (#583 перед бетой .5).
|
|
*
|
|
* Поиск был ограничен радиусом, но не работой: диск в 48 px с шагом 1 px — это
|
|
* 7238 кандидатов, и у каждого проверяется попадание в комнату и близость к
|
|
* кладке. Замер на `large-house-isometric-v1`: 2 523 652 просмотренных смещения,
|
|
* 1 742 740 проверок footprint против силуэтов, из них 98 % отвергнуты стеной, —
|
|
* 9.6 с на групповой проход и профиль, пробитый в 2–33 раза.
|
|
*
|
|
* Сначала идёт решётка с шагом 4 px (около 450 кандидатов), затем — уточнение
|
|
* 1 px в окрестности найденного места. Тесная сцена, где законного места нет
|
|
* вовсе, стоит 450 проверок вместо 7238; §6.4 ТЗ и требовал ограниченного
|
|
* детерминированного поиска, а не полного перебора.
|
|
*/
|
|
const ISO_OVERLAY_GROUP_COARSE_STEP_CSS_PX = 4;
|
|
|
|
const ISO_OVERLAY_GROUP_COARSE_TABLE: readonly GroupOffset[] = Object.freeze(
|
|
ISO_OVERLAY_GROUP_OFFSET_TABLE.filter((entry) =>
|
|
entry.offset[0] % ISO_OVERLAY_GROUP_COARSE_STEP_CSS_PX === 0
|
|
&& entry.offset[1] % ISO_OVERLAY_GROUP_COARSE_STEP_CSS_PX === 0) as GroupOffset[],
|
|
);
|
|
|
|
/** Уточнение вокруг грубого попадания: те же смещения, но только рядом с ним. */
|
|
function refinementOffsets(around: ScenePoint): readonly GroupOffset[] {
|
|
const reach = ISO_OVERLAY_GROUP_COARSE_STEP_CSS_PX;
|
|
return ISO_OVERLAY_GROUP_OFFSET_TABLE.filter((entry) =>
|
|
Math.abs(entry.offset[0] - around[0]) <= reach
|
|
&& Math.abs(entry.offset[1] - around[1]) <= reach);
|
|
}
|
|
|
|
function overlayRootBounds(
|
|
item: IsoOverlayCollisionItem, center: ScenePoint,
|
|
): Bounds {
|
|
return [
|
|
center[0] - item.screenHalfSize[0],
|
|
center[1] - item.screenHalfSize[1],
|
|
center[0] + item.screenHalfSize[0],
|
|
center[1] + item.screenHalfSize[1],
|
|
];
|
|
}
|
|
|
|
function expandedBounds(bounds: Bounds, gap: number): Bounds {
|
|
return [bounds[0] - gap, bounds[1] - gap, bounds[2] + gap, bounds[3] + gap];
|
|
}
|
|
|
|
function overlapPenalty(a: Bounds, b: Bounds, gap: number): number {
|
|
const x = Math.min(a[2], b[2]) - Math.max(a[0], b[0]) + gap;
|
|
const y = Math.min(a[3], b[3]) - Math.max(a[1], b[1]) + gap;
|
|
return x > EPS && y > EPS ? x * y : 0;
|
|
}
|
|
|
|
function raisedSceneToPlan(
|
|
point: ScenePoint, visualOffset: number, camera: IsoCamera,
|
|
): PlanPoint {
|
|
const tilt = camera.tiltDeg * Math.PI / 180;
|
|
return unprojectFloorPoint([
|
|
point[0],
|
|
point[1] + visualOffset * camera.zScale * Math.sin(tilt),
|
|
], camera);
|
|
}
|
|
|
|
function placementAtGroupOffset(
|
|
base: IsoOverlayPlacement,
|
|
offsetCss: ScenePoint,
|
|
unitsPerPixel: number,
|
|
residual: boolean,
|
|
): IsoOverlayPlacement {
|
|
const nudgeScene: ScenePoint = [
|
|
offsetCss[0] * unitsPerPixel,
|
|
offsetCss[1] * unitsPerPixel,
|
|
];
|
|
if (!residual
|
|
&& Math.abs(nudgeScene[0] - base.nudgeScene[0]) <= EPS
|
|
&& Math.abs(nudgeScene[1] - base.nudgeScene[1]) <= EPS) return base;
|
|
const delta: ScenePoint = [
|
|
nudgeScene[0] - base.nudgeScene[0],
|
|
nudgeScene[1] - base.nudgeScene[1],
|
|
];
|
|
const visualScene: ScenePoint = [
|
|
base.raisedScene[0] + nudgeScene[0],
|
|
base.raisedScene[1] + nudgeScene[1],
|
|
];
|
|
const nudgeDistanceCss = Math.hypot(offsetCss[0], offsetCss[1]);
|
|
return {
|
|
...base,
|
|
visualScene,
|
|
footprint: base.footprint.map((point) => [
|
|
point[0] + delta[0], point[1] + delta[1],
|
|
] as ScenePoint),
|
|
nudgeScene,
|
|
nudgeCss: offsetCss,
|
|
nudgeDistanceCss,
|
|
nudged: nudgeDistanceCss > EPS,
|
|
capped: base.capped || residual,
|
|
status: residual ? 'degraded' : base.status,
|
|
reason: residual ? 'overlay-collision' : base.reason,
|
|
tether: tetherGeometry(base.floorScene, visualScene, false),
|
|
};
|
|
}
|
|
|
|
type AcceptedOverlay = {
|
|
key: string;
|
|
bounds: Bounds;
|
|
};
|
|
|
|
/**
|
|
* Resolve device/lock collisions after each item has independently cleared
|
|
* wall geometry. Room labels deliberately do not enter this pass. Earlier
|
|
* items are the ones with the smaller already-required wall displacement;
|
|
* ties use kind/id, never HA registry or render order.
|
|
*/
|
|
export function resolveIsoOverlayCollisions(
|
|
input: IsoOverlayCollisionInput,
|
|
): IsoOverlayCollisionResult {
|
|
const unitsPerPixel = input.sceneUnitsPerCssPixel;
|
|
const maxNudge = input.maxNudgeCssPx ?? ISO_OVERLAY_MAX_NUDGE_CSS_PX;
|
|
const safetyGap = input.safetyGapCssPx ?? ISO_OVERLAY_SAFETY_GAP_CSS_PX;
|
|
const visualOffset = input.visualOffset ?? ISO_OVERLAY_VISUAL_OFFSET;
|
|
const camera = input.camera || ISO_CAMERA;
|
|
if (!(unitsPerPixel > 0) || !Number.isFinite(unitsPerPixel)
|
|
|| !(maxNudge >= 0) || !Number.isFinite(maxNudge)
|
|
|| !(safetyGap >= 0) || !Number.isFinite(safetyGap)) {
|
|
throw new Error('invalid isometric overlay collision input');
|
|
}
|
|
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 wallsValid = input.wallSilhouettes.every(validSilhouette);
|
|
const accepted: AcceptedOverlay[] = [];
|
|
const cells = new Map<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 cellRange = (bounds: Bounds): readonly [number, number, number, number] => [
|
|
Math.floor(bounds[0] / cellUnits), Math.floor(bounds[1] / cellUnits),
|
|
Math.floor(bounds[2] / cellUnits), Math.floor(bounds[3] / cellUnits),
|
|
];
|
|
const nearby = (bounds: Bounds): number[] => {
|
|
const [minX, minY, maxX, maxY] = cellRange(expandedBounds(bounds, gapUnits));
|
|
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);
|
|
}
|
|
}
|
|
return [...result].sort((a, b) => a - b);
|
|
};
|
|
const addAccepted = (value: AcceptedOverlay): void => {
|
|
const index = accepted.push(value) - 1;
|
|
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 list = cells.get(key) || [];
|
|
list.push(index);
|
|
cells.set(key, list);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const item of stableItems) {
|
|
const key = isoOverlayCollisionKey(item.kind, item.id);
|
|
const base = item.placement;
|
|
const baseOffsetCss: ScenePoint = [
|
|
base.nudgeScene[0] / unitsPerPixel,
|
|
base.nudgeScene[1] / unitsPerPixel,
|
|
];
|
|
const baseFootprint = base.footprint.map((point) => [
|
|
point[0] - base.nudgeScene[0], point[1] - base.nudgeScene[1],
|
|
] as ScenePoint);
|
|
const ownerRoom = base.owner ? rooms.get(base.owner.id) || null : null;
|
|
const currentPlan = raisedSceneToPlan(base.visualScene, visualOffset, camera);
|
|
const wallCandidates = wallsValid && baseFootprint.length
|
|
? input.wallSilhouettes.filter((wall) => {
|
|
const wallBounds = silhouetteBounds(wall, true);
|
|
if (!wallBounds) return false;
|
|
const radius = maxNudge * unitsPerPixel;
|
|
const footprintBounds = ringBounds(baseFootprint);
|
|
return !!footprintBounds && boundsNear(expandedBounds(footprintBounds, radius),
|
|
wallBounds, gapUnits);
|
|
})
|
|
: input.wallSilhouettes;
|
|
|
|
/**
|
|
* Дешёвая половина кандидата: где он окажется и с кем столкнётся.
|
|
*
|
|
* Порядок половин — не стиль, а цена (#583 перед бетой .5). Проверка
|
|
* комнаты и силуэтов стен строит трансформированный footprint и гоняет
|
|
* полигон против каждой стены-кандидата; на плотной группе полный обход
|
|
* диска в 7238 смещений делал это тысячи раз за один проход, и групповой
|
|
* резолвер стоил ~755 мс на вызов. Сначала считается то, что стоит
|
|
* обращения к сетке ячеек, и только у смещения, способного УЛУЧШИТЬ
|
|
* результат, проверяется допустимость.
|
|
*/
|
|
/**
|
|
* Соседи, до которых вообще можно дотянуться в пределах 48 px.
|
|
*
|
|
* Прежде каждое смещение спрашивало сетку ячеек заново: Set, разбор ключей
|
|
* и сортировка массива на каждого из 7238 кандидатов. Диапазон досягаемости
|
|
* известен заранее — он не зависит от смещения, — поэтому список строится
|
|
* один раз на элемент, и внутренний цикл остаётся арифметикой.
|
|
*/
|
|
const reachIndices = nearby(expandedBounds(
|
|
overlayRootBounds(item, base.raisedScene), maxNudge * unitsPerPixel,
|
|
));
|
|
const conflictsAt = (bounds: Bounds): { conflicts: number[]; penalty: number } => {
|
|
const conflicts: number[] = [];
|
|
let penalty = 0;
|
|
for (const index of reachIndices) {
|
|
const value = overlapPenalty(bounds, accepted[index].bounds, gapUnits);
|
|
if (value > EPS) { conflicts.push(index); penalty += value; }
|
|
}
|
|
return { conflicts, penalty };
|
|
};
|
|
|
|
const candidateShape = (offsetCss: ScenePoint): {
|
|
offsetScene: ScenePoint;
|
|
sameAsBase: boolean;
|
|
visualScene: ScenePoint;
|
|
bounds: Bounds;
|
|
conflicts: readonly number[];
|
|
penalty: number;
|
|
} | null => {
|
|
const distance = Math.hypot(offsetCss[0], offsetCss[1]);
|
|
if (distance > maxNudge + EPS) return null;
|
|
const offsetScene: ScenePoint = [
|
|
offsetCss[0] * unitsPerPixel, offsetCss[1] * unitsPerPixel,
|
|
];
|
|
const sameAsBase = Math.abs(offsetScene[0] - base.nudgeScene[0]) <= EPS
|
|
&& Math.abs(offsetScene[1] - base.nudgeScene[1]) <= EPS;
|
|
const visualScene: ScenePoint = [
|
|
base.raisedScene[0] + offsetScene[0],
|
|
base.raisedScene[1] + offsetScene[1],
|
|
];
|
|
const bounds = overlayRootBounds(item, visualScene);
|
|
const { conflicts, penalty } = conflictsAt(bounds);
|
|
return { offsetScene, sameAsBase, visualScene, bounds, conflicts, penalty };
|
|
};
|
|
|
|
/** Дорогая половина: комната владельца, кладка и непрерывность пути. */
|
|
/**
|
|
* Прямоугольник комнаты владельца — дешёвый отказ до полигонов (#583 перед
|
|
* бетой .5). Замер на `large-house-isometric-v1`: из 2 523 652 просмотренных
|
|
* смещений 1 911 721 доходило до проверки комнаты и кладки, и именно она
|
|
* съедала 8.5 из 9.6 секунд группового прохода. Подавляющее большинство
|
|
* этих точек лежит ВНЕ комнаты — узнать это можно сравнением четырёх чисел,
|
|
* а не обходом колец полигона и силуэтов стен.
|
|
*/
|
|
const ownerBox = ownerRoom ? ringBounds(ownerRoom.outer as readonly ScenePoint[]) : null;
|
|
|
|
/**
|
|
* Широкая фаза по стенам — то, чего требовал §6.4 ТЗ и чего не было.
|
|
*
|
|
* Замер на `large-house-isometric-v1`: 1 742 740 проверок footprint против
|
|
* 41 841 466 пар «кандидат × стена», 6.4 с из 8.9 с прохода. Стен в радиусе
|
|
* 48 px около двух десятков, но для КОНКРЕТНОГО смещения близка одна-две.
|
|
* Стены раскладываются по тем же ячейкам, что и принятые оверлеи, и каждый
|
|
* кандидат смотрит только свои ячейки.
|
|
*/
|
|
const wallCells = new Map<string, number[]>();
|
|
for (let index = 0; index < wallCandidates.length; index++) {
|
|
const wallBounds = silhouetteBounds(wallCandidates[index], true);
|
|
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 list = wallCells.get(key) || [];
|
|
list.push(index);
|
|
wallCells.set(key, list);
|
|
}
|
|
}
|
|
}
|
|
// Пометки поколения вместо Set на каждого кандидата: аллокация в этом цикле
|
|
// стоит дороже самой проверки.
|
|
const wallSeen = new Int32Array(wallCandidates.length);
|
|
let wallSeenGeneration = 0;
|
|
const wallsNear = (bounds: Bounds): IsoWallSilhouette[] => {
|
|
const [minX, minY, maxX, maxY] = cellRange(expandedBounds(bounds, gapUnits));
|
|
const result: IsoWallSilhouette[] = [];
|
|
wallSeenGeneration += 1;
|
|
for (let y = minY; y <= maxY; y++) {
|
|
for (let x = minX; x <= maxX; x++) {
|
|
for (const index of wallCells.get(`${x}:${y}`) || []) {
|
|
if (wallSeen[index] === wallSeenGeneration) continue;
|
|
wallSeen[index] = wallSeenGeneration;
|
|
result.push(wallCandidates[index]);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const candidateAllowed = (shape: {
|
|
offsetScene: ScenePoint; sameAsBase: boolean; visualScene: ScenePoint;
|
|
}): boolean => {
|
|
if (shape.sameAsBase) return true;
|
|
if (!wallsValid || !ownerRoom) return false;
|
|
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 false;
|
|
if (!pointStrictlyInRoom(plan, ownerRoom)) return false;
|
|
if (pointStrictlyInRoom(currentPlan, ownerRoom)
|
|
&& !segmentStrictlyInRoom(currentPlan, plan, ownerRoom)) return false;
|
|
const footprint = baseFootprint.map((point) => [
|
|
point[0] + shape.offsetScene[0], point[1] + shape.offsetScene[1],
|
|
] as ScenePoint);
|
|
const footprintBounds = ringBounds(footprint);
|
|
if (!footprintBounds) return false;
|
|
return !wallsNear(footprintBounds).some((wall) =>
|
|
footprintNearSilhouette(footprint, footprintBounds, wall, gapUnits, true));
|
|
};
|
|
|
|
const candidate = (offsetCss: ScenePoint): {
|
|
placement: IsoOverlayPlacement;
|
|
bounds: Bounds;
|
|
conflicts: readonly number[];
|
|
penalty: number;
|
|
} | null => {
|
|
const shape = candidateShape(offsetCss);
|
|
if (!shape || !candidateAllowed(shape)) return null;
|
|
return {
|
|
placement: placementAtGroupOffset(base, offsetCss, unitsPerPixel, false),
|
|
bounds: shape.bounds, conflicts: shape.conflicts, penalty: shape.penalty,
|
|
};
|
|
};
|
|
|
|
let best = candidate(baseOffsetCss);
|
|
/**
|
|
* Грубый проход даёт место, уточнение возвращает минимальность: без него
|
|
* значок остановился бы на узле решётки 4 px, хотя ближе есть законная
|
|
* точка. Уточняется только окрестность найденного, а не весь диск.
|
|
*/
|
|
const refine = (coarse: {
|
|
placement: IsoOverlayPlacement; bounds: Bounds;
|
|
conflicts: readonly number[]; penalty: number;
|
|
}): typeof coarse => {
|
|
let refined = coarse;
|
|
for (const entry of refinementOffsets(coarse.placement.nudgeCss)) {
|
|
if (entry.distance >= refined.placement.nudgeDistanceCss - EPS) continue;
|
|
const shape = candidateShape(entry.offset);
|
|
if (!shape || shape.conflicts.length || !candidateAllowed(shape)) continue;
|
|
refined = {
|
|
placement: placementAtGroupOffset(base, entry.offset, unitsPerPixel, false),
|
|
bounds: shape.bounds, conflicts: shape.conflicts, penalty: shape.penalty,
|
|
};
|
|
}
|
|
return refined;
|
|
};
|
|
if (!best || best.conflicts.length) {
|
|
for (const entry of ISO_OVERLAY_GROUP_COARSE_TABLE) {
|
|
if (entry.distance > maxNudge + EPS) break;
|
|
const offset = entry.offset;
|
|
if (Math.abs(offset[0] - baseOffsetCss[0]) <= EPS
|
|
&& Math.abs(offset[1] - baseOffsetCss[1]) <= EPS) continue;
|
|
const shape = candidateShape(offset);
|
|
if (!shape) continue;
|
|
// Смещения отсортированы по возрастанию расстояния, поэтому кандидат с
|
|
// тем же штрафом уже никогда не окажется ближе принятого: его отбор
|
|
// невозможен, и платить за проверку кладки незачем. Результат тот же,
|
|
// что у прежнего порядка, — меняется только цена.
|
|
const canWin = !best || !shape.conflicts.length || shape.penalty < best.penalty - EPS;
|
|
if (!canWin || !candidateAllowed(shape)) continue;
|
|
const next = {
|
|
placement: placementAtGroupOffset(base, offset, unitsPerPixel, false),
|
|
bounds: shape.bounds, conflicts: shape.conflicts, penalty: shape.penalty,
|
|
};
|
|
if (!next.conflicts.length) { best = refine(next); break; }
|
|
best = next;
|
|
}
|
|
// Грубая решётка могла пройти мимо законного места, которое лежит между
|
|
// её узлами: тогда лучший кандидат остаётся с пересечением. Уточнение
|
|
// вокруг него ищет ту самую точку с шагом 1 px — ограниченно и только
|
|
// один раз, а не по всему диску (АС4 ТЗ: разрешимое пересечение обязано
|
|
// быть устранено).
|
|
if (best && best.conflicts.length) best = refine(best);
|
|
}
|
|
if (!best) {
|
|
const placement = placementAtGroupOffset(base, baseOffsetCss, unitsPerPixel, true);
|
|
const bounds = overlayRootBounds(item, placement.visualScene);
|
|
best = { placement, bounds, conflicts: nearby(bounds), penalty: Infinity };
|
|
} else if (best.conflicts.length) {
|
|
best = { ...best, placement: placementAtGroupOffset(
|
|
base, best.placement.nudgeCss, unitsPerPixel, true,
|
|
) };
|
|
}
|
|
for (const index of best.conflicts) {
|
|
if (overlapPenalty(best.bounds, accepted[index].bounds, gapUnits) > EPS)
|
|
residualPairs.push(Object.freeze([accepted[index].key, key]));
|
|
}
|
|
placements.set(key, best.placement);
|
|
addAccepted({ key, bounds: best.bounds });
|
|
}
|
|
return {
|
|
placements,
|
|
residualPairs: Object.freeze(residualPairs),
|
|
};
|
|
}
|