Diagonals: streets, walls, fences, hedges and house walls at any angle
Deploy web Studio / deploy (push) Successful in 1m5s

Object frames turn by any angle (quarter turns stay exact, so scenes along
the axes render byte for byte as before); a box in a turned frame is a
prism with its own corners. Rotation is kept as given (normalised to
0..360), no longer snapped to quarter turns.

Depth sort: sprites whose plan outline is not their box carry their convex
outline; when boxes overlap, an outline edge that separates two sprites
orders them. A long diagonal wall sorts right against both sides.

Joints (line_meet, end_cut, cut_box in util.rhai): where a piece off the
axes is involved, corners are mitred and T-junctions slanted; fences put a
corner post or pillar in its own sprite and mitre stone bases; Y joints let
the lowest id run through. Streets: every end part is a cut e + k*w, so
side streets come in at any angle (slanted sidewalk gaps), two streets meet
in a mitre with seam patches; fixes a curve's outer sidewalk that was
skipped when its kerb and back lines had different point counts.

Studio: runs and drags keep to x/y or snap to 45 degree diagonals, Ctrl
gives any angle; a road run turning 45 degrees gets a 45 degree curve;
Rotation is a number box. Streets demo: a diagonal lane with a hedge.
MCP guidance, SCRIPTING/README/ROADMAP.

Tests: Rust 76, Studio and web checks green.
This commit is contained in:
grabowski committed 2026-10-25 13:22:59 +07:00
1 parent c91d63663b
commit 96d472625f
18 files changed
+1051 -178

No files matched your search

+78 -38
View File
@@ -46,8 +46,18 @@
function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); }
function qOf(rot) { return ((Math.round((rot || 0) / 90) % 4) + 4) % 4; }
function turnV(q, x, y) { return q === 0 ? [x, y] : q === 1 ? [-y, x] : q === 2 ? [-x, -y] : [y, -x]; }
function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); }
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; }
// is rot (degrees) a multiple of 90: does the object keep to the world axes?
function onAxes(rot) { var q = (rot || 0) / 90; return Math.abs(q - Math.round(q)) < 1e-5; }
// a plan vector turned by rot degrees (quarter turns stay exact, as in the engine)
function turnA(rot, x, y) {
if (onAxes(rot)) return turnV(qOf(rot), x, y);
var a = rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
return [c * x - s * y, s * x + c * y];
}
function localOf(o, x, y) { return turnA(-(o.rot || 0), x - o.x, y - o.y); }
function worldOf(o, u, v) { var d = turnA(o.rot || 0, u, v); return [o.x + d[0], o.y + d[1]]; }
// the angle of plan direction (dx, dy) in degrees, 4 decimals
function degOf(dx, dy) { return Math.round(Math.atan2(dy, dx) * 180 / Math.PI * 1e4) / 1e4; }
function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; }
function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
function clampProp(m, k, v) {
@@ -120,14 +130,21 @@
function linePieces(z) {
return S.doc.objects.filter(function (o) { var m = prefab(o.type); return !o.hidden && m && m.shape === "line" && Math.abs((o.z || 0) - z) < 0.5; }).map(function (o) {
var L = (+propOf(o, "w") || 0) / 2;
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0 };
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0, axis: onAxes(o.rot) };
});
}
// where a corner goes for pointer world point w: with `from` (the previous corner) the piece keeps
// to x or y and its far end snaps onto an end or the line of a piece it meets (or the run's own
// corners), else onto the grid; without, onto the nearest end, else onto a line, else the grid
function snapCorner(w, z, from, R) {
// to x or y (within 22.5 degrees) or a diagonal at 45, and its far end snaps onto an end or the
// line of a piece it meets (or the run's own corners), else onto the grid; `free` (Ctrl held): any
// angle, the end on a piece's end or line or a grid point; without `from`, onto the nearest end,
// else onto a line, else the grid
function snapCorner(w, z, from, R, free) {
var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i;
if (from && free) return snapCorner(w, z, null, R);
if (from) {
var k8 = Math.round(Math.atan2(w[1] - from[1], w[0] - from[0]) / (Math.PI / 4));
if (k8 % 2 !== 0) return snapDiagonal(w, from, R, [Math.cos(k8 * Math.PI / 4), Math.sin(k8 * Math.PI / 4)], pcs, own);
}
if (!from) {
pcs.forEach(function (p) {
[p.a, p.b].forEach(function (q) { var d = Math.hypot(q[0] - w[0], q[1] - w[1]); if (d < R && (!best || d < best.d)) best = { d: d, p: q }; });
@@ -157,18 +174,36 @@
out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
return out;
}
// the preview of one piece from p to q: its footprint, and its face when it has a height
// the far end of a diagonal piece from `from` along unit d: onto a piece's end on that line, a
// piece it crosses or the run's own corners within R, else where x lands on the grid
function snapDiagonal(w, from, R, d, pcs, own) {
var t0 = (w[0] - from[0]) * d[0] + (w[1] - from[1]) * d[1], cands = [], best = null;
var onRay = function (q) { var rx = q[0] - from[0], ry = q[1] - from[1]; if (Math.abs(rx * d[1] - ry * d[0]) < 0.01) cands.push(rx * d[0] + ry * d[1]); };
pcs.forEach(function (p) {
onRay(p.a); onRay(p.b);
// where the ray crosses the piece's centre line
var ex = p.b[0] - p.a[0], ey = p.b[1] - p.a[1], den = d[0] * ey - d[1] * ex;
if (Math.abs(den) < 1e-9) return;
var ax = p.a[0] - from[0], ay = p.a[1] - from[1], t = (ax * ey - ay * ex) / den, s = (ax * d[1] - ay * d[0]) / den;
if (s >= -1e-6 && s <= 1 + 1e-6) cands.push(t);
});
own.forEach(onRay);
cands.forEach(function (c) { var dd = Math.abs(c - t0); if (c > 0.05 && dd < R && (!best || dd < best.d)) best = { d: dd, t: c }; });
var t = best ? best.t : (snap(from[0] + t0 * d[0]) - from[0]) / d[0];
return [r3(from[0] + t * d[0]), r3(from[1] + t * d[1])];
}
// the preview of one piece from p to q (at any angle): its footprint, and its face when it has a height
function pieceGhost(m, p, q, z) {
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, ax = Math.abs(q[1] - p[1]) < 1e-9;
var x0 = Math.min(p[0], q[0]), x1 = Math.max(p[0], q[0]), y0 = Math.min(p[1], q[1]), y1 = Math.max(p[1], q[1]);
var out = [{ pts: ax ? rectPts(x0, y0 - th / 2, x1, y0 + th / 2, z) : rectPts(x0 - th / 2, y0, x0 + th / 2, y1, z) }];
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0;
var L = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1, nx = -(q[1] - p[1]) / L * th / 2, ny = (q[0] - p[0]) / L * th / 2;
var out = [{ pts: [[p[0] + nx, p[1] + ny, z], [q[0] + nx, q[1] + ny, z], [q[0] - nx, q[1] - ny, z], [p[0] - nx, p[1] - ny, z]] }];
if (h > 0) out.push({ pts: planePts(p, q, z, z + h), cls: "st-plane" });
return out;
}
function cornerMark(p, z) { return { pts: rectPts(p[0] - 0.18, p[1] - 0.18, p[0] + 0.18, p[1] + 0.18, z), cls: "st-plane" }; }
function runLength(pts) { var L = 0; for (var i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return L; }
function drawChain(e) {
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e)), polys = [], i;
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e), freeAngle(c.m, e)), polys = [], i;
for (i = 1; i < c.pts.length; i++) Array.prototype.push.apply(polys, pieceGhost(c.m, c.pts[i - 1], c.pts[i], c.z));
if (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05) Array.prototype.push.apply(polys, pieceGhost(c.m, last, q, c.z));
polys.push(cornerMark(c.pts[0], c.z));
@@ -176,14 +211,20 @@
var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0);
ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" +
(c.m.corner ? ", corners rounded" : "") + " · " +
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + " · Backspace undoes a corner · Esc cancels");
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") +
(c.m.directed ? "" : " · diagonals at 45°, Ctrl: any angle") + " · Backspace undoes a corner · Esc cancels");
}
// the turn of a line piece from p to q: along x or y; directed lines (conveyors) point from p to q
// the turn of a line piece from p to q: along x or y, else its angle (0 to 180); directed lines
// (conveyors) point from p to q
function lineRot(m, p, q) {
var ax = Math.abs(q[1] - p[1]) <= Math.abs(q[0] - p[0]);
var dx = q[0] - p[0], dy = q[1] - p[1];
if (Math.abs(dx) > 1e-9 && Math.abs(dy) > 1e-9) { var a = degOf(dx, dy); return m.directed ? (a + 360) % 360 : (a % 180 + 180) % 180; }
var ax = Math.abs(dy) <= Math.abs(dx);
if (!m.directed) return ax ? 0 : 90;
return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270);
}
// Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes
function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); }
// the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default
// radius, less where the pieces next to a corner are short): [segments [p, q]], [curve add ops]
function roundRun(m, pts, z) {
@@ -194,26 +235,27 @@
var closed = n > 2 && Math.hypot(segs[n - 1][1][0] - segs[0][0][0], segs[n - 1][1][1] - segs[0][0][1]) < 1e-6;
var len = segs.map(function (s) { return Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]); });
var dir = segs.map(function (s, k) { return [(s[1][0] - s[0][0]) / len[k], (s[1][1] - s[0][1]) / len[k]]; });
// turn at the start of segment k (from segment k - 1), 0 where the run goes straight on
// turn (radians, + left) at the start of segment k (from segment k - 1), 0 where the run goes on
var turn = segs.map(function (s, k) {
if (k === 0 && !closed) return 0;
var a = dir[(k + n - 1) % n], b = dir[k], cr = a[0] * b[1] - a[1] * b[0];
return Math.abs(cr) > 0.5 && a[0] * b[0] + a[1] * b[1] < 0.5 ? cr : 0;
var a = dir[(k + n - 1) % n], b = dir[k], th = Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]);
return Math.abs(th) > 5 * Math.PI / 180 && Math.abs(th) < 175 * Math.PI / 180 ? th : 0;
});
var R0 = +defOf(cm, "r") || 10, rmin = Math.max(+((cm.props || []).filter(function (d) { return d.key === "r"; })[0] || {}).min || 0, (+defOf(m, "d") || 0) / 2 + 0.5);
for (var k = 0; k < n; k++) {
if (!turn[k]) continue;
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed);
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1), len[k] * (nextTurn ? 0.5 : 1));
// the curve meets the legs r * tan(turn / 2) from the corner
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed), tn = Math.tan(Math.abs(turn[k]) / 2);
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1) / tn, len[k] * (nextTurn ? 0.5 : 1) / tn), T = r * tn;
if (r < rmin) continue;
var P = segs[k][0], da = dir[a], db = dir[k];
var s0 = [P[0] - da[0] * r, P[1] - da[1] * r];
var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (Math.round(Math.atan2(da[1], da[0]) * 2 / Math.PI) * 90 + 360) % 360,
props: { r: r3(r), angle: 90, turn: turn[k] > 0 ? "left" : "right" } };
var s0 = [P[0] - da[0] * T, P[1] - da[1] * T], deg = Math.abs(turn[k]) * 180 / Math.PI;
var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (lineRot({ directed: true }, [0, 0], da) + 360) % 360,
props: { r: r3(r), angle: Math.abs(deg - Math.round(deg)) < 1e-6 ? Math.round(deg) : r3(deg), turn: turn[k] > 0 ? "left" : "right" } };
if (z) add.z = z;
curves.push(add);
segs[a][1] = s0;
segs[k][0] = [P[0] + db[0] * r, P[1] + db[1] * r];
segs[k][0] = [P[0] + db[0] * T, P[1] + db[1] * T];
}
return { segs: segs, curves: curves };
}
@@ -321,7 +363,7 @@
down: function (e, hit) {
if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e)) : [snap(a[0]), snap(a[1])];
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])];
tool = { m: pm, z: z, a: a, b: null };
return true;
}
@@ -406,14 +448,13 @@
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
ghost(sm ? mountGhost(m, sm) : null);
}
// a drag while placing: "line" prefabs run from the press to the pointer along the main axis,
// "rect" prefabs (and rooms) fill the rectangle
// a drag while placing: "line" prefabs run from the press to the pointer (snapped to x, y or a
// diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle
function toolGeom(t) {
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
if (t.m.shape === "line") {
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy);
return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25,
rot: lineRot(t.m, a, ax ? [b[0], a[1]] : [a[0], b[1]]) };
var L = Math.hypot(dx, dy);
return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) };
}
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2,
x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 };
@@ -421,14 +462,13 @@
function drawTool(e) {
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
if (chain) { drawChain(e); return; }
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e)) : [snap(w[0]), snap(w[1])];
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
if (m.shape !== "line" && m.shape !== "rect") return;
var g = toolGeom(t);
if (g.line) {
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2;
polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) });
if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" });
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + (m.directed ? ", running from where you started" : "") + " · release to place");
polys = pieceGhost(m, g.a, g.b, z);
var tilt = onAxes(g.rot) ? "" : ", " + (+g.rot.toFixed(1)) + "°";
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + tilt + (m.directed ? ", running from where you started" : "") + " · release to place" + (m.directed ? "" : " · Ctrl: any angle"));
return;
}
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
@@ -446,7 +486,7 @@
if (m.shape === "line") {
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
if (chain) {
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e));
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, e));
if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 0.05) return;
chain.pts.push(q);
if (chain.pts.length > 3 && Math.hypot(q[0] - chain.pts[0][0], q[1] - chain.pts[0][1]) < 1e-6) finishChain(keep);
@@ -485,7 +525,7 @@
[[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) {
var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = [];
walls.forEach(function (w) {
if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
if (!onAxes(w.rot) || (qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
cov.push([c - L, c + L]);
});
@@ -667,7 +707,7 @@
var o = obj(id); if (!o) return;
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o);
// a copy of a window or door goes further along its wall
if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
if (h) { var v = turnA(h.host.rot || 0, (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }]));
}
function moveWith(id, dx, dy, carry, quiet) {
@@ -824,7 +864,7 @@
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
'<h2>Placement</h2><div class="st-form">' +
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><input type="number" step="any" data-f="rot" value="' + (+(o.rot || 0).toFixed(4)) + '" title="degrees, counter-clockwise from east; any angle">°<button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
'<h2>Label and tooltip</h2><div class="st-form">' +