From cfa16b3b49dd273f6468feeb7efac63ed2ff6043 Mon Sep 17 00:00:00 2001 From: WebCrew Date: Sun, 13 Sep 2026 08:11:46 +0200 Subject: [PATCH] Beta 21: optional Move-release surface placement --- .../workflows/publish-unsigned-preview.yml | 30 ++++----- CHANGES.md | 8 +++ README.md | 10 ++- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md | 4 +- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md | 39 +++++++++++ docs/ROADMAP.md | 20 +++++- docs/TESTING-v0.5.0.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/check-desktop-boundaries.cjs | 3 +- scripts/check-surface-placement.mjs | 65 +++++++++++++++++++ scripts/check-windows-distribution.cjs | 2 +- scripts/check.cjs | 6 +- src/engine/editor/SurfacePlacement.mjs | 16 +++++ src/engine/render/RendererBackend.mjs | 1 + src/engine/render/ThreeRenderer.mjs | 35 ++++++++++ src/renderer/app.mjs | 21 ++++++ src/renderer/index.html | 3 + 18 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md create mode 100644 scripts/check-surface-placement.mjs create mode 100644 src/engine/editor/SurfacePlacement.mjs 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 +