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:
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
32
eslint.config.js
Normal file
32
eslint.config.js
Normal 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
12
index.html
Normal 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
43
package.json
Normal 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
26
playwright.config.ts
Normal 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
260
src/app/App.tsx
Normal 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
362
src/app/app.css
Normal 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
178
src/app/editor-store.ts
Normal 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);
|
||||||
|
}
|
||||||
7
src/app/use-editor-store.ts
Normal file
7
src/app/use-editor-store.ts
Normal 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
1
src/assets/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
49
src/commands/command-history.ts
Normal file
49
src/commands/command-history.ts
Normal 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
19
src/commands/command.ts
Normal 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;
|
||||||
|
}
|
||||||
36
src/commands/set-scene-name-command.ts
Normal file
36
src/commands/set-scene-name-command.ts
Normal 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
10
src/core/ids.ts
Normal 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
5
src/core/selection.ts
Normal 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
1
src/core/tool-mode.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type ToolMode = "select" | "box-create" | "play";
|
||||||
11
src/core/vector.ts
Normal file
11
src/core/vector.ts
Normal 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
|
||||||
|
};
|
||||||
114
src/document/migrate-scene-document.ts
Normal file
114
src/document/migrate-scene-document.ts
Normal 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")
|
||||||
|
};
|
||||||
|
}
|
||||||
71
src/document/scene-document.ts
Normal file
71
src/document/scene-document.ts
Normal 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
1
src/entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
src/geometry/.gitkeep
Normal file
1
src/geometry/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
25
src/main.tsx
Normal file
25
src/main.tsx
Normal 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
1
src/materials/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
src/runtime-three/.gitkeep
Normal file
1
src/runtime-three/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
45
src/serialization/local-draft-storage.ts
Normal file
45
src/serialization/local-draft-storage.ts
Normal 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();
|
||||||
|
}
|
||||||
19
src/serialization/scene-document-json.ts
Normal file
19
src/serialization/scene-document-json.ts
Normal 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
14
src/shared-ui/Panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/viewport-three/ViewportCanvas.tsx
Normal file
37
src/viewport-three/ViewportCanvas.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
100
src/viewport-three/viewport-host.ts
Normal file
100
src/viewport-three/viewport-host.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
15
tests/domain/create-empty-scene-document.test.ts
Normal file
15
tests/domain/create-empty-scene-document.test.ts
Normal 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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
tests/domain/editor-store.test.ts
Normal file
56
tests/domain/editor-store.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tests/e2e/app-smoke.e2e.ts
Normal file
24
tests/e2e/app-smoke.e2e.ts
Normal 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([]);
|
||||||
|
});
|
||||||
31
tests/serialization/scene-document-json.test.ts
Normal file
31
tests/serialization/scene-document-json.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
1
tests/setup/vitest.setup.ts
Normal file
1
tests/setup/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
10
vite.config.ts
Normal 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
15
vitest.config.ts
Normal 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"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user