From 5fb002e00c546b0792107127d1359a78ce28d10a Mon Sep 17 00:00:00 2001 From: WebCrew Date: Tue, 15 Sep 2026 08:21:25 +0200 Subject: [PATCH 1/2] Beta 23: reopen the last saved editor session --- .../workflows/publish-unsigned-preview.yml | 26 ++--- CHANGES.md | 8 ++ README.md | 15 ++- docs/EDITOR-SESSION.md | 37 +++++++ docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md | 4 +- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md | 33 +++++++ docs/ROADMAP.md | 5 +- docs/TESTING-v0.5.0.md | 2 +- docs/WINDOWS-DISTRIBUTION.md | 4 +- package.json | 2 +- scripts/check-editor-session.mjs | 33 +++++++ scripts/check-electron-preload.cjs | 3 + scripts/check-frame-selected.mjs | 3 + scripts/check-windows-distribution.cjs | 2 +- scripts/check.cjs | 8 +- src/engine/editor/EditorSessionState.mjs | 55 +++++++++++ src/engine/render/ThreeRenderer.mjs | 15 +++ src/main/lastSessionFiles.mjs | 41 ++++++++ src/main/main.js | 96 +++++++++++++++++- src/main/preload.js | 3 + src/renderer/app.mjs | 99 ++++++++++++++++++- 21 files changed, 461 insertions(+), 33 deletions(-) create mode 100644 docs/EDITOR-SESSION.md create mode 100644 docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md create mode 100644 scripts/check-editor-session.mjs create mode 100644 src/engine/editor/EditorSessionState.mjs create mode 100644 src/main/lastSessionFiles.mjs diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 0c32535..aaafe23 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.22 + default: v0.5.0-beta.23 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 22 (unsigned) + default: Parlyn Engine v0.5.0 Beta 23 (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.22' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 22 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.23' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 23 (unsigned)' }} steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: throw "Expected exactly one Parlyn installer." } - $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.22-x64.zip" + $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.23-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,13 +72,13 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 22 + # Parlyn Engine v0.5.0 Beta 23 - This unsigned Windows beta completes the Phase 2 editor command and - keyboard audit. It centralizes shortcut routing, adds Delete selection - and provides a read-only shortcut reference under View. Text fields, - modal dialogs, key repeat and Alt/AltGr input are protected from - accidental editor commands. Beta 21 passed maintainer testing; Beta 22 + This unsigned Windows beta safely reopens the last saved project scene + or standalone scene after a normal application close. It restores the + project context, editor camera, 2.5D/3D view and valid selection. Missing + or moved files fall back to normal startup. Unsaved edits are never + restored by this feature. Beta 22 passed maintainer testing; Beta 23 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.22.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.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.22-x64.zip" ` + "release/Parlyn-Engine-Portable-0.5.0-beta.23-x64.zip" ` "release/latest.yml" ` "release/SHA256SUMS.txt" diff --git a/CHANGES.md b/CHANGES.md index 002d298..54692e7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,14 @@ ## Unreleased — Foundation Stabilization +- Beta 23: reopen the last saved project scene or standalone scene after a + normal application close, including camera, 2.5D/3D view and valid selection. +- Store only a small validated local session descriptor; unavailable or invalid + files fall back to normal startup and clear the stale entry. +- Preserve Save/Discard/Cancel semantics and keep unsaved recovery outside this + step. Explicitly closing or deleting a project prevents automatic reopening. +- Recorded successful Beta 22 human Windows acceptance. + - Beta 22: centralized editor keyboard routing, added Delete selection and a visible read-only shortcut reference under View. - Protected text editing, modal dialogs, key repeat, composition and Alt/AltGr diff --git a/README.md b/README.md index 9b55ca9..21d518c 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,17 @@ # Parlyn Engine -Beta 22 completes the Phase 2 command/keyboard audit with centralized shortcut +Beta 23 safely reopens the last saved project scene or standalone scene after a +normal application close. It restores the saved scene, project context, camera, +2.5D/3D view and valid selection while falling back to normal startup if files +were moved or removed. It never restores unsaved edits; separate autosave drafts +remain the final Phase 2 follow-up. Beta 23 awaits human Windows acceptance. + +Beta 22 completed the Phase 2 command/keyboard audit with centralized shortcut routing, Delete selection and a visible reference under **View → Keyboard Shortcuts**. Text entry, dialogs, key repeat and Alt/AltGr combinations are -protected from accidental editor commands. Beta 22 awaits human Windows -acceptance. +protected from accidental editor commands. Beta 22 passed human Windows +acceptance on 2026-09-15. Beta 21 added an optional **Surface** toggle in the Placement group: on Move release, a mesh or billboard drops onto the nearest suitable lower Mesh3D face @@ -68,6 +74,7 @@ The long-term direction includes: - Frame Selected for single and multiple nodes with the F shortcut - live Inspector synchronization during gizmo edits - editor camera orbit, pan and zoom +- safe reopening of the last saved project scene or standalone scene - undo/redo history foundation - cycle-safe scene hierarchy and isolated, bounded Undo/Redo snapshots - native scene open/save dialogs @@ -252,7 +259,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.22 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md). +repeatable [`v0.5.0-beta.23 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md). ### Windows release integrity diff --git a/docs/EDITOR-SESSION.md b/docs/EDITOR-SESSION.md new file mode 100644 index 0000000..0eebac9 --- /dev/null +++ b/docs/EDITOR-SESSION.md @@ -0,0 +1,37 @@ +# Editor Session Reopening + +Beta 23 reopens the last saved working context after a normal Parlyn shutdown. +The local descriptor contains only the active project or standalone scene path, +project-relative scene path, 2.5D/3D mode, bounded camera values and up to 100 +node IDs. It is stored below Electron's per-user application-data directory, not +inside the project and not in scene documents. + +## Safety contract + +- Scene content is always loaded from the normal saved scene file. +- Unsaved content is never placed in the session descriptor. +- Save, Discard and Cancel retain their existing meanings. +- Explicit Close Project and Delete Project clear the remembered context. +- Missing, moved, corrupt or incompatible files clear the stale descriptor and + fall back to normal startup instead of creating a retry loop. +- Selection IDs are restored only when they still exist in the loaded scene. +- Camera values, payload size and session format are validated and bounded. +- Failure to write optional session metadata must not block Save or application + shutdown. + +Autosave is deliberately separate. Beta 23 cannot recover work after a crash or +power loss and does not claim to do so. + +## Reference review + +- [Godot EditorSettings](https://docs.godotengine.org/en/stable/classes/class_editorsettings.html) + provides explicit restoration settings for scene tabs, scripts and windows. +- [Unity workspace customization](https://docs.unity3d.com/6000.0/Documentation/Manual/CustomizingYourWorkspace.html) + provides saved/restored editor layouts. +- [Unreal layout customization](https://dev.epicgames.com/documentation/en-us/unreal-engine/layout-customization) + persists editor tabs and layout between sessions. + +Parlyn adopts the familiar continuity goal but keeps this first implementation +smaller and easier to reason about: one last saved scene context, strict fallback +and no coupling between session convenience and project data. A future multi-tab +workspace can extend the versioned descriptor without changing scene files. diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md index 406d51e..8ccd02d 100644 --- a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Beta 22 — Maintainer Acceptance -Status: pending human Windows acceptance. Beta 21 passed maintainer testing on -2026-09-15. +Status: passed human Windows acceptance on 2026-09-15. Beta 21 passed maintainer +testing on 2026-09-15. Scope: final Phase 2 command and keyboard consistency audit. This release adds Delete for the current selection, centralizes existing shortcuts and displays a diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md new file mode 100644 index 0000000..2a72466 --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md @@ -0,0 +1,33 @@ +# Parlyn Engine v0.5.0 Beta 23 — Maintainer Acceptance + +Status: pending human Windows acceptance. Beta 22 passed maintainer testing on +2026-09-15. + +Scope: reopen the last saved project scene or standalone scene after a normal +application close. This includes the editor camera, 2.5D/3D view and valid node +selection. This release does not add autosave or recovery of unsaved edits. + +1. Open a project with at least two scenes and switch to its non-startup scene. + Save it, select two nodes, choose 3D view and noticeably orbit, pan and zoom. +2. Close Parlyn normally and reopen it. The same project and scene, saved scene + contents, 3D mode, camera position and both selected nodes must return. +3. Confirm the restored scene is clean (no unsaved marker) and its saved + Undo/Redo history still behaves as before. +4. Change a node, close Parlyn and choose **Save and Continue**. Reopen Parlyn; + the saved change and last workspace state must return. +5. Change a node again, close Parlyn and choose **Discard**. Reopen Parlyn; the + previous saved value must return and the discarded value must stay gone. +6. Change a node, close Parlyn and choose **Cancel**. Parlyn must remain open + with the unsaved change intact. Save or discard it before continuing. +7. Use **Close Project**, then close and reopen Parlyn. It must show the normal + empty startup workspace and must not reopen the deliberately closed project. +8. Open and save a standalone scene outside a project. Close and reopen Parlyn; + that saved scene and its camera/view/selection must return without a project. +9. Using a disposable project copy, close Parlyn and then rename or move that + project folder in Explorer. Parlyn must fall back to normal startup without a + startup loop. A second restart must remain normal. +10. Run a short regression for scene switching, selection, Move/Rotate/Scale, + Snap, Surface, Ground, Frame Selected, Bounds, panels and Save/Open. + +Only saved scene data is reopened. Unsaved recovery and separate drafts belong +to the next independently tested Phase 2 step. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b0685dc..80dea9b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,13 +71,14 @@ Completion evidence: world-space authoring guides passed human acceptance with Beta 20; - consistent commands and keyboard behavior — centralized conflict-safe command routing, Delete selection and a visible shortcut reference implemented in - Beta 22, human acceptance pending. + Beta 22, passed human acceptance on 2026-09-15. 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. + view/camera/selection safely; unavailable files must fall back to normal + startup. Implemented in Beta 23; human acceptance pending. 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 diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 66e8688..dff0b5f 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.22.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.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/docs/WINDOWS-DISTRIBUTION.md b/docs/WINDOWS-DISTRIBUTION.md index 5408db4..7b5200c 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.18`; its repeatable checklist is documented in -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.18.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.18.md). +`v0.5.0-beta.23`; its repeatable checklist is documented in +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.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.json b/package.json index a47d596..a2a650c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.22", + "version": "0.5.0-beta.23", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-editor-session.mjs b/scripts/check-editor-session.mjs new file mode 100644 index 0000000..f35dc4c --- /dev/null +++ b/scripts/check-editor-session.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { normalizeEditorSessionState, normalizeEditorViewState } from '../src/engine/editor/EditorSessionState.mjs'; +import { clearLastSession, readLastSession, writeLastSession } from '../src/main/lastSessionFiles.mjs'; + +const view = normalizeEditorViewState({ mode:'3d', camera:{ target:{ x:3, y:4, z:5 }, orbit:{ yaw:1, pitch:99, distance:500 } }, selectionIds:['one', 'one', '', 7, 'two'] }); +assert.deepEqual(view, { mode:'3d', camera:{ target:{ x:3, y:4, z:5 }, orbit:{ yaw:1, pitch:1.25, distance:40 } }, selectionIds:['one', 'two'] }); +assert.equal(normalizeEditorSessionState(null), null); +assert.equal(normalizeEditorSessionState({ version:1, kind:'project', projectRoot:'C:\\Game' }), null); + +const state = normalizeEditorSessionState({ version:1, kind:'project', projectRoot:'C:\\Game', scenePath:'scenes/Level.parlyn-scene.json', view }); +assert.equal(state.kind, 'project'); +assert.equal(state.scenePath, 'scenes/Level.parlyn-scene.json'); + +const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-session-')); +const filePath = path.join(directory, 'last-session.json'); +try { + assert.equal(await readLastSession(filePath), null); + await writeLastSession(filePath, state); + assert.deepEqual(await readLastSession(filePath), state); + await fs.writeFile(filePath, '{broken', 'utf8'); + await assert.rejects(readLastSession(filePath)); + assert.equal(await readLastSession(filePath), null); + await writeLastSession(filePath, { version:1, kind:'scene', filePath:'C:\\Scene.json', view:{} }); + await clearLastSession(filePath); + assert.equal(await readLastSession(filePath), null); +} finally { + await fs.rm(directory, { recursive:true, force:true }); +} + +console.log('Editor last-session validation check passed.'); diff --git a/scripts/check-electron-preload.cjs b/scripts/check-electron-preload.cjs index e16933d..fd3049d 100644 --- a/scripts/check-electron-preload.cjs +++ b/scripts/check-electron-preload.cjs @@ -7,6 +7,9 @@ const methods = [ 'editorReady', 'onAppCloseRequested', 'confirmAppClose', + 'rememberLastSession', + 'restoreLastSession', + 'clearLastSession', 'createProject', 'openProject', 'openProjectScene', diff --git a/scripts/check-frame-selected.mjs b/scripts/check-frame-selected.mjs index 35c36d8..8de643c 100644 --- a/scripts/check-frame-selected.mjs +++ b/scripts/check-frame-selected.mjs @@ -24,4 +24,7 @@ assert.equal(renderer.frameSelection(["left", "right"]), true); assert.deepEqual(renderer.cameraTarget.toArray().map((value) => Number(value.toFixed(6))), [0, 3, 0]); assert.ok(renderer.orbit.distance > 5, "Multiple separated nodes should require a wider frame."); +renderer.restoreEditorCameraState({ target:{ x:7, y:8, z:9 }, orbit:{ yaw:0.8, pitch:0.4, distance:14 } }); +assert.deepEqual(renderer.getEditorCameraState(), { target:{ x:7, y:8, z:9 }, orbit:{ yaw:0.8, pitch:0.4, distance:14 } }); + console.log("Frame Selected camera contract check passed."); diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs index 9e36b2c..32cf6b6 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.22-x64.zip'), 'Portable preview artifact has an unexpected name.'); +requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.23-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 0c62f80..f052a36 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -69,9 +69,14 @@ const required = [ 'src/engine/editor/SurfacePlacement.mjs', 'scripts/check-editor-shortcuts.mjs', 'src/engine/editor/EditorShortcuts.mjs', + 'src/engine/editor/EditorSessionState.mjs', + 'src/main/lastSessionFiles.mjs', + 'scripts/check-editor-session.mjs', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md', 'docs/EDITOR-COMMANDS.md', + 'docs/EDITOR-SESSION.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.23.md', 'src/main/sceneHistoryFiles.mjs', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md', 'src/engine/core/DocumentBounds.mjs', @@ -88,7 +93,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.22') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.23') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -118,6 +123,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-document-bound 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-editor-shortcuts.mjs')], { stdio: 'inherit' }); +cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-editor-session.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/EditorSessionState.mjs b/src/engine/editor/EditorSessionState.mjs new file mode 100644 index 0000000..023f11a --- /dev/null +++ b/src/engine/editor/EditorSessionState.mjs @@ -0,0 +1,55 @@ +export const EDITOR_SESSION_VERSION = 1; + +const DEFAULT_CAMERA = Object.freeze({ + target:Object.freeze({ x:0, y:0.7, z:0 }), + orbit:Object.freeze({ yaw:-0.55, pitch:0.42, distance:11 }) +}); + +function finite(value, fallback, minimum = -1_000_000, maximum = 1_000_000) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(maximum, Math.max(minimum, numeric)); +} + +export function normalizeEditorViewState(candidate) { + const source = candidate && typeof candidate === "object" ? candidate : {}; + const camera = source.camera && typeof source.camera === "object" ? source.camera : {}; + const target = camera.target && typeof camera.target === "object" ? camera.target : {}; + const orbit = camera.orbit && typeof camera.orbit === "object" ? camera.orbit : {}; + const selectionIds = Array.isArray(source.selectionIds) + ? [...new Set(source.selectionIds.filter((id) => typeof id === "string" && id.length > 0 && id.length <= 256))].slice(0, 100) + : []; + return { + mode:source.mode === "3d" ? "3d" : "2.5d", + camera:{ + target:{ + x:finite(target.x, DEFAULT_CAMERA.target.x), + y:finite(target.y, DEFAULT_CAMERA.target.y), + z:finite(target.z, DEFAULT_CAMERA.target.z) + }, + orbit:{ + yaw:finite(orbit.yaw, DEFAULT_CAMERA.orbit.yaw, -Math.PI * 20, Math.PI * 20), + pitch:finite(orbit.pitch, DEFAULT_CAMERA.orbit.pitch, -1.25, 1.25), + distance:finite(orbit.distance, DEFAULT_CAMERA.orbit.distance, 2.5, 40) + } + }, + selectionIds + }; +} + +export function normalizeEditorSessionState(candidate) { + if (!candidate || typeof candidate !== "object" || candidate.version !== EDITOR_SESSION_VERSION) return null; + if (!['project', 'scene'].includes(candidate.kind)) return null; + const location = candidate.kind === 'project' ? candidate.projectRoot : candidate.filePath; + if (typeof location !== 'string' || !location.trim() || location.length > 32_768) return null; + if (candidate.kind === 'project' && (typeof candidate.scenePath !== 'string' || !/^scenes\/.+\.parlyn-scene\.json$/.test(candidate.scenePath))) return null; + return { + format:'parlyn-editor-session', + version:EDITOR_SESSION_VERSION, + kind:candidate.kind, + ...(candidate.kind === 'project' + ? { projectRoot:location, scenePath:candidate.scenePath } + : { filePath:location }), + view:normalizeEditorViewState(candidate.view) + }; +} diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs index 0be6b2d..906e3b4 100644 --- a/src/engine/render/ThreeRenderer.mjs +++ b/src/engine/render/ThreeRenderer.mjs @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { TransformControls } from 'three/addons/controls/TransformControls.js'; import { RendererBackend } from './RendererBackend.mjs'; import { normalizeDocumentBounds } from '../core/DocumentBounds.mjs'; +import { normalizeEditorViewState } from '../editor/EditorSessionState.mjs'; export class ThreeRenderer extends RendererBackend { constructor(container, callbacks = {}) { @@ -414,6 +415,20 @@ export class ThreeRenderer extends RendererBackend { this.#updateCamera(); } + getEditorCameraState() { + return { + target:{ x:this.cameraTarget.x, y:this.cameraTarget.y, z:this.cameraTarget.z }, + orbit:{ ...this.orbit } + }; + } + + restoreEditorCameraState(camera) { + const normalized=normalizeEditorViewState({ camera }).camera; + this.cameraTarget.set(normalized.target.x,normalized.target.y,normalized.target.z); + this.orbit={ ...normalized.orbit }; + this.#updateCamera(); + } + #installInteraction() { const canvas = this.renderer.domElement; canvas.addEventListener('contextmenu',(event)=>event.preventDefault()); diff --git a/src/main/lastSessionFiles.mjs b/src/main/lastSessionFiles.mjs new file mode 100644 index 0000000..5ac34ee --- /dev/null +++ b/src/main/lastSessionFiles.mjs @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { normalizeEditorSessionState } from '../engine/editor/EditorSessionState.mjs'; + +const MAX_SESSION_BYTES = 64 * 1024; + +export async function readLastSession(filePath) { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile() || stat.size > MAX_SESSION_BYTES) throw new Error('Last-session data is not a valid small file.'); + const normalized = normalizeEditorSessionState(JSON.parse(await fs.readFile(filePath, 'utf8'))); + if (!normalized) throw new Error('Last-session data has an unsupported format.'); + return normalized; + } catch (error) { + if (error?.code === 'ENOENT') return null; + await clearLastSession(filePath); + throw error; + } +} + +export async function writeLastSession(filePath, candidate) { + const normalized = normalizeEditorSessionState(candidate); + if (!normalized) throw new TypeError('Invalid editor session state.'); + const source = `${JSON.stringify(normalized, null, 2)}\n`; + if (Buffer.byteLength(source, 'utf8') > MAX_SESSION_BYTES) throw new Error('Last-session data is too large.'); + const temporaryPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; + await fs.mkdir(path.dirname(filePath), { recursive:true }); + try { + await fs.writeFile(temporaryPath, source, { encoding:'utf8', flag:'wx' }); + await fs.rename(temporaryPath, filePath); + } catch (error) { + await fs.rm(temporaryPath, { force:true }).catch(() => {}); + throw error; + } + return normalized; +} + +export async function clearLastSession(filePath) { + await fs.rm(filePath, { force:true }); +} diff --git a/src/main/main.js b/src/main/main.js index 7949f86..7a86b1d 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -10,10 +10,12 @@ const { listAssets, moveAsset } = require('./assetFiles'); const persistence = import('../engine/persistence/DocumentPersistence.mjs'); const documentFiles = import('./documentFiles.mjs'); const sceneHistoryFiles = import('./sceneHistoryFiles.mjs'); +const lastSessionFiles = import('./lastSessionFiles.mjs'); const EDITOR_FILE = path.join(__dirname, '..', 'renderer', 'index.html'); const EDITOR_URL = pathToFileURL(EDITOR_FILE).href; const approvedWindowClosures = new WeakSet(); const readyEditorWindows = new WeakSet(); +let activeLooseScenePath = null; app.setAppUserModelId('org.parlyn.engine'); @@ -86,6 +88,19 @@ function requireScenePath(value) { return value; } +function lastSessionPath() { + return path.join(app.getPath('userData'), 'editor', 'last-session.json'); +} + +async function clearRememberedSession() { + await (await lastSessionFiles).clearLastSession(lastSessionPath()); +} + +async function clearRememberedSessionSafely() { + try { await clearRememberedSession(); } + catch (error) { console.warn('Last-session state could not be cleared:',error); } +} + function createWindow() { const win = new BrowserWindow({ width:1500, @@ -154,14 +169,16 @@ secureHandle('parlyn:scene:save-as', async (payload) => { const result = await dialog.showSaveDialog({ title:'Save Parlyn Scene', defaultPath:defaultName, filters:[{ name:'Parlyn Scene', extensions:['json'] }] }); if (result.canceled || !result.filePath) return { canceled:true }; await writeDocumentAtomic(result.filePath, payload?.scene, 'parlyn-scene', 'Parlyn scene'); - return { canceled:false, filePath:result.filePath }; + activeLooseScenePath = await fs.realpath(result.filePath); + return { canceled:false, filePath:activeLooseScenePath }; }, { payload:true }); secureHandle('parlyn:scene:open', async () => { const result = await dialog.showOpenDialog({ title:'Open Parlyn Scene', properties:['openFile'], filters:[{ name:'Parlyn Scene', extensions:['json'] }] }); if (result.canceled || !result.filePaths[0]) return { canceled:true }; const filePath=result.filePaths[0]; - return { canceled:false, filePath, scene:await readDocument(filePath, 'parlyn-scene', 'Parlyn scene') }; + activeLooseScenePath=await fs.realpath(filePath); + return { canceled:false, filePath:activeLooseScenePath, scene:await readDocument(activeLooseScenePath, 'parlyn-scene', 'Parlyn scene') }; }); secureHandle('parlyn:project:create', async (payload) => { @@ -194,6 +211,7 @@ secureHandle('parlyn:project:create', async (payload) => { throw error; } const activeProjectRoot=projectSession.activate(await fs.realpath(projectRoot)); + activeLooseScenePath=null; return { canceled:false, projectRoot:activeProjectRoot, project, world, assets:await listAssets(activeProjectRoot), scenes:await listProjectScenes(activeProjectRoot) }; }, { payload:true }); @@ -209,6 +227,7 @@ secureHandle('parlyn:project:open', async () => { const world=await readDocument(worldPath, 'parlyn-world', 'World document'); const sceneHistory=await loadSceneHistory(projectRoot, project.startupScene, scene); projectSession.activate(projectRoot); + activeLooseScenePath=null; return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot), scenes:await listProjectScenes(projectRoot), history:sceneHistory.history, historyWarning:sceneHistory.warning }; }); @@ -256,9 +275,78 @@ secureHandle('parlyn:project:move-scene', async (payload) => { return { ok:true, relativePath:targetPath, project, scene, scenes:await listProjectScenes(activeProjectRoot) }; }, { payload:true }); -secureHandle('parlyn:project:close', async () => projectSession.close()); +secureHandle('parlyn:project:close', async () => { + const result=projectSession.close(); + if (result.ok) await clearRememberedSessionSafely(); + return result; +}); + +secureHandle('parlyn:session:remember', async (payload) => { + let candidate; + if (payload?.kind === 'project') { + const projectRoot=projectSession.activeProjectRoot; + if (!projectRoot) throw new Error('No active project can be remembered.'); + const scenePath=requireScenePath(payload.scenePath); + await resolveExistingProjectPath(projectRoot,scenePath,'Remembered project scene'); + candidate={ version:1, kind:'project', projectRoot, scenePath, view:payload.view }; + } else if (payload?.kind === 'scene') { + if (!activeLooseScenePath || payload.filePath !== activeLooseScenePath) throw new Error('Only the active saved scene can be remembered.'); + candidate={ version:1, kind:'scene', filePath:activeLooseScenePath, view:payload.view }; + } else { + await clearRememberedSession(); + return { ok:true, cleared:true }; + } + await (await lastSessionFiles).writeLastSession(lastSessionPath(),candidate); + return { ok:true }; +}, { payload:true }); + +secureHandle('parlyn:session:clear', async () => { + await clearRememberedSession(); + return { ok:true }; +}); + +secureHandle('parlyn:session:restore', async () => { + let remembered; + try { + remembered=await (await lastSessionFiles).readLastSession(lastSessionPath()); + } catch (error) { + return { restored:false, warning:`Saved session settings were invalid and have been reset: ${error.message}` }; + } + if (!remembered) return { restored:false }; + try { + if (remembered.kind === 'scene') { + if (!path.isAbsolute(remembered.filePath)) throw new Error('Remembered scene path is not absolute.'); + const filePath=await fs.realpath(remembered.filePath); + const scene=await readDocument(filePath,'parlyn-scene','Remembered Parlyn scene'); + projectSession.close(); + activeLooseScenePath=filePath; + return { restored:true, kind:'scene', filePath, scene, view:remembered.view }; + } + if (!path.isAbsolute(remembered.projectRoot)) throw new Error('Remembered project path is not absolute.'); + const projectRoot=await fs.realpath(remembered.projectRoot); + const projectFile=await resolveExistingProjectPath(projectRoot,'parlyn.project.json','Remembered project file'); + const project=await readDocument(projectFile,'parlyn-project','Remembered project file'); + const scenePath=await resolveExistingProjectPath(projectRoot,remembered.scenePath,'Remembered project scene'); + const worldPath=await resolveExistingProjectPath(projectRoot,project.world,'Remembered world document'); + const scene=await readDocument(scenePath,'parlyn-scene','Remembered project scene'); + const world=await readDocument(worldPath,'parlyn-world','Remembered world document'); + const sceneHistory=await loadSceneHistory(projectRoot,remembered.scenePath,scene); + projectSession.activate(projectRoot); + activeLooseScenePath=null; + return { restored:true, kind:'project', projectRoot, project, scene, world, scenePath:remembered.scenePath, assets:await listAssets(projectRoot), scenes:await listProjectScenes(projectRoot), history:sceneHistory.history, historyWarning:sceneHistory.warning, view:remembered.view }; + } catch (error) { + projectSession.close(); + activeLooseScenePath=null; + await clearRememberedSession(); + return { restored:false, warning:`The last session is unavailable; normal startup was used. ${error.message}` }; + } +}); -secureHandle('parlyn:project:delete', async (payload) => projectSession.moveToTrash(payload?.confirmationName), { payload:true }); +secureHandle('parlyn:project:delete', async (payload) => { + const result=await projectSession.moveToTrash(payload?.confirmationName); + if (result.ok) await clearRememberedSessionSafely(); + return result; +}, { payload:true }); secureHandle('parlyn:project:save-scene', async (payload) => { const activeProjectRoot=projectSession.activeProjectRoot; diff --git a/src/main/preload.js b/src/main/preload.js index 21f68aa..ff997cc 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -10,6 +10,9 @@ contextBridge.exposeInMainWorld('parlynHost', { return ()=>ipcRenderer.removeListener('parlyn:app:close-requested',listener); }, confirmAppClose:()=>ipcRenderer.invoke('parlyn:app:confirm-close'), + rememberLastSession:(payload)=>ipcRenderer.invoke('parlyn:session:remember',payload), + restoreLastSession:()=>ipcRenderer.invoke('parlyn:session:restore'), + clearLastSession:()=>ipcRenderer.invoke('parlyn:session:clear'), copyText:(text)=>ipcRenderer.invoke('parlyn:clipboard:write-text',{ text }), saveSceneAs:(payload)=>ipcRenderer.invoke('parlyn:scene:save-as',payload), openScene:()=>ipcRenderer.invoke('parlyn:scene:open'), diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index 9f2e124..c76761b 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -49,6 +49,7 @@ async function bootstrap() { let currentErrorReport = null; let gizmoStartSnapshot = null; let transformMode = "select"; + let currentViewMode = "2.5d"; const moduleEvents = new EventTarget(); const modules = new ModuleRegistry({ events: moduleEvents, log: (message) => { console.info(`[Parlyn Module] ${message}`); @@ -395,9 +396,89 @@ async function bootstrap() { status.textContent = "Application close canceled."; return; } + try { await rememberCurrentSession(); } + catch (error) { console.warn("Last session could not be remembered:",error); } try { await host.confirmAppClose(); } catch (error) { showError("Application close failed", error); } } + + function currentEditorViewState() { + return { mode:currentViewMode, camera:renderer.getEditorCameraState(), selectionIds:[...selectedIds] }; + } + + async function rememberCurrentSession() { + try { + if (currentProject) return await host.rememberLastSession({ kind:'project', scenePath:currentSceneRelativePath || currentProject.startupScene, view:currentEditorViewState() }); + if (currentFilePath) return await host.rememberLastSession({ kind:'scene', filePath:currentFilePath, view:currentEditorViewState() }); + return await host.clearLastSession(); + } catch (error) { + console.warn('Last session could not be remembered:',error); + return { ok:false }; + } + } + + function restoreEditorView(view) { + currentViewMode=view?.mode === '3d' ? '3d' : '2.5d'; + $("view-25").classList.toggle("active",currentViewMode === '2.5d'); + $("view-3d").classList.toggle("active",currentViewMode === '3d'); + renderer.restoreEditorCameraState(view?.camera); + const validIds=(view?.selectionIds ?? []).filter((id) => scene.findById(id)); + if (!validIds.length) { + clearSelection(); + return; + } + selectedIds=new Set(validIds); + selectionAnchorId=validIds[0]; + selected=scene.findById(validIds[0]); + renderer.setSelection(validIds,selected.id); + renderHierarchy(); + if (validIds.length === 1) populateInspector(); + else { + $("inspector-empty").hidden=false; + $("inspector").hidden=true; + } + $("selected-type").textContent=validIds.length === 1 ? selected.type : `${validIds.length} selected`; + $("delete-node").disabled=false; + $("duplicate-node").disabled=validIds.length !== 1; + $("reparent-node").disabled=validIds.length !== 1; + $("frame-selected").disabled=false; + } + + async function restoreLastSession() { + const result=await host.restoreLastSession(); + if (!result.restored) return { restored:false, message:result.warning || null }; + if (result.kind === 'project') { + currentProject=ProjectDocument.fromJSON(result.project); + currentWorld=result.world ? WorldDocument.fromJSON(result.world) : null; + currentProjectRoot=result.projectRoot; + currentSceneRelativePath=result.scenePath; + currentFilePath=null; + assets=result.assets ?? []; + projectScenes=result.scenes ?? []; + } else { + currentProject=null; + currentWorld=null; + currentProjectRoot=null; + currentSceneRelativePath=null; + currentFilePath=result.filePath; + assets=[]; + projectScenes=[]; + } + scene=SceneDocument.fromJSON(result.scene); + if (result.history) { + try { history.restoreState(result.history); } + catch (error) { console.warn('Saved scene history was ignored:',error); history.clear(); } + } else history.clear(); + renderer.rebuild(scene); + restoreEditorView(result.view); + updateHistoryButtons(); + updateProjectUI(); + renderAssets(); + renderProjectScenes(); + setDirty(false); + const label=result.kind === 'project' ? `${currentProject.name} · ${currentSceneRelativePath}` : shortPath(currentFilePath); + return { restored:true, message:result.historyWarning ? `Last session reopened without local history: ${result.historyWarning}` : `Last session reopened: ${label}` }; + } async function leaveProjectForLooseScene() { if (!currentProject) return true; const result = await host.closeProject(); @@ -779,6 +860,7 @@ async function bootstrap() { clearSelection(); updateHistoryButtons(); setDirty(true); + await rememberCurrentSession(); status.textContent = "New untitled scene"; } catch (error) { showError("New scene failed", error); @@ -796,6 +878,7 @@ async function bootstrap() { return false; } setDirty(false); + await rememberCurrentSession(); status.textContent = `Saved project scene: ${currentSceneRelativePath || currentProject.startupScene}`; return true; } @@ -806,6 +889,7 @@ async function bootstrap() { } currentFilePath = result.filePath; setDirty(false); + await rememberCurrentSession(); status.textContent = `Saved: ${shortPath(currentFilePath)}`; return true; } catch (error) { @@ -827,6 +911,7 @@ async function bootstrap() { clearSelection(); updateHistoryButtons(); setDirty(false); + await rememberCurrentSession(); status.textContent = `Opened: ${shortPath(currentFilePath)}`; } catch (error) { showError("Scene open failed", error); @@ -857,6 +942,7 @@ async function bootstrap() { renderAssets(); renderProjectScenes(); setDirty(false); + await rememberCurrentSession(); status.textContent = `Project created: ${currentProject.name}`; } catch (error) { showError("Project creation failed", error); @@ -889,6 +975,7 @@ async function bootstrap() { renderAssets(); renderProjectScenes(); setDirty(false); + await rememberCurrentSession(); status.textContent = result.historyWarning ? `Project opened without local history: ${result.historyWarning}` : `Project opened: ${currentProject.name}`; @@ -1010,6 +1097,7 @@ async function bootstrap() { updateHistoryButtons(); renderProjectScenes(); setDirty(false); + await rememberCurrentSession(); status.textContent = result.historyWarning ? `Scene opened without local history: ${result.historyWarning}` : `Opened project scene: ${relativePath}`; } catch (error) { showError("Project scene open failed", error); @@ -1033,6 +1121,7 @@ async function bootstrap() { updateProjectUI(); renderProjectScenes(); setDirty(false); + await rememberCurrentSession(); $("create-scene-dialog").close(); status.textContent = `Scene created: ${result.relativePath}`; } catch (error) { @@ -1055,6 +1144,7 @@ async function bootstrap() { updateProjectUI(); renderProjectScenes(); setDirty(false); + await rememberCurrentSession(); $("move-scene-dialog").close(); status.textContent = `Scene moved: ${result.relativePath}`; } catch (error) { @@ -1249,12 +1339,14 @@ async function bootstrap() { host.onAppCloseRequested(handleAppCloseRequest); moduleEvents.addEventListener("module-changed", renderModules); $("view-25").addEventListener("click", () => { + currentViewMode = "2.5d"; renderer.setView("2.5d"); $("view-25").classList.add("active"); $("view-3d").classList.remove("active"); status.textContent = "2.5D editor view"; }); $("view-3d").addEventListener("click", () => { + currentViewMode = "3d"; renderer.setView("3d"); $("view-3d").classList.add("active"); $("view-25").classList.remove("active"); @@ -1336,7 +1428,10 @@ async function bootstrap() { renderAssets(); renderProjectScenes(); updateProjectUI(); - selectById(scene.root.children[1].id); + let startupResult={ restored:false, message:null }; + try { startupResult=await restoreLastSession(); } + catch (error) { console.warn('Last session could not be restored:',error); startupResult={ restored:false, message:'Last session could not be restored; normal startup was used.' }; } + if (!startupResult.restored) selectById(scene.root.children[1].id); updateHistoryButtons(); setDirty(false); let appVersion = "unknown"; @@ -1348,7 +1443,7 @@ async function bootstrap() { } $("brand-version").textContent = `${appVersion} GitHub Preview`; $("footer-version").textContent = `v${appVersion}`; - status.textContent = `Ready \xB7 Parlyn ${appVersion} \xB7 THREE renderer backend`; + status.textContent = startupResult.message || `Ready \xB7 Parlyn ${appVersion} \xB7 THREE renderer backend`; await host.editorReady(); } bootstrap().catch((error) => { From fdd11aff4d3f4b2a6e59b958b004e1cb0591c0ee Mon Sep 17 00:00:00 2001 From: WebCrew Date: Tue, 15 Sep 2026 08:21:46 +0200 Subject: [PATCH 2/2] chore: update Beta 23 package lock --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a785db..b76451b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.22", + "version": "0.5.0-beta.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.22", + "version": "0.5.0-beta.23", "license": "MIT", "dependencies": { "three": "0.185.1"