From 7105403404edbc56deca3c0163d0466c688e4d53 Mon Sep 17 00:00:00 2001 From: WebCrew Date: Tue, 8 Sep 2026 08:58:00 +0200 Subject: [PATCH 1/3] feat: add hierarchy multi-selection --- src/renderer/app.mjs | 79 +++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 26 deletions(-) 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 { From 85c3915dbf915e79f2f5b030eda6fd397517e382 Mon Sep 17 00:00:00 2001 From: WebCrew Date: Tue, 8 Sep 2026 08:58:38 +0200 Subject: [PATCH 2/3] feat: render multiple selected nodes --- src/engine/render/ThreeRenderer.mjs | 38 +++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) 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 }); }); } From 7195d245064322e0697e67a3283ea8f68031cab3 Mon Sep 17 00:00:00 2001 From: WebCrew Date: Tue, 8 Sep 2026 08:58:49 +0200 Subject: [PATCH 3/3] docs: record Phase 2 selection and ground shortcut --- docs/ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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.