mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight))
|
||||
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 points = corners.flatMap((point) => [
|
||||
projectPlanPoint(point, 0, camera), projectPlanPoint(point, wallHeight, 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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint,
|
||||
type IsoCamera, type PlanPoint, type ScenePoint,
|
||||
} from './iso-projection';
|
||||
|
||||
export interface IsoWallFace {
|
||||
d: string;
|
||||
depth: number;
|
||||
polygon: number;
|
||||
ring: number;
|
||||
edge: number;
|
||||
}
|
||||
|
||||
export interface IsoWallGeometry {
|
||||
topPath: string;
|
||||
sides: readonly IsoWallFace[];
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
const pointText = (point: ScenePoint): string =>
|
||||
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
|
||||
|
||||
function openRing(raw: any): PlanPoint[] {
|
||||
const points = (Array.isArray(raw) ? raw : [])
|
||||
.filter((point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))
|
||||
.map((point) => [Number(point[0]), Number(point[1])] as PlanPoint);
|
||||
const last = points[points.length - 1];
|
||||
if (points.length > 1 && points[0][0] === last[0] && points[0][1] === last[1])
|
||||
points.pop();
|
||||
return points;
|
||||
}
|
||||
|
||||
function signedArea(points: readonly PlanPoint[]): number {
|
||||
let area = 0;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const point = points[index], next = points[(index + 1) % points.length];
|
||||
area += point[0] * next[1] - next[0] * point[1];
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
/** Outer rings become positive, holes negative; solid always stays to the left. */
|
||||
function normalizedRing(raw: any, hole: boolean): PlanPoint[] {
|
||||
const points = openRing(raw);
|
||||
if (points.length < 3) return [];
|
||||
const wantPositive = !hole;
|
||||
return (signedArea(points) > 0) === wantPositive ? points : [...points].reverse();
|
||||
}
|
||||
|
||||
function topRingPath(points: readonly PlanPoint[], camera: IsoCamera, height: number): string {
|
||||
return points.length
|
||||
? `M ${points.map((point) => pointText(projectPlanPoint(point, height, camera))).join(' L ')} Z`
|
||||
: '';
|
||||
}
|
||||
|
||||
function visibleNormalY(a: PlanPoint, b: PlanPoint, camera: IsoCamera): number {
|
||||
// Normal points to the right of a normalized edge, away from the solid.
|
||||
const nx = b[1] - a[1], ny = -(b[0] - a[0]);
|
||||
const rot = camera.rotDeg * Math.PI / 180;
|
||||
return nx * Math.sin(rot) + ny * Math.cos(rot);
|
||||
}
|
||||
|
||||
/** Build one top surface and at most one side per canonical ring edge: O(E). */
|
||||
export function buildIsoWallGeometry(
|
||||
geometry: any,
|
||||
camera: IsoCamera = ISO_CAMERA,
|
||||
height = ISO_WALL_HEIGHT,
|
||||
): IsoWallGeometry {
|
||||
if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height');
|
||||
const tops: string[] = [];
|
||||
const sides: IsoWallFace[] = [];
|
||||
let edgeCount = 0;
|
||||
for (let polygon = 0; polygon < (geometry || []).length; polygon++) {
|
||||
const source = geometry[polygon];
|
||||
for (let ring = 0; ring < (source || []).length; ring++) {
|
||||
const points = normalizedRing(source[ring], ring > 0);
|
||||
if (points.length < 3 || Math.abs(signedArea(points)) < 1e-9) continue;
|
||||
tops.push(topRingPath(points, camera, height));
|
||||
edgeCount += points.length;
|
||||
for (let edge = 0; edge < points.length; edge++) {
|
||||
const a = points[edge], b = points[(edge + 1) % points.length];
|
||||
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
|
||||
const floorA = projectPlanPoint(a, 0, camera);
|
||||
const floorB = projectPlanPoint(b, 0, camera);
|
||||
const topB = projectPlanPoint(b, height, camera);
|
||||
const topA = projectPlanPoint(a, height, camera);
|
||||
sides.push({
|
||||
d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(topB)} L ${pointText(topA)} Z`,
|
||||
depth: Math.max(floorA[1], floorB[1]), polygon, ring, edge,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon
|
||||
|| a.ring - b.ring || a.edge - b.edge);
|
||||
return { topPath: tops.join(' '), sides, edgeCount };
|
||||
}
|
||||
|
||||
function mixHash(hash: number, value: number): number {
|
||||
hash ^= Math.round((Number.isFinite(value) ? value : 0) * 64);
|
||||
return Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
/** Content fingerprint: intentionally independent of config epochs and HA state. */
|
||||
export function isoGeometryFingerprint(value: unknown): string {
|
||||
let hash = 0x811c9dc5;
|
||||
const visit = (item: unknown): void => {
|
||||
if (typeof item === 'number') { hash = mixHash(hash, item); return; }
|
||||
if (typeof item === 'string') {
|
||||
for (const char of item) hash = mixHash(hash, char.codePointAt(0) || 0);
|
||||
return;
|
||||
}
|
||||
if (typeof item === 'boolean') { hash = mixHash(hash, item ? 1 : 0); return; }
|
||||
if (Array.isArray(item)) {
|
||||
hash = mixHash(hash, item.length);
|
||||
for (const child of item) visit(child);
|
||||
return;
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const record = item as Record<string, unknown>;
|
||||
for (const key of Object.keys(record).sort()) { visit(key); visit(record[key]); }
|
||||
return;
|
||||
}
|
||||
hash = mixHash(hash, -1);
|
||||
};
|
||||
visit(value);
|
||||
return hash.toString(36);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/** Reusable, presentation-only Labs flags. No flag may gate data or requests. */
|
||||
|
||||
export interface LabsFlag {
|
||||
id: string;
|
||||
issue: number;
|
||||
since: string;
|
||||
expires: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export const LABS_STORAGE_KEY = 'houseplan_card_labs_v1';
|
||||
|
||||
export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'iso',
|
||||
issue: 89,
|
||||
since: '1.62.0',
|
||||
expires: '1.65.0',
|
||||
summary: 'Volumetric plan renderer',
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface LabsLocation {
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface LabsResolution {
|
||||
active: readonly string[];
|
||||
space: string;
|
||||
persist: string | undefined;
|
||||
knownUrlOperation: boolean;
|
||||
}
|
||||
|
||||
export interface LabsSnapshot {
|
||||
active: readonly string[];
|
||||
space: string;
|
||||
}
|
||||
|
||||
type VersionCore = readonly [number, number, number];
|
||||
|
||||
export function parseVersionCore(value: string): VersionCore | null {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
|
||||
if (!match) return null;
|
||||
const core = match.slice(1).map(Number) as unknown as VersionCore;
|
||||
return core.every((part) => Number.isSafeInteger(part)) ? core : null;
|
||||
}
|
||||
|
||||
function compareVersion(a: VersionCore, b: VersionCore): number {
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function validLabsFlag(flag: LabsFlag): boolean {
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(flag.id) || !Number.isInteger(flag.issue) || flag.issue <= 0
|
||||
|| !flag.summary.trim()) return false;
|
||||
const since = parseVersionCore(flag.since);
|
||||
const expires = parseVersionCore(flag.expires);
|
||||
return !!since && !!expires && compareVersion(since, expires) < 0;
|
||||
}
|
||||
|
||||
export function liveLabsFlags(
|
||||
version: string, registry: readonly LabsFlag[] = LABS_FLAGS,
|
||||
): ReadonlyMap<string, LabsFlag> {
|
||||
const current = parseVersionCore(version);
|
||||
const live = new Map<string, LabsFlag>();
|
||||
if (!current) return live;
|
||||
const ids = new Set<string>();
|
||||
for (const flag of registry) {
|
||||
if (!validLabsFlag(flag) || ids.has(flag.id)) continue;
|
||||
ids.add(flag.id);
|
||||
const since = parseVersionCore(flag.since)!;
|
||||
const expires = parseVersionCore(flag.expires)!;
|
||||
if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) {
|
||||
live.set(flag.id, flag);
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
export function hashParams(hash: string): URLSearchParams {
|
||||
const raw = String(hash || '').replace(/^#/, '');
|
||||
return new URLSearchParams(raw);
|
||||
}
|
||||
|
||||
export function hashSpace(hash: string): string {
|
||||
return hashParams(hash).get('space') || '';
|
||||
}
|
||||
|
||||
function storedFlags(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function operations(params: URLSearchParams): string[] {
|
||||
return params.getAll('hp-labs').flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Pure URL/storage resolver. Query operations run before stronger hash operations. */
|
||||
export function resolveLabs(
|
||||
location: LabsLocation,
|
||||
storageValue: string | null | undefined,
|
||||
version: string,
|
||||
registry: readonly LabsFlag[] = LABS_FLAGS,
|
||||
): LabsResolution {
|
||||
const live = liveLabsFlags(version, registry);
|
||||
const known = new Set(registry.filter(validLabsFlag).map((flag) => flag.id));
|
||||
const active = new Set(storedFlags(storageValue).filter((id) => live.has(id)));
|
||||
let knownUrlOperation = false;
|
||||
const apply = (token: string) => {
|
||||
if (token === 'off') {
|
||||
active.clear();
|
||||
knownUrlOperation = true;
|
||||
return;
|
||||
}
|
||||
const remove = token.startsWith('-');
|
||||
const id = remove ? token.slice(1) : token;
|
||||
if (!known.has(id)) return;
|
||||
knownUrlOperation = true;
|
||||
if (remove || !live.has(id)) active.delete(id);
|
||||
else active.add(id);
|
||||
};
|
||||
for (const token of operations(new URLSearchParams(String(location.search || '').replace(/^\?/, '')))) apply(token);
|
||||
for (const token of operations(hashParams(location.hash))) apply(token);
|
||||
const sorted = Object.freeze([...active].sort());
|
||||
return {
|
||||
active: sorted,
|
||||
space: hashSpace(location.hash),
|
||||
persist: knownUrlOperation ? JSON.stringify(sorted) : undefined,
|
||||
knownUrlOperation,
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window { __hpLabs?: readonly string[]; }
|
||||
}
|
||||
|
||||
let snapshot: LabsSnapshot = { active: Object.freeze([]), space: '' };
|
||||
let configuredVersion = '';
|
||||
let listening = false;
|
||||
let loggedSignature = '';
|
||||
const subscribers = new Set<(value: LabsSnapshot) => void>();
|
||||
|
||||
function browserStorageRead(): string | null {
|
||||
try { return window.localStorage.getItem(LABS_STORAGE_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
function browserStorageWrite(value: string): void {
|
||||
try { window.localStorage.setItem(LABS_STORAGE_KEY, value); } catch { /* private mode/quota */ }
|
||||
}
|
||||
|
||||
function publishBrowserLabs(): LabsSnapshot {
|
||||
if (typeof window === 'undefined') return snapshot;
|
||||
const resolved = resolveLabs(window.location, browserStorageRead(), configuredVersion);
|
||||
if (resolved.persist !== undefined) browserStorageWrite(resolved.persist);
|
||||
snapshot = { active: resolved.active, space: resolved.space };
|
||||
window.__hpLabs = resolved.active;
|
||||
for (const subscriber of subscribers) subscriber(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function onLocationChange(): void { publishBrowserLabs(); }
|
||||
|
||||
function ensureBrowserLabs(version: string): LabsSnapshot {
|
||||
configuredVersion = version;
|
||||
if (typeof window === 'undefined') return snapshot;
|
||||
if (!listening) {
|
||||
listening = true;
|
||||
window.addEventListener('hashchange', onLocationChange);
|
||||
window.addEventListener('popstate', onLocationChange);
|
||||
}
|
||||
return publishBrowserLabs();
|
||||
}
|
||||
|
||||
/** One module-level browser listener; card instances only subscribe to its snapshot. */
|
||||
export function subscribeLabs(
|
||||
version: string, subscriber: (value: LabsSnapshot) => void,
|
||||
): () => void {
|
||||
subscribers.add(subscriber);
|
||||
subscriber(ensureBrowserLabs(version));
|
||||
return () => { subscribers.delete(subscriber); };
|
||||
}
|
||||
|
||||
export function currentLabs(version: string): LabsSnapshot {
|
||||
return ensureBrowserLabs(version);
|
||||
}
|
||||
|
||||
/** Diagnostics are emitted only when an active Labs frame is actually rendered. */
|
||||
export function noteLabsRender(version: string): void {
|
||||
const current = currentLabs(version);
|
||||
const signature = current.active.join(',');
|
||||
if (!signature || signature === loggedSignature) return;
|
||||
loggedSignature = signature;
|
||||
const registry = new Map(LABS_FLAGS.map((flag) => [flag.id, flag]));
|
||||
const summary = current.active.map((id) => {
|
||||
const flag = registry.get(id)!;
|
||||
return `${id} (#${flag.issue}, expires ${flag.expires})`;
|
||||
}).join(', ');
|
||||
console.info(`HOUSEPLAN LABS: ${summary}`);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ISO_CAMERA, ISO_WALL_HEIGHT, clientToScenePoint, isoFloorMatrix,
|
||||
projectPlanPoint, projectedFrame, unprojectFloorPoint,
|
||||
} from '../test-build/iso-projection.js';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
||||
|
||||
test('fixed camera is orthographic, unrotated and inside the owner range', () => {
|
||||
assert.equal(ISO_CAMERA.rotDeg, 0);
|
||||
assert.ok(ISO_CAMERA.tiltDeg >= 18 && ISO_CAMERA.tiltDeg <= 22);
|
||||
const origin = projectPlanPoint([500, 500], 0);
|
||||
assert.deepEqual(origin, [500, 500]);
|
||||
const x = projectPlanPoint([600, 500], 0);
|
||||
const y = projectPlanPoint([500, 600], 0);
|
||||
close(x[1], 500);
|
||||
close(y[0], 500);
|
||||
assert.ok(y[1] > 500, 'plan Y remains a screen-aligned axis');
|
||||
});
|
||||
|
||||
test('floor projection round-trips across the infinite canvas contract', () => {
|
||||
for (const point of [[-5000, -5000], [0, 0], [500, 500], [5000, 5000], [-1234.5, 4321.25]]) {
|
||||
const roundTrip = unprojectFloorPoint(projectPlanPoint(point, 0));
|
||||
close(roundTrip[0], point[0]);
|
||||
close(roundTrip[1], point[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('floor matrix is identical to point projection', () => {
|
||||
const [a, b, c, d, e, f] = isoFloorMatrix();
|
||||
for (const point of [[0, 0], [250, 750], [-3000, 4000]]) {
|
||||
const projected = projectPlanPoint(point, 0);
|
||||
close(a * point[0] + c * point[1] + e, projected[0]);
|
||||
close(b * point[0] + d * point[1] + f, projected[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('projected frame includes raised wall tops and is view-state independent', () => {
|
||||
const rect = { x: 100, y: 200, w: 400, h: 300 };
|
||||
const frame = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
|
||||
const floorOnly = projectedFrame({ rect, wallHeight: 0 });
|
||||
assert.ok(frame.y < floorOnly.y);
|
||||
assert.ok(frame.h > floorOnly.h);
|
||||
assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame);
|
||||
});
|
||||
|
||||
test('client coordinates map through the current scene view', () => {
|
||||
const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 },
|
||||
{ x: 100, y: 200, w: 800, h: 600 });
|
||||
assert.deepEqual(scene, [500, 500]);
|
||||
});
|
||||
|
||||
test('degenerate cameras and frames throw instead of mixing projections', () => {
|
||||
assert.throws(() => projectPlanPoint([0, 0], 0, { ...ISO_CAMERA, xyScale: 0 }));
|
||||
assert.throws(() => unprojectFloorPoint([0, 0], { ...ISO_CAMERA, tiltDeg: 90 }));
|
||||
assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 },
|
||||
{ x: 0, y: 0, w: 1, h: 1 }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildIsoWallGeometry, isoGeometryFingerprint } from '../test-build/iso-walls.js';
|
||||
|
||||
const closed = (points) => [...points, points[0]];
|
||||
const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
|
||||
test('one top and only O(E) visible sides are built deterministically', () => {
|
||||
const first = buildIsoWallGeometry([[square]]);
|
||||
const second = buildIsoWallGeometry([[square]]);
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(first.edgeCount, 4);
|
||||
assert.ok(first.sides.length > 0 && first.sides.length <= first.edgeCount);
|
||||
assert.match(first.topPath, /^M /);
|
||||
assert.equal(first.sides.every((face) => /^M .* Z$/.test(face.d)), true);
|
||||
});
|
||||
|
||||
test('holes preserve an evenodd top and add visible inner/jamb edges', () => {
|
||||
const hole = closed([[40, 40], [40, 60], [60, 60], [60, 40]]);
|
||||
const geometry = buildIsoWallGeometry([[square, hole]]);
|
||||
assert.equal(geometry.edgeCount, 8);
|
||||
assert.equal((geometry.topPath.match(/M /g) || []).length, 2);
|
||||
assert.ok(geometry.sides.some((face) => face.ring === 1));
|
||||
});
|
||||
|
||||
test('a full-height gap remains split into jamb edges without a bridge', () => {
|
||||
const left = closed([[0, 0], [40, 0], [40, 20], [0, 20]]);
|
||||
const right = closed([[60, 0], [100, 0], [100, 20], [60, 20]]);
|
||||
const geometry = buildIsoWallGeometry([[left], [right]]);
|
||||
assert.equal(geometry.edgeCount, 8);
|
||||
assert.equal((geometry.topPath.match(/M /g) || []).length, 2);
|
||||
assert.equal(geometry.topPath.includes('40 0 L 60 0'), false);
|
||||
});
|
||||
|
||||
test('fingerprint is content based, stable and sensitive to in-place geometry edits', () => {
|
||||
const value = { rooms: [{ poly: [[0, 0], [1, 0], [1, 1]] }], showBorders: true };
|
||||
const first = isoGeometryFingerprint(value);
|
||||
assert.equal(isoGeometryFingerprint({ showBorders: true, rooms: value.rooms }), first);
|
||||
value.rooms[0].poly[1][0] = 2;
|
||||
assert.notEqual(isoGeometryFingerprint(value), first);
|
||||
assert.equal(isoGeometryFingerprint({ rooms: value.rooms, showBorders: false }),
|
||||
isoGeometryFingerprint({ rooms: value.rooms, showBorders: false }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LABS_FLAGS, hashSpace, liveLabsFlags, parseVersionCore, resolveLabs, validLabsFlag,
|
||||
} from '../test-build/labs.js';
|
||||
|
||||
const resolve = (search = '', hash = '', storage = null, version = '1.62.0') =>
|
||||
resolveLabs({ search, hash }, storage, version);
|
||||
|
||||
test('registry metadata and numeric-core lifetime fail closed', () => {
|
||||
assert.equal(LABS_FLAGS.every(validLabsFlag), true);
|
||||
assert.deepEqual(parseVersionCore('1.65.0-beta.1'), [1, 65, 0]);
|
||||
assert.equal(liveLabsFlags('1.64.9').has('iso'), true);
|
||||
assert.equal(liveLabsFlags('1.65.0-beta.1').has('iso'), false);
|
||||
assert.equal(liveLabsFlags('1.65.0').has('iso'), false);
|
||||
assert.equal(liveLabsFlags('not-a-version').size, 0);
|
||||
assert.equal(validLabsFlag({ ...LABS_FLAGS[0], since: '1.65.0' }), false);
|
||||
});
|
||||
|
||||
test('URL operations are ordered query then hash with off and removal semantics', () => {
|
||||
assert.deepEqual(resolve('?hp-labs=off,iso').active, ['iso']);
|
||||
assert.deepEqual(resolve('?hp-labs=iso,-iso').active, []);
|
||||
assert.deepEqual(resolve('?hp-labs=-iso', '', '["iso"]').active, []);
|
||||
assert.deepEqual(resolve('?hp-labs=iso', '#hp-labs=-iso').active, []);
|
||||
assert.deepEqual(resolve('?hp-labs=off&hp-labs=iso').active, ['iso']);
|
||||
assert.deepEqual(resolve('?hp-labs=iso', '#hp-labs=off,iso').active, ['iso']);
|
||||
});
|
||||
|
||||
test('unknown operations do not rewrite storage and expired flags cannot revive', () => {
|
||||
const unknown = resolve('?hp-labs=unknown', '', '["iso"]');
|
||||
assert.deepEqual(unknown.active, ['iso']);
|
||||
assert.equal(unknown.persist, undefined);
|
||||
assert.equal(unknown.knownUrlOperation, false);
|
||||
const expired = resolve('?hp-labs=iso', '', '["iso"]', '1.65.0-beta.1');
|
||||
assert.deepEqual(expired.active, []);
|
||||
assert.equal(expired.persist, '[]');
|
||||
});
|
||||
|
||||
test('hash parser shares space and labs grammar with percent encoding', () => {
|
||||
assert.equal(hashSpace('#hp-labs=iso&space=first%20floor'), 'first floor');
|
||||
assert.equal(hashSpace('#space=ground&hp-labs=iso'), 'ground');
|
||||
const result = resolve('', '#space=ground&hp-labs=iso');
|
||||
assert.equal(result.space, 'ground');
|
||||
assert.deepEqual(result.active, ['iso']);
|
||||
});
|
||||
|
||||
test('malformed and unavailable storage are safe', () => {
|
||||
assert.deepEqual(resolve('', '', '{bad').active, []);
|
||||
assert.deepEqual(resolve('?hp-labs=iso', '', undefined).active, ['iso']);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"src/opening-placement.ts", "src/render/opening-symbol.ts",
|
||||
"src/wall-thickness.ts",
|
||||
"src/physical-geometry.ts",
|
||||
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts",
|
||||
"src/open-spans.ts",
|
||||
"src/editors/decor/geometry.ts"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user