Compare commits

..

4 Commits

Author SHA1 Message Date
azykov@mail.ru 830c2bdde6
codestyle fixes 2026-06-06 15:35:14 +03:00
azykov@mail.ru 1d13cfac36
minor voxel editor fixes 2026-06-06 10:24:13 +03:00
azykov@mail.ru 52caee642e
voxel editor undo/redo 2026-06-06 09:05:07 +03:00
azykov@mail.ru 30ab3e4d7e
objecttype voxel editor 2026-06-06 08:33:12 +03:00
11 changed files with 413 additions and 20 deletions

View File

@ -1,25 +1,34 @@
import { BrowserRouter, Link, Outlet, Route, Routes, useNavigate, useParams } from 'react-router-dom'; import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useEffect } from 'react'; import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage';
import { useEffect, useRef } from 'react';
import { reaction } from 'mobx'; import { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView'; import { ThreeView } from './components/ThreeView';
import { state } from './state'; import { state } from './state';
import './App.scss'; import './App.scss';
import { LeftPanel } from './components/LeftPanel';
import { Panels } from './components/Panels'; import { Panels } from './components/Panels';
function StateToUrlSync() { function StateToUrlSync() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation();
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
useEffect(() => reaction( useEffect(() => reaction(
() => ({ () => ({
isGame: state.isGamePlaying, isGame: !!state.game,
selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id, selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id,
selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id, selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id,
}), }),
({ isGame, selectedObjectId, selectedObjectTypeId }) => { ({ isGame, selectedObjectId, selectedObjectTypeId }) => {
if (isGame) navigate('/game'); let target: string;
else if (selectedObjectId) navigate(`/editor/object/${selectedObjectId}`); if (isGame) target = '/game';
else if (selectedObjectTypeId) navigate(`/editor/objectType/${selectedObjectTypeId}`); else if (selectedObjectId) target = `/editor/object/${selectedObjectId}`;
else navigate('/editor'); else if (selectedObjectTypeId) target = `/editor/objectType/${selectedObjectTypeId}`;
else target = '/editor';
const current = pathnameRef.current;
if (current === target || current.startsWith(target + '/')) return;
navigate(target);
}, },
), []); ), []);
return null; return null;
@ -27,7 +36,7 @@ function StateToUrlSync() {
function EditorRoute() { function EditorRoute() {
useEffect(() => { useEffect(() => {
if (state.isGamePlaying) state.stopGame(); if (!!state.game) state.stopGame();
state.worldEditor.resetSelection(); state.worldEditor.resetSelection();
}, []); }, []);
return null; return null;
@ -37,7 +46,8 @@ function EditorObjectRoute() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
if (state.isGamePlaying) state.stopGame(); if (!!state.game)
state.stopGame();
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'translate' ? state.worldEditor.selection.editMode ?? 'translate'
: 'translate'; : 'translate';
@ -50,7 +60,7 @@ function EditorObjectTypeRoute() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
if (state.isGamePlaying) state.stopGame(); if (!!state.game) state.stopGame();
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'scripts' ? state.worldEditor.selection.editMode ?? 'scripts'
: 'scripts'; : 'scripts';
@ -61,7 +71,8 @@ function EditorObjectTypeRoute() {
function GameRoute() { function GameRoute() {
useEffect(() => { useEffect(() => {
if (!state.isGamePlaying) state.startGame(); if (!state.game)
state.startGame();
}, []); }, []);
return null; return null;
} }
@ -86,6 +97,7 @@ export const App = function () {
<StateToUrlSync /> <StateToUrlSync />
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/editor/objectType/:id/voxels" element={<VoxelEditorPage />} />
<Route element={<EditorLayout />}> <Route element={<EditorLayout />}>
<Route path="/editor" element={<EditorRoute />} /> <Route path="/editor" element={<EditorRoute />} />
<Route path="/editor/object/:id" element={<EditorObjectRoute />} /> <Route path="/editor/object/:id" element={<EditorObjectRoute />} />

View File

@ -3,9 +3,11 @@ import { useEffect, useRef } from 'react';
import { state } from '../state'; import { state } from '../state';
import './MenuView.scss'; import './MenuView.scss';
import type { MenuNode } from '../state/menuState'; import type { MenuNode } from '../state/menuState';
import { useNavigate } from 'react-router-dom';
export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) { export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
const forceOpen = state.menu.nodeContainsSelected(node); const forceOpen = state.menu.nodeContainsSelected(node);
const navigate = useNavigate();
const ref = useRef<HTMLDetailsElement>(null); const ref = useRef<HTMLDetailsElement>(null);
@ -32,7 +34,13 @@ export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
<div <div
key={action.id} key={action.id}
title={action.tooltip} title={action.tooltip}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); action.onClick(); }} onClick={(e) => {
e.stopPropagation();
e.preventDefault();
action.onClick?.();
if (action.link)
navigate(action.link);
}}
> >
{action.content} {action.content}
</div> </div>

View File

@ -59,6 +59,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
if (!objectType) if (!objectType)
return null; return null;
// Track cache so observer re-renders when refreshObjectTypeCaches is called
void object.cache.updatedAt;
function handleClick() { function handleClick() {
if (state.worldEditor.isEnabled) { if (state.worldEditor.isEnabled) {
// Reading selection state inside an event handler: not tracked by observer. // Reading selection state inside an event handler: not tracked by observer.

View File

@ -115,7 +115,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
> >
{ {
object.cache.voxelGroups.map((vg) => object.cache.voxelGroups.map((vg) =>
<Instances key={vg.id} limit={vg.positions.length}> <Instances key={`${vg.id}-${object.cache.updatedAt}`} limit={vg.positions.length}>
<boxGeometry args={[1, 1, 1]} /> <boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} /> <meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
{vg.positions.map((pos, i) => <Instance key={i} position={pos} />)} {vg.positions.map((pos, i) => <Instance key={i} position={pos} />)}

View File

@ -0,0 +1,198 @@
import { Canvas } from '@react-three/fiber';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { useState, useCallback, useEffect, useRef } from 'react';
import { state } from '../../state';
import type { Voxel, V3 } from '../../types';
import { VoxelEditorScene } from './VoxelEditorScene';
import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react';
const PALETTE = [
'#ffffff', '#cccccc', '#999999', '#555555', '#222222',
'#e05555', '#e08844', '#e0cc44', '#55cc55', '#55cccc',
'#5588e0', '#aa55e0', '#a0522d', '#228b22', '#c06858',
'#e8b820', '#484040', '#d0c8b8', '#808080', '#181414',
];
type HistoryEntry =
| { type: 'add'; voxel: Voxel }
| { type: 'remove'; voxel: Voxel };
export const VoxelEditorPage = observer(function () {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [mode, setMode] = useState<'add' | 'remove'>('add');
const [color, setColor] = useState('#808080');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const history = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
const objectType = id ? state.world.getObjectTypeById(id) : undefined;
useEffect(() => {
return () => {
if (id) state.worldEditor.refreshObjectTypeCaches(id);
};
}, [id]);
function pushHistory(entry: HistoryEntry) {
history.current.push(entry);
future.current = [];
setCanUndo(true);
setCanRedo(false);
}
const undo = useCallback(() => {
const entry = history.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
} else {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
}
future.current.push(entry);
setCanUndo(history.current.length > 0);
setCanRedo(true);
}, [id]);
const redo = useCallback(() => {
const entry = future.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
} else {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
}
history.current.push(entry);
setCanUndo(true);
setCanRedo(future.current.length > 0);
}, [id]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!e.ctrlKey)
return;
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); }
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [undo, redo]);
function handleAdd(voxel: Voxel) {
if (!id)
return;
state.worldEditor.addVoxelToObjectType(id, voxel);
pushHistory({ type: 'add', voxel });
}
function handleRemove(position: V3) {
if (!id || !objectType)
return;
const voxel = objectType.voxels.find(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (!voxel)
return;
state.worldEditor.removeVoxelFromObjectType(id, position);
pushHistory({ type: 'remove', voxel });
}
if (!objectType) {
return (
<div style={{ color: 'white', padding: 24 }}>
Object type not found. <Link to="/editor" style={{ color: '#88aaff' }}>Back to editor</Link>
</div>
);
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}>
{/* Top bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}>
<button onClick={() => navigate(`/editor/objectType/${id}`)} style={btnStyle}>
<IconArrowLeft size={20} />
</button>
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
<div style={{ flex: 1 }} />
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
<IconArrowBackUp size={20} />
</button>
<button onClick={redo} disabled={!canRedo} style={{ ...btnStyle, opacity: canRedo ? 1 : 0.35 }}>
<IconArrowForwardUp size={20} />
</button>
</div>
{/* 3D canvas */}
<div style={{ flex: 1, position: 'relative' }}>
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
<VoxelEditorScene
voxels={objectType.voxels}
mode={mode}
color={color}
typeId="stone"
onAdd={handleAdd}
onRemove={handleRemove}
/>
</Canvas>
</div>
{/* Bottom bar */}
<div style={{ background: '#11111f', padding: '10px 12px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Color palette */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{PALETTE.map(c => (
<button
key={c}
onClick={() => { setColor(c); setMode('add'); }}
style={{
width: 36, height: 36,
background: c,
borderRadius: 6,
border: c === color ? '3px solid white' : '3px solid #333',
cursor: 'pointer',
flexShrink: 0,
}}
/>
))}
</div>
{/* Mode toggle */}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setMode('add')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'add' ? '#2d6a2d' : '#2a2a3a', border: mode === 'add' ? '2px solid #4CAF50' : '2px solid #444' }}
>
<IconPlus size={18} /> Add
</button>
<button
onClick={() => setMode('remove')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'remove' ? '#6a2d2d' : '#2a2a3a', border: mode === 'remove' ? '2px solid #f44336' : '2px solid #444' }}
>
<IconTrash size={18} /> Remove
</button>
</div>
</div>
</div>
);
});
const btnStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#2a2a3a',
color: '#ccc',
border: '2px solid #444',
borderRadius: 8,
padding: '6px 10px',
cursor: 'pointer',
fontSize: 14,
gap: 4,
};

View File

@ -0,0 +1,125 @@
import { OrbitControls } from '@react-three/drei';
import { useState } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
import type { Voxel, V3 } from '../../types';
type Props = {
voxels: Voxel[];
mode: 'add' | 'remove';
color: string;
typeId: string;
onAdd: (voxel: Voxel) => void;
onRemove: (position: V3) => void;
};
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;
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;
e.stopPropagation();
if (mode === 'remove') {
onRemove(voxel.position);
setGhost(null);
} 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 });
}
}
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
e.stopPropagation();
if (mode === 'remove') {
setGhost(voxel.position);
} else {
setGhost(null);
}
}
function handleFloorClick(e: ThreeEvent<MouseEvent>) {
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 });
}
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
if (mode !== 'add')
return;
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
}
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost));
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]}
onClick={handleFloorClick}
onPointerMove={handleFloorMove}
onPointerLeave={() => setGhost(null)}
>
<planeGeometry args={[40, 40]} />
<meshStandardMaterial color="#2a2a3a" />
</mesh>
<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>
);
})}
{/* Ghost voxel preview */}
{ghost && mode === 'add' && !ghostOccupied && (
<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>
)}
</>
);
}

View File

@ -1,13 +1,14 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import { state } from "./rootState"; import { state } from "./rootState";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff } from '@tabler/icons-react'; import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react';
export type MenuNodeAction = { export type MenuNodeAction = {
id: string; id: string;
content: ReactNode; content: ReactNode;
tooltip: string; tooltip: string;
onClick: () => void; onClick?: () => void;
link?: string;
} }
export type MenuNode = { export type MenuNode = {
@ -75,6 +76,12 @@ export class MenuState {
tooltip: 'Create new object instance', tooltip: 'Create new object instance',
onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); }, onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); },
}, },
{
id: 'edit-object-type-voxels',
content: <IconEdit size="1em" />,
tooltip: 'Edit object type voxels',
link: `/editor/objectType/${ot.id}/voxels`,
},
{ {
id: 'delete-object-type', id: 'delete-object-type',
content: <IconCubeOff size="1em" />, content: <IconCubeOff size="1em" />,

View File

@ -1,11 +1,13 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType } from "../types"; import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType, type Voxel } from "../types";
import { createObjectInstance } from "../utils/object"; import { createObjectInstance } from "../utils/object";
import { randomId } from "../utils"; import { randomId } from "../utils";
import { state } from "./rootState"; import { state } from "./rootState";
import { populateRuntimeObject } from "../utils/runtime"; import { populateRuntimeObject, computeBoundingBox } from "../utils/runtime";
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes"; import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
import { buildObjectTrimesh } from "../utils/graphics/mesh";
export const ObjectEditModeEnum = [ export const ObjectEditModeEnum = [
'translate', 'translate',
@ -161,6 +163,42 @@ export class WorldEditorState {
return objectType; return objectType;
} }
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
const occupied = objectType.voxels.some(v =>
v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2]
);
if (!occupied) objectType.voxels.push(voxel);
}
public removeVoxelFromObjectType(typeId: string, position: V3): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
const idx = objectType.voxels.findIndex(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (idx !== -1) objectType.voxels.splice(idx, 1);
}
public refreshObjectTypeCaches(typeId: string): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
const world = this.world.data;
const now = Date.now();
for (const obj of Object.values(this.scene.objects)) {
if (obj.typeId !== typeId) continue;
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
obj.cache.boundingBox = computeBoundingBox(objectType);
obj.cache.updatedAt = now;
}
}
public deleteObjectType(typeId: string) { public deleteObjectType(typeId: string) {
if (this.selection?.type === 'objectType' && this.selection.id === typeId) if (this.selection?.type === 'objectType' && this.selection.id === typeId)
this.resetSelection(); this.resetSelection();

View File

@ -7,6 +7,7 @@ export type ObjectInstanceRuntimeData = {
voxelGroups: VoxelGroup[]; voxelGroups: VoxelGroup[];
colliderMesh: [Float32Array, Uint32Array] | null; colliderMesh: [Float32Array, Uint32Array] | null;
boundingBox: { min: V3; max: V3 }; boundingBox: { min: V3; max: V3 };
updatedAt: number;
}; };
pendingActions: { pendingActions: {
impulse?: { direction: V3, amplitude: number }; impulse?: { direction: V3, amplitude: number };

View File

@ -4,7 +4,7 @@ export function createObjectInstance(id: string, typeId: string): ObjectInstance
return { return {
id, id,
typeId: typeId, typeId: typeId,
physics: false, physics: true,
gravityScale: 1, gravityScale: 1,
position: [0, 0, 0], position: [0, 0, 0],
rotation: [0, 0, 0], rotation: [0, 0, 0],

View File

@ -2,7 +2,7 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types"; import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh"; import { buildObjectTrimesh } from "../graphics/mesh";
function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } { export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
if (!objectType.voxels.length) if (!objectType.voxels.length)
return { min: [0, 0, 0], max: [1, 1, 1] }; return { min: [0, 0, 0], max: [1, 1, 1] };
let minX = Infinity, minY = Infinity, minZ = Infinity; let minX = Infinity, minY = Infinity, minZ = Infinity;
@ -27,6 +27,7 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes), voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes), colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
boundingBox: computeBoundingBox(objectType), boundingBox: computeBoundingBox(objectType),
updatedAt: 0,
}, },
pendingActions: {}, pendingActions: {},
}; };