Update ViewportCanvas to include document and selection props, add brush rendering and interaction

This commit is contained in:
2026-03-31 02:05:29 +02:00
parent e432888542
commit d78da160a0
2 changed files with 133 additions and 6 deletions

View File

@@ -1,14 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { WorldSettings } from "../document/scene-document"; import type { EditorSelection } from "../core/selection";
import type { SceneDocument, WorldSettings } from "../document/scene-document";
import { ViewportHost } from "./viewport-host"; import { ViewportHost } from "./viewport-host";
interface ViewportCanvasProps { interface ViewportCanvasProps {
world: WorldSettings; world: WorldSettings;
document: SceneDocument;
selection: EditorSelection;
onBrushSelectionChange(brushId: string | null): void;
} }
export function ViewportCanvas({ world }: ViewportCanvasProps) { export function ViewportCanvas({ world, document, selection, onBrushSelectionChange }: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null); const hostRef = useRef<ViewportHost | null>(null);
const [viewportMessage, setViewportMessage] = useState<string | null>(null); const [viewportMessage, setViewportMessage] = useState<string | null>(null);
@@ -52,6 +56,14 @@ export function ViewportCanvas({ world }: ViewportCanvasProps) {
hostRef.current?.updateWorld(world); hostRef.current?.updateWorld(world);
}, [world]); }, [world]);
useEffect(() => {
hostRef.current?.updateDocument(document, selection);
}, [document, selection]);
useEffect(() => {
hostRef.current?.setBrushSelectionChangeHandler(onBrushSelectionChange);
}, [onBrushSelectionChange]);
return ( return (
<div ref={containerRef} className="viewport-canvas" data-testid="viewport-shell" aria-label="Editor viewport"> <div ref={containerRef} className="viewport-canvas" data-testid="viewport-shell" aria-label="Editor viewport">
{viewportMessage === null ? null : ( {viewportMessage === null ? null : (

View File

@@ -1,16 +1,35 @@
import { import {
AmbientLight, AmbientLight,
AxesHelper, AxesHelper,
BoxGeometry,
Color, Color,
DirectionalLight, DirectionalLight,
GridHelper, EdgesGeometry,
Group,
LineBasicMaterial,
LineSegments,
Mesh,
MeshStandardMaterial,
PerspectiveCamera, PerspectiveCamera,
Raycaster,
Scene, Scene,
Vector2,
Vector3, Vector3,
WebGLRenderer WebGLRenderer
} from "three"; } from "three";
import type { WorldSettings } from "../document/scene-document"; import { isBrushSelected, type EditorSelection } from "../core/selection";
import type { SceneDocument, WorldSettings } from "../document/scene-document";
interface BrushRenderObjects {
mesh: Mesh<BoxGeometry, MeshStandardMaterial>;
edges: LineSegments<EdgesGeometry, LineBasicMaterial>;
}
const BRUSH_BASE_COLOR = 0x8c98a7;
const BRUSH_SELECTED_COLOR = 0xd9a26b;
const BRUSH_EDGE_COLOR = 0x0d1017;
const BRUSH_SELECTED_EDGE_COLOR = 0xf7d2aa;
export class ViewportHost { export class ViewportHost {
private readonly scene = new Scene(); private readonly scene = new Scene();
@@ -18,27 +37,34 @@ export class ViewportHost {
private readonly renderer = new WebGLRenderer({ antialias: true }); private readonly renderer = new WebGLRenderer({ antialias: true });
private readonly ambientLight = new AmbientLight(); private readonly ambientLight = new AmbientLight();
private readonly sunLight = new DirectionalLight(); private readonly sunLight = new DirectionalLight();
private readonly brushGroup = new Group();
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
private animationFrame = 0; private animationFrame = 0;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private brushSelectionChangeHandler: ((brushId: string | null) => void) | null = null;
constructor() { constructor() {
this.camera.position.set(8, 8, 8); this.camera.position.set(10, 9, 10);
this.camera.lookAt(new Vector3(0, 0, 0)); this.camera.lookAt(new Vector3(0, 0, 0));
const gridHelper = new GridHelper(40, 40, 0xc68d67, 0x4e596b); const gridHelper = new GridHelper(40, 40, 0xcf8354, 0x4e596b);
const axesHelper = new AxesHelper(2); const axesHelper = new AxesHelper(2);
this.scene.add(gridHelper); this.scene.add(gridHelper);
this.scene.add(axesHelper); this.scene.add(axesHelper);
this.scene.add(this.ambientLight); this.scene.add(this.ambientLight);
this.scene.add(this.sunLight); this.scene.add(this.sunLight);
this.scene.add(this.brushGroup);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
} }
mount(container: HTMLElement) { mount(container: HTMLElement) {
this.container = container; this.container = container;
container.appendChild(this.renderer.domElement); container.appendChild(this.renderer.domElement);
this.renderer.domElement.addEventListener("pointerdown", this.handlePointerDown);
this.resize(); this.resize();
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
@@ -58,6 +84,14 @@ export class ViewportHost {
this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18); this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18);
} }
updateDocument(document: SceneDocument, selection: EditorSelection) {
this.rebuildBrushMeshes(document, selection);
}
setBrushSelectionChangeHandler(handler: ((brushId: string | null) => void) | null) {
this.brushSelectionChangeHandler = handler;
}
dispose() { dispose() {
if (this.animationFrame !== 0) { if (this.animationFrame !== 0) {
cancelAnimationFrame(this.animationFrame); cancelAnimationFrame(this.animationFrame);
@@ -66,6 +100,8 @@ export class ViewportHost {
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.resizeObserver = null; this.resizeObserver = null;
this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDown);
this.clearBrushMeshes();
this.renderer.dispose(); this.renderer.dispose();
if (this.container !== null && this.container.contains(this.renderer.domElement)) { if (this.container !== null && this.container.contains(this.renderer.domElement)) {
@@ -75,6 +111,56 @@ export class ViewportHost {
this.container = null; this.container = null;
} }
private rebuildBrushMeshes(document: SceneDocument, selection: EditorSelection) {
this.clearBrushMeshes();
for (const brush of Object.values(document.brushes)) {
const selected = isBrushSelected(selection, brush.id);
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
const material = new MeshStandardMaterial({
color: selected ? BRUSH_SELECTED_COLOR : BRUSH_BASE_COLOR,
transparent: true,
opacity: selected ? 0.95 : 0.78,
roughness: 0.9,
metalness: 0.05
});
const mesh = new Mesh(geometry, material);
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
mesh.userData.brushId = brush.id;
mesh.castShadow = false;
mesh.receiveShadow = false;
const edges = new LineSegments(
new EdgesGeometry(geometry),
new LineBasicMaterial({
color: selected ? BRUSH_SELECTED_EDGE_COLOR : BRUSH_EDGE_COLOR
})
);
edges.position.copy(mesh.position);
this.brushGroup.add(mesh);
this.brushGroup.add(edges);
this.brushRenderObjects.set(brush.id, {
mesh,
edges
});
}
}
private clearBrushMeshes() {
for (const renderObjects of this.brushRenderObjects.values()) {
this.brushGroup.remove(renderObjects.mesh);
this.brushGroup.remove(renderObjects.edges);
renderObjects.mesh.geometry.dispose();
renderObjects.mesh.material.dispose();
renderObjects.edges.geometry.dispose();
renderObjects.edges.material.dispose();
}
this.brushRenderObjects.clear();
}
private resize() { private resize() {
if (this.container === null) { if (this.container === null) {
return; return;
@@ -92,6 +178,35 @@ export class ViewportHost {
this.renderer.setSize(width, height, false); this.renderer.setSize(width, height, false);
} }
private handlePointerDown = (event: PointerEvent) => {
const bounds = this.renderer.domElement.getBoundingClientRect();
if (bounds.width === 0 || bounds.height === 0) {
return;
}
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
this.raycaster.setFromCamera(this.pointer, this.camera);
const hits = this.raycaster.intersectObjects(
Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.mesh),
false
);
if (hits.length === 0) {
this.brushSelectionChangeHandler?.(null);
return;
}
const brushId = hits[0].object.userData.brushId;
if (typeof brushId === "string") {
this.brushSelectionChangeHandler?.(brushId);
}
};
private render = () => { private render = () => {
this.animationFrame = window.requestAnimationFrame(this.render); this.animationFrame = window.requestAnimationFrame(this.render);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);