mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
In 2.5D with a backdrop image every floor switch rendered the card twice before the first frame. The paper key of the first-frame state (#654) held the space id, so each switch cleared the ready paper: the first render inserted the loading veil, updated() probed the computed card background with a temporary span and asked for a second full update, which removed the veil again. The colour itself never changed: it is the theme card background, and no space sets those variables. Locally this second pass was about 50 ms per warm switch on the large house. The paper under a backdrop is now resolved once per theme identity (dark mode, default and dark default theme, theme) and card mode. The state keeps the resolved paper of the current theme and mode beside the current paper, so a floor with a backdrop is ready in prepare() when that paper is known -- also after a drawn floor in between -- and the switch renders once: no veil, no probe, no second update. A drawn plan keeps its white paper without the DOM. Any change of the theme identity or the mode, also one made in Flat or in an editor, drops the kept paper, so the first backdrop floor after load, a theme change and a trip to an editor take the #654 path unchanged. isoPaperContext still takes the floor; it deliberately leaves it out of the identity. Witnesses: the #739 unit test is red on dev at "a floor switch shows no veil" and on a key-only variant (space dropped, no theme cache) at "drawn -> backdrop keeps the known theme paper"; the new smoke_iso_floor_switch is red on dev (2 updates, 1 colour probe and a veil insertion in every click task). The iso-paper-resolved-per-floor mutant puts the floor back into the theme identity. Issue: #739 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
325 lines
18 KiB
JavaScript
325 lines
18 KiB
JavaScript
// #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, memoIsoLightFloorRooms,
|
||
isoWallMaterial, isoWallMaterialVars,
|
||
parseCssColor, parseHexColor,
|
||
} from '../test-build/iso-materials.js';
|
||
import { IsoFirstFrameState, isoPaperContext } from '../test-build/iso-first-frame.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('#654 light-floor memo reuses unchanged inputs and invalidates every visible input', () => {
|
||
const base = new Map([
|
||
['light', null],
|
||
['tinted', { color: '#486a8f', opacity: 0.42 }],
|
||
]);
|
||
const first = memoIsoLightFloorRooms(null, base, [255, 255, 255]);
|
||
const same = memoIsoLightFloorRooms(first, new Map(base), [255, 255, 255]);
|
||
assert.equal(same, first, 'equivalent paper/fills/rooms reuse the memo object');
|
||
assert.equal(same.rooms, first.rooms, 'the hot render path reuses the Set too');
|
||
|
||
const paperChanged = memoIsoLightFloorRooms(first, base, [20, 20, 20]);
|
||
assert.notEqual(paperChanged, first, 'paper RGB participates in the key');
|
||
assert.deepEqual([...paperChanged.rooms], [], 'dark paper changes an unfilled room');
|
||
|
||
const fillChanged = memoIsoLightFloorRooms(first, new Map([
|
||
['light', { color: '#000000', opacity: 1 }],
|
||
['tinted', { color: '#486a8f', opacity: 0.42 }],
|
||
]), [255, 255, 255]);
|
||
assert.notEqual(fillChanged, first, 'resolved fill participates in the key');
|
||
assert.equal(fillChanged.rooms.has('light'), false);
|
||
|
||
const roomsChanged = memoIsoLightFloorRooms(first, new Map([
|
||
...base,
|
||
['new-room', null],
|
||
]), [255, 255, 255]);
|
||
assert.notEqual(roomsChanged, first, 'room membership participates in the key');
|
||
assert.equal(roomsChanged.rooms.has('new-room'), true);
|
||
});
|
||
|
||
test('#654 first-frame state resolves paper after commit and releases failure to Flat', () => {
|
||
const state = new IsoFirstFrameState();
|
||
const light = isoPaperContext('floor', 'view', true, { darkMode: false, theme: 'light' });
|
||
state.prepare('iso', light);
|
||
assert.equal(state.pending('iso', false), true, 'runtime and image-paper are both cold');
|
||
let resolves = 0;
|
||
assert.equal(state.sync('iso', light, () => { resolves += 1; return [250, 250, 250]; }), true);
|
||
assert.equal(state.sync('iso', light, () => { resolves += 1; return [0, 0, 0]; }), false);
|
||
assert.equal(resolves, 1, 'unchanged HA updates do not resolve computed colour again');
|
||
state.runtimeReady();
|
||
assert.equal(state.readiness('iso', 'iso', true), 'ready');
|
||
|
||
const dark = isoPaperContext('floor', 'view', true, { darkMode: true, theme: 'dark' });
|
||
state.prepare('iso', dark);
|
||
assert.equal(state.pending('iso', true), true, 'a theme change hides the stale classification');
|
||
state.sync('iso', dark, () => { resolves += 1; return [17, 17, 17]; });
|
||
assert.equal(resolves, 2);
|
||
|
||
state.runtimeFailure();
|
||
assert.equal(state.pending('iso', false), false, 'a failed chunk cannot leave the veil stuck');
|
||
assert.equal(state.readiness('iso', 'flat', false), 'fallback');
|
||
assert.equal(state.readiness('flat', 'flat', false), null, 'Flat never inherits 2.5D waiting');
|
||
});
|
||
|
||
test('#654 a drawn plan has deterministic white paper before its first render', () => {
|
||
const state = new IsoFirstFrameState();
|
||
state.prepare('iso', isoPaperContext('floor', 'view', false));
|
||
assert.equal(state.pending('iso', true), false);
|
||
assert.equal(state.sync('iso', isoPaperContext('floor', 'view', false), () => {
|
||
assert.fail('drawn paper must not ask the DOM for its colour');
|
||
}), false);
|
||
});
|
||
|
||
test('#739 AC1 a known theme paper survives floor switches; theme and mode resolve it again', () => {
|
||
const state = new IsoFirstFrameState();
|
||
const light = { darkMode: false, theme: 'light' };
|
||
const calls = [];
|
||
const resolver = (label, rgb) => () => { calls.push(label); return rgb; };
|
||
// An unfilled room is light on white paper and not light on a dark card
|
||
// background, so the classification shows which paper the state holds.
|
||
const fills = new Map([['unfilled', null]]);
|
||
const themePaper = [30, 30, 30];
|
||
|
||
// (а) floor A with a backdrop: the #654 cold path — veil until the paper is resolved.
|
||
const a = isoPaperContext('A', 'view', true, light);
|
||
state.prepare('iso', a);
|
||
assert.equal(state.pending('iso', true), true, 'the first image floor waits for its paper');
|
||
assert.equal(state.sync('iso', a, resolver('A', themePaper)), true);
|
||
assert.deepEqual(calls, ['A']);
|
||
assert.equal(state.pending('iso', true), false);
|
||
assert.deepEqual([...state.lightFloors(fills)], []);
|
||
|
||
// (б) floor B with a backdrop, same theme and mode: ready before its first render.
|
||
const b = isoPaperContext('B', 'view', true, light);
|
||
state.prepare('iso', b);
|
||
assert.equal(state.pending('iso', true), false, 'a floor switch shows no veil');
|
||
assert.equal(state.readiness('iso', 'iso', true), 'ready');
|
||
assert.equal(state.sync('iso', b, resolver('B', [255, 255, 255])), false,
|
||
'no second update pass for a floor switch');
|
||
assert.deepEqual(calls, ['A'], 'a floor switch does not ask the DOM again');
|
||
|
||
// (в) floor C with a drawn plan: white paper without the DOM.
|
||
const c = isoPaperContext('C', 'view', false, light);
|
||
state.prepare('iso', c);
|
||
assert.equal(state.pending('iso', true), false);
|
||
assert.equal(state.sync('iso', c, resolver('C', themePaper)), false);
|
||
assert.deepEqual(calls, ['A']);
|
||
assert.deepEqual([...state.lightFloors(fills)], ['unfilled'], 'drawn plans stay on white paper');
|
||
|
||
// (г) back to B: the theme paper is still known and the floor classifies on it.
|
||
state.prepare('iso', b);
|
||
assert.equal(state.pending('iso', true), false, 'drawn → backdrop keeps the known theme paper');
|
||
assert.equal(state.sync('iso', b, resolver('B again', [255, 255, 255])), false);
|
||
assert.deepEqual(calls, ['A']);
|
||
assert.deepEqual([...state.lightFloors(fills)], [], 'the theme paper, not white, classifies B');
|
||
|
||
// (д) a theme change is the #654 path again: veil, one resolve.
|
||
const dark = isoPaperContext('B', 'view', true, { darkMode: true, theme: 'dark' });
|
||
state.prepare('iso', dark);
|
||
assert.equal(state.pending('iso', true), true, 'a theme change hides the stale classification');
|
||
assert.equal(state.sync('iso', dark, resolver('dark', [250, 250, 250])), true);
|
||
assert.deepEqual(calls, ['A', 'dark']);
|
||
assert.deepEqual([...state.lightFloors(fills)], ['unfilled']);
|
||
|
||
// (е) another card mode resolves once more.
|
||
const plan = isoPaperContext('B', 'plan', true, { darkMode: true, theme: 'dark' });
|
||
state.prepare('iso', plan);
|
||
assert.equal(state.pending('iso', true), true);
|
||
assert.equal(state.sync('iso', plan, resolver('mode', [250, 250, 250])), true);
|
||
assert.deepEqual(calls, ['A', 'dark', 'mode']);
|
||
|
||
// A trip to an editor and back is a mode change too, whatever floors it passes.
|
||
const drawnView = isoPaperContext('C', 'view', false, light);
|
||
const imageView = isoPaperContext('B', 'view', true, light);
|
||
state.prepare('iso', imageView);
|
||
state.sync('iso', imageView, resolver('view', themePaper));
|
||
state.prepare('iso', drawnView);
|
||
assert.equal(state.pending('iso', true), false);
|
||
state.prepare('flat', isoPaperContext('B', 'plan', true, light));
|
||
state.prepare('iso', imageView);
|
||
assert.equal(state.pending('iso', true), true, 'back from the editor the paper is resolved again');
|
||
assert.equal(state.sync('iso', imageView, resolver('back', themePaper)), true);
|
||
assert.deepEqual(calls, ['A', 'dark', 'mode', 'view', 'back']);
|
||
});
|
||
|
||
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)}; \\}`));
|
||
// CODE-REVIEW-649-r1 L3: the dark theme computes its own state edge.
|
||
assert.match(css, new RegExp(`\\.dev\\.theme-dark\\.${state} \\{ [^}]*--iso-edge: ${isoEdgeColor(body, 'dark', false)}; \\}`));
|
||
assert.match(css, new RegExp(`\\.dev\\.theme-dark\\.${state}\\.iso-floor-light \\{ --iso-edge: ${isoEdgeColor(body, 'dark', 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);
|
||
});
|
||
|
||
/**
|
||
* #732: an overlay fixture names only fields of the production input type;
|
||
* test/iso-overlay-fixture-types.test.mjs typechecks this file.
|
||
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlaySceneInput]?: unknown }} OverlaySceneFixture
|
||
*/
|
||
|
||
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: [],
|
||
};
|
||
/** @type {OverlaySceneFixture} */
|
||
const input = {
|
||
space, devices: [{ id: 'd1' }], openings: [],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure: { topPath: '', topFaces: [], sides: [], contactPath: '', edgeCount: 0 },
|
||
iconPct: 3, deviceBasePct: 3, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1,
|
||
positionOf: () => ({ x: 500, y: 500 }),
|
||
presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => true, openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
};
|
||
const scene = buildIsoOverlayRenderScene(input);
|
||
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');
|
||
});
|
||
|
||
test('#649 AC7 the 2.5D beam is cut by the same physical bodies as Flat, on the far side of the body', () => {
|
||
const rooms = [{ id: 'r', poly: [[0, 0], [400, 0], [400, 400], [0, 400]] }];
|
||
const windows = [{ id: 'south', x: 200, y: 400, angle: 0, length: 80 }];
|
||
// Sun due south: the light goes straight north (−y) from the south window.
|
||
const base = { rooms, windows, azimuth: 180, elevation: 30, northDeg: 0, wallHeight: 84 };
|
||
const inside = (p, poly) => {
|
||
let hit = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const [xi, yi] = poly[i]; const [xj, yj] = poly[j];
|
||
if ((yi > p[1]) !== (yj > p[1]) && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) hit = !hit;
|
||
}
|
||
return hit;
|
||
};
|
||
const lit = (beam, p) => beam.polys.some((poly) => inside(p, poly));
|
||
const area = (beam) => beam.polys.reduce((sum, poly) => sum + Math.abs(poly.reduce((s, p, i) => {
|
||
const q = poly[(i + 1) % poly.length];
|
||
return s + p[0] * q[1] - q[0] * p[1];
|
||
}, 0)) / 2, 0);
|
||
const [open] = computeIsoSunBeams(base);
|
||
assert.ok(open.depth > 150, 'the beam reaches past the body');
|
||
for (const p of [[200, 360], [200, 250], [170, 250]]) assert.ok(lit(open, p), `lit without a body at ${p}`);
|
||
// A column in the middle of the beam, 80–100 units into the room.
|
||
const column = [[190, 300], [210, 300], [210, 320], [190, 320]];
|
||
const [cut] = computeIsoSunBeams({ ...base, occluders: [column] });
|
||
assert.ok(lit(cut, [200, 360]), 'between the window and the body the floor stays lit');
|
||
assert.ok(!lit(cut, [200, 250]), 'behind the body, away from the sun, the floor is in shadow');
|
||
assert.ok(lit(cut, [170, 250]), 'beside the shadow the beam goes on');
|
||
assert.ok(area(cut) < area(open) - 20 * 50, 'the shadow runs along the whole remaining beam');
|
||
});
|