-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Add interactive 3D lab visualization with device components #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Create interactive 3D lab scene using React Three Fiber - Implement 12+ device components (liquid handler, microscope, centrifuge, AGV robot, etc.) - Add device detail modal with 3D preview and specifications - Support device animations and interactive highlighting - Include comprehensive device information database - Update navbar with 3D Lab navigation links - Add 3D Lab route with lazy loading and Suspense - Enhance scrollbar styling for better UX
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry @zhangligei, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces an interactive 3D laboratory visualization feature using React Three Fiber, adding 12+ device components with animations, device details modal, and comprehensive navigation integration. The implementation provides an immersive view of laboratory equipment with clickable interactions and detailed specifications.
Key Changes
- Added interactive 3D lab visualization with animated device components (liquid handler, microscope, centrifuge, AGV robots, etc.)
- Implemented lazy loading for all routes with Suspense fallback to improve initial page load performance
- Enhanced navbar with 3D Lab navigation links and proper routing differentiation (internal vs external links)
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
web/src/router.tsx |
Converts all route imports to lazy loading with Suspense wrapper and adds /3D_lab route |
web/src/app/navbar/Projects.tsx |
Adds 3D Lab and updates Anti navigation links with new icons |
web/src/app/navbar/NavbarFullWidth.tsx |
Adds Link/a tag routing logic to differentiate internal and external links |
web/src/app/navbar/Navbar.tsx |
Replaces Tutorial component with direct documentation link |
web/src/app/3D_lab/page.tsx |
Main 3D lab page with device selection, list view, and modal integration |
web/src/app/3D_lab/deviceInfo.ts |
Device information database with specs and descriptions |
web/src/app/3D_lab/deviceComponents.tsx |
12+ device component implementations with animation support |
web/src/app/3D_lab/InteractiveLabScene.tsx |
Complete 3D scene layout with lighting, cameras, and device positioning |
web/src/app/3D_lab/DeviceDetailModal.tsx |
Modal component for viewing device details with 3D preview |
web/src/app/3D_lab/styles.css |
Custom animations and scrollbar styling for 3D lab page |
web/index.css |
Global scrollbar webkit styling additions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| key={deviceId} | ||
| onClick={() => setSelectedDevice(deviceId)} | ||
| className="px-3 py-2 text-xs font-medium bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:hover:bg-indigo-800/50 text-indigo-700 dark:text-indigo-300 rounded-lg transition-colors" | ||
| title={info.name} |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The clickable device buttons in the quick access section lack accessible labels. Each button should have an aria-label attribute that includes the full device name (e.g., aria-label={info.name}) so screen reader users can understand what each button does. The title attribute alone is insufficient for accessibility as it only shows on hover.
| title={info.name} | |
| title={info.name} | |
| aria-label={info.name} |
| // 移液站模型组件 - 支持动画 | ||
| function LiquidHandlerModel({ | ||
| position = [0, 0.1, 0], | ||
| isAnimating = false, | ||
| }: PositionProps & { isAnimating?: boolean }) { | ||
| const OSS_BASE_URL = | ||
| 'https://storage.sciol.ac.cn/library/liquid_transform_xyz/meshes'; | ||
|
|
||
| const baseLink = useGLTF(`${OSS_BASE_URL}/base_link.glb`); | ||
| const xLink = useGLTF(`${OSS_BASE_URL}/x_link.glb`); | ||
| const yLink = useGLTF(`${OSS_BASE_URL}/y_link.glb`); | ||
| const zLink = useGLTF(`${OSS_BASE_URL}/z_link.glb`); | ||
|
|
||
| const xLinkRef = useRef<Group>(null); | ||
| const yLinkRef = useRef<Group>(null); | ||
| const zLinkRef = useRef<Group>(null); | ||
|
|
||
| // 移液站工作动画:X、Y、Z 轴移动 | ||
| useFrame((state) => { | ||
| if (isAnimating) { | ||
| const time = state.clock.elapsedTime; | ||
| if (xLinkRef.current) { | ||
| xLinkRef.current.position.x = Math.sin(time * 0.5) * 0.3; | ||
| } | ||
| if (yLinkRef.current) { | ||
| yLinkRef.current.position.y = Math.sin(time * 0.7 + 1) * 0.2; | ||
| } | ||
| if (zLinkRef.current) { | ||
| zLinkRef.current.position.z = Math.sin(time * 0.6 + 2) * 0.25; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| return ( | ||
| <group position={position} scale={1.5}> | ||
| <primitive object={baseLink.scene.clone()} /> | ||
| <group ref={xLinkRef}> | ||
| <primitive object={xLink.scene.clone()} /> | ||
| </group> | ||
| <group ref={yLinkRef}> | ||
| <primitive object={yLink.scene.clone()} /> | ||
| </group> | ||
| <group ref={zLinkRef}> | ||
| <primitive object={zLink.scene.clone()} /> | ||
| </group> | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 试剂瓶组 | ||
| function ReagentBottles({ position = [0, 0, 0] }: PositionProps) { | ||
| const bottles = [ | ||
| { pos: [0, 0, 0], color: '#ef4444' }, | ||
| { pos: [0.2, 0, 0], color: '#3b82f6' }, | ||
| { pos: [0.4, 0, 0], color: '#10b981' }, | ||
| { pos: [0, 0, 0.2], color: '#f59e0b' }, | ||
| { pos: [0.2, 0, 0.2], color: '#8b5cf6' }, | ||
| { pos: [0.4, 0, 0.2], color: '#06b6d4' }, | ||
| ]; | ||
|
|
||
| return ( | ||
| <group position={position}> | ||
| {bottles.map((bottle, i) => ( | ||
| <group key={i} position={bottle.pos as [number, number, number]}> | ||
| <mesh castShadow> | ||
| <cylinderGeometry args={[0.08, 0.08, 0.25, 16]} /> | ||
| <meshStandardMaterial | ||
| color={bottle.color} | ||
| metalness={0.1} | ||
| roughness={0.2} | ||
| transparent | ||
| opacity={0.7} | ||
| /> | ||
| </mesh> | ||
| <mesh position={[0, 0.14, 0]} castShadow> | ||
| <cylinderGeometry args={[0.06, 0.06, 0.03, 16]} /> | ||
| <meshStandardMaterial | ||
| color="#1f2937" | ||
| metalness={0.8} | ||
| roughness={0.3} | ||
| /> | ||
| </mesh> | ||
| <mesh position={[0, -0.03, 0]}> | ||
| <cylinderGeometry args={[0.075, 0.075, 0.18, 16]} /> | ||
| <meshStandardMaterial | ||
| color={bottle.color} | ||
| metalness={0.3} | ||
| roughness={0.1} | ||
| emissive={bottle.color} | ||
| emissiveIntensity={0.3} | ||
| /> | ||
| </mesh> | ||
| </group> | ||
| ))} | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 移液枪架 | ||
| function PipetteStand({ position = [0, 0, 0] }: PositionProps) { | ||
| return ( | ||
| <group position={position}> | ||
| <mesh castShadow> | ||
| <cylinderGeometry args={[0.15, 0.15, 0.03, 32]} /> | ||
| <meshStandardMaterial color="#374151" metalness={0.6} roughness={0.4} /> | ||
| </mesh> | ||
| <mesh position={[0, 0.15, 0]} castShadow> | ||
| <cylinderGeometry args={[0.02, 0.02, 0.3, 16]} /> | ||
| <meshStandardMaterial color="#6b7280" metalness={0.8} roughness={0.2} /> | ||
| </mesh> | ||
| {[0, 0.1, 0.2].map((y, i) => ( | ||
| <group key={i} position={[0.08, y, 0]}> | ||
| <mesh rotation={[0, 0, Math.PI / 6]} castShadow> | ||
| <cylinderGeometry args={[0.015, 0.01, 0.2, 16]} /> | ||
| <meshStandardMaterial | ||
| color={['#ef4444', '#3b82f6', '#10b981'][i]} | ||
| metalness={0.7} | ||
| roughness={0.3} | ||
| /> | ||
| </mesh> | ||
| </group> | ||
| ))} | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 实验室货架 | ||
| function LabShelf({ | ||
| position = [0, 0, 0], | ||
| rotation = [0, 0, 0], | ||
| }: PositionRotationProps) { | ||
| return ( | ||
| <group position={position} rotation={rotation}> | ||
| {[ | ||
| [-0.4, 0.75, -0.2], | ||
| [0.4, 0.75, -0.2], | ||
| [-0.4, 0.75, 0.2], | ||
| [0.4, 0.75, 0.2], | ||
| ].map((pos, i) => ( | ||
| <mesh key={i} position={pos as [number, number, number]} castShadow> | ||
| <boxGeometry args={[0.04, 1.5, 0.04]} /> | ||
| <meshStandardMaterial | ||
| color="#6b7280" | ||
| metalness={0.7} | ||
| roughness={0.3} | ||
| /> | ||
| </mesh> | ||
| ))} | ||
| {[0.3, 0.7, 1.1, 1.5].map((y, i) => ( | ||
| <mesh key={i} position={[0, y, 0]} castShadow receiveShadow> | ||
| <boxGeometry args={[0.9, 0.02, 0.45]} /> | ||
| <meshStandardMaterial | ||
| color="#d1d5db" | ||
| metalness={0.3} | ||
| roughness={0.6} | ||
| /> | ||
| </mesh> | ||
| ))} | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 实验台组件 | ||
| function LabBench({ | ||
| position = [0, 0, 0], | ||
| width = 5, | ||
| depth = 2.5, | ||
| }: LabBenchProps) { | ||
| return ( | ||
| <group position={position}> | ||
| <mesh position={[0, 0.9, 0]} receiveShadow castShadow> | ||
| <boxGeometry args={[width, 0.05, depth]} /> | ||
| <meshStandardMaterial color="#d1d5db" metalness={0.4} roughness={0.3} /> | ||
| </mesh> | ||
| {[ | ||
| [-width / 2 + 0.15, 0.45, -depth / 2 + 0.15], | ||
| [width / 2 - 0.15, 0.45, -depth / 2 + 0.15], | ||
| [-width / 2 + 0.15, 0.45, depth / 2 - 0.15], | ||
| [width / 2 - 0.15, 0.45, depth / 2 - 0.15], | ||
| ].map((pos, i) => ( | ||
| <mesh key={i} position={pos as [number, number, number]} castShadow> | ||
| <boxGeometry args={[0.05, 0.9, 0.05]} /> | ||
| <meshStandardMaterial | ||
| color="#6b7280" | ||
| metalness={0.8} | ||
| roughness={0.2} | ||
| /> | ||
| </mesh> | ||
| ))} | ||
| <mesh position={[0, 0.25, 0]} receiveShadow> | ||
| <boxGeometry args={[width - 0.2, 0.03, depth - 0.2]} /> | ||
| <meshStandardMaterial color="#9ca3af" metalness={0.3} roughness={0.5} /> | ||
| </mesh> | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 显微镜 - 支持动画 | ||
| function Microscope({ | ||
| position = [0, 0, 0], | ||
| isAnimating = false, | ||
| }: PositionProps & { isAnimating?: boolean }) { | ||
| const lensRef = useRef<Mesh>(null); | ||
|
|
||
| // 显微镜观察动画:镜头上下移动 | ||
| useFrame((state) => { | ||
| if (lensRef.current && isAnimating) { | ||
| const time = state.clock.elapsedTime; | ||
| lensRef.current.position.y = 0.5 + Math.sin(time * 1.5) * 0.05; | ||
| } | ||
| }); | ||
|
|
||
| return ( | ||
| <group position={position} scale={0.8}> | ||
| <mesh castShadow> | ||
| <cylinderGeometry args={[0.2, 0.25, 0.05, 32]} /> | ||
| <meshStandardMaterial color="#1f2937" metalness={0.8} roughness={0.2} /> | ||
| </mesh> | ||
| <mesh position={[0, 0.3, 0]} castShadow> | ||
| <cylinderGeometry args={[0.03, 0.03, 0.5, 16]} /> | ||
| <meshStandardMaterial color="#374151" metalness={0.9} roughness={0.1} /> | ||
| </mesh> | ||
| <mesh | ||
| ref={lensRef} | ||
| position={[0, 0.5, 0.1]} | ||
| rotation={[Math.PI / 6, 0, 0]} | ||
| castShadow | ||
| > | ||
| <cylinderGeometry args={[0.08, 0.06, 0.15, 16]} /> | ||
| <meshStandardMaterial color="#6b7280" metalness={0.8} roughness={0.2} /> | ||
| </mesh> | ||
| <mesh position={[0, 0.15, 0]} castShadow> | ||
| <cylinderGeometry args={[0.05, 0.04, 0.08, 16]} /> | ||
| <meshStandardMaterial color="#4b5563" metalness={0.9} roughness={0.1} /> | ||
| </mesh> | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // AGV机器人 - 支持动画 | ||
| function AGVRobot({ | ||
| position = [0, 0, 0], | ||
| rotation = [0, 0, 0], | ||
| isAnimating = false, | ||
| }: PositionRotationProps & { isAnimating?: boolean }) { | ||
| const robotRef = useRef<Group>(null); | ||
| const armRef = useRef<Group>(null); | ||
|
|
||
| // AGV移动和机械臂动画 | ||
| useFrame((state) => { | ||
| if (isAnimating) { | ||
| const time = state.clock.elapsedTime; | ||
| if (robotRef.current) { | ||
| // 前后移动 | ||
| robotRef.current.position.z = Math.sin(time * 0.3) * 0.5; | ||
| } | ||
| if (armRef.current) { | ||
| // 机械臂摆动 | ||
| armRef.current.rotation.y = Math.sin(time * 0.5) * 0.3; | ||
| armRef.current.rotation.z = Math.sin(time * 0.7) * 0.2; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| return ( | ||
| <group ref={robotRef} position={position} rotation={rotation}> | ||
| <mesh position={[0, 0.2, 0]} castShadow> | ||
| <boxGeometry args={[1.2, 0.3, 0.9]} /> | ||
| <meshStandardMaterial color="#fbbf24" metalness={0.6} roughness={0.3} /> | ||
| </mesh> | ||
| {[ | ||
| [-0.52, 0.1, 0.38], | ||
| [0.52, 0.1, 0.38], | ||
| [-0.52, 0.1, -0.38], | ||
| [0.52, 0.1, -0.38], | ||
| ].map((pos, i) => ( | ||
| <mesh | ||
| key={i} | ||
| position={pos as [number, number, number]} | ||
| rotation={[0, 0, Math.PI / 2]} | ||
| castShadow | ||
| > | ||
| <cylinderGeometry args={[0.12, 0.12, 0.15, 16]} /> | ||
| <meshStandardMaterial | ||
| color="#1f2937" | ||
| metalness={0.7} | ||
| roughness={0.4} | ||
| /> | ||
| </mesh> | ||
| ))} | ||
| <mesh position={[0, 0.38, 0]} castShadow> | ||
| <boxGeometry args={[1.05, 0.04, 0.75]} /> | ||
| <meshStandardMaterial color="#d1d5db" metalness={0.5} roughness={0.3} /> | ||
| </mesh> | ||
| <mesh position={[0, 0.55, 0]} castShadow> | ||
| <cylinderGeometry args={[0.15, 0.18, 0.25, 16]} /> | ||
| <meshStandardMaterial color="#374151" metalness={0.8} roughness={0.2} /> | ||
| </mesh> | ||
| <group ref={armRef} position={[0, 1.15, 0]}> | ||
| <mesh castShadow> | ||
| <cylinderGeometry args={[0.07, 0.07, 0.9, 16]} /> | ||
| <meshStandardMaterial | ||
| color="#4b5563" | ||
| metalness={0.8} | ||
| roughness={0.2} | ||
| /> | ||
| </mesh> | ||
| </group> | ||
| </group> | ||
| ); | ||
| } | ||
|
|
||
| // 离心机 - 支持动画 | ||
| function Centrifuge({ | ||
| position = [0, 0, 0], | ||
| isAnimating = false, | ||
| }: PositionProps & { isAnimating?: boolean }) { | ||
| const rotorRef = useRef<Mesh>(null); | ||
|
|
||
| // 离心机旋转动画 | ||
|
|
||
| return ( | ||
| <group position={position}> | ||
| <mesh castShadow> | ||
| <cylinderGeometry args={[0.15, 0.18, 0.25, 32]} /> | ||
| <meshStandardMaterial color="#e5e7eb" metalness={0.6} roughness={0.3} /> | ||
| </mesh> | ||
| <mesh ref={rotorRef} position={[0, 0.14, 0]} castShadow> | ||
| <cylinderGeometry args={[0.13, 0.13, 0.03, 32]} /> | ||
| <meshStandardMaterial color="#6b7280" metalness={0.8} roughness={0.2} /> | ||
| </mesh> | ||
| <mesh position={[0.16, 0.05, 0]} castShadow> | ||
| <boxGeometry args={[0.05, 0.08, 0.06]} /> | ||
| <meshStandardMaterial color="#1f2937" metalness={0.5} roughness={0.5} /> | ||
| </mesh> | ||
| <mesh position={[0.19, 0.08, 0]}> | ||
| <sphereGeometry args={[0.01, 16, 16]} /> | ||
| <meshStandardMaterial | ||
| color={isAnimating ? '#ef4444' : '#10b981'} | ||
| emissive={isAnimating ? '#ef4444' : '#10b981'} | ||
| emissiveIntensity={isAnimating ? 2 : 1} | ||
| /> | ||
| </mesh> | ||
| </group> | ||
| ); | ||
| } |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate device component definitions exist in both InteractiveLabScene.tsx (lines 137-482) and deviceComponents.tsx (lines 21-505). This code duplication violates the DRY principle and will be difficult to maintain. The InteractiveLabScene.tsx should import and use the components from deviceComponents.tsx instead of redefining them.
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"> | ||
| <div className="relative h-[90vh] w-[90vw] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl overflow-hidden"> | ||
| {/* 关闭按钮 */} | ||
| <button | ||
| onClick={onClose} | ||
| className="absolute top-4 right-4 z-10 rounded-full bg-white/90 dark:bg-neutral-800/90 p-2 shadow-lg hover:bg-white dark:hover:bg-neutral-700 transition-colors" | ||
| > | ||
| <XMarkIcon className="h-6 w-6 text-neutral-900 dark:text-neutral-100" /> | ||
| </button> |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modal overlay and close button lack proper accessibility attributes. The modal should include role="dialog", aria-modal="true", and aria-labelledby pointing to the device name. The close button should have aria-label="Close device details" for screen reader users. Additionally, focus should be trapped within the modal when open, and Escape key should close it.
| <Route path="/login" element={<LoginPage />} /> | ||
| <Route path="/login/callback" element={<CallbackPage />} /> | ||
| <Route path="/ui-test" element={<UiTestPage />} /> | ||
| <Route path="/chat" element={<ChatPage />} /> |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The route path '/3D_lab' uses uppercase and underscore, which violates the repository's routing convention. According to existing routes like '/chat', '/login', '/ui-test', route paths should use lowercase with hyphens. Consider changing this to '/3d-lab' to maintain consistency.
| deviceId="centrifuge" | ||
| position={[-1.2, 0.95, -4.2]} | ||
| onDeviceClick={onDeviceClick} | ||
| isHighlighted={highlightedDevice === 'centrifuge'} | ||
| isAnimating={animatingDevice === 'centrifuge'} | ||
| disabled={disabled} | ||
| > | ||
| <Centrifuge isAnimating={animatingDevice === 'centrifuge'} /> | ||
| </ClickableDevice> | ||
| <ClickableDevice | ||
| deviceId="centrifuge" | ||
| position={[1.2, 0.95, -4.2]} | ||
| onDeviceClick={onDeviceClick} | ||
| isHighlighted={highlightedDevice === 'centrifuge'} | ||
| isAnimating={animatingDevice === 'centrifuge'} | ||
| disabled={disabled} | ||
| > | ||
| <Centrifuge isAnimating={animatingDevice === 'centrifuge'} /> |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple ClickableDevice instances are using the same deviceId 'centrifuge' at different positions. While this may be intentional to show multiple instances of the same device, it will cause all centrifuge instances to highlight and animate simultaneously when any one is clicked, which may not be the desired behavior. Consider using unique device IDs like 'centrifuge-1', 'centrifuge-2', etc., or modify the highlighting logic to support device instance positioning.
| deviceId="centrifuge" | |
| position={[-1.2, 0.95, -4.2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'centrifuge'} | |
| isAnimating={animatingDevice === 'centrifuge'} | |
| disabled={disabled} | |
| > | |
| <Centrifuge isAnimating={animatingDevice === 'centrifuge'} /> | |
| </ClickableDevice> | |
| <ClickableDevice | |
| deviceId="centrifuge" | |
| position={[1.2, 0.95, -4.2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'centrifuge'} | |
| isAnimating={animatingDevice === 'centrifuge'} | |
| disabled={disabled} | |
| > | |
| <Centrifuge isAnimating={animatingDevice === 'centrifuge'} /> | |
| deviceId="centrifuge-1" | |
| position={[-1.2, 0.95, -4.2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'centrifuge-1'} | |
| isAnimating={animatingDevice === 'centrifuge-1'} | |
| disabled={disabled} | |
| > | |
| <Centrifuge isAnimating={animatingDevice === 'centrifuge-1'} /> | |
| </ClickableDevice> | |
| <ClickableDevice | |
| deviceId="centrifuge-2" | |
| position={[1.2, 0.95, -4.2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'centrifuge-2'} | |
| isAnimating={animatingDevice === 'centrifuge-2'} | |
| disabled={disabled} | |
| > | |
| <Centrifuge isAnimating={animatingDevice === 'centrifuge-2'} /> |
| /* 自定义滚动条样式 - 覆盖 Tailwind utility */ | ||
| .custom-scrollbar { | ||
| /* Firefox */ | ||
| scrollbar-width: thin !important; | ||
| scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.05) !important; | ||
| /* 强制显示滚动条 */ | ||
| overflow-y: auto !important; | ||
| overflow-x: hidden !important; | ||
| } | ||
|
|
||
| /* Webkit 浏览器(Chrome, Safari, Edge)的滚动条样式 */ | ||
| .custom-scrollbar::-webkit-scrollbar { | ||
| width: 10px !important; | ||
| height: 10px !important; | ||
| -webkit-appearance: none; | ||
| display: block !important; | ||
| } | ||
|
|
||
| .custom-scrollbar::-webkit-scrollbar-track { | ||
| background: rgba(0, 0, 0, 0.05) !important; | ||
| border-radius: 5px; | ||
| margin: 4px 0; | ||
| } | ||
|
|
||
| .dark .custom-scrollbar::-webkit-scrollbar-track { | ||
| background: rgba(255, 255, 255, 0.05) !important; | ||
| } | ||
|
|
||
| .custom-scrollbar::-webkit-scrollbar-thumb { | ||
| background-color: rgba(156, 163, 175, 0.5) !important; | ||
| border-radius: 5px !important; | ||
| border: 2px solid transparent; | ||
| background-clip: padding-box; | ||
| -webkit-appearance: none; | ||
| min-height: 20px; | ||
| } | ||
|
|
||
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | ||
| background-color: rgba(156, 163, 175, 0.6) !important; | ||
| } | ||
|
|
||
| .custom-scrollbar::-webkit-scrollbar-thumb:active { | ||
| background-color: rgba(156, 163, 175, 0.8) !important; | ||
| } | ||
|
|
||
| /* 深色模式下的滚动条 */ | ||
| .dark .custom-scrollbar { | ||
| scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; | ||
| } | ||
|
|
||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb { | ||
| background-color: rgba(156, 163, 175, 0.5) !important; | ||
| } | ||
|
|
||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { | ||
| background-color: rgba(156, 163, 175, 0.7) !important; | ||
| } | ||
|
|
||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:active { | ||
| background-color: rgba(156, 163, 175, 0.9) !important; | ||
| } |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This scrollbar styling is duplicated between web/src/app/3D_lab/styles.css (lines 44-104) and web/index.css (lines 113-141). The duplicate definitions will make maintenance difficult and could lead to inconsistent styling. Consider consolidating all scrollbar styling in the global index.css file and removing the duplicate from the 3D_lab styles.css.
| /* 自定义滚动条样式 - 覆盖 Tailwind utility */ | |
| .custom-scrollbar { | |
| /* Firefox */ | |
| scrollbar-width: thin !important; | |
| scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.05) !important; | |
| /* 强制显示滚动条 */ | |
| overflow-y: auto !important; | |
| overflow-x: hidden !important; | |
| } | |
| /* Webkit 浏览器(Chrome, Safari, Edge)的滚动条样式 */ | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 10px !important; | |
| height: 10px !important; | |
| -webkit-appearance: none; | |
| display: block !important; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-track { | |
| background: rgba(0, 0, 0, 0.05) !important; | |
| border-radius: 5px; | |
| margin: 4px 0; | |
| } | |
| .dark .custom-scrollbar::-webkit-scrollbar-track { | |
| background: rgba(255, 255, 255, 0.05) !important; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background-color: rgba(156, 163, 175, 0.5) !important; | |
| border-radius: 5px !important; | |
| border: 2px solid transparent; | |
| background-clip: padding-box; | |
| -webkit-appearance: none; | |
| min-height: 20px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background-color: rgba(156, 163, 175, 0.6) !important; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:active { | |
| background-color: rgba(156, 163, 175, 0.8) !important; | |
| } | |
| /* 深色模式下的滚动条 */ | |
| .dark .custom-scrollbar { | |
| scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; | |
| } | |
| .dark .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background-color: rgba(156, 163, 175, 0.5) !important; | |
| } | |
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background-color: rgba(156, 163, 175, 0.7) !important; | |
| } | |
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:active { | |
| background-color: rgba(156, 163, 175, 0.9) !important; | |
| } |
| name: 'Anti', | ||
| description: '用于实验室模拟的3D数字孪生平台', | ||
| href: `/deepmd-kit`, | ||
| href: `/3D_lab`, |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The route path '/3D_lab' uses uppercase and underscore format. For consistency with other routes in the navbar (like existing lowercase paths), this should be changed to '/3d-lab'.
| deviceId="agv-robot" | ||
| position={[-2.5, 0, 1.5]} | ||
| onDeviceClick={onDeviceClick} | ||
| isHighlighted={highlightedDevice === 'agv-robot'} | ||
| isAnimating={animatingDevice === 'agv-robot'} | ||
| disabled={disabled} | ||
| > | ||
| <AGVRobot | ||
| rotation={[0, Math.PI / 6, 0]} | ||
| isAnimating={animatingDevice === 'agv-robot'} | ||
| /> | ||
| </ClickableDevice> | ||
| <ClickableDevice | ||
| deviceId="agv-robot" | ||
| position={[2.8, 0, -2]} | ||
| onDeviceClick={onDeviceClick} | ||
| isHighlighted={highlightedDevice === 'agv-robot'} | ||
| isAnimating={animatingDevice === 'agv-robot'} | ||
| disabled={disabled} | ||
| > | ||
| <AGVRobot | ||
| rotation={[0, -Math.PI / 4, 0]} | ||
| isAnimating={animatingDevice === 'agv-robot'} |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two ClickableDevice instances are using the same deviceId 'agv-robot' at different positions. This will cause both AGV robots to highlight and animate simultaneously when either is clicked, which may confuse users. Consider using unique device IDs like 'agv-robot-1' and 'agv-robot-2' to enable independent interaction with each robot.
| deviceId="agv-robot" | |
| position={[-2.5, 0, 1.5]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'agv-robot'} | |
| isAnimating={animatingDevice === 'agv-robot'} | |
| disabled={disabled} | |
| > | |
| <AGVRobot | |
| rotation={[0, Math.PI / 6, 0]} | |
| isAnimating={animatingDevice === 'agv-robot'} | |
| /> | |
| </ClickableDevice> | |
| <ClickableDevice | |
| deviceId="agv-robot" | |
| position={[2.8, 0, -2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'agv-robot'} | |
| isAnimating={animatingDevice === 'agv-robot'} | |
| disabled={disabled} | |
| > | |
| <AGVRobot | |
| rotation={[0, -Math.PI / 4, 0]} | |
| isAnimating={animatingDevice === 'agv-robot'} | |
| deviceId="agv-robot-1" | |
| position={[-2.5, 0, 1.5]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'agv-robot-1'} | |
| isAnimating={animatingDevice === 'agv-robot-1'} | |
| disabled={disabled} | |
| > | |
| <AGVRobot | |
| rotation={[0, Math.PI / 6, 0]} | |
| isAnimating={animatingDevice === 'agv-robot-1'} | |
| /> | |
| </ClickableDevice> | |
| <ClickableDevice | |
| deviceId="agv-robot-2" | |
| position={[2.8, 0, -2]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'agv-robot-2'} | |
| isAnimating={animatingDevice === 'agv-robot-2'} | |
| disabled={disabled} | |
| > | |
| <AGVRobot | |
| rotation={[0, -Math.PI / 4, 0]} | |
| isAnimating={animatingDevice === 'agv-robot-2'} |
|
|
||
| {/* 前方工作台 */} | ||
| <LabBench position={[-1.5, 0, 3.5]} width={2.5} depth={1.5} /> | ||
| <Monitor position={[-1.5, 0.95, 3.5]} rotation={[0, Math.PI, 0]} /> |
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple Monitor instances are rendered in the scene (lines 592, 631, 657, 658, 695), but only two are wrapped in ClickableDevice components (lines 586-602, 630-647). The other monitors at positions [0, 0.95, -4.2], [-1.8, 0.95, -4.2], [1.8, 0.95, -4.2], and [-1.5, 0.95, 3.5] cannot be clicked. This creates an inconsistent user experience where some monitors are interactive and others are not.
| <Monitor position={[-1.5, 0.95, 3.5]} rotation={[0, Math.PI, 0]} /> | |
| <ClickableDevice | |
| deviceId="monitor-front" | |
| position={[-1.5, 0.95, 3.5]} | |
| rotation={[0, Math.PI, 0]} | |
| onDeviceClick={onDeviceClick} | |
| isHighlighted={highlightedDevice === 'monitor-front'} | |
| isAnimating={animatingDevice === 'monitor-front'} | |
| disabled={disabled} | |
| > | |
| <Monitor | |
| isAnimating={animatingDevice === 'monitor-front'} | |
| /> | |
| </ClickableDevice> |
| useFrame(() => { | ||
| if (groupRef.current) { | ||
| currentScale.current += (targetScale - currentScale.current) * 0.1; | ||
| groupRef.current.scale.setScalar(currentScale.current); | ||
| } | ||
| }); | ||
|
|
||
| // 点击动画 | ||
| useFrame((state) => { | ||
| if (groupRef.current && isAnimating) { | ||
| // 简单的脉冲动画 | ||
| const pulse = Math.sin(state.clock.elapsedTime * 2) * 0.05; | ||
| groupRef.current.scale.setScalar(1 + pulse); | ||
| } | ||
| }); | ||
|
|
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ClickableDevice component has conflicting animation logic. Lines 72-77 handle highlight scaling animation, while lines 80-86 handle clicking animation that also modifies scale. When both isHighlighted and isAnimating are true, these two useFrame hooks will fight over the scale value, potentially causing jittery behavior. The animation logic should be consolidated into a single useFrame hook that considers both states.
| useFrame(() => { | |
| if (groupRef.current) { | |
| currentScale.current += (targetScale - currentScale.current) * 0.1; | |
| groupRef.current.scale.setScalar(currentScale.current); | |
| } | |
| }); | |
| // 点击动画 | |
| useFrame((state) => { | |
| if (groupRef.current && isAnimating) { | |
| // 简单的脉冲动画 | |
| const pulse = Math.sin(state.clock.elapsedTime * 2) * 0.05; | |
| groupRef.current.scale.setScalar(1 + pulse); | |
| } | |
| }); | |
| useFrame((state) => { | |
| if (groupRef.current) { | |
| // Smoothly animate towards the target scale (highlight/hover) | |
| currentScale.current += (targetScale - currentScale.current) * 0.1; | |
| // If animating, apply pulse on top of base scale | |
| let finalScale = currentScale.current; | |
| if (isAnimating) { | |
| const pulse = Math.sin(state.clock.elapsedTime * 2) * 0.05; | |
| finalScale *= (1 + pulse); | |
| } | |
| groupRef.current.scale.setScalar(finalScale); | |
| } | |
| }); |
变更内容
简要描述本次 PR 的主要变更内容。
相关 Issue
请关联相关 Issue(如有):#编号
检查清单
默认已勾选,如不满足,请检查。
其他说明
如有特殊说明或注意事项,请补充。