feat: Implement ColliderCreator and ColliderInstance components, enhance PhysicsSimulator with multiple MaterialSpawner instances
This commit is contained in:
58
app/src/modules/scene/physics/colliders/colliderCreator.tsx
Normal file
58
app/src/modules/scene/physics/colliders/colliderCreator.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useThree } from '@react-three/fiber';
|
||||||
|
import ColliderInstance from './colliderInstance/colliderInstance';
|
||||||
|
|
||||||
|
function ColliderCreator() {
|
||||||
|
const { camera, gl, scene, raycaster, pointer } = useThree();
|
||||||
|
const [colliders, setColliders] = useState<
|
||||||
|
{ id: string; position: [number, number, number]; colliderType: 'Default material' | 'Material 1' | 'Material 2' | 'Material 3' }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const handleCtrlClick = (e: MouseEvent) => {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
|
||||||
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const point = intersects[0].point;
|
||||||
|
|
||||||
|
const spawnPosition: [number, number, number] = [point.x, point.y + 0.5, point.z];
|
||||||
|
|
||||||
|
setColliders(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
position: spawnPosition,
|
||||||
|
colliderType: 'Default material',
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
canvas.addEventListener('click', handleCtrlClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('click', handleCtrlClick);
|
||||||
|
};
|
||||||
|
}, [colliders, camera]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{colliders.map(({ id, position }) => (
|
||||||
|
<ColliderInstance
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
colliders={colliders}
|
||||||
|
setColliders={setColliders}
|
||||||
|
position={position}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColliderCreator;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { CameraControls } from '@react-three/drei';
|
||||||
|
import { useThree, useFrame } from '@react-three/fiber';
|
||||||
|
import { CollisionPayload, RapierRigidBody, RigidBody } from '@react-three/rapier';
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
function ColliderInstance({ id, colliders, setColliders, position }: {
|
||||||
|
id: string;
|
||||||
|
colliders: { id: string; position: [number, number, number]; colliderType: 'Default material' | 'Material 1' | 'Material 2' | 'Material 3' }[];
|
||||||
|
setColliders: React.Dispatch<React.SetStateAction<{ id: string; position: [number, number, number]; colliderType: 'Default material' | 'Material 1' | 'Material 2' | 'Material 3' }[]>>;
|
||||||
|
position: [number, number, number];
|
||||||
|
}) {
|
||||||
|
const { camera, gl, pointer, controls } = useThree();
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const dragOffset = useRef(new THREE.Vector3());
|
||||||
|
const initialDepth = useRef(0);
|
||||||
|
const ref = useRef<RapierRigidBody>(null);
|
||||||
|
const [color, setColor] = useState(new THREE.Color('white'));
|
||||||
|
const [objectsOnCollider, setObjectsOnCollider] = useState<Set<RapierRigidBody>>(new Set());
|
||||||
|
|
||||||
|
const getColorByType = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Material 1':
|
||||||
|
return new THREE.Color('blue');
|
||||||
|
case 'Material 2':
|
||||||
|
return new THREE.Color('green');
|
||||||
|
case 'Material 3':
|
||||||
|
return new THREE.Color('yellow');
|
||||||
|
default:
|
||||||
|
return new THREE.Color('white');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = colliders.find(c => c.id === id);
|
||||||
|
if (current) {
|
||||||
|
setColor(getColorByType(current.colliderType));
|
||||||
|
}
|
||||||
|
}, [colliders, id]);
|
||||||
|
|
||||||
|
|
||||||
|
const handlePointerDown = (id: string) => {
|
||||||
|
if (controls) {
|
||||||
|
(controls as CameraControls).enabled = false;
|
||||||
|
}
|
||||||
|
setDraggedId(id);
|
||||||
|
|
||||||
|
const collider = colliders.find(c => c.id === id);
|
||||||
|
if (!collider || !ref.current) return;
|
||||||
|
|
||||||
|
const pos = ref.current.translation();
|
||||||
|
const screenPos = new THREE.Vector3(pos.x, pos.y, pos.z).project(camera);
|
||||||
|
|
||||||
|
dragOffset.current = new THREE.Vector3(screenPos.x - pointer.x, 0, screenPos.y - pointer.y);
|
||||||
|
initialDepth.current = new THREE.Vector3(pos.x, pos.y, pos.z).sub(camera.position).length();
|
||||||
|
|
||||||
|
ref.current.setGravityScale(0, true);
|
||||||
|
ref.current.setLinearDamping(10);
|
||||||
|
ref.current.setAngularDamping(10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = () => {
|
||||||
|
if (!draggedId) return;
|
||||||
|
|
||||||
|
const collider = colliders.find(c => c.id === draggedId);
|
||||||
|
if (!collider || !ref.current) return;
|
||||||
|
|
||||||
|
const screenTarget = new THREE.Vector3(
|
||||||
|
pointer.x + dragOffset.current.x,
|
||||||
|
0,
|
||||||
|
pointer.y + dragOffset.current.z
|
||||||
|
);
|
||||||
|
|
||||||
|
const worldTarget = new THREE.Vector3(screenTarget.x, screenTarget.z, 0.5).unproject(camera);
|
||||||
|
const dir = worldTarget.clone().sub(camera.position).normalize();
|
||||||
|
const finalPos = camera.position.clone().add(dir.multiplyScalar(initialDepth.current));
|
||||||
|
|
||||||
|
ref.current.setTranslation(
|
||||||
|
{ x: finalPos.x, y: finalPos.y, z: finalPos.z },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
if (controls) {
|
||||||
|
(controls as CameraControls).enabled = true;
|
||||||
|
}
|
||||||
|
if (!draggedId) return;
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.setGravityScale(1, true);
|
||||||
|
ref.current.setLinearDamping(0.5);
|
||||||
|
ref.current.setAngularDamping(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
canvas.addEventListener('pointermove', handlePointerMove);
|
||||||
|
canvas.addEventListener('pointerup', handlePointerUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
canvas.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [colliders, draggedId, controls]);
|
||||||
|
|
||||||
|
const handleMaterialEnter = (e: CollisionPayload) => {
|
||||||
|
setColor(new THREE.Color('pink'));
|
||||||
|
const body = e.other.rigidBody;
|
||||||
|
const current = colliders.find(c => c.id === id);
|
||||||
|
if (current) {
|
||||||
|
if (body && (body.userData as any)?.materialType === current.colliderType) {
|
||||||
|
setObjectsOnCollider(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(body);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaterialExit = (e: CollisionPayload) => {
|
||||||
|
const current = colliders.find(c => c.id === id);
|
||||||
|
if (current)
|
||||||
|
setColor(getColorByType(current.colliderType));
|
||||||
|
const body = e.other.rigidBody;
|
||||||
|
if (body) {
|
||||||
|
setObjectsOnCollider(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(body);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
objectsOnCollider.forEach(rigidBody => {
|
||||||
|
if (!rigidBody) return;
|
||||||
|
|
||||||
|
rigidBody.setLinvel({ x: 0, y: 0, z: 2 }, true);
|
||||||
|
rigidBody.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RigidBody
|
||||||
|
name='Sensor-Collider'
|
||||||
|
key={id}
|
||||||
|
ref={ref}
|
||||||
|
type="fixed"
|
||||||
|
sensor
|
||||||
|
position={position}
|
||||||
|
colliders="cuboid"
|
||||||
|
includeInvisible
|
||||||
|
gravityScale={0}
|
||||||
|
onIntersectionEnter={handleMaterialEnter}
|
||||||
|
onIntersectionExit={handleMaterialExit}
|
||||||
|
>
|
||||||
|
<mesh
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerDown(id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setColliders(prev =>
|
||||||
|
prev.map(c =>
|
||||||
|
c.id === id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
colliderType:
|
||||||
|
c.colliderType === 'Default material'
|
||||||
|
? 'Material 1'
|
||||||
|
: c.colliderType === 'Material 1'
|
||||||
|
? 'Material 2'
|
||||||
|
: c.colliderType === 'Material 2'
|
||||||
|
? 'Material 3'
|
||||||
|
: 'Default material',
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color={color} transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
</RigidBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColliderInstance;
|
||||||
@@ -15,12 +15,19 @@ type MaterialSpawnerProps = {
|
|||||||
|
|
||||||
function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawnerProps) {
|
function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawnerProps) {
|
||||||
const { loadingProgress } = useLoadingProgress();
|
const { loadingProgress } = useLoadingProgress();
|
||||||
const [spawned, setSpawned] = useState<{ id: string; position: [number, number, number]; ref: React.RefObject<RapierRigidBody> }[]>([]);
|
const [spawned, setSpawned] = useState<{
|
||||||
|
id: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
ref: React.RefObject<RapierRigidBody>;
|
||||||
|
materialType: string;
|
||||||
|
}[]>([]);
|
||||||
|
const [spawningPaused, setSpawningPaused] = useState(false);
|
||||||
const spawnedCount = useRef(0);
|
const spawnedCount = useRef(0);
|
||||||
const { gl, camera, pointer, controls } = useThree();
|
const { gl, camera, pointer, controls } = useThree();
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
const dragOffset = useRef<THREE.Vector3>(new THREE.Vector3());
|
const dragOffset = useRef<THREE.Vector3>(new THREE.Vector3());
|
||||||
const initialDepth = useRef<number>(0);
|
const initialDepth = useRef<number>(0);
|
||||||
|
const materialTypes = ['Default material', 'Material 1', 'Material 2', 'Material 3'];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadingProgress !== 0) return;
|
if (loadingProgress !== 0) return;
|
||||||
@@ -30,6 +37,7 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
const startSpawning = () => {
|
const startSpawning = () => {
|
||||||
if (!interval) {
|
if (!interval) {
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
|
if (spawningPaused) return;
|
||||||
setSpawned(prev => {
|
setSpawned(prev => {
|
||||||
if (spawnCount !== undefined && spawnedCount.current >= spawnCount) {
|
if (spawnCount !== undefined && spawnedCount.current >= spawnCount) {
|
||||||
clearInterval(interval!);
|
clearInterval(interval!);
|
||||||
@@ -37,15 +45,20 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
spawnedCount.current++;
|
spawnedCount.current++;
|
||||||
|
|
||||||
|
const randomMaterialType = materialTypes[Math.floor(Math.random() * materialTypes.length)];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: generateUniqueId(),
|
id: generateUniqueId(),
|
||||||
position,
|
position,
|
||||||
ref: React.createRef<RapierRigidBody>(),
|
ref: React.createRef<RapierRigidBody>(),
|
||||||
|
materialType: randomMaterialType,
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
}, spawnInterval);
|
}, spawnInterval);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -72,16 +85,14 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
stopSpawning();
|
stopSpawning();
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
};
|
};
|
||||||
}, [loadingProgress, spawnInterval, spawnCount, position]);
|
}, [loadingProgress, spawnInterval, spawnCount, position, spawningPaused]);
|
||||||
|
|
||||||
const handleSleep = (id: string) => {
|
const handleSleep = (id: string) => {
|
||||||
setSpawned(prev => prev.filter(obj => obj.id !== id));
|
setSpawned(prev => prev.filter(obj => obj.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerDown = (id: string) => {
|
const handlePointerDown = (id: string) => {
|
||||||
if (controls) {
|
if (controls) (controls as CameraControls).enabled = false;
|
||||||
(controls as CameraControls).enabled = false;
|
|
||||||
}
|
|
||||||
setDraggedId(id);
|
setDraggedId(id);
|
||||||
|
|
||||||
const obj = spawned.find(o => o.id === id);
|
const obj = spawned.find(o => o.id === id);
|
||||||
@@ -128,7 +139,6 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
const finalPosition = camera.position.clone().add(direction.multiplyScalar(initialDepth.current));
|
const finalPosition = camera.position.clone().add(direction.multiplyScalar(initialDepth.current));
|
||||||
|
|
||||||
const currentPosition = obj.ref.current.translation();
|
const currentPosition = obj.ref.current.translation();
|
||||||
|
|
||||||
const moveDirection = new THREE.Vector3().subVectors(finalPosition, currentPosition);
|
const moveDirection = new THREE.Vector3().subVectors(finalPosition, currentPosition);
|
||||||
|
|
||||||
obj.ref.current.setLinvel({
|
obj.ref.current.setLinvel({
|
||||||
@@ -139,9 +149,7 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
if (controls) {
|
if (controls) (controls as CameraControls).enabled = true;
|
||||||
(controls as CameraControls).enabled = true;
|
|
||||||
}
|
|
||||||
if (!draggedId) return;
|
if (!draggedId) return;
|
||||||
|
|
||||||
const obj = spawned.find(o => o.id === draggedId);
|
const obj = spawned.find(o => o.id === draggedId);
|
||||||
@@ -165,9 +173,21 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
};
|
};
|
||||||
}, [draggedId, spawned, controls, camera, gl]);
|
}, [draggedId, spawned, controls, camera, gl]);
|
||||||
|
|
||||||
|
const handleBoxClick = () => {
|
||||||
|
setSpawningPaused(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{spawned.map(({ id, position, ref }) => (
|
<mesh
|
||||||
|
position={position}
|
||||||
|
onClick={handleBoxClick}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color={spawningPaused ? "red" : "white"} transparent opacity={0.2} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{spawned.map(({ id, position, materialType, ref }) => (
|
||||||
<RigidBody
|
<RigidBody
|
||||||
key={id}
|
key={id}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -176,11 +196,12 @@ function MaterialSpawner({ position, spawnInterval, spawnCount }: MaterialSpawne
|
|||||||
angularDamping={0.5}
|
angularDamping={0.5}
|
||||||
linearDamping={0.5}
|
linearDamping={0.5}
|
||||||
restitution={0.1}
|
restitution={0.1}
|
||||||
// onSleep={() => handleSleep(id)}
|
userData={{ materialType, materialUuid: id }}
|
||||||
|
// onSleep={() => handleSleep(id)}
|
||||||
>
|
>
|
||||||
<MaterialModel
|
<MaterialModel
|
||||||
materialId={id}
|
materialId={id}
|
||||||
materialType="Default material"
|
materialType={materialType}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePointerDown(id);
|
handlePointerDown(id);
|
||||||
|
|||||||
28
app/src/modules/scene/physics/physicsSimulator.tsx
Normal file
28
app/src/modules/scene/physics/physicsSimulator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import MaterialSpawner from './materialSpawner'
|
||||||
|
import ColliderCreator from './colliders/colliderCreator'
|
||||||
|
|
||||||
|
function PhysicsSimulator() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MaterialSpawner
|
||||||
|
position={[0, 3, 0]}
|
||||||
|
spawnInterval={1000}
|
||||||
|
spawnCount={15}
|
||||||
|
/>
|
||||||
|
<MaterialSpawner
|
||||||
|
position={[-21, 3, -8]}
|
||||||
|
spawnInterval={1000}
|
||||||
|
spawnCount={5}
|
||||||
|
/>
|
||||||
|
<MaterialSpawner
|
||||||
|
position={[-17, 3, 6]}
|
||||||
|
spawnInterval={1000}
|
||||||
|
spawnCount={50}
|
||||||
|
/>
|
||||||
|
<ColliderCreator />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PhysicsSimulator
|
||||||
@@ -7,6 +7,7 @@ import Builder from "../builder/builder";
|
|||||||
import Visualization from "../visualization/visualization";
|
import Visualization from "../visualization/visualization";
|
||||||
import Setup from "./setup/setup";
|
import Setup from "./setup/setup";
|
||||||
import Simulation from "../simulation/simulation";
|
import Simulation from "../simulation/simulation";
|
||||||
|
import PhysicsSimulator from "./physics/physicsSimulator";
|
||||||
import Collaboration from "../collaboration/collaboration";
|
import Collaboration from "../collaboration/collaboration";
|
||||||
import useModuleStore from "../../store/useModuleStore";
|
import useModuleStore from "../../store/useModuleStore";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -15,7 +16,6 @@ import { getUserData } from "../../functions/getUserData";
|
|||||||
import { useLoadingProgress, useSocketStore } from "../../store/builder/store";
|
import { useLoadingProgress, useSocketStore } from "../../store/builder/store";
|
||||||
import { Color } from "three";
|
import { Color } from "three";
|
||||||
import { Physics } from "@react-three/rapier";
|
import { Physics } from "@react-three/rapier";
|
||||||
import MaterialSpawner from "./physics/materialSpawner";
|
|
||||||
|
|
||||||
export default function Scene({ layout }: { readonly layout: 'Main Layout' | 'Comparison Layout' }) {
|
export default function Scene({ layout }: { readonly layout: 'Main Layout' | 'Comparison Layout' }) {
|
||||||
const map = useMemo(() => [
|
const map = useMemo(() => [
|
||||||
@@ -73,20 +73,11 @@ export default function Scene({ layout }: { readonly layout: 'Main Layout' | 'Co
|
|||||||
>
|
>
|
||||||
<Setup />
|
<Setup />
|
||||||
<Collaboration />
|
<Collaboration />
|
||||||
<Physics gravity={[0, -9.81, 0]} allowedLinearError={20} numSolverIterations={20} debug >
|
<Physics gravity={[0, -9.81, 0]} allowedLinearError={50} numSolverIterations={50} debug >
|
||||||
<Builder />
|
<Builder />
|
||||||
<Simulation />
|
<Simulation />
|
||||||
|
|
||||||
<MaterialSpawner
|
<PhysicsSimulator />
|
||||||
position={[0, 3, 0]}
|
|
||||||
spawnInterval={1000}
|
|
||||||
spawnCount={50}
|
|
||||||
/>
|
|
||||||
<MaterialSpawner
|
|
||||||
position={[-21, 3, -8]}
|
|
||||||
spawnInterval={1000}
|
|
||||||
spawnCount={10}
|
|
||||||
/>
|
|
||||||
</Physics>
|
</Physics>
|
||||||
<Visualization />
|
<Visualization />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
Reference in New Issue
Block a user