auto-git:

[add] .prettierrc.json
 [add] eslint.config.js
 [add] index.html
 [add] package.json
 [add] playwright.config.ts
 [add] src/app/App.tsx
 [add] src/app/app.css
 [add] src/app/editor-store.ts
 [add] src/app/use-editor-store.ts
 [add] src/assets/.gitkeep
 [add] src/commands/command-history.ts
 [add] src/commands/command.ts
 [add] src/commands/set-scene-name-command.ts
 [add] src/core/ids.ts
 [add] src/core/selection.ts
 [add] src/core/tool-mode.ts
 [add] src/core/vector.ts
 [add] src/document/migrate-scene-document.ts
 [add] src/document/scene-document.ts
 [add] src/entities/.gitkeep
 [add] src/geometry/.gitkeep
 [add] src/main.tsx
 [add] src/materials/.gitkeep
 [add] src/runtime-three/.gitkeep
 [add] src/serialization/local-draft-storage.ts
 [add] src/serialization/scene-document-json.ts
 [add] src/shared-ui/Panel.tsx
 [add] src/viewport-three/ViewportCanvas.tsx
 [add] src/viewport-three/viewport-host.ts
 [add] src/vite-env.d.ts
 [add] tests/domain/create-empty-scene-document.test.ts
 [add] tests/domain/editor-store.test.ts
 [add] tests/e2e/app-smoke.e2e.ts
 [add] tests/serialization/scene-document-json.test.ts
 [add] tests/setup/vitest.setup.ts
 [add] tsconfig.json
 [add] vite.config.ts
 [add] vitest.config.ts
This commit is contained in:
2026-03-31 01:29:35 +02:00
parent c90282ea45
commit 3af579c6bb
38 changed files with 1662 additions and 0 deletions

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": false,
"semi": true,
"trailingComma": "none"
}

32
eslint.config.js Normal file
View File

@@ -0,0 +1,32 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["coverage", "dist", "playwright-report", "test-results"]
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node
}
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
}
}
);

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebEditor3D</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "webeditor3d",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.169.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@playwright/test": "^1.48.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/node": "^22.8.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.13",
"globals": "^15.11.0",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.10.0",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}

26
playwright.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
expect: {
timeout: 5_000
},
use: {
baseURL: "http://127.0.0.1:4173",
trace: "on-first-retry"
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"]
}
}
],
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI
}
});

260
src/app/App.tsx Normal file
View File

@@ -0,0 +1,260 @@
import { useEffect, useRef, useState } from "react";
import { createSetSceneNameCommand } from "../commands/set-scene-name-command";
import { Panel } from "../shared-ui/Panel";
import { ViewportCanvas } from "../viewport-three/ViewportCanvas";
import type { EditorStore } from "./editor-store";
import { useEditorStoreState } from "./use-editor-store";
interface AppProps {
store: EditorStore;
}
function describeSelection(selectionKind: string): string {
switch (selectionKind) {
case "none":
return "No authored selection yet";
case "brushes":
return "Brush selection placeholder";
case "entities":
return "Entity selection placeholder";
case "modelInstances":
return "Model instance selection placeholder";
default:
return "Unknown selection";
}
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "An unexpected error occurred.";
}
export function App({ store }: AppProps) {
const editorState = useEditorStoreState(store);
const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name);
const [statusMessage, setStatusMessage] = useState("Viewport shell ready.");
const importInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setSceneNameDraft(editorState.document.name);
}, [editorState.document.name]);
const applySceneName = () => {
const normalizedName = sceneNameDraft.trim() || "Untitled Scene";
if (normalizedName === editorState.document.name) {
setStatusMessage("Scene name is already current.");
return;
}
store.executeCommand(createSetSceneNameCommand(normalizedName));
setStatusMessage(`Scene renamed to ${normalizedName}.`);
};
const handleSaveDraft = () => {
const didSave = store.saveDraft();
setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable.");
};
const handleLoadDraft = () => {
try {
const didLoad = store.loadDraft();
setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const handleExportJson = () => {
const exportedJson = store.exportDocumentJson();
const blob = new Blob([exportedJson], { type: "application/json" });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`;
anchor.click();
URL.revokeObjectURL(objectUrl);
setStatusMessage("Scene document exported as JSON.");
};
const handleImportButtonClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
if (file === undefined) {
return;
}
try {
const source = await file.text();
store.importDocumentJson(source);
setStatusMessage(`Imported ${file.name}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
} finally {
event.currentTarget.value = "";
}
};
return (
<div className="app-shell">
<header className="toolbar">
<div className="toolbar__brand">
<div className="toolbar__title">WebEditor3D</div>
<div className="toolbar__subtitle">Milestone 0 foundation slice</div>
</div>
<div className="toolbar__actions">
<div className="toolbar__group">
<button
className={`toolbar__button ${editorState.toolMode === "select" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("select")}
>
Select
</button>
<button
className={`toolbar__button ${editorState.toolMode === "box-create" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("box-create")}
>
Box
</button>
<button
className={`toolbar__button ${editorState.toolMode === "play" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("play")}
>
Play
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" onClick={handleSaveDraft}>
Save Draft
</button>
<button className="toolbar__button" type="button" onClick={handleLoadDraft}>
Load Draft
</button>
<button className="toolbar__button" type="button" onClick={handleExportJson}>
Export JSON
</button>
<button className="toolbar__button" type="button" onClick={handleImportButtonClick}>
Import JSON
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" disabled={!editorState.canUndo} onClick={() => store.undo()}>
Undo
</button>
<button className="toolbar__button" type="button" disabled={!editorState.canRedo} onClick={() => store.redo()}>
Redo
</button>
</div>
</div>
</header>
<div className="workspace">
<aside className="side-column">
<Panel title="Document">
<div className="stat-grid">
<div className="stat-card">
<div className="label">Version</div>
<div className="value">v{editorState.document.version}</div>
</div>
<div className="stat-card">
<div className="label">Tool Mode</div>
<div className="value">{editorState.toolMode}</div>
</div>
</div>
<label className="form-field">
<span className="label">Scene Name</span>
<input
className="text-input"
type="text"
value={sceneNameDraft}
onChange={(event) => setSceneNameDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
applySceneName();
}
}}
/>
</label>
<div className="inline-actions">
<button className="toolbar__button toolbar__button--accent" type="button" onClick={applySceneName}>
Apply Command
</button>
</div>
</Panel>
<Panel title="Outliner">
<ul className="placeholder-list">
<li>Brushes: {Object.keys(editorState.document.brushes).length}</li>
<li>Entities: {Object.keys(editorState.document.entities).length}</li>
<li>Model Instances: {Object.keys(editorState.document.modelInstances).length}</li>
</ul>
</Panel>
</aside>
<main className="viewport-region">
<div className="viewport-region__header">
<div className="viewport-region__title">Viewport</div>
<div className="viewport-region__caption">Imperative three.js editor surface</div>
</div>
<ViewportCanvas world={editorState.document.world} />
</main>
<aside className="side-column">
<Panel title="Inspector">
<div className="stat-card">
<div className="label">Selection</div>
<div className="value">{describeSelection(editorState.selection.kind)}</div>
</div>
<ul className="placeholder-list">
<li>Document is the canonical source of truth.</li>
<li>Viewport state is derived and disposable.</li>
<li>Real geometry tools intentionally start in Milestone 1.</li>
</ul>
</Panel>
<Panel title="Runner">
<ul className="placeholder-list">
<li>Built-in runner shell reserved for later slices.</li>
<li>Current focus is versioned documents, persistence, and editor boot flow.</li>
</ul>
</Panel>
</aside>
</div>
<footer className="status-bar">
<div>
<span className="status-bar__strong">Status:</span> {statusMessage}
</div>
<div>
<span className="status-bar__strong">History:</span> {editorState.lastCommandLabel ?? "No commands yet"}
</div>
</footer>
<input
ref={importInputRef}
className="visually-hidden"
type="file"
accept=".json,application/json"
onChange={handleImportChange}
/>
</div>
);
}

362
src/app/app.css Normal file
View File

@@ -0,0 +1,362 @@
:root {
color-scheme: dark;
font-family: "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--color-bg: #13171d;
--color-surface: rgba(31, 36, 45, 0.92);
--color-surface-strong: #2a313d;
--color-border: rgba(255, 255, 255, 0.08);
--color-text: #f2eee8;
--color-muted: #a8b0bd;
--color-accent: #cf7b42;
--color-accent-strong: #f3be8f;
--color-accent-soft: rgba(207, 123, 66, 0.18);
--shadow-panel: 0 18px 48px rgba(3, 7, 12, 0.38);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%),
linear-gradient(180deg, #1b2029 0%, #101318 100%);
color: var(--color-text);
}
button,
input {
font: inherit;
}
button {
border: 1px solid transparent;
border-radius: 12px;
cursor: pointer;
transition:
background-color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.app-shell {
display: grid;
grid-template-rows: 64px minmax(0, 1fr) 36px;
min-height: 100vh;
gap: 12px;
padding: 12px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 18px;
background: rgba(18, 22, 28, 0.88);
border: 1px solid var(--color-border);
border-radius: 18px;
box-shadow: var(--shadow-panel);
backdrop-filter: blur(16px);
}
.toolbar__brand {
display: flex;
flex-direction: column;
}
.toolbar__title {
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.toolbar__subtitle {
color: var(--color-muted);
font-size: 0.78rem;
}
.toolbar__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.toolbar__group {
display: flex;
gap: 8px;
padding: 8px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--color-border);
border-radius: 14px;
}
.toolbar__button {
padding: 0.65rem 0.9rem;
background: var(--color-surface-strong);
color: var(--color-text);
}
.toolbar__button:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.12);
}
.toolbar__button--active {
background: var(--color-accent-soft);
border-color: rgba(207, 123, 66, 0.55);
color: var(--color-accent-strong);
}
.toolbar__button--accent {
background: var(--color-accent);
color: #20140c;
font-weight: 700;
}
.workspace {
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px);
gap: 12px;
min-height: 0;
}
.side-column {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 18px;
overflow: hidden;
box-shadow: var(--shadow-panel);
}
.panel__header {
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
color: #e4d8ca;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.panel__body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
padding: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
border: 1px solid var(--color-border);
border-radius: 14px;
}
.label {
color: var(--color-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.value {
margin-top: 0.35rem;
font-size: 0.95rem;
font-weight: 600;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.text-input {
width: 100%;
padding: 0.85rem 0.95rem;
background: rgba(7, 9, 13, 0.4);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 12px;
}
.inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.placeholder-list {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.placeholder-list li {
padding: 10px 12px;
color: var(--color-muted);
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.11);
border-radius: 12px;
}
.viewport-region {
display: grid;
grid-template-rows: 42px minmax(0, 1fr);
min-height: 0;
background: rgba(20, 24, 31, 0.92);
border: 1px solid var(--color-border);
border-radius: 24px;
overflow: hidden;
box-shadow: var(--shadow-panel);
}
.viewport-region__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 100%);
border-bottom: 1px solid var(--color-border);
}
.viewport-region__title {
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.viewport-region__caption {
color: var(--color-muted);
font-size: 0.82rem;
}
.viewport-canvas {
position: relative;
min-height: 420px;
background:
radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%),
linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%);
}
.viewport-canvas canvas {
display: block;
width: 100%;
height: 100%;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 16px;
background: rgba(18, 22, 28, 0.88);
border: 1px solid var(--color-border);
border-radius: 16px;
color: var(--color-muted);
}
.status-bar__strong {
color: var(--color-text);
font-weight: 700;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1100px) {
.workspace {
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
}
.workspace > .side-column:last-child {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
.app-shell {
grid-template-rows: auto auto auto;
}
.toolbar {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.workspace {
grid-template-columns: 1fr;
}
.workspace > .side-column:last-child {
display: flex;
}
.viewport-canvas {
min-height: 320px;
}
.status-bar {
flex-direction: column;
align-items: flex-start;
padding: 12px 16px;
}
}

178
src/app/editor-store.ts Normal file
View File

@@ -0,0 +1,178 @@
import { CommandHistory } from "../commands/command-history";
import type { CommandContext, EditorCommand } from "../commands/command";
import type { EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
import {
DEFAULT_SCENE_DRAFT_STORAGE_KEY,
loadSceneDocumentDraft,
type KeyValueStorage,
saveSceneDocumentDraft
} from "../serialization/local-draft-storage";
import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json";
export interface EditorStoreState {
document: SceneDocument;
selection: EditorSelection;
toolMode: ToolMode;
canUndo: boolean;
canRedo: boolean;
lastCommandLabel: string | null;
}
interface EditorStoreOptions {
initialDocument?: SceneDocument;
storage?: KeyValueStorage | null;
storageKey?: string;
}
type EditorStoreListener = () => void;
export class EditorStore {
private document: SceneDocument;
private selection: EditorSelection = { kind: "none" };
private toolMode: ToolMode = "select";
private readonly history = new CommandHistory();
private readonly listeners = new Set<EditorStoreListener>();
private readonly storage: KeyValueStorage | null;
private readonly storageKey: string;
private lastCommandLabel: string | null = null;
private readonly commandContext: CommandContext = {
getDocument: () => this.document,
setDocument: (document) => {
this.document = document;
},
getSelection: () => this.selection,
setSelection: (selection) => {
this.selection = selection;
},
getToolMode: () => this.toolMode,
setToolMode: (toolMode) => {
this.toolMode = toolMode;
}
};
constructor(options: EditorStoreOptions = {}) {
this.document = options.initialDocument ?? createEmptySceneDocument();
this.storage = options.storage ?? null;
this.storageKey = options.storageKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY;
}
subscribe = (listener: EditorStoreListener) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getState = (): EditorStoreState => ({
document: this.document,
selection: this.selection,
toolMode: this.toolMode,
canUndo: this.history.canUndo(),
canRedo: this.history.canRedo(),
lastCommandLabel: this.lastCommandLabel
});
setToolMode(toolMode: ToolMode) {
if (this.toolMode === toolMode) {
return;
}
this.toolMode = toolMode;
this.emit();
}
setSelection(selection: EditorSelection) {
this.selection = selection;
this.emit();
}
executeCommand(command: EditorCommand) {
this.history.execute(command, this.commandContext);
this.lastCommandLabel = command.label;
this.emit();
}
undo(): boolean {
const command = this.history.undo(this.commandContext);
if (command === null) {
return false;
}
this.lastCommandLabel = `Undid ${command.label}`;
this.emit();
return true;
}
redo(): boolean {
const command = this.history.redo(this.commandContext);
if (command === null) {
return false;
}
this.lastCommandLabel = `Redid ${command.label}`;
this.emit();
return true;
}
replaceDocument(document: SceneDocument, resetHistory = true) {
this.document = document;
this.selection = { kind: "none" };
if (resetHistory) {
this.history.clear();
this.lastCommandLabel = null;
}
this.emit();
}
saveDraft(): boolean {
if (this.storage === null) {
return false;
}
saveSceneDocumentDraft(this.storage, this.document, this.storageKey);
return true;
}
loadDraft(): boolean {
if (this.storage === null) {
return false;
}
const document = loadSceneDocumentDraft(this.storage, this.storageKey);
if (document === null) {
return false;
}
this.replaceDocument(document);
return true;
}
exportDocumentJson(): string {
return serializeSceneDocument(this.document);
}
importDocumentJson(source: string): SceneDocument {
const document = parseSceneDocumentJson(source);
this.replaceDocument(document);
return document;
}
private emit() {
for (const listener of this.listeners) {
listener();
}
}
}
export function createEditorStore(options?: EditorStoreOptions): EditorStore {
return new EditorStore(options);
}

View File

@@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
import type { EditorStore, EditorStoreState } from "./editor-store";
export function useEditorStoreState(store: EditorStore): EditorStoreState {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}

1
src/assets/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,49 @@
import type { CommandContext, EditorCommand } from "./command";
export class CommandHistory {
private readonly undoStack: EditorCommand[] = [];
private readonly redoStack: EditorCommand[] = [];
execute(command: EditorCommand, context: CommandContext) {
command.execute(context);
this.undoStack.push(command);
this.redoStack.length = 0;
}
undo(context: CommandContext): EditorCommand | null {
const command = this.undoStack.pop();
if (command === undefined) {
return null;
}
command.undo(context);
this.redoStack.push(command);
return command;
}
redo(context: CommandContext): EditorCommand | null {
const command = this.redoStack.pop();
if (command === undefined) {
return null;
}
command.execute(context);
this.undoStack.push(command);
return command;
}
clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
canUndo(): boolean {
return this.undoStack.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
}

19
src/commands/command.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import type { SceneDocument } from "../document/scene-document";
export interface CommandContext {
getDocument(): SceneDocument;
setDocument(document: SceneDocument): void;
getSelection(): EditorSelection;
setSelection(selection: EditorSelection): void;
getToolMode(): ToolMode;
setToolMode(toolMode: ToolMode): void;
}
export interface EditorCommand {
id: string;
label: string;
execute(context: CommandContext): void;
undo(context: CommandContext): void;
}

View File

@@ -0,0 +1,36 @@
import { createOpaqueId } from "../core/ids";
import type { EditorCommand } from "./command";
export function createSetSceneNameCommand(nextName: string): EditorCommand {
const normalizedName = nextName.trim() || "Untitled Scene";
let previousName: string | null = null;
return {
id: createOpaqueId("command"),
label: `Rename scene to ${normalizedName}`,
execute(context) {
const currentDocument = context.getDocument();
if (previousName === null) {
previousName = currentDocument.name;
}
context.setDocument({
...currentDocument,
name: normalizedName
});
},
undo(context) {
if (previousName === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
name: previousName
});
}
};
}

10
src/core/ids.ts Normal file
View File

@@ -0,0 +1,10 @@
let fallbackCounter = 0;
export function createOpaqueId(prefix: string): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
}
fallbackCounter += 1;
return `${prefix}-${Date.now()}-${fallbackCounter}`;
}

5
src/core/selection.ts Normal file
View File

@@ -0,0 +1,5 @@
export type EditorSelection =
| { kind: "none" }
| { kind: "brushes"; ids: string[] }
| { kind: "entities"; ids: string[] }
| { kind: "modelInstances"; ids: string[] };

1
src/core/tool-mode.ts Normal file
View File

@@ -0,0 +1 @@
export type ToolMode = "select" | "box-create" | "play";

11
src/core/vector.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Vec3 {
x: number;
y: number;
z: number;
}
export const DEFAULT_SUN_DIRECTION: Vec3 = {
x: -0.6,
y: 1,
z: 0.35
};

View File

@@ -0,0 +1,114 @@
import { SCENE_DOCUMENT_VERSION, type SceneDocument, type WorldSettings } from "./scene-document";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function expectFiniteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} must be a finite number.`);
}
return value;
}
function expectString(value: unknown, label: string): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string.`);
}
return value;
}
function expectHexColor(value: unknown, label: string): string {
const normalizedValue = expectString(value, label);
if (!/^#[0-9a-f]{6}$/i.test(normalizedValue)) {
throw new Error(`${label} must use #RRGGBB format.`);
}
return normalizedValue;
}
function expectEmptyCollection(value: unknown, label: string): Record<string, never> {
if (!isRecord(value)) {
throw new Error(`${label} must be a record.`);
}
if (Object.keys(value).length > 0) {
throw new Error(`${label} must be empty in the foundation schema.`);
}
return {};
}
function readWorldSettings(value: unknown): WorldSettings {
if (!isRecord(value)) {
throw new Error("world must be an object.");
}
const background = value.background;
const ambientLight = value.ambientLight;
const sunLight = value.sunLight;
if (!isRecord(background)) {
throw new Error("world.background must be an object.");
}
if (!isRecord(ambientLight)) {
throw new Error("world.ambientLight must be an object.");
}
if (!isRecord(sunLight)) {
throw new Error("world.sunLight must be an object.");
}
const direction = sunLight.direction;
if (!isRecord(direction)) {
throw new Error("world.sunLight.direction must be an object.");
}
return {
background: {
mode: "solid",
colorHex: expectHexColor(background.colorHex, "world.background.colorHex")
},
ambientLight: {
colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"),
intensity: expectFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity")
},
sunLight: {
colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"),
intensity: expectFiniteNumber(sunLight.intensity, "world.sunLight.intensity"),
direction: {
x: expectFiniteNumber(direction.x, "world.sunLight.direction.x"),
y: expectFiniteNumber(direction.y, "world.sunLight.direction.y"),
z: expectFiniteNumber(direction.z, "world.sunLight.direction.z")
}
}
};
}
export function migrateSceneDocument(source: unknown): SceneDocument {
if (!isRecord(source)) {
throw new Error("Scene document must be a JSON object.");
}
if (source.version !== SCENE_DOCUMENT_VERSION) {
throw new Error(`Unsupported scene document version: ${String(source.version)}.`);
}
return {
version: SCENE_DOCUMENT_VERSION,
name: expectString(source.name, "name"),
world: readWorldSettings(source.world),
materials: expectEmptyCollection(source.materials, "materials"),
textures: expectEmptyCollection(source.textures, "textures"),
assets: expectEmptyCollection(source.assets, "assets"),
brushes: expectEmptyCollection(source.brushes, "brushes"),
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
entities: expectEmptyCollection(source.entities, "entities"),
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
};
}

View File

@@ -0,0 +1,71 @@
import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector";
export const SCENE_DOCUMENT_VERSION = 1 as const;
export interface WorldBackgroundSettings {
mode: "solid";
colorHex: string;
}
export interface WorldAmbientLightSettings {
colorHex: string;
intensity: number;
}
export interface WorldSunLightSettings {
colorHex: string;
intensity: number;
direction: Vec3;
}
export interface WorldSettings {
background: WorldBackgroundSettings;
ambientLight: WorldAmbientLightSettings;
sunLight: WorldSunLightSettings;
}
export interface SceneDocument {
version: typeof SCENE_DOCUMENT_VERSION;
name: string;
world: WorldSettings;
materials: Record<string, never>;
textures: Record<string, never>;
assets: Record<string, never>;
brushes: Record<string, never>;
modelInstances: Record<string, never>;
entities: Record<string, never>;
interactionLinks: Record<string, never>;
}
export function createDefaultWorldSettings(): WorldSettings {
return {
background: {
mode: "solid",
colorHex: "#2f3947"
},
ambientLight: {
colorHex: "#f7f1e8",
intensity: 1
},
sunLight: {
colorHex: "#fff1d5",
intensity: 1.75,
direction: DEFAULT_SUN_DIRECTION
}
};
}
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world">> = {}): SceneDocument {
return {
version: SCENE_DOCUMENT_VERSION,
name: overrides.name ?? "Untitled Scene",
world: overrides.world ?? createDefaultWorldSettings(),
materials: {},
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
};
}

1
src/entities/.gitkeep Normal file
View File

@@ -0,0 +1 @@

1
src/geometry/.gitkeep Normal file
View File

@@ -0,0 +1 @@

25
src/main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./app/app.css";
import { createEditorStore } from "./app/editor-store";
import { getBrowserStorage, loadOrCreateSceneDocument } from "./serialization/local-draft-storage";
const rootElement = document.getElementById("root");
if (rootElement === null) {
throw new Error("Expected #root element to bootstrap the editor.");
}
const storage = getBrowserStorage();
const editorStore = createEditorStore({
initialDocument: loadOrCreateSceneDocument(storage),
storage
});
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App store={editorStore} />
</React.StrictMode>
);

1
src/materials/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,45 @@
import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json";
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
export function getBrowserStorage(): KeyValueStorage | null {
if (typeof window === "undefined") {
return null;
}
return window.localStorage;
}
export function saveSceneDocumentDraft(
storage: KeyValueStorage,
document: SceneDocument,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
) {
storage.setItem(key, serializeSceneDocument(document));
}
export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null {
const rawDocument = storage.getItem(key);
if (rawDocument === null) {
return null;
}
return parseSceneDocumentJson(rawDocument);
}
export function loadOrCreateSceneDocument(storage: KeyValueStorage | null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument {
if (storage === null) {
return createEmptySceneDocument();
}
return loadSceneDocumentDraft(storage, key) ?? createEmptySceneDocument();
}

View File

@@ -0,0 +1,19 @@
import type { SceneDocument } from "../document/scene-document";
import { migrateSceneDocument } from "../document/migrate-scene-document";
export function serializeSceneDocument(document: SceneDocument): string {
return JSON.stringify(document, null, 2);
}
export function parseSceneDocumentJson(source: string): SceneDocument {
let parsedValue: unknown;
try {
parsedValue = JSON.parse(source);
} catch (error) {
const cause = error instanceof Error ? error.message : "Unknown JSON parse failure.";
throw new Error(`Scene document JSON could not be parsed: ${cause}`);
}
return migrateSceneDocument(parsedValue);
}

14
src/shared-ui/Panel.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { PropsWithChildren } from "react";
interface PanelProps extends PropsWithChildren {
title: string;
}
export function Panel({ title, children }: PanelProps) {
return (
<section className="panel">
<div className="panel__header">{title}</div>
<div className="panel__body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
import type { WorldSettings } from "../document/scene-document";
import { ViewportHost } from "./viewport-host";
interface ViewportCanvasProps {
world: WorldSettings;
}
export function ViewportCanvas({ world }: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null);
useEffect(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const viewportHost = new ViewportHost();
hostRef.current = viewportHost;
viewportHost.mount(container, world);
return () => {
viewportHost.dispose();
hostRef.current = null;
};
}, []);
useEffect(() => {
hostRef.current?.updateWorld(world);
}, [world]);
return <div ref={containerRef} className="viewport-canvas" data-testid="viewport-shell" aria-label="Editor viewport" />;
}

View File

@@ -0,0 +1,100 @@
import {
AmbientLight,
AxesHelper,
Color,
DirectionalLight,
GridHelper,
PerspectiveCamera,
Scene,
Vector3,
WebGLRenderer
} from "three";
import type { WorldSettings } from "../document/scene-document";
export class ViewportHost {
private readonly scene = new Scene();
private readonly camera = new PerspectiveCamera(60, 1, 0.1, 1000);
private readonly renderer = new WebGLRenderer({ antialias: true });
private readonly ambientLight = new AmbientLight();
private readonly sunLight = new DirectionalLight();
private resizeObserver: ResizeObserver | null = null;
private animationFrame = 0;
private container: HTMLElement | null = null;
constructor() {
this.camera.position.set(8, 8, 8);
this.camera.lookAt(new Vector3(0, 0, 0));
const gridHelper = new GridHelper(40, 40, 0xc68d67, 0x4e596b);
const axesHelper = new AxesHelper(2);
this.scene.add(gridHelper);
this.scene.add(axesHelper);
this.scene.add(this.ambientLight);
this.scene.add(this.sunLight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
mount(container: HTMLElement, world: WorldSettings) {
this.container = container;
container.appendChild(this.renderer.domElement);
this.updateWorld(world);
this.resize();
this.resizeObserver = new ResizeObserver(() => {
this.resize();
});
this.resizeObserver.observe(container);
this.render();
}
updateWorld(world: WorldSettings) {
this.scene.background = new Color(world.background.colorHex);
this.ambientLight.color.set(world.ambientLight.colorHex);
this.ambientLight.intensity = world.ambientLight.intensity;
this.sunLight.color.set(world.sunLight.colorHex);
this.sunLight.intensity = world.sunLight.intensity;
this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18);
}
dispose() {
if (this.animationFrame !== 0) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.renderer.dispose();
if (this.container !== null && this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
this.container = null;
}
private resize() {
if (this.container === null) {
return;
}
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) {
return;
}
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
private render = () => {
this.animationFrame = window.requestAnimationFrame(this.render);
this.renderer.render(this.scene, this.camera);
};
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document";
describe("createEmptySceneDocument", () => {
it("creates a versioned empty scene document", () => {
const document = createEmptySceneDocument();
expect(document.version).toBe(SCENE_DOCUMENT_VERSION);
expect(document.name).toBe("Untitled Scene");
expect(document.brushes).toEqual({});
expect(document.entities).toEqual({});
expect(document.modelInstances).toEqual({});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { createEditorStore } from "../../src/app/editor-store";
import { createSetSceneNameCommand } from "../../src/commands/set-scene-name-command";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import type { KeyValueStorage } from "../../src/serialization/local-draft-storage";
class MemoryStorage implements KeyValueStorage {
private readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
removeItem(key: string): void {
this.values.delete(key);
}
}
describe("EditorStore", () => {
it("applies command history with undo and redo", () => {
const store = createEditorStore();
store.executeCommand(createSetSceneNameCommand("Foundation Room"));
expect(store.getState().document.name).toBe("Foundation Room");
expect(store.getState().canUndo).toBe(true);
expect(store.undo()).toBe(true);
expect(store.getState().document.name).toBe("Untitled Scene");
expect(store.getState().canRedo).toBe(true);
expect(store.redo()).toBe(true);
expect(store.getState().document.name).toBe("Foundation Room");
});
it("saves and loads a local draft document", () => {
const storage = new MemoryStorage();
const writerStore = createEditorStore({ storage });
writerStore.executeCommand(createSetSceneNameCommand("Draft Scene"));
expect(writerStore.saveDraft()).toBe(true);
const readerStore = createEditorStore({
initialDocument: createEmptySceneDocument({ name: "Fresh Scene" }),
storage
});
expect(readerStore.loadDraft()).toBe(true);
expect(readerStore.getState().document.name).toBe("Draft Scene");
});
});

View File

@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
test("app boots and shows the viewport shell", async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await expect(page.getByText("WebEditor3D")).toBeVisible();
await expect(page.getByTestId("viewport-shell")).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import { migrateSceneDocument } from "../../src/document/migrate-scene-document";
import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json";
describe("scene document JSON", () => {
it("round-trips the current empty schema", () => {
const document = createEmptySceneDocument({ name: "Bootstrap Scene" });
const serializedDocument = serializeSceneDocument(document);
expect(parseSceneDocumentJson(serializedDocument)).toEqual(document);
});
it("rejects unsupported versions", () => {
expect(() =>
migrateSceneDocument({
version: 99,
name: "Legacy",
world: {},
materials: {},
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
})
).toThrow("Unsupported scene document version");
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "react-jsx",
"types": ["node", "vite/client", "vitest/globals"]
},
"include": ["src", "tests", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173
}
});

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup/vitest.setup.ts"],
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"]
}
})
);