diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0696338..b01457c 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -49,14 +49,15 @@ Completion evidence:
- duplication and reparenting — initial editor commands complete;
- durable, validated local Scene History — initial foundation complete;
-- multi-selection where appropriate;
+- multi-selection where appropriate — initial hierarchy and viewport selection complete;
- multi-scene project browser;
- rename and move project assets;
- unsaved-change protection;
- editor preferences;
- stronger error reporting;
- transform snapping and local/world gizmo modes;
-- optional Surface/Ground Snap and Grid Snap;
+- optional Surface/Ground Snap and Grid Snap, including a keyboard command to
+ place the current selection on the ground;
- Frame Selected;
- optional scene/world bounds independent from viewport size;
- consistent commands and keyboard behavior.
diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs
index d884934..1311b8d 100644
--- a/src/engine/render/ThreeRenderer.mjs
+++ b/src/engine/render/ThreeRenderer.mjs
@@ -14,7 +14,8 @@ export class ThreeRenderer extends RendererBackend {
this.pointer = new THREE.Vector2();
this.nodeObjects = new Map();
this.selectedId = null;
- this.selectionBox = null;
+ this.selectedIds = new Set();
+ this.selectionBoxes = [];
this.transformControls = null;
this.transformMode = 'select';
this.suppressSelectionClick = false;
@@ -209,25 +210,30 @@ export class ThreeRenderer extends RendererBackend {
object.rotateZ(object.userData.billboardRoll ?? 0);
}
}
- if (this.selectionBox) this.selectionBox.update();
+ for (const box of this.selectionBoxes) box.update();
this.renderer.render(this.scene,this.camera);
}
- selectNode(nodeId) {
- this.selectedId = nodeId;
+ selectNode(nodeId) { this.setSelection(nodeId ? [nodeId] : [], nodeId); }
+
+ setSelection(nodeIds, primaryId = null) {
+ this.selectedIds = new Set(nodeIds.filter((id) => this.nodeObjects.has(id)));
+ this.selectedId = this.selectedIds.has(primaryId) ? primaryId : this.selectedIds.values().next().value ?? null;
this.transformControls?.detach();
- if (this.selectionBox) {
- this.scene.remove(this.selectionBox);
- this.selectionBox.geometry.dispose();
- this.selectionBox.material.dispose();
- this.selectionBox = null;
+ for (const box of this.selectionBoxes) {
+ this.scene.remove(box);
+ box.geometry.dispose();
+ box.material.dispose();
}
- const object = this.nodeObjects.get(nodeId);
- if (object) {
- this.selectionBox = new THREE.BoxHelper(object,0x78b8ff);
- this.scene.add(this.selectionBox);
- this.#attachTransformControls();
+ this.selectionBoxes = [];
+ for (const id of this.selectedIds) {
+ const object = this.nodeObjects.get(id);
+ if (!object) continue;
+ const box = new THREE.BoxHelper(object, 0x78b8ff);
+ this.selectionBoxes.push(box);
+ this.scene.add(box);
}
+ this.#attachTransformControls();
}
setTransformMode(mode) {
@@ -276,7 +282,7 @@ export class ThreeRenderer extends RendererBackend {
object.userData.light.intensity = node.intensity;
object.userData.light.castShadow = node.castShadow;
}
- if (this.selectionBox && this.selectedId === node.id) this.selectionBox.update();
+ if (this.selectedIds.has(node.id)) for (const box of this.selectionBoxes) box.update();
}
setView(mode) {
@@ -333,7 +339,7 @@ export class ThreeRenderer extends RendererBackend {
const hit=this.raycaster.intersectObjects(candidates,false)[0];
let object=hit?.object;
while (object && !object.userData?.parlynNodeId) object=object.parent;
- if (object?.userData?.parlynNodeId) this.callbacks.onSelect?.(object.userData.parlynNodeId);
+ if (object?.userData?.parlynNodeId) this.callbacks.onSelect?.(object.userData.parlynNodeId, { toggle: event.ctrlKey || event.metaKey, range: event.shiftKey });
});
}
diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs
index 322fc14..c38f1a5 100644
--- a/src/renderer/app.mjs
+++ b/src/renderer/app.mjs
@@ -16,6 +16,9 @@ async function bootstrap() {
const history = new History({ limit: 100 });
let scene = createDemoScene();
let selected = null;
+ let selectedIds = new Set();
+ let selectionAnchorId = null;
+ let visibleHierarchyIds = [];
let currentFilePath = null;
let currentProject = null;
let currentProjectRoot = null;
@@ -60,12 +63,8 @@ async function bootstrap() {
function restoreSnapshot(snapshot, selectionId = null) {
scene = SceneDocument.fromJSON(snapshot);
renderer.rebuild(scene);
- selected = selectionId ? scene.findById(selectionId) : null;
- renderHierarchy();
- if (selected) {
- renderer.selectNode(selected.id);
- populateInspector();
- } else clearSelection();
+ if (selectionId && scene.findById(selectionId)) selectById(selectionId);
+ else clearSelection();
updateHistoryButtons();
setDirty(true);
}
@@ -84,18 +83,20 @@ async function bootstrap() {
function renderHierarchy() {
const root = $("hierarchy");
root.replaceChildren();
+ visibleHierarchyIds = [];
const rootButton = document.createElement("button");
rootButton.className = "tree-item scene-root";
rootButton.innerHTML = `\u25C7${escapeHtml(scene.name)}`;
root.appendChild(rootButton);
function appendNode(node, depth) {
const b = document.createElement("button");
- b.className = "tree-item child" + (selected?.id === node.id ? " active" : "");
+ b.className = "tree-item child" + (selectedIds.has(node.id) ? " active" : "");
b.style.setProperty("--tree-depth", depth);
b.dataset.id = node.id;
b.innerHTML = `${nodeIcon(node)}${escapeHtml(node.name)}`;
- b.addEventListener("click", () => selectById(node.id));
+ b.addEventListener("click", (event) => selectById(node.id, { toggle: event.ctrlKey || event.metaKey, range: event.shiftKey }));
root.appendChild(b);
+ visibleHierarchyIds.push(node.id);
node.children.forEach((child) => appendNode(child, depth + 1));
}
scene.root.children.forEach((node) => appendNode(node, 1));
@@ -154,7 +155,9 @@ async function bootstrap() {
}
function clearSelection() {
selected = null;
- renderer.selectNode(null);
+ selectedIds.clear();
+ selectionAnchorId = null;
+ renderer.setSelection([]);
$("inspector-empty").hidden = false;
$("inspector").hidden = true;
$("selected-type").textContent = "None";
@@ -163,16 +166,37 @@ async function bootstrap() {
$("reparent-node").disabled = true;
renderHierarchy();
}
- function selectById(id) {
- selected = scene.findById(id);
- if (!selected) return;
- renderer.selectNode(id);
+ function selectById(id, { toggle = false, range = false } = {}) {
+ const node = scene.findById(id);
+ if (!node) return;
+ if (range && selectionAnchorId && visibleHierarchyIds.includes(selectionAnchorId)) {
+ const start = visibleHierarchyIds.indexOf(selectionAnchorId);
+ const end = visibleHierarchyIds.indexOf(id);
+ selectedIds = new Set(visibleHierarchyIds.slice(Math.min(start, end), Math.max(start, end) + 1));
+ } else if (toggle) {
+ if (selectedIds.has(id)) selectedIds.delete(id);
+ else selectedIds.add(id);
+ selectionAnchorId = id;
+ } else {
+ selectedIds = new Set([id]);
+ selectionAnchorId = id;
+ }
+ selected = selectedIds.has(id) ? node : scene.findById([...selectedIds][0]) ?? null;
+ renderer.setSelection([...selectedIds], selected?.id ?? null);
renderHierarchy();
- populateInspector();
- $("delete-node").disabled = false;
- $("duplicate-node").disabled = false;
- $("reparent-node").disabled = false;
- status.textContent = `Selected: ${selected.name}`;
+ const count = selectedIds.size;
+ if (count === 1 && selected) {
+ populateInspector();
+ $("selected-type").textContent = selected.type;
+ } else {
+ $("inspector-empty").hidden = false;
+ $("inspector").hidden = true;
+ $("selected-type").textContent = count ? `${count} selected` : "None";
+ }
+ $("delete-node").disabled = count === 0;
+ $("duplicate-node").disabled = count !== 1;
+ $("reparent-node").disabled = count !== 1;
+ status.textContent = count === 1 ? `Selected: ${selected.name}` : `${count} nodes selected`;
}
function populateInspector() {
$("inspector-empty").hidden = true;
@@ -374,17 +398,20 @@ async function bootstrap() {
status.textContent = `Added: ${node.name}`;
}
function deleteSelected() {
- if (!selected) return;
+ if (!selectedIds.size) return;
const before = sceneSnapshot();
- const name = selected.name, id = selected.id;
- if (!scene.removeById(id)) return;
+ const targets = [...selectedIds].map((id) => scene.findById(id)).filter(Boolean);
+ const targetIds = new Set(targets.map((node) => node.id));
+ const roots = targets.filter((node) => !node.parent || !targetIds.has(node.parent.id));
+ if (!roots.length) return;
+ for (const node of roots) scene.removeById(node.id);
renderer.rebuild(scene);
- pushHistory(before, `Delete ${name}`);
+ pushHistory(before, roots.length === 1 ? `Delete ${roots[0].name}` : `Delete ${roots.length} nodes`);
clearSelection();
- status.textContent = `Deleted: ${name}`;
+ status.textContent = roots.length === 1 ? `Deleted: ${roots[0].name}` : `Deleted: ${roots.length} nodes`;
}
function duplicateSelected() {
- if (!selected) return;
+ if (!selected || selectedIds.size !== 1) return;
const before = sceneSnapshot();
const originalName = selected.name;
const duplicate = scene.duplicateById(selected.id);
@@ -401,7 +428,7 @@ async function bootstrap() {
return names.join(" › ");
}
function showReparentDialog() {
- if (!selected) return;
+ if (!selected || selectedIds.size !== 1) return;
const excluded = new Set();
selected.walk((node) => excluded.add(node.id));
const select = $("reparent-target");
@@ -422,7 +449,7 @@ async function bootstrap() {
$("reparent-dialog").showModal();
}
function reparentSelected() {
- if (!selected) return;
+ if (!selected || selectedIds.size !== 1) return;
const before = sceneSnapshot();
const target = scene.findById($("reparent-target").value);
try {