diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index c8a990f..615d1de 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -143,6 +143,7 @@ export function RepairGame({ {step === "fragmented" ? ( @@ -160,6 +161,7 @@ export function RepairGame({ <> = { description: "Repair the damaged cooling module before relaunching the bike", modelPath: "/models/ebike/model.gltf", - modelScale: 0.3, + modelScale: EBIKE_WORLD_SCALE, + modelRotation: [0, EBIKE_WORLD_ROTATION_Y, 0], stageUiPath: "/assets/world/UI/ebike-mission-notification.webm", interactUiPath: REPAIR_INTERACT_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH, diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index 6a5e6d7..ef228a3 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -64,6 +64,13 @@ export interface RepairMissionConfig { description: string; modelPath: string; modelScale?: ModelTransformProps["scale"]; + /** + * World-space rotation applied to the model when mounted by RepairGame + * (fragmented + repairing steps). Should match the rotation used by the + * source object in the world (e.g. parked Ebike) so the fragmented model + * lines up visually with the inspection model. + */ + modelRotation?: Vector3Tuple; stageUiPath: string; interactUiPath: string; brokenUiPath: string; diff --git a/src/utils/three/ExplodedModel.ts b/src/utils/three/ExplodedModel.ts index e646ebd..d381a78 100644 --- a/src/utils/three/ExplodedModel.ts +++ b/src/utils/three/ExplodedModel.ts @@ -53,13 +53,23 @@ export class ExplodedModel { } private createParts(model: THREE.Object3D): ExplodedPart[] { - const root = - model.children.length === 1 && model.children[0] - ? model.children[0] - : model; - const directChildren = root.children.filter((child) => hasMesh(child)); + // Drill down through single-mesh-bearing branches until we find a node + // with multiple mesh-bearing children (the natural "explosion group" the + // modeler authored). Falls back to flat mesh list only if no such group + // exists. This avoids exploding leaves in local space when wrapper nodes + // (e.g. "Empty" + "Moto" > "Eclatement") sit above the actual group. + let current = model; + while (true) { + const meshChildren = current.children.filter((child) => hasMesh(child)); + if (meshChildren.length === 1 && meshChildren[0]) { + current = meshChildren[0]; + continue; + } + break; + } + const directChildren = current.children.filter((child) => hasMesh(child)); const sourceObjects = - directChildren.length > 1 ? directChildren : getMeshes(root); + directChildren.length > 1 ? directChildren : getMeshes(current); if (sourceObjects.length === 0) return [];