mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Publish corrected 0.4.1 pack, preserve old cactus objects as a read-only alias, and cover the catalog, renderers, editor, PDF and visual scene. Issue: #606 User-Visible: yes
666 lines
31 KiB
JavaScript
666 lines
31 KiB
JavaScript
// The furniture library (docs/FURNITURE.md): the arithmetic that turns real
|
||
// centimetres into canvas units, the wall magnet, the independent resize and
|
||
// the generated geometry. Everything here is pure — the card only renders it.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
FURNITURE, FURNITURE_GROUPS, furnitureSymbol, furnitureOfGroup,
|
||
furnitureDefaultCm, furniturePathD, furnitureGraphic, furnitureArtIsLazy, furnitureCorners, furnitureResize,
|
||
furniturePlanScreenScale, furnitureStrokePx,
|
||
resizeFurnitureTransform, furnitureRotationAngle, furnitureRenderTransform,
|
||
furnitureSignedFieldCm, furnitureSignedFieldValue,
|
||
cmToNorm, normToCm, clampFurnSize, clampFurnCm,
|
||
FURN_MIN_N, FURN_MIN_CM, FURN_MAX_CM,
|
||
} from '../test-build/furniture.js';
|
||
import {
|
||
FURN_WALL_CELLS, resolveFurniturePlacement, snapFurnitureToWall,
|
||
} from '../test-build/furniture-placement.js';
|
||
import {
|
||
furnitureWallSurfacesFor, physicalFurnitureWallSurfaces, roomFurnitureWallSurfaces,
|
||
} from '../test-build/furniture-wall-surface.js';
|
||
import { setWallThickness, wallCmToUnits } from '../test-build/wall-thickness.js';
|
||
import { FURNITURE_ART_RUNTIME } from '../test-build/furniture-art-runtime.js';
|
||
import { FURNITURE_ART_FINGERPRINT, GENERATED_FURNITURE_ART } from '../test-build/furniture-plan-art.generated.js';
|
||
|
||
// #474: designer artwork is lazy; these tests exercise the library with the
|
||
// artwork handed over the way the editor does it (synchronous adopt).
|
||
assert.equal(FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT), true);
|
||
import { NORM_W, GRID_PITCH, GRID_N, GRID_STEP_N } from '../test-build/space-geometry.js';
|
||
|
||
const closeTo = (got, want, tol = 1e-9) =>
|
||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||
|
||
// ------------------------------- the table ---------------------------------
|
||
|
||
test('every symbol is well formed: unique id, a known group, positive default size', () => {
|
||
const seen = new Set();
|
||
for (const s of FURNITURE) {
|
||
assert.ok(/^[a-z0-9_]+$/.test(s.id), `bad id ${s.id}`);
|
||
assert.ok(!seen.has(s.id), `duplicate id ${s.id}`);
|
||
seen.add(s.id);
|
||
assert.ok(FURNITURE_GROUPS.includes(s.group), `${s.id}: unknown group ${s.group}`);
|
||
assert.ok(s.w > 0 && s.h > 0, `${s.id}: default size must be positive`);
|
||
assert.match(s.category, /^[a-z0-9_]+$/, `${s.id}: invalid category`);
|
||
assert.ok(furnitureGraphic(s.id)?.d, `${s.id}: nothing to draw`);
|
||
// #593: примитивов в unit box больше нет — у каждого символа свой
|
||
// физический viewBox, и он обязан совпадать с манифестом.
|
||
const art = furnitureGraphic(s.id);
|
||
assert.deepEqual([art.viewW, art.viewH], [s.w, s.h], `${s.id}: manifest and SVG viewBox differ`);
|
||
assert.equal(furnitureArtIsLazy(s.id), true, `${s.id}: artwork must come from the lazy chunk`);
|
||
}
|
||
assert.equal(FURNITURE.length, 60);
|
||
assert.equal(furnitureArtIsLazy('houseplan-no-such-symbol'), false);
|
||
});
|
||
|
||
// #593 AC4: пакет 0.4.0 перерисовывает 56 существующих ID, НЕ трогая их
|
||
// default-размеры. Таблица зашита в тест: сдвиг любого числа переписал бы
|
||
// габарит нового размещения у людей, которые ничего не меняли.
|
||
test('#593: default sizes of the 56 pre-existing ids are byte-for-byte the pack 0.3.0 values', () => {
|
||
const BEFORE = {
|
||
ac: [90, 25], armchair: [90, 90], armchair_office: [65, 65],
|
||
bathtub: [170, 75], bathtub_corner: [140, 140], bed_double: [160, 200],
|
||
bed_single: [90, 200], bidet: [40, 60], bidet_built_in: [40, 50],
|
||
bookshelf: [100, 35], cabinet_shoe: [80, 35], cabinet_sink: [80, 50],
|
||
cabinet_tv: [140, 45], chair: [50, 50], chair_bar: [45, 48],
|
||
coffee_table: [120, 60], coffee_table_oval: [120, 60], coffee_table_round: [80, 80],
|
||
coffee_table_rounded: [120, 60], cooktop_two: [30, 50], desk: [140, 70],
|
||
desk_corner: [160, 160], dishwasher: [60, 60], dryer: [60, 60],
|
||
fireplace: [120, 40], fridge: [60, 65], kitchen_floor: [60, 60],
|
||
kitchen_floor_corner: [90, 90], kitchen_sink: [60, 50], kitchen_sink_double: [90, 50],
|
||
kitchen_wall: [60, 35], kitchen_wall_corner: [60, 60], nightstand: [50, 40],
|
||
plant: [40, 40], rug: [200, 140], shelf_floor: [100, 35],
|
||
shelf_wall: [100, 25], shower: [90, 90], sink: [60, 45],
|
||
sofa: [180, 90], sofa_corner_right: [260, 170], sofa_three_seat: [240, 90],
|
||
stairs: [100, 280], stove: [60, 60], table_dining: [160, 90],
|
||
table_dining_oval: [180, 100], table_dining_rounded: [160, 90], table_round: [110, 110],
|
||
toilet: [40, 70], toilet_built_in: [40, 55], tv: [120, 28],
|
||
tv_wall: [120, 15], wall_unit: [240, 45], wardrobe: [180, 60],
|
||
washer: [60, 60], water_heater: [45, 45],
|
||
};
|
||
assert.equal(Object.keys(BEFORE).length, 56);
|
||
const drift = [];
|
||
for (const [id, [w, h]] of Object.entries(BEFORE)) {
|
||
const got = furnitureDefaultCm(id);
|
||
if (got.w !== w || got.h !== h) drift.push(`${id}: ${got.w}x${got.h} вместо ${w}x${h}`);
|
||
}
|
||
assert.deepEqual(drift, []);
|
||
// Новые ID — единственные, которых в таблице нет.
|
||
const added = FURNITURE.map((s) => s.id).filter((id) => !(id in BEFORE)).sort();
|
||
assert.deepEqual(added, ['computer', 'exercise', 'hood', 'oven']);
|
||
});
|
||
|
||
test('#606: saved cactus resolves through catalog, lazy gate and actual artwork without a second visible variant', () => {
|
||
assert.equal(FURNITURE.some((symbol) => symbol.id === 'cactus'), false);
|
||
assert.equal(furnitureSymbol('cactus')?.id, 'exercise');
|
||
assert.equal(furnitureArtIsLazy('cactus'), true);
|
||
assert.deepEqual(furnitureDefaultCm('cactus'), { w: 70, h: 120 });
|
||
assert.deepEqual(furnitureGraphic('cactus'), furnitureGraphic('exercise'));
|
||
assert.equal(furniturePathD('cactus', 70, 120), furniturePathD('exercise', 70, 120));
|
||
});
|
||
|
||
test('the three groups the owner named are all populated, and every symbol is in exactly one', () => {
|
||
for (const g of ['furniture', 'appliance', 'sanitary'])
|
||
assert.ok(furnitureOfGroup(g).length > 0, `group ${g} is empty`);
|
||
const total = FURNITURE_GROUPS.reduce((n, g) => n + furnitureOfGroup(g).length, 0);
|
||
assert.equal(total, FURNITURE.length);
|
||
});
|
||
|
||
test('the owner’s default sizes are the ones stored', () => {
|
||
// owner’s list, read as width ALONG the back edge x depth
|
||
assert.deepEqual(furnitureDefaultCm('sofa'), { w: 180, h: 90 });
|
||
assert.deepEqual(furnitureDefaultCm('bed_double'), { w: 160, h: 200 });
|
||
assert.deepEqual(furnitureDefaultCm('bed_single'), { w: 90, h: 200 });
|
||
assert.deepEqual(furnitureDefaultCm('table_dining'), { w: 160, h: 90 });
|
||
assert.deepEqual(furnitureDefaultCm('toilet'), { w: 40, h: 70 });
|
||
assert.deepEqual(furnitureDefaultCm('bathtub'), { w: 170, h: 75 });
|
||
assert.deepEqual(furnitureDefaultCm('shower'), { w: 90, h: 90 });
|
||
assert.deepEqual(furnitureDefaultCm('sink'), { w: 60, h: 45 });
|
||
assert.deepEqual(furnitureDefaultCm('stove'), { w: 60, h: 60 });
|
||
assert.deepEqual(furnitureDefaultCm('fridge'), { w: 60, h: 65 });
|
||
assert.deepEqual(furnitureDefaultCm('washer'), { w: 60, h: 60 });
|
||
assert.deepEqual(furnitureDefaultCm('dishwasher'), { w: 60, h: 60 });
|
||
assert.deepEqual(furnitureDefaultCm('wardrobe'), { w: 180, h: 60 });
|
||
assert.deepEqual(furnitureDefaultCm('chair'), { w: 50, h: 50 });
|
||
assert.deepEqual(furnitureDefaultCm('desk'), { w: 140, h: 70 });
|
||
assert.deepEqual(furnitureDefaultCm('sofa_corner_right'), { w: 260, h: 170 });
|
||
assert.deepEqual(furnitureDefaultCm('kitchen_sink_double'), { w: 90, h: 50 });
|
||
});
|
||
|
||
test('an unknown symbol is data, not a crash', () => {
|
||
assert.equal(furnitureSymbol('no_such_thing'), null);
|
||
assert.equal(furnitureSymbol(undefined), null);
|
||
assert.deepEqual(furnitureDefaultCm('no_such_thing'), { w: 60, h: 60 });
|
||
assert.equal(furniturePathD('no_such_thing', 10, 10), '');
|
||
});
|
||
|
||
// --------------------------- centimetres <-> canvas -------------------------
|
||
|
||
test('cmToNorm goes through cell_cm: one cell of the grid is cell_cm centimetres', () => {
|
||
// a 220 cm sofa on a 5 cm/cell plan is 44 cells; the canvas has GRID_N cells
|
||
closeTo(cmToNorm(220, 5), 44 / GRID_N, 1e-12);
|
||
// …and the same sofa on a 10 cm/cell plan is HALF the canvas fraction
|
||
closeTo(cmToNorm(220, 10), 22 / GRID_N, 1e-12);
|
||
// the render-unit form the card actually uses
|
||
closeTo(cmToNorm(220, 5) * NORM_W, (220 / 5) * GRID_PITCH, 1e-9);
|
||
});
|
||
|
||
test('normToCm is its inverse, and a missing cell_cm defaults to 5', () => {
|
||
for (const cm of [1, 45, 220, 10000]) {
|
||
closeTo(normToCm(cmToNorm(cm, 5), 5), cm, 1e-9);
|
||
closeTo(normToCm(cmToNorm(cm, 12.5), 12.5), cm, 1e-9);
|
||
}
|
||
closeTo(cmToNorm(100, 0), cmToNorm(100, 5), 1e-12);
|
||
closeTo(cmToNorm(100, NaN), cmToNorm(100, 5), 1e-12);
|
||
});
|
||
|
||
test('sizes are clamped, not trusted', () => {
|
||
assert.equal(clampFurnSize(NaN), FURN_MIN_N);
|
||
assert.equal(clampFurnSize(-3), FURN_MIN_N);
|
||
assert.equal(clampFurnSize(1e9), 5000);
|
||
assert.equal(clampFurnCm(0), FURN_MIN_CM);
|
||
assert.equal(clampFurnCm(1e9), FURN_MAX_CM);
|
||
assert.equal(clampFurnCm(220), 220);
|
||
});
|
||
|
||
// ------------------------------- the drawing --------------------------------
|
||
|
||
test('every path keeps its native viewBox, including the twelve former primitives', () => {
|
||
const sofa = furnitureGraphic('sofa');
|
||
assert.deepEqual([sofa.viewW, sofa.viewH], [180, 90]);
|
||
assert.ok(sofa.d.length > 10);
|
||
// #593: `fridge` раньше рисовался примитивом из unit box и начинался с
|
||
// `M0 0H60V65H0Z`. Теперь это дизайнерский рисунок в своём viewBox.
|
||
const fridge = furnitureGraphic('fridge');
|
||
assert.deepEqual([fridge.viewW, fridge.viewH], [60, 65]);
|
||
assert.ok(fridge.d.startsWith('M'), fridge.d.slice(0, 40));
|
||
assert.ok(!fridge.d.startsWith('M0 0H60V65H0Z'), 'примитив не должен пережить пакет 0.4.0');
|
||
// a degenerate box draws nothing rather than NaNs
|
||
assert.equal(furniturePathD('sofa', 0, 10), '');
|
||
assert.ok(!/NaN/.test(furniturePathD('toilet', 40, 70)));
|
||
});
|
||
|
||
test('every symbol exposes one finite path and a positive native coordinate box', () => {
|
||
for (const s of FURNITURE) {
|
||
const art = furnitureGraphic(s.id);
|
||
assert.ok(art.viewW > 0 && art.viewH > 0, `${s.id}: invalid viewBox`);
|
||
assert.ok(art.d.length > 0, `${s.id}: empty path`);
|
||
assert.ok(!/NaN|Infinity|undefined/.test(art.d), `${s.id}: ${art.d.slice(0, 60)}`);
|
||
}
|
||
});
|
||
|
||
test('furniture stroke follows the outer plan viewBox camera like physical decor', () => {
|
||
// 1000 plan units fitted into 500 CSS px: the same 3-unit physical line is
|
||
// 1.5 px on screen. Halving the viewBox is a 2x camera zoom and doubles it.
|
||
const fit = furniturePlanScreenScale(500, 300, 1000, 600);
|
||
const zoom2 = furniturePlanScreenScale(500, 300, 500, 300);
|
||
closeTo(fit, 0.5);
|
||
closeTo(zoom2, 1);
|
||
closeTo(furnitureStrokePx(3, fit), 1.5);
|
||
closeTo(furnitureStrokePx(3, zoom2), 3);
|
||
closeTo(furnitureStrokePx(3, zoom2) / furnitureStrokePx(3, fit), 2);
|
||
});
|
||
|
||
test('furniture stroke uses the meet scale and stays independent of the local artwork box', () => {
|
||
// Width alone would say 0.8; xMidYMid meet is constrained by height at 0.5.
|
||
const letterboxed = furniturePlanScreenScale(800, 300, 1000, 600);
|
||
closeTo(letterboxed, 0.5);
|
||
const stroke = furnitureStrokePx(4, letterboxed);
|
||
for (const symbol of ['sofa', 'fridge']) {
|
||
assert.ok(furnitureGraphic(symbol), `${symbol}: fixture must render`);
|
||
// Designer native viewBox and retained unit-box art receive the same
|
||
// physical stroke even if their independent width/depth scales differ.
|
||
closeTo(furnitureStrokePx(4, letterboxed), stroke);
|
||
}
|
||
});
|
||
|
||
test('furniture stroke layout fallbacks are finite and recover on measured layout', () => {
|
||
for (const metrics of [
|
||
[0, 300, 1000, 600], [500, NaN, 1000, 600], [500, 300, 0, 600],
|
||
[undefined, undefined, undefined, undefined],
|
||
]) assert.equal(furniturePlanScreenScale(...metrics), 1);
|
||
assert.equal(furnitureStrokePx(NaN, NaN, 2.5), 2.5);
|
||
assert.equal(furnitureStrokePx(3, 0), 3);
|
||
assert.ok(Number.isFinite(furnitureStrokePx(Infinity, Infinity)));
|
||
closeTo(furnitureStrokePx(3, furniturePlanScreenScale(500, 300, 500, 300)), 3);
|
||
});
|
||
|
||
// ------------------------------ the wall magnet -----------------------------
|
||
|
||
// One 400x300 room in the render-space units used by the card.
|
||
const ROOM = { id: 'r', poly: [[100, 100], [500, 100], [500, 400], [100, 400]] };
|
||
const roomSurfaces = (rooms, walls = [], openCuts = []) => roomFurnitureWallSurfaces(
|
||
rooms, walls, openCuts, GRID_STEP_N, 5, GRID_PITCH, NORM_W,
|
||
);
|
||
const ZERO_SURFACES = roomSurfaces([ROOM]);
|
||
const thickTopWalls = (cm) => setWallThickness(
|
||
[], ROOM.poly[0], ROOM.poly[1], cm, GRID_STEP_N, NORM_W,
|
||
);
|
||
const THICK_SURFACES = roomSurfaces([ROOM], thickTopWalls(20));
|
||
const HALF_10 = wallCmToUnits(10, 5, GRID_PITCH) / 2;
|
||
const HALF_20 = wallCmToUnits(20, 5, GRID_PITCH) / 2;
|
||
const TOP_20 = 100 + HALF_20;
|
||
|
||
test('the magnet presses the BACK edge onto the wall and turns the piece to it', () => {
|
||
const s = snapFurnitureToWall(300, TOP_20, 90, THICK_SURFACES, 30);
|
||
assert.ok(s, 'the physical face under the pointer must be found');
|
||
closeTo(s.angle, 0);
|
||
closeTo(s.cy, TOP_20 + 45); // centre = physical face + half the depth
|
||
closeTo(s.cx, 300);
|
||
closeTo(s.dist, 0);
|
||
});
|
||
|
||
test('an exterior wall exposes both physical faces and keeps furniture on the intent side', () => {
|
||
const outsideY = 100 - HALF_20;
|
||
const s = snapFurnitureToWall(300, outsideY, 90, THICK_SURFACES, 30);
|
||
closeTo(Math.abs(s.angle), 180);
|
||
closeTo(s.cy, outsideY - 45);
|
||
closeTo(s.dist, 0);
|
||
|
||
const top = THICK_SURFACES.filter((surface) => Math.abs(surface.axisA[1] - 100) < 1e-9
|
||
&& Math.abs(surface.axisB[1] - 100) < 1e-9);
|
||
assert.equal(top.length, 2);
|
||
assert.deepEqual(top.map((surface) => surface.roomSide).sort(), ['inside', 'outside']);
|
||
const inside = snapFurnitureToWall(300, TOP_20, 90, THICK_SURFACES, 30);
|
||
closeTo(inside.angle, 0);
|
||
closeTo(inside.cy, TOP_20 + 45);
|
||
});
|
||
|
||
test('new exact-axis exterior placement defaults inside while drag preserves either side', () => {
|
||
const placed = snapFurnitureToWall(300, 100, 90, THICK_SURFACES, 30);
|
||
closeTo(placed.angle, 0);
|
||
closeTo(placed.cy, TOP_20 + 45);
|
||
const keepOutside = snapFurnitureToWall(
|
||
300, 100, 90, [...THICK_SURFACES].reverse(), 30, 0, [300, 100], [0, -1],
|
||
);
|
||
closeTo(Math.abs(keepOutside.angle), 180);
|
||
closeTo(keepOutside.cy, 100 - HALF_20 - 45);
|
||
});
|
||
|
||
test('a vertical wall gives a right angle and the depth measured sideways', () => {
|
||
const left = snapFurnitureToWall(112, 250, 60, THICK_SURFACES, 30);
|
||
closeTo(Math.abs(left.angle), 90);
|
||
closeTo(left.cx, 100 + 30); // this unconfigured edge remains zero-thickness
|
||
closeTo(left.cy, 250);
|
||
const right = snapFurnitureToWall(488, 250, 60, THICK_SURFACES, 30);
|
||
closeTo(right.cx, 500 - 30);
|
||
// the two are opposite: the back always looks at its own wall
|
||
closeTo(Math.abs(((left.angle - right.angle) % 360 + 360) % 360), 180);
|
||
});
|
||
|
||
test('out of reach there is no magnet at all', () => {
|
||
assert.equal(snapFurnitureToWall(300, 250, 90, ZERO_SURFACES, 30), null);
|
||
assert.equal(snapFurnitureToWall(300, 112, 90, [], 30), null); // no walls
|
||
// Reach is measured from the physical y=112 face, not the y=100 axis.
|
||
assert.equal(snapFurnitureToWall(300, TOP_20 + 30.1, 90, THICK_SURFACES, 30), null);
|
||
assert.ok(snapFurnitureToWall(300, TOP_20 + 29.9, 90, THICK_SURFACES, 30));
|
||
});
|
||
|
||
test('the offset ALONG the wall is quantised to the grid when a step is given', () => {
|
||
const step = GRID_PITCH; // one cell
|
||
const free = snapFurnitureToWall(303.3, TOP_20, 90, THICK_SURFACES, 30);
|
||
const snapped = snapFurnitureToWall(303.3, TOP_20, 90, THICK_SURFACES, 30, step);
|
||
closeTo(free.cx, 303.3);
|
||
// the wall starts at x=100, so a snapped centre sits on 100 + k*step
|
||
closeTo(((snapped.cx - 100) / step) % 1, 0, 1e-9);
|
||
assert.ok(Math.abs(snapped.cx - 303.3) <= step);
|
||
});
|
||
|
||
test('a shared thick wall selects the intent side and exact-axis drag preserves its side', () => {
|
||
const rooms = [
|
||
{ id: 'a', poly: [[100, 100], [300, 100], [300, 400], [100, 400]] },
|
||
{ id: 'b', poly: [[300, 100], [500, 100], [500, 400], [300, 400]] },
|
||
];
|
||
const walls = setWallThickness(
|
||
[], [300, 100], [300, 400], 20, GRID_STEP_N, NORM_W,
|
||
);
|
||
const surfaces = roomSurfaces(rooms, walls);
|
||
const shared = surfaces.filter((surface) => Math.abs(surface.axisA[0] - 300) < 1e-9
|
||
&& Math.abs(surface.axisB[0] - 300) < 1e-9);
|
||
assert.equal(shared.length, 2, 'a shared atom already has one room-facing face per room');
|
||
assert.ok(shared.every((surface) => surface.roomSide === undefined));
|
||
const left = snapFurnitureToWall(280, 250, 60, surfaces, 30);
|
||
const right = snapFurnitureToWall(320, 250, 60, surfaces, 30);
|
||
closeTo(left.cx, 300 - HALF_20 - 30);
|
||
closeTo(right.cx, 300 + HALF_20 + 30);
|
||
closeTo(left.dist, 20 - HALF_20);
|
||
closeTo(right.dist, 20 - HALF_20);
|
||
|
||
// The higher-level decor snap may move the placement point onto the axis;
|
||
// the untouched pointer still owns the requested room side.
|
||
const snappedPointKeepsIntent = snapFurnitureToWall(
|
||
300, 250, 60, surfaces, 30, 0, [320, 250],
|
||
);
|
||
closeTo(snappedPointKeepsIntent.cx, 300 + HALF_20 + 30);
|
||
|
||
const keepRight = snapFurnitureToWall(
|
||
300, 250, 60, surfaces, 30, 0, [300, 250], [1, 0],
|
||
);
|
||
closeTo(keepRight.cx, 300 + HALF_20 + 30);
|
||
const keepLeft = snapFurnitureToWall(
|
||
300, 250, 60, [...surfaces].reverse(), 30, 0, [300, 250], [-1, 0],
|
||
);
|
||
closeTo(keepLeft.cx, 300 - HALF_20 - 30);
|
||
});
|
||
|
||
test('partially shared edges are atomised before exterior ownership is assigned', () => {
|
||
const rooms = [
|
||
{ id: 'main', poly: [[100, 100], [500, 100], [500, 400], [100, 400]] },
|
||
{ id: 'side', poly: [[500, 200], [700, 200], [700, 300], [500, 300]] },
|
||
];
|
||
const walls = setWallThickness(
|
||
[], [500, 100], [500, 400], 20, GRID_STEP_N, NORM_W,
|
||
);
|
||
const surfaces = roomSurfaces(rooms, walls);
|
||
const vertical = surfaces.filter((surface) => Math.abs(surface.axisA[0] - 500) < 1e-9
|
||
&& Math.abs(surface.axisB[0] - 500) < 1e-9);
|
||
const span = (surface) => [surface.axisA[1], surface.axisB[1]].sort((a, b) => a - b);
|
||
const shared = vertical.filter((surface) => {
|
||
const [lo, hi] = span(surface);
|
||
return Math.abs(lo - 200) < 1e-9 && Math.abs(hi - 300) < 1e-9;
|
||
});
|
||
assert.equal(shared.length, 2);
|
||
assert.ok(shared.every((surface) => surface.roomSide === undefined));
|
||
for (const [lo, hi] of [[100, 200], [300, 400]]) {
|
||
const outer = vertical.filter((surface) => {
|
||
const extent = span(surface);
|
||
return Math.abs(extent[0] - lo) < 1e-9 && Math.abs(extent[1] - hi) < 1e-9;
|
||
});
|
||
assert.equal(outer.length, 2, `outer child ${lo}..${hi} needs inside + outside`);
|
||
assert.deepEqual(outer.map((surface) => surface.roomSide).sort(), ['inside', 'outside']);
|
||
}
|
||
});
|
||
|
||
test('new exact-axis placement is stable across room order, winding and surface order', () => {
|
||
const base = [
|
||
{ id: 'a', poly: [[100, 100], [300, 100], [300, 400], [100, 400]] },
|
||
{ id: 'b', poly: [[300, 100], [500, 100], [500, 400], [300, 400]] },
|
||
];
|
||
const walls = setWallThickness(
|
||
[], [300, 100], [300, 400], 20, GRID_STEP_N, NORM_W,
|
||
);
|
||
const variants = [
|
||
base,
|
||
[...base].reverse(),
|
||
base.map((room) => ({ ...room, poly: [...room.poly].reverse() })),
|
||
];
|
||
const snaps = variants.flatMap((rooms) => {
|
||
const surfaces = roomSurfaces(rooms, walls);
|
||
return [surfaces, [...surfaces].reverse()].map((input) =>
|
||
snapFurnitureToWall(300, 250, 60, input, 30));
|
||
});
|
||
for (const snap of snaps.slice(1)) {
|
||
closeTo(snap.cx, snaps[0].cx);
|
||
closeTo(snap.cy, snaps[0].cy);
|
||
closeTo(snap.angle, snaps[0].angle);
|
||
}
|
||
});
|
||
|
||
test('local atomic wall thickness owns the surface under the projection', () => {
|
||
let walls = setWallThickness(
|
||
[], [100, 100], [300, 100], 10, GRID_STEP_N, NORM_W,
|
||
);
|
||
walls = setWallThickness(
|
||
walls, [300, 100], [500, 100], 20, GRID_STEP_N, NORM_W,
|
||
);
|
||
const surfaces = roomSurfaces([ROOM], walls);
|
||
const thin = snapFurnitureToWall(200, 100 + HALF_10, 20, surfaces, 30);
|
||
const thick = snapFurnitureToWall(400, 100 + HALF_20, 20, surfaces, 30);
|
||
closeTo(thin.cy, 100 + HALF_10 + 10);
|
||
closeTo(thick.cy, 100 + HALF_20 + 10);
|
||
closeTo(thin.dist, 0);
|
||
closeTo(thick.dist, 0);
|
||
});
|
||
|
||
test('zero walls keep the old centreline geometry', () => {
|
||
const snap = snapFurnitureToWall(300, 112, 90, ZERO_SURFACES, 30);
|
||
closeTo(snap.cy, 100 + 45);
|
||
closeTo(snap.dist, 12);
|
||
});
|
||
|
||
test('independent physical-body faces are not offset twice', () => {
|
||
const surfaces = physicalFurnitureWallSurfaces([[
|
||
[200, 200], [400, 200], [400, 220], [200, 220],
|
||
]]);
|
||
const above = snapFurnitureToWall(300, 190, 20, surfaces, 30);
|
||
closeTo(above.cy, 200 - 10);
|
||
closeTo(above.dist, 10);
|
||
const below = snapFurnitureToWall(300, 230, 20, surfaces, 30);
|
||
closeTo(below.cy, 220 + 10);
|
||
closeTo(below.dist, 10);
|
||
});
|
||
|
||
test('corner selection is nearest-first, intent-aware and invariant to input order', () => {
|
||
const horizontal = {
|
||
a: [-100, 0], b: [100, 0], axisA: [-100, 0], axisB: [100, 0],
|
||
normal: [0, 1], owner: 'room', stableId: 'z-horizontal', roomId: 'h',
|
||
};
|
||
const vertical = {
|
||
a: [0, -100], b: [0, 100], axisA: [0, -100], axisB: [0, 100],
|
||
normal: [1, 0], owner: 'room', stableId: 'a-vertical', roomId: 'v',
|
||
};
|
||
const nearest = snapFurnitureToWall(20, 10, 10, [horizontal, vertical], 30);
|
||
closeTo(nearest.angle, 0); // y=0 is 10 away; x=0 is 20 away
|
||
|
||
// Equal distance, but the point is on the allowed side of horizontal and
|
||
// the forbidden side of vertical. Intent wins even though vertical sorts first.
|
||
const intended = snapFurnitureToWall(-10, 10, 10, [vertical, horizontal], 30);
|
||
closeTo(intended.angle, 0);
|
||
|
||
const tiedA = snapFurnitureToWall(10, 10, 10, [horizontal, vertical], 30);
|
||
const tiedB = snapFurnitureToWall(10, 10, 10, [vertical, horizontal], 30);
|
||
assert.deepEqual(tiedA, tiedB);
|
||
closeTo(Math.abs(tiedA.angle), 90); // stable id a-vertical wins full equality
|
||
});
|
||
|
||
test('malformed surfaces are ignored and along-wall quantisation stays inside an atom', () => {
|
||
const valid = THICK_SURFACES.find((surface) => surface.roomId === 'r'
|
||
&& Math.abs(surface.a[1] - TOP_20) < 1e-9);
|
||
const broken = {
|
||
a: [NaN, 0], b: [0, 0], axisA: [0, 0], axisB: [0, 0],
|
||
normal: [0, 1], owner: 'room', stableId: 'broken', roomId: 'bad',
|
||
};
|
||
const snap = snapFurnitureToWall(
|
||
499.9, TOP_20, 10, [broken, valid], 30, 400, [499.9, TOP_20],
|
||
);
|
||
assert.ok(Number.isFinite(snap.cx) && Number.isFinite(snap.cy));
|
||
closeTo(snap.cx, 500); // rounded along coordinate is clamped to the atom end
|
||
});
|
||
|
||
test('runtime wall surfaces are built once per geometry epoch', () => {
|
||
let builds = 0;
|
||
const source = {
|
||
_cfgEpoch: 1,
|
||
_cellCm: 5,
|
||
_gridPitch: GRID_PITCH,
|
||
_wallKeyPitch: GRID_STEP_N,
|
||
_spaceWalls: thickTopWalls(20),
|
||
_spaceModel: () => ({ id: 'cache-room', rooms: [ROOM] }),
|
||
_openCuts: () => { builds++; return []; },
|
||
_rawPhysicalBodiesR: () => [],
|
||
};
|
||
const first = furnitureWallSurfacesFor(source);
|
||
const second = furnitureWallSurfacesFor(source);
|
||
assert.equal(first, second);
|
||
assert.equal(builds, 1);
|
||
source._cfgEpoch++;
|
||
const afterGeometryChange = furnitureWallSurfacesFor(source);
|
||
assert.notEqual(afterGeometryChange, first);
|
||
assert.equal(builds, 2);
|
||
});
|
||
|
||
test('the default reach is six cells — thirty centimetres on a default plan', () => {
|
||
assert.equal(FURN_WALL_CELLS, 6);
|
||
closeTo(GRID_PITCH * FURN_WALL_CELLS * (5 / GRID_PITCH), 30); // cells x cell_cm
|
||
});
|
||
|
||
test('preview and commit share one deterministic furniture placement resolver', () => {
|
||
const input = {
|
||
symbol: 'sofa', widthCm: 180, depthCm: 90,
|
||
point: [300, TOP_20], canvasW: 1000, canvasH: 1000,
|
||
intentPoint: [300, TOP_20],
|
||
cellCm: 5, gridPitch: GRID_PITCH, walls: THICK_SURFACES,
|
||
wallReach: 30,
|
||
};
|
||
const preview = resolveFurniturePlacement(input);
|
||
const commit = resolveFurniturePlacement({ ...input });
|
||
assert.deepEqual(preview, commit);
|
||
assert.equal(preview.symbol, 'sofa');
|
||
closeTo((preview.x + preview.w / 2) * 1000, 300, GRID_PITCH);
|
||
closeTo((preview.y + preview.h / 2) * 1000, TOP_20 + preview.h * 500);
|
||
assert.equal(preview.angle, 0);
|
||
});
|
||
|
||
test('the shared placement resolver supports Shift/free, canvas guards and unknown symbols', () => {
|
||
const base = {
|
||
symbol: 'sofa', widthCm: 180, depthCm: 90,
|
||
point: [300, TOP_20], canvasW: 1000, canvasH: 1000,
|
||
intentPoint: [300, TOP_20],
|
||
cellCm: 5, gridPitch: GRID_PITCH, walls: THICK_SURFACES,
|
||
wallReach: 30,
|
||
};
|
||
const free = resolveFurniturePlacement({ ...base, free: true });
|
||
closeTo((free.x + free.w / 2) * 1000, 300);
|
||
closeTo((free.y + free.h / 2) * 1000, TOP_20);
|
||
const guarded = resolveFurniturePlacement({ ...base, point: [-1e9, -1e9], free: true });
|
||
assert.equal(guarded.x, -5000);
|
||
assert.equal(guarded.y, -5000);
|
||
assert.equal(resolveFurniturePlacement({ ...base, symbol: 'future_unknown_symbol' }), null);
|
||
});
|
||
|
||
// ------------------------------- the frame ----------------------------------
|
||
|
||
test('the corners of an unrotated box are the box; a rotated one turns about its centre', () => {
|
||
assert.deepEqual(furnitureCorners(100, 200, 40, 20, 0),
|
||
[[100, 200], [140, 200], [140, 220], [100, 220]]);
|
||
const c = furnitureCorners(0, 0, 40, 20, 90);
|
||
// 90 degrees clockwise in SVG coordinates: the NW corner lands top-right
|
||
closeTo(c[0][0], 30); closeTo(c[0][1], -10);
|
||
closeTo(c[2][0], 10); closeTo(c[2][1], 30);
|
||
});
|
||
|
||
test('a corner drag moves BOTH axes and keeps the opposite corner still', () => {
|
||
const orig = { x: 100, y: 100, w: 200, h: 100, angle: 0 };
|
||
// pull the SE corner (+1,+1) out to (360, 260): the NW corner must not move
|
||
const r = furnitureResize(orig, 1, 1, 360, 260);
|
||
closeTo(r.x, 100); closeTo(r.y, 100);
|
||
closeTo(r.w, 260); closeTo(r.h, 160);
|
||
// …and pulling the NW corner (-1,-1) keeps the SE one
|
||
const r2 = furnitureResize(orig, -1, -1, 60, 60);
|
||
closeTo(r2.x + r2.w, 300); closeTo(r2.y + r2.h, 200);
|
||
closeTo(r2.w, 240); closeTo(r2.h, 140);
|
||
});
|
||
|
||
test('the two axes are INDEPENDENT: the aspect ratio is not preserved', () => {
|
||
const orig = { x: 0, y: 0, w: 200, h: 100 };
|
||
const r = furnitureResize(orig, 1, 1, 400, 110);
|
||
closeTo(r.w, 400);
|
||
closeTo(r.h, 110); // a uniform scale would have made this 200
|
||
});
|
||
|
||
test('a step quantises each dimension; without one the drag is exact (Shift)', () => {
|
||
const orig = { x: 0, y: 0, w: 200, h: 100 };
|
||
const step = GRID_PITCH;
|
||
const snapped = furnitureResize(orig, 1, 1, 203.3, 101.1, step);
|
||
closeTo((snapped.w / step) % 1, 0, 1e-9);
|
||
closeTo((snapped.h / step) % 1, 0, 1e-9);
|
||
const free = furnitureResize(orig, 1, 1, 203.3, 101.1, 0);
|
||
closeTo(free.w, 203.3); closeTo(free.h, 101.1);
|
||
});
|
||
|
||
test('a resize never collapses below the minimum, however far the corner is dragged back', () => {
|
||
const orig = { x: 0, y: 0, w: 200, h: 100 };
|
||
const r = furnitureResize(orig, 1, 1, -500, -500, 0, 2);
|
||
closeTo(r.w, 2); closeTo(r.h, 2);
|
||
});
|
||
|
||
test('resizing a ROTATED piece works along its own axes and keeps its own fixed corner', () => {
|
||
const orig = { x: 0, y: 0, w: 100, h: 50, angle: 90 };
|
||
// the fixed corner of an SE drag is the piece's NW corner, in WORLD units
|
||
const fixed = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle)[0];
|
||
const r = furnitureResize(orig, 1, 1, fixed[0] + 10, fixed[1] + 200, 0);
|
||
const after = furnitureCorners(r.x, r.y, r.w, r.h, orig.angle)[0];
|
||
closeTo(after[0], fixed[0], 1e-6);
|
||
closeTo(after[1], fixed[1], 1e-6);
|
||
// rotated 90 degrees, pulling DOWN in world space grows the piece's WIDTH
|
||
closeTo(r.w, 200, 1e-6);
|
||
});
|
||
|
||
test('#383 furniture resize is sub-grid, proportional by default and independent with Shift', () => {
|
||
const orig = { x: 10, y: 20, w: 40, h: 20 };
|
||
const proportional = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, true, 0.1);
|
||
closeTo(proportional.w / proportional.h, 2);
|
||
assert.ok(Math.abs(proportional.w / GRID_PITCH - Math.round(proportional.w / GRID_PITCH)) > 1e-3,
|
||
'continuous resize must not land on the cell lattice by construction');
|
||
const independent = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, false, 0.1);
|
||
closeTo(independent.w, 40.37);
|
||
closeTo(independent.h, 20.02);
|
||
});
|
||
|
||
test('#383 edge handles change one local axis and keep the opposite world edge fixed', () => {
|
||
const orig = { x: 10, y: 20, w: 40, h: 20, angle: 90 };
|
||
const before = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle);
|
||
const right = resizeFurnitureTransform(orig, 1, 0, 30, 90, false, 0.1);
|
||
closeTo(right.h, orig.h);
|
||
const after = furnitureCorners(right.x, right.y, right.w, right.h, orig.angle);
|
||
closeTo((after[0][0] + after[3][0]) / 2, (before[0][0] + before[3][0]) / 2);
|
||
closeTo((after[0][1] + after[3][1]) / 2, (before[0][1] + before[3][1]) / 2);
|
||
|
||
const bottom = resizeFurnitureTransform(orig, 0, 1, -20, 40, false, 0.1);
|
||
closeTo(bottom.w, orig.w);
|
||
});
|
||
|
||
test('#383 crossing keeps positive extents, toggles only crossed flips and preserves fixed corner', () => {
|
||
const orig = { x: 0, y: 0, w: 40, h: 20, angle: 30, flip_h: true };
|
||
const fixed = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle)[0];
|
||
const crossed = resizeFurnitureTransform(orig, 1, 1, fixed[0] - 20, fixed[1] + 5, false, 0.1);
|
||
assert.ok(crossed.w > 0 && crossed.h > 0);
|
||
assert.equal(crossed.flip_h, undefined, 'crossing horizontal axis toggles existing H flip off');
|
||
assert.equal(crossed.flip_v, undefined);
|
||
const after = furnitureCorners(crossed.x, crossed.y, crossed.w, crossed.h, orig.angle);
|
||
// A crossed active corner becomes the adjacent geometric corner; the fixed
|
||
// world point is nevertheless still one of the box corners.
|
||
assert.ok(after.some((point) => Math.hypot(point[0] - fixed[0], point[1] - fixed[1]) < 1e-8));
|
||
|
||
const both = resizeFurnitureTransform({ x: 0, y: 0, w: 40, h: 20 }, 1, 1, -5, -8, false, 0.1);
|
||
assert.equal(both.flip_h, true);
|
||
assert.equal(both.flip_v, true);
|
||
assert.deepEqual([both.w, both.h], [5, 8]);
|
||
});
|
||
|
||
test('#383 furniture rotation is free normally and Shift snaps to 45 degrees', () => {
|
||
closeTo(furnitureRotationAngle(10, 20, 33.4, false), 23.4);
|
||
assert.equal(furnitureRotationAngle(10, 20, 33.4, true), 45);
|
||
assert.equal(furnitureRotationAngle(170, 0, 30, true), 180);
|
||
assert.equal(furnitureRotationAngle(0, 0, 22.5, true), 45);
|
||
assert.equal(furnitureRotationAngle(0, 0, -22.5, true), -45);
|
||
});
|
||
|
||
test('#383 render transform mirrors inside the same positive box before rotation', () => {
|
||
const base = { x: 0.1, y: 0.2, w: 0.3, h: 0.4, angle: 30 };
|
||
assert.equal(
|
||
furnitureRenderTransform(base, 1000, 500, 100, 50),
|
||
'rotate(30 250 200) translate(100 100) scale(3 4)',
|
||
);
|
||
assert.equal(
|
||
furnitureRenderTransform({ ...base, flip_h: true, flip_v: true }, 1000, 500, 100, 50),
|
||
'rotate(30 250 200) translate(400 300) scale(-3 -4)',
|
||
);
|
||
});
|
||
|
||
test('#383 signed property fields project flags without persisting negative extents', () => {
|
||
assert.equal(furnitureSignedFieldValue(180, false, false), '1.8');
|
||
assert.equal(furnitureSignedFieldValue(180, true, false), '-1.8');
|
||
assert.equal(furnitureSignedFieldValue(30.48, true, true), '-1');
|
||
assert.equal(furnitureSignedFieldValue(0.1, false, false), '0.001');
|
||
assert.equal(furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, false), false, 10000), 0.1);
|
||
closeTo(
|
||
furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, true), true, 10000),
|
||
0.1,
|
||
0.0001,
|
||
);
|
||
assert.equal(furnitureSignedFieldCm('-1.8', false, 10000), -180);
|
||
assert.equal(furnitureSignedFieldCm('1', true, 10000), 30.48);
|
||
for (const invalid of ['', '0', 0, 'wat', Infinity])
|
||
assert.equal(furnitureSignedFieldCm(invalid, false, 10000), null);
|
||
});
|