Files
houseplan-card/test/iso-stage6.test.mjs
T
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- settings.volumetric_view (General settings › Display, third switch) replaces
  the alpha entry, the header projection toggle and the phone-menu item; one
  rule for card, sidebar page and kiosk; editors stay Flat; backend accepts
  only a boolean, support package copies it.
- Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge
  0.1 D with the lab filters, ×1.12 size also in layout/collision, lift
  0.075 D, one floor-shadow layer under all markers (theme × floor table),
  frames hugging tile and edge (Alert > Focus > Selected > Hover), forced
  colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles).
- Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates
  and windows as sun_rays, length from elevation, parallelogram along the sun,
  tone and streaks by floor lightness, sill line.
- Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme
  rules for walls/openings/labels removed; furniture uses the Flat stroke rule.
- Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new
  mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their
  Linux CI baselines), ACCEPTANCE.md side by side.

Issue: #649
User-Visible: yes
2026-09-25 18:55:14 +03:00

147 lines
8.9 KiB
JavaScript
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.
// #649 (2.5D stage 6): the pure parts — wall colours from the user's data,
// light-floor luma, tile edge/shadow numbers from the designer lab, soft window
// light geometry. Browser behaviour is proven by demo/smoke_iso_*.mjs.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
colorLuma, compositeFloor, isLightFloor, isoLightFloorRooms, isoWallMaterial, isoWallMaterialVars,
parseCssColor, parseHexColor,
} from '../test-build/iso-materials.js';
import {
ISO_ICON_SCALE, ISO_STATE_BODIES, ISO_TILE, isoEdgeColor, isoTileShadow, isoTileStateCss,
} from '../test-build/iso-tiles.js';
import { computeIsoSunBeams, isoSunDepth, ISO_SUN_STOPS_DARK_FLOOR, ISO_SUN_STOPS_LIGHT_FLOOR } from '../test-build/iso-sun.js';
import { volumetricViewOf } from '../test-build/logic.js';
test('#649 п.4 volumetric_view: only an explicit true enables 2.5D', () => {
assert.equal(volumetricViewOf({ volumetric_view: true }), true);
for (const s of [undefined, null, {}, { volumetric_view: false }, { volumetric_view: 'true' }, { volumetric_view: 1 }]) {
assert.equal(volumetricViewOf(s), false, JSON.stringify(s));
}
});
test('#649 3b walls: top = user colour, sides darker per channel; default white = lab matte', () => {
assert.deepEqual(isoWallMaterial('#ffffff'), {
topHi: '#ffffff', topLo: '#ededed', sideHi: '#c4c4c4', sideMid: '#adadad', sideLo: '#999999',
});
const user = isoWallMaterial('#d9c8b4');
assert.equal(user.topHi, '#d9c8b4');
assert.equal(user.sideLo, '#82786c');
assert.equal(isoWallMaterial('nonsense').topHi, '#ffffff', 'a malformed colour falls back to white');
assert.match(isoWallMaterialVars('#d9c8b4'), /^--iso-top-hi:#d9c8b4;--iso-top-lo:#[0-9a-f]{6};--iso-side-hi:/);
assert.doesNotMatch(isoWallMaterialVars('#d9c8b4'), /dark|theme/);
});
test('#649 light floor: lab luma > 0.55 on the fill composited over the paper', () => {
assert.deepEqual(parseHexColor('#abc'), [170, 187, 204]);
assert.deepEqual(parseCssColor('rgb(238, 232, 222)'), [238, 232, 222]);
assert.equal(isLightFloor([0xee, 0xe8, 0xde]), true, 'lab warm floor');
assert.equal(isLightFloor([0x73, 0x77, 0x77]), false, 'lab dark floor');
assert.equal(Math.round(colorLuma([255, 255, 255]) * 100), 100);
assert.deepEqual(compositeFloor(null, 1, [1, 2, 3]), [1, 2, 3], 'no fill = paper');
assert.deepEqual(compositeFloor([0, 0, 0], 0.5, [200, 200, 200]), [100, 100, 100]);
const rooms = isoLightFloorRooms(new Map([
['light', null], ['dark', { color: '#0d1b2a', opacity: 0.5 }], ['half', { color: '#486a8f', opacity: 0.42 }],
]), [255, 255, 255]);
assert.deepEqual([...rooms].sort(), ['half', 'light']);
});
test('#649 п.1 tile numbers are the lab units / 80 and the scale 1.12', () => {
assert.equal(ISO_ICON_SCALE, 1.12);
assert.deepEqual({ ...ISO_TILE }, {
depth: 0.1, radius: 0.275, radiusOfHeight: 0.3, lift: 0.075, badgeGap: 0.075,
shadowInset: 0.0375, frameWidth: 0.0625, frameOutset: 0.075,
});
});
test('#649 п.1 edge colours: brightness/saturate of the body, dark bodies fixed, light floor lighter', () => {
assert.equal(isoEdgeColor('#ffffff', 'light', false), '#b3b3b3');
assert.equal(isoEdgeColor('#ffffff', 'light', true), '#d1d1d1');
assert.equal(isoEdgeColor('#F0A00C', 'light', false), '#a07119');
assert.equal(isoEdgeColor('#000000', 'light', false), '#5b5e5a', 'black body (luma < 70)');
assert.equal(isoEdgeColor('#252525', 'dark', false), '#4a4a4a', 'dark core');
assert.equal(isoEdgeColor('#252525', 'dark', true), '#4a4a4a', 'the floor does not lighten a dark edge');
const css = isoTileStateCss();
for (const [state, body] of Object.entries(ISO_STATE_BODIES)) {
assert.match(css, new RegExp(`\\.dev\\.${state} \\{ --iso-body: ${body}; [^}]*--iso-edge: ${isoEdgeColor(body, 'light', false)};`));
assert.match(css, new RegExp(`\\.dev\\.${state}\\.iso-floor-light \\{ --iso-edge: ${isoEdgeColor(body, 'light', true)}; \\}`));
}
});
test('#649 п.1 shadow table: all four theme × floor combinations (SPEC-REVIEW-649-r1 M1)', () => {
const row = (t, f) => { const s = isoTileShadow(t, f); return [s.dy * 80, s.sigma * 80, s.opacityWhite, s.opacityTinted]; };
assert.deepEqual(row('light', false), [30, 22, 0.34, 0.5]);
assert.deepEqual(row('light', true), [34, 11, 0.3, 0.42]);
assert.deepEqual(row('dark', false), [30, 22, 0.4, 0.4]);
assert.deepEqual(row('dark', true), [34, 11, 0.4, 0.4]);
const css = isoTileStateCss();
const dark = css.indexOf('.iso-tile-shadow.theme-dark.theme-dark {');
const floorTinted = css.indexOf('.iso-tile-shadow.iso-floor-light:is(');
assert.ok(floorTinted > 0 && dark > floorTinted, 'the dark-theme opacity rule comes after the floor rules');
});
test('#649 п.2 light depth: lab length curve in wall heights, low sun = long', () => {
const H = 84;
assert.ok(Math.abs(isoSunDepth(90, H) - 408 * 0.55 / 218.8 * H) < 1e-9, 'noon: the lab base × 0.55');
assert.ok(isoSunDepth(90, 1) >= 215 / 218.8 - 1e-12, 'never below the lab minimum');
assert.ok(isoSunDepth(10, H) > isoSunDepth(40, H), 'low sun reaches further');
assert.ok(isoSunDepth(0, H) <= 910 / 218.8 * H + 1e-9);
const e = 30;
const lab = Math.min(910, Math.max(215, 408 * (0.55 + 1.4 * Math.pow(1 - e / 90, 1.6))));
assert.ok(Math.abs(isoSunDepth(e, H) - lab / 218.8 * H) < 1e-9);
assert.equal(ISO_SUN_STOPS_DARK_FLOOR[0][1], '#ffe9b4');
assert.equal(ISO_SUN_STOPS_LIGHT_FLOOR[0][1], '#e2b95e');
});
test('#649 п.2 beams: same exterior windows as Flat, parallelogram along the sun, room clip, light floor', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [400, 0], [400, 400], [0, 400]] }, { id: 'n', poly: [[400, 0], [800, 0], [800, 400], [400, 400]] }];
// A south window (bottom wall, y = 400) and an interior window between the rooms.
const windows = [
{ id: 'south', x: 200, y: 400, angle: 0, length: 80 },
{ id: 'interior', x: 400, y: 200, angle: 90, length: 80 },
];
const base = { rooms, windows, azimuth: 200, elevation: 30, northDeg: 0, wallHeight: 84 };
const beams = computeIsoSunBeams(base);
assert.deepEqual(beams.map((b) => b.openingId), ['south'], 'interior windows never light');
const [beam] = beams;
assert.deepEqual(beam.normal.map((v) => Math.round(v)), [0, -1], 'light enters the room');
assert.ok(Math.abs(beam.depth - isoSunDepth(30, 84)) < 1e-9);
const lateral = beam.shift[0] / -beam.shift[1];
assert.ok(Math.abs(lateral - Math.tan(20 * Math.PI / 180)) < 1e-6, 'oblique along the real sun, not the normal');
assert.ok(beam.polys.every((poly) => poly.every(([x, y]) => x >= -1e-6 && x <= 400 + 1e-6 && y >= -1e-6 && y <= 400 + 1e-6)),
'clipped to the room');
assert.equal(beam.lightFloor, false);
assert.equal(computeIsoSunBeams({ ...base, lightFloorRooms: new Set(['r']) })[0].lightFloor, true);
assert.deepEqual(computeIsoSunBeams({ ...base, elevation: -1 }), [], 'night');
assert.deepEqual(computeIsoSunBeams({ ...base, azimuth: 20 }), [], 'the sun behind the wall');
// A wall face moves the base to the inner face (half the wall depth inward).
const faced = computeIsoSunBeams({ ...base, wallDepthByOpening: { south: 20 } })[0];
assert.equal(Math.round(faced.a[1]), 390);
});
test('#649 AC3 layout and collisions see the 2.5D tile size (× ISO_ICON_SCALE)', async () => {
const { buildIsoOverlayRenderScene, isoRaisedOverlayHalfSize } = await import('../test-build/iso-scene-render.js');
const { iconUnit } = await import('../test-build/space-geometry.js');
const presentation = { scale: 1, pulse: { animated: false }, classes: [], supplemental: [] };
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 1000, 1000], bg: null,
rooms: [{ id: 'r', poly: [[0, 0], [1000, 0], [1000, 1000], [0, 1000]] }],
wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const scene = buildIsoOverlayRenderScene({
space, devices: [{ id: 'd1' }], openings: [], view: { x: 0, y: 0, w: 1000, h: 1000 },
display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true }, wallSilhouettes: [],
iconPct: 3, deviceBasePct: 3, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 1000, height: 1000 }, positionOf: () => ({ x: 500, y: 500 }),
presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1,
openingEntityAvailable: () => true, openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
});
const footprint = scene.devices.get('d1').footprint;
const half = (Math.max(...footprint.map((p) => p[0])) - Math.min(...footprint.map((p) => p[0]))) / 2;
const base = 3 * iconUnit(space) / 100;
const scaled = isoRaisedOverlayHalfSize({ kind: 'device', core: base * ISO_ICON_SCALE, presentation })[0];
const flat = isoRaisedOverlayHalfSize({ kind: 'device', core: base, presentation })[0];
assert.ok(Math.abs(half - scaled) < 1e-6, `footprint half ${half} = the 1.12 tile ${scaled}`);
assert.ok(half > flat * 1.1, 'the Flat size would let 2.5D tiles overlap');
});