Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .github/workflows/publish-unsigned-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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."
}
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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"
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.19.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions docs/PROJECT-FORMAT.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTING-v0.5.0.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
78 changes: 78 additions & 0 deletions scripts/check-document-bounds.mjs
Original file line number Diff line number Diff line change
@@ -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.');
2 changes: 1 addition & 1 deletion scripts/check-windows-distribution.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion scripts/check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) => {
Expand All @@ -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' });

Expand Down
18 changes: 18 additions & 0 deletions src/engine/core/DocumentBounds.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/engine/render/RendererBackend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
Loading
Loading