feat: support openings in independent walls

Issue: #132
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 01:11:55 +03:00
parent 083621342a
commit 9f77e3e932
40 changed files with 2114 additions and 603 deletions
+33
View File
@@ -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);
+1 -1
View File
@@ -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/);
+40
View File
@@ -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' }),
+132
View File
@@ -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);
});
+1 -1
View File
@@ -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({