mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
export type PlanPoint = readonly [number, number];
|
|
export type ScenePoint = readonly [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;
|
|
}
|
|
|
|
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 = 64;
|
|
export const ISO_FLOOR_EDGE_HEIGHT = 10;
|
|
|
|
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 && Math.abs(Math.cos(camera.tiltDeg * Math.PI / 180)) > 1e-12;
|
|
}
|
|
|
|
export function projectPlanPoint(
|
|
point: PlanPoint, zUnits: number, camera: IsoCamera = ISO_CAMERA,
|
|
): ScenePoint {
|
|
if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1])
|
|
|| !Number.isFinite(zUnits)) throw new Error('invalid isometric projection input');
|
|
const rot = camera.rotDeg * Math.PI / 180;
|
|
const tilt = camera.tiltDeg * Math.PI / 180;
|
|
const dx = point[0] - camera.origin[0], dy = point[1] - camera.origin[1];
|
|
const rx = (dx * Math.cos(rot) - dy * Math.sin(rot)) * camera.xyScale;
|
|
const ry = (dx * Math.sin(rot) + dy * Math.cos(rot)) * camera.xyScale;
|
|
return [
|
|
camera.origin[0] + rx,
|
|
camera.origin[1] + ry * Math.cos(tilt) - zUnits * camera.zScale * Math.sin(tilt),
|
|
];
|
|
}
|
|
|
|
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 tilt = camera.tiltDeg * Math.PI / 180;
|
|
const rx = (point[0] - camera.origin[0]) / camera.xyScale;
|
|
const ry = (point[1] - camera.origin[1]) / (camera.xyScale * Math.cos(tilt));
|
|
return [
|
|
camera.origin[0] + rx * Math.cos(rot) + ry * Math.sin(rot),
|
|
camera.origin[1] - rx * Math.sin(rot) + ry * Math.cos(rot),
|
|
];
|
|
}
|
|
|
|
export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): readonly [number, number, number, number, number, number] {
|
|
if (!finiteCamera(camera)) 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) * Math.cos(tilt);
|
|
const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt);
|
|
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];
|
|
return [a, b, c, d, e, f];
|
|
}
|
|
|
|
export function isoFloorMatrixCss(camera: IsoCamera = ISO_CAMERA): string {
|
|
return `matrix(${isoFloorMatrix(camera).map((value) => Number(value.toFixed(12))).join(' ')})`;
|
|
}
|
|
|
|
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;
|
|
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)
|
|
|| !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth)
|
|
|| wallHeight < 0 || openingHeight < 0 || floorDepth < 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);
|
|
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 };
|
|
}
|