merge develop into feat/galerie - resolve model and code conflicts
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { createNetShader } from "@/shaders/NetShader";
|
||||
|
||||
export function NetTest(): React.JSX.Element {
|
||||
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const timeUniform = materialRef.current?.uniforms.uTime;
|
||||
if (timeUniform) timeUniform.value += delta;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh position={[0, 2, -3]} rotation={[0, 0, 0]}>
|
||||
<planeGeometry args={[2, 2, 1, 1]} />
|
||||
<primitive
|
||||
object={createNetShader()}
|
||||
ref={materialRef}
|
||||
attach="material"
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface RepairCompletionStepProps {
|
||||
config: RepairMissionConfig;
|
||||
|
||||
@@ -7,21 +7,18 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import {
|
||||
RepairScanSequence,
|
||||
type RepairScannedBrokenPart,
|
||||
} from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import {
|
||||
REPAIR_MISSIONS,
|
||||
type RepairMissionConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionConfig,
|
||||
RepairMissionId,
|
||||
RepairScannedBrokenPart,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
@@ -70,6 +67,7 @@ export function RepairGame({
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
@@ -109,7 +107,7 @@ export function RepairGame({
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
@@ -117,7 +115,7 @@ export function RepairGame({
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
worldPosition={snappedPosition}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairInspectionObjectProps {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionCaseProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface RepairReassemblyStepProps {
|
||||
config: RepairMissionConfig;
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
RepairScannedBrokenPart,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
|
||||
const VALID_PART_COLOR = "#22c55e";
|
||||
const INVALID_PART_COLOR = "#ef4444";
|
||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
let hasWarnedMissingPlaceholders = false;
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
if (!hasWarnedMissingPlaceholders) {
|
||||
hasWarnedMissingPlaceholders = true;
|
||||
logger.warn(
|
||||
"RepairGame",
|
||||
"Repair case placeholders missing, using fallback slots",
|
||||
);
|
||||
}
|
||||
|
||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||
(offset, index): RepairCasePlaceholder => ({
|
||||
name: `placeholder_${index + 1}`,
|
||||
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
|
||||
part: RepairScannedBrokenPart,
|
||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||
): readonly Vector3Tuple[] {
|
||||
if (!part.placeholderName) {
|
||||
if (!part.caseSlotName) {
|
||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
const matchingPlaceholder = placeholderTargets.find(
|
||||
(placeholder) => placeholder.name === part.placeholderName,
|
||||
(placeholder) => placeholder.name === part.caseSlotName,
|
||||
);
|
||||
|
||||
return matchingPlaceholder
|
||||
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
|
||||
id: part.id,
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
||||
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
RepairScannedBrokenPart,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
|
||||
interface RepairScanSequenceProps {
|
||||
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
|
||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||
}
|
||||
|
||||
export interface RepairScannedBrokenPart {
|
||||
id: string;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
placeholderName?: string;
|
||||
interface RepairBrokenPartMatch {
|
||||
config: RepairMissionPartConfig;
|
||||
partIndex: number;
|
||||
}
|
||||
|
||||
const warnedMissingScanParts = new Set<string>();
|
||||
|
||||
export function RepairScanSequence({
|
||||
config,
|
||||
onComplete,
|
||||
@@ -31,9 +33,9 @@ export function RepairScanSequence({
|
||||
const [activePartIndex, setActivePartIndex] = useState(0);
|
||||
const activePart = parts[activePartIndex];
|
||||
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
|
||||
(partIndex) => partIndex <= activePartIndex,
|
||||
const brokenPartMatches = getBrokenPartMatches(parts, config);
|
||||
const visibleBrokenPartMatches = brokenPartMatches.filter(
|
||||
(match) => match.partIndex <= activePartIndex,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,8 +67,8 @@ export function RepairScanSequence({
|
||||
onPartsReady={setParts}
|
||||
/>
|
||||
<RepairScanVisual target={activePart?.object} />
|
||||
{visibleBrokenPartIndexes.map((partIndex) => {
|
||||
const part = parts[partIndex];
|
||||
{visibleBrokenPartMatches.map((match) => {
|
||||
const part = parts[match.partIndex];
|
||||
if (!part) return null;
|
||||
|
||||
return (
|
||||
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
|
||||
parts: readonly ExplodedPart[],
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
|
||||
return brokenPartIndexes.map((_, index) => {
|
||||
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
|
||||
|
||||
return getBrokenPartMatches(parts, config).map((match) => {
|
||||
return {
|
||||
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
|
||||
label: configuredPart?.label ?? `${config.label} broken part`,
|
||||
modelPath: configuredPart?.modelPath ?? config.modelPath,
|
||||
...(configuredPart?.placeholderName
|
||||
? { placeholderName: configuredPart.placeholderName }
|
||||
id: match.config.id,
|
||||
label: match.config.label,
|
||||
modelPath: match.config.modelPath ?? config.modelPath,
|
||||
...(match.config.caseSlotName
|
||||
? { caseSlotName: match.config.caseSlotName }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getBrokenPartIndexes(
|
||||
function getBrokenPartMatches(
|
||||
parts: readonly ExplodedPart[],
|
||||
brokenParts: readonly RepairMissionPartConfig[],
|
||||
): number[] {
|
||||
if (parts.length === 0 || brokenParts.length === 0) return [];
|
||||
config: RepairMissionConfig,
|
||||
): RepairBrokenPartMatch[] {
|
||||
if (parts.length === 0 || config.brokenParts.length === 0) return [];
|
||||
|
||||
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
|
||||
const matches = config.brokenParts.flatMap((brokenPart) => {
|
||||
const { nodeName } = brokenPart;
|
||||
if (!nodeName) return [];
|
||||
|
||||
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
|
||||
objectContainsNodeName(part.object, nodeName),
|
||||
);
|
||||
|
||||
return index >= 0 ? [index] : [];
|
||||
return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
|
||||
});
|
||||
|
||||
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
|
||||
if (matches.length !== config.brokenParts.length) {
|
||||
const matchedIds = new Set(matches.map((match) => match.config.id));
|
||||
const missingIds = config.brokenParts
|
||||
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
|
||||
.map((brokenPart) => brokenPart.id);
|
||||
|
||||
return parts.slice(0, brokenParts.length).map((_, index) => index);
|
||||
const warningKey = `${config.id}:${missingIds.join(",")}`;
|
||||
if (!warnedMissingScanParts.has(warningKey)) {
|
||||
warnedMissingScanParts.add(warningKey);
|
||||
logger.warn("RepairScan", "Broken parts missing from exploded model", {
|
||||
missionId: config.id,
|
||||
missingIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches.filter(
|
||||
(match, index, allMatches) =>
|
||||
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
|
||||
index,
|
||||
);
|
||||
}
|
||||
|
||||
function objectContainsNodeName(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { clone } from "three/addons/utils/SkeletonUtils.js";
|
||||
import { SkeletonUtils } from "three-stdlib";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import {
|
||||
useHandTrackingGloveStatus,
|
||||
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
|
||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||
}
|
||||
|
||||
const clonedRootNode = clone(rootNode);
|
||||
const clonedRootNode = SkeletonUtils.clone(rootNode);
|
||||
clonedRootNode.visible = false;
|
||||
|
||||
return clonedRootNode;
|
||||
|
||||
@@ -41,8 +41,27 @@ type InteractableObjectProps =
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _cameraDir = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _objectBounds = new THREE.Box3();
|
||||
const _raycaster = new THREE.Raycaster();
|
||||
|
||||
function getInteractableWorldPosition(
|
||||
group: THREE.Group,
|
||||
debugSphere: THREE.Mesh | null,
|
||||
): THREE.Vector3 {
|
||||
_objectBounds.makeEmpty();
|
||||
|
||||
for (const child of group.children) {
|
||||
if (child === debugSphere) continue;
|
||||
_objectBounds.expandByObject(child);
|
||||
}
|
||||
|
||||
if (!_objectBounds.isEmpty()) {
|
||||
return _objectBounds.getCenter(_objectPos);
|
||||
}
|
||||
|
||||
return group.getWorldPosition(_objectPos);
|
||||
}
|
||||
|
||||
function createInteractableHandle(
|
||||
props: InteractableObjectProps,
|
||||
): InteractableHandle {
|
||||
@@ -158,7 +177,7 @@ export function InteractableObject(
|
||||
const t = bodyRef.current.translation();
|
||||
_objectPos.set(t.x, t.y, t.z);
|
||||
} else if (group) {
|
||||
group.getWorldPosition(_objectPos);
|
||||
getInteractableWorldPosition(group, debugSphereRef.current);
|
||||
} else {
|
||||
_objectPos.set(...position);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { AnimationAction } from "three";
|
||||
import {
|
||||
AnimatedModelContext,
|
||||
type AnimatedModelContextValue,
|
||||
} from "@/components/three/models/useAnimatedModel";
|
||||
} from "@/hooks/animation/useAnimatedModel";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export interface AnimatedModelConfig extends ModelTransformProps {
|
||||
interface AnimatedModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
animations?: string[];
|
||||
defaultAnimation?: string;
|
||||
@@ -67,32 +68,6 @@ export function AnimatedModel({
|
||||
}
|
||||
}, [mixer, onAnimationEnd]);
|
||||
|
||||
const play = useCallback(
|
||||
(name: string, fade = fadeDuration) => {
|
||||
const action = actions[name];
|
||||
if (action) {
|
||||
Object.values(actions).forEach((a) => {
|
||||
if (a && a !== action) a.fadeOut(fade);
|
||||
});
|
||||
action.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(name);
|
||||
}
|
||||
},
|
||||
[actions, fadeDuration],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
(fade = fadeDuration) => {
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||
const defaultAction = actions[defaultAnimation];
|
||||
if (defaultAction) {
|
||||
defaultAction.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(defaultAnimation);
|
||||
}
|
||||
},
|
||||
[actions, defaultAnimation, fadeDuration],
|
||||
);
|
||||
|
||||
const fadeTo = useCallback(
|
||||
(name: string, fade = fadeDuration) => {
|
||||
const action = actions[name];
|
||||
@@ -106,6 +81,19 @@ export function AnimatedModel({
|
||||
},
|
||||
[actions, fadeDuration],
|
||||
);
|
||||
const play = fadeTo;
|
||||
|
||||
const stop = useCallback(
|
||||
(fade = fadeDuration) => {
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||
const defaultAction = actions[defaultAnimation];
|
||||
if (defaultAction) {
|
||||
defaultAction.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(defaultAnimation);
|
||||
}
|
||||
},
|
||||
[actions, defaultAnimation, fadeDuration],
|
||||
);
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(newSpeed: number) => {
|
||||
@@ -121,17 +109,39 @@ export function AnimatedModel({
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultAction = actions[defaultAnimation as string];
|
||||
let defaultAction = actions[defaultAnimation];
|
||||
|
||||
if (!defaultAction && names.length > 0) {
|
||||
defaultAction = actions[names[0] as string];
|
||||
const fallbackAnimation = names[0];
|
||||
if (!defaultAction && fallbackAnimation) {
|
||||
logger.warn(
|
||||
"AnimatedModel",
|
||||
"Default animation missing, using fallback",
|
||||
{
|
||||
modelPath,
|
||||
defaultAnimation,
|
||||
fallbackAnimation,
|
||||
availableAnimations: names,
|
||||
},
|
||||
);
|
||||
defaultAction = actions[fallbackAnimation];
|
||||
}
|
||||
|
||||
if (defaultAction) {
|
||||
defaultAction.play();
|
||||
Object.values(actions).forEach((action) => {
|
||||
if (action && action !== defaultAction) action.fadeOut(fadeDuration);
|
||||
});
|
||||
defaultAction.reset().fadeIn(fadeDuration).play();
|
||||
onLoaded?.();
|
||||
}
|
||||
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
|
||||
}, [
|
||||
actions,
|
||||
defaultAnimation,
|
||||
fadeDuration,
|
||||
modelPath,
|
||||
names,
|
||||
autoPlay,
|
||||
onLoaded,
|
||||
]);
|
||||
|
||||
const contextValue: AnimatedModelContextValue = {
|
||||
play,
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface SimpleModelConfig extends ModelTransformProps {
|
||||
function applyShadowSettings(
|
||||
object: THREE.Object3D,
|
||||
castShadow: boolean,
|
||||
receiveShadow: boolean,
|
||||
): void {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
|
||||
child.castShadow = castShadow;
|
||||
child.receiveShadow = receiveShadow;
|
||||
});
|
||||
}
|
||||
|
||||
interface SimpleModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
@@ -27,20 +42,18 @@ export function SimpleModel({
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const model = useClonedObject(scene, { cloneResources: true });
|
||||
|
||||
useEffect(() => {
|
||||
applyShadowSettings(model, castShadow, receiveShadow);
|
||||
}, [castShadow, model, receiveShadow]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
{children ?? (
|
||||
<primitive
|
||||
object={model}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
/>
|
||||
)}
|
||||
{children ?? <primitive object={model} />}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AnimatedModelContextValue {
|
||||
play: (name: string, fade?: number) => void;
|
||||
stop: (fade?: number) => void;
|
||||
fadeTo: (name: string, fade?: number) => void;
|
||||
currentAnimation: string;
|
||||
isReady: boolean;
|
||||
setSpeed: (speed: number) => void;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export const AnimatedModelContext =
|
||||
createContext<AnimatedModelContextValue | null>(null);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
interface CloudModelProps {
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
}
|
||||
|
||||
function applyCloudSettings(
|
||||
scene: THREE.Object3D,
|
||||
castShadow: boolean,
|
||||
receiveShadow: boolean,
|
||||
): void {
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = castShadow;
|
||||
child.receiveShadow = receiveShadow;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
for (const material of materials) {
|
||||
material.fog = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function CloudModel({
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
}: CloudModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(CLOUD_CONFIG.modelPath);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
|
||||
const cloud = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const model = scene.clone(true);
|
||||
applyCloudSettings(model, castShadow, receiveShadow);
|
||||
return model;
|
||||
}, [castShadow, maxAnisotropy, receiveShadow, scene]);
|
||||
|
||||
return <primitive object={cloud} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(CLOUD_CONFIG.modelPath);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
|
||||
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
|
||||
|
||||
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function FermeVerticaleModel(
|
||||
props: FermeVerticaleModelProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<MergedStaticMapModel modelPath={FERME_VERTICALE_MODEL_PATH} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(FERME_VERTICALE_MODEL_PATH);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
|
||||
|
||||
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function GenerateurModel(
|
||||
props: GenerateurModelProps,
|
||||
): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={GENERATEUR_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(GENERATEUR_MODEL_PATH);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
||||
|
||||
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function LaFabrikMapModel(
|
||||
props: LaFabrikMapModelProps,
|
||||
): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(LA_FABRIK_MODEL_PATH);
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
export interface MergedStaticMapModelProps {
|
||||
modelPath: string;
|
||||
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<string, GeometryGroup>();
|
||||
|
||||
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) {
|
||||
const [geometry] = group.geometries;
|
||||
if (!geometry) return null;
|
||||
|
||||
return {
|
||||
geometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const geometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const sourceGeometry of group.geometries) {
|
||||
sourceGeometry.dispose();
|
||||
}
|
||||
|
||||
if (!geometry) {
|
||||
disposeMaterial(group.material);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
geometry,
|
||||
material: group.material,
|
||||
};
|
||||
})
|
||||
.filter((meshData): meshData is MergedMeshData => meshData !== null);
|
||||
}
|
||||
|
||||
export function MergedStaticMapModel({
|
||||
modelPath,
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
onLoaded,
|
||||
}: MergedStaticMapModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
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, maxAnisotropy, modelPath, onLoaded, receiveShadow, scene]);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
interface SkyModelProps {
|
||||
modelPath: string;
|
||||
fallbackModelScale?: number | undefined;
|
||||
fallbackModelPath?: string | undefined;
|
||||
fallbackScale?: number | undefined;
|
||||
fallbackColor?: string | undefined;
|
||||
materialSide?: THREE.Side | undefined;
|
||||
modelPath: string;
|
||||
scale?: number | undefined;
|
||||
unlit?: boolean | undefined;
|
||||
}
|
||||
@@ -23,6 +26,8 @@ interface SkyModelContentProps {
|
||||
interface SkyModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface SkyModelErrorBoundaryState {
|
||||
@@ -31,7 +36,7 @@ interface SkyModelErrorBoundaryState {
|
||||
|
||||
const SKY_MODEL_SCALE = 1;
|
||||
const SKY_MODEL_RENDER_ORDER = -1000;
|
||||
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
|
||||
|
||||
class SkyModelErrorBoundary extends Component<
|
||||
SkyModelErrorBoundaryProps,
|
||||
@@ -46,6 +51,17 @@ class SkyModelErrorBoundary extends Component<
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logger.warn(
|
||||
"SkyModel",
|
||||
`${this.props.label} model failed; using fallback`,
|
||||
{
|
||||
error,
|
||||
modelPath: this.props.modelPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
@@ -56,6 +72,8 @@ class SkyModelErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
export function SkyModel({
|
||||
fallbackColor,
|
||||
fallbackModelScale = SKY_MODEL_SCALE,
|
||||
fallbackModelPath,
|
||||
fallbackScale = SKY_MODEL_SCALE,
|
||||
materialSide = THREE.BackSide,
|
||||
@@ -63,17 +81,35 @@ export function SkyModel({
|
||||
scale = SKY_MODEL_SCALE,
|
||||
unlit = false,
|
||||
}: SkyModelProps): React.JSX.Element {
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={fallbackModelPath}
|
||||
scale={fallbackScale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
const colorFallback = fallbackColor ? (
|
||||
<color attach="background" args={[fallbackColor]} />
|
||||
) : null;
|
||||
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelErrorBoundary
|
||||
key={fallbackModelPath}
|
||||
fallback={colorFallback}
|
||||
label="Fallback sky"
|
||||
modelPath={fallbackModelPath}
|
||||
>
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={fallbackModelPath}
|
||||
scale={fallbackScale ?? fallbackModelScale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
</SkyModelErrorBoundary>
|
||||
) : (
|
||||
colorFallback
|
||||
);
|
||||
|
||||
return (
|
||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||
<SkyModelErrorBoundary
|
||||
key={modelPath}
|
||||
fallback={fallback}
|
||||
label="Primary sky"
|
||||
modelPath={modelPath}
|
||||
>
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={modelPath}
|
||||
@@ -190,5 +226,4 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||
});
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/skybox/skybox.gltf");
|
||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
||||
useGLTF.preload(SKYBOX_MODEL_PATH);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
interface TerrainModelProps {
|
||||
position?: Vector3Tuple;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: Vector3Tuple;
|
||||
receiveShadow?: boolean;
|
||||
visible?: boolean;
|
||||
groupRef?: React.RefObject<THREE.Group>;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
function applyTerrainMaterialSettings(
|
||||
scene: THREE.Object3D,
|
||||
receiveShadow: boolean,
|
||||
): void {
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.receiveShadow = receiveShadow;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
for (const material of materials) {
|
||||
const materialWithAlphaMap = material as THREE.Material & {
|
||||
alphaMap?: THREE.Texture | null;
|
||||
};
|
||||
|
||||
material.depthTest = true;
|
||||
material.depthWrite = true;
|
||||
|
||||
if (material.opacity >= 1 && !materialWithAlphaMap.alphaMap) {
|
||||
material.transparent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function TerrainModel({
|
||||
position = TERRAIN_DEFAULT_POSITION,
|
||||
rotation = [0, 0, 0],
|
||||
scale = [1, 1, 1],
|
||||
receiveShadow = true,
|
||||
visible = true,
|
||||
groupRef,
|
||||
onLoaded,
|
||||
}: TerrainModelProps): React.JSX.Element {
|
||||
const internalRef = useRef<THREE.Group>(null);
|
||||
const ref = groupRef ?? internalRef;
|
||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
|
||||
const terrainModel = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const model = scene.clone(true);
|
||||
applyTerrainMaterialSettings(model, receiveShadow);
|
||||
return model;
|
||||
}, [maxAnisotropy, scene, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded?.();
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={ref}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
visible={visible}
|
||||
>
|
||||
<primitive object={terrainModel} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(TERRAIN_MODEL_PATH);
|
||||
Reference in New Issue
Block a user