From 7d2a257e847102d5f8ec0770bdaa9b20f33c40c9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 2 Jun 2026 18:20:43 +0200 Subject: [PATCH] feat(repair): expose case part anchors and fix lid node name - Fix REPAIR_CASE_LID_NODE_NAME from "partiesup" to "partsup" (the actual node name in packderelance.gltf), restoring the lid open/close animation that was silently no-oping since introduction. - Add REPAIR_CASE_PART_ANCHOR_NAMES (cabledroit, cablegauche, pucehaut, pucebas, refroidisseur) and REPAIR_CASE_PART_ANCHOR_FALLBACKS for case-local positions used when the GLTF lacks a node (refroidisseur). - RepairCaseModel now resolves these anchor nodes on mount, hides existing meshes underneath them, and creates lightweight Object3D placeholders for missing names so the anchoring pipeline is uniform. - Each frame, anchor world positions are converted to step-local space and emitted via the new onAnchorsChange callback (debounced via signature like placeholders). Consumers added in subsequent commits. --- .../three/gameplay/RepairCaseModel.tsx | 77 +++++++++++++++++++ src/data/gameplay/repairCaseConfig.ts | 49 +++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/components/three/gameplay/RepairCaseModel.tsx b/src/components/three/gameplay/RepairCaseModel.tsx index 3df6132..259b95c 100644 --- a/src/components/three/gameplay/RepairCaseModel.tsx +++ b/src/components/three/gameplay/RepairCaseModel.tsx @@ -15,11 +15,15 @@ import { REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES, REPAIR_CASE_CLOSE_SOUND_PATH, REPAIR_CASE_OPEN_SOUND_PATH, + REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION, + REPAIR_CASE_PART_ANCHOR_FALLBACKS, + REPAIR_CASE_PART_ANCHOR_NAMES, REPAIR_CASE_PLACEHOLDER_NAME_PREFIX, REPAIR_CASE_POP_DURATION, REPAIR_CASE_POP_Y_OFFSET, REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES, REPAIR_CASE_ROTATION_RESET_SPEED, + type RepairCasePartAnchorName, } from "@/data/gameplay/repairCaseConfig"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; @@ -32,6 +36,10 @@ export interface RepairCasePlaceholder { position: Vector3Tuple; } +export type RepairCasePartAnchors = Partial< + Record +>; + interface RepairCaseModelProps extends ModelTransformProps { modelPath: string; open: boolean; @@ -40,6 +48,7 @@ interface RepairCaseModelProps extends ModelTransformProps { onPlaceholdersChange?: | ((placeholders: readonly RepairCasePlaceholder[]) => void) | undefined; + onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined; onExitComplete?: (() => void) | undefined; } @@ -59,6 +68,7 @@ export function RepairCaseModel({ exiting = false, floating = true, onPlaceholdersChange, + onAnchorsChange, onExitComplete, position = [0, 0, 0], rotation = [0, 0, 0], @@ -81,6 +91,7 @@ export function RepairCaseModel({ const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET }); const onExitCompleteRef = useRef(onExitComplete); const onPlaceholdersChangeRef = useRef(onPlaceholdersChange); + const onAnchorsChangeRef = useRef(onAnchorsChange); const initialOpen = useRef(open); const previousOpen = useRef(open); const openedRotationZ = useRef(0); @@ -89,6 +100,12 @@ export function RepairCaseModel({ const placeholderSignature = useRef("__initial__"); const placeholderPosition = useRef(new THREE.Vector3()); const placeholderLocalPosition = useRef(new THREE.Vector3()); + const anchorNodes = useRef>( + new Map(), + ); + const anchorSignature = useRef("__initial__"); + const anchorWorldPosition = useRef(new THREE.Vector3()); + const anchorLocalPosition = useRef(new THREE.Vector3()); useEffect(() => { onExitCompleteRef.current = onExitComplete; @@ -98,6 +115,10 @@ export function RepairCaseModel({ onPlaceholdersChangeRef.current = onPlaceholdersChange; }, [onPlaceholdersChange]); + useEffect(() => { + onAnchorsChangeRef.current = onAnchorsChange; + }, [onAnchorsChange]); + useEffect(() => { const popAnimation = pop.current; @@ -153,6 +174,37 @@ export function RepairCaseModel({ } }); + // Resolve part anchor nodes (cabledroit, cablegauche, pucehaut, pucebas, + // refroidisseur). Existing GLTF nodes are reused and their meshes are + // hidden so the standalone model injected at the same position becomes + // the only visible representation. Missing nodes are created on the fly + // at the configured fallback case-local position. + anchorNodes.current = new Map(); + REPAIR_CASE_PART_ANCHOR_NAMES.forEach((anchorName) => { + let node = model.getObjectByName(anchorName); + if (node) { + node.traverse((descendant) => { + if ((descendant as THREE.Mesh).isMesh) { + descendant.visible = false; + } + }); + } else { + const placeholder = new THREE.Object3D(); + placeholder.name = anchorName; + const fallback = REPAIR_CASE_PART_ANCHOR_FALLBACKS[anchorName]; + placeholder.position.set(fallback[0], fallback[1], fallback[2]); + placeholder.quaternion.set( + REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[0], + REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[1], + REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[2], + REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[3], + ); + model.add(placeholder); + node = placeholder; + } + anchorNodes.current.set(anchorName, node); + }); + if (lid) { lid.rotation.z = openedRotationZ.current + @@ -250,6 +302,31 @@ export function RepairCaseModel({ } } + if (anchorNodes.current.size > 0) { + const anchors: RepairCasePartAnchors = {}; + const signatureParts: string[] = []; + anchorNodes.current.forEach((node, anchorName) => { + node.getWorldPosition(anchorWorldPosition.current); + anchorLocalPosition.current.copy(anchorWorldPosition.current); + group.parent?.worldToLocal(anchorLocalPosition.current); + const tuple: Vector3Tuple = [ + anchorLocalPosition.current.x, + anchorLocalPosition.current.y, + anchorLocalPosition.current.z, + ]; + anchors[anchorName] = tuple; + signatureParts.push( + `${anchorName}:${tuple.map((value) => value.toFixed(3)).join(",")}`, + ); + }); + signatureParts.sort(); + const nextAnchorSignature = signatureParts.join("|"); + if (nextAnchorSignature !== anchorSignature.current) { + anchorSignature.current = nextAnchorSignature; + onAnchorsChangeRef.current?.(anchors); + } + } + animationActiveRef.current = isNear; if (animationActiveRef.current) { diff --git a/src/data/gameplay/repairCaseConfig.ts b/src/data/gameplay/repairCaseConfig.ts index 1e7b209..f2d0e7f 100644 --- a/src/data/gameplay/repairCaseConfig.ts +++ b/src/data/gameplay/repairCaseConfig.ts @@ -4,7 +4,7 @@ export const REPAIR_CASE_MODEL_PATH = "/models/packderelance/model.gltf"; export const REPAIR_CASE_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3"; export const REPAIR_CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-malette.mp3"; -export const REPAIR_CASE_LID_NODE_NAME = "partiesup"; +export const REPAIR_CASE_LID_NODE_NAME = "partsup"; export const REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES = 0; export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115; export const REPAIR_CASE_ANIMATION_DURATION = 0.8; @@ -27,3 +27,50 @@ export const REPAIR_CASE_FOCUS_SCALE = 2.25; export const REPAIR_CASE_PLACEHOLDER_NAME_PREFIX = "placeholder_"; export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65; export const REPAIR_CASE_PLACEHOLDER_SNAP_DURATION = 0.25; + +/** + * Names of nodes inside the packderelance GLTF where standalone part models + * are anchored (visually injected). The original meshes under these nodes are + * hidden at runtime so the standalone model takes their place. + * + * Some entries (e.g. `refroidisseur`) do not exist as nodes in the GLTF; an + * empty Object3D is created at mount time at the corresponding case-local + * fallback position so the anchoring pipeline is uniform. + */ +export const REPAIR_CASE_PART_ANCHOR_NAMES = [ + "cabledroit", + "cablegauche", + "pucehaut", + "pucebas", + "refroidisseur", +] as const; + +export type RepairCasePartAnchorName = + (typeof REPAIR_CASE_PART_ANCHOR_NAMES)[number]; + +/** + * Case-local positions used when an anchor node is missing from the GLTF. + * Values are expressed in the case model's local coordinate system (the case + * is rendered at small intrinsic scale; magnitudes are in the 0.01-0.25 range + * to match the existing nodes such as `cabledroit`). + */ +export const REPAIR_CASE_PART_ANCHOR_FALLBACKS: Record< + RepairCasePartAnchorName, + Vector3Tuple +> = { + cabledroit: [0.0087, 0.0139, 0.1921], + cablegauche: [0.0087, 0.0139, 0.2477], + pucehaut: [-0.0207, 0.009, -0.0479], + pucebas: [0.0987, 0.009, -0.0479], + refroidisseur: [0.05, 0.014, 0.05], +}; + +/** + * Quaternion applied to anchor nodes that are created at runtime (because + * the corresponding node is absent from the GLTF). Matches the rotation of + * the existing part nodes in packderelance to keep visual orientation + * consistent. + */ +export const REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION = [ + 0.7071068286895752, 0, 0, 0.7071068286895752, +] as const satisfies readonly [number, number, number, number];