Update ViewportCanvas to include document and selection props, add brush rendering and interaction
This commit is contained in:
@@ -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 : (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user