diff --git a/src/components/three/gameplay/RepairObjectModel.tsx b/src/components/three/gameplay/RepairObjectModel.tsx
index cb678c0..e8671c8 100644
--- a/src/components/three/gameplay/RepairObjectModel.tsx
+++ b/src/components/three/gameplay/RepairObjectModel.tsx
@@ -8,6 +8,7 @@ import { toVector3Scale } from "@/utils/three/scale";
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
modelPath: string;
+ ghosted?: boolean;
}
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
@@ -73,6 +74,7 @@ export function RepairObjectModel({
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
+ ghosted = false,
}: RepairObjectModelProps): React.JSX.Element {
return (
);
diff --git a/src/components/three/gameplay/RepairRepairingStep.tsx b/src/components/three/gameplay/RepairRepairingStep.tsx
index 27d20de..471f71d 100644
--- a/src/components/three/gameplay/RepairRepairingStep.tsx
+++ b/src/components/three/gameplay/RepairRepairingStep.tsx
@@ -81,6 +81,9 @@ export function RepairRepairingStep({
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
Record
>({});
+ const [heldPartByLockGroup, setHeldPartByLockGroup] = useState<
+ Record
+ >({});
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
useState(false);
const replacementParts = getReplacementParts(config);
@@ -183,6 +186,24 @@ export function RepairRepairingStep({
});
}
+ function handleReplacementGrabChange(
+ part: RepairMissionPartConfig,
+ held: boolean,
+ ): void {
+ if (!part.caseLockGroup) return;
+ const group = part.caseLockGroup;
+ setHeldPartByLockGroup((current) => {
+ if (held) {
+ if (current[group] === part.id) return current;
+ return { ...current, [group]: part.id };
+ }
+ if (current[group] !== part.id) return current;
+ const next = { ...current };
+ delete next[group];
+ return next;
+ });
+ }
+
return (
{
+ handleReplacementGrabChange(part, held);
+ }}
onPositionChange={(position) => {
handleReplacementPosition(part.id, position);
}}
@@ -234,6 +263,7 @@ export function RepairRepairingStep({
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
+ ghosted={lockedByOther}
/>
diff --git a/src/components/three/interaction/GrabbableObject.tsx b/src/components/three/interaction/GrabbableObject.tsx
index 62821da..f0cc054 100644
--- a/src/components/three/interaction/GrabbableObject.tsx
+++ b/src/components/three/interaction/GrabbableObject.tsx
@@ -34,6 +34,8 @@ interface GrabbableObjectProps {
colliders?: ColliderShape;
label?: string;
handControlled?: boolean;
+ disabled?: boolean;
+ onGrabChange?: (held: boolean) => void;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
@@ -131,6 +133,8 @@ export function GrabbableObject({
colliders = GRAB_DEFAULT_COLLIDERS,
label = GRAB_DEFAULT_LABEL,
handControlled = false,
+ disabled = false,
+ onGrabChange,
onPositionChange,
onSnap,
snapDuration = 0.25,
@@ -152,6 +156,19 @@ export function GrabbableObject({
};
}, []);
+ useEffect(() => {
+ if (!disabled) return;
+ if (isHolding.current) {
+ isHolding.current = false;
+ onGrabChange?.(false);
+ }
+ if (isHandHolding.current) {
+ isHandHolding.current = false;
+ InteractionManager.getInstance().setHandHolding(false);
+ onGrabChange?.(false);
+ }
+ }, [disabled, onGrabChange]);
+
function snapToNearestTarget(): void {
const body = rbRef.current;
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
@@ -242,14 +259,16 @@ export function GrabbableObject({
useFrame(() => {
if (!rbRef.current) return;
- const fistHand = handControlled
- ? hands.find((hand) => hand.isFist)
- : undefined;
-
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
onPositionChange?.(_currentPos);
+ if (disabled) return;
+
+ const fistHand = handControlled
+ ? hands.find((hand) => hand.isFist)
+ : undefined;
+
if (fistHand) {
const handCenter = getHandCenterPoint(fistHand);
@@ -267,15 +286,20 @@ export function GrabbableObject({
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
: null;
- isHandHolding.current = Boolean(hit);
- InteractionManager.getInstance().setHandHolding(isHandHolding.current);
+ const hadHit = Boolean(hit);
+ if (hadHit) {
+ isHandHolding.current = true;
+ InteractionManager.getInstance().setHandHolding(true);
+ onGrabChange?.(true);
+ }
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
+ isHandHolding.current = false;
+ InteractionManager.getInstance().setHandHolding(false);
+ onGrabChange?.(false);
}
- isHandHolding.current = false;
- InteractionManager.getInstance().setHandHolding(false);
}
if (!isHolding.current && !isHandHolding.current) return;
@@ -311,35 +335,41 @@ export function GrabbableObject({
position={position}
>
- {
- isHolding.current = true;
- }}
- onRelease={() => {
- isHolding.current = false;
- snapToNearestTarget();
- if (
- !rbRef.current ||
- grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
- )
- return;
- const v = rbRef.current.linvel();
- rbRef.current.setLinvel(
- {
- x: v.x * grabDebugParams.throwBoost,
- y: v.y * grabDebugParams.throwBoost,
- z: v.z * grabDebugParams.throwBoost,
- },
- true,
- );
- }}
- >
- {children}
-
+ {disabled ? (
+ children
+ ) : (
+ {
+ isHolding.current = true;
+ onGrabChange?.(true);
+ }}
+ onRelease={() => {
+ isHolding.current = false;
+ onGrabChange?.(false);
+ snapToNearestTarget();
+ if (
+ !rbRef.current ||
+ grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
+ )
+ return;
+ const v = rbRef.current.linvel();
+ rbRef.current.setLinvel(
+ {
+ x: v.x * grabDebugParams.throwBoost,
+ y: v.y * grabDebugParams.throwBoost,
+ z: v.z * grabDebugParams.throwBoost,
+ },
+ true,
+ );
+ }}
+ >
+ {children}
+
+ )}
diff --git a/src/components/three/models/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx
index 0122940..7d0e0b1 100644
--- a/src/components/three/models/SimpleModel.tsx
+++ b/src/components/three/models/SimpleModel.tsx
@@ -17,10 +17,29 @@ function applyShadowSettings(
});
}
+function applyOpacity(object: THREE.Object3D, opacity: number): void {
+ object.traverse((child) => {
+ if (!(child instanceof THREE.Mesh)) return;
+
+ const materials = Array.isArray(child.material)
+ ? child.material
+ : [child.material];
+
+ materials.forEach((material) => {
+ if (!(material instanceof THREE.Material)) return;
+ material.transparent = opacity < 1;
+ material.opacity = opacity;
+ material.depthWrite = opacity >= 1;
+ material.needsUpdate = true;
+ });
+ });
+}
+
interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
+ opacity?: number;
}
interface SimpleModelProps extends SimpleModelConfig {
@@ -34,6 +53,7 @@ export function SimpleModel({
scale = 1,
castShadow = true,
receiveShadow = true,
+ opacity = 1,
children,
}: SimpleModelProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
@@ -48,6 +68,10 @@ export function SimpleModel({
applyShadowSettings(model, castShadow, receiveShadow);
}, [castShadow, model, receiveShadow]);
+ useEffect(() => {
+ applyOpacity(model, opacity);
+ }, [model, opacity]);
+
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;