Multi-select, select joined walls and fences, pond with swimming fish
Deploy web Studio / deploy (push) Successful in 1m27s
Deploy web Studio / deploy (push) Successful in 1m27s
Selection: S.sel stays the object picked last; S.selIds holds everything selected. - In the object list, Ctrl+click adds or removes an object and Shift+click selects a range. - On the map, Ctrl/Shift+click toggles an object and Shift+drag draws a box that selects what lies wholly inside it (locked and hidden objects are left out). - Ctrl+A selects everything that is not locked; Esc selects nothing. - Clicking a wall or fence a second time selects every piece of the same type joined to it; another click goes back to that one piece. - Dragging one selected object moves them all, together with the windows and doors on them, as one undo step. Arrow keys, R (turns the group about its middle), Ctrl+D, Delete, Lock and Hide all work on the whole selection. - When all selected objects share a type, the property form edits them all; fields where they differ show "mixed". - The agent's system prompt lists the selected objects. The viewer outlines several objects with selectObj([ids]) and passes the click event to onPick. A batch op now answers `ids`, one per operation, so the copies from a group duplicate get selected. Pond (Nature): natural, round or square, with a stone or grass edge, fish, lily pads, spreading ripples and reed clumps. The fish come from a new Swim primitive (script: s.swim). One matrix maps the object's frame to the screen. Each fish turns round its ellipse with a CSS rotation, so off-screen pausing and reduced motion apply as for the other animations. The house demo gets a koi pond in the back garden. Tests: ponds in every shape and rotation have the right number of fish, no NaN and no depth-sort cycles, and stay inside their rectangle. check_studio covers list and map multi-select, a group drag undone in one step, joined-fence selection, a mixed-value form applied to all, box select, delete and undo, Ctrl+A, Esc, and a new pond's fish.
This commit is contained in:
1 parent
e4017ce2a8
commit
19f4c83ae8
21 files changed
+759
-59
No files matched your search
+4
-1
@@ -262,6 +262,9 @@
|
||||
}
|
||||
function system() {
|
||||
var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view;
|
||||
var many = (S.selIds || []).map(function (id) { return s.obj(id); }).filter(Boolean);
|
||||
var selText = many.length > 1 ? "Selected " + many.length + " objects (\"these\", \"them\"): " + JSON.stringify(many.slice(0, 30).map(compact)) + (many.length > 30 ? " and " + (many.length - 30) + " more" : "") + "."
|
||||
: sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.";
|
||||
return {
|
||||
role: "system",
|
||||
content: [
|
||||
@@ -270,7 +273,7 @@
|
||||
"How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.",
|
||||
"Answer in the language of the user's latest message, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them). To show the result, write the answer first and call select_object in the same step: that ends your turn.",
|
||||
"The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " +
|
||||
(sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.") + " " + screenAxes(v.getRotation())
|
||||
selText + " " + screenAxes(v.getRotation())
|
||||
].filter(Boolean).join("\n\n")
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<tr><th>Draw</th><td>walls, fences, hedges, roads: drag along the map for one piece, or click corner after corner for a run (double-click or Enter ends it, clicking the first corner closes it, Backspace drops the last corner); ends snap onto the walls and fences they meet, which then join at corners and T-junctions. 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>Select</th><td>click it on the map or in the list; click a wall or fence again to select every piece joined to it (again: just that one)</td></tr>
|
||||
<tr><th>Several</th><td>Ctrl+click (map or list) adds or takes out one, Shift+click in the list selects a range, Shift+drag on the map selects what lies inside the box, Ctrl+A everything not locked, Esc none. Drag one of them, arrow keys, R, Ctrl+D and Delete then work on all; the form edits a property for all of them</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>
|
||||
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
|
||||
|
||||
@@ -159,6 +159,10 @@ 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; }
|
||||
|
||||
/* the selection box (Shift / Ctrl + drag on the map) */
|
||||
.st-band { position: fixed; z-index: 30; pointer-events: none; border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
#objects { user-select: none; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
+236
-39
@@ -7,7 +7,7 @@
|
||||
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, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
||||
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, """); }
|
||||
@@ -219,8 +219,9 @@
|
||||
}
|
||||
takeState(r);
|
||||
$("save").textContent = "Saved";
|
||||
if (r.id && o.op !== "update") S.sel = r.id;
|
||||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||||
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;
|
||||
@@ -230,7 +231,7 @@
|
||||
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||||
function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||||
function afterExternal() {
|
||||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||||
pruneSel();
|
||||
S.view.refresh(); renderLists(); renderRight();
|
||||
}
|
||||
function reloadAll(why) {
|
||||
@@ -261,13 +262,13 @@
|
||||
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");
|
||||
if (S.sel) S.view.selectObj(S.sel);
|
||||
var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
|
||||
},
|
||||
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); },
|
||||
onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); },
|
||||
onKey: keyHandler,
|
||||
interact: {
|
||||
down: function (e, hit) {
|
||||
@@ -277,8 +278,17 @@
|
||||
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);
|
||||
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,
|
||||
@@ -287,11 +297,14 @@
|
||||
},
|
||||
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; 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)); });
|
||||
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;
|
||||
@@ -310,19 +323,22 @@
|
||||
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 || mv.cancel) { if (d.moved) S.view.refresh(); select(d.id); 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) moveWith(d.id, d.dx, d.dy, d.carry);
|
||||
} 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(); drag = null; tool = null; ghost(null); },
|
||||
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, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: 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() {
|
||||
@@ -453,12 +469,142 @@
|
||||
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.view.selectObj(id);
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
|
||||
if (!quiet) renderRight();
|
||||
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
|
||||
@@ -492,7 +638,8 @@
|
||||
opList(ops);
|
||||
}
|
||||
function nudge(id, dx, dy) {
|
||||
var o = obj(id); if (!o || o.locked) return;
|
||||
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();
|
||||
@@ -501,7 +648,8 @@
|
||||
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); }
|
||||
moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
|
||||
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");
|
||||
@@ -512,10 +660,12 @@
|
||||
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;
|
||||
if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; }
|
||||
if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; }
|
||||
if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; }
|
||||
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; }
|
||||
@@ -553,7 +703,7 @@
|
||||
$("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="' + (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>";
|
||||
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
|
||||
@@ -574,29 +724,42 @@
|
||||
});
|
||||
$("psearch").addEventListener("input", renderPalette);
|
||||
$("osearch").addEventListener("input", renderLists);
|
||||
$("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); });
|
||||
// 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); }
|
||||
function propInput(d, v) {
|
||||
var id = "p-" + d.key, val = v === undefined ? d.default : v;
|
||||
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
|
||||
// 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 (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
|
||||
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
|
||||
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) + '">';
|
||||
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 = 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) + '"></div>';
|
||||
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) + '">';
|
||||
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;
|
||||
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>" +
|
||||
@@ -627,6 +790,29 @@
|
||||
'<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;
|
||||
@@ -645,7 +831,16 @@
|
||||
}
|
||||
$("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);
|
||||
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);
|
||||
@@ -675,9 +870,11 @@
|
||||
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 = {}; pp[key] = pv;
|
||||
if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); }
|
||||
else update(o.id, { props: pp }, true);
|
||||
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 });
|
||||
|
||||
Reference in new issue
Block a user