Files
houseplan-card/test/iso-overlays.test.mjs
T
2026-09-26 04:34:49 +03:00

620 lines
25 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ISO_OVERLAY_MAX_NUDGE_CSS_PX,
ISO_OVERLAY_SAFETY_GAP_CSS_PX,
buildIsoOverlayBoundaryCandidates,
buildIsoFootprintPolygon,
isoOverlayCollisionKey,
isoOverlayPlane,
isoRoomSafePoint,
resolveIsoOverlayCollisions,
resolveIsoOverlayOwner,
resolveIsoOverlayPlacement,
resolveIsoOverlayRigidGroups,
} from '../test-build/iso-overlays.js';
import {
ISO_RAISED_OVERLAY_HEIGHT,
projectPlanPoint,
} from '../test-build/iso-projection.js';
const close = (actual, expected, epsilon = 1e-7) =>
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
const square = (id, x0, y0, x1, y1, safePoint) => ({
id,
outer: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
safePoint,
});
const identityCamera = {
rotDeg: 0,
tiltDeg: 0,
xyScale: 1,
zScale: 1,
origin: [0, 0],
};
const placement = (overrides = {}) => resolveIsoOverlayPlacement({
kind: 'device',
floorAnchor: [50, 50],
rooms: [square('room', 0, 0, 100, 100, [50, 50])],
preferredRoomId: 'room',
showBorders: true,
wallSilhouettes: [],
footprintHalfSize: [4, 4],
...overrides,
});
test('the exact Stage 4 overlay matrix keeps only the three interactive roots on the low plane', () => {
for (const kind of ['device', 'room-label', 'opening-lock'])
assert.equal(isoOverlayPlane(kind, true), 'raised', kind);
for (const kind of [
'vacuum', 'vacuum-trail', 'glow', 'room-fill', 'room-hover',
'sunlight', 'decor', 'furniture', 'backdrop',
]) assert.equal(isoOverlayPlane(kind, true), 'floor', kind);
for (const kind of ['device', 'room-label', 'opening-lock'])
assert.equal(isoOverlayPlane(kind, false), 'floor', `${kind} without borders`);
});
test('group collision separates a solvable dense set independently of input order', () => {
const make = (id) => ({
id,
kind: id === 'lock' ? 'opening-lock' : 'device',
placement: placement({
floorAnchor: [100, 100],
rooms: [square('room', 0, 0, 200, 200, [100, 100])],
visualOffset: 0,
camera: identityCamera,
}),
screenHalfSize: [8, 8],
});
const solve = (items) => resolveIsoOverlayCollisions({
items,
rooms: [square('room', 0, 0, 200, 200, [100, 100])],
wallSilhouettes: [],
sceneUnitsPerCssPixel: 1,
visualOffset: 0,
camera: identityCamera,
});
const normal = solve([make('a'), make('b'), make('lock')]);
const reversed = solve([make('lock'), make('b'), make('a')]);
assert.deepEqual(normal.residualPairs, []);
assert.deepEqual(reversed.residualPairs, []);
const keys = [
isoOverlayCollisionKey('device', 'a'),
isoOverlayCollisionKey('device', 'b'),
isoOverlayCollisionKey('opening-lock', 'lock'),
];
const centers = keys.map((key) => normal.placements.get(key).visualScene);
for (let index = 0; index < centers.length; index++) {
assert.deepEqual(reversed.placements.get(keys[index]).visualScene, centers[index]);
assert.ok(normal.placements.get(keys[index]).nudgeDistanceCss <= 48);
for (let other = 0; other < index; other++) {
assert.ok(Math.abs(centers[index][0] - centers[other][0]) >= 20
|| Math.abs(centers[index][1] - centers[other][1]) >= 20,
`pair ${index}/${other} must clear its complete roots plus the 4px gap`);
}
}
assert.deepEqual(normal.placements.get(keys[0]).visualScene, [100, 100],
'the stable first item stays at its zero-deviation anchor');
const hintedItems = [make('a'), make('b'), make('lock')].map((item) => ({
...item,
nudgeHintCss: normal.placements.get(isoOverlayCollisionKey(item.kind, item.id)).nudgeCss,
}));
const hinted = solve(hintedItems);
for (const key of keys) assert.deepEqual(hinted.placements.get(key), normal.placements.get(key),
'a previous exact placement is only an upper bound and cannot change the nearest result');
});
test('rigid overlay groups preserve a row and use one deterministic wall displacement', () => {
const room = square('room', 0, 0, 240, 160, [120, 80]);
const wall = { outer: [[0, 0], [240, 0], [240, 14], [0, 14]] };
const make = (id, x) => ({
id,
kind: id === 'lock' ? 'opening-lock' : 'device',
placement: placement({
kind: id === 'lock' ? 'opening-lock' : 'device',
floorAnchor: [x, 18], rooms: [room], preferredRoomId: room.id,
wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0,
visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera,
}),
screenHalfSize: [7, 7],
});
const items = [make('a', 70), make('lock', 90), make('b', 110)];
const solve = (value) => resolveIsoOverlayRigidGroups({
items: value, rooms: [room], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const normal = solve(items);
const reversed = solve([...items].reverse());
const placements = items.map((item) => normal.placements.get(
isoOverlayCollisionKey(item.kind, item.id),
));
const nudges = placements.map((value) => value.nudgeCss);
nudges.slice(1).forEach((value) => assert.deepEqual(value, nudges[0],
'every member receives the exact same group vector'));
assert.ok(nudges[0][1] > 0, 'the complete row moves away from the wall');
assert.deepEqual(placements.map((value) => value.visualScene[1]),
[placements[0].visualScene[1], placements[0].visualScene[1], placements[0].visualScene[1]],
'a horizontal canonical row stays horizontal');
for (const item of items) {
const key = isoOverlayCollisionKey(item.kind, item.id);
assert.deepEqual(reversed.placements.get(key), normal.placements.get(key),
'input order cannot change a rigid layout');
}
});
test('rigid overlay groups never join close markers owned by different rooms', () => {
const left = square('left', 0, 0, 100, 160, [50, 80]);
const right = square('right', 100, 0, 200, 160, [150, 80]);
const wall = { outer: [[94, 0], [106, 0], [106, 160], [94, 160]] };
const make = (id, room, x) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [x, 80], rooms: [left, right], preferredRoomId: room.id,
wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0,
visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera,
}),
screenHalfSize: [7, 7],
});
const leftItem = make('left-device', left, 90);
const rightItem = make('right-device', right, 110);
const result = resolveIsoOverlayRigidGroups({
items: [leftItem, rightItem], rooms: [left, right], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const leftPlacement = result.placements.get(isoOverlayCollisionKey('device', leftItem.id));
const rightPlacement = result.placements.get(isoOverlayCollisionKey('device', rightItem.id));
assert.ok(leftPlacement.nudgeCss[0] < 0,
'the left-room marker clears the shared wall into its own room');
assert.ok(rightPlacement.nudgeCss[0] > 0,
'the right-room marker clears the shared wall into its own room');
assert.notDeepEqual(leftPlacement.nudgeCss, rightPlacement.nudgeCss,
'close markers across a room boundary must remain separate groups');
});
test('rigid fallback prioritizes room, then wall, then overlap inside the 48px cap', () => {
const obstacleRoom = square('obstacle-room', 40, 0, 60, 40, [50, 30]);
const targetRoom = square('target-room', 40, 0, 60, 40, [50, 30]);
const wall = { outer: [[40, 0], [60, 0], [60, 14], [40, 14]] };
const make = (id, room, y) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [50, y], rooms: [obstacleRoom, targetRoom], preferredRoomId: room.id,
wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0,
visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera,
}),
screenHalfSize: [9, 9],
});
const obstacle = make('a-obstacle', obstacleRoom, 30);
const target = make('b-target', targetRoom, 18);
const result = resolveIsoOverlayRigidGroups({
items: [obstacle, target], rooms: [obstacleRoom, targetRoom], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const resolved = result.placements.get(isoOverlayCollisionKey('device', target.id));
assert.equal(resolved.status, 'degraded', 'the narrow room has no conflict-free placement');
assert.equal(resolved.reason, 'overlay-collision',
'after preserving room ownership and clearing the wall, overlap is the remaining debt');
assert.equal(resolved.nearWallAfter, false,
'wall clearance outranks overlap with the previously accepted group');
assert.ok(resolved.nudgeCss[1] > 0 && resolved.nudgeDistanceCss <= 48,
'the fallback moves into the room without exceeding the global cap');
});
test('group collision checks room-edge events before accepting a legal farther hint', () => {
const room = {
id: 'diagonal-strip',
outer: [[-5, -5], [55, 15], [55, 25], [-5, 5]],
safePoint: [30, 12],
};
const make = (id) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [0, 0], rooms: [room], preferredRoomId: room.id,
wallSilhouettes: [], footprintHalfSize: [1, 1], wallHeight: 0,
visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera,
}),
screenHalfSize: [8, 8],
});
const solve = (hint) => resolveIsoOverlayCollisions({
items: [make('a'), { ...make('b'), ...(hint ? { nudgeHintCss: hint } : {}) }],
rooms: [room], wallSilhouettes: [], sceneUnitsPerCssPixel: 1,
visualOffset: 0, safetyGapCssPx: 4, camera: identityCamera,
}).placements.get(isoOverlayCollisionKey('device', 'b'));
const exact = solve();
const hinted = solve([25, 6]);
assert.deepEqual(exact.nudgeCss, [20, 4]);
assert.deepEqual(hinted, exact,
'a legal cached hint cannot skip the nearer overlay/room boundary intersection');
});
test('group collision finds a legal one-pixel slit between the coarse nodes', () => {
// The roots need 41 px of separation. Offset 40 still overlaps; offset 44
// reaches masonry; and the room rejects the next coarse node at 48. The old
// 4 px pass therefore degraded at 40 even though the exact offset 41 is free.
const room = square('slit', 99, 99, 147.5, 101, [100, 100]);
const wall = { outer: [[147.8, 99], [148.2, 99], [148.2, 101], [147.8, 101]] };
const make = (id) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [100, 100], rooms: [room], preferredRoomId: 'slit',
wallSilhouettes: [wall], footprintHalfSize: [0.1, 0.1],
wallHeight: 0, visualOffset: 0, sceneUnitsPerCssPixel: 1,
camera: identityCamera,
}),
screenHalfSize: [18.5, 18.5],
});
const result = resolveIsoOverlayCollisions({
items: [make('a'), make('b')], rooms: [room], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const second = result.placements.get(isoOverlayCollisionKey('device', 'b'));
assert.deepEqual(result.residualPairs, []);
assert.deepEqual(second.nudgeCss, [41, 0]);
assert.equal(second.status, 'ok');
assert.ok(second.nudgeDistanceCss <= ISO_OVERLAY_MAX_NUDGE_CSS_PX);
});
test('group collision follows a real concave wall edge instead of its bounding box', () => {
const room = square('notch', 99, 99, 140, 112, [100, 100]);
// The wall bounding box covers the whole right-hand strip. Its actual ring
// has a 10 px notch; the nearest legal root is found only where that real
// inner edge meets the already accepted overlay boundary.
const wall = { outer: [
[120, 90], [130, 90], [130, 130], [120, 130],
[120, 115], [125, 115], [125, 105], [120, 105],
] };
const make = (id) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [100, 100], rooms: [room], preferredRoomId: 'notch',
wallSilhouettes: [wall], footprintHalfSize: [1, 1],
wallHeight: 0, visualOffset: 0, sceneUnitsPerCssPixel: 1,
safetyGapCssPx: 0, camera: identityCamera,
}),
screenHalfSize: [10, 10],
});
const result = resolveIsoOverlayCollisions({
items: [make('a'), make('b')], rooms: [room], wallSilhouettes: [wall],
sceneUnitsPerCssPixel: 1, visualOffset: 0, safetyGapCssPx: 0,
camera: identityCamera,
});
const second = result.placements.get(isoOverlayCollisionKey('device', 'b'));
assert.deepEqual(result.residualPairs, []);
assert.deepEqual(second.nudgeCss, [20, 7]);
assert.equal(second.status, 'ok');
});
test('group collision boundary events stay sparse while covering sub-grid positions', () => {
const candidates = buildIsoOverlayBoundaryCandidates([
[-41, -41, 41, 41],
[-13.25, -8.75, 17.25, 22.75],
], ISO_OVERLAY_MAX_NUDGE_CSS_PX);
const fullDiskLatticeSize = 7238;
assert.ok(candidates.length < fullDiskLatticeSize / 10,
`${candidates.length} boundary events must stay far below a full disk scan`);
assert.ok(candidates.some(([x, y]) => x === 41 && y === 0),
'the one-pixel position between the former 4 px nodes is present');
assert.ok(candidates.every(([x, y]) => Number.isInteger(x) && Number.isInteger(y)
&& Math.hypot(x, y) <= ISO_OVERLAY_MAX_NUDGE_CSS_PX),
'every event stays on the integer CSS lattice and inside the absolute cap');
});
test('boundary events do not cross unrelated rectangle axes into an interior grid', () => {
const candidates = buildIsoOverlayBoundaryCandidates([
[10, -2, 12, 2],
[-2, 10, 2, 12],
], ISO_OVERLAY_MAX_NUDGE_CSS_PX);
assert.ok(!candidates.some(([x, y]) => x === 10 && y === 10),
'finite edges that never meet must not invent their Cartesian intersection');
assert.ok(candidates.some(([x, y]) => x === 10 && y === 0));
assert.ok(candidates.some(([x, y]) => x === 0 && y === 10));
});
test('group collision reports a deterministic residual without exceeding the absolute cap', () => {
const room = square('tight', 0, 0, 1, 1, [0.5, 0.5]);
const make = (id) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [0.5, 0.5], rooms: [room], preferredRoomId: 'tight',
visualOffset: 0, camera: identityCamera,
}),
screenHalfSize: [30, 30],
});
const result = resolveIsoOverlayCollisions({
items: [make('a'), make('b')], rooms: [room], wallSilhouettes: [],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const second = result.placements.get(isoOverlayCollisionKey('device', 'b'));
assert.equal(result.residualPairs.length, 1);
assert.equal(second.status, 'degraded');
assert.equal(second.reason, 'overlay-collision');
assert.ok(second.nudgeDistanceCss <= 48,
'the search radius is total displacement, not a budget added per collision');
});
test('owner resolution honours bindings, then canonical minimum area and stable id', () => {
const rooms = [
square('wide', 0, 0, 100, 100, [50, 50]),
square('z-small', 25, 25, 75, 75, [50, 50]),
square('a-small', 25, 25, 75, 75, [50, 50]),
];
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [50, 50], rooms, preferredRoomId: 'wide',
})?.id, 'wide', 'a valid explicit device binding wins');
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [50, 50], rooms, preferredRoomId: 'missing',
})?.id, 'a-small', 'minimum area and then stable id resolve the fallback');
assert.equal(resolveIsoOverlayOwner({
kind: 'room-label', floorAnchor: [150, 150], rooms, preferredRoomId: 'wide',
})?.id, 'wide', 'a saved room label may live outside its owning room');
assert.equal(resolveIsoOverlayOwner({
kind: 'opening-lock', floorAnchor: [100, 50], rooms, preferredRoomId: 'wide',
})?.id, 'wide', 'the opening host supplies lock ownership');
});
test('strict room ownership excludes holes, shared boundaries and outside points', () => {
const ring = {
...square('ring', 0, 0, 100, 100, [20, 20]),
holes: [[[40, 40], [60, 40], [60, 60], [40, 60]]],
};
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [20, 20], rooms: [ring],
})?.id, 'ring');
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [50, 50], rooms: [ring],
}), null, 'a point in a room hole has no guessed owner');
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [0, 50], rooms: [ring],
}), null, 'a point on a shared/boundary edge is not strictly contained');
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [150, 50], rooms: [ring],
}), null, 'an outside saved point stays ownerless');
});
test('safe-point search is deterministic and stays strictly inside concave rooms and holes', () => {
const rooms = [
{
id: 'donut',
outer: [[0, 0], [100, 0], [100, 100], [0, 100]],
holes: [[[35, 35], [65, 35], [65, 65], [35, 65]]],
},
{
id: 'concave',
outer: [
[0, 0], [100, 0], [100, 100], [60, 100],
[60, 35], [40, 35], [40, 100], [0, 100],
],
},
];
for (const room of rooms) {
const first = isoRoomSafePoint(room);
assert.ok(first, `${room.id}: bounded search finds an inner point`);
assert.deepEqual(isoRoomSafePoint(room), first, `${room.id}: repeated search is deterministic`);
assert.equal(resolveIsoOverlayOwner({
kind: 'device', floorAnchor: first, rooms: [room],
})?.id, room.id, `${room.id}: result is strictly inside the room and outside every hole`);
}
});
test('a degenerate room has no safe point and placement degrades without throwing', () => {
const degenerate = { id: 'line', outer: [[0, 0], [50, 0], [100, 0]] };
assert.equal(isoRoomSafePoint(degenerate), null);
const result = placement({
rooms: [degenerate],
preferredRoomId: degenerate.id,
wallSilhouettes: [{ outer: buildIsoFootprintPolygon([50, 50], [10, 10], ISO_RAISED_OVERLAY_HEIGHT) }],
});
assert.equal(result.owner, null);
assert.equal(result.status, 'degraded');
assert.equal(result.reason, 'missing-owner');
assert.equal(result.nudged, false);
});
test('footprint corners use the same affine camera on the raised plane', () => {
const footprint = buildIsoFootprintPolygon([50, 50], [10, 5], ISO_RAISED_OVERLAY_HEIGHT);
const logical = [[40, 45], [60, 45], [60, 55], [40, 55]];
logical.forEach((point, index) => {
const projected = projectPlanPoint(point, ISO_RAISED_OVERLAY_HEIGHT);
close(footprint[index][0], projected[0]);
close(footprint[index][1], projected[1]);
});
close(footprint[0][1], footprint[1][1]);
assert.ok(footprint[2][1] > footprint[1][1],
'zero yaw keeps the screen-facing footprint aligned with the plan axes');
});
test('free low overlay separates the immutable floor anchor from invisible collision geometry', () => {
const normal = placement();
assert.equal(normal.plane, 'raised');
assert.deepEqual(normal.floorAnchor, [50, 50]);
assert.deepEqual(normal.floorScene, projectPlanPoint([50, 50], 0));
assert.deepEqual(normal.raisedScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT));
assert.deepEqual(normal.visualScene, normal.raisedScene);
assert.equal(normal.footprint.length, 4);
assert.equal(normal.grounding.visible, false);
assert.equal(normal.tether.visible, false);
assert.equal(normal.status, 'ok');
for (const state of ['hovered', 'focused', 'selected'])
assert.equal(placement({ [state]: true }).tether.visible, false, state);
assert.equal(placement({ filtersSupported: false }).grounding.visible, false,
'unsupported filters remove the soft grounding shadow only');
});
test('wall-aware nudge is deterministic, minimal, inward and never changes the floor anchor', () => {
const wall = {
outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_RAISED_OVERLAY_HEIGHT),
};
const input = {
floorAnchor: [5, 50],
rooms: [square('room', 0, 0, 100, 100, [50, 50])],
preferredRoomId: 'room',
wallSilhouettes: [wall],
};
const first = placement(input);
const second = placement(input);
const hinted = placement({ ...input, nudgeHintCss: first.nudgeDistanceCss });
assert.deepEqual(second, first, 'identical structural and viewport inputs are stable');
assert.deepEqual(hinted, first,
'a cached CSS-distance hint is accepted only when the exact wall test proves it clear');
assert.deepEqual(first.floorAnchor, [5, 50]);
assert.deepEqual(first.floorScene, projectPlanPoint([5, 50], 0));
assert.equal(first.nearWallBefore, true);
assert.equal(first.nearWallAfter, false);
assert.equal(first.cleared, true);
assert.equal(first.capped, false);
assert.ok(first.nudgeDistanceCss > 0 && first.nudgeDistanceCss < ISO_OVERLAY_MAX_NUDGE_CSS_PX);
const towardSafe = projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT);
const safeVector = [towardSafe[0] - first.raisedScene[0], towardSafe[1] - first.raisedScene[1]];
assert.ok(first.nudgeScene[0] * safeVector[0] + first.nudgeScene[1] * safeVector[1] > 0);
assert.equal(first.tether.visible, false);
});
test('shared-wall and corner fixtures nudge only toward the selected room', () => {
const verticalWall = {
outer: buildIsoFootprintPolygon([50, 50], [3, 60], ISO_RAISED_OVERLAY_HEIGHT),
};
const shared = placement({
floorAnchor: [49, 50],
rooms: [
square('left', 0, 0, 50, 100, [25, 50]),
square('right', 50, 0, 100, 100, [75, 50]),
],
preferredRoomId: 'left',
wallSilhouettes: [verticalWall],
});
assert.equal(shared.owner?.id, 'left');
assert.ok(shared.nudgeScene[0] < 0, 'shared-wall marker moves into the left owner');
const corner = placement({
floorAnchor: [5, 5],
rooms: [square('corner', 0, 0, 100, 100, [50, 50])],
preferredRoomId: 'corner',
wallSilhouettes: [
{ outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_RAISED_OVERLAY_HEIGHT) },
{ outer: buildIsoFootprintPolygon([50, 0], [60, 4], ISO_RAISED_OVERLAY_HEIGHT) },
],
});
assert.equal(corner.nearWallBefore, true);
assert.ok(corner.nudgeScene[0] > 0 && corner.nudgeScene[1] > 0,
'corner marker follows the projected inward diagonal');
assert.equal(corner.tether.visible, false);
});
test('nudge never crosses an island hole or a concave-room boundary', () => {
const fixtures = [
{
name: 'island hole',
room: {
...square('donut', 0, 0, 100, 100, [80, 50]),
holes: [[[40, 40], [60, 40], [60, 60], [40, 60]]],
},
floorAnchor: [20, 50],
expectedVisual: [39, 50],
},
{
name: 'concave notch',
room: {
id: 'concave',
outer: [
[0, 0], [100, 0], [100, 100], [60, 100],
[60, 40], [40, 40], [40, 100], [0, 100],
],
safePoint: [80, 80],
},
floorAnchor: [20, 80],
expectedVisual: [39, 80],
},
];
const obstacle = { outer: [[10, 30], [75, 30], [75, 90], [10, 90]] };
for (const fixture of fixtures) {
const result = placement({
floorAnchor: fixture.floorAnchor,
rooms: [fixture.room],
preferredRoomId: fixture.room.id,
wallSilhouettes: [obstacle],
footprintHalfSize: [1, 1],
wallHeight: 0,
visualOffset: 0,
sceneUnitsPerCssPixel: 1,
safetyGapCssPx: 0,
maxNudgeCssPx: 48,
camera: identityCamera,
});
assert.deepEqual(result.floorAnchor, fixture.floorAnchor, `${fixture.name}: model anchor is immutable`);
assert.deepEqual(result.visualScene, fixture.expectedVisual,
`${fixture.name}: the last valid point is retained before the boundary`);
assert.equal(result.nudgeDistanceCss, 19, fixture.name);
assert.equal(result.status, 'degraded', fixture.name);
assert.equal(result.reason, 'owner-boundary', fixture.name);
assert.equal(result.capped, true, fixture.name);
assert.equal(result.cleared, false, `${fixture.name}: no unsafe jump is used to clear the wall`);
}
});
test('cap and ambiguous ownership fail safe without bringing back debug cues', () => {
const largeWall = {
outer: buildIsoFootprintPolygon([25, 50], [30, 30], ISO_RAISED_OVERLAY_HEIGHT),
};
const capped = placement({
floorAnchor: [25, 50], wallSilhouettes: [largeWall], maxNudgeCssPx: 2,
});
close(capped.nudgeDistanceCss, 2);
assert.equal(capped.capped, true);
assert.equal(capped.cleared, false);
assert.equal(capped.status, 'degraded');
assert.equal(capped.reason, 'nudge-cap');
assert.equal(capped.tether.visible, false);
const ownerless = placement({
floorAnchor: [150, 50], rooms: [], preferredRoomId: null,
wallSilhouettes: [{
outer: buildIsoFootprintPolygon([150, 50], [10, 10], ISO_RAISED_OVERLAY_HEIGHT),
}],
});
assert.equal(ownerless.owner, null);
assert.equal(ownerless.nudged, false);
assert.equal(ownerless.status, 'degraded');
assert.equal(ownerless.reason, 'missing-owner');
assert.equal(ownerless.tether.visible, false);
});
test('malformed collision input degrades without a guessed move', () => {
const result = placement({
wallSilhouettes: [{ outer: [[0, 0], [Number.NaN, 1], [2, 2]] }],
});
assert.equal(result.status, 'degraded');
assert.equal(result.reason, 'invalid-wall-geometry');
assert.equal(result.nudged, false);
assert.equal(result.tether.visible, false);
});
test('show_borders:false is exact no-volume: floor anchor, no footprint/nudge/cues', () => {
const result = placement({
showBorders: false,
wallSilhouettes: [{ outer: [[0, 0], [Number.NaN, 1], [2, 2]] }],
hovered: true,
});
assert.equal(result.plane, 'floor');
assert.deepEqual(result.visualScene, result.floorScene);
assert.deepEqual(result.footprint, []);
assert.equal(result.nudged, false);
assert.equal(result.grounding.visible, false);
assert.equal(result.tether.visible, false);
assert.equal(result.status, 'ok');
assert.equal(ISO_OVERLAY_SAFETY_GAP_CSS_PX, 4);
});