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

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