From d29b01e398c9b1c0b04392aca2920183fb1bb4b7 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 2 Jun 2026 19:12:38 +0200 Subject: [PATCH] feat(repair): broken parts spawn from exploded model node positions Render the exploded mission model during the repairing step so broken nodes (e.g. ebike refroidisseur) stay visible in the world, and surface their world positions to RepairRepairingStep so broken pieces spawn from where they belong on the model rather than from a static offset. - ExplodableModel: add hideNodeNames (mesh visibility off, restored on unmount) and nodeAnchorNames + onNodeAnchorsChange (per-frame world positions debounced via signature so React state updates only when the values actually move). - RepairGame: render ExplodableModel during 'repairing' with the broken node names hidden + anchors forwarded; threads brokenAnchors state to RepairRepairingStep. - RepairScanSequence + RepairRepairingStep: propagate targetNodeName through scanned and fallback broken-part lists. - RepairRepairingStep: broken parts spawn at brokenAnchors[targetNodeName] when set, falling back to legacy BROKEN_PART_START_OFFSETS otherwise. --- src/components/three/gameplay/RepairGame.tsx | 41 ++++++++++--- .../three/gameplay/RepairRepairingStep.tsx | 18 ++++-- .../three/gameplay/RepairScanSequence.tsx | 3 + .../three/models/ExplodableModel.tsx | 60 ++++++++++++++++++- 4 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index febbaca..c6bc9df 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -1,6 +1,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { ExplodableModel } from "@/components/three/models/ExplodableModel"; +import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; import type { RepairCasePartAnchors, RepairCasePlaceholder, @@ -67,12 +68,14 @@ export function RepairGame({ readonly RepairCasePlaceholder[] >([]); const [caseAnchors, setCaseAnchors] = useState({}); + const [brokenAnchors, setBrokenAnchors] = useState({}); const [scannedBrokenParts, setScannedBrokenParts] = useState< readonly RepairScannedBrokenPart[] >([]); const parsedScale = toVector3Scale(scale); const snappedPosition = useTerrainSnappedPosition(position); const readyForFragmentation = step === "inspected"; + const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]); useRepairFragmentationInput({ enabled: mainState === mission && readyForFragmentation, @@ -86,6 +89,7 @@ export function RepairGame({ const timeoutId = window.setTimeout(() => { setCasePlaceholders([]); setCaseAnchors({}); + setBrokenAnchors({}); setScannedBrokenParts([]); }, 0); @@ -141,13 +145,24 @@ export function RepairGame({ /> ) : null} {step === "repairing" ? ( - setMissionStep(mission, "reassembling")} - /> + <> + + setMissionStep(mission, "reassembling")} + /> + ) : null} {step === "reassembling" ? ( (); + config.brokenParts.forEach((part) => { + if (part.targetNodeName) names.add(part.targetNodeName); + else if (part.nodeName) names.add(part.nodeName); + }); + config.replacementParts.forEach((part) => { + if (part.targetNodeName) names.add(part.targetNodeName); + }); + return Array.from(names); +} diff --git a/src/components/three/gameplay/RepairRepairingStep.tsx b/src/components/three/gameplay/RepairRepairingStep.tsx index 471f71d..ca42e25 100644 --- a/src/components/three/gameplay/RepairRepairingStep.tsx +++ b/src/components/three/gameplay/RepairRepairingStep.tsx @@ -4,6 +4,7 @@ import type { RepairCasePartAnchors, RepairCasePlaceholder, } from "@/components/three/gameplay/RepairCaseModel"; +import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; @@ -42,6 +43,7 @@ let hasWarnedMissingPlaceholders = false; interface RepairRepairingStepProps { anchors?: RepairCasePartAnchors; + brokenAnchors?: ExplodedNodeAnchors; brokenParts: readonly RepairScannedBrokenPart[]; config: RepairMissionConfig; placeholders: readonly RepairCasePlaceholder[]; @@ -68,6 +70,7 @@ interface RepairPartPlacementFeedbackProps { export function RepairRepairingStep({ anchors = {}, + brokenAnchors = {}, brokenParts, config, placeholders, @@ -272,14 +275,18 @@ export function RepairRepairingStep({ })} {brokenPartsToDeposit.map((part, index) => { - const startOffset = + const fallbackOffset = BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ?? BROKEN_PART_START_OFFSETS[0]!; - const startPosition: Vector3Tuple = [ - REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0], - REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1], - REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2], + const fallbackPosition: Vector3Tuple = [ + REPAIR_CASE_FOCUS_POSITION[0] + fallbackOffset[0], + REPAIR_CASE_FOCUS_POSITION[1] + fallbackOffset[1], + REPAIR_CASE_FOCUS_POSITION[2] + fallbackOffset[2], ]; + const anchorPosition = part.targetNodeName + ? brokenAnchors[part.targetNodeName] + : undefined; + const startPosition: Vector3Tuple = anchorPosition ?? fallbackPosition; const targetPositions = getBrokenPartTargetPositions( part, placeholderTargets, @@ -529,5 +536,6 @@ function getBrokenPartsToDeposit( label: part.label, modelPath: part.modelPath ?? config.modelPath, ...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}), + ...(part.targetNodeName ? { targetNodeName: part.targetNodeName } : {}), })); } diff --git a/src/components/three/gameplay/RepairScanSequence.tsx b/src/components/three/gameplay/RepairScanSequence.tsx index c25d2c6..a0b73b5 100644 --- a/src/components/three/gameplay/RepairScanSequence.tsx +++ b/src/components/three/gameplay/RepairScanSequence.tsx @@ -97,6 +97,9 @@ function getScannedBrokenParts( ...(match.config.caseSlotName ? { caseSlotName: match.config.caseSlotName } : {}), + ...(match.config.targetNodeName + ? { targetNodeName: match.config.targetNodeName } + : {}), }; }); } diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx index 711acdc..ee81f3d 100644 --- a/src/components/three/models/ExplodableModel.tsx +++ b/src/components/three/models/ExplodableModel.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; -import { Component, useEffect, useMemo } from "react"; +import { Component, useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; import { useFrame } from "@react-three/fiber"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; @@ -9,6 +10,10 @@ import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { toVector3Scale } from "@/utils/three/scale"; +export type ExplodedNodeAnchors = Readonly>; + +const _anchorWorld = new THREE.Vector3(); + interface ModelErrorBoundaryProps { children: ReactNode; modelPath: string; @@ -67,6 +72,9 @@ interface ExplodableModelInnerProps extends ModelTransformProps { split: boolean; splitDistance?: number; onPartsReady?: (parts: readonly ExplodedPart[]) => void; + hideNodeNames?: readonly string[]; + nodeAnchorNames?: readonly string[]; + onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void; } export function ExplodableModel( @@ -93,6 +101,9 @@ function ExplodableModelInner({ scale = 1, splitDistance = 1.2, onPartsReady, + hideNodeNames, + nodeAnchorNames, + onNodeAnchorsChange, }: ExplodableModelInnerProps): React.JSX.Element { const { scene } = useLoggedGLTF(modelPath, { scope: "ExplodableModel", @@ -106,6 +117,24 @@ function ExplodableModelInner({ [model, splitDistance], ); const parsedScale = toVector3Scale(scale); + const anchorSignatureRef = useRef(""); + + useEffect(() => { + if (!hideNodeNames || hideNodeNames.length === 0) return; + const hidden: THREE.Object3D[] = []; + model.traverse((child) => { + if (hideNodeNames.includes(child.name)) { + hidden.push(child); + child.visible = false; + } + }); + + return () => { + hidden.forEach((object) => { + object.visible = true; + }); + }; + }, [hideNodeNames, model]); useEffect(() => { explodedModel.setSplit(split); @@ -117,6 +146,35 @@ function ExplodableModelInner({ useFrame((_, delta) => { explodedModel.update(delta); + + if ( + !onNodeAnchorsChange || + !nodeAnchorNames || + nodeAnchorNames.length === 0 + ) { + return; + } + + const anchors: Record = {}; + nodeAnchorNames.forEach((name) => { + const node = model.getObjectByName(name); + if (!node) return; + node.getWorldPosition(_anchorWorld); + anchors[name] = [_anchorWorld.x, _anchorWorld.y, _anchorWorld.z]; + }); + + const signature = nodeAnchorNames + .map((name) => { + const a = anchors[name]; + return a + ? `${name}:${a[0].toFixed(3)},${a[1].toFixed(3)},${a[2].toFixed(3)}` + : `${name}:?`; + }) + .join("|"); + + if (signature === anchorSignatureRef.current) return; + anchorSignatureRef.current = signature; + onNodeAnchorsChange(anchors); }); return (