mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Кешированное смещение больше не завершает поиск до точной проверки пересечений с границами комнаты и стен. Добавлен регрессионный тест для более близкого решения у диагональной границы. Issue: #585 User-Visible: no
521 lines
20 KiB
JavaScript
521 lines
20 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,
|
|
} 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('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);
|
|
});
|