Files
houseplan-card/src/iso-projection.ts
T
Claude 0d641ffbfd feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)
- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 15:18:00 +03:00

153 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type PlanPoint = readonly [number, number];
export type ScenePoint = readonly [number, number];
export type IsoAffineMatrix = readonly [number, number, number, number, number, number];
export interface IsoCamera {
rotDeg: number;
tiltDeg: number;
xyScale: number;
zScale: number;
origin: PlanPoint;
}
export interface ViewRect { x: number; y: number; w: number; h: number; }
export interface IsoFrameInput {
rect: ViewRect;
wallHeight: number;
openingHeight?: number;
floorDepth?: number;
raisedHeight?: number;
}
export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
rotDeg: 0,
tiltDeg: 20,
xyScale: 1,
zScale: 1,
origin: Object.freeze([500, 500]) as PlanPoint,
});
export const ISO_WALL_HEIGHT = 84;
export const ISO_FLOOR_EDGE_HEIGHT = 10;
/** Low visual lift above the floor; callers scale it with the same grid policy. */
export const ISO_OVERLAY_VISUAL_OFFSET = 4;
export const ISO_RAISED_OVERLAY_HEIGHT = ISO_OVERLAY_VISUAL_OFFSET;
function finiteCamera(camera: IsoCamera): boolean {
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
camera.origin[0], camera.origin[1]].every(Number.isFinite)
&& Math.abs(camera.xyScale) > 1e-12;
}
export function projectPlanPoint(
point: PlanPoint, zUnits: number, camera: IsoCamera = ISO_CAMERA,
): ScenePoint {
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1]))
throw new Error('invalid isometric projection input');
return applyIsoMatrix(point, isoPlaneMatrix(zUnits, camera));
}
export function unprojectFloorPoint(
point: ScenePoint, camera: IsoCamera = ISO_CAMERA,
): PlanPoint {
if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1]))
throw new Error('invalid isometric projection input');
const rot = camera.rotDeg * Math.PI / 180;
const rx = (point[0] - camera.origin[0]) / camera.xyScale;
const ry = (point[1] - camera.origin[1]) / camera.xyScale;
return [
camera.origin[0] + rx * Math.cos(rot) + ry * Math.sin(rot),
camera.origin[1] - rx * Math.sin(rot) + ry * Math.cos(rot),
];
}
/**
* Canonical plan-plane affine matrix at one logical height. Floor SVG, raised
* plates and individual point projection all use this exact transform.
*
* #713: a vertical oblique projection. The floor keeps the Flat plan (no
* foreshortening), a height moves a point straight up the screen by
* `z × sin(tilt)`: the on-screen wall height of the former 20° camera.
*/
export function isoPlaneMatrix(
zUnits = 0, camera: IsoCamera = ISO_CAMERA,
): IsoAffineMatrix {
if (!finiteCamera(camera) || !Number.isFinite(zUnits))
throw new Error('invalid isometric camera');
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const a = camera.xyScale * Math.cos(rot);
const c = -camera.xyScale * Math.sin(rot);
const b = camera.xyScale * Math.sin(rot);
const d = camera.xyScale * Math.cos(rot);
const e = camera.origin[0] - a * camera.origin[0] - c * camera.origin[1];
const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1]
- zUnits * camera.zScale * Math.sin(tilt);
return [a, b, c, d, e, f];
}
export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): IsoAffineMatrix {
return isoPlaneMatrix(0, camera);
}
export function applyIsoMatrix(point: PlanPoint, matrix: IsoAffineMatrix): ScenePoint {
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])
|| !matrix.every(Number.isFinite)) throw new Error('invalid isometric affine input');
const [a, b, c, d, e, f] = matrix;
return [a * point[0] + c * point[1] + e, b * point[0] + d * point[1] + f];
}
export function isoPlaneMatrixCss(zUnits = 0, camera: IsoCamera = ISO_CAMERA): string {
return `matrix(${isoPlaneMatrix(zUnits, camera)
.map((value) => Number(value.toFixed(12))).join(' ')})`;
}
export function isoFloorMatrixCss(camera: IsoCamera = ISO_CAMERA): string {
return isoPlaneMatrixCss(0, camera);
}
export function isoOverlayVisualHeight(visualOffset: number): number {
if (!Number.isFinite(visualOffset) || visualOffset < 0)
throw new Error('invalid isometric overlay height');
return visualOffset;
}
export function clientToScenePoint(
client: PlanPoint,
stageRect: Pick<DOMRectReadOnly, 'left' | 'top' | 'width' | 'height'>,
view: ViewRect,
): ScenePoint {
if (!(stageRect.width > 0) || !(stageRect.height > 0) || !(view.w > 0) || !(view.h > 0))
throw new Error('invalid client/scene frame');
return [
view.x + ((client[0] - stageRect.left) / stageRect.width) * view.w,
view.y + ((client[1] - stageRect.top) / stageRect.height) * view.h,
];
}
export function projectedFrame(
input: IsoFrameInput, camera: IsoCamera = ISO_CAMERA,
): ViewRect {
const { rect, wallHeight } = input;
const openingHeight = input.openingHeight ?? wallHeight;
const floorDepth = input.floorDepth ?? 0;
const raisedHeight = input.raisedHeight ?? wallHeight;
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)
|| !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth)
|| !Number.isFinite(raisedHeight) || wallHeight < 0 || openingHeight < 0
|| floorDepth < 0 || raisedHeight < 0)
throw new Error('invalid isometric frame');
const corners: PlanPoint[] = [
[rect.x, rect.y], [rect.x + rect.w, rect.y],
[rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h],
];
const top = Math.max(wallHeight, openingHeight, raisedHeight);
const points = corners.flatMap((point) => [
projectPlanPoint(point, -floorDepth, camera), projectPlanPoint(point, top, camera),
]);
const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]);
const x = Math.min(...xs), y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}