From 4d85a3e6653db57de6c6d203ba4d11f0d8292465 Mon Sep 17 00:00:00 2001 From: WebCrew Date: Sun, 13 Sep 2026 06:55:22 +0200 Subject: [PATCH] Add optional scene and world authoring bounds for Beta 19 --- .../workflows/publish-unsigned-preview.yml | 27 ++++--- CHANGES.md | 6 ++ README.md | 8 +- docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md | 33 ++++++++ docs/PROJECT-FORMAT.md | 16 ++++ docs/ROADMAP.md | 3 +- docs/TESTING-v0.5.0.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/check-document-bounds.mjs | 78 +++++++++++++++++++ scripts/check-windows-distribution.cjs | 2 +- scripts/check.cjs | 6 +- src/engine/core/DocumentBounds.mjs | 18 +++++ src/engine/render/RendererBackend.mjs | 1 + src/engine/render/ThreeRenderer.mjs | 31 ++++++++ src/engine/scene/SceneDocument.mjs | 6 +- src/engine/world/WorldDocument.mjs | 8 +- src/renderer/app.mjs | 75 ++++++++++++++++++ src/renderer/index.html | 16 ++++ src/renderer/styles.css | 4 + 20 files changed, 324 insertions(+), 22 deletions(-) create mode 100644 docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md create mode 100644 scripts/check-document-bounds.mjs create mode 100644 src/engine/core/DocumentBounds.mjs diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 188edc4..af28131 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.18 + default: v0.5.0-beta.19 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 18 (unsigned) + default: Parlyn Engine v0.5.0 Beta 19 (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.18' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 18 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.19' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 19 (unsigned)' }} steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: throw "Expected exactly one Parlyn installer." } - $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.18-x64.zip" + $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.19-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,11 +72,14 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 18 - - This unsigned Windows beta adds Frame Selected for single and multiple - nodes through the Command Bar and the F shortcut. The command preserves - the current viewing angle and changes only the editor camera. + # Parlyn Engine v0.5.0 Beta 19 + + This unsigned Windows beta adds optional scene and world bounds as + world-space authoring guides through the Bounds command. Scene bounds + use normal saving and Undo/Redo; world bounds are explicitly saved. + Bounds do not constrain objects, cameras or runtime physics. + Beta 18 passed the maintainer's full editor regression test; Beta 19 + awaits human Windows acceptance. Older editors discard bounds on save. The portable ZIP creates no Windows installation or uninstall entry and remains the preferred unsigned test path. @@ -93,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.18.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release @@ -111,6 +114,6 @@ jobs: --notes-file release/PREVIEW-NOTES.md ` --prerelease ` "$($installer[0].FullName)" ` - "release/Parlyn-Engine-Portable-0.5.0-beta.18-x64.zip" ` + "release/Parlyn-Engine-Portable-0.5.0-beta.19-x64.zip" ` "release/latest.yml" ` "release/SHA256SUMS.txt" diff --git a/CHANGES.md b/CHANGES.md index cc57d55..5595dfb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## Unreleased — Foundation Stabilization +- Beta 19: added optional validated scene/world authoring bounds, independent + of viewport size, with separate blue/amber guides and explicit world saving. +- Added scene bounds Undo/Redo and round-trip/validation/helper-lifecycle tests. +- Recorded the full successful Beta 18 maintainer regression test; Beta 19 + awaits human Windows acceptance. + - Added Frame Selected for single and multiple nodes through the Command Bar and F shortcut. - Preserved the current viewing angle while fitting the selection's visible bounds with a safe margin. - Recorded the successful Beta 17 maintainer acceptance of the Command Bar and regression scope. diff --git a/README.md b/README.md index 67247f8..3f1f3bb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ # Parlyn Engine +Beta 19 adds 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. Human Beta 19 Windows +acceptance is pending. 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. Parlyn treats sprites, billboards, layered depth, lighting and real 3D geometry as parts of the same scene workflow. 2.5D is not a secondary editor mode: it is a first-class engine concept. @@ -229,7 +235,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.18 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.18.md). +repeatable [`v0.5.0-beta.19 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md). ### Windows release integrity diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md new file mode 100644 index 0000000..a263002 --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md @@ -0,0 +1,33 @@ +# Parlyn Engine v0.5.0 Beta 19 — Maintainer Acceptance + +Status: pending human Windows acceptance. Beta 18 passed the maintainer's full +editor regression test on 2026-09-12. + +1. Open an existing project. Bounds are initially absent. Open **Bounds** in + the View command group; check tooltips, keyboard focus and readable labels. +2. Enable Current Scene bounds, enter Min (-10,-3,-10), Max (10,8,10), and + Apply. A blue box appears and the scene becomes unsaved. +3. Undo removes the box; Redo restores it. Save, close and reopen the scene: + the exact values and guide survive. Test switching between two scenes. +4. Change coordinates, disable bounds, Cancel and press Escape. Canceled edits + must not affect the document or history. Applying unchanged values must + not mark an otherwise saved scene dirty. +5. Enter equal/reversed Min/Max, blank values or coordinates outside ±1000000: + no invalid document is applied or saved; correction remains possible. +6. Select Project World and enable different bounds. **Save World Bounds** + produces an amber box. Reopen the project and verify persistence. World + bounds are separate from scene Undo/Redo and do not clear scene dirtiness. +7. In a loose scene, Project World must be unavailable. Disable World bounds + and save; no amber box remains. A failed world save must retain previous + world bounds and report an error in the dialog. +8. Orbit/pan/zoom, change 2.5D/3D, hide/show/resize panels and resize the window. + The bounds remain fixed in world coordinates, not screen coordinates. +9. Move objects outside the boxes. They remain editable; boxes cannot be + selected or included by Frame Selected. Camera movement remains unrestricted. +10. Re-test Move/Rotate/Scale, Snap, Ground/End, Frame Selected/F, selection, + Undo/Redo, Save, project close and unsaved Cancel/Discard/Save & Continue. + +Scope: optional authoring guides only. No collision walls, runtime constraints, +camera clamps, world streaming or Smart Systems simulation are implemented. +Use Beta 19 or later to edit bounds; older editors do not preserve this optional +field when saving. Projects without bounds remain compatible and unchanged. diff --git a/docs/PROJECT-FORMAT.md b/docs/PROJECT-FORMAT.md index 7faafed..713fbe9 100644 --- a/docs/PROJECT-FORMAT.md +++ b/docs/PROJECT-FORMAT.md @@ -1,5 +1,21 @@ # Parlyn Project Format +## Optional authoring bounds (Beta 19) + +Scene v2 and World v1 documents may carry an optional `bounds` object with +`min` and `max` vectors containing finite `x`, `y`, `z` coordinates within +±1000000 units. Each minimum must be strictly below its maximum. Omitted or +null bounds mean disabled; disabled bounds are omitted on serialization. +These additive authoring fields do not alter runtime behavior. They define a +world-space axis-aligned guide, never a viewport size, camera clamp or collision +wall. Scene and World guides are independent and may overlap. + +Scene bounds use scene saving and local Undo/Redo. World bounds use explicit +world saving and are not part of scene history. Beta 19 validates them on load +and save; use Beta 19 or later when editing these documents. Older editors may +discard the optional bounds field on save. Documents without bounds retain +their existing serialized shape. + Parlyn v0.4 introduces the first project-folder convention. ```text diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9c212e0..11769e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,7 +65,8 @@ Completion evidence: place the current selection on the ground — initial Ground command and End shortcut complete; automatic surface placement remains; - Frame Selected — initial single- and multi-selection camera framing complete; -- optional scene/world bounds independent from viewport size; +- optional scene/world bounds independent from viewport size — initial validated + world-space authoring guides implemented in Beta 19; human acceptance pending; - consistent commands and keyboard behavior. ## Phase 3 — 2.5D renderer and materials diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 5efa80e..4ad4626 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.18.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.18.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.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 b27bdce..d087d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.18", + "version": "0.5.0-beta.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.18", + "version": "0.5.0-beta.19", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index f35192b..437e29f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.18", + "version": "0.5.0-beta.19", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-document-bounds.mjs b/scripts/check-document-bounds.mjs new file mode 100644 index 0000000..f5b40ac --- /dev/null +++ b/scripts/check-document-bounds.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import * as THREE from 'three'; +import { normalizeDocumentBounds } from '../src/engine/core/DocumentBounds.mjs'; +import { SceneDocument } from '../src/engine/scene/SceneDocument.mjs'; +import { WorldDocument } from '../src/engine/world/WorldDocument.mjs'; +import { History } from '../src/engine/history/History.mjs'; +import { SceneHistoryDocument } from '../src/engine/history/SceneHistoryDocument.mjs'; +import { normalizeDocument } from '../src/engine/persistence/DocumentPersistence.mjs'; +import { ThreeRenderer } from '../src/engine/render/ThreeRenderer.mjs'; + +const bounds = { min:{ x:-20, y:-3, z:-10 }, max:{ x:20, y:8, z:10 } }; +assert.equal(normalizeDocumentBounds(undefined), null); +for (const invalid of [false, [], {}, { min:[], max:{} }]) assert.throws(() => normalizeDocumentBounds(invalid)); +for (const bad of [NaN, Infinity, -Infinity, '1', 1000001, -1000001]) { + const copy = structuredClone(bounds); copy.min.x = bad; + assert.throws(() => normalizeDocumentBounds(copy)); +} +for (const axis of ['x', 'y', 'z']) for (const offset of [0, 1]) { + const copy = structuredClone(bounds); copy.min[axis] = copy.max[axis] + offset; + assert.throws(() => normalizeDocumentBounds(copy)); +} +const normalized = normalizeDocumentBounds(bounds); +normalized.min.x = 99; +assert.equal(bounds.min.x, -20, 'Normalization must isolate mutable vectors.'); + +const scene = new SceneDocument(); +const oldScene = scene.toJSON(); +assert.ok(!Object.hasOwn(oldScene, 'bounds')); +assert.equal(SceneDocument.fromJSON(oldScene).bounds, null); +const oldWorld = new WorldDocument().toJSON(); +assert.equal(WorldDocument.fromJSON(oldWorld).bounds, null); +scene.bounds = structuredClone(bounds); +const world = new WorldDocument({ bounds }); +for (const doc of [scene, world]) { + const json = doc.toJSON(); + assert.deepEqual(normalizeDocument(JSON.parse(JSON.stringify(json))).bounds, bounds); + json.bounds.max.x = 99; + assert.equal(doc.bounds.max.x, 20, 'Serialization must isolate bounds.'); + doc.bounds.min.x = doc.bounds.max.x; + assert.throws(() => doc.toJSON()); + doc.bounds = structuredClone(bounds); +} +const history = new History(); +history.push(oldScene, 'Change Scene Bounds'); +const undo = history.undo(scene.toJSON()); +assert.equal(SceneDocument.fromJSON(undo.snapshot).bounds, null); +assert.deepEqual(SceneDocument.fromJSON(history.redo(oldScene).snapshot).bounds, bounds); +const restoredHistory = new History(); +restoredHistory.restoreState(history.exportState()); +assert.equal(restoredHistory.canUndo, true); +const historyDocument = new SceneHistoryDocument({ scenePath:'scenes/main.parlynscene', currentScene:scene.toJSON(), history:history.exportState() }); +assert.deepEqual(normalizeDocument(historyDocument.toJSON()).currentScene.bounds, bounds); +for (const original of [scene.toJSON(), world.toJSON()]) { + const invalid = structuredClone(original); + invalid.bounds.min.y = invalid.bounds.max.y; + assert.throws(() => normalizeDocument(invalid), 'Invalid bounds must be rejected on document load.'); +} + +const renderer = new ThreeRenderer(null); +renderer.scene = new THREE.Scene(); +const targetBefore = renderer.cameraTarget.clone(); +renderer.setDocumentBounds('scene', bounds); +renderer.setDocumentBounds('world', bounds); +assert.equal(renderer.scene.children.length, 2); +assert.equal(renderer.nodeObjects.size, 0, 'Bounds guides must not be selectable scene nodes.'); +const helper = renderer.boundsHelpers.get('scene'); +assert.deepEqual(helper.box.min.toArray(), [-20, -3, -10]); +renderer.setDocumentBounds('scene', structuredClone(bounds)); +assert.equal(renderer.boundsHelpers.get('scene'), helper, 'Unchanged guides must not be recreated.'); +let disposed = false; +helper.geometry.addEventListener('dispose', () => { disposed = true; }); +renderer.setDocumentBounds('scene', null); +assert.equal(disposed, true); +assert.equal(renderer.scene.children.length, 1); +assert.deepEqual(renderer.cameraTarget, targetBefore); +renderer.setDocumentBounds('world', null); +assert.equal(renderer.scene.children.length, 0); +console.log('Optional scene/world bounds contract check passed.'); diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs index b8e171d..3c38dcb 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.18-x64.zip'), 'Portable preview artifact has an unexpected name.'); +requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.19-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 69cc3ed..7308131 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -63,6 +63,9 @@ const required = [ 'scripts/check-transform-snapping.mjs', 'scripts/check-transform-space.mjs', 'scripts/check-frame-selected.mjs', + 'scripts/check-document-bounds.mjs', + 'src/engine/core/DocumentBounds.mjs', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md', 'scripts/check-asset-files.cjs', 'scripts/check-electron-preload.cjs', 'scripts/check-windows-distribution.cjs', @@ -75,7 +78,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.18') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.19') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -101,6 +104,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-error-reportin cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-transform-snapping.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-transform-space.mjs')], { stdio: 'inherit' }); 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-asset-files.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' }); diff --git a/src/engine/core/DocumentBounds.mjs b/src/engine/core/DocumentBounds.mjs new file mode 100644 index 0000000..3e299f5 --- /dev/null +++ b/src/engine/core/DocumentBounds.mjs @@ -0,0 +1,18 @@ +// Authoring bounds are optional world-space guides, never viewport or physics limits. +export function normalizeDocumentBounds(value) { + if (value === undefined || value === null) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Bounds must be an object or null.'); + const result = { min:{}, max:{} }; + for (const end of ['min', 'max']) { + if (!value[end] || typeof value[end] !== 'object' || Array.isArray(value[end])) throw new TypeError(`Bounds ${end} must be a vector.`); + for (const axis of ['x', 'y', 'z']) { + const n = value[end][axis]; + if (!Number.isFinite(n) || Math.abs(n) > 1000000) throw new RangeError('Bounds coordinates must be finite and within ±1000000 units.'); + result[end][axis] = n; + } + } + for (const axis of ['x', 'y', 'z']) { + if (result.min[axis] >= result.max[axis]) throw new RangeError(`Bounds minimum ${axis.toUpperCase()} must be below its maximum.`); + } + return result; +} diff --git a/src/engine/render/RendererBackend.mjs b/src/engine/render/RendererBackend.mjs index c5a2653..ff91223 100644 --- a/src/engine/render/RendererBackend.mjs +++ b/src/engine/render/RendererBackend.mjs @@ -12,6 +12,7 @@ export class RendererBackend { setTransformSpace() { throw new Error('setTransformSpace() not implemented'); } getGroundedPosition() { throw new Error('getGroundedPosition() not implemented'); } frameSelection() { throw new Error('frameSelection() not implemented'); } + setDocumentBounds() { throw new Error('setDocumentBounds() not implemented'); } setView() { throw new Error('setView() not implemented'); } dispose() {} } diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs index fc56416..f67c5a9 100644 --- a/src/engine/render/ThreeRenderer.mjs +++ b/src/engine/render/ThreeRenderer.mjs @@ -1,6 +1,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'; export class ThreeRenderer extends RendererBackend { constructor(container, callbacks = {}) { @@ -24,6 +25,8 @@ export class ThreeRenderer extends RendererBackend { this.cameraTarget = new THREE.Vector3(0, 0.7, 0); this.orbit = { yaw:-0.55, pitch:0.42, distance:11 }; this.drag = null; + this.boundsHelpers = new Map(); + this.boundsKeys = new Map(); } async initialize(sceneDocument) { @@ -72,11 +75,13 @@ export class ThreeRenderer extends RendererBackend { }); sceneDocument.root.walk((node) => this.#createObjectForNode(node)); + this.setDocumentBounds('scene', sceneDocument.bounds); this.#installInteraction(); this.resize(); } rebuild(sceneDocument) { + this.setDocumentBounds('scene', sceneDocument.bounds); this.transformControls?.detach(); for (const [id, object] of this.nodeObjects) this.#disposeNodeObject(id, object); this.nodeObjects.clear(); @@ -86,6 +91,32 @@ export class ThreeRenderer extends RendererBackend { addNode(node) { this.#createObjectForNode(node); } + setDocumentBounds(kind, value) { + if (!['scene', 'world'].includes(kind)) throw new Error('Unknown bounds kind.'); + const bounds = normalizeDocumentBounds(value); + if (!this.scene) return; + const key = JSON.stringify(bounds); + if (this.boundsKeys.get(kind) === key) return; + const old = this.boundsHelpers.get(kind); + if (old) { + this.scene.remove(old); + old.geometry.dispose(); + old.material.dispose(); + this.boundsHelpers.delete(kind); + } + this.boundsKeys.set(kind, key); + if (!bounds) return; + const box = new THREE.Box3( + new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), + new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z)); + const helper = new THREE.Box3Helper(box, kind === 'scene' ? 0x60bfff : 0xffbd69); + helper.material.depthTest = false; + helper.material.fog = false; + helper.renderOrder = 10; + this.boundsHelpers.set(kind, helper); + this.scene.add(helper); + } + removeNode(nodeId) { const object = this.nodeObjects.get(nodeId); if (!object) return; diff --git a/src/engine/scene/SceneDocument.mjs b/src/engine/scene/SceneDocument.mjs index ddb9235..e1e074f 100644 --- a/src/engine/scene/SceneDocument.mjs +++ b/src/engine/scene/SceneDocument.mjs @@ -3,6 +3,7 @@ import { Node2_5D } from '../core/Node2_5D.mjs'; import { Node3D } from '../core/Node3D.mjs'; import { Camera3D } from '../core/Camera3D.mjs'; import { Light3D } from '../core/Light3D.mjs'; +import { normalizeDocumentBounds } from '../core/DocumentBounds.mjs'; const FORMAT = 'parlyn-scene'; const VERSION = 2; @@ -155,6 +156,7 @@ export class SceneDocument { this.version = VERSION; this.name = name.trim(); this.root = new Node({ name:this.name, type:'SceneRoot' }); + this.bounds = null; } findById(id) { @@ -191,7 +193,8 @@ export class SceneDocument { } toJSON() { - return { format:this.format, version:VERSION, name:this.name, root:this.root.toJSON() }; + const bounds = normalizeDocumentBounds(this.bounds); + return { format:this.format, version:VERSION, name:this.name, root:this.root.toJSON(), ...(bounds ? { bounds } : {}) }; } static fromJSON(data) { @@ -202,6 +205,7 @@ export class SceneDocument { const sourceVersion = data.version; if (!SUPPORTED_VERSIONS.has(sourceVersion)) throw new Error(`Unsupported Parlyn scene version: ${data.version}`); const scene = new SceneDocument(data.name); + scene.bounds = normalizeDocumentBounds(data.bounds); const rootData = sourceVersion === 1 ? migrateNodeV1(data.root) : data.root; scene.root = nodeFromJSON(rootData, { ids:new Set(), count:0 }); if (scene.root.type !== 'SceneRoot') throw new Error('Parlyn scene root must use type SceneRoot.'); diff --git a/src/engine/world/WorldDocument.mjs b/src/engine/world/WorldDocument.mjs index 9f1d45c..9dfdbe3 100644 --- a/src/engine/world/WorldDocument.mjs +++ b/src/engine/world/WorldDocument.mjs @@ -1,3 +1,4 @@ +import { normalizeDocumentBounds } from '../core/DocumentBounds.mjs'; const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; const FORMAT = 'parlyn-world'; const VERSION = 1; @@ -50,7 +51,8 @@ export class WorldDocument { ways = [], landmarks = [], encounters = [], - memory = {} + memory = {}, + bounds = null } = {}) { this.format = FORMAT; this.version = VERSION; @@ -62,10 +64,12 @@ export class WorldDocument { this.landmarks = structuredClone(landmarks); this.encounters = structuredClone(encounters); this.memory = structuredClone(memory); + this.bounds = normalizeDocumentBounds(bounds); this.validate(); } validate() { + this.bounds = normalizeDocumentBounds(this.bounds); const capsuleIds = uniqueById(this.capsules, 'Scene Capsule'); const wayIds = uniqueById(this.ways, 'Parlyn Way'); uniqueById(this.landmarks, 'landmark'); @@ -121,7 +125,9 @@ export class WorldDocument { } toJSON() { + const bounds = normalizeDocumentBounds(this.bounds); return { + ...(bounds ? { bounds } : {}), format:this.format, version:this.version, name:this.name, diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index 7be3b0b..3f391e5 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -14,6 +14,7 @@ import { DEFAULT_WORKSPACE_LAYOUT, normalizeWorkspaceLayout } from "../engine/ed 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"; async function bootstrap() { const $ = (id) => document.getElementById(id); const status = $("status"); @@ -291,6 +292,7 @@ async function bootstrap() { return { Mesh3D: "\u25C6", Sprite2_5D: "\u25B1", Billboard2_5D: "\u25E9", Light3D: "\u263C", Camera3D: "\u25A3" }[node.type] ?? "\u25C7"; } function renderHierarchy() { + renderer.setDocumentBounds("world", currentWorld?.bounds ?? null); const root = $("hierarchy"); root.replaceChildren(); visibleHierarchyIds = []; @@ -1064,6 +1066,79 @@ async function bootstrap() { function shortPath(filePath) { return filePath ? filePath.split(/[\\/]/).slice(-2).join("/") : ""; } + let savingBounds = false; + function populateBoundsDialog() { + const isWorld = $("bounds-target").value === "world"; + const bounds = (isWorld ? currentWorld : scene)?.bounds; + $("bounds-enabled").checked = Boolean(bounds); + $("bounds-coordinates").disabled = !bounds; + for (const end of ["min", "max"]) for (const axis of ["x", "y", "z"]) { + $("bounds-" + end + "-" + axis).value = bounds?.[end][axis] ?? (end === "min" ? -10 : 10); + } + $("apply-bounds").textContent = isWorld ? "Save World Bounds" : "Apply Scene Bounds"; + $("bounds-error").textContent = ""; + } + async function applyBounds() { + if (savingBounds) return; + try { + let value = null; + if ($("bounds-enabled").checked) { + value = { min:{}, max:{} }; + for (const end of ["min", "max"]) for (const axis of ["x", "y", "z"]) { + const input = $("bounds-" + end + "-" + axis); + if (!input.reportValidity()) return; + value[end][axis] = input.valueAsNumber; + } + value = normalizeDocumentBounds(value); + } + if ($("bounds-target").value === "world") { + if (!currentProject || !currentWorld) throw new Error("Open a project with a world document first."); + const worldBefore = currentWorld; + const projectBefore = currentProject; + const candidate = WorldDocument.fromJSON(currentWorld.toJSON()); + candidate.bounds = value; + savingBounds = true; + $("apply-bounds").disabled = true; + $("cancel-bounds").disabled = true; + $("bounds-target").disabled = true; + const result = await host.saveProjectWorld({ relativePath:currentProject.world, world:candidate.toJSON() }); + if (!result.ok) throw new Error("World bounds could not be saved."); + if (currentWorld === worldBefore && currentProject === projectBefore) { + currentWorld = candidate; + renderer.setDocumentBounds("world", candidate.bounds); + } + status.textContent = "World bounds saved."; + } else { + const before = sceneSnapshot(); + if (JSON.stringify(normalizeDocumentBounds(scene.bounds)) !== JSON.stringify(value)) { + scene.bounds = value; + pushHistory(before, "Change Scene Bounds"); + renderer.setDocumentBounds("scene", value); + } + status.textContent = "Scene bounds applied."; + } + $("bounds-dialog").close(); + } catch (error) { + $("bounds-error").textContent = error.message; + } finally { + savingBounds = false; + $("apply-bounds").disabled = false; + $("cancel-bounds").disabled = false; + $("bounds-target").disabled = false; + } + } + $("document-bounds").addEventListener("click", () => { + $("bounds-target").value = "scene"; + $("bounds-target").querySelector('option[value="world"]').disabled = !currentProject || !currentWorld; + populateBoundsDialog(); + $("bounds-dialog").showModal(); + }); + $("bounds-target").addEventListener("change", populateBoundsDialog); + $("bounds-enabled").addEventListener("change", () => { $("bounds-coordinates").disabled = !$("bounds-enabled").checked; }); + $("cancel-bounds").addEventListener("click", () => $("bounds-dialog").close()); + $("bounds-dialog").addEventListener("cancel", (event) => { if (savingBounds) event.preventDefault(); }); + $("apply-bounds").addEventListener("click", applyBounds); + function undo() { const entry = history.undo(sceneSnapshot()); if (!entry) return; diff --git a/src/renderer/index.html b/src/renderer/index.html index d1dc2e6..65bdb34 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -103,6 +103,7 @@ + @@ -166,6 +167,21 @@

Camera

+ +
Scene / World BoundsWorld-space authoring guides, independent of viewport size.
+
+ + +
Minimum / Maximum (units) +
+
+
+

Guides only: objects and the camera may leave these bounds. Scene edits use Undo/Redo and normal Save. World edits are saved explicitly and do not use scene Undo/Redo.

+ +
+
+
+
Transform Snap SettingsSet the increments used while dragging viewport gizmos.
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index a4a70c9..7a638dd 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -40,3 +40,7 @@ .center{grid-template-rows:40px minmax(260px,1fr) 5px var(--assets-height)} .panel-resizer{position:relative;background:#101720;touch-action:none;z-index:3}.panel-resizer::after{content:"";position:absolute;background:var(--parlyn-border)}.panel-resizer:hover::after,.panel-resizer.dragging::after,.panel-resizer:focus-visible::after{background:var(--parlyn-blue)}.panel-resizer.vertical{cursor:col-resize}.panel-resizer.vertical::after{inset:0 2px}.panel-resizer.horizontal{cursor:row-resize}.panel-resizer.horizontal::after{inset:2px 0} .view-menu-wrap{position:relative}.view-menu{position:absolute;top:calc(100% + 7px);right:0;width:205px;padding:8px;background:var(--parlyn-panel);border:1px solid var(--parlyn-border);border-radius:6px;box-shadow:0 12px 32px rgba(0,0,0,.45);z-index:20}.view-menu>strong{display:block;padding:4px 6px 7px;font-size:10px;color:var(--parlyn-muted);text-transform:uppercase;letter-spacing:.07em}.view-menu label{display:flex;align-items:center;gap:8px;padding:7px 6px;border-radius:4px;font-size:12px;cursor:pointer}.view-menu label:hover{background:var(--parlyn-hover)}.view-menu input{width:auto}.view-menu button{width:100%;margin-top:7px}.panel-close{font-size:16px;line-height:1}.workspace.is-resizing,.workspace.is-resizing *{cursor:inherit!important;user-select:none!important} +#bounds-dialog fieldset { border:1px solid var(--parlyn-border); margin:12px 0; padding:8px; min-width:0; } +#bounds-dialog input[type=checkbox] { width:auto; justify-self:start; } +#bounds-dialog p { color:var(--parlyn-muted); font-size:11px; line-height:1.5; } +#bounds-dialog #bounds-error { color:#ffbd69; }