fix(openings): center symbols across wall depth

Issue: #242
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 20:23:20 +03:00
parent 4241bb56f8
commit 6d6db12c79
20 changed files with 470 additions and 280 deletions
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -142,8 +142,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
// The shared centre edge is a long 25 cm physical wall. It can contain the
// complete 90 cm door preset while still proving rotation, inner-face
// offset and ruler placement on a thick wall.
// complete 90 cm door preset while still proving rotation, centreline
// placement and ruler geometry on a thick wall.
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 },
+11 -7
View File
@@ -1,6 +1,7 @@
/**
* Wall thickness (docs/WALL-THICKNESS.md): tool, hatch body, floor area drops
* with thickness, opening cuts, door inner-face offset, shared once, clear→line,
* with thickness, opening cuts, centred/default and flipped door symbols,
* shared once, clear→line,
* degrade/rekey, real resize+undo keeps walls.
*/
import { launch, checkAll, finish } from './serve.mjs';
@@ -111,12 +112,15 @@ const res = await page.evaluate(async () => {
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.openingCutsSlab = (bodyD.match(/\bM\b/g) || []).length >= 2;
const g = sr().querySelector('[data-hp="opening"][data-kind="door"] g g');
const inner = [...(g?.querySelectorAll('g') || [])].find((n) => {
const t = n.getAttribute('transform') || '';
return /translate\(([^)]+)\)/.test(t) && !/translate\(\s*0\s+0\s*\)/.test(t);
});
out.doorInnerFace = !!inner || (sp().walls || []).some((w) => w.cm === 25);
const doorBodyTransform = () => sr()
.querySelector('[data-hp="opening"][data-kind="door"] > g[transform^="scale("]'
+ ' > g[transform^="translate("]')
?.getAttribute('transform') || '';
out.doorDefaultCentered = /^translate\(0 0\)$/.test(doorBodyTransform());
sp().openings[0].flip_v = true;
await upd();
out.doorSavedFlipUsesEdge = /^translate\([^)]*\)$/.test(doorBodyTransform())
&& !/^translate\(\s*0\s+0\s*\)$/.test(doorBodyTransform());
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
c._setMode('view');
File diff suppressed because one or more lines are too long
+76 -76
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -577,9 +577,11 @@ authoritative, while `x/y/angle` are an atomically refreshed compatibility
projection. No explicit host ever falls back to a nearest wall.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall. The
default visible group is centred across wall depth on Flat, preview, Static and Iso; a saved
`flip_v: true` edge-aligns only door/window through the shared pure placement helper. Windows
are two casement leaves. A gate has the same data/light/contact/lock semantics as a door, but
uses two half-width leaves opening only 10° toward the exterior face and no large swing arc.
uses two centred half-width leaves opening only 10° toward the selected face and no large swing arc.
Its default width in the editor is 300 cm. `openingAmount` (pure) maps the contact state to
0..1: no sensor → door/gate drawn open / window closed (static-plan convention);
`unavailable`/`unknown` freeze that default. The lock renders as a compact
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Door, window and gate symbols now sit on the centreline of thick room and
independent walls by default, consistently in placement preview, Flat,
Static and hidden Iso. Existing flipped doors/windows keep their explicit
edge alignment; gates remain centred while the flip changes only their 10°
opening direction, and jambs still span the full wall depth
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
- Space tabs can once again be reordered with a real mouse drag in an editor.
Browser pointer capture no longer traps the drop target on the held tab; a
divider now marks the exact side where the space will be inserted, and
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Символы дверей, окон и ворот теперь по умолчанию находятся на центральной
оси толщины стен комнат и независимых стен — одинаково в предпросмотре,
плоском виде, Static и скрытой изометрии. Существующие перевёрнутые
двери/окна сохраняют явное выравнивание к грани; ворота остаются по центру,
а флаг меняет только направление их 10° открытия. Косяки по-прежнему
проходят через полную глубину стены
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
в редакторе. Захват указателя браузером больше не удерживает цель на исходной
вкладке; разделитель показывает точную сторону вставки, а отпускание мыши вне
+4
View File
@@ -157,6 +157,10 @@ door has one jamb-hinged leaf, gate has two leaves with the established
`passage` keeps the same full-height masonry cut but has zero leaves/panels and
therefore no panel or leaf shadow. Heights are fixed presentation ratios of
`ISO_WALL_HEIGHT`; there is no schema field.
The panel basis consumes the same pure visible-offset contract as Flat: default
door/window/gate panels are centred across wall depth, `flip_v: true` keeps the
door/window compatibility edge alignment, and a gate flip changes only turn
direction. Jamb/cut depth remains physical and independent of that translation.
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
actions remain the only interactive opening surface.
+6 -3
View File
@@ -606,9 +606,12 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
### Толстые стены
Тело стены вырезается по ширине проёма, откосы идут через всю глубину стены.
Створка/дуга двери и окна смещается к внутренней грани комнаты, а створки
ворот — к наружной. Свет проходит через дверь, ворота или открытый проём по
ширине внутреннего тоннеля и отсекается откосами. Солнечный луч начинается из
По умолчанию видимая группа двери, окна или ворот расположена точно по центру
толщины стены; у окна это относится одновременно к стеклу, створкам и дугам.
Сохранённый флаг «Открывается в другую сторону» вручную сдвигает к другой грани
только дверь или окно, а у ворот меняет направление 10° поворота без сдвига
створок с оси. Свет проходит через дверь, ворота или открытый проём по ширине
внутреннего тоннеля и отсекается откосами. Солнечный луч начинается из
внутренних углов оконного тоннеля.
В компактной Static-карточке открытый проём также разрывает тело стены и
+9 -5
View File
@@ -104,11 +104,15 @@ keeps its flat cap. This topology is render-only: a T does not split or rewrite
the saved target segment. The live open-outline/rubber-band preview calls the
same primitive with saved per-segment thicknesses plus the current field value.
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
door swing from the **inner face**. Association uses wall direction ≈ opening
angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
One atomic `OpeningWallIndex` is authoritative for the symbol offset, physical
wall cut and coloured tunnel. Candidates must be effectively collinear with
Openings cut the body full-depth and jambs cap the whole cut. By default the
complete visible door/window/gate group is centred on the wall axis, including
window glass. A saved `flip_v: true` keeps edge alignment only for doors and
windows; gates stay centred and use the resolved side only for their 10° turn.
Association uses wall direction ≈ opening angle (mod 180°), then nearest span —
never a perpendicular neighbour at a T. One atomic `OpeningWallIndex` is
authoritative for physical depth/direction, wall cut and coloured tunnel, while
the shared symbol-placement helper turns that result into the user-visible
translation. Candidates must be effectively collinear with
the opening axis (not merely parallel within one grid cell); ties use complete
opening coverage, signed distance to the inner face, smaller room area and
stable room id. The index and batch tunnel geometry are cached by space,
+36
View File
@@ -1562,6 +1562,42 @@ export const MUTANTS = [
+ '* gridVisualScale(spec.cellCm)',
}],
},
{
id: 'opening-symbol-default-uses-room-face',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/opening-symbol-placement.test.mjs',
because: 'dropping the explicit default-centre guard makes every ordinary door/window '
+ 'follow the resolved physical face again, restoring issue #242',
patches: [{
file: 'src/opening-symbol-placement.ts',
find: " if (!flipV || type === 'gate' || type === 'passage') return { ox: 0, oy: 0 };",
replace: " if (type === 'gate' || type === 'passage') return { ox: 0, oy: 0 };",
}],
},
{
id: 'opening-symbol-partition-follows-endpoints',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/opening-symbol-placement.test.mjs',
because: 'using the signed resolver offset instead of its depth makes the chosen edge '
+ 'depend on room ownership or reversed host direction instead of the local opening axis',
patches: [{
file: 'src/opening-symbol-placement.ts',
find: ' const half = Math.hypot(face.ox, face.oy);',
replace: ' const half = face.oy || face.ox;',
}],
},
{
id: 'opening-gate-flip-translates-leaves',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/opening-symbol-placement.test.mjs',
because: 'removing the gate exemption turns flip_v back into a wall-face translation; '
+ 'the leaves must stay centred while only their ten-degree turn changes direction',
patches: [{
file: 'src/opening-symbol-placement.ts',
find: " if (!flipV || type === 'gate' || type === 'passage') return { ox: 0, oy: 0 };",
replace: " if (!flipV || type === 'passage') return { ox: 0, oy: 0 };",
}],
},
];
// --- механика ---------------------------------------------------------------
+3 -1
View File
@@ -2,6 +2,7 @@ import {
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint,
type IsoCamera, type PlanPoint, type ScenePoint,
} from './iso-projection';
import { openingSymbolOffset } from './opening-symbol-placement';
export type IsoOpeningType = 'door' | 'window' | 'gate' | 'passage';
@@ -80,7 +81,8 @@ function leafBasis(
): IsoOpeningLeafBasis {
const sx = input.flipH ? -1 : 1;
const sy = input.flipV ? -1 : 1;
const origin: PlanPoint = [input.x + input.face.ox, input.y + input.face.oy];
const offset = openingSymbolOffset(input.type, input.flipV, input.angle, input.face);
const origin: PlanPoint = [input.x + offset.ox, input.y + offset.oy];
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
const closedVector = transformVector(localVector, input.angle, sx, sy);
const quarterVector = transformVector(
+36
View File
@@ -0,0 +1,36 @@
import type { OpeningCfg } from './types';
export interface OpeningFaceOffset {
ox: number;
oy: number;
cm: number;
side: -1 | 1;
}
export interface OpeningSymbolOffset {
ox: number;
oy: number;
}
/**
* Resolve only the visible symbol translation. Wall-face direction and wall
* depth remain separate inputs: the default symbol is centred, while the
* saved door/window flip keeps its explicit edge-aligned compatibility mode.
*/
export function openingSymbolOffset(
type: OpeningCfg['type'],
flipV: boolean,
angle: number,
face: Pick<OpeningFaceOffset, 'ox' | 'oy'>,
): OpeningSymbolOffset {
if (!flipV || type === 'gate' || type === 'passage') return { ox: 0, oy: 0 };
const half = Math.hypot(face.ox, face.oy);
if (!(half > 0) || !Number.isFinite(angle)) return { ox: 0, oy: 0 };
const rad = angle * Math.PI / 180;
const ox = -Math.sin(rad) * half;
const oy = Math.cos(rad) * half;
return {
ox: Math.abs(ox) < 1e-12 ? 0 : ox,
oy: Math.abs(oy) < 1e-12 ? 0 : oy,
};
}
+11 -11
View File
@@ -1,13 +1,9 @@
import { svg, type TemplateResult } from 'lit';
import type { OpeningCfg } from '../types';
import { gridVisualScale, gridVisualUnits } from '../grid-scale';
import { openingSymbolOffset, type OpeningFaceOffset } from '../opening-symbol-placement';
export interface OpeningFaceOffset {
ox: number;
oy: number;
cm: number;
side: -1 | 1;
}
export type { OpeningFaceOffset } from '../opening-symbol-placement';
export interface OpeningVisibleSpec {
type: OpeningCfg['type'];
@@ -69,14 +65,18 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
const sx = spec.flipH ? -1 : 1;
const sy = spec.flipV ? -1 : 1;
// Shift swing geometry to its selected wall face. The outer scale applies
// the user flips, so undo those signs here and let SVG re-apply them once.
// Default symbols sit on the wall centreline. A saved door/window flip keeps
// the explicit edge-aligned compatibility mode, but gate leaves always stay
// centred and use face.side only for their 10 degree turn.
const visualOffset = openingSymbolOffset(
spec.type, spec.flipV, spec.angle, spec.face,
);
let swingTx = 0, swingTy = 0;
if (spec.face.cm > 0 && (spec.face.ox || spec.face.oy)) {
if (visualOffset.ox || visualOffset.oy) {
const rad = (-spec.angle * Math.PI) / 180;
const c = Math.cos(rad), s = Math.sin(rad);
swingTx = spec.face.ox * c - spec.face.oy * s;
swingTy = spec.face.ox * s + spec.face.oy * c;
swingTx = visualOffset.ox * c - visualOffset.oy * s;
swingTy = visualOffset.ox * s + visualOffset.oy * c;
swingTx *= sx;
swingTy *= sy;
}
+13 -12
View File
@@ -2485,10 +2485,9 @@ function centrePiece(side: OpeningWallSide): OpeningWallPiece {
}
/**
* Half-depth from the centreline toward the selected face of an opening.
* The exact same association resolver is used by wall cuts and tunnel fills;
* invalid angle/distance fallbacks can no longer move a symbol into a slot
* which the other renderers do not recognise.
* Physical half-depth and direction for an opening. Visible symbol placement
* is resolved separately: default symbols are centred and only an explicit
* door/window flip uses an edge offset.
*/
export function openingInnerFaceOffset(
rooms: any[],
@@ -2511,15 +2510,17 @@ export function openingInnerFaceOffsetFromIndex(
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
const association = resolveOpeningWallAssociation(index, opening);
const available = [association.negative, association.positive]
.filter((side): side is OpeningWallSide => !!side)
// Preserve the pre-index symbol behaviour: on a shared wall the first room
// in model order owns the unflipped face. This is separate from tunnel
// ownership, whose geometric tie-breaks must remain order-independent.
.sort((a, b) => a.order - b.order);
.filter((side): side is OpeningWallSide => !!side);
if (!available.length) return { ox: 0, oy: 0, cm: 0, side: -1 };
const natural = available[0];
const selectedSide = (opening.flip_v ? -natural.side : natural.side) as -1 | 1;
const selected = (selectedSide === -1 ? association.negative : association.positive) || natural;
// An exterior wall keeps its real room-side direction so gates still open
// outward. A shared wall has no exterior, so use the opening-local negative
// side instead of the first room in model order.
const naturalSide = association.negative && association.positive
? -1
: available[0].side;
const selectedSide = (opening.flip_v ? -naturalSide : naturalSide) as -1 | 1;
const selected = (selectedSide === -1 ? association.negative : association.positive)
|| available[0];
const piece = centrePiece(selected);
if (!(piece.half > 0) || !(piece.cm > 0)) return { ox: 0, oy: 0, cm: 0, side: selectedSide };
const rad = opening.angle * Math.PI / 180;
+29
View File
@@ -36,6 +36,35 @@ test('window and gate retain two leaves, fixed height bounds and gate 10 degree
assert.equal(projectIsoOpening(gateBasis, 1).length, 2);
});
test('isometric symbols share centred defaults and explicit edge alignment', () => {
const centred = buildIsoOpeningBasis(opening());
const oppositeResolvedFace = buildIsoOpeningBasis(opening({
face: { ox: 0, oy: -5, side: -1 },
}));
assert.deepEqual(oppositeResolvedFace.leaves, centred.leaves,
'default door ignores which physical room face was resolved');
const flippedPositive = buildIsoOpeningBasis(opening({ flipV: true }));
const flippedNegative = buildIsoOpeningBasis(opening({
flipV: true, face: { ox: 0, oy: -5, side: -1 },
}));
assert.deepEqual(flippedNegative.leaves, flippedPositive.leaves,
'saved flip uses the same opening-local edge for either room order');
assert.equal(flippedPositive.leaves[0].hinge[1], centred.leaves[0].hinge[1] + 5);
const gate = buildIsoOpeningBasis(opening({ type: 'gate' }));
const gateFlipped = buildIsoOpeningBasis(opening({
type: 'gate', flipV: true, face: { ox: 0, oy: -5, side: -1 },
}));
assert.equal(gate.leaves[0].hinge[1], 80);
assert.equal(gateFlipped.leaves[0].hinge[1], 80);
assert.notDeepEqual(
projectIsoOpening(gate, 1).map((panel) => panel.d),
projectIsoOpening(gateFlipped, 1).map((panel) => panel.d),
'flip changes the gate turn without translating its centred origin',
);
});
test('passage keeps the wall cut but never creates an isometric panel', () => {
const passageBasis = buildIsoOpeningBasis(opening({ type: 'passage' }));
assert.deepEqual(passageBasis.leaves, []);
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openingSymbolOffset } from '../test-build/opening-symbol-placement.js';
const positiveFace = { ox: 0, oy: 20, cm: 20, side: 1 };
const negativeFace = { ox: 0, oy: -20, cm: 20, side: -1 };
test('default opening symbols stay centred regardless of the resolved room face', () => {
for (const type of ['door', 'window', 'gate', 'passage']) {
assert.deepEqual(openingSymbolOffset(type, false, 0, positiveFace), { ox: 0, oy: 0 });
assert.deepEqual(openingSymbolOffset(type, false, 0, negativeFace), { ox: 0, oy: 0 });
}
});
test('saved door and window flips use one canonical local edge', () => {
for (const type of ['door', 'window']) {
assert.deepEqual(openingSymbolOffset(type, true, 0, positiveFace), { ox: 0, oy: 20 });
assert.deepEqual(openingSymbolOffset(type, true, 0, negativeFace), { ox: 0, oy: 20 });
const diagonal = openingSymbolOffset(type, true, 90, positiveFace);
assert.ok(Math.abs(diagonal.ox + 20) < 1e-9);
assert.ok(Math.abs(diagonal.oy) < 1e-9);
}
});
test('gate leaves never translate and malformed faces fail centred', () => {
assert.deepEqual(openingSymbolOffset('gate', true, 0, positiveFace), { ox: 0, oy: 0 });
assert.deepEqual(openingSymbolOffset('gate', true, 45, negativeFace), { ox: 0, oy: 0 });
assert.deepEqual(openingSymbolOffset('door', true, NaN, positiveFace), { ox: 0, oy: 0 });
assert.deepEqual(openingSymbolOffset('window', true, 0, { ox: 0, oy: 0 }), { ox: 0, oy: 0 });
});
+24 -6
View File
@@ -67,19 +67,37 @@ test('an open passage emits no visible symbol at all', () => {
assert.equal(passageText, '');
});
test('shared renderer preserves flips, wall-face offset and animated geometry', () => {
const text = templateText(renderOpeningVisibleGeometry(spec({
test('shared renderer centres defaults and preserves explicit door/window edge alignment', () => {
const centred = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', amount: 0.5,
face: { ox: 0, oy: -20, cm: 20, side: -1 },
})));
assert.match(centred, /translate\(0 0\)/);
const flippedPositive = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', amount: 0.5, flipH: true, flipV: true,
face: { ox: 0, oy: 20, cm: 20, side: 1 },
})));
assert.match(text, /scale\(-1 -1\)/);
assert.match(text, /translate\(0 -20\)/);
assert.match(text, /rotate\(45deg\)/);
assert.match(text, /stroke-dashoffset="39\.269/);
const flippedNegative = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', amount: 0.5, flipH: true, flipV: true,
face: { ox: 0, oy: -20, cm: 20, side: -1 },
})));
for (const text of [flippedPositive, flippedNegative]) {
assert.match(text, /scale\(-1 -1\)/);
assert.match(text, /translate\(0 -20\)/);
assert.match(text, /rotate\(45deg\)/);
assert.match(text, /stroke-dashoffset="39\.269/);
}
const gate = templateText(renderOpeningVisibleGeometry(spec({
type: 'gate', amount: 1, face: { ox: 0, oy: 20, cm: 20, side: 1 },
})));
const flippedGate = templateText(renderOpeningVisibleGeometry(spec({
type: 'gate', amount: 1, flipV: true,
face: { ox: 0, oy: -20, cm: 20, side: -1 },
})));
assert.match(gate, /translate\(0 0\)/);
assert.match(flippedGate, /translate\(0 0\)/);
assert.match(gate, /rotate\(10deg\)/);
assert.match(gate, /rotate\(-10deg\)/);
});
+9 -3
View File
@@ -532,7 +532,7 @@ test('opening face side is known without wall thickness and can be inverted for
assert.equal(bottomInner.side, -1, 'the bottom wall room side is -Y');
});
test('opening face keeps the first room side on an ambiguous shared wall', () => {
test('opening face is independent of room order on an ambiguous shared wall', () => {
const rooms = [
{ id: 'large-first', poly: [[0, 0], [10, 0], [10, 8], [0, 8]] },
{ id: 'small-second', poly: [[3, 8], [7, 8], [7, 11], [3, 11]] },
@@ -540,8 +540,14 @@ test('opening face keeps the first room side on an ambiguous shared wall', () =>
const opening = { x: 5, y: 8, angle: 0, length: 2 };
const natural = openingInnerFaceOffset(rooms, opening, [], pitch, cellCm, pitch);
const flipped = openingInnerFaceOffset(rooms, { ...opening, flip_v: true }, [], pitch, cellCm, pitch);
assert.equal(natural.side, -1, 'the first room is above the wall, so its inner face is -Y');
assert.equal(flipped.side, 1, 'flip_v selects the opposite face without an area-based side swap');
const reversed = openingInnerFaceOffset([...rooms].reverse(), opening, [], pitch, cellCm, pitch);
const reversedFlip = openingInnerFaceOffset(
[...rooms].reverse(), { ...opening, flip_v: true }, [], pitch, cellCm, pitch,
);
assert.equal(natural.side, -1, 'shared walls use the canonical local negative side');
assert.equal(flipped.side, 1, 'flip_v selects the opposite canonical side');
assert.deepEqual(reversed, natural);
assert.deepEqual(reversedFlip, flipped);
});
test('openingTunnelGeometry: an outer thick wall gives the one room both tunnel halves', () => {