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.
This commit is contained in:
Tom Boullay
2026-06-02 18:20:43 +02:00
parent 58eb60292f
commit 7d2a257e84
2 changed files with 125 additions and 1 deletions
@@ -15,11 +15,15 @@ import {
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES, REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_CLOSE_SOUND_PATH, REPAIR_CASE_CLOSE_SOUND_PATH,
REPAIR_CASE_OPEN_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_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION, REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET, REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES, REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED, REPAIR_CASE_ROTATION_RESET_SPEED,
type RepairCasePartAnchorName,
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
@@ -32,6 +36,10 @@ export interface RepairCasePlaceholder {
position: Vector3Tuple; position: Vector3Tuple;
} }
export type RepairCasePartAnchors = Partial<
Record<RepairCasePartAnchorName, Vector3Tuple>
>;
interface RepairCaseModelProps extends ModelTransformProps { interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string; modelPath: string;
open: boolean; open: boolean;
@@ -40,6 +48,7 @@ interface RepairCaseModelProps extends ModelTransformProps {
onPlaceholdersChange?: onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void) | ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined; | undefined;
onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined;
onExitComplete?: (() => void) | undefined; onExitComplete?: (() => void) | undefined;
} }
@@ -59,6 +68,7 @@ export function RepairCaseModel({
exiting = false, exiting = false,
floating = true, floating = true,
onPlaceholdersChange, onPlaceholdersChange,
onAnchorsChange,
onExitComplete, onExitComplete,
position = [0, 0, 0], position = [0, 0, 0],
rotation = [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 pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete); const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange); const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const onAnchorsChangeRef = useRef(onAnchorsChange);
const initialOpen = useRef(open); const initialOpen = useRef(open);
const previousOpen = useRef(open); const previousOpen = useRef(open);
const openedRotationZ = useRef(0); const openedRotationZ = useRef(0);
@@ -89,6 +100,12 @@ export function RepairCaseModel({
const placeholderSignature = useRef("__initial__"); const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3()); const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3()); const placeholderLocalPosition = useRef(new THREE.Vector3());
const anchorNodes = useRef<Map<RepairCasePartAnchorName, THREE.Object3D>>(
new Map(),
);
const anchorSignature = useRef("__initial__");
const anchorWorldPosition = useRef(new THREE.Vector3());
const anchorLocalPosition = useRef(new THREE.Vector3());
useEffect(() => { useEffect(() => {
onExitCompleteRef.current = onExitComplete; onExitCompleteRef.current = onExitComplete;
@@ -98,6 +115,10 @@ export function RepairCaseModel({
onPlaceholdersChangeRef.current = onPlaceholdersChange; onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]); }, [onPlaceholdersChange]);
useEffect(() => {
onAnchorsChangeRef.current = onAnchorsChange;
}, [onAnchorsChange]);
useEffect(() => { useEffect(() => {
const popAnimation = pop.current; 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) { if (lid) {
lid.rotation.z = lid.rotation.z =
openedRotationZ.current + 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; animationActiveRef.current = isNear;
if (animationActiveRef.current) { if (animationActiveRef.current) {
+48 -1
View File
@@ -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_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3";
export const REPAIR_CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-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_CLOSED_ROTATION_OFFSET_DEGREES = 0;
export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115; export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115;
export const REPAIR_CASE_ANIMATION_DURATION = 0.8; 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_NAME_PREFIX = "placeholder_";
export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65; export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65;
export const REPAIR_CASE_PLACEHOLDER_SNAP_DURATION = 0.25; 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];