feat: add labs and isometric geometry core

Issue: #89
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-13 14:22:30 +03:00
parent a841d85e40
commit 02502c990c
7 changed files with 604 additions and 0 deletions
+113
View File
@@ -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 };
}
+128
View File
@@ -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
View File
@@ -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}`);
}
+61
View File
@@ -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 }));
});
+44
View File
@@ -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 }));
});
+51
View File
@@ -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']);
});
+1
View File
@@ -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"
]