mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
feat: support openings in independent walls
Issue: #132 User-Visible: yes
This commit is contained in:
@@ -74,6 +74,39 @@ test('an opening stays ON its wall — it is wall-bound, not node-bound', () =>
|
||||
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
|
||||
});
|
||||
|
||||
test('a hosted opening follows aligned partition identity and t', () => {
|
||||
const spaces = [{
|
||||
id: 'f1', cell_cm: 5, rooms: [],
|
||||
partitions: [{ id: 'p1', a: [0.203, 0.2], b: [0.603, 0.2], cm: 15 }],
|
||||
openings: [{
|
||||
id: 'o1', type: 'door', x: 9, y: 9, angle: 77, length: 0.1,
|
||||
host: { kind: 'partition', id: 'p1', t: 0.25 },
|
||||
}],
|
||||
}];
|
||||
const { spaces: aligned } = alignAllToGrid(spaces, {});
|
||||
const partition = aligned[0].partitions[0];
|
||||
const opening = aligned[0].openings[0];
|
||||
assert.deepEqual(opening.host, { kind: 'partition', id: 'p1', t: 0.25 });
|
||||
assert.equal(opening.x, partition.a[0] + (partition.b[0] - partition.a[0]) * 0.25);
|
||||
assert.equal(opening.y, partition.a[1] + (partition.b[1] - partition.a[1]) * 0.25);
|
||||
assert.equal(opening.angle, 0);
|
||||
});
|
||||
|
||||
test('alignment never shortens a host past its opening interval', () => {
|
||||
const spaces = [{
|
||||
id: 'f1', cell_cm: 5, rooms: [],
|
||||
partitions: [{ id: 'p1', a: [0.0022, 0], b: [0.101, 0], cm: 15 }],
|
||||
openings: [{
|
||||
id: 'o1', type: 'door', x: 0.0516, y: 0, angle: 0, length: 0.098,
|
||||
host: { kind: 'partition', id: 'p1', t: 0.5 },
|
||||
}],
|
||||
}];
|
||||
const { spaces: aligned } = alignAllToGrid(spaces, {});
|
||||
assert.deepEqual(aligned[0].partitions[0].a, [0.0022, 0]);
|
||||
assert.deepEqual(aligned[0].partitions[0].b, [0.101, 0]);
|
||||
assert.ok(Math.abs(aligned[0].openings[0].x - 0.0516) < 1e-12);
|
||||
});
|
||||
|
||||
test('idempotent: a second run moves nothing and changes nothing', () => {
|
||||
const first = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const second = alignAllToGrid(first.spaces, first.layout);
|
||||
|
||||
@@ -44,7 +44,7 @@ test('passage bindings cannot reach subscriptions, locks or the info card', () =
|
||||
});
|
||||
|
||||
test('static passage cuts and tunnels are passage-only additions', () => {
|
||||
assert.match(staticRender, /staticPassageOpenings\(spCfg\.openings, NORM_W\)/);
|
||||
assert.match(staticRender, /staticPassageOpenings\(resolvedRawOpenings, NORM_W\)/);
|
||||
assert.match(staticRender, /passages: staticPassages\.map/);
|
||||
assert.match(staticRender, /renderOpeningTunnelFills/);
|
||||
assert.match(staticRender, /passageDataTunnels/);
|
||||
|
||||
@@ -53,6 +53,46 @@ test('Shared room-owned copies collapse to one physical target', () => {
|
||||
assert.equal(targets[0].physicalHalfWidth, 10);
|
||||
});
|
||||
|
||||
test('a coincident room wall and one partition choose the explicit partition host', () => {
|
||||
const candidate = resolve({
|
||||
intervals: [
|
||||
interval({ roomId: 'room' }),
|
||||
interval({ roomId: '', key: 'partition:p1', partitionHost: { kind: 'partition', id: 'p1' } }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(candidate.host, { kind: 'partition', id: 'p1', t: 0.5 });
|
||||
});
|
||||
|
||||
test('a collinear composite chooses its partition host when endpoints differ', () => {
|
||||
const candidate = resolve({
|
||||
pointer: [50, 0],
|
||||
intervals: [
|
||||
interval({ roomId: 'room', a: [0, 0], b: [100, 0] }),
|
||||
interval({ roomId: '', a: [20, 0], b: [80, 0], key: 'partition:p1',
|
||||
partitionHost: { kind: 'partition', id: 'p1' } }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(candidate.host, { kind: 'partition', id: 'p1', t: 0.5 });
|
||||
});
|
||||
|
||||
test('a crossing partition tie is rejected instead of choosing by key', () => {
|
||||
assert.equal(resolve({
|
||||
pointer: [50, 0],
|
||||
intervals: [
|
||||
interval({ roomId: 'room', a: [0, 0], b: [100, 0] }),
|
||||
interval({ roomId: '', a: [50, -50], b: [50, 50], key: 'partition:p1',
|
||||
partitionHost: { kind: 'partition', id: 'p1' } }),
|
||||
],
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('two coincident independent hosts are rejected as ambiguous', () => {
|
||||
assert.equal(resolve({ intervals: [
|
||||
interval({ partitionHost: { kind: 'partition', id: 'p1' } }),
|
||||
interval({ partitionHost: { kind: 'partition', id: 'p2' } }),
|
||||
] }), null);
|
||||
});
|
||||
|
||||
test('Different concentric spans never alias through the legacy wall key', () => {
|
||||
const targets = openingPlacementTargets([
|
||||
interval({ a: [0, 0], b: [100, 0], key: 'same-midpoint-and-angle' }),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
hostedOpeningIntervalsOverlap,
|
||||
materializePartitionOpening,
|
||||
partitionOpeningCut,
|
||||
partitionOpeningFace,
|
||||
partitionOpeningHasCompositeRoomWall,
|
||||
resolvePartitionOpening,
|
||||
} from '../test-build/partition-openings.js';
|
||||
import { cutPartitionBody, partitionBody } from '../test-build/physical-geometry.js';
|
||||
import { physicalBodyParts } from '../test-build/physical-geometry.js';
|
||||
|
||||
const partition = { id: 'p1', a: [0, 0], b: [100, 0], cm: 15 };
|
||||
const opening = (patch = {}) => ({
|
||||
id: 'o1', type: 'door', x: 0.5, y: 0, angle: 0, length: 0.3,
|
||||
host: { kind: 'partition', id: 'p1', t: 0.5 },
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('explicit host resolves authoritative centre, angle, depth and compatibility projection', () => {
|
||||
const resolution = resolvePartitionOpening(opening(), [partition], 100, 5, 5);
|
||||
assert.equal(resolution.reason, null);
|
||||
assert.deepEqual(resolution.resolved.center, [50, 0]);
|
||||
assert.equal(resolution.resolved.length, 30);
|
||||
assert.equal(resolution.resolved.depth, 15);
|
||||
assert.deepEqual(materializePartitionOpening(
|
||||
{ ...opening(), x: 9, y: 9, angle: 77 }, resolution.resolved, 100,
|
||||
), { ...opening(), x: 0.5, y: 0, angle: 0 });
|
||||
});
|
||||
|
||||
test('reversed host direction preserves directed t and canonical symbol angle', () => {
|
||||
const reversed = { ...partition, a: [100, 0], b: [0, 0] };
|
||||
const resolved = resolvePartitionOpening(opening({
|
||||
host: { kind: 'partition', id: 'p1', t: 0.25 },
|
||||
}), [reversed], 100, 5, 5).resolved;
|
||||
assert.deepEqual(resolved.center, [75, 0]);
|
||||
assert.equal(resolved.angle, 0);
|
||||
assert.equal(partitionOpeningFace(resolved, false).cm, 15);
|
||||
assert.equal(partitionOpeningFace(resolved, true).side, 1);
|
||||
});
|
||||
|
||||
test('missing, out-of-range and too-short hosts fail dark', () => {
|
||||
assert.equal(resolvePartitionOpening(opening(), [], 100).reason, 'missing-partition');
|
||||
assert.equal(resolvePartitionOpening(opening({
|
||||
host: { kind: 'partition', id: 'p1', t: 2 },
|
||||
}), [partition], 100).reason, 'invalid-position');
|
||||
assert.equal(resolvePartitionOpening(opening({ length: 1.1 }), [partition], 100).reason,
|
||||
'does-not-fit');
|
||||
});
|
||||
|
||||
test('partition body is split by its own full-depth slot only', () => {
|
||||
const body = partitionBody(partition.a, partition.b, partition.cm, 5, 5);
|
||||
const resolved = resolvePartitionOpening(opening(), [partition], 100, 5, 5).resolved;
|
||||
const pieces = cutPartitionBody(body, [partitionOpeningCut(resolved)]);
|
||||
assert.equal(pieces.length, 2);
|
||||
const ranges = pieces.map((piece) => [
|
||||
Math.min(...piece.map((point) => point[0])),
|
||||
Math.max(...piece.map((point) => point[0])),
|
||||
]).sort((a, b) => a[0] - b[0]);
|
||||
assert.ok(ranges[0][1] <= 35 + 1e-6);
|
||||
assert.ok(ranges[1][0] >= 65 - 1e-6);
|
||||
});
|
||||
|
||||
test('full-depth cut is stable for 1/15/100 cm and diagonal hosts', () => {
|
||||
for (const cm of [1, 15, 100]) {
|
||||
const diagonal = { id: 'p1', a: [0, 0], b: [100, 100], cm };
|
||||
const resolved = resolvePartitionOpening(opening(), [diagonal], 100, 5, 5).resolved;
|
||||
const body = partitionBody(diagonal.a, diagonal.b, cm, 5, 5);
|
||||
const pieces = cutPartitionBody(body, [partitionOpeningCut(resolved)]);
|
||||
assert.equal(pieces.length, 2, `${cm} cm diagonal host keeps two jamb bodies`);
|
||||
}
|
||||
});
|
||||
|
||||
test('computed junction patches cannot bridge a hosted slot', () => {
|
||||
const resolved = resolvePartitionOpening(opening({
|
||||
length: 0.1, host: { kind: 'partition', id: 'p1', t: 0.95 },
|
||||
}), [partition], 100, 5, 5).resolved;
|
||||
const parts = physicalBodyParts({
|
||||
partitions: [partition, { id: 'branch', a: [100, 0], b: [100, 60], cm: 15 }],
|
||||
room_drafts: [], wall_columns: [],
|
||||
}, 5, 5, 0.001, [partitionOpeningCut(resolved)]);
|
||||
const pointIn = (point, body) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
|
||||
const a = body[i], b = body[j];
|
||||
if (((a[1] > point[1]) !== (b[1] > point[1]))
|
||||
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1]))
|
||||
/ ((b[1] - a[1]) || 1e-12) + a[0]) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
assert.equal(parts.patches.some((body) => pointIn([92, 0], body)), false);
|
||||
});
|
||||
|
||||
test('composite cut requires exact collinearity and full interval coverage', () => {
|
||||
const resolved = resolvePartitionOpening(opening(), [partition], 100, 5, 5).resolved;
|
||||
const interval = (patch = {}) => ({
|
||||
roomId: 'r', a: [0, 0], b: [100, 0], key: 'wall',
|
||||
kind: 'outer', cm: 15, open: false, half: 7.5, ...patch,
|
||||
});
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [interval()], 0.01), true);
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(
|
||||
resolved, [interval({ a: [0, 1], b: [100, 1] })], 0.01,
|
||||
), false);
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(
|
||||
resolved, [interval({ a: [0, 0], b: [40, 0] })], 0.01,
|
||||
), false);
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(
|
||||
resolved, [interval({ a: [50, -50], b: [50, 50] })], 0.01,
|
||||
), false);
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [
|
||||
interval({ a: [0, 0], b: [50, 0] }),
|
||||
interval({ a: [50, 0], b: [100, 0] }),
|
||||
], 0.01), true, 'adjacent atomic room intervals jointly cover the opening');
|
||||
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [
|
||||
interval({ a: [0, 0], b: [49, 0] }),
|
||||
interval({ a: [51, 0], b: [100, 0] }),
|
||||
], 0.01), false, 'a real coverage gap is not a composite wall');
|
||||
});
|
||||
|
||||
test('overlap reservation is scoped to one explicit host', () => {
|
||||
const first = resolvePartitionOpening(opening(), [partition], 100).resolved;
|
||||
const overlapping = resolvePartitionOpening(opening({
|
||||
id: 'o2', host: { kind: 'partition', id: 'p1', t: 0.6 },
|
||||
}), [partition], 100).resolved;
|
||||
const clear = resolvePartitionOpening(opening({
|
||||
id: 'o3', length: 0.1, host: { kind: 'partition', id: 'p1', t: 0.9 },
|
||||
}), [partition], 100).resolved;
|
||||
assert.equal(hostedOpeningIntervalsOverlap(first, [overlapping]), true);
|
||||
assert.equal(hostedOpeningIntervalsOverlap(first, [clear]), false);
|
||||
});
|
||||
@@ -94,7 +94,7 @@ test('shared-room interval respects cuts and rejects draft or partition-only axe
|
||||
});
|
||||
assert.ok(findSharedRoomSnapSegment(cut, [0, 0], [30, 0]));
|
||||
assert.equal(findSharedRoomSnapSegment(cut, [0, 0], [100, 0]), null,
|
||||
'opening or open-span cuts split eligibility');
|
||||
'a real open-span cut splits structural eligibility');
|
||||
|
||||
const independent = buildPlanSnapGeometry({
|
||||
space: space({
|
||||
|
||||
Reference in New Issue
Block a user