From 24eb9559f84fd42ed933203528d6e72aa4bc1bc7 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun, 25 Oct 2026 22:40:52 -0400 Subject: [PATCH] Diagonal tubes as thick as straight ones, with centred elbows where they join (t35) Co-Authored-By: Claude Opus 5.5 --- docs/PROJECT_MAP.md | 13 +++--- docs/agent-log.md | 8 ++++ src/render/smooth-shaders.js | 36 +++++++++++---- src/render/world.js | 85 ++++++++++++++++++++++++++++-------- src/ui/preview-selection.js | 3 +- src/ui/previews.js | 33 ++++++++++---- test/t_diag_tubes.js | 16 ++++++- tools/scenes/t35-joins.js | 21 +++++++++ 8 files changed, 171 insertions(+), 44 deletions(-) create mode 100644 tools/scenes/t35-joins.js diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index b584c0e..4223306 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -356,10 +356,10 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab Propagated coloured light from glowing and burning cells. - functions: builtForLight, looseForLight, computeLight -### src/render/world.js (361 lines) +### src/render/world.js (410 lines) Scale colour without changing the smooth-look class. -- functions: fogColor, diagonalTubeBlock, drawWorld -- variables: TUBE_CLASS +- functions: fogColor, diagonalTubeJoins, diagonalTubeShape, diagonalTubeBlock, drawWorld +- variables: TUBE_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap ### src/render/machines.js (242 lines) Machine overlays: fans, engines, silo and the rest, drawn on top of the world. @@ -374,7 +374,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - variables: SM, smBytes -### src/render/smooth-shaders.js (348 lines) +### src/render/smooth-shaders.js (366 lines) Smooth shaders: vertex and fragment programs for the rounded world look. - variables: SM_VS, SM_FS @@ -467,7 +467,7 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (199 lines) +### src/ui/previews.js (216 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock @@ -476,7 +476,7 @@ Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the dr - functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim - variables: CRACK_N, CRACK_LV -### src/ui/preview-selection.js (80 lines) +### src/ui/preview-selection.js (81 lines) Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels) - functions: drawTag, drawSelection, drawPastePreview - variables: SEL_COL @@ -547,4 +547,5 @@ The frame loop and boot. - `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7 - `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket. - `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt. +- `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect. - `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. diff --git a/docs/agent-log.md b/docs/agent-log.md index 42c1300..d70829f 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,14 @@ 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: t35 follow-up: diagonal tube elbows (Claude Code, Codex) + +- Diagonal tubes are as thick as straight ones, and an end block joining a straight tube, belt or + filter draws an elbow at its centre, so the pipes meet centred and edge to edge; free ends get a + collar; ghosts match (`src/render/world.js`, `smooth-shaders.js`, `src/ui/previews.js`, + `preview-selection.js`). Scene `tools/scenes/t35-joins.js`. +- `test/t_diag_tubes.js`; `run_all.py --browser` ALL PASSED. + ## 2026-10-25: t35/t36 follow-ups: wall drags make slopes, diagonal tube art (Claude Code, Codex) - Wall piece: a diagonal drag lays slopes (`pieceCode`/`turnSlope` in `src/ui/selection.js`), T flips diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js index 77b1310..ec56109 100644 --- a/src/render/smooth-shaders.js +++ b/src/render/smooth-shaders.js @@ -51,7 +51,8 @@ float beltSD(vec2 lb, bool endL, bool endR) { // the line only rounds off where the whole run ends. float beltLineSD(ivec2 c, vec2 lb) { int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0))); - return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5); + return beltSD(lb, isBelt(nl) + isF(nl) < 0.5 && !(nl >= 19 && nl <= 22), + isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 22)); } // The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and // back along the bottom. @@ -250,18 +251,35 @@ void main() { bool vert = nc == 9 || nc == 11; float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0; float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x; + float tubeSD = -1.0, capRing = 0.0; if (nc >= 19) { - vec2 d = nc == 19 ? vec2(1.0, -1.0) : nc == 20 ? vec2(1.0, 1.0) : nc == 21 ? vec2(-1.0, 1.0) : vec2(-1.0, -1.0); - float offset = cellAt(tTree, c).r * 255.0 - 128.0; - bool rising = nc == 19 || nc == 21; - a = 2.0 + (offset + dot(fract(w) - 0.5, vec2(1.0, rising ? 1.0 : -1.0))) * 0.70710678; - along = dot(w, d) * 0.70710678; sp2 = 1.0; + vec2 d = vec2(1.0, nc == 19 || nc == 21 ? -1.0 : 1.0); + vec4 tag = cellAt(tTree, c); + vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0; + int joins = int(tag.a * 255.0 + 0.5); + tubeSD = 100.0; + for (int end = 0; end < 2; end++) { + int join = (joins >> (end * 3)) & 7; + float side = end == 0 ? -1.0 : 1.0; + vec2 ray = join == 0 ? vec2(side, side * (nc == 19 || nc == 21 ? -1.0 : 1.0)) * 0.70710678 : + join == 1 ? vec2(0, -1) : join == 2 ? vec2(1, 0) : join == 3 ? vec2(0, 1) : vec2(-1, 0); + float t = dot(local, ray), cross2 = dot(local, vec2(-ray.y, ray.x)); + float radius = length(vec2(cross2, min(0.0, t))), cap = t - (join == 0 ? 2.82842712 : 2.0); + float sd = max(radius - 2.0, join != 0 || (joins & (64 << end)) != 0 ? cap : -10.0); + if (sd < tubeSD) { + tubeSD = sd; + a = 2.0 + (side * cross2 < 0.0 ? -radius : radius); + along = dot(w - local, d) * 0.70710678 + side * t; + capRing = (joins & (64 << end)) != 0 ? smoothstep(-0.55, -0.15, cap) : 0.0; + } + } + sp2 = nc == 19 || nc == 20 ? 1.0 : -1.0; } float wall = 0.62; vec3 tc; - if (a < wall || a > 4.0 - wall) { + if (a < wall || a > 4.0 - wall || capRing > 0.5) { float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore - float cyl = sin(u2 * 1.5708); + float cyl = sin(clamp(u2, 0.0, 1.0) * 1.5708); tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl); if (a < wall) tc *= 1.08; float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0); @@ -279,7 +297,7 @@ void main() { if (nc >= 19) { bool full = own.r + own.g + own.b > 0.0; if (full) C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); - C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, abs(a - 2.0))); + C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, tubeSD + 2.0)); } else C = tc; } else if (nc == 6 || (nc >= 15 && nc <= 18)) { diff --git a/src/render/world.js b/src/render/world.js index d15518f..66e6024 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -6,25 +6,66 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1 const TUBE_CLASS = new Uint8Array(15); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12; for (let p = P_TUR; p <= P_TUL; p++) TUBE_CLASS[p] = 19 + p - P_TUR; // Drawing only: the four transport lanes remain in the square block. +let diagonalJoins = new Uint8Array(0); +function diagonalTubeJoins(bx, by, v, planned = null) { + // Low/high three bits select the two edge exits; bits 6/7 mark free-end collars. + const dy = v === 4 || v === 6 ? -1 : 1; + let joins = 0; + for (let end = 0; end < 2; end++) { + const side = end ? 1 : -1, nx = bx + side, ny = by + side * dy; + const nb = ny * BW + nx, next = planned && planned.get(nb); + const nc = next ? next[0] : blockP[nb], nv = next ? next[1] : blockV[nb]; + if (nx >= 0 && ny >= 0 && nx < BW && ny < BH && + nc === BP_TUBE && nv >= 4 && (nv === 4 || nv === 6) === (dy < 0)) continue; + let join = 0; + for (const dir of [side > 0 ? 1 : 3, side * dy > 0 ? 2 : 0]) { + const x = bx + TUBE_DIRS[dir][0], y = by + TUBE_DIRS[dir][1]; + if (x < 0 || y < 0 || x >= BW || y >= BH) continue; + const n = y * BW + x, next = planned && planned.get(n); + const code = next ? next[0] : blockP[n], nv = next ? next[1] : blockV[n]; + if ((code === BP_TUBE && nv < 4 && (nv & 1) === (dir & 1)) || + ((code === BP_BELT || code === BP_FILTER) && (dir & 1))) { join = dir + 1; break; } + } + joins |= join << (end * 3); + if (!join) joins |= 64 << end; + } + return joins; +} +// Scratch coordinates shared by the pixel shading; no allocations in the grain loop. +let diagonalAcross = 0, diagonalAlong = 0, diagonalCap = 0; +function diagonalTubeShape(x, y, b, v, joins) { + const lx = x - (b % BW) * BS - 2, ly = y - ((b / BW) | 0) * BS - 2; + let best = Infinity; + for (let end = 0; end < 2; end++) { + const join = (joins >> (end * 3)) & 7, side = end ? 1 : -1; + const dx = join ? TUBE_DIRS[join - 1][0] : side * Math.SQRT1_2; + const dy = join ? TUBE_DIRS[join - 1][1] : side * (v === 4 || v === 6 ? -1 : 1) * Math.SQRT1_2; + const along = lx * dx + ly * dy, across = -lx * dy + ly * dx; + const radius = Math.hypot(across, Math.min(0, along)), cap = along - (join ? 2 : 2 * Math.SQRT2); + const sd = Math.max(radius - 2, join || (joins & (64 << end)) ? cap : -10); + if (sd < best) { + best = sd; diagonalAcross = side * across < 0 ? -radius : radius; + diagonalAlong = ((b % BW) * BS + 2 + (((b / BW) | 0) * BS + 2) * (v === 4 || v === 6 ? -1 : 1)) * Math.SQRT1_2 + side * along; diagonalCap = joins & (64 << end) ? cap : -10; + } + } + return best; +} function diagonalTubeBlock(x, y, smooth) { const i = y * W + x, own = (y >> 2) * BW + (x >> 2); if (part[i] >= P_TUR && part[i] <= P_TUL) return own; if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[own]) return -1; - // Look in the four adjoining blocks for a pipe crossing this empty corner. + let best = smooth ? 0.75 : 0, found = -1; for (let k = 0; k < 4; k++) { const bx = (x >> 2) + (k === 0 ? -1 : k === 1 ? 1 : 0); const by = (y >> 2) + (k === 2 ? -1 : k === 3 ? 1 : 0); if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue; const b = by * BW + bx, v = blockV[b]; if (blockP[b] !== BP_TUBE || v < 4) continue; - const rising = v === 4 || v === 6, lx = x - bx * BS, ly = rising ? 3 - (y - by * BS) : y - by * BS; - if (Math.abs(lx - ly) > (smooth ? 3 : 1)) continue; - const side = lx + ly < 3 ? -1 : 1, nx = bx + side, ny = by + (rising ? -side : side); - if (nx < 0 || ny < 0 || nx >= BW || ny >= BH) continue; - const nb = ny * BW + nx; - if (blockP[nb] === BP_BELT || (blockP[nb] === BP_TUBE && (blockV[nb] < 4 || ((blockV[nb] === 4 || blockV[nb] === 6) === rising)))) return b; + const joins = diagonalJoins[b] === undefined ? diagonalTubeJoins(bx, by, v) : diagonalJoins[b]; + const sd = diagonalTubeShape(x + 0.5, y + 0.5, b, v, joins); + if (sd <= best) { best = sd; found = b; } } - return -1; + return found; } function drawWorld() { const bw = R.bw, bh = R.bh, px = R.px; @@ -44,6 +85,13 @@ function drawWorld() { for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; } } } + if (diagonalJoins.length !== NB) diagonalJoins = new Uint8Array(NB); + for (let by = Math.max(0, (vy0 >> 2) - 1); by <= Math.min(BH - 1, ((vy0 + bh) >> 2) + 1); by++) { + for (let bx = Math.max(0, (vx0 >> 2) - 1); bx <= Math.min(BW - 1, ((vx0 + bw) >> 2) + 1); bx++) { + const b = by * BW + bx; + if (blockP[b] === BP_TUBE && blockV[b] >= 4) diagonalJoins[b] = diagonalTubeJoins(bx, by, blockV[b]); + } + } const useFog = SET.fog; const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black const lighting = SET.lighting !== 'off' && !R.lens; @@ -178,22 +226,23 @@ function drawWorld() { // field parts drawn over contents let p = part[i]; const db = diagonalTubeBlock(x, y, sm); - let diagonalAcross = 0; + let diagonal = false; if (db >= 0) { - const v = blockV[db], rising = v === 4 || v === 6; - diagonalAcross = x - (db % BW) * BS - (rising ? 3 - (y - ((db / BW) | 0) * BS) : y - ((db / BW) | 0) * BS); - p = sm || Math.abs(diagonalAcross) <= 1 ? TUBE_PARTS[v] : 0; - if (sm) tpx[row + bx] = 128 + diagonalAcross; + const v = blockV[db], joins = diagonalJoins[db]; + const sd = diagonalTubeShape(x + 0.5, y + 0.5, db, v, joins); + p = sm || sd <= 0 ? TUBE_PARTS[v] : 0; + diagonal = !!p; + if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8); } if (p && sm && (IS_TUBE[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } } else if (p) { if (IS_TUBE[p]) { const on = netSat(i) > 0; - const vert = p === P_TU || p === P_TD, diagonal = TDX[p] && TDY[p]; - const axis = diagonal ? (x * TDX[p] + y * TDY[p]) >> 1 : vert ? y : x; - const across = diagonal ? diagonalAcross + 1 : vert ? x & 3 : y & 3; - const dir = diagonal ? -1 : (p === P_TU || p === P_TL) ? 1 : -1; - if (diagonal ? across !== 1 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through + const vert = p === P_TU || p === P_TD; + const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x; + const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3; + const dir = diagonal ? (blockV[db] >= 6 ? 1 : -1) : (p === P_TU || p === P_TL) ? 1 : -1; + if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; } if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; } } else { diff --git a/src/ui/preview-selection.js b/src/ui/preview-selection.js index 9a1d269..5ef2143 100644 --- a/src/ui/preview-selection.js +++ b/src/ui/preview-selection.js @@ -72,7 +72,8 @@ function drawPastePreview() { const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh); ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); - for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; previewBlock(code, bx2, by2, bw2, bh2); } + const planned = new Map(bp.blocks.map(([dx, dy, code, v]) => [(oy + dy) * BW + ox + dx, [code, v]])); + for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; previewBlock(code, bx2, by2, bw2, bh2, false, code === BP_TUBE ? v : -1, ox + dx, oy + dy, planned); } for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); } for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); } ctx.restore(); diff --git a/src/ui/previews.js b/src/ui/previews.js index 4bad502..c7c8c88 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -112,6 +112,7 @@ function drawPreviews() { if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; } const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0); const afford = canAfford(pieceCostNow(P), cells.length); + const planned = code === BP_TUBE && dIdx >= 4 ? new Map(cells.map(([x, y]) => [y * BW + x, [BP_TUBE, dIdx]])) : null; for (const [bx, by] of cells) { const err = canPlaceBlock(bx, by, code), rb = by * BW + bx; const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P)); @@ -119,10 +120,10 @@ function drawPreviews() { const ok = (!err || swap) && !ghost && afford; const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)'; - previewBlock(code, x, y, w, h, false, P.id === 'tube' ? dIdx : -1); + previewBlock(code, x, y, w, h, false, P.id === 'tube' ? dIdx : -1, bx, by, planned); ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25); if (ghost) ctx.setLineDash([zp, zp * 0.7]); - previewBlock(code, x, y, w, h, true, P.id === 'tube' ? dIdx : -1); ctx.setLineDash([]); + previewBlock(code, x, y, w, h, true, P.id === 'tube' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]); if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) { // a chevron on every block, pointing the way it will carry (or pump, or pour) const [dx, dy] = P.id === 'tube' ? TUBE_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx]; @@ -179,14 +180,30 @@ function drawPreviews() { drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2); } -function previewBlock(code, x, y, w, h, stroke = false, direction = -1) { +function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) { const ctx = R.ctx; if (code === BP_TUBE && direction >= 4) { - const rising = direction === 4 || direction === 6; - ctx.save(); ctx.translate(x + w / 2, y + h / 2); ctx.rotate(rising ? -Math.PI / 4 : Math.PI / 4); - const length = w * Math.SQRT2, width = w * 0.75; - if (stroke) ctx.strokeRect(-length / 2, -width / 2, length, width); - else ctx.fillRect(-length / 2, -width / 2, length, width); + const joins = bx >= 0 ? diagonalTubeJoins(bx, by, direction, planned) : 0; + const points = []; + for (let end = 0; end < 2; end++) { + const join = (joins >> (end * 3)) & 7, side = end ? 1 : -1; + const d = join ? TUBE_DIRS[join - 1] : [side, side * (direction === 4 || direction === 6 ? -1 : 1)]; + points.push([d[0] * w / 2, d[1] * h / 2]); + } + const r = w / 2, a = points[0], b = points[1]; + const al = Math.hypot(...a), bl = Math.hypot(...b); + const n0 = [a[1] / al, -a[0] / al], n1 = [-b[1] / bl, b[0] / bl]; + const turn = n0[0] * n1[1] - n0[1] * n1[0], dot = n0[0] * n1[0] + n0[1] * n1[1]; + ctx.save(); ctx.translate(x + w / 2, y + h / 2); ctx.beginPath(); + ctx.moveTo(a[0] + n0[0] * r, a[1] + n0[1] * r); + if (turn >= 0) ctx.lineTo((n0[0] + n1[0]) * r / (1 + dot), (n0[1] + n1[1]) * r / (1 + dot)); + else ctx.arc(0, 0, r, Math.atan2(n0[1], n0[0]), Math.atan2(n1[1], n1[0]), true); + ctx.lineTo(b[0] + n1[0] * r, b[1] + n1[1] * r); + ctx.lineTo(b[0] - n1[0] * r, b[1] - n1[1] * r); + if (turn <= 0) ctx.lineTo(-(n0[0] + n1[0]) * r / (1 + dot), -(n0[1] + n1[1]) * r / (1 + dot)); + else ctx.arc(0, 0, r, Math.atan2(-n1[1], -n1[0]), Math.atan2(-n0[1], -n0[0]), true); + ctx.lineTo(a[0] - n0[0] * r, a[1] - n0[1] * r); ctx.closePath(); + if (stroke) ctx.stroke(); else ctx.fill(); ctx.restore(); return; } if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; } diff --git a/test/t_diag_tubes.js b/test/t_diag_tubes.js index 375d95a..6f64a50 100644 --- a/test/t_diag_tubes.js +++ b/test/t_diag_tubes.js @@ -168,8 +168,19 @@ for(const [dx,dy,v] of [[5,-4,4],[4,5,5],[-5,4,6],[-4,-5,7]]) { check(`a diagonal drag picks direction ${v}`,run(`dragDirFor(PIECE_BY_ID.tube,{bx:20,by:20},{bx:${20+dx},by:${20+dy}})`)===v); check(`direction ${v} lays a corner-touching diagonal run`,run(`blockLine(20,20,${20+dx},${20+dy},true).every(([x,y],k)=>x===20+k*${Math.sign(dx)} && y===20+k*${Math.sign(dy)})`)); } -// The drawing can bridge a joint only in unoccupied air; transport parts stay square. +// End elbows follow the adjoining straight piece's axis, regardless of flow direction. run(fs.readFileSync('src/render/world.js', 'utf8')); +for (const v of [4,5,6,7]) for (let dir=0;dir<4;dir++) for (const code of ['BP_TUBE','BP_BELT','BP_FILTER']) { + if (code !== 'BP_TUBE' && !(dir & 1)) continue; + fresh(); + const [dx,dy]=run(`TUBE_DIRS[${dir}]`); + run(`placeBlock(BP_TUBE,${v},20,20,true);placeBlock(${code},${dir},${20+dx},${20+dy},true);`); + const end = dx ? dx>0 : (v===4 || v===6 ? dy<0 : dy>0); + check(`diagonal ${v} bends toward ${code} direction ${dir}`,run(`(diagonalTubeJoins(20,20,${v}) >> ${end?3:0} & 7)`)===dir+1); + check(`planned ${code} direction ${dir} bends the diagonal ${v} ghost too`,run(`(()=>{removeBlock(${20+dx},${20+dy});const planned=new Map([[${20+dy}*BW+${20+dx},[${code},${dir}]]]);const j=diagonalTubeJoins(20,20,${v},planned);placeBlock(${code},${dir},${20+dx},${20+dy},true);return (j >> ${end?3:0} & 7)===${dir+1};})()`)); + check(`diagonal ${v} meets ${code} direction ${dir} across its centred edge`,run(`(()=>{const b=20*BW+20,j=diagonalTubeJoins(20,20,${v});return [0.5,1.5,2.5,3.5].every(t=>diagonalTubeShape(${dx ? 82+dx*1.99 : '80+t'},${dy ? 82+dy*1.99 : '80+t'},b,${v},j)<=0);})()`)); +} +// The drawing can bridge a joint only in unoccupied air; transport parts stay square. for (const v of [4,5,6,7]) { flatWorld(run,240,192,176); const rising = v===4 || v===6; @@ -179,7 +190,8 @@ for (const v of [4,5,6,7]) { for(const obstacle of ['mat[j]=STONE','mat[j]=SAND','part[j]=P_PIPE','mach[j]=1','occ[j]=1','blockP[(j/W>>2)*BW+(j%W>>2)]=BP_WALL']) { check(`diagonal ${v} leaves ${obstacle} visible`,run(`(()=>{const j=${y}*W+${x};${obstacle};const hidden=diagonalTubeBlock(${x},${y},true);mat[j]=EMPTY;part[j]=0;mach[j]=0;occ[j]=0;blockP[(${y}>>2)*BW+(${x}>>2)]=0;return hidden<0;})()`)); } + check(`diagonal ${v} keeps its continuation uncapped`,run(`(diagonalTubeJoins(20,20,${v}) & 128)===0 && (diagonalTubeJoins(21,${rising ? 19 : 21},${v}) & 64)===0`)); run(`removeBlock(21,${rising ? 19 : 21});`); - check(`diagonal ${v} has no stray corner after its end`,run(`diagonalTubeBlock(${x},${y},false)===-1`)); + check(`diagonal ${v} has no stray corner after its end`,run(`diagonalTubeBlock(${x+1},${rising ? y-1 : y+1},false)===-1`)); } done(); diff --git a/tools/scenes/t35-joins.js b/tools/scenes/t35-joins.js new file mode 100644 index 0000000..49bc6d1 --- /dev/null +++ b/tools/scenes/t35-joins.js @@ -0,0 +1,21 @@ +// Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect. +const jx = BX + 5, jy = BY - 3; +for (let k = 1; k <= 4; k++) placeBlock(BP_TUBE, 0, jx, jy + k, true); +for (let k = 0; k < 6; k++) placeBlock(BP_TUBE, 4, jx + k, jy - k, true); +for (let k = 1; k <= 4; k++) placeBlock(BP_TUBE, 0, jx + 5, jy - 5 - k, true); +const hx = BX + 21, hy = BY - 3; +for (let k = 1; k <= 4; k++) placeBlock(BP_BELT, 1, hx - k, hy, true); +for (let k = 0; k < 6; k++) placeBlock(BP_TUBE, 4, hx + k, hy - k, true); +for (let k = 1; k <= 4; k++) placeBlock(BP_BELT, 1, hx + 5 + k, hy - 5, true); +for (let k = 0; k < 4; k++) placeBlock(BP_TUBE, 5, BX + 32 + k, BY - 10 + k, true); +window.JOINTS = { + verticalIn: [jx * 4 + 2, jy * 4 + 4], + verticalOut: [(jx + 5) * 4 + 2, (jy - 5) * 4], + beltIn: [hx * 4, hy * 4 + 2], + beltOut: [(hx + 6) * 4, (hy - 5) * 4 + 2], + free: [(BX + 35) * 4 + 4, (BY - 7) * 4 + 4] +}; +window.CAM = [(BX + 20) * 4, (BY - 5) * 4]; + +// Keep the camera-following bot out of the joint close-ups. +drawBot = () => {};