+ .text=${la(i,t)} .ariaLabel=${la(i,e)}>`:G}get _stageEl(){return this.renderRoot.querySelector(".stage")}_contentItems(t){const e=[];for(const i of this._devices){if(i.space!==t.id||i.hidden)continue;const s=this._pos(i);e.push({minX:s.x,minY:s.y,maxX:s.x,maxY:s.y})}if(t.id===this._space){for(const t of this._openingsR){const i=Number(t.angle)*Math.PI/180,s=Math.cos(i)*t.rlen/2,n=Math.sin(i)*t.rlen/2,o=wc([[t.rx-s,t.ry-n],[t.rx+s,t.ry+n]]);o&&e.push(o)}const i=this._decorH;for(const t of this._decorList){const s=wc("line"===t.kind?[[t.x1*Zg,t.y1*i],[t.x2*Zg,t.y2*i]]:"text"===t.kind?[[t.x*Zg,t.y*i]]:pr({x:t.x*Zg,y:t.y*i,w:t.w*Zg,h:t.h*i,angle:t.angle}));s&&e.push(s)}for(const i of this._physicalBodiesR(t)){const t=wc(i);t&&e.push(t)}}return xc(t,e)}_frameOf(){const t=this._spaceModel();if(!t){this._frame=null;const t={x:0,y:0,w:Zg,h:Zg};return{rect:t,all:t,outliers:0}}const e=this._frame,i="view"!==this._mode;if(e&&e.id===t.id&&this._bdDrag)return e;if(e&&e.id===t.id&&e.model===t&&e.layout===this._layout&&e.devs===this._devices&&e.far===this._showFar&&e.grow===i)return e;const s=Cc(this._contentItems(t));let n=s.all||Dc(t),o=this._showFar?n:s.core||Dc(t);return e&&e.id===t.id&&i&&e.grow&&(o=tm(e.rect,o),n=tm(e.all,n)),this._frame={id:t.id,model:t,layout:this._layout,devs:this._devices,far:this._showFar,grow:i,rect:o,all:n,outliers:s.outliers},this._frame}_isoSource(){const t=this._spaceModel();if(!t)return null;const e=this._spaceWalls,i=this._openPairs().flatMap(t=>t.segs),s=this._openingsR.map((t,e)=>({id:String(t.id||e),sourceIndex:e,type:t.type,x:t.rx,y:t.ry,angle:Number(t.angle)||0,length:t.rlen>0?t.rlen:900,flipH:!!t.flip_h,flipV:!!t.flip_v})),n=tc(64,this._cellCm),o=tc(10,this._cellCm),a=`${t.id}|${function(t){let e=2166136261;const i=t=>{if("number"!=typeof t)if("string"!=typeof t)if("boolean"!=typeof t)if(Array.isArray(t)){e=sg(e,t.length);for(const e of t)i(e)}else{if(t&&"object"==typeof t){const e=t;for(const t of Object.keys(e).sort())i(t),i(e[t]);return}e=sg(e,-1)}else e=sg(e,t?1:0);else for(const i of t)e=sg(e,i.codePointAt(0)||0);else e=sg(e,t)};return i(t),e.toString(36)}({rooms:t.rooms,walls:e,openCuts:i,openings:s,partitions:t.partitions,roomDrafts:t.room_drafts,columns:t.wall_columns,cellCm:this._cellCm,gridPitch:this._gridPitch,wallKeyPitch:this._wallKeyPitch,camera:U_,wallHeight:n,floorEdgeHeight:o,algorithm:3})}`;return{key:a,build:()=>{const o=Bl(t,this._cellCm,this._gridPitch,2e-4*this._gridPitch,this._partitionOpeningCuts(t)).all,a=this._roomWallOpeningInputs(this._openingsR,t),r=e.length||o.length?vl(t.rooms,e,i,a,this._wallKeyPitch,this._cellCm,this._gridPitch,Zg,o):null;if((e.length||o.length)&&!r)throw new Error("wall boolean geometry failed");const l=r?.paperGeom??ml(t.rooms,e,i,this._wallKeyPitch,this._cellCm,this._gridPitch,Zg);if(!l)throw new Error("floor boolean geometry failed");const c=r?.openingIndex||kl(t.rooms,e,i,this._wallKeyPitch,this._cellCm,this._gridPitch,Zg),h=s.map(t=>{const i=this._openingsR[t.sourceIndex],s="gate"===t.type?!t.flipV:t.flipV,o=i?.partitionHost?Sd(i.partitionHost,s):e.length||"gate"===t.type?Ml(c,{x:t.x,y:t.y,angle:t.angle,length:t.length,flip_v:s}):{ox:0,oy:0,cm:0,side:-1};return function(t,e=64){if(!(ng([t.x,t.y,t.angle,t.length,t.face.ox,t.face.oy,e])&&t.length>0&&e>0))throw new Error("invalid isometric opening input");const i=t.length/2;let s;if("passage"===t.type)s=[];else if("gate"===t.type){const n=10*t.face.side;s=[ag(t,0,[-i,0],[i,0],n,0,.88*e),ag(t,1,[i,0],[-i,0],-n,0,.88*e)]}else s="window"===t.type?[ag(t,0,[-i,0],[i,0],-90,.27*e,.78*e),ag(t,1,[i,0],[-i,0],90,.27*e,.78*e)]:[ag(t,0,[-i,0],[t.length,0],-90,0,.92*e)];return{id:t.id,sourceIndex:t.sourceIndex,type:t.type,leaves:Object.freeze(s.map(t=>Object.freeze(t)))}}({...t,face:o},n)});return{walls:r?.geom||[],floor:l,openings:Object.freeze(h)}}}}_isoSceneKey(){if(!this._labsIso||"view"!==this._mode)return null;try{return this._isoSource()?.key??null}catch{return`${this._space}|invalid`}}_isoScene(){const t=this._isoSource();if(!t)return null;const e=this._isoGeometryCache.get(t.key);if(e)return{key:t.key,...e};const i=this._frameOf().rect,s=t.build(),n=tc(64,this._cellCm),o=tc(10,this._cellCm),a=function(t){let e=1/0,i=1/0,s=-1/0,n=-1/0;for(const o of t)for(const t of o.leaves){const o=Math.hypot(t.closedVector[0],t.closedVector[1]);e=Math.min(e,t.hinge[0]-o),i=Math.min(i,t.hinge[1]-o),s=Math.max(s,t.hinge[0]+o),n=Math.max(n,t.hinge[1]+o)}return ng([e,i,s,n])?{x:e,y:i,w:s-e,h:n-i}:null}(s.openings),r=X_({rect:a?tm(i,a):i,wallHeight:n,openingHeight:n,floorDepth:o}),l=function(t,e=U_,i=64){if(!Number.isFinite(i)||i<0)throw new Error("invalid wall height");const s=[],n=[],o=[];let a=0;for(let r=0;r<(t||[]).length;r++){const l=t[r];for(let t=0;t<(l||[]).length;t++){const c=Q_(l[t],t>0);if(!(c.length<3||Math.abs(Z_(c))<1e-9)){s.push(tg(c,e,i)),a+=c.length;for(let s=0;s
t.depth-e.depth||t.polygon-e.polygon||t.ring-e.ring||t.edge-e.edge),{topPath:s.join(" "),sides:n,contactPath:o.join(" "),edgeCount:a}}(s.walls,U_,n),c={geometry:l,floor:ig(s.floor,o),openings:s.openings,frame:r};return Lg(this._isoGeometryCache,t.key,c,8),{key:t.key,...c}}_effectiveProjection(){if("iso"!==this._desiredProjection||!this._model.length)return"flat";const t=this._isoSceneKey()||`${this._space}|invalid`;if(this._isoFallback.has(t))return"flat";try{const t=this._isoScene();return t?(e="iso",i=t.key,s=this._isoFallback,"iso"===e&&i&&!s.has(i)?"iso":"flat"):"flat"}catch(e){if(!this._isoFallback.has(t)){this._isoFallback.add(t);const i=t.split("|");console.warn(`HOUSEPLAN ISO FALLBACK: #89, space ${this._space}, fingerprint ${i[i.length-1]}, ${e instanceof Error?e.message:"renderer error"}`)}return"flat"}var e,i,s}_scenePoint(t){return"iso"===this._renderProjection?V_(t,0):t}_floorView(t){if("iso"!==this._renderProjection)return t;const e=K_([t.x,t.y]),i=K_([t.x+t.w,t.y+t.h]);return{x:e[0],y:e[1],w:i[0]-e[0],h:i[1]-e[1]}}_baseVb(){if("iso"===this._effectiveProjection()){if(!this._spaceDisplayForRender().showBorders){const t=X_({rect:this._frameOf().rect,wallHeight:tc(64,this._cellCm)});return[t.x,t.y,t.w,t.h]}const t=this._isoScene()?.frame??this._frameOf().rect;return[t.x,t.y,t.w,t.h]}const t=this._frameOf().rect;return[t.x,t.y,t.w,t.h]}get _outliers(){return this._showFar?0:this._frameOf().outliers}_fitFar(){this._showFar=!0,this._frame=null,this._resetZoom()}_fitAll(){this._showFar=!0,this._frame=null,this._resetZoom()}_renderFarHint(){return this._kiosk||"view"!==this._mode||this._booting||!this._outliers?G:W`
${this._t("canvas.far_objects",{n:this._outliers})}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9536ddcf..99d18998 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -5,8 +5,9 @@
- 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
+ edge alignment; gates remain centred while the flip reverses their 10°
+ opening direction on shared room and independent walls without a second
+ mirror inversion, 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.
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 418402c4..c7c36b18 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -12,8 +12,9 @@
оси толщины стен комнат и независимых стен — одинаково в предпросмотре,
плоском виде, Static и скрытой изометрии. Существующие перевёрнутые
двери/окна сохраняют явное выравнивание к грани; ворота остаются по центру,
- а флаг меняет только направление их 10° открытия. Косяки по-прежнему
- проходят через полную глубину стены
+ а флаг без повторной зеркальной инверсии меняет направление их 10° открытия
+ на общей стене комнат и независимой стене. Косяки по-прежнему проходят через
+ полную глубину стены
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 4cb3099b..c93077c6 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -55,6 +55,73 @@
- [ ] `hide_decor`, the Background editor override and stored config remain
unchanged; no per-object under-plan compatibility flag is introduced.
+## Opening symbol centreline (#242)
+
+- [ ] Unit and browser checks prove that door/window/gate defaults stay on the
+ wall centreline, saved door/window `flip_v` uses the canonical edge, and
+ gate `flip_v` reverses the first-leaf 10° turn on shared room walls,
+ independent partitions and hidden Iso without translating the gate
+ [unit: `opening-symbol.test.mjs`, `iso-openings.test.mjs`; auto:
+ `smoke_wall_thickness.mjs`, `smoke_isometric_contract.mjs`; mutation:
+ `opening-gate-flip-cancels-turn`].
+- [ ] Matrix v37 adds four dedicated semantic scenes. Before PNG comparison
+ they assert the saved flip value, wall centreline, visible-group offset,
+ full jamb depth, window glass membership and opposite gate turn signs:
+ `opening-symbol-room-wall-light`,
+ `opening-symbol-diagonal-partition-dark`,
+ `opening-symbol-flip-pairs-light`,
+ `isometric-opening-symbol-parity-dark`.
+- [ ] The exact existing golden impact set below contains **67** scenes. It was
+ measured by comparing `actualSha256` for HEAD and `origin/dev` under the
+ same Chromium build; baseline status alone is not used because `dev`
+ already has unrelated pending pre-release candidates. Every listed frame
+ uses a shared fixture containing an affected opening or retains that plan
+ behind an editor/dialog. No other existing frame changed:
+
+ `isometric-geometry-view-dark`, `isometric-geometry-view-light`,
+ `isometric-live-layers-dark`, `isometric-no-borders-dark`,
+ `isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`,
+ `geometry-view-dark-fit`, `geometry-view-light-fit`,
+ `room-label-parity-view-dark`, `room-label-parity-plan-dark`,
+ `room-label-parity-view-light`, `room-label-parity-plan-light`,
+ `day-cycle-dawn-dark`, `day-cycle-day-dark`, `day-cycle-dusk-dark`,
+ `day-cycle-night-dark`, `geometry-plan-editor-dark`,
+ `space-tab-drop-before-light`, `space-tab-drop-after-dark`,
+ `plan-snap-endpoint-light`, `plan-snap-line-gaps-dark`,
+ `junction-patch-resilience-plan-dark`,
+ `opening-placement-door-thick-wall-dark`,
+ `opening-placement-passage-thick-wall-dark`,
+ `opening-placement-passage-thick-wall-light`,
+ `geometry-devices-editor-dark`, `geometry-decor-editor-dark`,
+ `tray-wide-selection-en`, `tray-wide-tool-ru`,
+ `tray-medium-group-en`, `tray-medium-selection-ru`,
+ `tray-narrow-palette-en`, `tray-narrow-tool-ru`,
+ `geometry-diagonal-45-opening-dark`, `openings-thick-wall-dark`,
+ `lighting-glow-sun-dark`, `device-value-badge-positions-dark`,
+ `device-icon-state-table-light`, `device-icon-state-table-dark`,
+ `device-text-shell-long-light`, `device-text-shell-long-dark`,
+ `lighting-sun-window-state-only-dark`,
+ `lighting-fill-light-axis-split-dark`,
+ `lighting-fill-temp-axis-split-dark`,
+ `lighting-fill-lqi-axis-split-dark`, `lighting-temp-glow-dark`,
+ `lighting-temp-glow-light`, `lighting-custom-glow-dark`,
+ `lighting-opaque-glow-two-doorways-dark`,
+ `lighting-custom-glow-light`, `lighting-temp-glow-no-sources-dark`,
+ `lighting-temp-glow-room-override-dark`,
+ `lighting-manual-auto-spill-overlap-dark`, `hover-over-glow-dark`,
+ `hover-nested-room-dark`, `large-house-zoom-040-dark`,
+ `large-house-zoom-250-dark`, `large-house-warm-remount-dark`,
+ `device-dialog-desktop-en`, `device-help-popover-light-ru`,
+ `decor-color-popover-desktop-en`, `general-color-popover-desktop-en`,
+ `space-room-color-popover-desktop-ru`,
+ `backup-full-preview-desktop-en`,
+ `backup-plan-only-export-desktop-en`,
+ `optimize-preflight-dialog-dark-en`,
+ `optimize-preflight-dialog-light-ru`.
+- [ ] Baselines for the 67 existing and four dedicated scenes are accepted
+ only from the reviewed full Linux pre-beta artifact. Local
+ `golden:accept` remains forbidden.
+
## Device icon design package (#179)
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index 4b4a72f8..fb7f80c5 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -1598,6 +1598,19 @@ export const MUTANTS = [
replace: " if (!flipV || type === 'passage') return { ox: 0, oy: 0 };",
}],
},
+ {
+ id: 'opening-gate-flip-cancels-turn',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="shared renderer centres defaults" '
+ + 'test/opening-symbol.test.mjs',
+ because: 'restoring the second flip_v inversion makes shared and partition gates emit '
+ + 'the same first-leaf turn sign for both saved values even though their origin stays centred',
+ patches: [{
+ file: 'src/render/opening-symbol.ts',
+ find: ' const gateAngle = spec.face.side * 10 * amount;',
+ replace: ' const gateAngle = spec.face.side * sy * 10 * amount;',
+ }],
+ },
];
// --- механика ---------------------------------------------------------------
diff --git a/src/iso-openings.ts b/src/iso-openings.ts
index 2109bc15..8edb1c23 100644
--- a/src/iso-openings.ts
+++ b/src/iso-openings.ts
@@ -80,7 +80,10 @@ function leafBasis(
top: number,
): IsoOpeningLeafBasis {
const sx = input.flipH ? -1 : 1;
- const sy = input.flipV ? -1 : 1;
+ // Gate flip_v is represented by its resolved face.side/turnDeg. Mirroring
+ // the structural basis as well would cancel that direction on shared and
+ // partition hosts, just like a nested scaleY in the flat renderer.
+ const sy = input.type === 'gate' ? 1 : input.flipV ? -1 : 1;
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));
@@ -111,8 +114,7 @@ export function buildIsoOpeningBasis(
if (input.type === 'passage') {
leaves = [];
} else if (input.type === 'gate') {
- const sy = input.flipV ? -1 : 1;
- const turn = input.face.side * sy * 10;
+ const turn = input.face.side * 10;
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88),
diff --git a/src/render/opening-symbol.ts b/src/render/opening-symbol.ts
index fa17d58e..13206f48 100644
--- a/src/render/opening-symbol.ts
+++ b/src/render/opening-symbol.ts
@@ -64,6 +64,11 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
const leafHalf = 1.75 * visualScale;
const sx = spec.flipH ? -1 : 1;
const sy = spec.flipV ? -1 : 1;
+ // A gate is already symmetric around the wall axis. Its vertical flip is a
+ // direction command, not a mirror transform: face.side carries the resolved
+ // turn side and must remain observable instead of being cancelled by a
+ // second scaleY(-1).
+ const renderSy = spec.type === 'gate' ? 1 : sy;
// Default symbols sit on the wall centreline. A saved door/window flip keeps
// the explicit edge-aligned compatibility mode, but gate leaves always stay
@@ -107,9 +112,10 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
${glass}
`;
} else if (spec.type === 'gate') {
- // Gate leaves open only 10° towards the resolved exterior face. Conjugating
- // their rotation through scaleY(-1) reverses the sign.
- const gateAngle = spec.face.side * sy * 10 * amount;
+ // Gate leaves open only 10° towards the resolved face. Gate flip_v changes
+ // face.side at the resolver; there is deliberately no second inversion
+ // here, so shared and partition hosts expose opposite turn signs too.
+ const gateAngle = spec.face.side * 10 * amount;
body = svg`
@@ -137,7 +143,7 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
`;
}
- return svg`
+ return svg`
{
@@ -54,6 +56,24 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(['door', 'window', 'gate'].includes(scenario.openingGeometry.type), true, scenario.id);
assert.equal(Number.isFinite(scenario.openingGeometry.angle), true, scenario.id);
}
+ if (scenario.openingSymbolContract) {
+ const contract = scenario.openingSymbolContract;
+ assert.equal(['room', 'partition'].includes(contract.kind), true, scenario.id);
+ assert.equal(['flat', 'iso'].includes(contract.surface), true, scenario.id);
+ assert.equal(contract.wallCm > 0, true, scenario.id);
+ assert.equal(contract.openings.length >= 3, true, scenario.id);
+ assert.equal(new Set(contract.openings.map((opening) => opening.id)).size,
+ contract.openings.length, scenario.id);
+ for (const opening of contract.openings) {
+ assert.match(opening.id, /^[a-z0-9-]+$/, scenario.id);
+ assert.equal(['door', 'window', 'gate'].includes(opening.type), true, scenario.id);
+ assert.equal(['center', 'edge'].includes(opening.offset), true, scenario.id);
+ assert.equal(typeof opening.flipV, 'boolean', scenario.id);
+ assert.equal(Number.isFinite(opening.at) && opening.length > 0, true, scenario.id);
+ if (opening.type === 'gate') assert.equal(opening.offset, 'center', scenario.id);
+ }
+ assert.equal(contract.surface === 'iso', scenario.projection === 'iso', scenario.id);
+ }
if (scenario.tunnelContinuity) {
assert.match(scenario.tunnelContinuity.openingId, /^[a-z0-9-]+$/, scenario.id);
assert.equal(Number.isInteger(scenario.tunnelContinuity.insetPx)
@@ -301,7 +321,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, 36);
+ assert.equal(GOLDEN_MATRIX_VERSION, 37);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -463,6 +483,50 @@ test('diagonal opening golden asserts a real painted 45 degree symbol', () => {
assert.deepEqual(space.openings.map((opening) => opening.id), ['geo-diagonal-window']);
});
+test('opening symbol goldens lock room, diagonal, flip-pair and hidden Iso contracts', () => {
+ const scenarios = GOLDEN_SCENARIOS.filter((item) => item.openingSymbolContract);
+ assert.deepEqual(scenarios.map((item) => item.id), [
+ 'opening-symbol-room-wall-light',
+ 'opening-symbol-diagonal-partition-dark',
+ 'opening-symbol-flip-pairs-light',
+ 'isometric-opening-symbol-parity-dark',
+ ]);
+ for (const scenario of scenarios) {
+ const fixture = prepareGoldenFixture(scenario);
+ const space = fixture.config.spaces.find((item) => item.id === scenario.space);
+ assert.ok(space, scenario.id);
+ assert.equal(space.openings.length, scenario.openingSymbolContract.openings.length, scenario.id);
+ assert.equal(space.settings.show_borders, true, scenario.id);
+ assert.equal(space.openings.every((opening) => Number.isFinite(opening.x)
+ && Number.isFinite(opening.y) && Number.isFinite(opening.angle)), true, scenario.id);
+ if (scenario.openingSymbolContract.kind === 'partition') {
+ assert.equal(space.partitions.length, 1, scenario.id);
+ assert.equal(space.openings.every((opening) => opening.host?.id === space.partitions[0].id),
+ true, scenario.id);
+ } else {
+ assert.equal(space.rooms.length, 2, scenario.id);
+ assert.equal(space.walls.some((wall) => wall.cm === scenario.openingSymbolContract.wallCm),
+ true, scenario.id);
+ }
+ }
+ const flipPair = scenarios.find((item) => item.id === 'opening-symbol-flip-pairs-light');
+ assert.deepEqual(flipPair.openingSymbolContract.openings.map((opening) => [
+ opening.type, opening.flipV, opening.offset,
+ ]), [
+ ['door', false, 'center'], ['door', true, 'edge'],
+ ['window', false, 'center'], ['window', true, 'edge'],
+ ['gate', false, 'center'], ['gate', true, 'center'],
+ ]);
+ assert.equal(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT.length, 67);
+ assert.equal(new Set(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT).size, 67);
+ const scenarioIds = new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.id));
+ const testingDoc = readFileSync(new URL('../docs/TESTING.md', import.meta.url), 'utf8');
+ for (const id of OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT) {
+ assert.equal(scenarioIds.has(id), true, id);
+ assert.match(testingDoc, new RegExp(`\\b${id}\\b`), id);
+ }
+});
+
test('golden harness applies doorway, state and layout overrides to a cloned fixture', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
const fixture = prepareGoldenFixture(scenario);
diff --git a/test/iso-openings.test.mjs b/test/iso-openings.test.mjs
index ef22c94c..f57d00af 100644
--- a/test/iso-openings.test.mjs
+++ b/test/iso-openings.test.mjs
@@ -58,6 +58,8 @@ test('isometric symbols share centred defaults and explicit edge alignment', ()
}));
assert.equal(gate.leaves[0].hinge[1], 80);
assert.equal(gateFlipped.leaves[0].hinge[1], 80);
+ assert.deepEqual(gate.leaves.map((leaf) => leaf.turnDeg), [10, -10]);
+ assert.deepEqual(gateFlipped.leaves.map((leaf) => leaf.turnDeg), [-10, 10]);
assert.notDeepEqual(
projectIsoOpening(gate, 1).map((panel) => panel.d),
projectIsoOpening(gateFlipped, 1).map((panel) => panel.d),
diff --git a/test/opening-symbol.test.mjs b/test/opening-symbol.test.mjs
index 7667f073..246d55a7 100644
--- a/test/opening-symbol.test.mjs
+++ b/test/opening-symbol.test.mjs
@@ -98,6 +98,18 @@ test('shared renderer centres defaults and preserves explicit door/window edge a
})));
assert.match(gate, /translate\(0 0\)/);
assert.match(flippedGate, /translate\(0 0\)/);
+ assert.match(gate, /scale\(1 1\)/);
+ assert.match(flippedGate, /scale\(1 1\)/);
assert.match(gate, /rotate\(10deg\)/);
assert.match(gate, /rotate\(-10deg\)/);
+ assert.match(flippedGate, /rotate\(-10deg\)/);
+ assert.match(flippedGate, /rotate\(10deg\)/);
+ assert.equal(
+ gate.indexOf('rotate(10deg)') < gate.indexOf('rotate(-10deg)'), true,
+ 'default gate turns its first leaf towards the resolved face',
+ );
+ assert.equal(
+ flippedGate.indexOf('rotate(-10deg)') < flippedGate.indexOf('rotate(10deg)'), true,
+ 'flip_v reverses the first-leaf turn instead of cancelling in scaleY',
+ );
});