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

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);
}