feat: implement isometric stage 3

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 07:06:54 +03:00
parent 3bf9e2d6d3
commit ee7d486924
87 changed files with 7676 additions and 1375 deletions
+15 -1
View File
@@ -138,8 +138,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* 300 090 Б расположен почти по центру полосы: 910 Б сверху и 1 090 Б снизу.
* Core adapter вынесен в `src/version-recovery-card.ts`; это изменение
* фиксирует цену новой границы восстановления, а не разрешает молчаливый рост.
*
* 2026-09-05, #160: потолок опущен 301 000 → 300 000 без изменения общего
* бюджета. Скрытый Stage 3 вынесен в отдельный alpha-runtime: обычный View
* больше не загружает renderer/overlay/opening graph. Измеренный initial факт
* 299 464 Б оставляет 536 Б сверху и 1 464 Б до нижней границы полосы;
* lazy-isometric graph отдельно измеряется манифестом (12 083 Б gzip).
*/
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
export const INITIAL_VIEW_GZIP_CEILING = 300_000;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -253,12 +259,18 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (!manifest.lazyLocaleFiles?.length) {
throw new Error('bundle has no lazy locale graph');
}
if (!manifest.lazyIsometricFiles?.length) {
throw new Error('bundle has no lazy isometric graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyLocaleFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy locale graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyIsometricFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy isometric graph');
}
if (manifest.lazyLocaleFiles.some((path) => manifest.lazyEditorFiles.includes(path)
|| manifest.lazyOnboardingFiles?.includes(path))) {
throw new Error('lazy locale graph overlaps an editor graph');
@@ -272,6 +284,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
initialViewGzipBytes: manifest.initialViewGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
};
}
@@ -289,6 +302,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip`,
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
];
for (const line of lines) console.log(line);
const warning = lowHeadroomWarning(headroom);
+21 -3
View File
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const ISO_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ISO_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
@@ -36,7 +37,9 @@ export function buildBundleManifest(bundle, fingerprint) {
? 'onboarding'
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
? 'editor'
: undefined;
: modules.some((id) => id.endsWith('/src/iso-scene-render.ts'))
? 'isometric'
: undefined;
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
@@ -66,6 +69,8 @@ export function buildBundleManifest(bundle, fingerprint) {
const editorRoots = dynamicRoots.filter((path) => (byPath.get(path)?._role === 'editor'
|| /(?:^|\/)editor(?:-[^/]+)?\.js$/.test(path))
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
const isometricRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'isometric'
|| path.includes('iso-scene-render-'));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -76,6 +81,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const lazyLocale = graphFrom(localeRoots);
const lazyIsometric = graphFrom(isometricRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -92,6 +98,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyOnboardingGzipBytes: sum(lazyOnboarding),
lazyLocaleFiles: [...lazyLocale].sort(),
lazyLocaleGzipBytes: sum(lazyLocale),
lazyIsometricFiles: [...lazyIsometric].sort(),
lazyIsometricGzipBytes: sum(lazyIsometric),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -139,16 +147,20 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
const isometric = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/iso-scene-render.ts')));
const german = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
const french = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
if (!french) throw new Error('French locale chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
let isometricReplacements = 0;
let germanReplacements = 0;
let frenchReplacements = 0;
for (const chunk of chunks) {
@@ -164,6 +176,12 @@ export function editorRuntimeRetryUrlPlugin() {
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(ISO_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), isometric.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
isometricReplacements += chunk.code.split(ISO_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ISO_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(DE_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), german.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
@@ -177,10 +195,10 @@ export function editorRuntimeRetryUrlPlugin() {
chunk.code = chunk.code.replaceAll(FR_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}/${frenchReplacements}, expected 1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}, expected 1/1/1/1/1`);
}
},
};
+180 -14
View File
@@ -1840,9 +1840,13 @@ const MUTANT_DEFINITIONS = [
+ 'every intermediate transition frame (#152 AC10)',
patches: [{
file: 'src/houseplan-card.ts',
find: 'space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))',
replace: 'space.rooms.map((r) => this._renderRoomLabel(r, space, '
+ 'this._cameraTransition.target?.viewBox || view, disp))',
find: 'space.rooms.map((r) => this._renderRoomLabel(\n'
+ ' r, space, view, disp, isoOverlays?.rooms.get(r),\n'
+ ' ))',
replace: 'space.rooms.map((r) => this._renderRoomLabel(\n'
+ ' r, space, this._cameraTransition.target?.viewBox || view, disp, '
+ 'isoOverlays?.rooms.get(r),\n'
+ ' ))',
}],
},
{
@@ -3816,13 +3820,13 @@ const MUTANT_DEFINITIONS = [
+ ' live lighting, physical plan geometry and devices. Keep\n'
+ ' hide_decor visual-only: the decor editor must always paint\n'
+ ' stored shapes so they remain editable. */}\n'
+ " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n",
+ " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}\n",
replace: '',
}, {
file: 'src/houseplan-card.ts',
find: ' ${(() => {\n'
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
replace: " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n"
replace: " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}\n"
+ ' ${(() => {\n'
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
}],
@@ -4145,13 +4149,13 @@ const MUTANT_DEFINITIONS = [
+ 'the smoke must reject the legacy centred SVG substitute independently of compact and iso',
patches: [{
file: 'src/houseplan-card.ts',
find: ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
find: ' ${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}',
replace: ' ${!space.bg && !disp.showNames && !this._markup ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = this._roomCenter(room);\n'
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
+ ' })}</g>` : nothing}\n'
+ ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
+ ' ${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}',
}],
},
{
@@ -5371,13 +5375,17 @@ const MUTANT_DEFINITIONS = [
because: 'hidden isometric wall and floor heights are visual geometry and must project to '
+ 'the same raster at 1 and 5 cm per cell (#239)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const structural = source.build();\n'
+ ' const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);\n'
+ ' const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm);',
replace: ' const structural = source.build();\n'
+ ' const wallHeight = ISO_WALL_HEIGHT;\n'
+ ' const floorEdgeHeight = ISO_FLOOR_EDGE_HEIGHT;',
file: 'src/iso-scene-render.ts',
find: ' const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);\n'
+ ' const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);\n'
+ ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n'
+ ' const cached = lruRead(input.cache, input.source.key);\n'
+ ' let value = cached.hit ? cached.value : undefined;',
replace: ' const wallHeight = ISO_WALL_HEIGHT;\n'
+ ' const floorEdgeHeight = ISO_FLOOR_EDGE_HEIGHT;\n'
+ ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n'
+ ' const cached = lruRead(input.cache, input.source.key);\n'
+ ' let value = cached.hit ? cached.value : undefined;',
}],
},
{
@@ -6454,6 +6462,164 @@ const MUTANT_DEFINITIONS = [
replace: ' if (!this.diagnosticsCache) return;\n if (true) return;\n',
}],
},
{
id: 'stage3-w1-camera-rotation-reset',
guard: 'node --test --test-name-pattern="exact fixed" test/iso-projection.test.mjs',
because: 'W1: the reviewed Stage 3 camera is exactly +4 degrees; a front-on camera must '
+ 'fail the exact projection contract (#160)',
patches: [{
file: 'src/iso-projection.ts',
find: ' rotDeg: 4,\n',
replace: ' rotDeg: 0,\n',
}],
},
{
id: 'stage3-w2-room-label-left-on-floor',
guard: 'node --test --test-name-pattern="exact D2 overlay matrix" test/iso-overlays.test.mjs',
because: 'W2: room labels/cards and value-bearing device roots belong to the raised plane; '
+ 'the D2 matrix must reject a floor-plane room label (#160)',
patches: [{
file: 'src/iso-overlays.ts',
find: " return showBorders && (kind === 'device' || kind === 'room-label' || kind === 'opening-lock')\n"
+ " ? 'raised' : 'floor';",
replace: " return showBorders && (kind === 'device' || kind === 'opening-lock')\n"
+ " ? 'raised' : 'floor';",
}],
},
{
id: 'stage3-w3-vacuum-raised-with-devices',
guard: 'node --test --test-name-pattern="raises only device" test/isometric-contract.test.mjs',
because: 'W3: the live vacuum puck/trail stays on the floor even while device, room and lock '
+ 'roots are raised (#160)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const point = this._scenePoint([cx, cy]);',
replace: " const point = this._renderProjection === 'iso'\n"
+ ' ? projectPlanPoint([cx, cy], gridVisualUnits(68, this._cellCm))\n'
+ ' : [cx, cy] as ScenePoint;',
}],
},
{
id: 'stage3-w4-device-target-loses-44px-floor',
guard: 'node demo/smoke_isometric_contract.mjs',
because: 'W4: the raised interactive root must own an actual 44 by 44 CSS-pixel hit target '
+ 'on both fine and coarse pointers (#160)',
patches: [{
file: 'src/styles/devices.styles.ts',
find: ' width: max(44px, var(--device-shell-size));\n'
+ ' height: max(44px, var(--device-shell-size));',
replace: ' width: var(--device-shell-size);\n'
+ ' height: var(--device-shell-size);',
}],
},
{
id: 'stage3-w5-runtime-nudge-writes-storage',
guard: 'node demo/smoke_isometric_live_touch.mjs',
because: 'W5: runtime nudge is presentation-only and must never write config, layout or '
+ 'browser storage (#160)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' return runtime.buildIsoOverlayRenderScene({',
replace: " localStorage.setItem('houseplan_stage3_nudge_mutant', space.id);\n"
+ ' return runtime.buildIsoOverlayRenderScene({',
}],
},
{
id: 'stage3-w6-no-borders-keeps-raised-plates',
guard: 'node demo/smoke_isometric_live_touch.mjs',
because: 'W6: show_borders:false is a true affine floor with no volume, raised plate, '
+ 'ground or tether layers (#160)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (!runtime || !layers?.structural || !structural) return null;',
replace: ' if (!runtime || !layers || (!structural && disp.showBorders)) return null;',
}, {
file: 'src/houseplan-card.ts',
find: ' wallSilhouettes: structural.wallSilhouettes,',
replace: ' wallSilhouettes: structural?.wallSilhouettes ?? [],',
}],
},
{
id: 'stage3-w7-sun-state-enters-structural-key',
guard: 'node demo/smoke_isometric_live_touch.mjs',
because: 'W7: HA sun state is live paint and must not enter the structural fingerprint or '
+ 'cause topology rebuilds (#160)',
patches: [{
file: 'src/iso-scene-render.ts',
find: ' onBuild(): void;\n}',
replace: ' onBuild(): void;\n liveFingerprint?: string;\n}',
}, {
file: 'src/iso-scene-render.ts',
find: ' algorithm: 4,\n })}`;',
replace: ' algorithm: 4,\n })}|${input.liveFingerprint ?? \'\'}`;',
}, {
file: 'src/houseplan-card.ts',
find: ' onBuild: () => { this._isoStructuralBuildCount += 1; },',
replace: " liveFingerprint: JSON.stringify(this.hass?.states?.['sun.sun'] ?? null),\n"
+ ' onBuild: () => { this._isoStructuralBuildCount += 1; },',
}],
},
{
id: 'stage3-w8-material-defs-created-per-face',
guard: 'node demo/smoke_isometric_contract.mjs',
because: 'W8: Stage 3 material patterns and filters are shared O(1) definitions, never '
+ 'replicated for every face or marker (#160)',
patches: [{
file: 'src/iso-scene-render.ts',
find: ' return svg`<path class="iso-wall-top" d=${face.d} data-component=${face.component}\n'
+ ' fill-rule="evenodd"></path>${layers.materialNuance',
replace: ' return svg`${renderIsoDefs(layers, \'walls\', cellCm)}<path class="iso-wall-top" d=${face.d} data-component=${face.component}\n'
+ ' fill-rule="evenodd"></path>${layers.materialNuance',
}],
},
{
id: 'stage3-w9-gate-face-flip-reversed',
guard: 'node --test --test-name-pattern="gate flips move unhosted structural face" '
+ 'test/iso-scene-render.test.mjs',
because: 'W9: door/gate hinge and face basis must retain the reviewed flip convention '
+ 'independently of live leaf state (#160)',
patches: [{
file: 'src/iso-scene-render.ts',
find: " const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;",
replace: " const faceFlipV = opening.flipV;",
}],
},
{
id: 'stage3-w10-flat-fallback-counted-as-success',
guard: 'node --test --test-name-pattern="runner fails closed" test/performance-workflow.test.mjs',
because: 'W10: a dense benchmark sample in Flat fallback is invalid evidence and must fail '
+ 'before timings are accepted (#160)',
patches: [{
file: 'demo/benchmark_large_house.mjs',
find: " if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');",
replace: " if (false && snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');",
}],
},
{
id: 'stage3-w11-nudged-overlay-loses-tether',
guard: 'node --test --test-name-pattern="wall-aware nudge" test/iso-overlays.test.mjs',
because: 'W11: every non-zero nudge keeps a tether back to its immutable floor owner '
+ 'anchor (#160)',
patches: [{
file: 'src/iso-overlays.ts',
find: ' const tetherVisible = nudged || nearWallBefore || nearWallAfter\n'
+ ' || !!input.hovered || !!input.focused || !!input.selected;',
replace: ' const tetherVisible = !nudged && (nearWallBefore || nearWallAfter\n'
+ ' || !!input.hovered || !!input.focused || !!input.selected);',
}],
},
{
id: 'stage3-w12-separate-alpha-url-key-restored',
guard: 'node --test --test-name-pattern="Labs iso is presentation-only" '
+ 'test/isometric-contract.test.mjs',
because: 'W12: Stage 3 shares the one permanent hp_alpha switch; a feature-specific URL '
+ 'key or expiry must not return (#160)',
patches: [{
file: 'src/labs.ts',
find: "params.getAll('hp_alpha')",
replace: "params.getAll('hp_alpha_stage3')",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');