mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix(openings): center symbols across wall depth
Issue: #242 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 },
|
||||
|
||||
@@ -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
Vendored
+76
-76
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Символы дверей, окон и ворот теперь по умолчанию находятся на центральной
|
||||
оси толщины стен комнат и независимых стен — одинаково в предпросмотре,
|
||||
плоском виде, Static и скрытой изометрии. Существующие перевёрнутые
|
||||
двери/окна сохраняют явное выравнивание к грани; ворота остаются по центру,
|
||||
а флаг меняет только направление их 10° открытия. Косяки по-прежнему
|
||||
проходят через полную глубину стены
|
||||
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
|
||||
|
||||
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
|
||||
в редакторе. Захват указателя браузером больше не удерживает цель на исходной
|
||||
вкладке; разделитель показывает точную сторону вставки, а отпускание мыши вне
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -606,9 +606,12 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
|
||||
### Толстые стены
|
||||
|
||||
Тело стены вырезается по ширине проёма, откосы идут через всю глубину стены.
|
||||
Створка/дуга двери и окна смещается к внутренней грани комнаты, а створки
|
||||
ворот — к наружной. Свет проходит через дверь, ворота или открытый проём по
|
||||
ширине внутреннего тоннеля и отсекается откосами. Солнечный луч начинается из
|
||||
По умолчанию видимая группа двери, окна или ворот расположена точно по центру
|
||||
толщины стены; у окна это относится одновременно к стеклу, створкам и дугам.
|
||||
Сохранённый флаг «Открывается в другую сторону» вручную сдвигает к другой грани
|
||||
только дверь или окно, а у ворот меняет направление 10° поворота без сдвига
|
||||
створок с оси. Свет проходит через дверь, ворота или открытый проём по ширине
|
||||
внутреннего тоннеля и отсекается откосами. Солнечный луч начинается из
|
||||
внутренних углов оконного тоннеля.
|
||||
|
||||
В компактной Static-карточке открытый проём также разрывает тело стены и
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user