Files
houseplan-card/src/iso-openings.ts
T
Sergey Matyunin a65db3985d fix(openings): preserve gate flip turn direction
Address code review H1 by keeping gate flip direction observable without a second vertical mirror. Add fail-closed golden contracts, smoke coverage, and a mutation guard for the affected opening-symbol geometry.

Issue: #242
User-Visible: yes
2026-08-22 21:14:37 +03:00

221 lines
7.1 KiB
TypeScript

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';
export interface IsoOpeningFace {
ox: number;
oy: number;
side: -1 | 1;
}
export interface IsoOpeningInput {
id: string;
sourceIndex: number;
type: IsoOpeningType;
x: number;
y: number;
angle: number;
length: number;
flipH: boolean;
flipV: boolean;
face: IsoOpeningFace;
}
export interface IsoOpeningLeafBasis {
leaf: number;
hinge: PlanPoint;
closedVector: PlanPoint;
quarterVector: PlanPoint;
turnDeg: number;
bottom: number;
top: number;
}
/** Immutable jamb/axis topology stored in the structural Iso LRU. */
export interface IsoOpeningBasis {
id: string;
sourceIndex: number;
type: IsoOpeningType;
leaves: readonly IsoOpeningLeafBasis[];
}
export interface IsoOpeningPanel {
id: string;
sourceIndex: number;
type: IsoOpeningType;
leaf: number;
d: string;
shadowD: string;
depth: number;
}
export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; }
const finite = (values: readonly number[]): boolean => values.every(Number.isFinite);
function add(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] + b[0], a[1] + b[1]];
}
function transformVector(
point: PlanPoint, angleDeg: number, sx: number, sy: number,
): PlanPoint {
const x = point[0] * sx, y = point[1] * sy;
const angle = angleDeg * Math.PI / 180;
return [x * Math.cos(angle) - y * Math.sin(angle),
x * Math.sin(angle) + y * Math.cos(angle)];
}
function leafBasis(
input: IsoOpeningInput,
leaf: number,
localHinge: PlanPoint,
localVector: PlanPoint,
turnDeg: number,
bottom: number,
top: number,
): IsoOpeningLeafBasis {
const sx = input.flipH ? -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));
const closedVector = transformVector(localVector, input.angle, sx, sy);
const quarterVector = transformVector(
[-localVector[1], localVector[0]], input.angle, sx, sy,
);
return { leaf, hinge, closedVector, quarterVector, turnDeg, bottom, top };
}
/**
* Build only stable opening topology. The transform is algebraically the same
* as the floor symbol's translate/rotate/flip nesting, including the gate's
* 0..10 degree exterior-face convention.
*/
export function buildIsoOpeningBasis(
input: IsoOpeningInput,
wallHeight = ISO_WALL_HEIGHT,
): IsoOpeningBasis {
if (!finite([
input.x, input.y, input.angle, input.length,
input.face.ox, input.face.oy, wallHeight,
]) || !(input.length > 0) || !(wallHeight > 0)) {
throw new Error('invalid isometric opening input');
}
const half = input.length / 2;
let leaves: IsoOpeningLeafBasis[];
if (input.type === 'passage') {
leaves = [];
} else if (input.type === 'gate') {
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),
];
} else if (input.type === 'window') {
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], -90, wallHeight * 0.27, wallHeight * 0.78),
leafBasis(input, 1, [half, 0], [-half, 0], 90, wallHeight * 0.27, wallHeight * 0.78),
];
} else {
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0, wallHeight * 0.92)];
}
return {
id: input.id,
sourceIndex: input.sourceIndex,
type: input.type,
leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))),
};
}
const pointText = (point: ScenePoint): string =>
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
const angle = leaf.turnDeg * amount * Math.PI / 180;
return [
leaf.hinge[0] + leaf.closedVector[0] * Math.cos(angle)
+ leaf.quarterVector[0] * Math.sin(angle),
leaf.hinge[1] + leaf.closedVector[1] * Math.cos(angle)
+ leaf.quarterVector[1] * Math.sin(angle),
];
}
/** Apply live state after the structural cache: O(leaves), no topology work. */
export function projectIsoOpening(
basis: IsoOpeningBasis,
amount: number,
camera: IsoCamera = ISO_CAMERA,
): IsoOpeningPanel[] {
const liveAmount = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0));
return basis.leaves.map((leaf) => {
const tip = liveTip(leaf, liveAmount);
const floorHinge = projectPlanPoint(leaf.hinge, leaf.bottom, camera);
const floorTip = projectPlanPoint(tip, leaf.bottom, camera);
const topTip = projectPlanPoint(tip, leaf.top, camera);
const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera);
return {
id: basis.id,
sourceIndex: basis.sourceIndex,
type: basis.type,
leaf: leaf.leaf,
d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`,
shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`,
depth: Math.max(floorHinge[1], floorTip[1]),
};
});
}
/** State-independent plan envelope for fit/home; blur is intentionally absent. */
export function isoOpeningBounds(
bases: readonly IsoOpeningBasis[],
): IsoOpeningBounds | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const basis of bases) {
for (const leaf of basis.leaves) {
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1]);
minX = Math.min(minX, leaf.hinge[0] - radius);
minY = Math.min(minY, leaf.hinge[1] - radius);
maxX = Math.max(maxX, leaf.hinge[0] + radius);
maxY = Math.max(maxY, leaf.hinge[1] + radius);
}
}
return finite([minX, minY, maxX, maxY])
? { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
: null;
}
export interface IsoDecorationInput {
showBorders: boolean;
hideOpenings: boolean;
filtersSupported: boolean;
forcedColors: boolean;
}
export interface IsoDecorationLayers {
structural: boolean;
panels: boolean;
shadows: boolean;
materialNuance: boolean;
floorSymbols: boolean;
}
/** Display/capability policy is pure so decoration can never trigger Flat fallback. */
export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLayers {
const structural = !!input.showBorders;
return {
structural,
panels: structural && !input.hideOpenings,
shadows: structural && input.filtersSupported && !input.forcedColors,
materialNuance: structural && !input.forcedColors,
floorSymbols: !input.hideOpenings && !structural,
};
}