mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
feat: добавить лестницы между этажами (#663)
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D. Issue: #663 User-Visible: yes
This commit is contained in:
@@ -31,6 +31,29 @@ test('typical small plan: the frame is exactly what is drawn (unchanged behaviou
|
||||
assert.deepEqual(contentBounds(withVb), { x: 390, y: 390, w: 220, h: 220 });
|
||||
});
|
||||
|
||||
test('#663 stairs contribute their exact rotated and circular footprints to fit-all', () => {
|
||||
const rotated = model({ id: 's', stairs: [{
|
||||
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 45,
|
||||
direction: 'forward', length: 0.2, width: 0.1,
|
||||
}] });
|
||||
const [box] = contentItems(rotated);
|
||||
const half = 150 / Math.sqrt(2);
|
||||
assert.ok(Math.abs(box.minX - (500 - half)) < 1e-9);
|
||||
assert.ok(Math.abs(box.maxX - (500 + half)) < 1e-9);
|
||||
assert.ok(Math.abs(box.minY - (500 - half)) < 1e-9);
|
||||
assert.ok(Math.abs(box.maxY - (500 + half)) < 1e-9);
|
||||
|
||||
const round = model({ id: 's', stairs: [{
|
||||
id: 'spiral', kind: 'spiral', x: 0.25, y: 0.4, angle: 17,
|
||||
direction: 'counterclockwise', radius: 0.08,
|
||||
}] });
|
||||
const circle = contentItems(round)[0];
|
||||
assert.ok(Math.abs(circle.minX - 170) < 1e-9);
|
||||
assert.ok(Math.abs(circle.minY - 320) < 1e-9);
|
||||
assert.ok(Math.abs(circle.maxX - 330) < 1e-9);
|
||||
assert.ok(Math.abs(circle.maxY - 480) < 1e-9);
|
||||
});
|
||||
|
||||
test('per-edge frame padding can remove only the top inset', () => {
|
||||
const m = model({ id: 's', rooms: [{
|
||||
id: 'r', poly: [[0.2, 0.3], [0.8, 0.3], [0.8, 0.7], [0.2, 0.7]],
|
||||
|
||||
@@ -163,6 +163,21 @@ test('#383 furniture flip flags survive frontend canonicalization unchanged', ()
|
||||
assert.equal(furniture.flip_v, false);
|
||||
});
|
||||
|
||||
test('#663 stair transforms keep continuous wall-face magnet positions', () => {
|
||||
const stair = {
|
||||
id: 'stair', kind: 'straight', direction: 'forward', target_space_id: 'upper',
|
||||
x: 0.5000001, y: 0.3999999, length: 0.2000001, width: 0.0999999,
|
||||
angle: 17.1234567896,
|
||||
};
|
||||
const output = canonicalizeConfigGeometry({ spaces: [{ id: 'ground', stairs: [stair] }] })
|
||||
.spaces[0].stairs[0];
|
||||
assert.deepEqual(output, {
|
||||
...stair,
|
||||
x: 0.5000001, y: 0.3999999, length: 0.2000001, width: 0.0999999,
|
||||
angle: 17.12345679,
|
||||
});
|
||||
});
|
||||
|
||||
test('one position changes only x/y and preserves future metadata (#224)', () => {
|
||||
const input = {
|
||||
s: 'floor', x: 0.1000000006, y: -0.0000000004,
|
||||
|
||||
@@ -445,7 +445,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 64);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 65);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -492,6 +492,7 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
|
||||
'vac.diag_position',
|
||||
'version_mismatch.backend',
|
||||
'radar.source_optional',
|
||||
'stairs.radius', // #663: Radius is the same engineering term in EN and DE.
|
||||
]);
|
||||
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
|
||||
assert.deepEqual(new Set(equalKeys), allowed);
|
||||
@@ -544,6 +545,8 @@ test('i18n: French values equal to English are explicitly reviewed (#371)', () =
|
||||
'wallthick.unit_cm',
|
||||
'radar.heading',
|
||||
'radar.radians',
|
||||
'stairs.type', // #663: Type and Rotation are valid French/English homographs.
|
||||
'stairs.rotation',
|
||||
]);
|
||||
const equalKeys = Object.keys(en).filter((key) => en[key] === fr[key]);
|
||||
assert.deepEqual(new Set(equalKeys), allowed);
|
||||
|
||||
@@ -12,6 +12,8 @@ test('large-house fixture meets the HP-PERF-01 reference counts', () => {
|
||||
assert.equal(count('partitions'), LARGE_HOUSE_COUNTS.partitions);
|
||||
assert.equal(count('wall_columns'), LARGE_HOUSE_COUNTS.columns);
|
||||
assert.equal(count('decor'), LARGE_HOUSE_COUNTS.decor);
|
||||
assert.equal(count('stairs'), LARGE_HOUSE_COUNTS.stairs);
|
||||
assert.equal(fixture.config.spaces[0].stairs.length, 250, 'one floor exercises the backend cap');
|
||||
assert.equal(Object.keys(fixture.devices).length, LARGE_HOUSE_COUNTS.devices);
|
||||
assert.equal(Object.keys(fixture.entities).length, LARGE_HOUSE_COUNTS.devices);
|
||||
assert.equal(fixture.config.markers.length > 0, true);
|
||||
|
||||
@@ -170,6 +170,35 @@ test('PDF scene includes architecture and respects names/dimensions switches', (
|
||||
'door leaf/arc is represented by vector lines');
|
||||
});
|
||||
|
||||
test('#663 PDF renders both stair symbols and uses stair-adjusted clean area', () => {
|
||||
const withStairs = structuredClone(rawSpace);
|
||||
withStairs.stairs = [
|
||||
{
|
||||
id: 'straight', kind: 'straight', x: 0.4, y: 0.4, angle: 0,
|
||||
direction: 'forward', length: 0.2, width: 0.1, target_space_id: 'upper',
|
||||
},
|
||||
{
|
||||
id: 'spiral', kind: 'spiral', x: 0.68, y: 0.5, angle: 30,
|
||||
direction: 'counterclockwise', radius: 0.06, target_space_id: null,
|
||||
},
|
||||
];
|
||||
const output = buildRawPage(withStairs, {
|
||||
dimensions: true, roomNames: false, decor: false, backdrop: false,
|
||||
});
|
||||
const stairOutlines = output.commands.filter((command) => command.kind === 'path'
|
||||
&& (command.rings[0]?.length === 4 || command.rings[0]?.length === 64));
|
||||
assert.equal(stairOutlines.length, 2, 'stairs are architectural output even when decor is hidden');
|
||||
assert.equal(output.commands.filter((command) => command.kind === 'vector').length, 2,
|
||||
'each stair keeps its direction arrow');
|
||||
const area = output.commands.find((command) => command.kind === 'text'
|
||||
&& /m²/.test(command.text));
|
||||
assert.ok(area, 'room area is printed');
|
||||
// Base room: 700×600 render units. The two non-overlapping footprints are
|
||||
// 200×100 and a 60-unit-radius circle; cmPerUnit is 1.2.
|
||||
const expectedM2 = (420_000 - 20_000 - Math.PI * 60 ** 2) * 1.2 ** 2 / 10_000;
|
||||
assert.match(area.text, new RegExp(`^${expectedM2.toFixed(1).replace('.', '[.,]')}\\s*m²$`));
|
||||
});
|
||||
|
||||
test('shared wall architecture is emitted once and devices never enter the PDF scene', () => {
|
||||
const output = sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false });
|
||||
const architecture = output.commands.filter((command) => command.kind === 'path');
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import {
|
||||
cachedStairRenderGeometry,
|
||||
floorAreaMinusStairs,
|
||||
geometryAreaMinusStairs,
|
||||
isStair,
|
||||
stairRenderGeometry,
|
||||
} from '../test-build/stairs.js';
|
||||
import {
|
||||
convertStairKind,
|
||||
defaultStair,
|
||||
snapStairToStairs,
|
||||
stairPhysicalSizeCm,
|
||||
stairTargetState,
|
||||
} from '../test-build/stairs-editor-model.js';
|
||||
|
||||
const straight = (extra = {}) => ({
|
||||
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 0,
|
||||
direction: 'forward', length: 0.24, width: 0.1,
|
||||
target_space_id: 'upper', ...extra,
|
||||
});
|
||||
|
||||
const spiral = (extra = {}) => ({
|
||||
id: 'spiral', kind: 'spiral', x: 0.5, y: 0.5, angle: 0,
|
||||
direction: 'clockwise', radius: 0.1,
|
||||
target_space_id: 'upper', ...extra,
|
||||
});
|
||||
|
||||
test('#663 validates the discriminated stair model and preserves future fields', () => {
|
||||
assert.equal(isStair({ ...straight(), future: { keep: true } }), true);
|
||||
assert.equal(isStair({ ...straight(), direction: 'clockwise' }), false);
|
||||
assert.equal(isStair({ ...straight(), width: 0 }), false);
|
||||
assert.equal(isStair({ ...spiral(), direction: 'backward' }), false);
|
||||
assert.equal(isStair({ ...spiral(), radius: Number.NaN }), false);
|
||||
assert.equal(isStair({ ...spiral(), radius: 5001 }), false);
|
||||
assert.equal(isStair({ ...straight(), angle: 361 }), false);
|
||||
});
|
||||
|
||||
test('#663 default physical sizes and type conversion are predictable', () => {
|
||||
const first = defaultStair('straight', 500, 400, 5, 'a');
|
||||
assert.equal(first.kind, 'straight');
|
||||
assert.deepEqual(stairPhysicalSizeCm(first, 5).map(Math.round), [240, 100]);
|
||||
first.target_space_id = 'upper';
|
||||
const round = convertStairKind(first, 'spiral');
|
||||
assert.equal(round.kind, 'spiral');
|
||||
assert.equal(Math.round(stairPhysicalSizeCm(round, 5)[0]), 120);
|
||||
const restored = convertStairKind(round, 'straight');
|
||||
assert.equal(restored.kind, 'straight');
|
||||
assert.deepEqual(stairPhysicalSizeCm(restored, 5).map(Math.round), [240, 240]);
|
||||
assert.equal(restored.target_space_id, 'upper');
|
||||
});
|
||||
|
||||
test('#663 straight stair treads keep exact 30 cm intervals and top remainder', () => {
|
||||
const geometry = stairRenderGeometry(straight(), 5);
|
||||
assert.equal(geometry.treads.length, 9);
|
||||
for (let index = 1; index < geometry.treads.length; index++) {
|
||||
assert.equal(geometry.treads[index].a[0] - geometry.treads[index - 1].a[0], 25);
|
||||
}
|
||||
assert.equal(geometry.treads[0].a[0], 405, 'first line is 30 cm after the lower edge');
|
||||
assert.equal(geometry.treads.at(-1).a[0], 605, 'the short remainder stays before the top edge');
|
||||
assert.deepEqual(
|
||||
geometry.treads.map((line) => line.b[1] - line.a[1]),
|
||||
Array(9).fill(100),
|
||||
);
|
||||
const zoomed = stairRenderGeometry(straight(), 5, 500);
|
||||
assert.equal(zoomed.treads.length, geometry.treads.length,
|
||||
'zoom changes only pixels, never the physical tread count');
|
||||
assert.equal(zoomed.treads[1].a[0] - zoomed.treads[0].a[0], 12.5,
|
||||
'the same 30 cm interval scales with the symbol, not with viewport zoom');
|
||||
});
|
||||
|
||||
test('#663 spiral stair uses one turn with 30 cm travel-line spacing', () => {
|
||||
const geometry = stairRenderGeometry(spiral({ angle: 30 }), 5);
|
||||
assert.equal(geometry.treads.length, 16);
|
||||
const center = geometry.center;
|
||||
const angles = geometry.treads.map((line) => Math.atan2(
|
||||
line.b[1] - center[1], line.b[0] - center[0],
|
||||
));
|
||||
const unwrapped = angles.reduce((result, angle) => {
|
||||
let value = angle;
|
||||
while (result.length && value <= result.at(-1)) value += Math.PI * 2;
|
||||
result.push(value);
|
||||
return result;
|
||||
}, []);
|
||||
const expected = 25 / (100 * 2 / 3);
|
||||
for (let index = 1; index < unwrapped.length; index++) {
|
||||
assert.ok(Math.abs((unwrapped[index] - unwrapped[index - 1]) - expected) < 1e-10);
|
||||
}
|
||||
const reverse = stairRenderGeometry(spiral({ direction: 'counterclockwise' }), 5);
|
||||
assert.ok(reverse.treads[1].b[1] < reverse.treads[0].b[1]);
|
||||
});
|
||||
|
||||
test('#663 cached render geometry survives live repaints and invalidates on transform', () => {
|
||||
const item = straight();
|
||||
const first = cachedStairRenderGeometry(item, 5);
|
||||
const second = cachedStairRenderGeometry(item, 5);
|
||||
assert.equal(second, first, 'unchanged stair reuses the dense render geometry');
|
||||
item.angle = 45;
|
||||
const changed = cachedStairRenderGeometry(item, 5);
|
||||
assert.notEqual(changed, first, 'in-place edits cannot leave a stale cache entry');
|
||||
});
|
||||
|
||||
test('#663 stair magnet covers rectangle/rectangle, rectangle/circle and circle/circle footprints', () => {
|
||||
const rect = straight({ id: 'rect', x: 0.5, y: 0.5, length: 0.2, width: 0.1 });
|
||||
const circle = spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 });
|
||||
const movingRect = straight({ id: 'moving-rect', length: 0.2, width: 0.1 });
|
||||
const movingCircle = spiral({ id: 'moving-circle', radius: 0.08 });
|
||||
|
||||
assert.deepEqual(snapStairToStairs(movingRect, [704, 500], [rect], 10), [700, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [686, 500], [rect], 10), [680, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingRect, [686, 500], [circle], 10), [680, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [664, 500], [circle], 10), [660, 500]);
|
||||
assert.deepEqual(snapStairToStairs(movingCircle, [700, 500], [circle], 10), [700, 500]);
|
||||
assert.deepEqual(circle, spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 }));
|
||||
});
|
||||
|
||||
test('#663 area removes only stair overlap and never becomes negative', () => {
|
||||
const floor = [[0, 0], [1000, 0], [1000, 1000], [0, 1000]];
|
||||
assert.equal(floorAreaMinusStairs(floor, [straight()]), 976_000);
|
||||
assert.equal(floorAreaMinusStairs(floor, [straight({ x: 0.95, length: 0.2, width: 0.2 })]), 970_000);
|
||||
const circular = floorAreaMinusStairs(floor, [spiral()]);
|
||||
assert.ok(Math.abs(circular - (1_000_000 - Math.PI * 10_000)) < 60,
|
||||
'64-segment circle remains physically accurate');
|
||||
const source = [[[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]];
|
||||
assert.equal(geometryAreaMinusStairs(source, [straight({ length: 2, width: 2 })]), 0);
|
||||
});
|
||||
|
||||
test('#663 target states distinguish active, missing, self, deleted and fixed', () => {
|
||||
const spaces = new Set(['ground', 'upper']);
|
||||
assert.equal(stairTargetState(straight(), 'ground', spaces, false), 'active');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: null }), 'ground', spaces, false), 'missing');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: 'ground' }), 'ground', spaces, false), 'self');
|
||||
assert.equal(stairTargetState(straight({ target_space_id: 'gone' }), 'ground', spaces, false), 'deleted');
|
||||
assert.equal(stairTargetState(straight(), 'ground', spaces, true), 'fixed');
|
||||
});
|
||||
|
||||
test('#663 Optimize preserves continuous authored stair transforms exactly', () => {
|
||||
const authored = straight({
|
||||
x: 0.123456789, y: -0.287654321, angle: 17.123456789,
|
||||
length: 0.234567891, width: 0.087654319,
|
||||
});
|
||||
const config = {
|
||||
model_version: 10,
|
||||
spaces: [{
|
||||
id: 'ground', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [], wall_segments: [], stairs: [authored],
|
||||
}, {
|
||||
id: 'upper', title: 'Upper', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [], wall_segments: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
assert.deepEqual(result.config.spaces[0].stairs[0], authored);
|
||||
});
|
||||
Reference in New Issue
Block a user