diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 4fc3457..102e69e 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -356,11 +356,12 @@ 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/slope-belts.js (28 lines)
-A slope's band uses world coordinates, so its stripes never restart at block joints.
-- functions: slopeBeltAt, slopeBeltColor
+### src/render/slope-belts.js (127 lines)
+Drawing only: the face stays on its simulation cells; the band hangs below it.
+- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor
+- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance
-### src/render/world.js (422 lines)
+### src/render/world.js (440 lines)
Scale colour without changing the smooth-look class.
- functions: fogColor, diagonalTubeJoins, diagonalTubeShape, diagonalTubeBlock, drawWorld
- variables: TUBE_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
@@ -378,7 +379,11 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes
-### src/render/smooth-shaders.js (393 lines)
+### src/render/slope-belt-shader.js (84 lines)
+Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
+- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
+
+### src/render/smooth-shaders.js (369 lines)
Smooth shaders: vertex and fragment programs for the rounded world look.
- variables: SM_VS, SM_FS
@@ -471,7 +476,7 @@ Gamepad input.
- functions: padPressed, pollPad
- variables: PAD
-### src/ui/previews.js (227 lines)
+### src/ui/previews.js (237 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
@@ -522,6 +527,7 @@ The frame loop and boot.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
+- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
diff --git a/docs/agent-log.md b/docs/agent-log.md
index b8c72a9..7539540 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: t40 art pass: sloped belts and filters drawn as one belt (Claude Code, Codex)
+
+- Sloped runs draw as the flat belt (rollers, studs) or filter (tinted mesh) band turned to 45°,
+ continuous, with elbows into flat belts, filters and tubes and rounded free ends, in both looks
+ (`src/render/slope-belts.js`, new `slope-belt-shader.js`, `world.js`, `smooth-shaders.js`);
+ ghosts and icons match. Drawing only.
+- `test/t_slope_belt_art.js`; `run_all.py --browser` ALL PASSED.
+
## 2026-10-25: t40: sloped belts and filters (Claude Code, Codex)
- Block codes 19-22 (`BP_BELT_UP/DOWN`, `BP_FILTER_UP/DOWN`, `src/sim/pieces.js`): slope-shaped
diff --git a/src/manifest.json b/src/manifest.json
index 4a33a0e..7f8c8b8 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -46,6 +46,7 @@
"render/machines.js",
"render/overlays.js",
"render/smooth.js",
+ "render/slope-belt-shader.js",
"render/smooth-shaders.js",
"ui/audio.js",
"ui/state.js",
diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js
new file mode 100644
index 0000000..3c212d5
--- /dev/null
+++ b/src/render/slope-belt-shader.js
@@ -0,0 +1,84 @@
+// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
+const SM_SLOPE_HELPERS = `
+// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band.
+vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) {
+ vec2 ray = b - a; float len = length(ray); ray /= len;
+ float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x));
+ vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1);
+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75;
+ return vec3(sd, origin + along, across + 2.0);
+}
+vec3 slopeCorner(vec2 p, vec2 a, float origin) {
+ vec2 d = p - a; float radius = length(d);
+ return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
+}
+vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
+ float sy = grade < 0.0 ? 4.0 : 0.0;
+ vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
+ if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
+ else if ((joins & 4) != 0) a -= vec2(4.0, grade * 4.0);
+ if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
+ else if ((joins & 8) != 0) b += vec2(4.0, grade * 4.0);
+ vec2 axis = vec2(1.0, grade) * 0.70710678;
+ vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
+ if ((joins & 1) != 0) {
+ vec3 h = slopeStroke(p, vec2(-2.0, a.y), a, worldOrigin.x - 2.0);
+ if (h.x < best.x) best = h;
+ h = slopeCorner(p, a, worldOrigin.x + a.x);
+ if (h.x < best.x) best = h;
+ }
+ if ((joins & 2) != 0) {
+ vec3 h = slopeStroke(p, b, vec2(6, b.y), worldOrigin.x + b.x);
+ if (h.x < best.x) best = h;
+ h = slopeCorner(p, b, worldOrigin.x + b.x);
+ if (h.x < best.x) best = h;
+ }
+ return best;
+}
+`;
+
+const SM_SLOPE_DRAW = `
+ // Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
+ if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
+ (int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
+ cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
+ int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5);
+ int shape = (slopeClass - 23) / 2;
+ bool mesh = shape >= 2;
+ float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
+ float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0;
+ vec4 tag = cellAt(tTree, c);
+ int joins = int(tag.a * 255.0 + 0.5);
+ vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
+ vec3 shapePos = slopeShape(local, w - local, grade, joins);
+ float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z;
+ float time = (joins & 16) != 0 ? uTime : 0.0;
+ vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth);
+ float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
+ vec3 belt;
+ if (-sd < 0.8) {
+ belt = bandColor(lb, rg);
+ if (mesh && ((joins & 32) != 0) == (depth < 2.0) && dot(own.rgb, vec3(1)) > 0.01)
+ belt = mix(belt, own.rgb * (depth < 2.0 ? 1.0 : 0.8), 0.75 * rg);
+ belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
+ } else if (mesh) {
+ vec2 cg = abs(fract(alongWorld) - 0.5);
+ float bw2 = max(px * 0.9, 0.09);
+ float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
+ vec3 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5);
+ belt = mix(steel * 0.78, C * 0.8, hole);
+ } else {
+ vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0);
+ float rd = length(dv);
+ belt = vec3(0.15, 0.16, 0.18);
+ if (rd < 0.95) {
+ float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95;
+ float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
+ belt = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
+ belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
+ belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
+ }
+ }
+ C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
+ }
+`;
diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js
index 7e3f9db..599199b 100644
--- a/src/render/slope-belts.js
+++ b/src/render/slope-belts.js
@@ -1,28 +1,127 @@
-// A slope's band uses world coordinates, so its stripes never restart at block joints.
-function slopeBeltAt(i, x, y) {
- const b = (y >> 2) * BW + (x >> 2);
- if (isSlopeBelt(blockP[b])) return b;
- if ((y & 3) || ((x & 3) !== 0 && (x & 3) !== 3)) return -1;
- const owner = slopeSealOwner(i);
- return owner >= 0 && isSlopeBelt(blockP[owner]) ? owner : -1;
+// Drawing only: the face stays on its simulation cells; the band hangs below it.
+let slopeBeltJoins = new Uint8Array(0), slopeBeltHalo = new Uint8Array(0), beltCaps = new Uint8Array(0);
+let slopeAcross = 0, slopeAlong = 0, slopeDistance = 0;
+function slopeBeltConnections(bx, by, code, planned = null) {
+ const grade = beltGrade(code);
+ let joins = 0;
+ for (let end = 0; end < 2; end++) {
+ const side = end ? 1 : -1, nx = bx + side, ny = by + side * grade;
+ const nb = ny * BW + nx, next = planned && planned.get(nb);
+ if (nx >= 0 && nx < BW && ny >= 0 && ny < BH && beltGrade(next ? next[0] : blockP[nb]) === grade) {
+ joins |= 4 << end; continue;
+ }
+ const fy = by + (end ? grade > 0 : grade < 0), fb = fy * BW + nx;
+ if (nx < 0 || nx >= BW || fy < 0 || fy >= BH) continue;
+ const flat = planned && planned.get(fb), c = flat ? flat[0] : blockP[fb], v = flat ? flat[1] : blockV[fb];
+ if (c === BP_BELT || c === BP_FILTER || (c === BP_TUBE && (v === 1 || v === 3))) joins |= 1 << end;
+ }
+ return joins;
+}
+function flatBeltCaps(b) {
+ const bx = b % BW, by = (b / BW) | 0;
+ let caps = 0;
+ for (let end = 0; end < 2; end++) {
+ const x = bx + (end ? 1 : -1);
+ let joined = false;
+ if (x >= 0 && x < BW) for (let dy = -1; dy <= 0; dy++) {
+ if (by + dy < 0) continue;
+ const n = (by + dy) * BW + x, c = blockP[n], g = beltGrade(c);
+ if ((!dy && (c === BP_BELT || c === BP_FILTER || (c === BP_TUBE && (blockV[n] === 1 || blockV[n] === 3 || blockV[n] >= 4)))) ||
+ (g && dy === -(end ? g < 0 : g > 0))) joined = true;
+ }
+ if (!joined) caps |= 1 << end;
+ }
+ return caps;
+}
+function prepareSlopeBelts(x0, y0, x1, y1) {
+ if (slopeBeltJoins.length !== NB) { slopeBeltJoins = new Uint8Array(NB); slopeBeltHalo = new Uint8Array(NB); beltCaps = new Uint8Array(NB); }
+ slopeBeltHalo.fill(0);
+ for (let by = Math.max(0, (y0 >> 2) - 2); by <= Math.min(BH - 1, (y1 >> 2) + 2); by++) {
+ for (let bx = Math.max(0, (x0 >> 2) - 2); bx <= Math.min(BW - 1, (x1 >> 2) + 2); bx++) {
+ const b = by * BW + bx, c = blockP[b];
+ if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b);
+ if (!isSlopeBelt(c)) continue;
+ const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS;
+ slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0);
+ for (let yy = Math.max(0, by - 1); yy <= Math.min(BH - 1, by + 1); yy++)
+ for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1;
+ }
+ }
+}
+// Rounded stroke, with coordinates along/across the winning segment for rollers and mesh.
+function slopeBeltSegment(x, y, ax, ay, bx, by, origin) {
+ const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len;
+ const along = (x - ax) * ux + (y - ay) * uy, across = -(x - ax) * uy + (y - ay) * ux;
+ const qx = Math.abs(along - len / 2) - len / 2 + 0.75, qy = Math.abs(across) - 1.1;
+ const sd = Math.hypot(Math.max(0, qx), Math.max(0, qy)) + Math.min(Math.max(qx, qy), 0) - 0.75;
+ if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = across + 2; slopeAlong = origin + along; }
+}
+function slopeBeltCorner(x, y, ax, ay, origin) {
+ const dx = x - ax, dy = y - ay, radius = Math.hypot(dx, dy), sd = radius - 1.85;
+ if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = 2 + dy; slopeAlong = origin + dx; }
+}
+function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) {
+ const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
+ x -= ox; y -= oy;
+ const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0;
+ const normal = 1.85 * Math.SQRT1_2;
+ let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal;
+ if (joins & 1) { ay = sy + 2; ax = (ay - sy - offset) / grade; }
+ else if (joins & 4) { ax -= 4; ay -= grade * 4; }
+ if (joins & 2) { by = 6 - sy; bx = (by - sy - offset) / grade; }
+ else if (joins & 8) { bx += 4; by += grade * 4; }
+ slopeDistance = Infinity;
+ const origin = (ox + grade * oy) * Math.SQRT1_2;
+ slopeBeltSegment(x, y, ax, ay, bx, by, origin + (ax + grade * ay) * Math.SQRT1_2);
+ if (joins & 1) {
+ slopeBeltSegment(x, y, -2, ay, ax, ay, ox - 2);
+ slopeBeltCorner(x, y, ax, ay, ox + ax);
+ }
+ if (joins & 2) {
+ slopeBeltSegment(x, y, bx, by, 6, by, ox + bx);
+ slopeBeltCorner(x, y, bx, by, ox + bx);
+ }
+ return slopeDistance;
+}
+function slopeBeltAt(i, x, y, smooth = false) {
+ const own = (y >> 2) * BW + (x >> 2), code = blockP[own];
+ if (!slopeBeltHalo[own]) return -1;
+ const slope = isSlopeBelt(code);
+ if (!slope && (mach[i] || occ[i] || (mat[i] !== EMPTY && mat[i] !== BELTL && mat[i] !== BELTR))) return -1;
+ let seal = !slope && (mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER) ? slopeSealOwner(i) : -1;
+ if (seal >= 0 && !isSlopeBelt(blockP[seal])) seal = -1;
+ if (!slope && seal < 0 && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || code)) return -1;
+ let best = smooth ? 0.8 : 0, found = slope ? own : seal;
+ for (let yy = Math.max(0, (y >> 2) - 1); yy <= Math.min(BH - 1, (y >> 2) + 1); yy++) {
+ for (let xx = Math.max(0, (x >> 2) - 1); xx <= Math.min(BW - 1, (x >> 2) + 1); xx++) {
+ const b = yy * BW + xx;
+ if (!isSlopeBelt(blockP[b])) continue;
+ const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
+ if (sd < best) { best = sd; found = b; }
+ }
+ }
+ return found;
}
function slopeBeltColor(b, x, y, anim, br, bg, bb) {
- const c = blockP[b], grade = beltGrade(c), d = y - beltFaceY(c, b % BW, (b / BW) | 0, x);
- let r = br, g = bg, blue = bb;
- if (d >= 0) {
- const dir = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
- const axis = (x + grade * y) * 0.5, stripe = (Math.floor((axis - dir * (anim >> 1)) / 2) & 1);
- if (d === 0) {
- r = stripe ? 92 : 56; g = stripe ? 96 : 58; blue = stripe ? 104 : 66;
- if (isFilter(c) && !stripe) {
- const f = filterCfg.get(b), m = f && f.m[0];
- if (m) { r = COLR[m] * 0.7; g = COLG[m] * 0.7; blue = COLB[m] * 0.7; }
- }
- } else if (isFilter(c)) {
- if (d === 1 && ((x + (grade < 0 ? 0 : 1)) & 1)) { r = 64; g = 68; blue = 76; }
- } else if (d === 1) {
- r = stripe ? 132 : 104; g = stripe ? 120 : 94; blue = stripe ? 108 : 84;
- } else { r = 46; g = 48; blue = 54; }
+ const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
+ if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16);
+ const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c);
+ const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
+ const a = slopeAlong, d = slopeAcross, time = joins & 16 ? anim : 0;
+ let r = 38, g = 41, blue = 46;
+ if (-sd < 0.8) {
+ const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72;
+ r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k;
+ } else if (mesh) {
+ const f = filterCfg.get(b), m = f && f.m.length ? f.m[(Math.floor(a / 2) & 65535) % f.m.length] : 0;
+ if ((Math.floor(a) + Math.floor(d)) & 1) { r = br * 0.8; g = bg * 0.8; blue = bb * 0.8; }
+ else { r = m ? COLR[m] * 0.75 : 64; g = m ? COLG[m] * 0.75 : 68; blue = m ? COLB[m] * 0.75 : 76; }
+ } else {
+ const dx = ((a % 2 + 2) % 2) - 1, dy = d - 2;
+ if (dx * dx + dy * dy < 0.95 * 0.95) {
+ const spin = Math.cos((Math.atan2(dy, dx) - dir * time / 1.9) * 3) > 0.6;
+ r = spin ? 104 : 132; g = spin ? 94 : 120; blue = spin ? 84 : 108;
+ }
}
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
}
diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js
index bf5ae7b..07b3ac6 100644
--- a/src/render/smooth-shaders.js
+++ b/src/render/smooth-shaders.js
@@ -50,9 +50,8 @@ float beltSD(vec2 lb, bool endL, bool endR) {
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
// 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 && !(nl >= 19 && nl <= 30) && cls(cellAt(tCol, c + ivec2(-4, -4))) < 23,
- isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 30) && cls(cellAt(tCol, c + ivec2(4, -4))) < 23);
+ int caps = int(cellAt(tTree, c).a * 255.0 + 0.5);
+ return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
}
// 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.
@@ -62,6 +61,8 @@ float bandRidge(vec2 w, vec2 lb, float dir) {
}
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
+${SM_SLOPE_HELPERS}
+
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
@@ -185,6 +186,8 @@ void main() {
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
+${SM_SLOPE_DRAW}
+
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
@@ -352,33 +355,6 @@ void main() {
}
}
- // Sloped belts and filters: a continuous running band over a triangular frame.
- if (nc >= 23 && nc <= 30) {
- int shape = (nc - 23) / 2;
- bool mesh = shape >= 2;
- float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
- float dir = ((nc - 23) % 2) == 1 ? 1.0 : -1.0;
- vec4 tag = cellAt(tTree, c);
- vec2 lb = mod(w, 4.0);
- if (tag.a > 0.4) lb.y += 4.0;
- float face = grade < 0.0 ? 4.0 - lb.x : lb.x;
- float depth = (lb.y - face) * 0.70710678;
- float inside = smoothstep(-px, px, depth);
- float axis = (w.x + grade * w.y) * 0.5;
- float rg = step(0.72, fract((axis - dir * uTime * 30.0) * 0.5));
- vec3 band = bandColor(vec2(axis, 0.5), rg);
- vec3 frame = vec3(0.18, 0.19, 0.21);
- float roller = 1.0 - smoothstep(0.32, 0.5, length(vec2(mod(axis, 2.0) - 1.0, depth - 0.95)));
- frame = mix(frame, vec3(0.50, 0.40, 0.31), roller);
- if (mesh) {
- float hole = step(0.48, fract(axis)) * step(0.35, fract(depth * 1.8));
- band = mix(band, dot(tag.rgb, vec3(1)) > 0.01 ? tag.rgb * 0.7 : vec3(0.28, 0.32, 0.33), hole);
- frame = mix(C, vec3(0.25, 0.27, 0.30), roller * 0.7);
- }
- vec3 belt = mix(band, frame, smoothstep(0.55, 0.75, depth));
- C = mix(C, belt, inside);
- }
-
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
diff --git a/src/render/world.js b/src/render/world.js
index cc28eb3..d8ff782 100644
--- a/src/render/world.js
+++ b/src/render/world.js
@@ -92,6 +92,7 @@ function drawWorld() {
if (blockP[b] === BP_TUBE && blockV[b] >= 4) diagonalJoins[b] = diagonalTubeJoins(bx, by, blockV[b]);
}
}
+ prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
const useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
const lighting = SET.lighting !== 'off' && !R.lens;
@@ -224,7 +225,9 @@ function drawWorld() {
}
}
// field parts drawn over contents
+ const sb = slopeBeltAt(i, x, y, sm);
let p = part[i];
+ if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
const db = diagonalTubeBlock(x, y, sm);
let diagonal = false;
if (db >= 0) {
@@ -281,8 +284,8 @@ function drawWorld() {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
- const sb = slopeBeltAt(i, x, y), slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
- if (slopeArt) {
+ const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
+ if (slopeArt && !sm) {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
@@ -307,9 +310,12 @@ function drawWorld() {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
- tpx[row + bx] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
+ tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
+ }
+ else if (m === BELTL || m === BELTR) {
+ cl = m === BELTL ? 7 : 8;
+ tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
}
- else if (m === BELTL || m === BELTR) cl = m === BELTL ? 7 : 8;
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
@@ -324,12 +330,14 @@ function drawWorld() {
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
}
- if (slopeArt) {
+ if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
- cl = 23 + (code - BP_BELT_UP) * 2 + dir;
- const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m[0];
- tpx[row + bx] = ((sb !== (y >> 2) * BW + (x >> 2) ? 0x80 : 0x40) << 24) |
- (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
+ const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
+ if (slopeArt) cl = slopeClass;
+ const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
+ tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
+ ((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
+ if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0);
@@ -380,12 +388,22 @@ function drawWorld() {
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
- for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) {
+ const slope = isSlopeBelt(code), joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : 0;
+ for (let dy = slope ? -4 : 0; dy < (slope ? 8 : BS); dy++) for (let dx = slope ? -4 : 0; dx < (slope ? 8 : BS); dx++) {
+ let sd = 0;
+ if (slope) {
+ const x = gbx * BS + dx, y = gby * BS + dy;
+ if (x < 0 || y < 0 || x >= W || y >= H) continue;
+ const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
+ if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
+ sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
+ if (sd > 0) continue;
+ }
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx, c = px[k];
- const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
+ const edge = slope ? sd > -0.8 : dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index 380104e..edd351a 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -12,8 +12,8 @@ const PIECE_ICONS = {
wall: '',
grate: '',
gate: '',
- belt: '',
- filter: '',
+ belt: '',
+ filter: '',
tube: '',
pipe: '',
pump: '',
diff --git a/src/ui/previews.js b/src/ui/previews.js
index f8768b2..f74820f 100644
--- a/src/ui/previews.js
+++ b/src/ui/previews.js
@@ -207,14 +207,24 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1,
if (stroke) ctx.stroke(); else ctx.fill();
ctx.restore(); return;
}
- if (isSlopeBelt(code) && isFilter(code)) {
+ if (isSlopeBelt(code)) {
+ const b = by * BW + bx, joins = bx >= 0 ? slopeBeltConnections(bx, by, code, planned) : 0;
+ const scale = w / BS, step = SET.look === 'smooth' ? 0.25 : 1;
+ const ox = bx * BS, oy = by * BS;
ctx.beginPath();
- if (beltGrade(code) < 0) {
- ctx.moveTo(x, y + h); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + h * 0.25); ctx.lineTo(x + w * 0.25, y + h);
- } else {
- ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w * 0.75, y + h); ctx.lineTo(x, y + h * 0.25);
+ for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
+ const wx = ox + xx, wy = oy + yy, ix = Math.floor(wx), iy = Math.floor(wy);
+ if (bx >= 0 && (ix < ox || ix >= ox + BS || iy < oy || iy >= oy + BS)) {
+ if (ix < 0 || ix >= W || iy < 0 || iy >= H) continue;
+ const i = iy * W + ix, n = (iy >> 2) * BW + (ix >> 2), next = planned && planned.get(n);
+ if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || (blockP[n] && !isSlopeBelt(blockP[n])) || (next && !isSlopeBelt(next[0]))) continue;
+ }
+ const sd = slopeBeltShape(wx + step / 2, wy + step / 2, b, joins, code);
+ if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
}
- ctx.closePath(); if (stroke) ctx.stroke(); else ctx.fill(); return;
+ if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
+ else ctx.fill();
+ return;
}
code = slopeShape(code);
if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; }
diff --git a/test/t_slope_belt_art.js b/test/t_slope_belt_art.js
new file mode 100644
index 0000000..3df4b94
--- /dev/null
+++ b/test/t_slope_belt_art.js
@@ -0,0 +1,100 @@
+// Drawing geometry against real parts, without changing transport.
+const fs = require('fs');
+const path = require('path');
+const { load, suite, flatWorld } = require('./harness');
+const { run } = load();
+const { check, done } = suite();
+run(fs.readFileSync(path.join(__dirname, '../src/render/slope-belts.js'), 'utf8'));
+flatWorld(run, 160, 120, 112);
+for (const code of ['BP_BELT_UP', 'BP_BELT_DOWN', 'BP_FILTER_UP', 'BP_FILTER_DOWN']) {
+ run(`
+ mat.fill(EMPTY); part.fill(0); blockP.fill(0); blockV.fill(0);
+ var artCode = ${code}, artGrade = beltGrade(artCode), artB = 12 * BW + 12;
+ for (let k = -2; k <= 2; k++) placeBlock(artCode, 1, 12 + k, 12 + artGrade * k, true);
+ prepareSlopeBelts(0, 0, W, H);
+ `);
+ check(`${code}: diagonal neighbours continue the band`, run('(slopeBeltJoins[artB] & 12) === 12'));
+ check(`${code}: corner seals use the same band`, run(`(() => {
+ const i = slopeSeal(artCode, 12, 12);
+ return i < 0 || slopeBeltAt(i, i % W, (i / W) | 0) >= 0;
+ })()`));
+ check(`${code}: band is as thick as a flat belt`, run(`(() => {
+ const g = artGrade, x = 50 - g * 1.85 * Math.SQRT1_2, y = 50 + 1.85 * Math.SQRT1_2;
+ return slopeBeltShape(x, y, artB) < -1.8 &&
+ slopeBeltShape(x - g * 1.7 * Math.SQRT1_2, y + 1.7 * Math.SQRT1_2, artB) < 0 &&
+ slopeBeltShape(x - g * 2.1 * Math.SQRT1_2, y + 2.1 * Math.SQRT1_2, artB) > 0;
+ })()`));
+ check(`${code}: air below the triangle gets the continuous band`, run(`(() => {
+ for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) {
+ const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
+ if (!blockP[own] && mat[i] === EMPTY && slopeBeltAt(i, x, y) >= 0) return true;
+ }
+ return false;
+ })()`));
+ run(`
+ for (let end = 0; end < 2; end++) {
+ const side = end ? 1 : -1;
+ removeBlock(12 + side, 12 + side * artGrade, true);
+ placeBlock(BP_BELT, 1, 12 + side, 12 + (end ? artGrade > 0 : artGrade < 0), true);
+ }
+ prepareSlopeBelts(0, 0, W, H);
+ `);
+ check(`${code}: both flat joints form elbows`, run('(slopeBeltJoins[artB] & 3) === 3'));
+ check(`${code}: adjoining flats do not round off at the joint`, run(`(() => {
+ const l = (12 + (artGrade < 0)) * BW + 11, r = (12 + (artGrade > 0)) * BW + 13;
+ return !(flatBeltCaps(l) & 2) && !(flatBeltCaps(r) & 1);
+ })()`));
+ check(`${code}: overhang never covers terrain, parts, machines or bars`, run(`(() => {
+ for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) {
+ const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
+ if (blockP[own] || mat[i] !== EMPTY || slopeBeltAt(i, x, y) < 0) continue;
+ mat[i] = STONE; const ground = slopeBeltAt(i, x, y); mat[i] = EMPTY;
+ part[i] = P_GRATE; const built = slopeBeltAt(i, x, y); part[i] = 0;
+ mach[i] = 1; const machine = slopeBeltAt(i, x, y); mach[i] = 0;
+ occ[i] = 1; const bar = slopeBeltAt(i, x, y); occ[i] = 0;
+ return ground < 0 && built < 0 && machine < 0 && bar < 0;
+ }
+ return false;
+ })()`));
+}
+check('free end rounds its corners and stops at the end', run(`(() => {
+ const b = artB, x = 48 - Math.SQRT2, y = 48 + Math.SQRT2;
+ blockP[b] = BP_BELT_DOWN;
+ return slopeBeltShape(x + 0.8, y + 0.8, b, 0) < 0 && slopeBeltShape(x - 1, y - 1, b, 0) > 0;
+})()`));
+check('flat belts still meet diagonal tube elbows without an end cap', run(`(() => {
+ blockP[artB - 1] = BP_BELT;
+ blockP[artB] = BP_TUBE; blockV[artB] = 4;
+ const joined = !(flatBeltCaps(artB - 1) & 2);
+ blockP[artB] = BP_BELT_DOWN;
+ return joined;
+})()`));
+check('unpowered rollers and studs stand still', run(`(() => {
+ slopeBeltJoins[artB] &= ~16;
+ for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++)
+ if (slopeBeltColor(artB, x, y, 0, 150, 170, 190) !== slopeBeltColor(artB, x, y, 20, 150, 170, 190)) return false;
+ return true;
+})()`));
+check('powered rollers and studs reverse with the belt', run(`(() => {
+ slopeBeltJoins[artB] |= 16;
+ let moves = false;
+ for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++) {
+ blockV[artB] = 1;
+ const a = slopeBeltColor(artB, x, y, 12, 150, 170, 190), stopped = slopeBeltColor(artB, x, y, 0, 150, 170, 190);
+ blockV[artB] = 0;
+ if (a !== slopeBeltColor(artB, x, y, -12, 150, 170, 190)) return false;
+ if (a !== stopped) moves = true;
+ }
+ return moves;
+})()`));
+check('drawing leaves the seed, cells and saved block data unchanged', run(`(() => {
+ const snapshot = () => JSON.stringify([RS, Array.from(mat), Array.from(part), Array.from(blockP), Array.from(blockV)]);
+ const before = snapshot();
+ prepareSlopeBelts(0, 0, W, H);
+ for (let y = 40; y < 64; y++) for (let x = 40; x < 72; x++) {
+ const b = slopeBeltAt(y * W + x, x, y);
+ if (b >= 0) slopeBeltColor(b, x, y, 12, 150, 170, 190);
+ }
+ return snapshot() === before;
+})()`));
+done();
diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js
index 3e12340..71caa67 100644
--- a/test/t_slope_belts.js
+++ b/test/t_slope_belts.js
@@ -138,7 +138,9 @@ fresh();
run('placeBlock(BP_BELT_UP,1,20,32,true);for(let l=1;l<=CARRY_DEPTH;l++)setCell((131-l)*W+80,SAND,20,0);tickStamp++;carryColumn(130*W+80,1,null);');
check('a slope carries the same full 96-grain column as a flat belt',run('Array.from({length:CARRY_DEPTH},(_,k)=>mat[(129-k)*W+81]===SAND).every(Boolean)'));
run(fs.readFileSync('src/render/slope-belts.js','utf8'));
-check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(80,131,0)!==c(82,129,0) && c(80,131,0)===c(82,129,4);})()'));
+// The renderer prepares joins and power first; stripe pitch is measured along the rotated band.
+run('prepareSlopeBelts(0,0,W,H);');
+check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(81,130,0)!==c(82,129,0) && c(81,130,0)===c(82,129,4);})()'));
run(fs.readFileSync('src/ui/selection.js','utf8'));
run(fs.readFileSync('src/ui/actions.js','utf8'));
run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wallMat:RSTONE};globalThis.hudDirty=false;globalThis.aimBlock=()=>({bx:25,by:15});globalThis.toolId=()=>'build';globalThis.playSfx=()=>{};");