Preview open passage placement

Issue: #193
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 10:51:07 +03:00
parent 07d0c2ef86
commit 9ec3636a42
17 changed files with 351 additions and 32 deletions
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -447,7 +447,7 @@ export async function prepareGoldenScenario(page, scenario) {
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
if (!['window', 'door', 'gate'].includes(type)
if (!['window', 'door', 'passage', 'gate'].includes(type)
|| !Array.isArray(pointer) || pointer.length !== 2
|| !pointer.every(Number.isFinite)) {
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
@@ -471,7 +471,11 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
if (!preview || !preview.querySelector('.op-leaf')) {
const expectedGeometry = type === 'passage'
? !!preview?.querySelector('.passage-preview-cut')
&& preview.querySelectorAll('.passage-preview-boundary').length === 2
: !!preview?.querySelector('.op-leaf');
if (!preview || !expectedGeometry) {
const intervals = card._openingPlacementIntervalsCache?.value || [];
const nearest = intervals.map((interval) => {
const [px, py] = card._cursorPt || [0, 0];
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 27;
export const GOLDEN_MATRIX_VERSION = 28;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -104,6 +104,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-placement-passage-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-placement-passage-thick-wall-light', fixture: 'visual', space: 'golden-geometry',
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
+96 -5
View File
@@ -91,14 +91,104 @@ const res = await page.evaluate(async () => {
card._cursorPt = wallMid;
await settle();
const passagePreview = root().querySelector('.opening-preview[data-kind="passage"]');
out.passagePreviewHasNoSymbol = !!passagePreview
&& passagePreview.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
const passageCandidate = card._resolveOpeningPlacement(wallMid);
const passageCut = passagePreview?.querySelector('.passage-preview-cut');
const passageBoundaries = [...(passagePreview?.querySelectorAll('.passage-preview-boundary') || [])];
const passageHalfLength = (passageCandidate?.renderedLength || 0) / 2;
const passageHalfDepth = passageCandidate?.target?.physicalHalfWidth || 0;
const passageBoundaryHalf = passageHalfDepth + card._gridPitch * 0.18;
const near = (value, expected) => Math.abs(Number.parseFloat(value) - expected) < 1e-6;
out.passagePreviewGeometry = !!passagePreview && !!passageCandidate && !!passageCut
&& passageBoundaries.length === 2
&& near(passageCut.getAttribute('x'), -passageHalfLength)
&& near(passageCut.getAttribute('y'), -passageHalfDepth)
&& near(passageCut.getAttribute('width'), passageCandidate.renderedLength)
&& near(passageCut.getAttribute('height'), passageHalfDepth * 2)
&& passageBoundaries.every((boundary, index) => {
const x = index === 0 ? -passageHalfLength : passageHalfLength;
return near(boundary.getAttribute('x1'), x) && near(boundary.getAttribute('x2'), x)
&& near(boundary.getAttribute('y1'), -passageBoundaryHalf)
&& near(boundary.getAttribute('y2'), passageBoundaryHalf);
});
const wallFill = root().querySelector('.wallbody-fill');
const passageDot = root().querySelector('.opening-preview-dot');
const passagePreviewStyle = passagePreview ? getComputedStyle(passagePreview) : null;
const passageCutStyle = passageCut ? getComputedStyle(passageCut) : null;
const wallFillStyle = wallFill ? getComputedStyle(wallFill) : null;
const passageDotStyle = passageDot ? getComputedStyle(passageDot) : null;
const inheritedWallFill = passageCutStyle?.getPropertyValue('--wall-fill').trim() || '';
out._passagePreviewThemeDiagnostics = {
opacity: passagePreviewStyle?.opacity,
fillOpacity: passageCutStyle?.fillOpacity,
cutFill: passageCutStyle?.fill,
wallFill: wallFillStyle?.fill,
inheritedWallFill,
configuredWallFill: card._fillColors.wall_fill.c,
boundaryStrokes: passageBoundaries.map((boundary) => getComputedStyle(boundary).stroke),
dotFill: passageDotStyle?.fill,
};
out.passagePreviewTheme = !!passagePreview && !!passageCut
&& Math.abs(Number.parseFloat(passagePreviewStyle.opacity) - 1) < 0.001
&& Math.abs(Number.parseFloat(passageCutStyle.fillOpacity) - 0.35) < 0.001
&& inheritedWallFill.toLowerCase() === card._fillColors.wall_fill.c.toLowerCase()
&& passageCutStyle.fill !== 'none'
&& passageBoundaries.every((boundary) => getComputedStyle(boundary).stroke
=== passageDotStyle.fill);
out.passagePreviewInert = passagePreview?.getAttribute('aria-hidden') === 'true'
&& passagePreview?.getAttribute('pointer-events') === 'none'
&& passageCut?.getAttribute('pointer-events') === 'none'
&& passageBoundaries.every((boundary) => boundary.getAttribute('pointer-events') === 'none');
out.passagePreviewKeepsRulers = root().querySelectorAll('.measurelabel.opshoulder').length === 2
&& !!root().querySelector('.opcentertick');
out.passagePreviewHasNoCommittedSymbolParts = !!passagePreview
&& passagePreview.querySelectorAll('.op-leaf,.op-arc,.op-glass').length === 0;
out.otherPreviewHasNoPassageGeometry = windowPreview
?.querySelectorAll('.passage-preview-cut,.passage-preview-boundary').length === 0;
out.typeChoicesDoNotMutateGeometry = card._cfgEpoch === epochBeforeMenu
&& JSON.stringify(card._curSpaceCfg.openings || []) === geometryBeforeMenu;
// Passage click and save use the same candidate, but the committed opening
// keeps only the canonical masonry cut and never the transient overlay.
const passageWallMid = [
(poly[2][0] + poly[3][0]) / 2,
(poly[2][1] + poly[3][1]) / 2,
];
card._cursorPt = passageWallMid;
await settle();
const passageSaveCandidate = card._resolveOpeningPlacement(passageWallMid);
const passageIdsBeforeSave = new Set((card._curSpaceCfg.openings || []).map((item) => item.id));
card._openingClick(passageWallMid);
await settle();
out.passageDialogMatchesPreview = !!passageSaveCandidate && card._openingDialog?.type === 'passage'
&& near(card._openingDialog.x, passageSaveCandidate.x)
&& near(card._openingDialog.y, passageSaveCandidate.y)
&& near(card._openingDialog.angle, passageSaveCandidate.angle)
&& card._openingDialog.lengthCm === passageSaveCandidate.lengthCm
&& !root().querySelector('.opening-preview');
card._saveOpening();
await settle();
const savedPassage = (card._curSpaceCfg.openings || [])
.find((item) => !passageIdsBeforeSave.has(item.id));
const committedPassage = savedPassage
? root().querySelector(`.opening[data-id="${savedPassage.id}"]`)
: null;
out.passageSaveMatchesCandidate = !!savedPassage && !!passageSaveCandidate
&& savedPassage.type === 'passage'
&& near(savedPassage.x * 1000, passageSaveCandidate.x)
&& near(savedPassage.y * card._spaceH, passageSaveCandidate.y)
&& near(savedPassage.angle, passageSaveCandidate.angle)
&& near(savedPassage.length * 1000, passageSaveCandidate.renderedLength);
out.committedPassageHasNoPreviewSymbol = !!committedPassage
&& committedPassage.querySelectorAll(
'.passage-preview-cut,.passage-preview-boundary,.op-leaf,.op-arc,.op-glass',
).length === 0;
await choose('gate');
card._cursorPt = wallMid;
await settle();
const gatePreview = root().querySelector('.opening-preview[data-kind="gate"]');
out.gatePreviewGeometry = gatePreview?.querySelectorAll('.op-leaf').length === 2
&& gatePreview?.querySelectorAll('.op-arc').length === 0;
&& gatePreview?.querySelectorAll('.op-arc').length === 0
&& gatePreview?.querySelectorAll('.passage-preview-cut,.passage-preview-boundary').length === 0;
await choose('door');
const axisCandidate = card._resolveOpeningPlacement(wallMid);
const target = axisCandidate?.target;
@@ -122,8 +212,6 @@ const res = await page.evaluate(async () => {
out.previewAriaHidden = preview?.getAttribute('aria-hidden') === 'true';
out.previewHasNoPersistentIdentity = !preview?.hasAttribute('data-id')
&& !preview?.hasAttribute('data-hp');
out.typeChoicesDoNotMutateGeometry = card._cfgEpoch === epochBeforeMenu
&& JSON.stringify(card._curSpaceCfg.openings || []) === geometryBeforeMenu;
const wallBody = root().querySelector('.wallbody');
out.previewPaintedAfterWall = !wallBody || (!!preview
&& !!(wallBody.compareDocumentPosition(preview) & Node.DOCUMENT_POSITION_FOLLOWING));
@@ -239,5 +327,8 @@ const res = await page.evaluate(async () => {
return out;
});
if (!res.passagePreviewTheme)
console.error('passagePreviewTheme diagnostics:', res._passagePreviewThemeDiagnostics);
delete res._passagePreviewThemeDiagnostics;
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+25 -3
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -2,6 +2,10 @@
## v1.65.0-beta.3 — 2026-08-19
- Open-passage placement now previews the exact future wall cut before the
click: a translucent wall-coloured segment shows its real length and depth,
with two orange boundary marks at its ends. The saved passage remains a
symbol-free physical opening ([#193](https://github.com/Matysh/houseplan-card/issues/193)).
- The House Plan color picker now draws the full spectrum directly on the Hue
track, so the target colour family is visible before moving the slider. Mouse,
touch and keyboard values remain unchanged, and a contrasting ring keeps the
+5
View File
@@ -8,6 +8,11 @@
## v1.65.0-beta.3 — 2026-08-19
- Перед размещением открытого проёма теперь виден точный будущий разрыв стены:
полупрозрачный сегмент цвета стены показывает реальную длину и глубину, а две
оранжевые засечки отмечают его границы. После сохранения проём по-прежнему
остаётся физическим разрывом без отдельного символа
([#193](https://github.com/Matysh/houseplan-card/issues/193)).
- Палитра House Plan теперь показывает полный спектр прямо на шкале «Оттенок»,
поэтому нужную группу цветов видно ещё до движения ползунка. Значения и
управление мышью, касанием и клавиатурой не изменились, а контрастный контур
+7
View File
@@ -1103,6 +1103,13 @@ separately promised workflows:
absent far from walls, on virtual spans, over an existing opening, before
a type is selected, or in other tools [auto: smoke_opening_preview;
golden: opening-placement-door-thick-wall-dark]
- [ ] Open-passage placement preview: hover paints one wall-coloured cut segment
at effective opacity 0.35 plus exactly two orange boundary marks. Its
length and depth come from the resolved candidate, ruler labels remain
visible, and save leaves no preview-only symbol in the committed passage
[unit: opening-placement, open-passage-contract; auto:
smoke_opening_preview; golden:
opening-placement-passage-thick-wall-dark/light]
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
Split shows pointer until a room is picked then crosshair; the cut can be
a polyline — start on a wall, intermediate clicks inside the room, finish
+6 -2
View File
@@ -288,8 +288,12 @@ still contain manually assigned devices and explicit measurement sources.
## 9. Doors, windows, gates and locks
Choose Opening, select door/window/gate, and click a wall. Defaults are 90 cm,
120 cm and 300 cm. The complete opening must fit on the wall.
Choose Opening, select door/window/open passage/gate, and click a wall. Defaults
are 90 cm, 120 cm, 90 cm and 300 cm. The complete opening must fit on the wall.
Before the click, door, window and gate show their translucent architectural
symbol. An open passage instead shows the exact future wall cut as a translucent
wall-coloured segment with an orange boundary mark at each end. Its depth follows
the real wall thickness; after saving it has no standalone symbol.
An opening may bind a contact; doors and gates may also bind a lock. View paints
the moving leaf and state. A lock badge is green when locked and orange when
+5 -3
View File
@@ -434,9 +434,11 @@ Glow и солнечные лучи, но при Resize комнаты оста
В **Редакторе плана** кнопка **Проём** сама по себе ничего не размещает: она
открывает компактное подменю **Окно / Дверь / Открытый проём / Ворота**. После выбора типа
наведите указатель на физическую стену. Полупрозрачный символ показывает точный
будущий вид, положение и сторону открывания; он виден поверх тела толстой стены.
Клик открывает свойства, а **Сохранить** создаёт объект. После сохранения или
наведите указатель на физическую стену. Для окна, двери и ворот полупрозрачный
символ показывает точный будущий вид, положение и сторону открывания. Для
открытого проёма полупрозрачный сегмент цвета стены показывает точную длину и
глубину будущего разрыва, а две оранжевые засечки отмечают его границы. Клик
открывает свойства, а **Сохранить** создаёт объект. После сохранения или
отмены выбранный тип остаётся активным для серии одинаковых проёмов. `Esc`,
выбор другого инструмента, редактора или пространства завершает эту серию.
+14 -2
View File
@@ -249,7 +249,8 @@ import {
type OpeningFaceOffset, type OpeningVisibleSpec,
} from './render/opening-symbol';
import {
openingDefaultLengthCm, openingPlacementPreset, resolveOpeningPlacement, sameOpeningPlacementInput,
openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry,
resolveOpeningPlacement, sameOpeningPlacementInput,
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import { safeStoredColor } from './color';
@@ -17363,10 +17364,21 @@ class HouseplanCard extends LitElement {
gridPitch: this._gridPitch,
face: candidate.face,
};
const passageGeometry = candidate.type === 'passage'
? passagePlacementPreviewGeometry(candidate, this._gridPitch)
: null;
return svg`<g class="opening-preview" data-kind=${candidate.type}
aria-hidden="true" pointer-events="none"
transform="translate(${candidate.x} ${candidate.y}) rotate(${candidate.angle})">
${renderOpeningVisibleGeometry(visibleSpec)}
${passageGeometry ? svg`
<rect class="passage-preview-cut" pointer-events="none"
x=${passageGeometry.rect.x} y=${passageGeometry.rect.y}
width=${passageGeometry.rect.width} height=${passageGeometry.rect.height}></rect>
${passageGeometry.boundaries.map((boundary) => svg`
<line class="passage-preview-boundary" pointer-events="none"
x1=${boundary.x1} y1=${boundary.y1}
x2=${boundary.x2} y2=${boundary.y2}></line>`)}
` : renderOpeningVisibleGeometry(visibleSpec)}
</g>
<circle class="opening-preview-dot opghost-dot" aria-hidden="true" pointer-events="none"
cx=${candidate.x} cy=${candidate.y} r=${this._gridPitch * 0.18}></circle>` as unknown as TemplateResult;
+31
View File
@@ -47,6 +47,14 @@ export interface OpeningPlacementCore {
measure: OpeningPlacementMeasureGeometry;
}
export interface PassagePlacementPreviewGeometry {
rect: { x: number; y: number; width: number; height: number };
boundaries: readonly [
{ x1: number; y1: number; x2: number; y2: number },
{ x1: number; y1: number; x2: number; y2: number },
];
}
export interface ResolveOpeningPlacementInput {
pointer: readonly [number, number];
preset: OpeningPlacementPreset;
@@ -77,6 +85,29 @@ export function openingPlacementPreset(
return { type, lengthCm: openingDefaultLengthCm(type), flipH: false, flipV: false, revision };
}
/** Local SVG geometry for the passage-only placement overlay. The candidate
* remains the authority for both the future wall cut and the preview. */
export function passagePlacementPreviewGeometry(
candidate: Pick<OpeningPlacementCore, 'renderedLength' | 'target'>,
gridPitch: number,
): PassagePlacementPreviewGeometry {
const halfLength = Math.max(0, candidate.renderedLength) / 2;
const halfDepth = Math.max(0, candidate.target.physicalHalfWidth);
const boundaryHalfLength = halfDepth + Math.max(0, gridPitch) * 0.18;
const boundary = (x: number) => ({
x1: x, y1: -boundaryHalfLength, x2: x, y2: boundaryHalfLength,
});
return {
rect: {
x: -halfLength,
y: halfDepth ? -halfDepth : 0,
width: halfLength * 2,
height: halfDepth * 2,
},
boundaries: [boundary(-halfLength), boundary(halfLength)],
};
}
function pointOrder(a: readonly number[], b: readonly number[]): number {
return a[0] - b[0] || a[1] - b[1];
}
+14
View File
@@ -1373,6 +1373,20 @@ export const cardStyles = css`
opacity: 0.5;
pointer-events: none;
}
.opening-preview[data-kind="passage"] {
opacity: 1;
}
.passage-preview-cut {
fill: var(--wall-fill, #ffffff);
fill-opacity: 0.35;
stroke: none;
pointer-events: none;
}
.passage-preview-boundary {
stroke: var(--hp-open, #ff9800);
stroke-width: 2.5;
pointer-events: none;
}
.opening-preview .op-leaf,
.opening-preview .op-arc {
transition: none;
+23 -8
View File
@@ -46,6 +46,8 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(Number.isInteger(scenario.openingPreviewPixels.minChannelDelta)
&& scenario.openingPreviewPixels.minChannelDelta > 0
&& scenario.openingPreviewPixels.minChannelDelta <= 32, true, scenario.id);
assert.equal(Number.isInteger(scenario.openingPreviewPixels.minInsideWallPixels)
&& scenario.openingPreviewPixels.minInsideWallPixels > 0, true, scenario.id);
}
if (scenario.openingGeometry) {
assert.match(scenario.openingGeometry.id, /^[a-z0-9-]+$/, scenario.id);
@@ -209,7 +211,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, 27);
assert.equal(GOLDEN_MATRIX_VERSION, 28);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -256,15 +258,28 @@ test('value badge golden covers four positions and bottom badge with separate LQ
});
test('opening placement golden requires browser-painted preview pixels', () => {
const scenario = GOLDEN_SCENARIOS.find(
const door = GOLDEN_SCENARIOS.find(
(item) => item.id === 'opening-placement-door-thick-wall-dark',
);
assert.ok(scenario);
assert.equal(scenario.mode, 'plan');
assert.equal(scenario.openingPreview?.type, 'door');
assert.deepEqual(scenario.openingPreview?.pointer, [0.48, 0.65]);
assert.equal(scenario.openingPreviewPixels.minPixels >= 150, true);
assert.equal(scenario.openingPreviewPixels.minChannelDelta >= 4, true);
assert.ok(door);
assert.equal(door.mode, 'plan');
assert.equal(door.openingPreview?.type, 'door');
assert.deepEqual(door.openingPreview?.pointer, [0.48, 0.65]);
assert.equal(door.openingPreviewPixels.minPixels >= 150, true);
assert.equal(door.openingPreviewPixels.minChannelDelta >= 4, true);
const passages = GOLDEN_SCENARIOS.filter(
(item) => item.id.startsWith('opening-placement-passage-thick-wall-'),
);
assert.deepEqual(passages.map((scenario) => scenario.theme), ['dark', 'light']);
for (const scenario of passages) {
assert.equal(scenario.mode, 'plan');
assert.equal(scenario.openingPreview?.type, 'passage');
assert.deepEqual(scenario.openingPreview?.pointer, [0.48, 0.65]);
assert.equal(scenario.openingPreviewPixels.minPixels >= 150, true);
assert.equal(scenario.openingPreviewPixels.minInsideWallPixels >= 8, true);
assert.equal(scenario.openingPreviewPixels.minChannelDelta >= 4, true);
}
});
test('diagonal opening golden asserts a real painted 45 degree symbol', () => {
+16
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8');
const staticRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
const backend = readFileSync(new URL('../custom_components/houseplan/websocket_api.py', import.meta.url), 'utf8');
const importer = readFileSync(new URL('../custom_components/houseplan/import_export.py', import.meta.url), 'utf8');
@@ -51,6 +52,21 @@ test('static passage cuts and tunnels are passage-only additions', () => {
assert.match(staticRender, /passageGlowTunnels/);
});
test('placement preview adds passage-only cut geometry without changing saved symbols', () => {
const start = card.indexOf('private _renderOpeningPlacementPreview()');
const preview = card.slice(start, card.indexOf('private _renderOpenings(', start));
assert.match(preview, /candidate\.type === 'passage'/);
assert.match(preview, /passagePlacementPreviewGeometry\(candidate, this\._gridPitch\)/);
assert.equal((preview.match(/class="passage-preview-cut"/g) || []).length, 1);
assert.equal((preview.match(/class="passage-preview-boundary"/g) || []).length, 1,
'one mapped template emits exactly two resolved boundary records');
assert.match(preview, /: renderOpeningVisibleGeometry\(visibleSpec\)/);
assert.match(preview, /aria-hidden="true" pointer-events="none"/);
assert.match(styles, /\.opening-preview\[data-kind="passage"\][\s\S]*opacity: 1/);
assert.match(styles, /\.passage-preview-cut[\s\S]*fill-opacity: 0\.35/);
assert.match(styles, /\.passage-preview-boundary[\s\S]*stroke: var\(--hp-open/);
});
test('all write/import paths invoke the semantic passage validator', () => {
assert.equal((backend.match(/validate_opening_passages\(/g) || []).length, 2);
assert.match(importer, /validate_opening_passages\(merged_config, current_config\)/);
+40
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
openingPlacementPreset,
openingPlacementTargets,
passagePlacementPreviewGeometry,
resolveOpeningPlacement,
sameOpeningPlacementInput,
} from '../test-build/opening-placement.js';
@@ -42,6 +43,45 @@ test('Opening presets use the agreed type-specific defaults', () => {
assert.equal(openingPlacementPreset('gate', 3).lengthCm, 300);
});
test('passage preview geometry follows the resolved length and standard wall depth', () => {
const geometry = passagePlacementPreviewGeometry({
renderedLength: 90,
target: { physicalHalfWidth: 7.5 },
}, 10);
assert.deepEqual(geometry, {
rect: { x: -45, y: -7.5, width: 90, height: 15 },
boundaries: [
{ x1: -45, y1: -9.3, x2: -45, y2: 9.3 },
{ x1: 45, y1: -9.3, x2: 45, y2: 9.3 },
],
});
});
test('passage preview wall depth changes only its rect height and boundary span', () => {
const geometry = passagePlacementPreviewGeometry({
renderedLength: 90,
target: { physicalHalfWidth: 12.5 },
}, 10);
assert.deepEqual(geometry.rect, { x: -45, y: -12.5, width: 90, height: 25 });
assert.deepEqual(geometry.boundaries, [
{ x1: -45, y1: -14.3, x2: -45, y2: 14.3 },
{ x1: 45, y1: -14.3, x2: 45, y2: 14.3 },
]);
});
test('passage preview preserves boundary marks when physical wall depth is zero', () => {
const geometry = passagePlacementPreviewGeometry({
renderedLength: 60,
target: { physicalHalfWidth: 0 },
}, 5);
assert.deepEqual(geometry.rect, { x: -30, y: 0, width: 60, height: 0 });
const boundaryHalf = 5 * 0.18;
assert.deepEqual(geometry.boundaries, [
{ x1: -30, y1: -boundaryHalf, x2: -30, y2: boundaryHalf },
{ x1: 30, y1: -boundaryHalf, x2: 30, y2: boundaryHalf },
]);
});
test('Shared room-owned copies collapse to one physical target', () => {
const targets = openingPlacementTargets([
interval({ roomId: 'r1', half: 7 }),