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.
1018 lines
65 KiB
JavaScript
1018 lines
65 KiB
JavaScript
/* isokit Studio: the scene editor. Talks to `isokit studio` over /api, or to
|
||
* window.IsoKitBackend when a page provides one (the web Studio runs the
|
||
* engine as WASM in the page and answers the same requests). */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
// { api(path, body) -> json | Promise, source(meta), agentBox(meta), preview(), saveTitle }
|
||
var B = window.IsoKitBackend || null;
|
||
var S = { meta: null, doc: null, version: null, sel: null, selIds: [], placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
||
var ICON_DEFAULT = "▪";
|
||
|
||
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||
function api(path, body) {
|
||
if (B) return Promise.resolve().then(function () { return B.api(path, body); });
|
||
var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||
return fetch(path, o).then(function (r) { return r.json(); });
|
||
}
|
||
var toastT = 0;
|
||
function toast(msg, bad) {
|
||
var t = $("toast");
|
||
t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad);
|
||
clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600);
|
||
}
|
||
function snapStep() { return parseFloat($("snap").value) || 0; }
|
||
function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; }
|
||
function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; }
|
||
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
|
||
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
|
||
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
|
||
function r3(v) { return Math.round(v * 1000) / 1000; }
|
||
|
||
// ------------------------------------------------------------ storeys, frames, mounts
|
||
// The same rules as the scripts (stdlib/house.rhai, mount_of in api.rs).
|
||
var ROOM = { type: "@room", label: "Room", category: "House", icon: "⬚", shape: "rect", storey: true, props: [],
|
||
help: "Drag a rectangle: a floor with house walls around it. Walls already standing along an edge are not doubled." };
|
||
function pmeta(kind) { return kind === ROOM.type ? ROOM : prefab(kind); }
|
||
function floorOf(lv) { if (lv === "gf") return 0; var m = /^(\d+)f$/.exec(lv || ""); return m ? +m[1] : -1; }
|
||
function storeyH() {
|
||
var v = (S.doc.cfg || {}).storey_h;
|
||
if (typeof v !== "number") { var d = (S.meta.params || []).filter(function (p) { return p.name === "storey_h"; })[0]; v = d ? +d.default : 3; }
|
||
return v > 0 ? v : 3;
|
||
}
|
||
// height new objects go to: the floor of the storey the view shows, for prefabs that live on storeys
|
||
function placeZ(m) { if (!m || !m.storey) return 0; var k = floorOf(S.view.getLevel()); return k > 0 ? r3(k * storeyH()) : 0; }
|
||
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]; }
|
||
// 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) {
|
||
var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0];
|
||
if (d && d.min !== undefined) v = Math.max(d.min, v);
|
||
if (d && d.max !== undefined) v = Math.min(d.max, v);
|
||
return r3(v);
|
||
}
|
||
// the host an object sits on (a window's wall): nearest centre line within half its thickness + 0.3 m
|
||
function hostOf(o) {
|
||
var m = prefab(o.type), hosts = (m && m.mount) || [], best = null;
|
||
if (!hosts.length) return null;
|
||
S.doc.objects.forEach(function (h) {
|
||
if (h.hidden || h.id === o.id || hosts.indexOf(h.type) < 0 || Math.abs((o.z || 0) - (h.z || 0)) > 0.5) return;
|
||
var l = localOf(h, o.x, o.y), half = (+propOf(h, "w") || 1) / 2, lim = (+propOf(h, "t") || 0) / 2 + 0.3;
|
||
if (Math.abs(l[0]) <= half + 1e-6 && Math.abs(l[1]) <= lim + 1e-6 && (!best || Math.abs(l[1]) < Math.abs(best.v) - 1e-9)) best = { host: h, u: l[0], v: l[1] };
|
||
});
|
||
return best;
|
||
}
|
||
function isHost(kind) { return (S.meta.catalog || []).some(function (m) { return (m.mount || []).indexOf(kind) >= 0; }); }
|
||
// the objects sitting on h; they move, turn, copy and delete with it
|
||
function mountedOn(h) {
|
||
if (!isHost(h.type)) return [];
|
||
return S.doc.objects.filter(function (o) { var r = o.id !== h.id && hostOf(o); return r && r.host.id === h.id; });
|
||
}
|
||
// where a window or door goes when dropped at w: onto the nearest host's centre line (within 1.5 m),
|
||
// along it by the snap step, kept inside the host's length
|
||
function snapMount(m, w, z, selfId, width) {
|
||
var best = null;
|
||
S.doc.objects.forEach(function (h) {
|
||
if (h.hidden || h.id === selfId || (m.mount || []).indexOf(h.type) < 0 || Math.abs((h.z || 0) - z) > 0.5) return;
|
||
var l = localOf(h, w[0], w[1]), half = (+propOf(h, "w") || 1) / 2, u = Math.max(-half, Math.min(half, l[0])), d = Math.hypot(l[0] - u, l[1]);
|
||
if (d < 1.5 && (!best || d < best.d)) best = { host: h, u: u, side: l[1] < 0 ? -1 : 1, d: d, half: half };
|
||
});
|
||
if (!best) return null;
|
||
var s = snapStep(), u = s ? Math.round(best.u / s) * s : best.u, hw = (+width || 1) / 2;
|
||
u = hw >= best.half ? 0 : Math.max(-best.half + hw, Math.min(best.half - hw, u));
|
||
var p = worldOf(best.host, u, 0);
|
||
return { x: r3(p[0]), y: r3(p[1]), z: best.host.z || 0, host: best.host, u: u, side: best.side, hw: hw };
|
||
}
|
||
|
||
// ------------------------------------------------------------ ghosts: previews drawn over the view
|
||
function ghost(polys, text) {
|
||
var svg = S.view.svg, g = svg.querySelector(".st-ghosts");
|
||
if (!polys || !polys.length) { if (g) g.parentNode.removeChild(g); return; }
|
||
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-ghosts"); svg.appendChild(g); }
|
||
var rot = S.view.getRotation();
|
||
g.innerHTML = polys.map(function (p) {
|
||
return '<path class="st-ghost' + (p.cls ? " " + p.cls : "") + '" d="' + p.pts.map(function (q, i) {
|
||
var s = IsoKit.project(rot, q[0], q[1], q[2] || 0);
|
||
return (i ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1);
|
||
}).join("") + 'Z"/>';
|
||
}).join("");
|
||
if (text !== undefined) { var h = $("hint"); h.textContent = text; h.hidden = false; }
|
||
}
|
||
function rectPts(x0, y0, x1, y1, z) { return [[x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]]; }
|
||
// an upright quad from a to b (plan points), z0 to z1
|
||
function planePts(a, b, z0, z1) { return [[a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1]]; }
|
||
function mountGhost(m, sm, o) {
|
||
var z0 = +(o ? propOf(o, "sill") : defOf(m, "sill")) || 0, h = +(o ? propOf(o, "h") : defOf(m, "h")) || 2;
|
||
return [{ pts: planePts(worldOf(sm.host, sm.u - sm.hw, 0), worldOf(sm.host, sm.u + sm.hw, 0), sm.z + z0, sm.z + z0 + h), cls: "st-plane" }];
|
||
}
|
||
|
||
// ------------------------------------------------------------ drawing runs of walls and fences
|
||
// Line prefabs (walls, fences, hedges, roads) are drawn corner by corner. Corners snap onto the
|
||
// ends and lines of the pieces already there, so the scripts join them (line_joint in util.rhai).
|
||
var chain = null; // a run being drawn: { m, z, pts: [[x, y], ...] }
|
||
// metres per screen pixel around a pointer event
|
||
function pxM(e) { var a = S.view.toWorld(e.clientX, e.clientY, 0), b = S.view.toWorld(e.clientX + 10, e.clientY, 0); return Math.hypot(b[0] - a[0], b[1] - a[1]) / 10; }
|
||
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, 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 (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 }; });
|
||
});
|
||
if (best) return [r3(best.p[0]), r3(best.p[1])];
|
||
pcs.forEach(function (p) {
|
||
var l = localOf(p.o, w[0], w[1]), L = (+propOf(p.o, "w") || 0) / 2;
|
||
if (Math.abs(l[0]) <= L && Math.abs(l[1]) < R && (!best || Math.abs(l[1]) < best.d)) best = { d: Math.abs(l[1]), p: worldOf(p.o, Math.max(-L, Math.min(L, snap(l[0]))), 0) };
|
||
});
|
||
return best ? [r3(best.p[0]), r3(best.p[1])] : [snap(w[0]), snap(w[1])];
|
||
}
|
||
var ax = Math.abs(w[0] - from[0]) >= Math.abs(w[1] - from[1]), k = ax ? 0 : 1, lk = 1 - k;
|
||
var f = w[k], fix = from[lk], cands = [];
|
||
pcs.forEach(function (p) {
|
||
if (p.ax === ax) {
|
||
// in line with this piece: its ends
|
||
if (Math.abs(p.a[lk] - fix) < 0.01) { cands.push(p.a[k]); cands.push(p.b[k]); }
|
||
} else {
|
||
// across this piece: its centre line, where it spans our line
|
||
var lo = Math.min(p.a[lk], p.b[lk]), hi = Math.max(p.a[lk], p.b[lk]);
|
||
if (fix >= lo - 1e-6 && fix <= hi + 1e-6) cands.push(p.a[k]);
|
||
}
|
||
});
|
||
for (i = 0; i < own.length; i++) if (Math.abs(own[i][lk] - fix) < 0.01) cands.push(own[i][k]);
|
||
cands.forEach(function (c) { var d = Math.abs(c - f); if (Math.abs(c - from[k]) > 0.05 && d < R && (!best || d < best.d)) best = { d: d, c: c }; });
|
||
var out = [0, 0];
|
||
out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
|
||
return out;
|
||
}
|
||
// 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;
|
||
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), 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));
|
||
var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6;
|
||
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") +
|
||
(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, else its angle (0 to 180); directed lines
|
||
// (conveyors) point from p to q
|
||
function lineRot(m, p, q) {
|
||
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) {
|
||
var segs = [], curves = [], cm = m.corner && prefab(m.corner), i;
|
||
for (i = 1; i < pts.length; i++) if (Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) >= 0.05) segs.push([pts[i - 1].slice(), pts[i].slice()]);
|
||
var n = segs.length;
|
||
if (!cm || n < 2) return { segs: segs, curves: curves };
|
||
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 (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], 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;
|
||
// 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] * 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] * T, P[1] + db[1] * T];
|
||
}
|
||
return { segs: segs, curves: curves };
|
||
}
|
||
// the pieces of a run as add ops (pieces longer than the prefab allows are split; corners rounded)
|
||
function runOps(m, pts, z) {
|
||
var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9, rr = roundRun(m, pts, z);
|
||
for (var i = 0; i < rr.segs.length; i++) {
|
||
var p = rr.segs[i][0], q = rr.segs[i][1], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9);
|
||
if (L < 0.05) continue;
|
||
for (var j = 0; j < n; j++) {
|
||
var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: lineRot(m, p, q), props: { w: clampProp(m, "w", L / n) } };
|
||
if (z) add.z = z;
|
||
ops.push(add);
|
||
}
|
||
}
|
||
return ops.concat(rr.curves);
|
||
}
|
||
function finishChain(keep) {
|
||
var c = chain; chain = null; ghost(null);
|
||
if (!c) return;
|
||
var ops = runOps(c.m, c.pts, c.z);
|
||
if (ops.length) {
|
||
var L = runLength(c.pts);
|
||
opList(ops).then(function (r) { if (r) toast(c.m.label + ": " + ops.length + " piece" + (ops.length === 1 ? "" : "s") + ", " + L.toFixed(1) + " m · Ctrl+Z takes the run back"); });
|
||
}
|
||
if (keep) setPlacing(c.m.type); else setPlacing(null);
|
||
}
|
||
|
||
// ------------------------------------------------------------ server state
|
||
function takeState(r) {
|
||
if (r.doc) S.doc = r.doc;
|
||
if (r.version !== undefined) S.version = r.version;
|
||
if (r.can_undo !== undefined) { S.canUndo = r.can_undo; S.canRedo = r.can_redo; }
|
||
$("undo").disabled = !S.canUndo; $("redo").disabled = !S.canRedo;
|
||
}
|
||
function op(o, quiet) {
|
||
$("save").textContent = "Saving…"; $("save").classList.remove("st-err");
|
||
return api("/api/op", o).then(function (r) {
|
||
if (r.error) {
|
||
$("save").textContent = "Not saved"; $("save").classList.add("st-err");
|
||
toast(r.error, true);
|
||
return null;
|
||
}
|
||
takeState(r);
|
||
$("save").textContent = "Saved";
|
||
var adds = (o.ops || [o]).some(function (x) { return x.op === "add" || x.op === "duplicate"; });
|
||
if (r.id && adds) { S.sel = r.id; S.selIds = [r.id]; }
|
||
pruneSel();
|
||
S.view.refresh();
|
||
renderLists(); if (!quiet) renderRight();
|
||
return r;
|
||
}, function (e) { $("save").textContent = B ? "Not saved" : "Offline"; $("save").classList.add("st-err"); toast((B ? "Could not save: " : "Studio server not reachable: ") + e, true); });
|
||
}
|
||
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
|
||
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||
function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||
function afterExternal() {
|
||
pruneSel();
|
||
S.view.refresh(); renderLists(); renderRight();
|
||
}
|
||
function reloadAll(why) {
|
||
return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||
var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels);
|
||
S.meta = rs[0]; takeState(rs[1]);
|
||
if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); }
|
||
renderPalette(); afterExternal();
|
||
if (why) toast(why);
|
||
});
|
||
}
|
||
// another editor or an agent changed the files: follow along
|
||
function poll() {
|
||
api("/api/version").then(function (r) {
|
||
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll(r.why || "Scene updated outside the Studio (agent or file edit)"); }
|
||
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
|
||
}
|
||
|
||
// ------------------------------------------------------------ view
|
||
var drag = null, tool = null, hov = null, hovRaf = 0, hovText = "", hovLate = 0;
|
||
function showCursor() { hovLate = 0; var el = $("cursor"); if (el.textContent !== hovText) el.textContent = hovText; }
|
||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||
function mountView() {
|
||
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
||
S.view = IsoKit.mount($("view"), src, {
|
||
note: false, noSelect: true, wheelZoom: true,
|
||
onRender: function (d) {
|
||
if (!d || d.error) { $("stats").textContent = "script error"; return; }
|
||
S.boxes = d.boxes || {}; S.warnings = d.warnings || [];
|
||
$("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms";
|
||
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
||
var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
|
||
},
|
||
// once per frame at most: pointers send moves faster than that; the x/y readout about ten
|
||
// times a second (each new text costs a layout and a paint of the page)
|
||
onHover: function (w, e) {
|
||
hov = { w: w, e: e };
|
||
if (!hovRaf) hovRaf = requestAnimationFrame(function () {
|
||
var h = hov; hovRaf = 0;
|
||
if (h.w) { hovText = "x " + h.w[0].toFixed(1) + " y " + h.w[1].toFixed(1); if (!hovLate) hovLate = setTimeout(showCursor, 100); }
|
||
if (S.placing && !tool && h.e) hoverPlacing(h.e);
|
||
});
|
||
},
|
||
onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); },
|
||
onKey: keyHandler,
|
||
interact: {
|
||
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), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])];
|
||
tool = { m: pm, z: z, a: a, b: null };
|
||
return true;
|
||
}
|
||
// Ctrl / Shift: a click adds or takes out an object, a drag draws a box around more
|
||
if (e.shiftKey || e.ctrlKey || e.metaKey) { drag = { box: true, x0: e.clientX, y0: e.clientY, hit: hit.obj }; return true; }
|
||
if (!hit.obj) return false;
|
||
var o = obj(hit.obj), ids = selIds(), group = !!o && ids.length > 1 && ids.indexOf(o.id) >= 0;
|
||
if (group) {
|
||
// a drag on one of several selected objects moves them all
|
||
var set = moveSet(ids);
|
||
if (!set.objs.length) return false;
|
||
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, group: set, carry: [] };
|
||
return true;
|
||
}
|
||
if (!o || o.locked) return false;
|
||
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
|
||
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
|
||
rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) };
|
||
return true;
|
||
},
|
||
move: function (e, mv) {
|
||
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
|
||
if (drag && drag.box) { drawBand(drag, e); return; }
|
||
if (!drag || !mv.world || !drag.w0) return;
|
||
if (!drag.moved) {
|
||
drag.moved = true; drag.els = [];
|
||
if (!drag.group) select(drag.id, true);
|
||
// what sits on the objects (a wall's windows) moves along
|
||
var moving = drag.group ? drag.group.objs.concat(drag.group.carry) : [obj(drag.id)].concat(drag.carry);
|
||
moving.forEach(function (x) { if (x) Array.prototype.push.apply(drag.els, moveEls(x.id)); });
|
||
}
|
||
var tx = drag.ox + mv.world[0] - drag.w0[0], ty = drag.oy + mv.world[1] - drag.w0[1];
|
||
var dx = snap(tx) - drag.ox, dy = snap(ty) - drag.oy;
|
||
drag.snapTo = null;
|
||
if (drag.mount) {
|
||
// windows and doors snap onto the nearest wall
|
||
var o = obj(drag.id), sm = o && snapMount(drag.m, [tx, ty], o.z || 0, o.id, propOf(o, "w"));
|
||
if (sm) { drag.snapTo = sm; dx = sm.x - drag.ox; dy = sm.y - drag.oy; ghost(mountGhost(drag.m, sm, o)); } else ghost(null);
|
||
}
|
||
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
|
||
drag.dx = dx; drag.dy = dy;
|
||
drag.els.forEach(function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
|
||
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
|
||
},
|
||
up: function (e, mv) {
|
||
if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; }
|
||
var d = drag; drag = null; ghost(null);
|
||
if (!d) return;
|
||
if (d.box) { endBand(d, e, mv); return; }
|
||
if (mv.cancel) { if (d.moved) S.view.refresh(); return; }
|
||
if (!d.moved) { clickObj(d.id, e); return; }
|
||
if (d.snapTo) {
|
||
// on a wall: keep the turn relative to the wall it came from (door swing), else open to the side dropped on
|
||
var sm = d.snapTo, rel = d.rel !== null ? d.rel * 90 : (sm.side < 0 ? 180 : 0);
|
||
update(d.id, { x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + rel) % 360 });
|
||
} else if ((d.dx || d.dy) && d.group) moveMany(d.group, d.dx, d.dy);
|
||
else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
|
||
else S.view.refresh();
|
||
},
|
||
cancel: function () { if (drag && drag.els) S.view.refresh(); if (drag && drag.box) band(null); drag = null; tool = null; ghost(null); },
|
||
dblclick: function (e) { if (!chain) return false; finishChain(e.shiftKey); return true; }
|
||
}
|
||
});
|
||
window.__studio = { S: S, view: S.view, op: op, select: select, selectMany: selectMany, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
|
||
}
|
||
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
||
function remount() {
|
||
var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null;
|
||
if (S.view) S.view.destroy();
|
||
mountView();
|
||
if (lv) S.view.setLevel(lv);
|
||
if (rot !== null) S.view.setRotation(rot);
|
||
}
|
||
|
||
// ------------------------------------------------------------ actions
|
||
// the hover preview while placing a window or door: where on which wall it goes
|
||
function hoverPlacing(e) {
|
||
var m = pmeta(S.placing);
|
||
if (chain) { drawChain(e); return; }
|
||
if (m && m.shape === "line") { var z0 = placeZ(m); ghost([cornerMark(snapCorner(worldAt(e, z0), z0, null, 12 * pxM(e)), z0)]); return; }
|
||
if (!m || !m.mount || !m.mount.length) return;
|
||
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 (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 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 };
|
||
}
|
||
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), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
|
||
if (m.shape !== "line" && m.shape !== "rect") return;
|
||
var g = toolGeom(t);
|
||
if (g.line) {
|
||
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) });
|
||
if (m === ROOM) {
|
||
var wh = +defOf(prefab("house_wall"), "h") || 2.8;
|
||
[[g.x0, g.y0, g.x1, g.y0], [g.x0, g.y1, g.x1, g.y1], [g.x0, g.y0, g.x0, g.y1], [g.x1, g.y0, g.x1, g.y1]].forEach(function (s) {
|
||
polys.push({ pts: planePts([s[0], s[1]], [s[2], s[3]], z, z + wh), cls: "st-plane" });
|
||
});
|
||
}
|
||
ghost(polys, m.label + " " + g.w.toFixed(2) + " × " + g.d.toFixed(2) + " m · release to place");
|
||
}
|
||
function finishTool(t, e) {
|
||
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
|
||
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
|
||
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), 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);
|
||
else drawChain(e);
|
||
return;
|
||
}
|
||
if (!g || !g.ok) { chain = { m: m, z: t.z, pts: [t.a] }; drawChain(e); return; }
|
||
var ops = runOps(m, [t.a, t.b], t.z);
|
||
opList(ops).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to draw several")); });
|
||
if (!keep) setPlacing(null);
|
||
return;
|
||
}
|
||
if (m === ROOM) {
|
||
if (!g || !g.ok) { toast("Drag a rectangle on the map to draw a room"); setPlacing(m.type); return; }
|
||
var fm = prefab("floor"), ops = roomWalls(g.x0, g.y0, g.x1, g.y1, t.z);
|
||
ops.push({ op: "add", type: "floor", x: r3(g.x), y: r3(g.y), z: t.z, props: { w: clampProp(fm, "w", g.w), d: clampProp(fm, "d", g.d) } });
|
||
op({ ops: ops }).then(function (r) { if (r) toast("Room " + g.w.toFixed(1) + " × " + g.d.toFixed(1) + " m: a floor and " + (ops.length - 1) + " wall" + (ops.length === 2 ? "" : "s")); });
|
||
if (!keep) setPlacing(null);
|
||
return;
|
||
}
|
||
if (g && g.ok && m.shape === "line") add = { type: m.type, x: r3(g.x), y: r3(g.y), rot: g.rot, props: { w: clampProp(m, "w", g.len) } };
|
||
else if (g && g.ok && m.shape === "rect") add = { type: m.type, x: r3(g.x), y: r3(g.y), props: { w: clampProp(m, "w", g.w), d: clampProp(m, "d", g.d) } };
|
||
else if (m.mount && m.mount.length) {
|
||
// onto the nearest wall, opening towards the side clicked on; on no wall it stands alone
|
||
var sm = snapMount(m, at, t.z, null, defOf(m, "w"));
|
||
add = sm ? { type: m.type, x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + (sm.side < 0 ? 180 : 0)) % 360 } : { type: m.type, x: p[0], y: p[1] };
|
||
} else add = { type: m.type, x: p[0], y: p[1] };
|
||
add.op = "add";
|
||
if (add.z === undefined && t.z) add.z = t.z;
|
||
op(add).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to place several")); });
|
||
if (!keep) setPlacing(null);
|
||
}
|
||
// walls around a room's rectangle, leaving out the stretches a wall already stands on
|
||
function roomWalls(x0, y0, x1, y1, z) {
|
||
var walls = S.doc.objects.filter(function (o) { return o.type === "house_wall" && !o.hidden && Math.abs((o.z || 0) - z) < 0.5; }), out = [];
|
||
[[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 (!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]);
|
||
});
|
||
cov.sort(function (a, b) { return a[0] - b[0]; });
|
||
var segs = [], cur = lo;
|
||
cov.forEach(function (c) { if (c[0] > cur + 0.05) segs.push([cur, Math.min(c[0], hi)]); cur = Math.max(cur, c[1]); });
|
||
if (hi > cur + 0.05) segs.push([cur, hi]);
|
||
segs.forEach(function (s) {
|
||
if (s[1] - s[0] < 0.2) return;
|
||
var mid = r3((s[0] + s[1]) / 2);
|
||
out.push({ op: "add", type: "house_wall", x: ax ? mid : line, y: ax ? line : mid, z: z, rot: ax ? 0 : 90, props: { w: r3(s[1] - s[0]) } });
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
function setPlacing(kind) {
|
||
S.placing = kind; tool = null; chain = null;
|
||
if (S.view) ghost(null);
|
||
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
||
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
||
var h = $("hint");
|
||
if (kind) {
|
||
var m = pmeta(kind), lab = m ? m.label : kind;
|
||
h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop"
|
||
: m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop"
|
||
: m && m.shape === "line" ? "Drag along the map to draw one " + lab + ", or click corner after corner for a run (double-click or Enter ends it, the first corner closes it); ends snap onto walls and fences · Esc to stop"
|
||
: m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
|
||
: "Click on the map to place: " + lab + " · Esc to stop";
|
||
h.hidden = false;
|
||
} else h.hidden = true;
|
||
}
|
||
// ------------------------------------------------------------ selection
|
||
// S.sel: the object picked last (its form shows), S.selIds: everything selected (S.sel among them)
|
||
function selIds() { return (S.selIds || []).filter(function (id) { return obj(id); }); }
|
||
function pruneSel() {
|
||
S.selIds = selIds();
|
||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||
if (S.sel && S.selIds.indexOf(S.sel) < 0) S.selIds.push(S.sel);
|
||
if (!S.sel && S.selIds.length) S.sel = S.selIds[S.selIds.length - 1];
|
||
}
|
||
function showSel(quiet) {
|
||
var ids = selIds();
|
||
S.view.selectObj(ids.length > 1 ? ids : S.sel);
|
||
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); });
|
||
if (!quiet) renderRight();
|
||
}
|
||
function select(id, quiet) {
|
||
if (id && !obj(id)) id = null;
|
||
S.sel = id; S.selIds = id ? [id] : [];
|
||
showSel(quiet);
|
||
}
|
||
function selectMany(ids, primary, quiet) {
|
||
var seen = {};
|
||
ids = ids.filter(function (id) { if (seen[id] || !obj(id)) return false; seen[id] = 1; return true; });
|
||
if (ids.length < 2) { select(ids[0] || null, quiet); return; }
|
||
S.selIds = ids; S.sel = primary && ids.indexOf(primary) >= 0 ? primary : ids[ids.length - 1];
|
||
showSel(quiet);
|
||
}
|
||
function toggleSel(id) {
|
||
var ids = selIds(), i = ids.indexOf(id);
|
||
if (i >= 0) { ids.splice(i, 1); selectMany(ids, S.sel === id ? ids[ids.length - 1] : S.sel); } else selectMany(ids.concat([id]), id);
|
||
}
|
||
// the walls (fences, ...) joined to o: same type, an end on the other's centre line; o first
|
||
function connectedRun(o) {
|
||
var m = prefab(o.type);
|
||
if (!m || m.shape !== "line") return [o.id];
|
||
var pcs = S.doc.objects.filter(function (x) { return x.type === o.type && !x.hidden && Math.abs((x.z || 0) - (o.z || 0)) < 0.5; });
|
||
function ends(x) { var L = (+propOf(x, "w") || 0) / 2; return [worldOf(x, -L, 0), worldOf(x, L, 0)]; }
|
||
function onLine(p, x) { var l = localOf(x, p[0], p[1]); return Math.abs(l[0]) <= (+propOf(x, "w") || 0) / 2 + 0.05 && Math.abs(l[1]) <= 0.3; }
|
||
function joined(a, b) { return ends(a).some(function (p) { return onLine(p, b); }) || ends(b).some(function (p) { return onLine(p, a); }); }
|
||
var out = [o], todo = [o];
|
||
while (todo.length) {
|
||
var a = todo.pop();
|
||
pcs.forEach(function (b) { if (out.indexOf(b) < 0 && joined(a, b)) { out.push(b); todo.push(b); } });
|
||
}
|
||
return out.map(function (x) { return x.id; });
|
||
}
|
||
// a click on an object: Ctrl / Shift add it or take it out; a second click on a wall or fence
|
||
// selects every piece joined to it, a click on one of those selects just that one again
|
||
function clickObj(id, e) {
|
||
if (e && (e.ctrlKey || e.metaKey || e.shiftKey)) { toggleSel(id); return; }
|
||
var ids = selIds();
|
||
if (ids.length === 1 && ids[0] === id) {
|
||
var run = connectedRun(obj(id));
|
||
if (run.length > 1) { selectMany(run, id); toast(run.length + " joined pieces selected · click one of them for just that one"); return; }
|
||
}
|
||
select(id);
|
||
}
|
||
function selectAll() {
|
||
selectMany((S.doc.objects || []).filter(function (o) { return !o.locked && !o.hidden; }).map(function (o) { return o.id; }));
|
||
}
|
||
// the selection box drawn with Shift / Ctrl + drag
|
||
function band(r) {
|
||
var b = $("stband");
|
||
if (!r) { if (b) b.parentNode.removeChild(b); return; }
|
||
if (!b) { b = document.createElement("div"); b.id = "stband"; b.className = "st-band"; document.body.appendChild(b); }
|
||
b.style.left = r.x0 + "px"; b.style.top = r.y0 + "px"; b.style.width = (r.x1 - r.x0) + "px"; b.style.height = (r.y1 - r.y0) + "px";
|
||
}
|
||
function bandRect(d, e) { return { x0: Math.min(d.x0, e.clientX), y0: Math.min(d.y0, e.clientY), x1: Math.max(d.x0, e.clientX), y1: Math.max(d.y0, e.clientY) }; }
|
||
function drawBand(d, e) { band(bandRect(d, e)); }
|
||
function endBand(d, e, mv) {
|
||
band(null);
|
||
if (mv.cancel) return;
|
||
if (!mv.moved) { if (d.hit) toggleSel(d.hit); return; }
|
||
// every object drawn wholly inside the box (not locked or hidden)
|
||
var r = bandRect(d, e), found = (S.doc.objects || []).filter(function (o) {
|
||
if (o.locked || o.hidden) return false;
|
||
var els = moveEls(o.id), u = null;
|
||
Array.prototype.forEach.call(els, function (el) {
|
||
var b = el.getBoundingClientRect();
|
||
if (!b.width && !b.height) return;
|
||
u = u ? { l: Math.min(u.l, b.left), t: Math.min(u.t, b.top), r: Math.max(u.r, b.right), b: Math.max(u.b, b.bottom) } : { l: b.left, t: b.top, r: b.right, b: b.bottom };
|
||
});
|
||
return u && u.l >= r.x0 && u.r <= r.x1 && u.t >= r.y0 && u.b <= r.y1;
|
||
}).map(function (o) { return o.id; });
|
||
if (!found.length) { toast("No object lies wholly inside the box"); return; }
|
||
selectMany(selIds().concat(found), found[found.length - 1]);
|
||
toast(selIds().length + " objects selected");
|
||
}
|
||
// what a group move takes along: the selected objects that are not locked, and what sits on them
|
||
function moveSet(ids) {
|
||
var objs = ids.map(obj).filter(function (o) { return o && !o.locked; }), seen = {}, carry = [];
|
||
objs.forEach(function (o) { seen[o.id] = 1; });
|
||
objs.forEach(function (o) { mountedOn(o).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = 1; carry.push(c); } }); });
|
||
return { objs: objs, carry: carry };
|
||
}
|
||
function moveMany(set, dx, dy, quiet) {
|
||
return opList(set.carry.concat(set.objs).map(function (o) { return { op: "update", id: o.id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } }; }), quiet);
|
||
}
|
||
// turn the group about the middle of its objects, by the snap step
|
||
function turnGroup(ids, d) {
|
||
var set = moveSet(ids), all = set.objs.concat(set.carry);
|
||
if (!set.objs.length) return;
|
||
var xs = set.objs.map(function (o) { return o.x; }), ys = set.objs.map(function (o) { return o.y; });
|
||
var cx = snap((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2), cy = snap((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2), q = qOf(d);
|
||
opList(all.map(function (o) {
|
||
var v = turnV(q, o.x - cx, o.y - cy);
|
||
return { op: "update", id: o.id, patch: { x: r3(cx + v[0]), y: r3(cy + v[1]), rot: ((o.rot || 0) + d + 360) % 360 } };
|
||
}));
|
||
}
|
||
function extraOn(ids) {
|
||
var seen = {}, out = [];
|
||
ids.forEach(function (id) { var o = obj(id); if (o) mountedOn(o).forEach(function (c) { if (!seen[c.id] && ids.indexOf(c.id) < 0) { seen[c.id] = 1; out.push(c.id); } }); });
|
||
return out;
|
||
}
|
||
function removeMany(ids) {
|
||
var extra = extraOn(ids);
|
||
opList(extra.concat(ids).map(function (id) { return { op: "remove", id: id }; })).then(function (r) {
|
||
if (r) toast("Deleted " + ids.length + " objects" + (extra.length ? " and the " + extra.length + " windows / doors on them" : "") + " · Ctrl+Z brings them back");
|
||
});
|
||
}
|
||
function duplicateMany(ids) {
|
||
var dd = Math.max(snapStep(), 1) * 2, extra = extraOn(ids);
|
||
opList(extra.concat(ids).map(function (id) { return { op: "duplicate", id: id, dx: dd, dy: dd }; })).then(function (r) {
|
||
if (r && r.ids) selectMany(r.ids.slice(-ids.length));
|
||
});
|
||
}
|
||
function setAll(ids, key) {
|
||
var v = !ids.every(function (id) { return obj(id)[key]; });
|
||
opList(ids.map(function (id) { var p = {}; p[key] = v; return { op: "update", id: id, patch: p }; }));
|
||
}
|
||
function focusMany(ids) {
|
||
var bs = ids.map(function (id) { return S.boxes[id]; }).filter(Boolean);
|
||
if (!bs.length) return;
|
||
var x0 = Math.min.apply(null, bs.map(function (b) { return b[0]; })), x1 = Math.max.apply(null, bs.map(function (b) { return b[3]; }));
|
||
var y0 = Math.min.apply(null, bs.map(function (b) { return b[1]; })), y1 = Math.max.apply(null, bs.map(function (b) { return b[4]; }));
|
||
S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2);
|
||
}
|
||
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
|
||
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
|
||
function remove(id) {
|
||
var o = obj(id); if (!o) return;
|
||
var carry = mountedOn(o);
|
||
opList(carry.map(function (c) { return { op: "remove", id: c.id }; }).concat([{ op: "remove", id: id }])).then(function (r) {
|
||
if (r) toast("Deleted" + (carry.length ? " with the " + carry.length + " window" + (carry.length === 1 ? "" : "s") + " / door" + (carry.length === 1 ? "" : "s") + " on it" : "") + " · Ctrl+Z brings it back");
|
||
});
|
||
}
|
||
function duplicate(id) {
|
||
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 = 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) {
|
||
var o = obj(id); if (!o) return Promise.resolve(null);
|
||
var ops = (carry || mountedOn(o)).map(function (c) { return { op: "update", id: c.id, patch: { x: r3(c.x + dx), y: r3(c.y + dy) } }; });
|
||
ops.push({ op: "update", id: id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } });
|
||
return opList(ops, quiet);
|
||
}
|
||
function turn(id, d) {
|
||
var o = obj(id); if (!o) return;
|
||
var q = qOf(d), ops = mountedOn(o).map(function (c) {
|
||
var v = turnV(q, c.x - o.x, c.y - o.y);
|
||
return { op: "update", id: c.id, patch: { x: r3(o.x + v[0]), y: r3(o.y + v[1]), rot: ((c.rot || 0) + d + 360) % 360 } };
|
||
});
|
||
ops.push({ op: "update", id: id, patch: { rot: ((o.rot || 0) + d + 360) % 360 } });
|
||
opList(ops);
|
||
}
|
||
function nudge(id, dx, dy) {
|
||
var o = obj(id), many = selIds().length > 1;
|
||
if (!o || (o.locked && !many)) return;
|
||
var s = snapStep() || 0.5;
|
||
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
|
||
var rot = S.view.getRotation();
|
||
var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0);
|
||
var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]];
|
||
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
|
||
// pick the dominant world axis so a key press moves along x or y only
|
||
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
|
||
if (many) moveMany(moveSet(selIds()), wx * s, wy * s, true).then(function () { renderRight(); });
|
||
else moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
|
||
}
|
||
function keyHandler(e) {
|
||
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
||
if (typing || document.querySelector("dialog[open]")) return false;
|
||
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
||
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
||
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
||
if (chain && k === "Enter") { finishChain(e.shiftKey); return true; }
|
||
if (chain && k === "Backspace") { chain.pts.pop(); if (!chain.pts.length) { chain = null; ghost(null); setPlacing(S.placing); } else ghost(null); return true; }
|
||
if (k === "Escape") { if (chain) { chain = null; ghost(null); setPlacing(S.placing); } else if (S.placing) setPlacing(null); else select(null); return true; }
|
||
if (ctrl && (k === "a" || k === "A")) { selectAll(); return true; }
|
||
if (!S.sel) return false;
|
||
var many = selIds().length > 1;
|
||
if (k === "Delete" || k === "Backspace") { if (many) removeMany(selIds()); else remove(S.sel); return true; }
|
||
if (ctrl && (k === "d" || k === "D")) { if (many) duplicateMany(selIds()); else duplicate(S.sel); return true; }
|
||
if (!ctrl && (k === "r" || k === "R")) { if (many) turnGroup(selIds(), e.shiftKey ? -90 : 90); else turn(S.sel, e.shiftKey ? -90 : 90); return true; }
|
||
if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; }
|
||
if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; }
|
||
if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; }
|
||
if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; }
|
||
return false;
|
||
}
|
||
document.addEventListener("keydown", function (e) {
|
||
if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself
|
||
if (keyHandler(e)) e.preventDefault();
|
||
});
|
||
|
||
// ------------------------------------------------------------ left panel
|
||
function renderPalette() {
|
||
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [], list = (S.meta.catalog || []).slice();
|
||
// the Room tool (a floor with walls) leads the House parts
|
||
var hi = list.findIndex(function (m) { return m.category === "House"; });
|
||
if (hi >= 0 && prefab("house_wall") && prefab("floor")) list.splice(hi, 0, ROOM);
|
||
list.forEach(function (m) {
|
||
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
|
||
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
|
||
cats[m.category].push(m);
|
||
});
|
||
var closed = paletteClosed();
|
||
$("palette").innerHTML = order.map(function (c) {
|
||
var shut = !q && closed.indexOf(c) >= 0;
|
||
return '<div><div class="st-cat' + (shut ? " st-closed" : "") + '" data-cat="' + esc(c) + '" title="Show / hide">' + esc(c) + " <small>" + cats[c].length + "</small></div>" +
|
||
'<div class="st-tiles"' + (shut ? " hidden" : "") + ">" + cats[c].map(function (m) {
|
||
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
|
||
}).join("") + "</div></div>";
|
||
}).join("") || '<div class="st-help">No object matches.</div>';
|
||
}
|
||
function renderLists() {
|
||
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [];
|
||
$("ocount").textContent = list.length;
|
||
$("objects").innerHTML = list.filter(function (o) {
|
||
return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0;
|
||
}).slice().reverse().map(function (o) {
|
||
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||
}).join("");
|
||
}
|
||
// closed palette categories, remembered in this browser
|
||
function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } }
|
||
$("palette").addEventListener("click", function (e) {
|
||
var h = e.target.closest(".st-cat");
|
||
if (h) {
|
||
var c = h.getAttribute("data-cat"), list = paletteClosed(), i = list.indexOf(c);
|
||
if (i >= 0) list.splice(i, 1); else list.push(c);
|
||
try { localStorage.setItem("isokit-palette-closed", JSON.stringify(list)); } catch (er) { /* private mode */ }
|
||
renderPalette();
|
||
return;
|
||
}
|
||
var b = e.target.closest(".st-tile"); if (!b) return;
|
||
var k = b.getAttribute("data-type");
|
||
setPlacing(S.placing === k ? null : k);
|
||
S.view.svg.focus();
|
||
});
|
||
$("psearch").addEventListener("input", renderPalette);
|
||
$("osearch").addEventListener("input", renderLists);
|
||
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
|
||
$("objects").addEventListener("click", function (e) {
|
||
var li = e.target.closest("li"); if (!li) return;
|
||
var id = li.getAttribute("data-id");
|
||
if (e.ctrlKey || e.metaKey) { toggleSel(id); return; }
|
||
if (e.shiftKey && S.sel) {
|
||
var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id);
|
||
if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; }
|
||
}
|
||
select(id);
|
||
});
|
||
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
|
||
|
||
// ------------------------------------------------------------ right panel
|
||
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
|
||
// a property's input; mixed: the selected objects differ here, so it shows no value until set
|
||
function propInput(d, v, mixed) {
|
||
var id = "p-" + d.key, val = mixed ? "" : v === undefined ? d.default : v, ph = mixed ? ' placeholder="mixed"' : "";
|
||
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + (mixed ? ' data-mixed="1"' : "") + ">";
|
||
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
|
||
var ch = (d.choices || []).slice(); if (!mixed && val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
|
||
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + (mixed ? '<option value="" selected disabled>mixed</option>' : "") +
|
||
ch.map(function (c) { return "<option" + (!mixed && c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
|
||
}
|
||
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '"' + ph + ">";
|
||
if (d.type === "float" || d.type === "int") {
|
||
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
|
||
var range = !mixed && d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
|
||
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"' + ph + "></div>";
|
||
}
|
||
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '"' + ph + ">";
|
||
}
|
||
function renderRight() {
|
||
var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds();
|
||
if (!o) { renderProject(R); return; }
|
||
if (ids.length > 1) { renderMany(R, ids); return; }
|
||
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
|
||
var levels = S.meta.levels || [];
|
||
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
|
||
'<div class="st-sub">' + esc(m.label) + " · id " + esc(o.id) + "</div>" +
|
||
(m.help ? '<p class="st-help">' + esc(m.help) + "</p>" : "") +
|
||
'<div class="st-actions">' +
|
||
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
|
||
'<button type="button" data-act="focus" title="Zoom to it">Zoom to</button>' +
|
||
'<button type="button" data-act="front" title="Draw after the others (ground items)">To front</button>' +
|
||
'<button type="button" data-act="back" title="Draw before the others (ground items)">To back</button>' +
|
||
'<button type="button" data-act="lock">' + (o.locked ? "Unlock" : "Lock") + "</button>" +
|
||
'<button type="button" data-act="hide">' + (o.hidden ? "Show" : "Hide") + "</button>" +
|
||
'<button type="button" data-act="del" class="st-danger" title="Delete">Delete</button></div>' +
|
||
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><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">' +
|
||
field("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
|
||
field("tag", "Tag", '<input data-f="tag" value="' + esc(o.tag || "") + '" placeholder="label on the map">') +
|
||
field("info", "Tooltip", '<textarea data-f="info" rows="2" placeholder="shown on hover / tap">' + esc(o.info || "") + "</textarea>") +
|
||
"</div>" +
|
||
(levels.length > 1 ? '<h2>Shown on levels</h2><div class="st-levels">' + levels.map(function (l) {
|
||
return '<label><input type="checkbox" data-level="' + esc(l.id) + '"' + ((o.levels || []).indexOf(l.id) >= 0 ? " checked" : "") + "> " + esc(l.label || l.id) + "</label>";
|
||
}).join("") + '</div><p class="st-help">None ticked: shown on every level.</p>' : "") +
|
||
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
|
||
R.innerHTML = h;
|
||
}
|
||
// several objects: what they are, actions for all, and the properties when they are all one type
|
||
function renderMany(R, ids) {
|
||
var os = ids.map(obj), count = {}, order = [];
|
||
os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; });
|
||
var one = order.length === 1 ? prefab(order[0]) : null, locked = os.every(function (o) { return o.locked; }), hidden = os.every(function (o) { return o.hidden; });
|
||
R.innerHTML = "<h3><i>▣</i>" + ids.length + " objects selected</h3>" +
|
||
'<div class="st-sub">' + order.map(function (t) { var m = prefab(t); return count[t] + " × " + esc(m ? m.label : t); }).join(", ") + "</div>" +
|
||
'<p class="st-help">Drag one of them to move them all; arrow keys nudge, R turns the group, Delete removes them. Ctrl+click or Shift+click adds or takes out one, Shift+drag on the map adds those in a box, Esc clears.</p>' +
|
||
'<div class="st-actions">' +
|
||
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
|
||
'<button type="button" data-act="focus">Zoom to</button>' +
|
||
'<button type="button" data-act="lock">' + (locked ? "Unlock" : "Lock") + "</button>" +
|
||
'<button type="button" data-act="hide">' + (hidden ? "Show" : "Hide") + "</button>" +
|
||
'<button type="button" data-act="del" class="st-danger">Delete ' + ids.length + "</button></div>" +
|
||
'<h2>Placement</h2><div class="st-form">' +
|
||
field("rot", "Turn the group", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>90°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') + "</div>" +
|
||
(one && one.props.length ? "<h2>Properties <small>for all " + ids.length + "</small></h2><div class=\"st-form\">" + one.props.map(function (d) {
|
||
var vals = os.map(function (o) { return JSON.stringify(propOf(o, d.key)); }), same = vals.every(function (v) { return v === vals[0]; });
|
||
return field(d.key, d.label || d.key, propInput(d, propOf(os[0], d.key), !same));
|
||
}).join("") + "</div>" : "") +
|
||
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
|
||
Array.prototype.forEach.call(R.querySelectorAll("input[data-mixed]"), function (x) { x.indeterminate = true; });
|
||
}
|
||
function renderProject(R) {
|
||
var m = S.meta, cfg = S.doc.cfg || {};
|
||
var base = location.origin;
|
||
R.innerHTML = "<h3>" + esc(m.title) + "</h3>" + '<div class="st-sub">' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types</div>" +
|
||
(m.params && m.params.length ? "<h2>Scene options</h2><div class=\"st-form\">" + m.params.map(function (p) {
|
||
var v = cfg[p.name] !== undefined ? cfg[p.name] : p.default;
|
||
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
|
||
}).join("") + "</div>" : "") +
|
||
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
|
||
(B && B.agentBox ? B.agentBox(m) :
|
||
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
|
||
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦ Agent (top bar) chats with one right here.</p>' +
|
||
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
|
||
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>") +
|
||
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
|
||
}
|
||
$("right").addEventListener("click", function (e) {
|
||
var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return;
|
||
var a = b.getAttribute("data-act"), o = obj(S.sel), ids = selIds();
|
||
if (ids.length > 1) {
|
||
if (a === "del") removeMany(ids);
|
||
else if (a === "dup") duplicateMany(ids);
|
||
else if (a === "focus") focusMany(ids);
|
||
else if (a === "lock") setAll(ids, "locked");
|
||
else if (a === "hide") setAll(ids, "hidden");
|
||
else if (a === "rotl" || a === "rotr") turnGroup(ids, a === "rotl" ? -90 : 90);
|
||
return;
|
||
}
|
||
if (a === "del") remove(S.sel);
|
||
else if (a === "dup") duplicate(S.sel);
|
||
else if (a === "focus") S.view.focus(S.sel, 3);
|
||
else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a });
|
||
else if (a === "lock") update(S.sel, { locked: !o.locked });
|
||
else if (a === "hide") update(S.sel, { hidden: !o.hidden });
|
||
else if (a === "rotl") turn(S.sel, -90);
|
||
else if (a === "rotr") turn(S.sel, 90);
|
||
});
|
||
var rangeT = 0;
|
||
function formChange(e, live) {
|
||
var t = e.target, o = S.sel ? obj(S.sel) : null;
|
||
if (t.hasAttribute("data-cfg")) {
|
||
var k = t.getAttribute("data-cfg"), v = t.type === "checkbox" ? t.checked : t.type === "number" || t.type === "range" ? parseFloat(t.value) : t.value, vals = {};
|
||
vals[k] = v; op({ op: "set_cfg", values: vals }, true);
|
||
return;
|
||
}
|
||
if (!o) return;
|
||
if (t.hasAttribute("data-f")) {
|
||
var f = t.getAttribute("data-f"), val = t.type === "number" ? parseFloat(t.value) : t.value, patch = {};
|
||
if (t.type === "number" && !isFinite(val)) return;
|
||
patch[f] = val; update(o.id, patch, live);
|
||
} else if (t.hasAttribute("data-prop")) {
|
||
var key = t.getAttribute("data-prop"), d = (prefab(o.type) || { props: [] }).props.filter(function (x) { return x.key === key; })[0] || {};
|
||
var pv = t.type === "checkbox" ? t.checked : (d.type === "float" || d.type === "int") ? parseFloat(t.value) : t.value;
|
||
if ((d.type === "float" || d.type === "int") && !isFinite(pv)) return;
|
||
if (d.type === "int") pv = Math.round(pv);
|
||
// keep the slider and the number box in step
|
||
Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; });
|
||
var pp = {}, all = selIds().length > 1 ? selIds() : [o.id];
|
||
pp[key] = pv;
|
||
var put = function () { opList(all.map(function (id) { return { op: "update", id: id, patch: { props: pp } }; }), true); };
|
||
if (live) { clearTimeout(rangeT); rangeT = setTimeout(put, 160); }
|
||
else put();
|
||
} else if (t.hasAttribute("data-level")) {
|
||
var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); });
|
||
update(o.id, { levels: lv });
|
||
}
|
||
}
|
||
$("right").addEventListener("change", function (e) { formChange(e, false); });
|
||
$("right").addEventListener("input", function (e) { if (e.target.type === "range") formChange(e, true); });
|
||
$("right").addEventListener("keydown", function (e) { if (e.key === "Enter" && e.target.tagName === "INPUT") e.target.blur(); });
|
||
|
||
// ------------------------------------------------------------ header
|
||
$("undo").addEventListener("click", undo);
|
||
$("redo").addEventListener("click", redo);
|
||
$("help").addEventListener("click", function () { $("helpdlg").showModal(); });
|
||
$("theme").addEventListener("click", function () {
|
||
var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ }
|
||
});
|
||
$("build").addEventListener("click", function () {
|
||
api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast(r.message || "Page written: " + r.path); }, function (e) { toast(String(e), true); });
|
||
});
|
||
$("warnbtn").addEventListener("click", function () { select(null); });
|
||
if (B && B.preview) $("preview").addEventListener("click", function (e) { e.preventDefault(); B.preview(); });
|
||
if (B && B.saveTitle) $("save").title = B.saveTitle;
|
||
|
||
// engine version from /api/meta; a web build adds its commit and date
|
||
// (<meta name="isokit-build" content="<sha> <date> [dirty]">, tools/build_web.py)
|
||
function showVersion() {
|
||
var el = $("ver"), m = document.querySelector('meta[name="isokit-build"]');
|
||
var b = m ? m.getAttribute("content").split(" ") : [];
|
||
var text = "isokit " + (S.meta.engine || "?"), tip = "isokit engine " + (S.meta.engine || "?");
|
||
if (b[0]) {
|
||
text += " · " + b[0].slice(0, 7) + (b[2] ? "+" : "");
|
||
tip += ", build " + b[0] + (b[1] ? " (" + b[1] + ")" : "") + (b[2] ? ", with uncommitted changes" : "");
|
||
el.href = "https://git.b4l.co.th/B4L/isokit/commit/" + b[0];
|
||
}
|
||
el.textContent = text;
|
||
el.title = tip;
|
||
}
|
||
|
||
// ------------------------------------------------------------ start
|
||
Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||
S.meta = rs[0]; takeState(rs[1]);
|
||
document.title = S.meta.title + " · isokit Studio";
|
||
$("title").textContent = S.meta.title;
|
||
if (S.meta.load_error) toast(S.meta.load_error, true);
|
||
showVersion();
|
||
renderPalette(); renderLists(); mountView(); renderRight();
|
||
poll();
|
||
window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this
|
||
});
|
||
})();
|