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 @@
+