From 20d0627d75c39f9c3f3a5c7d37d3ee3c164b6342 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun, 25 Oct 2026 16:42:07 -0400 Subject: [PATCH] Buildings cast no shadows: daylight passes player-built pieces (t37) The lighting finds its own roof per column, skipping walls, belts, tubes, machines and molds, and lets light through them like air, in both looks. Natural rock still shades; the simulation's roof (steam condensing under a built roof) is unchanged. New test/t_light.js. Co-Authored-By: Claude Opus 5.5 --- docs/PROJECT_MAP.md | 5 ++-- docs/agent-log.md | 9 +++++++ src/render/light.js | 20 ++++++++++++-- src/render/world.js | 2 +- test/t_light.js | 63 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 test/t_light.js diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 5ee11b7..ccd63c3 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -352,9 +352,9 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B -### src/render/light.js (79 lines) +### src/render/light.js (95 lines) Propagated coloured light from glowing and burning cells. -- functions: computeLight +- functions: builtForLight, computeLight ### src/render/world.js (308 lines) Scale colour without changing the smooth-look class. @@ -509,6 +509,7 @@ The frame loop and boot. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. +- `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling. diff --git a/docs/agent-log.md b/docs/agent-log.md index 39365b5..598a997 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,15 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-25: t37: buildings cast no shadows (Claude Code, Codex) + +- `computeLight()` (`src/render/light.js`): a per-view-column light roof that skips player-built + cells (`builtForLight`: unnatural solids, block, machine or part cells), and built cells pass + light like air. `drawWorld()` stops marking built cells as shading terrain for the smooth look. + The simulation's `roofY` is untouched (built roofs still condense steam). +- `test/t_light.js`; screenshots `shots/t37-before-*.png`, `shots/t37-after-*.png`. + `run_all.py --browser` ALL PASSED. + ## 2026-10-25: t34: the nozzle blows no further than it sucks (Claude Code, Codex) - `toolBlow()` (`src/sim/vacuum.js`) aims at the capped point from `nozzle()` when the cursor is diff --git a/src/render/light.js b/src/render/light.js index 57f271d..eeca94a 100644 --- a/src/render/light.js +++ b/src/render/light.js @@ -2,6 +2,9 @@ // ---------- Lighting ---------- // Light is computed on a coarse grid (2 or 4 grains per cell), written into a // small canvas, then the GPU upscales it smoothly and multiplies it over the world. +function builtForLight(i, x, y) { + return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i]; +} function computeLight() { const res = SET.lighting === 'fast' ? 4 : 2; const M = Math.ceil(28 / res); @@ -13,6 +16,7 @@ function computeLight() { R.lightC.width = lw; R.lightC.height = lh; R.lightImg = R.lightX.createImageData(lw, lh); R.lightPx = new Uint32Array(R.lightImg.data.buffer); + R.lightRoof = new Int32Array(lw); } R.lx0 = lx0; R.ly0 = ly0; const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt; @@ -20,15 +24,27 @@ function computeLight() { const P = res; const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P); const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains + // Only sampled view columns, starting at the simulation roof; nothing below the view matters. + const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof; + for (let lx = 0; lx < lw; lx++) { + const x = (lx0 + lx) * res; + if (x < 0 || x >= W) continue; + let y = roofY[x]; + for (; y < roofEnd; y++) { + const i = y * W + x, ph = PH[mat[i]]; + if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y)) break; + } + lightRoof[lx] = y; + } for (let ly = 0; ly < lh; ly++) { const wy = (ly0 + ly) * res; for (let lx = 0; lx < lw; lx++) { const wx = (lx0 + lx) * res, k = ly * lw + lx; if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; } const i = wy * W + wx, m = mat[i], ph = PH[m]; - la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol; + la[k] = builtForLight(i, wx, wy) ? aAir : ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol; let r = 0, g = 0, b = 0; - if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; } + if (wy < lightRoof[lx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; } else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; } // emission from heat: hottest grain in the block let T = 0; diff --git a/src/render/world.js b/src/render/world.js index 1a8052c..255371e 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -219,7 +219,7 @@ function drawWorld() { else cl = 5; } top = cl << 24; - fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0); + fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0); } if (fv < 255) { const k = fv / 255; r *= k; g *= k; b *= k; diff --git a/test/t_light.js b/test/t_light.js new file mode 100644 index 0000000..b25acb6 --- /dev/null +++ b/test/t_light.js @@ -0,0 +1,63 @@ +// Run the renderer's lighting against real simulation data, with only canvas storage stubbed. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8')); +run(` + const SET = { lighting: 'full', daylight: 1, ambient: 0.12 }; + R.lightC = {}; + R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} }; + R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80; + function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; } +`); +flatWorld(run, 320, 200, 160); +run(` + bot.lamp = false; + for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true); + for (let by = 21; by < 39; by++) { + placeBlock(BP_WALL, RSTONE, 8, by, true); + placeBlock(BP_WALL, RSTONE, 29, by, true); + } + placeBlock(BP_BELT, 1, 12, 30, true); + placeBlock(BP_TUBE, 0, 14, 30, true); + addMachine('silo', 18, 30, 0); + addMold('bit', 24, 30, RSTONE, true); + for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); + updateRoof(); + const simRoof = Array.from(roofY); +`); +for (const mode of ['full', 'fast']) { + run(`SET.lighting = '${mode}'; computeLight();`); + for (const channel of ['lr', 'lg']) { + const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`); + check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`); + check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`); + check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`); + } + // Directly check transmission as daylight can hide an opaque piece in the open. + for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['tube', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) { + const a = run(`lightAt(R.latt, ${x}, ${y})`); + check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); + } +} +check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80')); +for (const material of ['STONE', 'SAND', 'WATER']) { + run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); updateRoof(); computeLight();`); + check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95')); + run('setCell(96 * W + 80, EMPTY, AMB[96], 0);'); +} +run('updateRoof(); computeLight();'); +check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1')); +for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) { + run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`); + check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6')); + run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`); +} +run('updateRoof();'); +run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`); +check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2')); +run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`); +check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2')); +done();