custom voxel mesh builder
object visibility
This commit is contained in:
@@ -83,6 +83,8 @@ return;
|
||||
return () => {
|
||||
canvas.removeEventListener('click', onClick);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
if (document.pointerLockElement === canvas)
|
||||
document.exitPointerLock();
|
||||
};
|
||||
}, [gl]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RuntimeObjectInstance } from "../types";
|
||||
import { debugLabelsStore } from "../state/debugLabelsStore";
|
||||
import { state } from "../state";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { formatV3 } from "../utils";
|
||||
|
||||
export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -13,8 +14,8 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
|
||||
const rb = obj.rb();
|
||||
const texts = [
|
||||
`${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`,
|
||||
`position: ${obj.position}`,
|
||||
`rotation: ${obj.rotation}`,
|
||||
`position: ${formatV3(obj.position)}`,
|
||||
`rotation: ${formatV3(obj.rotation)}`,
|
||||
...Object.entries(debugLabelsStore.getAll(obj.id))
|
||||
.map(([k, v]) => `${k}: ${v}`),
|
||||
];
|
||||
@@ -51,13 +52,14 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
color: '#fff',
|
||||
padding: '2px 7px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
padding: 10,
|
||||
borderRadius: 10,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
opacity: 0.3,
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { debugLabelsStore } from "../state/debugLabelsStore";
|
||||
import { DebugLabel } from "./DebugLabel";
|
||||
import type { RuntimeScene } from "../types";
|
||||
|
||||
export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeScene }) {
|
||||
export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: RuntimeScene, editMode?: boolean }) {
|
||||
useEffect(
|
||||
() => {
|
||||
return () => { debugLabelsStore.clear(); };
|
||||
@@ -15,6 +15,7 @@ export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeSce
|
||||
return <>
|
||||
{
|
||||
Object.entries(scene.objects)
|
||||
.filter(([_, obj]) => editMode || obj.visible)
|
||||
.map(([objectId, obj]) => {
|
||||
return <DebugLabel key={objectId} obj={obj} />;
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PlayerObjectView } from "./CharacterView";
|
||||
export type GameObjectViewProps = {
|
||||
object: RuntimeGameObjectInstance;
|
||||
isPlayer: boolean;
|
||||
isSceneEditor?: boolean;
|
||||
}
|
||||
|
||||
export const GameObjectView = observer(function (props: GameObjectViewProps) {
|
||||
@@ -25,5 +26,6 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) {
|
||||
{...props}
|
||||
objectType={objectType}
|
||||
onClick={handleClick}
|
||||
isSceneEditor={props.isSceneEditor}
|
||||
/>;
|
||||
});
|
||||
|
||||
@@ -78,7 +78,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
||||
|
||||
function handleTransformChange() {
|
||||
const group = dataRef.current?.group;
|
||||
if (!group) return;
|
||||
if (!group)
|
||||
return;
|
||||
const pos = group.getWorldPosition(new Vector3());
|
||||
const quat = group.getWorldQuaternion(new Quaternion());
|
||||
const euler = new Euler().setFromQuaternion(quat);
|
||||
@@ -90,7 +91,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
||||
|
||||
function handleTransformEnd() {
|
||||
const group = dataRef.current?.group;
|
||||
if (!group) return;
|
||||
if (!group)
|
||||
return;
|
||||
const pos = group.getWorldPosition(new Vector3());
|
||||
const quat = group.getWorldQuaternion(new Quaternion());
|
||||
const euler = new Euler().setFromQuaternion(quat);
|
||||
@@ -113,8 +115,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
||||
<ObjectViewInternal
|
||||
ref={dataRef}
|
||||
object={object}
|
||||
isEditor
|
||||
editMode
|
||||
isPlayer={isPlayer}
|
||||
isSceneEditor
|
||||
objectType={objectType}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
|
||||
import type { VoxelGroup } from "../types/runtime/voxelGroup";
|
||||
import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react";
|
||||
import { Euler, Quaternion, type Group } from "three";
|
||||
import { Euler, Quaternion, Mesh, MeshStandardMaterial, type Group } from "three";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
|
||||
import { SyncRigidBody } from "./SyncRigidBody";
|
||||
import { reaction, runInAction } from "mobx";
|
||||
import { v3toRapier } from "../utils";
|
||||
import { state } from "../state";
|
||||
import { greedyMesh } from "../utils/graphics/greedyMesh";
|
||||
import { buildVoxelMesh } from "../utils/graphics/voxelMesh";
|
||||
|
||||
const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg }: { vg: VoxelGroup }) {
|
||||
const geo = useMemo(() => greedyMesh(vg.positions), [vg]);
|
||||
const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) {
|
||||
const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]);
|
||||
useEffect(() => () => { geo.dispose(); }, [geo]);
|
||||
|
||||
const op = vg.opacity * opacity;
|
||||
|
||||
return (
|
||||
<mesh geometry={geo}>
|
||||
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
|
||||
<mesh geometry={geo} userData={{ baseOpacity: vg.opacity }}>
|
||||
<meshStandardMaterial color={vg.color} opacity={op} transparent={op < 1} />
|
||||
</mesh>
|
||||
);
|
||||
});
|
||||
@@ -29,8 +31,9 @@ export type ObjectViewInternalHandle = {
|
||||
type ObjectViewInternalProps = {
|
||||
object: Omit<RuntimeObjectInstance, 'typeId'>;
|
||||
objectType: ObjectType;
|
||||
isEditor?: boolean;
|
||||
editMode?: boolean;
|
||||
isPlayer?: boolean;
|
||||
isSceneEditor?: boolean;
|
||||
onClick?: () => void;
|
||||
ref?: Ref<ObjectViewInternalHandle>;
|
||||
}
|
||||
@@ -72,6 +75,29 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||
|
||||
const gameObj = object as RuntimeGameObjectInstance;
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
return reaction(
|
||||
() => object.visible,
|
||||
(objectVisible) => {
|
||||
if (!groupRef.current)
|
||||
return;
|
||||
groupRef.current.visible = props.isSceneEditor || objectVisible;
|
||||
groupRef.current.traverse((child) => {
|
||||
if (child instanceof Mesh && child.material instanceof MeshStandardMaterial) {
|
||||
const baseOpacity: number = child.userData.baseOpacity ?? 1;
|
||||
child.material.opacity = baseOpacity * (objectVisible ? 1 : 0.5);
|
||||
child.material.transparent = child.material.opacity < 1;
|
||||
child.material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ fireImmediately: true },
|
||||
);
|
||||
},
|
||||
[object, props.isSceneEditor],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
runInAction(() => { gameObj.rb = () => rbRef.current; });
|
||||
@@ -97,7 +123,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||
gravityScale={object.gravityScale}
|
||||
position={object.position}
|
||||
rotation={object.rotation}
|
||||
onCollisionEnter={props.isEditor ? undefined : (payload) => {
|
||||
onCollisionEnter={props.editMode ? undefined : (payload) => {
|
||||
const game = state.game;
|
||||
if (!game)
|
||||
return;
|
||||
@@ -114,7 +140,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||
runInAction(() => {
|
||||
object.position = data.position;
|
||||
object.rotation = data.rotation;
|
||||
if (!props.isEditor) {
|
||||
if (!props.editMode) {
|
||||
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
|
||||
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
|
||||
}
|
||||
@@ -130,7 +156,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||
>
|
||||
{
|
||||
object.cache.voxelGroups.map((vg) =>
|
||||
<VoxelGroupMesh key={`${vg.id}-${object.cache.updatedAt}`} vg={vg} />
|
||||
<VoxelGroupMesh
|
||||
key={`${vg.id}-${object.cache.updatedAt}`}
|
||||
vg={vg}
|
||||
opacity={object.visible ? 1 : 0.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Grid, OrbitControls } from '@react-three/drei';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { state } from '../state';
|
||||
import { SceneView } from './SceneView';
|
||||
import { type OrthographicCamera, type PerspectiveCamera } from 'three';
|
||||
import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three';
|
||||
import { CameraSync } from './tools/CameraSync';
|
||||
import { Physics } from '@react-three/rapier';
|
||||
|
||||
@@ -49,17 +49,23 @@ export const SceneEditorView = observer(function () {
|
||||
onStart={handleStart}
|
||||
onEnd={handleEnd}
|
||||
makeDefault
|
||||
mouseButtons={{
|
||||
LEFT: MOUSE.PAN,
|
||||
MIDDLE: MOUSE.ROTATE,
|
||||
RIGHT: MOUSE.ROTATE,
|
||||
}}
|
||||
rotateSpeed={0.2}
|
||||
enableDamping={false}
|
||||
/>
|
||||
<CameraSync camera={state.worldEditor.camera} />
|
||||
<Grid
|
||||
{/* <Grid
|
||||
sectionThickness={0.5}
|
||||
cellThickness={0}
|
||||
sectionColor="white"
|
||||
infiniteGrid
|
||||
/>
|
||||
/> */}
|
||||
<Physics colliders={false} paused={true}>
|
||||
<SceneView scene={state.worldEditor.scene} editMode debugOverlay />
|
||||
<SceneView scene={state.worldEditor.scene} isEditor debugOverlay />
|
||||
</Physics>
|
||||
</Suspense>
|
||||
</>);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DebugLabelsView } from "./DebugLabelsView";
|
||||
|
||||
type SceneViewProps = {
|
||||
scene: RuntimeScene,
|
||||
editMode?: boolean;
|
||||
isEditor?: boolean;
|
||||
debugOverlay?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ export const SceneView = observer(function (props: SceneViewProps) {
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} />
|
||||
{
|
||||
Object.values(props.scene.objects).map((obj) => (
|
||||
!props.editMode
|
||||
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} />
|
||||
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
|
||||
))
|
||||
Object.values(props.scene.objects)
|
||||
.map((obj) => (
|
||||
!props.isEditor
|
||||
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} isSceneEditor={props.isEditor} />
|
||||
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
|
||||
))
|
||||
}
|
||||
{props.debugOverlay && <DebugLabelsView scene={props.scene} />}
|
||||
{props.debugOverlay && <DebugLabelsView scene={props.scene} editMode={props.isEditor} />}
|
||||
</>);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Perf } from 'r3f-perf'
|
||||
import { Perf } from 'r3f-perf';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { KeyboardControls } from '@react-three/drei';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
@@ -134,7 +134,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
|
||||
<VoxelEditorScene
|
||||
voxels={objectType.voxels}
|
||||
voxels={objectType.voxels.slice()}
|
||||
mode={mode}
|
||||
color={color}
|
||||
typeId="stone"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { ThreeEvent } from '@react-three/fiber';
|
||||
import type { Ray } from 'three';
|
||||
import type { Voxel, V3 } from '../../types';
|
||||
import { buildVoxelMesh } from '../../utils/graphics/voxelMesh';
|
||||
|
||||
type Props = {
|
||||
voxels: Voxel[];
|
||||
@@ -13,42 +15,110 @@ type Props = {
|
||||
};
|
||||
|
||||
const FLOOR_Y = 0;
|
||||
|
||||
function adjPosition(pos: V3, nx: number, ny: number, nz: number): V3 {
|
||||
return [pos[0] + Math.round(nx), pos[1] + Math.round(ny), pos[2] + Math.round(nz)];
|
||||
}
|
||||
|
||||
function posEq(a: V3, b: V3) {
|
||||
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
function raycastVoxels(ray: Ray, voxels: Voxel[]): { position: V3; normal: V3 } | null {
|
||||
const od = [ray.origin.x, ray.origin.y, ray.origin.z];
|
||||
const dd = [ray.direction.x, ray.direction.y, ray.direction.z];
|
||||
let closestT = Infinity;
|
||||
let result: { position: V3; normal: V3 } | null = null;
|
||||
|
||||
for (const v of voxels) {
|
||||
const p = v.position;
|
||||
let tmin = -Infinity, tmax = Infinity;
|
||||
let hitAxis = 0;
|
||||
let miss = false;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const lo = p[i], hi = p[i] + 1;
|
||||
if (Math.abs(dd[i]) < 1e-8) {
|
||||
if (od[i] < lo || od[i] > hi) { miss = true; break; }
|
||||
} else {
|
||||
const inv = 1 / dd[i];
|
||||
let t0 = (lo - od[i]) * inv;
|
||||
let t1 = (hi - od[i]) * inv;
|
||||
if (t0 > t1) { const tmp = t0; t0 = t1; t1 = tmp; }
|
||||
if (t0 > tmin) { tmin = t0; hitAxis = i; }
|
||||
tmax = Math.min(tmax, t1);
|
||||
}
|
||||
}
|
||||
|
||||
if (miss || tmin > tmax || tmin < 0 || tmin >= closestT)
|
||||
continue;
|
||||
closestT = tmin;
|
||||
const normal: V3 = [0, 0, 0];
|
||||
normal[hitAxis] = dd[hitAxis] < 0 ? 1 : -1;
|
||||
result = { position: p, normal };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface MergedVoxelMeshProps {
|
||||
color: string;
|
||||
positions: V3[];
|
||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => void;
|
||||
onClick: (e: ThreeEvent<MouseEvent>) => void;
|
||||
onPointerLeave: () => void;
|
||||
}
|
||||
|
||||
function MergedVoxelMesh({ color, positions, onPointerMove, onClick, onPointerLeave }: MergedVoxelMeshProps) {
|
||||
const geo = useMemo(
|
||||
() => buildVoxelMesh(positions),
|
||||
[positions],
|
||||
);
|
||||
useEffect(() => () => { geo.dispose(); }, [geo]);
|
||||
|
||||
return (<>
|
||||
<mesh geometry={geo} onClick={onClick} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
|
||||
<meshStandardMaterial color={color} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
|
||||
const [ghost, setGhost] = useState<V3 | null>(null);
|
||||
|
||||
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
|
||||
if (e.delta > DRAG_THRESHOLD)
|
||||
return;
|
||||
const colorGroups = useMemo(() => {
|
||||
const map = new Map<string, V3[]>();
|
||||
for (const v of voxels) {
|
||||
const c = v.color ?? '#888888';
|
||||
if (!map.has(c))
|
||||
map.set(c, []);
|
||||
map.get(c)!.push(v.position);
|
||||
}
|
||||
return [...map.entries()].map(([c, positions]) => ({ color: c, positions }));
|
||||
}, [voxels]);
|
||||
|
||||
function handleMeshMove(e: ThreeEvent<PointerEvent>) {
|
||||
e.stopPropagation();
|
||||
const hit = raycastVoxels(e.ray, voxels);
|
||||
if (!hit)
|
||||
return;
|
||||
if (mode === 'remove') {
|
||||
onRemove(voxel.position);
|
||||
setGhost(null);
|
||||
setGhost(hit.position);
|
||||
} else {
|
||||
if (!e.face)
|
||||
return;
|
||||
const { x, y, z } = e.face.normal;
|
||||
const pos = adjPosition(voxel.position, x, y, z);
|
||||
onAdd({ typeId, position: pos, color });
|
||||
const [x, y, z] = hit.position;
|
||||
const [nx, ny, nz] = hit.normal;
|
||||
setGhost([x + nx, y + ny, z + nz]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
|
||||
function handleMeshClick(e: ThreeEvent<MouseEvent>) {
|
||||
if (e.delta > DRAG_THRESHOLD)
|
||||
return;
|
||||
e.stopPropagation();
|
||||
const hit = raycastVoxels(e.ray, voxels);
|
||||
if (!hit)
|
||||
return;
|
||||
if (mode === 'remove') {
|
||||
setGhost(voxel.position);
|
||||
} else {
|
||||
onRemove(hit.position);
|
||||
setGhost(null);
|
||||
} else {
|
||||
const [x, y, z] = hit.position;
|
||||
const [nx, ny, nz] = hit.normal;
|
||||
onAdd({ typeId, position: [x + nx, y + ny, z + nz], color });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +126,7 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
|
||||
if (e.delta > DRAG_THRESHOLD || mode !== 'add')
|
||||
return;
|
||||
e.stopPropagation();
|
||||
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
|
||||
onAdd({ typeId, position: pos, color });
|
||||
onAdd({ typeId, position: [Math.floor(e.point.x), 0, Math.floor(e.point.z)], color });
|
||||
}
|
||||
|
||||
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
|
||||
@@ -66,16 +135,16 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
|
||||
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
|
||||
}
|
||||
|
||||
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost));
|
||||
const ghostOccupied = ghost !== null && voxels.some(
|
||||
v => v.position[0] === ghost[0] && v.position[1] === ghost[1] && v.position[2] === ghost[2],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.7} />
|
||||
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
|
||||
|
||||
<OrbitControls makeDefault />
|
||||
|
||||
{/* Floor plane */}
|
||||
<mesh
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, FLOOR_Y, 0]}
|
||||
@@ -89,37 +158,30 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
|
||||
|
||||
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
|
||||
|
||||
{/* Voxels */}
|
||||
{voxels.map((v, i) => {
|
||||
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
|
||||
return (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
|
||||
onClick={(e) => handleVoxelClick(e, v)}
|
||||
onPointerMove={(e) => handleVoxelMove(e, v)}
|
||||
onPointerLeave={() => setGhost(null)}
|
||||
>
|
||||
<boxGeometry />
|
||||
<meshStandardMaterial
|
||||
color={v.color ?? '#888888'}
|
||||
opacity={isHovered ? 0.4 : 1}
|
||||
transparent={isHovered}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{colorGroups.map(({ color: groupColor, positions }) => (
|
||||
<MergedVoxelMesh
|
||||
key={groupColor}
|
||||
color={groupColor}
|
||||
positions={positions}
|
||||
onPointerMove={handleMeshMove}
|
||||
onClick={handleMeshClick}
|
||||
onPointerLeave={() => setGhost(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ghost voxel preview */}
|
||||
{ghost && mode === 'add' && !ghostOccupied && (
|
||||
<mesh
|
||||
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
|
||||
raycast={() => null}
|
||||
>
|
||||
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
|
||||
<boxGeometry />
|
||||
<meshStandardMaterial color={color} opacity={0.45} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{ghost && mode === 'remove' && ghostOccupied && (
|
||||
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
|
||||
<boxGeometry args={[1.02, 1.02, 1.02]} />
|
||||
<meshStandardMaterial color="#ff4444" opacity={0.4} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user