From 26ddbebe14432e6f7ea06dcb169f2be24bc93dad Mon Sep 17 00:00:00 2001 From: tom-boullay Date: Thu, 21 May 2026 15:34:49 +0200 Subject: [PATCH] refactor(map): add generated R3F model for ecole --- .../three/models/generated/EcoleModel.tsx | 161 ++++++++++++++++++ src/world/GameMap.tsx | 16 +- .../GeneratedMapNodeInstance.tsx | 25 +++ .../map-generated/generatedMapModelConfig.ts | 5 + 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 src/components/three/models/generated/EcoleModel.tsx create mode 100644 src/world/map-generated/GeneratedMapNodeInstance.tsx create mode 100644 src/world/map-generated/generatedMapModelConfig.ts diff --git a/src/components/three/models/generated/EcoleModel.tsx b/src/components/three/models/generated/EcoleModel.tsx new file mode 100644 index 0000000..e165e79 --- /dev/null +++ b/src/components/three/models/generated/EcoleModel.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { useGLTF } from "@react-three/drei"; +import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js"; +import type { Vector3Tuple } from "@/types/three/three"; + +const ECOLE_MODEL_PATH = "/models/ecole/model.gltf"; + +interface EcoleModelProps { + position: Vector3Tuple; + rotation: Vector3Tuple; + scale: Vector3Tuple; + castShadow?: boolean; + receiveShadow?: boolean; + onLoaded?: () => void; +} + +interface MergedMeshData { + geometry: THREE.BufferGeometry; + material: THREE.Material | THREE.Material[]; +} + +interface GeometryGroup { + geometries: THREE.BufferGeometry[]; + material: THREE.Material | THREE.Material[]; +} + +function cloneMaterial( + material: THREE.Material | THREE.Material[], +): THREE.Material | THREE.Material[] { + return Array.isArray(material) + ? material.map((item) => item.clone()) + : material.clone(); +} + +function disposeMaterial(material: THREE.Material | THREE.Material[]): void { + if (Array.isArray(material)) { + for (const item of material) { + item.dispose(); + } + return; + } + + material.dispose(); +} + +function createGeometrySignature(geometry: THREE.BufferGeometry): string { + const attributes = Object.entries(geometry.attributes) + .map(([name, attribute]) => { + return `${name}:${attribute.itemSize}:${attribute.normalized}`; + }) + .sort() + .join("|"); + + return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`; +} + +function createMaterialKey( + material: THREE.Material | THREE.Material[], +): string { + if (Array.isArray(material)) { + return material.map((item) => item.uuid).join("|"); + } + + return material.uuid; +} + +function createMergedMeshes(scene: THREE.Group): MergedMeshData[] { + const groups = new Map(); + + scene.updateMatrixWorld(true); + scene.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + + const geometry = child.geometry.clone(); + geometry.applyMatrix4(child.matrixWorld); + const material = child.material; + const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`; + const group = groups.get(key); + + if (group) { + group.geometries.push(geometry); + return; + } + + groups.set(key, { + geometries: [geometry], + material: cloneMaterial(material), + }); + }); + + return [...groups.values()].map((group) => { + if (group.geometries.length === 1) { + return { + geometry: group.geometries[0] as THREE.BufferGeometry, + material: group.material, + }; + } + + const geometry = mergeGeometries(group.geometries, false); + + for (const sourceGeometry of group.geometries) { + sourceGeometry.dispose(); + } + + return { + geometry, + material: group.material, + }; + }); +} + +export function EcoleModel({ + position, + rotation, + scale, + castShadow = true, + receiveShadow = true, + onLoaded, +}: EcoleModelProps): React.JSX.Element { + const { scene } = useGLTF(ECOLE_MODEL_PATH); + const groupRef = useRef(null); + + useEffect(() => { + const group = groupRef.current; + if (!group) return; + + const mergedMeshes = createMergedMeshes(scene); + const meshes = mergedMeshes.map((meshData) => { + const mesh = new THREE.Mesh(meshData.geometry, meshData.material); + mesh.castShadow = castShadow; + mesh.receiveShadow = receiveShadow; + return mesh; + }); + + for (const mesh of meshes) { + group.add(mesh); + } + + onLoaded?.(); + + return () => { + for (const mesh of meshes) { + group.remove(mesh); + mesh.geometry.dispose(); + disposeMaterial(mesh.material); + } + }; + }, [castShadow, onLoaded, receiveShadow, scene]); + + return ( + + ); +} + +useGLTF.preload(ECOLE_MODEL_PATH); diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 9ede61e..21335a5 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -12,6 +12,8 @@ import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { TerrainModel } from "@/components/three/world/TerrainModel"; import { GameMapCollision } from "@/world/GameMapCollision"; +import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; +import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig"; import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig"; import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; @@ -274,11 +276,21 @@ function MapNodeInstance({ modelUrl: string | null; onSettled: () => void; }): React.JSX.Element { + const isGeneratedModel = isGeneratedMapModelName(node.name); + useEffect(() => { - if (modelUrl !== null) return; + if (modelUrl !== null || isGeneratedModel) return; onSettled(); - }, [modelUrl, onSettled]); + }, [isGeneratedModel, modelUrl, onSettled]); + + if (isGeneratedModel) { + return ( + }> + + + ); + } if (!modelUrl) { return ; diff --git a/src/world/map-generated/GeneratedMapNodeInstance.tsx b/src/world/map-generated/GeneratedMapNodeInstance.tsx new file mode 100644 index 0000000..bf87b21 --- /dev/null +++ b/src/world/map-generated/GeneratedMapNodeInstance.tsx @@ -0,0 +1,25 @@ +import { EcoleModel } from "@/components/three/models/generated/EcoleModel"; +import type { MapNode } from "@/types/editor/editor"; + +interface GeneratedMapNodeInstanceProps { + node: MapNode; + onLoaded: () => void; +} + +export function GeneratedMapNodeInstance({ + node, + onLoaded, +}: GeneratedMapNodeInstanceProps): React.JSX.Element | null { + if (node.name === "ecole") { + return ( + + ); + } + + return null; +} diff --git a/src/world/map-generated/generatedMapModelConfig.ts b/src/world/map-generated/generatedMapModelConfig.ts new file mode 100644 index 0000000..686d295 --- /dev/null +++ b/src/world/map-generated/generatedMapModelConfig.ts @@ -0,0 +1,5 @@ +const GENERATED_MAP_MODEL_NAMES = new Set(["ecole"]); + +export function isGeneratedMapModelName(name: string): boolean { + return GENERATED_MAP_MODEL_NAMES.has(name); +}