Skip to content

Commit

Permalink
3Dモデルの表示・編集機能 (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
toufu-24 authored Oct 30, 2024
2 parents e66b835 + 339fac0 commit 6a32032
Show file tree
Hide file tree
Showing 8 changed files with 18,103 additions and 106 deletions.
44 changes: 44 additions & 0 deletions app/components/ModelColor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useCallback, useMemo, useRef, useState } from "react";

export function getMeshColor() {
const instanceRef = useRef<{ [key: string]: string } | null>(null); // シングルトンインスタンスを保持
const [visibility, setVisibility] = useState<{
[key: string]: string;
} | null>(null);

const generateMeshColor = useCallback((): { [key: string]: string } => {
if (instanceRef.current) return instanceRef.current; // 既存のインスタンスを返す

// 初期値は白
instanceRef.current = {
hairear: "#ffffff",
hairback: "#ffffff",
hairfront: "#ffffff",
hairtail: "#ffffff",
hairsid: "#ffffff",
hair: "#ffffff",
};
setVisibility(instanceRef.current); // 状態を更新
return instanceRef.current;
}, []);

const updateVisibility = useCallback((key: string, color: string) => {
if (instanceRef.current) {
instanceRef.current[key] = color; // 値を更新
if (key === "hair") {
instanceRef.current.hairear = color;
instanceRef.current.hairback = color;
instanceRef.current.hairfront = color;
instanceRef.current.hairtail = color;
instanceRef.current.hairsid = color;
}
setVisibility({ ...instanceRef.current }); // 状態を更新
}
}, []);

const meshColor = useMemo(() => {
return visibility || generateMeshColor(); // 初回は生成したものを使用
}, [visibility, generateMeshColor]);

return { meshColor, updateVisibility }; // 可視性と更新関数を返す
}
115 changes: 115 additions & 0 deletions app/components/ModelConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";

interface ModelListProps {
onModelSelect: (modelPath: string) => void;
updateVisibility: (key: string, value: boolean) => void;
updateColor: (key: string, value: string) => void;
}

export function ModelConfig({
onModelSelect,
updateVisibility,
updateColor,
}: ModelListProps) {
const [models, setModels] = useState<string[]>([]);
const [containerWidth, setContainerWidth] = useState(0);
const [selectedColors, setSelectedColors] = useState<Record<string, string>>(
{},
);

useEffect(() => {
const fetchModels = async () => {
const modelFiles = [
"/models/web_asuka.glb",
"/models/web_jiraichan.glb",
"/models/web_kaiju.glb",
"/models/web_necochan.glb",
"/models/web_sushong.glb",
];
setModels(modelFiles);
};

fetchModels();

const updateContainerWidth = () => {
const containerRef = document.getElementById("model-list-container");
if (containerRef) {
setContainerWidth(containerRef.offsetWidth);
}
};

updateContainerWidth();
window.addEventListener("resize", updateContainerWidth);

return () => window.removeEventListener("resize", updateContainerWidth);
}, []);

// ボタンの幅を計算
const buttonWidth = `${Math.floor(containerWidth / models.length) - 16}px`;
// 表示非表示のキー
const visibilityKeys = [
"accessoryeyepatch",
"accessoryglasses",
"goggle",
"goggle_1",
"accessorymask",
];

return (
<div>
{/* モデル選択ボタン */}
<div
id="model-list-container"
className="mb-4 flex flex-wrap justify-center space-x-2"
>
{models.map((model) => (
<button
key={model}
type="button"
onClick={() => onModelSelect(model)}
className="rounded bg-gray-200 px-4 py-2 font-medium text-gray-800 hover:bg-gray-300"
style={{ width: buttonWidth }}
>
{model.split("/").pop()?.split("_")[1].split(".")[0]}
</button>
))}
</div>
{/* 表示非表示ボタン */}
<div className="flex flex-col items-center">
{visibilityKeys.map((key) => (
<div key={key} className="flex items-center space-x-2">
<span>{key}</span>
<button
type="button"
onClick={() => updateVisibility(key, true)} // 表示するボタン
className="rounded bg-green-200 px-2 py-1 text-gray-800 hover:bg-green-300"
>
表示
</button>
<button
type="button"
onClick={() => updateVisibility(key, false)} // 非表示にするボタン
className="rounded bg-red-200 px-2 py-1 text-gray-800 hover:bg-red-300"
>
非表示
</button>
</div>
))}
</div>
{/* 色変更ボタン */}
<div className="flex items-center justify-center space-x-2">
<span className="font-medium">Hair Color:</span>
<input
type="color"
value={selectedColors.hair}
onChange={(e) => {
setSelectedColors({ ...selectedColors, hair: e.target.value });
updateColor("hair", e.target.value);
}}
className="h-8 w-8 cursor-pointer rounded border border-gray-300"
title="髪の色を変更"
/>
</div>
</div>
);
}
96 changes: 96 additions & 0 deletions app/components/ModelLoad.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { type ReactNode, useEffect, useRef } from "react";
import type { Group, MeshStandardMaterial } from "three";
import type * as THREE from "three";

function Model({
path,
colorMap,
meshVisibility,
}: {
path: string;
colorMap: { [key: string]: string };
meshVisibility: { [key: string]: boolean };
}): ReactNode {
const { scene } = useGLTF(path);
// モデルのグループ(オブジェクト全体)にアクセスするための参照を作成
const groupRef = useRef<Group>(null);

useEffect(() => {
scene.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;

// メッシュ名をコンソールに出力
// console.log("Mesh name:", mesh.name);

// 部位名がmeshVisibilityのキーに一致する場合に表示非表示を設定
if (
Object.hasOwn(meshVisibility, mesh.name) &&
!meshVisibility[mesh.name]
) {
mesh.visible = false;
return;
}

mesh.visible = true;

const material = mesh.material as MeshStandardMaterial;

// 部位名がcolorMapのキーに一致する場合に色を設定
if (Object.hasOwn(colorMap, mesh.name)) {
material.color.set(colorMap[mesh.name]);
}
}
});
}, [scene, colorMap, meshVisibility]);

return (
// グループとしてシーンをレンダリング
<group ref={groupRef} position={[0, -0.5, 0]}>
{/* モデルのプリミティブ(生のオブジェクト)を表示 */}
<primitive object={scene} />
</group>
);
}

// 3Dモデルビューアーコンポーネント
// モデルのパスを受け取って、それを表示する
type ModelViewerProps = {
modelPath: string; // モデルのパス
colorMap: { [key: string]: string }; // 部位ごとの色マップ
meshVisibility: { [key: string]: boolean }; // 部位ごとの表示非表示
};
export function ModelViewer({
modelPath,
colorMap,
meshVisibility,
}: ModelViewerProps): ReactNode {
return (
<div>
{/* 3Dモデルを表示するためのCanvasエリア */}
<div style={{ height: "60vh" }}>
<Canvas
camera={{
position: [0, 0, 4],
fov: 30,
}} // カメラの初期位置と視野角を設定
>
{/* 環境を設定*/}
<Environment preset="studio" />
{/* 環境光を追加(全体的に均一な光を当てる) */}
<ambientLight intensity={0.5} />
{/* GLBモデルの読み込みと表示 */}
<Model
path={modelPath}
colorMap={colorMap}
meshVisibility={meshVisibility}
/>
{/* カメラコントロールの追加(ユーザーが自由にカメラを操作できるようにする) */}
<OrbitControls makeDefault />
</Canvas>
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions app/components/ModelVisibility.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useCallback, useMemo, useRef, useState } from "react";

export function getMeshVisibility() {
const instanceRef = useRef<{ [key: string]: boolean } | null>(null); // シングルトンインスタンスを保持
const [visibility, setVisibility] = useState<{
[key: string]: boolean;
} | null>(null);

const generateMeshVisibility = useCallback((): { [key: string]: boolean } => {
if (instanceRef.current) return instanceRef.current; // 既存のインスタンスを返す

// 初期値は非表示
instanceRef.current = {
accessoryeyepatch: false,
accessoryglasses: false,
goggle: false,
goggle_1: false,
accessorymask: false,
};
setVisibility(instanceRef.current); // 状態を更新
return instanceRef.current;
}, []);

const updateVisibility = useCallback((key: string, value: boolean) => {
if (instanceRef.current) {
instanceRef.current[key] = value; // 値を更新
setVisibility({ ...instanceRef.current }); // 状態を更新
}
}, []);

const meshVisibility = useMemo(() => {
return visibility || generateMeshVisibility(); // 初回は生成したものを使用
}, [visibility, generateMeshVisibility]);

return { meshVisibility, updateVisibility }; // 可視性と更新関数を返す
}
19 changes: 19 additions & 0 deletions app/routes/_auth.edit.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import type { MetaFunction } from "@remix-run/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { Heading } from "~/components/Heading";
import { getMeshColor } from "~/components/ModelColor";
import { ModelConfig } from "~/components/ModelConfig";
import { ModelViewer } from "~/components/ModelLoad";
import { getMeshVisibility } from "~/components/ModelVisibility";

export const meta: MetaFunction = () => [{ title: "キャラ編集 | RicoShot" }];

export default function Page(): ReactNode {
const [modelPath, setModelPath] = useState("/models/web_asuka.glb");
const { meshVisibility, updateVisibility } = getMeshVisibility();
const { meshColor, updateVisibility: updateColor } = getMeshColor();

return (
<div
className="min-h-dvh w-full p-4"
style={{ viewTransitionName: "main" }}
>
<main className="mx-auto grid w-full max-w-screen-sm gap-y-4">
<Heading>キャラ編集</Heading>
<ModelViewer
modelPath={modelPath}
colorMap={meshColor}
meshVisibility={meshVisibility}
/>
<ModelConfig
onModelSelect={setModelPath}
updateVisibility={updateVisibility}
updateColor={updateColor}
/>
</main>
</div>
);
Expand Down
Loading

0 comments on commit 6a32032

Please sign in to comment.