From 4b3dca29d40dcc5070134cf68f311c3bb4d266ad Mon Sep 17 00:00:00 2001 From: WebCrew Date: Fri, 11 Sep 2026 16:32:57 +0200 Subject: [PATCH] feat: add ground placement command --- .../workflows/publish-unsigned-preview.yml | 24 +++++++-------- README.md | 2 +- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md | 17 +++++++++++ docs/ROADMAP.md | 3 +- docs/TESTING-v0.5.0.md | 2 +- docs/WINDOWS-DISTRIBUTION.md | 4 +-- package-lock.json | 4 +-- package.json | 2 +- scripts/check-desktop-boundaries.cjs | 4 +++ scripts/check-windows-distribution.cjs | 2 +- scripts/check.cjs | 3 +- src/engine/render/RendererBackend.mjs | 1 + src/engine/render/ThreeRenderer.mjs | 9 ++++++ src/renderer/app.mjs | 30 +++++++++++++++++++ src/renderer/index.html | 1 + 15 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 5f34eff..7180ce9 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -12,12 +12,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.14 + default: v0.5.0-beta.15 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 14 (unsigned) + default: Parlyn Engine v0.5.0 Beta 15 (unsigned) permissions: contents: write @@ -29,8 +29,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.14' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 14 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.15' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 15 (unsigned)' }} steps: - name: Checkout @@ -60,7 +60,7 @@ jobs: throw "Expected exactly one Parlyn installer." } - $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.14-x64.zip" + $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.15-x64.zip" if (-not (Test-Path -LiteralPath "release/win-unpacked/Parlyn Engine.exe")) { throw "Packaged Parlyn executable was not found for the portable build." } @@ -77,12 +77,12 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 14 + # Parlyn Engine v0.5.0 Beta 15 - This unsigned Windows beta adds explicit Local and World orientation for - Move and Rotate gizmos. The selected orientation persists locally and - works with configurable snapping. Scale remains local by design, while - Inspector entry, 2.5D axis limits and scene history remain unchanged. + This unsigned Windows beta adds Ground Placement for the current + selection through a toolbar command and the End key. Placement uses the + rendered object bounds and integrates with multi-selection, Undo/Redo + and scene persistence. Automatic Surface Snap remains a later step. The portable ZIP creates no Windows installation or uninstall entry and remains the preferred unsigned test path. @@ -99,7 +99,7 @@ jobs: other public certificate provider. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release @@ -117,6 +117,6 @@ jobs: --notes-file release/PREVIEW-NOTES.md ` --prerelease ` "$($installer[0].FullName)" ` - "release/Parlyn-Engine-Portable-0.5.0-beta.14-x64.zip" ` + "release/Parlyn-Engine-Portable-0.5.0-beta.15-x64.zip" ` "release/latest.yml" ` "release/SHA256SUMS.txt" diff --git a/README.md b/README.md index 2129dc7..f074790 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. Phase 1 acceptance is complete. The current Phase 2 editor candidate uses the -repeatable [`v0.5.0-beta.14 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md). +repeatable [`v0.5.0-beta.15 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md). ### Windows release integrity diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md new file mode 100644 index 0000000..a1e9433 --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md @@ -0,0 +1,17 @@ +# Parlyn Engine v0.5.0 Beta 15 — Maintainer Acceptance + +Beta 15 adds the first Ground Placement command. It uses the rendered bounds of +the current selection so objects rest on the editor ground instead of merely +moving their pivot to a fixed Y value. + +1. Open a project and move a 3D mesh clearly above the ground. +2. Select it and click **Ground**. Confirm its lowest visible point rests on the grid. +3. Move it upward again and press **End**. Confirm the same result. +4. Rotate and scale the mesh, then repeat; its current visible bounds must be used. +5. Repeat with a 2.5D sprite. +6. Multi-select several nodes at different heights and confirm each is grounded. +7. Undo and Redo the complete operation as one history step. +8. Save and reopen the scene and confirm the grounded positions persist. +9. Confirm Snap and Local/World modes still behave normally. + +Automatic Surface Snap remains a later, separate step. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7e677a2..bb32a8c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -60,7 +60,8 @@ Completion evidence: - transform snapping and local/world gizmo modes — complete with persistent, configurable snapping and explicit Local/World orientation; - optional Surface/Ground Snap and Grid Snap, including a keyboard command to - place the current selection on the ground; + place the current selection on the ground — initial Ground command and End + shortcut complete; automatic surface placement remains; - Frame Selected; - optional scene/world bounds independent from viewport size; - consistent commands and keyboard behavior. diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 0759224..0fa52dc 100644 --- a/docs/TESTING-v0.5.0.md +++ b/docs/TESTING-v0.5.0.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Test Guide For the current Phase 2 editor acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md) and record the result in Issue #29. The guide below remains the detailed feature reference. This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. diff --git a/docs/WINDOWS-DISTRIBUTION.md b/docs/WINDOWS-DISTRIBUTION.md index 5106049..5268d5c 100644 --- a/docs/WINDOWS-DISTRIBUTION.md +++ b/docs/WINDOWS-DISTRIBUTION.md @@ -129,8 +129,8 @@ blocker. Phase 1 functional acceptance completed with the unsigned `v0.5.0-beta.4` prerelease and Issue #23. The current Phase 2 editor candidate is -`v0.5.0-beta.14`; its repeatable checklist is documented in -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md). +`v0.5.0-beta.15`; its repeatable checklist is documented in +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md). Unsigned preview releases also provide a portable ZIP for functional testing. It creates no installation or uninstall registration and is the preferred test diff --git a/package-lock.json b/package-lock.json index 8ffc8b8..31a42f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.14", + "version": "0.5.0-beta.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.14", + "version": "0.5.0-beta.15", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index 333cc67..211a1c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.14", + "version": "0.5.0-beta.15", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index 360291a..09e909a 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -61,6 +61,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(html, /id="snap-toggle"[^>]+aria-pressed="false"/); assert.match(html, /id="snap-settings-dialog"/); assert.match(html, /id="transform-space"[^>]+aria-pressed="false"/); + assert.match(html, /id="place-on-ground"/); assert.match(html, /id="snap-translation"[^>]+min="0\.01"[^>]+max="100"/); assert.match(html, /id="snap-rotation"[^>]+min="1"[^>]+max="180"/); assert.match(html, /id="snap-scale"[^>]+min="0\.01"[^>]+max="10"/); @@ -103,8 +104,11 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(renderer, /normalizeTransformSpace/); assert.match(renderer, /renderer\.setTransformSpace\(transformSpace\.space\)/); assert.match(renderer, /parlyn\.editor\.transform-space/); + assert.match(renderer, /placeSelectionOnGround/); + assert.match(renderer, /key === "end"/); assert.match(threeRenderer, /setTransformSpace\(space\)/); assert.match(threeRenderer, /this\.transformMode === 'scale' \? 'local' : this\.transformSpace/); + assert.match(threeRenderer, /getGroundedPosition\(nodeId, groundY = -1\.55\)/); assert.match(renderer, /parlyn\.editor\.workspace-layout/); for (const [selector, column] of [['#hierarchy-panel', 1], ['#hierarchy-resizer', 2], ['.center', 3], ['#inspector-resizer', 4], ['#inspector-panel', 5]]) { assert.match(styles, new RegExp(`${selector.replace('.', '\\.') }\\{grid-column:${column}\\}`), `${selector} must keep a stable workspace grid column.`); diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs index c3d1168..19fad32 100644 --- a/scripts/check-windows-distribution.cjs +++ b/scripts/check-windows-distribution.cjs @@ -37,7 +37,7 @@ requireValue(workflow.includes('Verify signed Windows artifacts'), 'Signed outpu requireValue(workflow.includes('require_signing'), 'Windows workflow must distinguish signed and unsigned preflight builds.'); requireValue(!workflow.includes('BEGIN PRIVATE KEY'), 'Signing material must never be embedded in the workflow.'); requireValue(previewWorkflow.includes('Compress-Archive'), 'Unsigned previews must create a portable ZIP test artifact.'); -requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.14-x64.zip'), 'Portable preview artifact has an unexpected name.'); +requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.15-x64.zip'), 'Portable preview artifact has an unexpected name.'); requireValue(previewWorkflow.includes('release/win-unpacked/Parlyn Engine.exe'), 'Portable preview must verify its packaged executable.'); const gitignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); diff --git a/scripts/check.cjs b/scripts/check.cjs index c24238d..82928d5 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -48,6 +48,7 @@ const required = [ 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.12.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.13.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.14.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.15.md', 'build/icon.ico', '.github/workflows/windows-installer.yml', 'scripts/check-smart-systems.mjs', @@ -72,7 +73,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.5.0-beta.14') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.15') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/engine/render/RendererBackend.mjs b/src/engine/render/RendererBackend.mjs index dd932ef..98f24c8 100644 --- a/src/engine/render/RendererBackend.mjs +++ b/src/engine/render/RendererBackend.mjs @@ -10,6 +10,7 @@ export class RendererBackend { updateNodeTransform() { throw new Error('updateNodeTransform() not implemented'); } setTransformSnapping() { throw new Error('setTransformSnapping() not implemented'); } setTransformSpace() { throw new Error('setTransformSpace() not implemented'); } + getGroundedPosition() { throw new Error('getGroundedPosition() not implemented'); } setView() { throw new Error('setView() not implemented'); } dispose() {} } diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs index 9a8f167..e170d44 100644 --- a/src/engine/render/ThreeRenderer.mjs +++ b/src/engine/render/ThreeRenderer.mjs @@ -258,6 +258,15 @@ export class ThreeRenderer extends RendererBackend { this.#attachTransformControls(); } + getGroundedPosition(nodeId, groundY = -1.55) { + const object = this.nodeObjects.get(nodeId); + if (!object || !Number.isFinite(groundY)) return null; + object.updateWorldMatrix(true, true); + const bounds = new THREE.Box3().setFromObject(object); + if (bounds.isEmpty() || !Number.isFinite(bounds.min.y)) return null; + return { x:object.position.x, y:object.position.y + groundY - bounds.min.y, z:object.position.z }; + } + #attachTransformControls() { if (!this.transformControls) return; this.transformControls.detach(); diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index cd2e4a9..f343040 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -658,6 +658,30 @@ async function bootstrap() { selectById(duplicate.id); status.textContent = `Duplicated: ${duplicate.name}`; } + function placeSelectionOnGround() { + const nodes = [...selectedIds].map((id) => scene.findById(id)).filter(Boolean); + if (!nodes.length) { + status.textContent = "Select at least one node to place on the ground."; + return; + } + const before = sceneSnapshot(); + let placed = 0; + for (const node of nodes) { + const position = renderer.getGroundedPosition(node.id); + if (!position) continue; + node.position = position; + renderer.updateNodeTransform(node); + placed += 1; + } + if (!placed || JSON.stringify(before) === JSON.stringify(sceneSnapshot())) { + status.textContent = placed ? "Selection is already on the ground." : "The selection cannot be placed on the ground."; + return; + } + pushHistory(before, placed === 1 ? `Ground ${nodes[0].name}` : `Ground ${placed} nodes`); + if (selectedIds.size === 1) populateInspector(); + setDirty(true); + status.textContent = placed === 1 ? `Placed on ground: ${nodes[0].name}` : `Placed ${placed} nodes on ground`; + } function nodePath(node) { const names = []; for (let current = node; current && current !== scene.root; current = current.parent) names.unshift(current.name); @@ -1051,6 +1075,7 @@ async function bootstrap() { $("tool-scale").addEventListener("click", () => setTransformMode("scale")); $("snap-toggle").addEventListener("click", toggleTransformSnapping); $("transform-space").addEventListener("click", toggleTransformSpace); + $("place-on-ground").addEventListener("click", placeSelectionOnGround); $("snap-settings").addEventListener("click", openTransformSnapSettings); $("cancel-snap-settings").addEventListener("click", () => $("snap-settings-dialog").close()); $("save-snap-settings").addEventListener("click", saveTransformSnapSettings); @@ -1169,6 +1194,11 @@ async function bootstrap() { setTransformMode("scale"); return; } + if (key === "end") { + event.preventDefault(); + placeSelectionOnGround(); + return; + } } if (!(event.ctrlKey || event.metaKey)) return; if (key === "d" && !editing) { diff --git a/src/renderer/index.html b/src/renderer/index.html index bf088f4..cb9d861 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -77,6 +77,7 @@ +