Build your own house: walls, windows, doors, floors, roof, stairs, furniture
Deploy web Studio / deploy (push) Successful in 3m27s

House parts (stdlib isokit/house): house_wall drawn along a line, with ends
that adapt at corners, T-junctions and straight runs so no two walls
overlap; window and door that sit on a wall and cut its opening (door
styles panel, glass, double, sliding, opening; swing and hinge by
rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat,
optional solar); stairs.

Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with
chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen
run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp.

Cut-open levels: on gf / 1f the walls in front of a room are cut low
(rooms are found by their floors), the roof becomes an outline on the
floor, and storeys above are left out. A part's storey comes from z
(storey_h, default 3 m).

Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame,
to_world, to_local, local_of. Prefab meta: shape (line / rect), mount,
storey.

Studio: drag to draw lines and rectangles (walls, roads, fences, hedges,
floors, areas, buildings, roofs). The Room tool draws a floor plus walls,
without doubling walls that already stand on an edge. Windows and doors
snap onto walls with a hover preview, and they move, turn, copy and
delete with their wall in one undo step. New parts land on the storey
the view shows. Palette categories collapse, and the palette scrolls
apart from the object list.

The MCP instructions explain house building, and list_prefabs shows
sits_on. examples/house is a furnished bungalow; the web Studio gets a
House demo button. Tests: wall corners and junctions, zero depth-sort
cycles in every view, cut levels, and the house demo in every view;
check_studio covers the room / window / door / storey flow, check_web
the House demo.
This commit is contained in:
grabowski committed 2026-10-24 17:39:04 +07:00
1 parent 84309a7a08
commit 95ea4a8512
26 files changed
+2137 -64

No files matched your search

+5 -2
View File
@@ -30,8 +30,8 @@ document.documentElement.setAttribute("data-theme",t);})();
</header>
<main class="st-main">
<aside class="st-left">
<section class="st-panel">
<h2>Add <small>click, then click on the map</small></h2>
<section class="st-panel st-pal-panel">
<h2>Add <small>click, then click or drag on the map</small></h2>
<input type="search" id="psearch" placeholder="Search objects…" autocomplete="off">
<div id="palette" class="st-palette"></div>
</section>
@@ -62,6 +62,9 @@ document.documentElement.setAttribute("data-theme",t);})();
<h3>isokit Studio</h3>
<table>
<tr><th>Add</th><td>pick an object on the left, click on the map (Shift-click to place several)</td></tr>
<tr><th>Draw</th><td>walls, roads, fences: drag along the map; floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it</td></tr>
<tr><th>Windows, doors</th><td>click on a wall: they snap onto it, cut their opening and move, turn, copy and delete with the wall; R turns a door to the other hinge and swing side</td></tr>
<tr><th>Storeys</th><td>GF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter <code>storey_h</code>)</td></tr>
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
+15 -1
View File
@@ -27,11 +27,20 @@ input[type=checkbox] { accent-color: var(--accent); }
.st-right { border-right: 0; border-left: 1px solid var(--stroke); padding: 10px; }
.st-panel { padding: 8px 10px; border-bottom: 1px solid var(--stroke); }
.st-grow-panel { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 140px; }
/* the palette scrolls on its own, so the object list below stays in view */
.st-left { overflow: hidden; }
.st-pal-panel { flex: 0 1 auto; max-height: 62%; min-height: 120px; display: flex; flex-direction: column; }
.st-pal-panel .st-palette { overflow: auto; min-height: 0; padding-right: 2px; }
.st-panel h2, .st-right h2 { margin: 2px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); font-weight: 600; }
.st-panel h2 small { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 4px; }
.st-panel input[type=search] { width: 100%; margin-bottom: 6px; }
.st-palette { display: flex; flex-direction: column; gap: 6px; }
.st-cat { font-size: 11px; color: var(--mute); margin: 4px 0 2px; }
.st-cat { font-size: 11px; color: var(--mute); margin: 4px 0 2px; cursor: pointer; user-select: none; }
.st-cat::before { content: "▾"; display: inline-block; width: 12px; }
.st-cat.st-closed::before { content: "▸"; }
.st-cat:hover { color: var(--fg); }
.st-cat small { opacity: .7; }
.st-tiles[hidden] { display: none; }
.st-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.st-tile { display: flex; align-items: center; gap: 6px; text-align: left; padding: 4px 6px; overflow: hidden; }
.st-tile i { font-style: normal; width: 18px; text-align: center; flex: 0 0 auto; }
@@ -150,6 +159,11 @@ input[type=checkbox] { accent-color: var(--accent); }
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
.ag-grip:hover { opacity: 1; }
/* previews while placing: footprints, wall planes, where a window lands */
.iso-svg .st-ghosts { pointer-events: none; }
.iso-svg .st-ghost { fill: color-mix(in srgb, var(--iso-accent) 16%, transparent); stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
.iso-svg .st-ghost.st-plane { fill: color-mix(in srgb, var(--iso-accent) 28%, transparent); stroke-dasharray: none; }
@media (max-width: 900px) {
.st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; }
.st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); }
+266 -33
View File
@@ -28,6 +28,88 @@
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]; }
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]]; }
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" }];
}
// ------------------------------------------------------------ server state
function takeState(r) {
@@ -77,7 +159,7 @@
}
// ------------------------------------------------------------ view
var drag = null;
var drag = null, tool = null;
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));
@@ -90,39 +172,61 @@
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
if (S.sel) S.view.selectObj(S.sel);
},
onHover: function (w) { if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); },
onHover: function (w, e) {
if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1);
if (S.placing && !tool && e) hoverPlacing(e);
},
onPick: function (i) { select(i && i.obj ? i.obj : null); },
onKey: keyHandler,
interact: {
down: function (e, hit) {
if (S.placing) { return true; }
if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
tool = { m: pm, z: z, a: [snap(a[0]), snap(a[1])], b: null };
return true;
}
if (!hit.obj) return false;
var o = obj(hit.obj);
if (!o || o.locked) return false;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null };
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, m) {
if (S.placing || !drag || !m.world || !drag.w0) return;
if (!drag.moved) { drag.moved = true; drag.els = moveEls(drag.id); select(drag.id, true); }
var dx = snap(drag.ox + m.world[0] - drag.w0[0]) - drag.ox, dy = snap(drag.oy + m.world[1] - drag.w0[1]) - drag.oy;
move: function (e, mv) {
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
if (!drag || !mv.world || !drag.w0) return;
if (!drag.moved) {
drag.moved = true; select(drag.id, true); drag.els = [];
// what sits on the object (a wall's windows) moves along
[drag.id].concat(drag.carry.map(function (c) { return c.id; })).forEach(function (id) { Array.prototype.push.apply(drag.els, moveEls(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;
Array.prototype.forEach.call(drag.els, function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
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, m) {
if (S.placing) {
if (!m.cancel && m.world) place(S.placing, m.world, e.shiftKey);
return;
}
var d = drag; drag = null;
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.moved || m.cancel) { select(d.id); return; }
if (d.dx || d.dy) update(d.id, { x: +(d.ox + d.dx).toFixed(3), y: +(d.oy + d.dy).toFixed(3) });
if (!d.moved || mv.cancel) { if (d.moved) S.view.refresh(); select(d.id); 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) moveWith(d.id, d.dx, d.dy, d.carry);
else S.view.refresh();
},
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; tool = null; ghost(null); }
}
});
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
@@ -137,20 +241,105 @@
}
// ------------------------------------------------------------ actions
function place(kind, w, keep) {
var m = prefab(kind);
op({ op: "add", type: kind, x: snap(w[0]), y: snap(w[1]) }).then(function (r) {
if (r) toast((m ? m.label : kind) + " added" + (keep ? "" : " · Shift-click to place several"));
});
// the hover preview while placing a window or door: where on which wall it goes
function hoverPlacing(e) {
var m = pmeta(S.placing);
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 along the main axis,
// "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 };
}
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 = [];
t.b = [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 · release to place");
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 === 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.ax ? 0 : 90, 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 ((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;
S.placing = kind; tool = 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 = prefab(kind); h.textContent = "Click on the map to place: " + (m ? m.label : kind) + " · Esc to stop"; h.hidden = false; }
else h.hidden = true;
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: " + lab + " (a click gives the default length) · 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;
}
function select(id, quiet) {
if (id && !obj(id)) id = null;
@@ -159,9 +348,37 @@
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
if (!quiet) renderRight();
}
function remove(id) { if (id) op({ op: "remove", id: id }).then(function (r) { if (r) toast("Deleted · Ctrl+Z brings it back"); }); }
function duplicate(id) { if (id) op({ op: "duplicate", id: id, dx: Math.max(snapStep(), 1) * 2, dy: Math.max(snapStep(), 1) * 2 }); }
function turn(id, d) { var o = obj(id); if (o) update(id, { rot: ((o.rot || 0) + d + 360) % 360 }); }
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 = turnV(qOf(h.host.rot), (+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); if (!o || o.locked) return;
var s = snapStep() || 0.5;
@@ -172,7 +389,7 @@
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); }
update(id, { x: +(o.x + wx * s).toFixed(3), y: +(o.y + wy * s).toFixed(3) }, true).then(function () { renderRight(); });
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");
@@ -198,14 +415,20 @@
// ------------------------------------------------------------ left panel
function renderPalette() {
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [];
(S.meta.catalog || []).forEach(function (m) {
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) {
return '<div><div class="st-cat">' + esc(c) + '</div><div class="st-tiles">' + cats[c].map(function (m) {
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>';
@@ -219,7 +442,17 @@
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (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);