diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml
index a2f20a6..845fcc1 100644
--- a/.github/workflows/publish-unsigned-preview.yml
+++ b/.github/workflows/publish-unsigned-preview.yml
@@ -7,12 +7,12 @@ on:
description: New prerelease tag; an existing release is never overwritten
required: true
type: string
- default: v0.5.0-beta.20
+ default: v0.5.0-beta.21
preview_title:
description: Public prerelease title
required: true
type: string
- default: Parlyn Engine v0.5.0 Beta 20 (unsigned)
+ default: Parlyn Engine v0.5.0 Beta 21 (unsigned)
permissions:
contents: write
@@ -24,8 +24,8 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
GH_TOKEN: ${{ github.token }}
- PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.20' }}
- PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 20 (unsigned)' }}
+ PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.21' }}
+ PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 21 (unsigned)' }}
steps:
- name: Checkout
@@ -55,7 +55,7 @@ jobs:
throw "Expected exactly one Parlyn installer."
}
- $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.20-x64.zip"
+ $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.21-x64.zip"
if (-not (Test-Path -LiteralPath "release/win-unpacked/Parlyn Engine.exe")) {
throw "Packaged Parlyn executable was not found for the portable build."
}
@@ -72,14 +72,14 @@ jobs:
shell: pwsh
run: |
@"
- # Parlyn Engine v0.5.0 Beta 20
-
- This unsigned Windows beta fixes saved Undo/Redo disappearing after
- switching project scenes. History is stored independently per scene.
- It retains Beta 19's optional scene and world authoring bounds.
- Beta 19 was not accepted due to the history defect; Beta 20 awaits
- human Windows acceptance. Previously overwritten history cannot be
- reconstructed. Older editors discard bounds on save.
+ # Parlyn Engine v0.5.0 Beta 21
+
+ This unsigned Windows beta adds optional Surface placement on Move
+ gizmo release. Meshes and billboards drop to a lower authored mesh
+ surface or the editor ground; one Undo reverses movement and placement.
+ It does not implement runtime physics, slope alignment or imported
+ terrain. Rotate, Scale, Inspector and Ground/End remain unchanged.
+ Beta 20 passed maintainer testing; Beta 21 awaits human acceptance.
The portable ZIP creates no Windows installation or uninstall entry and
remains the preferred unsigned test path.
@@ -96,7 +96,7 @@ jobs:
other public certificate provider.
Maintainer acceptance instructions:
- https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md
+ https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md
"@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8
- name: Publish GitHub pre-release
@@ -114,6 +114,6 @@ jobs:
--notes-file release/PREVIEW-NOTES.md `
--prerelease `
"$($installer[0].FullName)" `
- "release/Parlyn-Engine-Portable-0.5.0-beta.20-x64.zip" `
+ "release/Parlyn-Engine-Portable-0.5.0-beta.21-x64.zip" `
"release/latest.yml" `
"release/SHA256SUMS.txt"
diff --git a/CHANGES.md b/CHANGES.md
index a94dd28..de03896 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,14 @@
## Unreleased — Foundation Stabilization
+- Beta 21: optional persistent Surface toggle places the moved primary node
+ on a lower mesh support or editor ground when the Move gizmo is released.
+- Combined placement and movement into one Undo/Redo step; excluded self,
+ selection and related hierarchy from support targets.
+- Added ray/eligibility and actual editor commit regression tests.
+- Recorded Beta 20 human acceptance and explicitly planned the later session
+ reopening and separate autosave-draft steps, preserving Discard semantics.
+
- Beta 20: fixed project scenes overwriting one shared history file. Saved
Undo/Redo now uses a separate hashed path per scene, with read-only legacy
fallback and unchanged stale-snapshot/size validation.
diff --git a/README.md b/README.md
index 05e1be0..6532472 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,18 @@
# Parlyn Engine
+Beta 21 adds an optional **Surface** toggle in the Placement group: on Move
+release, a mesh or billboard drops onto the nearest suitable lower Mesh3D face
+or the existing editor ground. Movement plus placement is one Undo step. It
+does not affect Rotate, Scale or Inspector edits and is not physics/collision.
+Beta 21 awaits human Windows acceptance; Beta 20 passed maintainer testing.
+
Beta 20 fixes saved per-scene Undo/Redo history after a scene switch. It retains
Beta 19's optional scene/world bounds through **Bounds** in the View command
group: blue scene guides and amber world guides in world coordinates. They do
not restrict camera movement, objects or physics. Scene edits use Undo/Redo and
normal Save; world edits use explicit Save World Bounds. Beta 19 was not accepted
-due to the history defect; Beta 20 human Windows acceptance is pending.
+due to the history defect; Beta 20 subsequently passed human Windows acceptance.
Beta 18 passed the full maintainer editor regression test.
**Parlyn Engine** is a free and open-source game engine designed primarily for **modern 2.5D development**, while keeping real 3D available wherever a project benefits from it.
@@ -237,7 +243,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.20 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md).
+repeatable [`v0.5.0-beta.21 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md).
### Windows release integrity
diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md
index 3830ab1..10e6f75 100644
--- a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md
+++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md
@@ -1,6 +1,8 @@
# Parlyn Engine v0.5.0 Beta 20 — Maintainer Acceptance
-Status: pending human Windows acceptance. Beta 19 was not accepted because
+Status: passed human Windows acceptance on 2026-09-13, including saved per-scene
+Undo/Redo, Bounds validation/persistence and additional editor regression tests.
+Beta 19 was not accepted because
saving a second project scene overwrote the first scene's local history.
Beta 20 separates saved history by project-relative scene path.
diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md
new file mode 100644
index 0000000..277b65d
--- /dev/null
+++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md
@@ -0,0 +1,39 @@
+# Parlyn Engine v0.5.0 Beta 21 — Maintainer Acceptance
+
+Status: pending human Windows acceptance. Beta 20 passed the maintainer's
+full Bounds/history re-test and additional editor regression tests on 2026-09-13.
+
+Scope: optional downward authoring placement on Move-gizmo release only.
+No collision/physics, slope alignment, Inspector snap or asset-import changes.
+Surface supports currently mean authored Mesh3D objects, not sprite backdrops.
+
+1. Locate the Surface icon in the Placement group (down arrow onto a sloped
+ line). Verify its tooltip, focus, pressed state and independent toggle.
+ In a fresh preference store it must be off.
+2. With Surface off, Move an object above the floor. It stays where released.
+3. Turn Surface on, Move it while above the floor: on release its visible
+ bottom rests at the existing editor ground Y=-1.55. It does not drop mid-drag.
+4. Create a Mesh3D platform via Add Node, Scale it wider/flatter and place it
+ below another mesh or billboard. With Surface on, release Move above it:
+ the node rests on its nearest lower mesh top, not on the ground below it.
+5. Move beyond the platform: placement falls back to the editor ground. A node
+ below the ground with no lower support must not be lifted automatically.
+ This downward tool does not resolve already-intersecting geometry.
+6. Undo once restores the whole previous position; Redo restores the moved
+ and placed result. Save, switch A/B scenes, reopen and verify history.
+7. Rotate and Scale do not drop objects; Inspector coordinate changes do not
+ drop objects. A click without changing the gizmo must not invoke placement.
+8. Check scaled/rotated moving objects, 2.5D and 3D views and billboard placement.
+ Only the center-underfoot support is sampled; slope-normal alignment and
+ footprint-wide collision are not part of this foundation.
+9. With multi-selection, only the existing primary-node Move behavior applies;
+ selected nodes and related hierarchy nodes must not act as self-support.
+10. Transform Snap remains independent. Surface positioning takes precedence
+ over the final vertical grid increment. Ground button/End stays unchanged.
+11. Toggle Surface, restart and confirm the preference is restored. Toggling
+ must not mark a scene dirty or add History. Re-test panels, Bounds, F,
+ Save/Open and unsaved Cancel/Discard/Save & Continue.
+
+Unavailable, disabled, overhead or steep mesh faces are not support targets.
+The downward ray is bounded to 1000000 authoring units. This is not runtime
+collision detection or an imported-terrain placement promise.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 1e7ab6b..82cbfba 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -49,7 +49,7 @@ Completion evidence:
- duplication and reparenting — initial editor commands complete;
- durable, validated local Scene History — initial foundation complete;
- Beta 20 corrects saved per-scene history isolation; human re-test pending;
+ Beta 20 saved per-scene history isolation passed human re-testing on 2026-09-13;
- multi-selection where appropriate — initial hierarchy and viewport selection complete;
- multi-scene project browser — validated listing, switching, creation, rename and move complete;
- rename and move project assets — initial safe editor workflow complete;
@@ -64,12 +64,26 @@ Completion evidence:
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 — initial Ground command and End
- shortcut complete; automatic surface placement remains;
+ shortcut complete; optional Move-release surface placement implemented in
+ Beta 21, human acceptance pending;
- Frame Selected — initial single- and multi-selection camera framing complete;
- optional scene/world bounds independent from viewport size — initial validated
- world-space authoring guides implemented in Beta 19; human acceptance pending;
+ world-space authoring guides passed human acceptance with Beta 20;
- consistent commands and keyboard behavior.
+Explicitly accepted Phase 2 workspace follow-ups (planned, not parallel work):
+
+1. Optional **Reopen Last Session** after the remaining placement and command
+ checks: reopen the last project/scene's saved state, then restore editor
+ view/camera/selection safely; unavailable files must fall back to normal startup.
+2. Optional autosave to separate drafts, implemented and tested as its own step.
+ Normal Save commits scene data; Discard must discard the draft too. Autosave
+ must not silently overwrite scene files or restore intentionally discarded
+ changes. A minimal offer to reopen an interrupted draft belongs to this
+ design, not a broader recovery subsystem.
+
+These follow-ups were explicitly approved by the maintainer on 2026-09-13.
+
## Phase 3 — 2.5D renderer and materials
**Status: Planned**
diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md
index de149e1..580c7ec 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.20.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md)
+[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md)
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/package-lock.json b/package-lock.json
index a46302f..d95af0f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "parlyn-engine",
- "version": "0.5.0-beta.20",
+ "version": "0.5.0-beta.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "parlyn-engine",
- "version": "0.5.0-beta.20",
+ "version": "0.5.0-beta.21",
"license": "MIT",
"dependencies": {
"three": "0.185.1"
diff --git a/package.json b/package.json
index e6bb634..1e9110d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "parlyn-engine",
- "version": "0.5.0-beta.20",
+ "version": "0.5.0-beta.21",
"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 040eaa8..8583fde 100644
--- a/scripts/check-desktop-boundaries.cjs
+++ b/scripts/check-desktop-boundaries.cjs
@@ -66,7 +66,8 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP
assert.match(html, /class="tool-group viewport-tool-group" aria-label="Transform tools"/);
assert.match(html, /class="tool-group placement-tool-group" aria-label="Placement tools"/);
assert.match(html, /class="tool-group viewport-view-group" aria-label="Viewport mode"/);
- assert.equal((html.match(/class="command-icon"/g) || []).length, 8, 'Command Bar must expose eight Parlyn command icons.');
+ assert.equal((html.match(/class="command-icon"/g) || []).length, 9, 'Command Bar must expose nine Parlyn command icons including Surface.');
+ assert.match(html, /id="surface-placement"[^>]*aria-pressed="false"/);
assert.match(html, /id="tool-select"[^>]+aria-label="Select"/);
assert.match(html, /id="tool-move"[^>]+aria-label="Move"/);
assert.match(html, /id="tool-rotate"[^>]+aria-label="Rotate"/);
diff --git a/scripts/check-surface-placement.mjs b/scripts/check-surface-placement.mjs
new file mode 100644
index 0000000..e6ec8b6
--- /dev/null
+++ b/scripts/check-surface-placement.mjs
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import * as THREE from 'three';
+import { SceneDocument } from '../src/engine/scene/SceneDocument.mjs';
+import { Node3D } from '../src/engine/core/Node3D.mjs';
+import { Node2_5D } from '../src/engine/core/Node2_5D.mjs';
+import { ThreeRenderer } from '../src/engine/render/ThreeRenderer.mjs';
+import { History } from '../src/engine/history/History.mjs';
+import { placeNodeOnSurface } from '../src/engine/editor/SurfacePlacement.mjs';
+
+const scene = new SceneDocument();
+const base = scene.root.addChild(new Node3D({ type:'Mesh3D', position:{y:0}, scale:{x:4,y:0.5,z:4} }));
+const upper = scene.root.addChild(new Node3D({ type:'Mesh3D', position:{y:2}, scale:{x:2,y:0.5,z:2} }));
+const moving = scene.root.addChild(new Node3D({ type:'Mesh3D', position:{y:5} }));
+const renderer = new ThreeRenderer(null); renderer.scene = new THREE.Scene(); renderer.rebuild(scene);
+assert.equal(renderer.getSurfacePlacedPosition(moving.id).y, 3.125, 'Nearest lower platform must win.');
+assert.equal(renderer.getSurfacePlacedPosition(moving.id, [upper.id]).y, 1.125);
+const beforeQuery = scene.toJSON();
+renderer.getSurfacePlacedPosition(moving.id);
+assert.deepEqual(scene.toJSON(), beforeQuery, 'Query must not mutate documents.');
+moving.position.x = 20; renderer.updateNodeTransform(moving);
+assert.ok(Math.abs(renderer.getSurfacePlacedPosition(moving.id).y + 0.8) < 0.000001, 'Missing mesh support uses the editor ground.');
+moving.position.y = -5; renderer.updateNodeTransform(moving);
+assert.equal(renderer.getSurfacePlacedPosition(moving.id), null, 'No upward placement from below ground.');
+assert.equal(renderer.getSurfacePlacedPosition('missing'), null);
+moving.position = {x:0,y:5,z:0}; upper.position.y = 8; renderer.updateNodeTransform(moving); renderer.updateNodeTransform(upper);
+assert.equal(renderer.getSurfacePlacedPosition(moving.id).y, 1.125, 'Overhead platforms must not lift the node.');
+upper.position.y = 2;
+moving.position = {x:0,y:5,z:0};
+upper.enabled = false; renderer.rebuild(scene);
+assert.equal(renderer.getSurfacePlacedPosition(moving.id).y, 1.125, 'Disabled supports are excluded.');
+upper.enabled = true;
+const sprite = scene.root.addChild(new Node2_5D({ type:'Billboard2_5D', position:{x:0,y:6,z:0} }));
+sprite.metadata = {width:2,height:2}; renderer.rebuild(scene);
+assert.equal(renderer.getSurfacePlacedPosition(sprite.id).y, 3.375, 'Billboards can be placed on mesh surfaces.');
+assert.equal(placeNodeOnSurface(scene, renderer, sprite.id), true);
+assert.equal(placeNodeOnSurface(scene, renderer, sprite.id), false, 'Already placed nodes are a no-op.');
+assert.equal(placeNodeOnSurface(scene, renderer, scene.root.id), false);
+
+// Execute the actual editor commit function to verify opt-out and one-step Undo.
+const source = fs.readFileSync(new URL('../src/renderer/app.mjs', import.meta.url), 'utf8');
+const start = source.indexOf('function commitGizmoTransform(id, transform)');
+const end = source.indexOf('function transformModeLabel', start);
+assert.ok(start >= 0 && end > start);
+for (const enabled of [false, true]) for (const mode of ['translate', 'rotate', 'scale']) {
+ moving.position = {x:0,y:5,z:0}; renderer.rebuild(scene);
+ const before = scene.toJSON(); const history = new History();
+ const context = {scene, renderer, gizmoStartSnapshot:before, transformMode:mode,
+ surfacePlacementEnabled:enabled, selected:null, selectedIds:new Set([moving.id]),
+ placeNodeOnSurface, populateInspector(){}, sceneSnapshot:()=>scene.toJSON(),
+ applyGizmoTransform(id, transform){ moving.position = {...transform.position}; renderer.updateNodeTransform(moving); },
+ pushHistory(snapshot,label){ history.push(snapshot,label); },
+ transformModeLabel:()=>mode, status:{textContent:''} };
+ const commit = vm.runInNewContext('(' + source.slice(start,end).trim() + ')', context);
+ commit(moving.id, {position:{x:0.2,y:5,z:0}});
+ assert.equal(moving.position.y, enabled && mode === 'translate' ? 3.125 : 5);
+ assert.equal(history.undoStack.length, 1, 'Move and placement must be one history entry.');
+ assert.deepEqual(history.undo(scene.toJSON()).snapshot, before);
+}
+// Hierarchy exclusions must not inadvertently exclude unrelated sibling supports.
+moving.position = {x:0,y:5,z:0}; renderer.rebuild(scene);
+assert.equal(placeNodeOnSurface(scene, renderer, moving.id, [moving.id]), true);
+assert.equal(moving.position.y, 3.125);
+console.log('Optional Move-release surface placement checks passed.');
diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs
index 8b75034..40e31ae 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.20-x64.zip'), 'Portable preview artifact has an unexpected name.');
+requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.21-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 c12cb16..cc39820 100644
--- a/scripts/check.cjs
+++ b/scripts/check.cjs
@@ -65,6 +65,9 @@ const required = [
'scripts/check-frame-selected.mjs',
'scripts/check-document-bounds.mjs',
'scripts/check-scene-history-files.mjs',
+ 'scripts/check-surface-placement.mjs',
+ 'src/engine/editor/SurfacePlacement.mjs',
+ 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md',
'src/main/sceneHistoryFiles.mjs',
'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md',
'src/engine/core/DocumentBounds.mjs',
@@ -81,7 +84,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.20') throw new Error(`Unexpected package version: ${pkg.version}`);
+if (pkg.version !== '0.5.0-beta.21') throw new Error(`Unexpected package version: ${pkg.version}`);
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -109,6 +112,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-transform-spac
cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-frame-selected.mjs')], { stdio: 'inherit' });
cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-document-bounds.mjs')], { stdio: 'inherit' });
cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-scene-history-files.mjs')], { stdio: 'inherit' });
+cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-surface-placement.mjs')], { stdio: 'inherit' });
cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-asset-files.cjs')], { stdio: 'inherit' });
cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' });
diff --git a/src/engine/editor/SurfacePlacement.mjs b/src/engine/editor/SurfacePlacement.mjs
new file mode 100644
index 0000000..eddb265
--- /dev/null
+++ b/src/engine/editor/SurfacePlacement.mjs
@@ -0,0 +1,16 @@
+export function placeNodeOnSurface(scene, renderer, nodeId, selectedIds = []) {
+ const node = scene.findById(nodeId);
+ if (!node || !['Mesh3D', 'Sprite2_5D', 'Billboard2_5D'].includes(node.type)) return false;
+ const excluded = new Set([nodeId, ...selectedIds]);
+ for (const id of [nodeId, ...selectedIds]) {
+ const related = scene.findById(id);
+ related?.walk(child => excluded.add(child.id));
+ for (let ancestor = related?.parent; ancestor; ancestor = ancestor.parent) excluded.add(ancestor.id);
+ }
+ const position = renderer.getSurfacePlacedPosition(nodeId, [...excluded]);
+ if (!position || !['x', 'y', 'z'].every(axis => Number.isFinite(position[axis]))) return false;
+ if (Math.abs(position.y - node.position.y) < 0.000001) return false;
+ node.position = position;
+ renderer.updateNodeTransform(node);
+ return true;
+}
diff --git a/src/engine/render/RendererBackend.mjs b/src/engine/render/RendererBackend.mjs
index ff91223..f223525 100644
--- a/src/engine/render/RendererBackend.mjs
+++ b/src/engine/render/RendererBackend.mjs
@@ -11,6 +11,7 @@ export class RendererBackend {
setTransformSnapping() { throw new Error('setTransformSnapping() not implemented'); }
setTransformSpace() { throw new Error('setTransformSpace() not implemented'); }
getGroundedPosition() { throw new Error('getGroundedPosition() not implemented'); }
+ getSurfacePlacedPosition() { throw new Error('getSurfacePlacedPosition() not implemented'); }
frameSelection() { throw new Error('frameSelection() not implemented'); }
setDocumentBounds() { throw new Error('setDocumentBounds() not implemented'); }
setView() { throw new Error('setView() not implemented'); }
diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs
index f67c5a9..0be6b2d 100644
--- a/src/engine/render/ThreeRenderer.mjs
+++ b/src/engine/render/ThreeRenderer.mjs
@@ -183,6 +183,10 @@ export class ThreeRenderer extends RendererBackend {
} else return;
object.userData.parlynNodeId = node.id;
+ object.userData.surfaceSupport = node.type === 'Mesh3D';
+ for (let related = node; related; related = related.parent) {
+ if (!related.enabled) object.userData.surfaceSupport = false;
+ }
this.#applyNodeTransform(node, object);
this.nodeObjects.set(node.id, object);
this.scene.add(object);
@@ -298,6 +302,37 @@ export class ThreeRenderer extends RendererBackend {
return { x:object.position.x, y:object.position.y + groundY - bounds.min.y, z:object.position.z };
}
+ getSurfacePlacedPosition(nodeId, excludedIds = [], 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() || ![bounds.min.y, object.position.x, object.position.y, object.position.z].every(Number.isFinite)) return null;
+ const center = bounds.getCenter(new THREE.Vector3());
+ if (![center.x, center.y, center.z].every(Number.isFinite)) return null;
+ const epsilon = 0.001;
+ const maxDistance = 1000000;
+ const excluded = new Set([nodeId, ...excludedIds]);
+ const candidates = [];
+ for (const [id, candidate] of this.nodeObjects) {
+ if (excluded.has(id) || !candidate.userData.surfaceSupport || !candidate.visible) continue;
+ candidate.updateWorldMatrix(true, true);
+ candidates.push(candidate);
+ }
+ const ray = new THREE.Raycaster(new THREE.Vector3(center.x, bounds.min.y + epsilon, center.z), new THREE.Vector3(0, -1, 0), 0, maxDistance);
+ let supportY = groundY <= bounds.min.y + epsilon && groundY >= bounds.min.y - maxDistance ? groundY : null;
+ for (const hit of ray.intersectObjects(candidates, true)) {
+ if (!hit.face || !Number.isFinite(hit.point.y) || hit.point.y > bounds.min.y + epsilon) continue;
+ const normal = hit.face.normal.clone().applyNormalMatrix(new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld));
+ if (normal.y < 0.5) continue;
+ if (supportY === null || hit.point.y > supportY) supportY = hit.point.y;
+ }
+ if (supportY === null) return null;
+ const y = object.position.y + supportY - bounds.min.y;
+ if (!Number.isFinite(y)) return null;
+ return { x:object.position.x, y, z:object.position.z };
+ }
+
frameSelection(nodeIds = [...this.selectedIds]) {
if (!this.camera) return false;
const bounds = new THREE.Box3();
diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs
index 3f391e5..0e4205d 100644
--- a/src/renderer/app.mjs
+++ b/src/renderer/app.mjs
@@ -15,6 +15,7 @@ import { createErrorReport } from "../engine/editor/ErrorReport.mjs";
import { DEFAULT_TRANSFORM_SNAPPING, normalizeTransformSnapping } from "../engine/editor/TransformSnapping.mjs";
import { normalizeTransformSpace } from "../engine/editor/TransformSpace.mjs";
import { normalizeDocumentBounds } from "../engine/core/DocumentBounds.mjs";
+import { placeNodeOnSurface } from "../engine/editor/SurfacePlacement.mjs";
async function bootstrap() {
const $ = (id) => document.getElementById(id);
const status = $("status");
@@ -25,6 +26,8 @@ async function bootstrap() {
let workspaceLayout = readWorkspaceLayout();
let transformSnapping = readTransformSnapping();
let transformSpace = readTransformSpace();
+ let surfacePlacementEnabled = false;
+ try { surfacePlacementEnabled = localStorage.getItem("parlyn.editor.surface-placement") === "true"; } catch { /* Keep the safe default. */ }
const history = new History({ limit: 100 });
let scene = createDemoScene();
let selected = null;
@@ -530,6 +533,10 @@ async function bootstrap() {
const before = gizmoStartSnapshot;
gizmoStartSnapshot = null;
if (node && before && JSON.stringify(before) !== JSON.stringify(sceneSnapshot())) {
+ if (transformMode === "translate" && surfacePlacementEnabled) {
+ placeNodeOnSurface(scene, renderer, id, [...selectedIds]);
+ if (selected?.id === id) populateInspector();
+ }
pushHistory(before, `${transformModeLabel(transformMode)} ${node.name}`);
status.textContent = `${transformModeLabel(transformMode)}: ${node.name}`;
}
@@ -1163,6 +1170,20 @@ async function bootstrap() {
$("tool-scale").addEventListener("click", () => setTransformMode("scale"));
$("snap-toggle").addEventListener("click", toggleTransformSnapping);
$("transform-space").addEventListener("click", toggleTransformSpace);
+ function updateSurfacePlacementButton() {
+ const button = $("surface-placement");
+ button.classList.toggle("active", surfacePlacementEnabled);
+ button.setAttribute("aria-pressed", String(surfacePlacementEnabled));
+ button.title = `Surface placement: ${surfacePlacementEnabled ? "on" : "off"} (on Move release)`;
+ }
+ updateSurfacePlacementButton();
+ $("surface-placement").addEventListener("click", () => {
+ surfacePlacementEnabled = !surfacePlacementEnabled;
+ updateSurfacePlacementButton();
+ try { localStorage.setItem("parlyn.editor.surface-placement", String(surfacePlacementEnabled)); }
+ catch (error) { console.warn("Surface placement preference could not be saved:", error); }
+ status.textContent = `Surface placement ${surfacePlacementEnabled ? "enabled" : "disabled"}; applies on Move release only.`;
+ });
$("place-on-ground").addEventListener("click", placeSelectionOnGround);
$("frame-selected").addEventListener("click", frameSelected);
$("snap-settings").addEventListener("click", openTransformSnapSettings);
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 65bdb34..b22a1b0 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -93,6 +93,9 @@
World
+