はじめに
皆様こんにちは。
AMDlab Webエンジニアの塚田です。
引き続き react-three/fiberとdrei の学習を進めています。
今回は3D上で面操作可能な多角柱を作成していきたいと思います。面操作には色々方法はありますが、今回はdreiのTransformControlsを使用して作成してみたいと思います。
説明
react、typescript、react-three/fiber、drei、levaについては事前に準備してください。(私はNext.jsを使用しています。)
完成したプロジェクトのフォルダ構成は以下のようになります。
app/
3d-manager/
page.tsx
components/
custom-process/
CustomProcess.tsx
CustomRectangle.tsx
CustomShape.tsx
CustomTransformControls.tsx
panel/
CustomPanel.tsx
先にlevaによる操作パネル(CustomPanel.tsx)について記載します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import { button, useControls } from "leva"; type Props = { setMode: React.Dispatch<React.SetStateAction<number>>; setIsMove: React.Dispatch<React.SetStateAction<boolean>>; }; const CustomPanel: React.FC<Props> = ({ setMode, setIsMove }) => { useControls({ 六角柱: button(() => { setMode(0); }), 四角柱: button(() => { setMode(1); }), 八角柱: button(() => { setMode(2); }), shape移動: button(() => { setIsMove(true); }), shape伸縮高さ方向: button(() => { setIsMove(false); }), }); return <></>; }; export default CustomPanel; |
動画のような動作になります。デフォルトでパネルを掴んで移動させる機能が地味に嬉しいです。
次に今回のメインとなる面操作と形状の変化についてです。
dreiのTransformControlsで面を操作します。形状の変化については自前で用意した面(CustomRectangle)と
page.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
"use client"; import React, { useState } from "react"; import CustomProcess from "../components/custom-process/CustomProcess"; import CustomPanel from "../components/panel/CustomPanel"; const Manager = () => { const [mode, setMode] = useState<number>(0); const [isMove, setIsMove] = useState<boolean>(true); return ( <div style={{ width: "100vw", height: "100vh" }}> <CustomPanel setMode={setMode} setIsMove={setIsMove} /> <CustomProcess mode={mode} isMove={isMove} /> </div> ); }; export default Manager; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
"use client"; import React, { useEffect, useState } from "react"; import { Canvas } from "@react-three/fiber"; import { OrbitControls } from "@react-three/drei"; import * as THREE from "three"; import CustomShape from "./CustomShape"; import CustomRectangle from "./CustomRectangle"; import { CustomTransformControls } from "./CustomTransformControls"; type Props = { mode: number; isMove: boolean; }; const CustomProcess: React.FC<Props> = ({ mode, isMove }) => { const [isEnabled, setEnabled] = useState<boolean>(true); // 柱の形状とするshapeの頂点情報。上面と下面のshapeを保持 const [points, setPoints] = useState<THREE.Vector3[][]>([]); const [current, setCurrent] = useState<string | null>(null); useEffect(() => { if (mode === 0) { // 現状固定値だが、今後画面上で作成できるようにするつもり // 六角柱 setPoints([ [ new THREE.Vector3(3, 0, 0), new THREE.Vector3(1.5, 0, 2.598), new THREE.Vector3(-1.5, 0, 2.598), new THREE.Vector3(-3, 0, 0), new THREE.Vector3(-1.5, 0, -2.598), new THREE.Vector3(1.5, 0, -2.598), ], [ new THREE.Vector3(3, 1, 0), new THREE.Vector3(1.5, 1, 2.598), new THREE.Vector3(-1.5, 1, 2.598), new THREE.Vector3(-3, 1, 0), new THREE.Vector3(-1.5, 1, -2.598), new THREE.Vector3(1.5, 1, -2.598), ], ]); } else if (mode === 1) { // 四角柱 setPoints([ [ new THREE.Vector3(-1.5, 0, -1.5), new THREE.Vector3(1.5, 0, -1.5), new THREE.Vector3(1.5, 0, 1.5), new THREE.Vector3(-1.5, 0, 1.5), ], [ new THREE.Vector3(-1.5, 1, -1.5), new THREE.Vector3(1.5, 1, -1.5), new THREE.Vector3(1.5, 1, 1.5), new THREE.Vector3(-1.5, 1, 1.5), ], ]); } else if (mode === 2) { // 八角柱 setPoints([ [ new THREE.Vector3(3.61, 0, 0), new THREE.Vector3(2.55, 0, 2.55), new THREE.Vector3(0, 0, 3.61), new THREE.Vector3(-2.55, 0, 2.55), new THREE.Vector3(-3.61, 0, 0), new THREE.Vector3(-2.55, 0, -2.55), new THREE.Vector3(0, 0, -3.61), new THREE.Vector3(2.55, 0, -2.55), ], [ new THREE.Vector3(3.61, 1, 0), new THREE.Vector3(2.55, 1, 2.55), new THREE.Vector3(0, 1, 3.61), new THREE.Vector3(-2.55, 1, 2.55), new THREE.Vector3(-3.61, 1, 0), new THREE.Vector3(-2.55, 1, -2.55), new THREE.Vector3(0, 1, -3.61), new THREE.Vector3(2.55, 1, -2.55), ], ]); } }, [mode, setPoints]); return ( <Canvas camera={{ position: [0, 10, 0], rotation: [-Math.PI / 2, 0, 0], fov: 50, aspect: typeof window !== "undefined" ? window.innerWidth / window.innerHeight : 1, near: 0.1, far: 2000, }} dpr={typeof window !== "undefined" ? window.devicePixelRatio : 1} shadows > {points.length > 1 && points[0].length > 2 && ( <CustomShape index={0} points={points[0]} setCurrent={setCurrent} /> )} {points.length > 1 && points[1].length > 2 && ( <CustomShape index={1} points={points[1]} setCurrent={setCurrent} /> )} <group> {points.length > 1 && points[0].length > 2 && points[0].map((point, index) => ( <CustomRectangle key={`custom-rectangle-${index}`} index={index} startPoint1={point} startPoint2={points[1][index]} lastIndex={points[0].length - 1} setCurrent={setCurrent} points={points} /> ))} </group> {points.length > 1 && points[0].length > 2 && ( <CustomTransformControls setPoints={setPoints} setEnableRotate={setEnabled} current={current} lastIndex={points[0].length - 1} isMove={isMove} /> )} <pointLight position={[10, 10, 10]} /> <ambientLight /> <OrbitControls zoomToCursor enabled={isEnabled} /> <axesHelper /> <gridHelper position={[0, 0.01, 0]} args={[10, 10, "red", "black"]} /> </Canvas> ); }; export default CustomProcess; |
CustomProcess にはCanvasを用意して、OrbitControlsや各コンポーネントを配置します。
床面と上面のshapeの座標を保持しておく為、points というvector3の2次元配列のstate変数を用意します。初期表示時に固定値を設定しています。
CustomShape.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
import React from "react"; import { Extrude } from "@react-three/drei"; import * as THREE from "three"; type Props = { index: number; points: THREE.Vector3[]; setCurrent: React.Dispatch<React.SetStateAction<string | null>>; }; // 柱の床面と上面 const CustomShape: React.FC<Props> = ({ index, points, setCurrent }) => { const extrudeSettings = { steps: 1, depth: 0, bevelEnabled: false, }; const getMidPoint = () => { const totalX = points.reduce((acc, cur) => acc + cur.x, 0) / points.length; const totalY = points.reduce((acc, cur) => acc + cur.y, 0) / points.length; const totalZ = points.reduce((acc, cur) => acc + cur.z, 0) / points.length; return new THREE.Vector3(totalX, totalY, totalZ); }; const createShape = () => { const midpoint = getMidPoint(); const newShape = new THREE.Shape(); if (points.length > 0) { newShape.moveTo(points[0].x - midpoint.x, -points[0].z + midpoint.z); for (let i = 1; i < points.length; i++) { newShape.lineTo(points[i].x - midpoint.x, -points[i].z + midpoint.z); } newShape.closePath(); } return newShape; }; return ( <mesh name={`custom-extrude-${index + 1}`} position={getMidPoint()} userData={{ index: index, midpoint: getMidPoint() }} rotation={[-Math.PI / 2, 0, 0]} onClick={(e) => { e.stopPropagation(); setCurrent(`custom-extrude-${index + 1}`); }} > <Extrude args={[createShape(), extrudeSettings]}> <meshStandardMaterial attach="material" color="lightblue" opacity={0.7} transparent={true} /> </Extrude> </mesh> ); }; export default CustomShape; |
CustomShape は床面と上面のshapeを作成するコンポーネントです。
CustomRectangle.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
import React, { useMemo } from "react"; import * as THREE from "three"; type Props = { index: number; startPoint1: THREE.Vector3; startPoint2: THREE.Vector3; lastIndex: number; setCurrent: React.Dispatch<React.SetStateAction<string | null>>; points: THREE.Vector3[][]; }; // 柱のサイドの面(長方形) const CustomRectangle: React.FC<Props> = ({ setCurrent, index, startPoint1, startPoint2, lastIndex, points, }) => { const end1 = index === lastIndex ? points[0][0] : points[0][index + 1]; const end2 = index === lastIndex ? points[1][0] : points[1][index + 1]; const direction = new THREE.Vector3().subVectors(end1, startPoint1).normalize(); const quaternion = useMemo(() => { const defaultDirection = new THREE.Vector3(1, 0, 0); const quat = new THREE.Quaternion(); quat.setFromUnitVectors(defaultDirection, direction); return quat; }, [direction]); // 中点を計算 const midpoint = new THREE.Vector3( (startPoint1.x + startPoint2.x + end1.x + end2.x) / 4, (startPoint1.y + startPoint2.y + end1.y + end2.y) / 4, (startPoint1.z + startPoint2.z + end1.z + end2.z) / 4 ); const vertices: number[][] = [ [ startPoint1.x - midpoint.x, startPoint1.y - midpoint.y, startPoint1.z - midpoint.z, ], [end1.x - midpoint.x, end1.y - midpoint.y, end1.z - midpoint.z], [end2.x - midpoint.x, end2.y - midpoint.y, end2.z - midpoint.z], [ startPoint2.x - midpoint.x, startPoint2.y - midpoint.y, startPoint2.z - midpoint.z, ], ]; const geometry = new THREE.BufferGeometry(); const inverseQuaternion = quaternion.clone().invert(); // 頂点をfloat32配列に変換してBufferAttributeとして追加 let verticesArray = new Float32Array(vertices.flat()); verticesArray = new Float32Array(vertices.flat()); vertices.forEach((vertex, i) => { const vec = new THREE.Vector3(vertex[0], vertex[1], vertex[2]); vec.applyQuaternion(inverseQuaternion); // Apply quaternion rotation verticesArray.set(vec.toArray(), i * 3); }); geometry.setAttribute( "position", new THREE.BufferAttribute(verticesArray, 3) ); // 面を定義するためのインデックスを指定 (2つの三角形で四角形を描画) const indices = [0, 1, 2, 2, 3, 0]; geometry.setIndex(indices); // EdgesGeometry を使って外枠を描画 const edges = new THREE.EdgesGeometry(geometry); return ( <> <mesh geometry={geometry} position={midpoint} quaternion={quaternion} key={`mesh-${index + 1}`} name={`mesh-${index + 1}`} userData={{ index: index, midpoint: midpoint }} onClick={(e) => { e.stopPropagation(); setCurrent(`mesh-${index + 1}`); }} > <meshStandardMaterial attach="material" color="lightblue" side={THREE.DoubleSide} opacity={0.7} transparent={true} /> </mesh> {/* エッジ(外枠)を描画 */} <lineSegments geometry={edges} position={midpoint} quaternion={quaternion} > <lineBasicMaterial attach="material" color="black" /> </lineSegments> </> ); }; export default CustomRectangle; |
CustomRectangleは多角柱の側面を形成します。各面を長方形で表示します。
ここでは自前でFaceを作成して長方形を作成しています。TransformControlsで面の向いている方向に動かせる方が便利と考えたので、Quaternionを利用して傾けています。
CustomTransformControls.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
import React from "react"; import { useThree } from "@react-three/fiber"; import { TransformControls } from "@react-three/drei"; import { Vector3 } from "three"; import * as THREE from "three"; type Props = { setEnableRotate: React.Dispatch<React.SetStateAction<boolean>>; current: string | null; setPoints: React.Dispatch<React.SetStateAction<THREE.Vector3[][]>>; lastIndex: number; isMove: boolean; }; export const CustomTransformControls: React.FC<Props> = ({ setEnableRotate, current, setPoints, lastIndex, isMove, }) => { const { scene } = useThree(); const getObjectByName = (current: string | null) => { const selectedObject = current != null ? scene.getObjectByName(current) : undefined; return selectedObject; }; const isShowY = () => { if (current?.includes("mesh")) { return false; } if (isMove) { return true; } return false; }; const isShowX = () => { if (current?.includes("mesh")) { return true; } if (isMove) { return true; } return false; }; const setNewPoint = ( newPointsArray: Vector3[][], shapeIndex: number, index: number, midpoint: THREE.Vector3, currentPos: THREE.Vector3 ) => { const newPoints = newPointsArray[shapeIndex]; const newPoint = newPoints[index]; newPoints[index] = new Vector3( newPoint.x - midpoint.x + currentPos.x, newPoint.y - midpoint.y + currentPos.y, newPoint.z - midpoint.z + currentPos.z ); }; return ( <TransformControls object={getObjectByName(current)} space="local" onMouseDown={() => { setEnableRotate(false); }} onMouseUp={() => { setEnableRotate(true); const obj = getObjectByName(current); if (obj != null) { const index = obj.userData.index; const midpoint = obj.userData.midpoint; if (current?.includes("mesh")) { setPoints((prev) => { const currentPos = obj.position; const newPointsArray = prev.map((innerArray) => [...innerArray]); const nextIndex = index === lastIndex ? 0 : index + 1; setNewPoint(newPointsArray, 0, index, midpoint, currentPos); setNewPoint(newPointsArray, 0, nextIndex, midpoint, currentPos); setNewPoint(newPointsArray, 1, index, midpoint, currentPos); setNewPoint(newPointsArray, 1, nextIndex, midpoint, currentPos); return newPointsArray; }); } else { if (isMove) { setPoints((prev) => { const currentPos = obj.position; const newPointsArray = prev.map((innerArray) => [ ...innerArray, ]); return newPointsArray.map((newPoints) => { return newPoints.map((newPoint) => { return new Vector3( newPoint.x - midpoint.x + currentPos.x, newPoint.y - midpoint.y + currentPos.y, newPoint.z - midpoint.z + currentPos.z ); }); }); }); } else { setPoints((prev) => { const currentPos = obj.position; const newPointsArray = prev.map((innerArray) => [ ...innerArray, ]); newPointsArray[index] = newPointsArray[index].map( (newPoint) => { return new THREE.Vector3( newPoint.x - midpoint.x + currentPos.x, newPoint.y - midpoint.y + currentPos.y, newPoint.z - midpoint.z + currentPos.z ); } ); return newPointsArray; }); } } } }} showY={isShowY()} showX={isShowX()} mode={"translate"} /> ); }; |
CustomTransformControls では対象の面を操作できるようにしています。
形状が破綻しないように以下の制約を課しています。
①shapeは移動と高さ方向にしか伸縮できない。
②側面はx,y方向にしか変化させることができない。
動作結果
側面は面の向いている方向に動作し、ある程度複雑な形状まで床面が変化できました。
まとめ(今後の課題)
今回の実装についてですが、dreiのTransformControlsやShapeを利用することで比較的楽に作成することができました。
実用面では多角柱の形状を領域に見立ててその範囲内の物体を取得したりする使い方ができそうです。
今後は線や点も操作できるようにしたりthree-csg-tsなどを利用してmesh同士のマージ等ができるようにしたりしていきたいと思います。
COMMENTS