Split project into frontend and backend

This commit is contained in:
2026-08-13 16:24:36 +08:00
commit f051146486
274 changed files with 953932 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090b0e" />
<title>Orbit Joint Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2649
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "orbit-joint-studio",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.4.0",
"lucide-react": "^0.468.0",
"motion": "^12.23.24",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sirv": "^3.0.1",
"three": "^0.180.0",
"urdf-loader": "^0.12.6"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.4",
"vite": "^7.1.7"
}
}
+1795
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: set this to true or false
onlyBuiltDependencies:
- esbuild
+527
View File
@@ -0,0 +1,527 @@
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import { Environment, Float, Html, OrbitControls, RoundedBox, Sparkles } from '@react-three/drei'
import { AnimatePresence, motion } from 'motion/react'
import URDFLoader from 'urdf-loader'
import {
ArrowLeft, ArrowUp, Bot, Box, Check, ChevronRight, CircleGauge,
CirclePause, CirclePlay, Crosshair, Cuboid, Eye, EyeOff, Grid3X3,
Layers3, LoaderCircle, Maximize2, PanelLeftClose, RefreshCcw, Rotate3D,
Ruler, ShieldCheck, SlidersHorizontal, Sparkles as SparklesIcon,
WandSparkles,
} from 'lucide-react'
import * as THREE from 'three'
const FALLBACK = {
ratio: 7.5,
parameters: { z_sun: 20, z_planet: 55, z_ring: 130, planet_count: 3, module_mm: 0.5 },
reducerValidation: { total: 260, passed: 260, failed: 0 },
jointValidation: { total: 15, passed: 15, failed: 0 },
}
const INITIAL_PARTS = {
housing: { label: '外壳', color: '#9ba7b4', opacity: 0.28, visible: true },
ring: { label: '内齿圈', color: '#ff9f43', opacity: 0.94, visible: true },
sun: { label: '太阳轮', color: '#f5c451', opacity: 1, visible: true },
planets: { label: '行星轮组', color: '#d5dbe2', opacity: 1, visible: true },
carrier: { label: '行星架', color: '#cbd4dd', opacity: 0.76, visible: true },
motor: { label: '电机', color: '#65717d', opacity: 0.95, visible: true },
}
const CANDIDATES = [
{ id: 1, ratio: 7.5, torque: 7.0, planets: 3, form: '直齿', status: '真实产物', color: '#4d969a' },
]
function Gear({ teeth = 20, radius = 1, depth = .38, color, opacity = 1, inner = 0, ...props }) {
const shape = useMemo(() => {
const s = new THREE.Shape()
const root = radius * .84
const tip = radius
const count = teeth * 4
for (let i = 0; i < count; i++) {
const a = (i / count) * Math.PI * 2
const r = i % 4 === 1 || i % 4 === 2 ? tip : root
const x = Math.cos(a) * r
const y = Math.sin(a) * r
if (i === 0) s.moveTo(x, y); else s.lineTo(x, y)
}
s.closePath()
if (inner > 0) {
const hole = new THREE.Path()
hole.absarc(0, 0, inner, 0, Math.PI * 2, true)
s.holes.push(hole)
}
return s
}, [teeth, radius, inner])
return (
<mesh {...props} castShadow receiveShadow>
<extrudeGeometry args={[shape, { depth, bevelEnabled: true, bevelSize: .025, bevelThickness: .025, bevelSegments: 2 }]} />
<meshStandardMaterial color={color} metalness={.78} roughness={.24} transparent opacity={opacity} side={THREE.DoubleSide} />
</mesh>
)
}
function RingGear({ part, ...props }) {
const teeth = 52
const blocks = useMemo(() => Array.from({ length: teeth }, (_, i) => i), [])
return (
<group {...props} visible={part.visible}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[2.55, .24, 16, 96]} />
<meshStandardMaterial color={part.color} metalness={.8} roughness={.22} transparent opacity={part.opacity} />
</mesh>
{blocks.map((i) => {
const a = i / teeth * Math.PI * 2
return <mesh key={i} position={[Math.cos(a) * 2.28, 0, Math.sin(a) * 2.28]} rotation={[0, -a, 0]}>
<boxGeometry args={[.19, .4, .24]} />
<meshStandardMaterial color={part.color} metalness={.82} roughness={.2} transparent opacity={part.opacity} />
</mesh>
})}
</group>
)
}
function JointModel({ parts, compact = false, accent = '#f1a64b', animate = true }) {
const sun = useRef(), carrier = useRef(), planetRefs = useRef([])
useFrame((_, dt) => {
if (!animate) return
if (sun.current) sun.current.rotation.z -= dt * 1.3
if (carrier.current) carrier.current.rotation.y += dt * .18
planetRefs.current.forEach((g, i) => { if (g) g.rotation.z += dt * .58 * (i % 2 ? -1 : 1) })
})
const orbit = compact ? 1.45 : 1.72
return (
<group rotation={[.22, 0, compact ? -.12 : -.18]} scale={compact ? .7 : 1}>
<group visible={parts.motor.visible} position={[0, -2.45, 0]}>
<RoundedBox args={[3.1, 1.9, 3.1]} radius={.36} smoothness={4}>
<meshStandardMaterial color={parts.motor.color} metalness={.7} roughness={.25} transparent opacity={parts.motor.opacity} />
</RoundedBox>
{Array.from({ length: 12 }, (_, i) => <mesh key={i} position={[Math.sin(i/12*Math.PI*2)*1.58, 0, Math.cos(i/12*Math.PI*2)*1.58]}>
<boxGeometry args={[.1, 1.35, .24]} /><meshStandardMaterial color="#909aa4" metalness={.6} roughness={.3} />
</mesh>)}
</group>
<group ref={sun} visible={parts.sun.visible} rotation={[-Math.PI / 2, 0, 0]} position={[0, -.32, 0]}>
<Gear teeth={20} radius={.82} depth={.56} color={parts.sun.color} opacity={parts.sun.opacity} inner={.2} />
</group>
<RingGear part={parts.ring} position={[0, -.03, 0]} />
<group ref={carrier} visible={parts.carrier.visible} position={[0, .15, 0]}>
{Array.from({ length: 3 }, (_, i) => {
const a = i / 3 * Math.PI * 2
return <mesh key={i} position={[Math.cos(a) * orbit * .54, .28, Math.sin(a) * orbit * .54]} rotation={[0, -a, 0]}>
<boxGeometry args={[orbit * 1.05, .12, .22]} />
<meshStandardMaterial color={parts.carrier.color} metalness={.76} roughness={.28} transparent opacity={parts.carrier.opacity} />
</mesh>
})}
<mesh position={[0, .28, 0]}><cylinderGeometry args={[.46, .46, .28, 40]} /><meshStandardMaterial color={parts.carrier.color} metalness={.8} roughness={.22} transparent opacity={parts.carrier.opacity}/></mesh>
</group>
<group visible={parts.planets.visible}>
{Array.from({ length: 3 }, (_, i) => {
const a = i / 3 * Math.PI * 2
return <group key={i} position={[Math.cos(a) * orbit, -.26, Math.sin(a) * orbit]} rotation={[-Math.PI/2, 0, -a]} ref={el => planetRefs.current[i] = el}>
<Gear teeth={18} radius={.72} depth={.5} color={parts.planets.color} opacity={parts.planets.opacity} inner={.16}/>
</group>
})}
</group>
<group visible={parts.housing.visible}>
<mesh position={[0, -.15, 0]}>
<cylinderGeometry args={[3.25, 3.25, 2.2, 72, 1, true]} />
<meshPhysicalMaterial color={parts.housing.color} metalness={.42} roughness={.3} transparent opacity={parts.housing.opacity} side={THREE.DoubleSide} depthWrite={false} transmission={parts.housing.opacity < .7 ? .18 : 0} />
</mesh>
<mesh position={[0, .95, 0]}><torusGeometry args={[2.92, .32, 20, 96]} /><meshStandardMaterial color={parts.housing.color} metalness={.7} roughness={.25} transparent opacity={Math.min(1, parts.housing.opacity + .16)} /></mesh>
</group>
<mesh position={[0, 1.7, 0]}><cylinderGeometry args={[.34, .34, 2.1, 32]} /><meshStandardMaterial color={accent} metalness={.82} roughness={.2}/></mesh>
</group>
)
}
function Viewer({ parts, compact, accent, animate = true }) {
return <Canvas camera={{ position: compact ? [6, 5, 7] : [8, 5.5, 8], fov: compact ? 34 : 36 }} shadows dpr={[1, 1.75]}>
<color attach="background" args={['#eef2f3']} />
<fog attach="fog" args={['#eef2f3', 10, 20]} />
<ambientLight intensity={1.25} />
<spotLight position={[4, 8, 5]} intensity={17} angle={.55} penumbra={1} color="#fff8e9" castShadow />
<pointLight position={[-5, 1, -4]} intensity={7} color={accent} />
<Suspense fallback={null}>
<Float speed={compact ? 1.1 : .55} rotationIntensity={compact ? .08 : .03} floatIntensity={compact ? .2 : .08}>
<JointModel parts={parts} compact={compact} accent={accent} animate={animate}/>
</Float>
<Sparkles count={compact ? 20 : 42} scale={9} size={1.2} speed={.18} color={accent}/>
<Environment preset="warehouse" />
</Suspense>
<OrbitControls enablePan={false} minDistance={5.5} maxDistance={13} autoRotate={!compact} autoRotateSpeed={.24}/>
</Canvas>
}
let cachedUrdfRobot = null
let cachedUrdfPromise = null
let urdfLoadSnapshot = { stage: 'idle', progress: 0 }
const urdfLoadListeners = new Set()
const URDF_LOAD_STAGES = {
idle: { eyebrow: 'REAL ASSET PIPELINE', title: '制造数据准备中', detail: '读取零件清单、材料与装配坐标' },
manufacturing: { eyebrow: 'MANUFACTURING DATA', title: '制造数据读取中', detail: '载入项目生成并验证通过的 URDF' },
generating: { eyebrow: 'GEOMETRY GENERATION', title: '零件网格生成中', detail: '解析电机、齿轮与外壳几何' },
assembling: { eyebrow: 'KINEMATIC ASSEMBLY', title: '运动关节拼装中', detail: '建立父子关系与传动约束' },
error: { eyebrow: 'ASSEMBLY INTERRUPTED', title: '装配读取失败', detail: '请检查项目产物与网格路径' },
}
function publishUrdfLoad(stage, progress) {
urdfLoadSnapshot = { stage, progress }
urdfLoadListeners.forEach(listener => listener(urdfLoadSnapshot))
}
function useUrdfLoadSnapshot() {
const [snapshot, setSnapshot] = useState(urdfLoadSnapshot)
useEffect(() => {
setSnapshot(urdfLoadSnapshot)
urdfLoadListeners.add(setSnapshot)
return () => urdfLoadListeners.delete(setSnapshot)
}, [])
return snapshot
}
function prepareUrdfRobot(loadedRobot) {
// URDF is Z-up; flip it into the viewer's Y-up frame while keeping
// the motor below the reducer, matching the generated STEP assembly.
loadedRobot.rotation.x = Math.PI / 2
loadedRobot.traverse(node => {
if (!node.isMesh) return
node.castShadow = true
node.receiveShadow = true
node.material = node.material.clone()
const partName = node.name.toLowerCase()
let color = '#9cafba'
if (partName.includes('housing')) color = '#b9c8ce'
else if (partName.includes('motor')) color = '#c87867'
else if (partName.includes('ring')) color = '#d8b65f'
else if (partName.includes('sun')) color = '#efb34e'
else if (partName.includes('planet')) color = '#79aeb8'
else if (partName.includes('carrier')) color = '#6d91b2'
else if (partName.includes('output')) color = '#62a7a2'
node.material.color.set(color)
node.material.metalness = .45
node.material.roughness = .38
})
const linkPalette = {
housing_link: '#bdcdd2', motor_link: '#bd7568', motor_rotor_link: '#bd7568',
motor_stator_housing_link: '#b9c6ca', motor_windings_link: '#b8734d', motor_output_shaft_link: '#7897a4', ring_link: '#d4b35d',
sun_link: '#efad3f', carrier_link: '#648eae', output_shaft_link: '#5ca39d',
carrier_front_plate_link: '#7fa5bd',
}
Object.entries(loadedRobot.links || {}).forEach(([linkName, link]) => {
link.traverse(node => { node.userData.urdfLinkName = linkName })
let color = linkPalette[linkName]
if (!color && linkName.includes('planet_') && !linkName.includes('orbit')) color = '#76aeb5'
if (!color && linkName.includes('bearing')) color = '#a8b5ba'
if (!color) return
link.traverse(node => {
if (!node.isMesh) return
node.material = new THREE.MeshStandardMaterial({ color, metalness: .38, roughness: .42, side: THREE.DoubleSide })
})
})
loadedRobot.updateMatrixWorld(true)
const bounds = new THREE.Box3().setFromObject(loadedRobot)
loadedRobot.userData.orbitSourceCenter = bounds.getCenter(new THREE.Vector3())
const size = bounds.getSize(new THREE.Vector3())
loadedRobot.userData.orbitSourceMaxDimension = Math.max(size.x, size.y, size.z, 1)
return loadedRobot
}
function configureUrdfForView(robot, compact) {
const scale = (compact ? 34 : 29) / robot.userData.orbitSourceMaxDimension
robot.position.copy(robot.userData.orbitSourceCenter).multiplyScalar(-scale)
robot.scale.setScalar(scale)
robot.updateMatrixWorld(true)
return robot
}
function loadUrdfOnce() {
if (cachedUrdfRobot) return Promise.resolve(cachedUrdfRobot)
if (cachedUrdfPromise) return cachedUrdfPromise
publishUrdfLoad('manufacturing', .05)
cachedUrdfPromise = new Promise((resolve, reject) => {
const manager = new THREE.LoadingManager()
const loader = new URDFLoader(manager)
let loadedRobot = null
let finalized = false
let managerComplete = false
loader.packages = { planetary_reducer_joint_module: '/project-assets/urdf' }
manager.onProgress = (_, loaded, total) => {
const ratio = total ? loaded / total : 0
publishUrdfLoad('generating', Math.min(.9, .14 + ratio * .74))
}
manager.onError = () => publishUrdfLoad('error', 0)
const finalize = () => {
if (finalized || !loadedRobot) return
finalized = true
publishUrdfLoad('assembling', .94)
// Yield one frame so the user can see the real kinematic assembly phase.
requestAnimationFrame(() => {
try {
cachedUrdfRobot = prepareUrdfRobot(loadedRobot)
publishUrdfLoad('ready', 1)
resolve(cachedUrdfRobot)
} catch (error) {
cachedUrdfPromise = null
publishUrdfLoad('error', 0)
reject(error)
}
})
}
manager.onLoad = () => {
managerComplete = true
finalize()
}
loader.load('/project-assets/urdf/joint_module.urdf', loaded => {
loadedRobot = loaded
publishUrdfLoad('generating', Math.max(urdfLoadSnapshot.progress, .16))
if (managerComplete) finalize()
}, undefined, error => {
cachedUrdfPromise = null
publishUrdfLoad('error', 0)
reject(error)
})
})
return cachedUrdfPromise
}
function ManufacturingLoader({ compact = false }) {
const { stage, progress } = useUrdfLoadSnapshot()
const copy = URDF_LOAD_STAGES[stage] || URDF_LOAD_STAGES.idle
const activeIndex = stage === 'manufacturing' ? 0 : stage === 'generating' ? 1 : stage === 'assembling' ? 2 : -1
return <Html center><div className={`manufacturing-loader ${compact ? 'compact' : ''}`}>
<div className="manufacturing-mark"><span/><span/><i/></div>
<div className="manufacturing-copy"><small>{copy.eyebrow}</small><b>{copy.title}</b><p>{copy.detail}</p></div>
<div className="manufacturing-progress"><i style={{ width: `${Math.max(5, progress * 100)}%` }}/></div>
<div className="manufacturing-stages">
{['制造数据', '生成网格', '拼装关节'].map((label, index) => <span key={label} className={index < activeIndex ? 'done' : index === activeIndex ? 'active' : ''}><i/>{label}</span>)}
</div>
</div></Html>
}
function UrdfRobot({ housingOpacity, housingVisible, coverVisible = true, compact = false, playing = true, selectedLink, onSelectLink }) {
const [robot, setRobot] = useState(() => cachedUrdfRobot ? configureUrdfForView(cachedUrdfRobot, compact) : null)
const phase = useRef(0)
useEffect(() => {
let active = true
if (cachedUrdfRobot) {
setRobot(configureUrdfForView(cachedUrdfRobot, compact))
return () => { active = false }
}
loadUrdfOnce().then(loaded => {
if (active) setRobot(configureUrdfForView(loaded, compact))
}).catch(() => {})
return () => { active = false }
}, [compact])
useEffect(() => {
if (!robot?.links?.housing_link) return
robot.links.housing_link.visible = housingVisible
robot.links.housing_link.traverse(node => {
if (!node.isMesh) return
node.material.transparent = housingOpacity < .999
node.material.opacity = housingOpacity
node.material.depthWrite = housingOpacity > .45
node.material.needsUpdate = true
})
}, [robot, housingOpacity, housingVisible])
useEffect(() => {
if (!robot?.links) return
// The visible blue cover is composed of the carrier body plus its front
// plate. Hide only their own meshes so child joints and planet motion stay alive.
for (const linkName of ['carrier_link', 'carrier_front_plate_link']) {
const link = robot.links[linkName]
if (!link) continue
link.visible = true
link.traverse(node => {
if (node.isMesh && node.userData.urdfLinkName === linkName) node.visible = coverVisible
})
}
}, [robot, coverVisible])
useEffect(() => {
if (!robot?.links) return
Object.entries(robot.links).forEach(([linkName, link]) => {
link.traverse(node => {
if (!node.isMesh || !node.material?.emissive) return
const active = linkName === selectedLink || (selectedLink === 'carrier_front_plate_link' && linkName === 'carrier_link')
node.material.emissive.set(active ? '#76a9b8' : '#000000')
node.material.emissiveIntensity = active ? .22 : 0
node.material.needsUpdate = true
})
})
}, [robot, selectedLink])
useFrame((_, dt) => {
if (!robot || !playing) return
phase.current += dt * 1.25
robot.setJointValue('sun_input_joint', phase.current)
robot.setJointValue('motor_rotor_joint', phase.current)
robot.setJointValue('carrier_output_joint', phase.current / 7.5)
const planetRelative = phase.current * -0.315151515
for (let i = 1; i <= 3; i++) robot.setJointValue(`planet_${i}_spin_joint`, planetRelative)
})
if (!robot) return <ManufacturingLoader compact={compact}/>
return <primitive object={robot} onPointerDown={event => {
if (compact || !onSelectLink) return
event.stopPropagation()
const linkName = event.object?.userData?.urdfLinkName
if (linkName) onSelectLink(['carrier_link', 'carrier_front_plate_link'].includes(linkName) ? 'carrier_front_plate_link' : linkName)
}}/>
}
function UrdfViewer({ housingOpacity = .28, housingVisible = true, coverVisible = true, compact = false, playing = true, selectedLink, onSelectLink }) {
return <Canvas camera={{ position: compact ? [5.6, 4.2, 7.2] : [5.2, 3.7, 6.5], fov: compact ? 38 : 34 }} shadows dpr={compact ? [1, 1.2] : [1, 1.65]}>
<color attach="background" args={['#e8edf0']}/>
<ambientLight intensity={1.55}/>
<hemisphereLight args={['#ffffff', '#b8c4ca', 1.6]}/>
<spotLight position={[4, 7, 5]} intensity={18} angle={.5} penumbra={1} color="#fff7e8" castShadow/>
<pointLight position={[-5, 1, -4]} intensity={7} color="#87bfc4"/>
<UrdfRobot compact={compact} playing={playing} coverVisible={coverVisible} selectedLink={selectedLink} onSelectLink={onSelectLink} housingOpacity={housingOpacity} housingVisible={housingVisible}/>
<Suspense fallback={null}><Environment preset="warehouse"/></Suspense>
<OrbitControls enablePan={false} enableZoom={!compact} enableRotate={!compact} autoRotate={compact} autoRotateSpeed={.35} minDistance={3.2} maxDistance={15}/>
</Canvas>
}
function ChatPanel({ onGenerate, generating }) {
const [input, setInput] = useState('设计一个输出扭矩 7 N·m、结构紧凑的机器人关节模组')
const [messages, setMessages] = useState([
{ role: 'ai', text: '你好,我是 Orbit 设计助手。告诉我目标扭矩、尺寸或速度偏好,我会调用行星减速器流程生成候选方案。' },
])
const send = () => {
if (!input.trim() || generating) return
setMessages(m => [...m, { role: 'user', text: input }, { role: 'ai', loading: true }])
onGenerate(input)
setTimeout(() => setMessages(m => [...m.filter(x => !x.loading), { role: 'ai', text: '已解析需求:7 N·m 输出,优先紧凑尺寸。我生成了 6 个构型候选;橙色方案使用项目验证过的 7.5:1 单级 2K-H 架构。请选择一个继续查看。' }]), 1850)
setInput('')
}
return <aside className="chat-panel">
<div className="brand"><div className="brand-mark"><OrbitIcon /></div><div><b>ORBIT</b><span>JOINT STUDIO</span></div></div>
<div className="chat-heading"><span className="live-dot"/>AI 设计会话 <button aria-label="收起"><PanelLeftClose size={17}/></button></div>
<div className="messages">
<div className="date-label">今天</div>
{messages.map((m, i) => <div className={`message ${m.role}`} key={i}>
{m.role === 'ai' && <div className="avatar"><Bot size={15}/></div>}
<div className="bubble">{m.loading ? <><LoaderCircle className="spin" size={15}/> 正在运行参数求解与构型组合</> : m.text}</div>
</div>)}
</div>
<div className="prompt-suggestions"><button onClick={() => setInput('更紧凑一些,外径小于 90 mm')}>更紧凑</button><button onClick={() => setInput('优先低噪音的斜齿方案')}>低噪音</button></div>
<div className="composer">
<textarea value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} placeholder="描述你想要的关节…" />
<div className="composer-foot"><span><WandSparkles size={14}/> 已连接项目求解器</span><button onClick={send} aria-label="发送"><ArrowUp size={18}/></button></div>
</div>
</aside>
}
function OrbitIcon() { return <div className="orbit-icon"><i/><i/><i/></div> }
function CandidateCard({ item, onSelect, show }) {
return <article className={`candidate ${show ? 'show' : ''}`} onClick={() => onSelect(item)} style={{ '--accent': item.color }}>
<div className="candidate-visual">
<div className="candidate-top"><span>DESIGN / {String(item.id).padStart(2, '0')}</span><em><Check size={11}/>{item.status}</em></div>
<div className="mini-view"><UrdfViewer compact housingOpacity={.18}/><div className="model-aura"/><div className="scan-line"/><span className="real-asset-badge">LIVE URDF</span></div>
<div className="view-caption"><Crosshair size={13}/><span>动态装配预览</span><b>30 LINKS · 29 JOINTS</b></div>
</div>
<div className="candidate-copy">
<span className="candidate-kicker">JR075 / PLANETARY JOINT</span>
<h2>紧凑型机器人<br/>关节模组</h2>
<p>单级 2K-H 行星传动真实电机外壳与减速器装配模型已通过项目几何及运动学验证</p>
<div className="candidate-specs">
<div><span>目标输出</span><b>{item.torque}<small>N·m</small></b></div>
<div><span>传动比</span><b>{item.ratio}<small>: 1</small></b></div>
<div><span>传动形式</span><b>{item.form}<small>{item.planets} 行星轮</small></b></div>
</div>
<button className="open-design">进入交互装配 <ChevronRight size={17}/></button>
</div>
</article>
}
function PartControls({ parts, setParts, selectedPart, setSelectedPart }) {
const current = parts[selectedPart]
const update = patch => setParts(p => ({ ...p, [selectedPart]: { ...p[selectedPart], ...patch } }))
return <div className="part-panel">
<div className="part-panel-title"><div><Layers3 size={16}/> 零件管理</div><span>6 PARTS</span></div>
<div className="part-list">
{Object.entries(parts).map(([key, part]) => <button key={key} className={selectedPart === key ? 'active' : ''} onClick={() => setSelectedPart(key)}>
<i style={{ background: part.color }}/><span>{part.label}</span><div onClick={e => { e.stopPropagation(); setParts(p => ({ ...p, [key]: { ...part, visible: !part.visible } })) }}>{part.visible ? <Eye size={15}/> : <EyeOff size={15}/>}</div>
</button>)}
</div>
<div className="opacity-control"><div><span>透明度</span><b>{Math.round(current.opacity * 100)}%</b></div><input type="range" min="0.04" max="1" step="0.01" value={current.opacity} onChange={e => update({ opacity: +e.target.value })}/></div>
<button className="isolate" onClick={() => setParts(p => Object.fromEntries(Object.entries(p).map(([k, v]) => [k, { ...v, visible: k === selectedPart }])))}><Box size={15}/> 单独显示所选零件</button>
<button className="restore" onClick={() => setParts(INITIAL_PARTS)}>恢复全部零件</button>
</div>
}
function DetailView({ item, onBack, project }) {
const [housingOpacity, setHousingOpacity] = useState(.28)
const [housingVisible, setHousingVisible] = useState(true)
const [coverVisible, setCoverVisible] = useState(true)
const [selectedLink, setSelectedLink] = useState(null)
const [playing, setPlaying] = useState(true)
return <section className="detail-view">
<div className="detail-toolbar">
<button className="back" onClick={onBack}><ArrowLeft size={18}/> 返回方案</button>
<div className="breadcrumb">JR075 <ChevronRight size={14}/> <b>交互装配</b></div>
<div className="detail-mode"><span className="active">装配</span><span>运动</span><span>检查</span></div>
<div className="validated"><ShieldCheck size={13}/> 项目验证通过</div>
</div>
<div className="detail-canvas"><UrdfViewer playing={playing} coverVisible={coverVisible} selectedLink={selectedLink} onSelectLink={setSelectedLink} housingOpacity={housingOpacity} housingVisible={housingVisible}/><div className="cad-grid"/><div className="detail-gradient"/></div>
<div className="view-tools">
<button aria-label="适应窗口"><Maximize2 size={16}/></button>
<button aria-label="旋转视图" className="active"><Rotate3D size={16}/></button>
<button aria-label="正交定位"><Crosshair size={16}/></button>
<i/>
<button aria-label="显示网格"><Grid3X3 size={16}/></button>
<button aria-label="测量"><Ruler size={16}/></button>
</div>
<div className="model-title"><span>JR075 / PLANETARY JOINT</span><h2>{item.torque} N·m <em>/</em> {item.ratio}:1</h2><p>真实 URDF 装配 · 拖动旋转 · 滚轮缩放</p></div>
<div className="housing-panel">
<div className="housing-title"><div><Layers3 size={16}/> 显示与结构</div><span>URDF / 30</span></div>
<div className="panel-section-label">外观控制</div>
<button className={`housing-toggle ${housingVisible ? 'on' : ''}`} onClick={() => setHousingVisible(v => !v)}><span>{housingVisible ? <Eye size={16}/> : <EyeOff size={16}/>} 外壳</span><i/></button>
<div className="opacity-control"><div><span>透明度</span><b>{Math.round(housingOpacity * 100)}%</b></div><input disabled={!housingVisible} type="range" min="0.03" max="1" step="0.01" value={housingOpacity} onChange={e => setHousingOpacity(+e.target.value)}/></div>
<button className={`part-visibility-toggle ${coverVisible ? 'on' : ''} ${selectedLink === 'carrier_front_plate_link' ? 'selected' : ''}`} onClick={() => { setSelectedLink('carrier_front_plate_link'); setCoverVisible(v => !v) }}>
<span><i className="part-color cover"/>{coverVisible ? <Eye size={15}/> : <EyeOff size={15}/>} 蓝色输出盖板</span><b>{coverVisible ? '显示' : '隐藏'}</b>
</button>
<div className="pick-hint"><Crosshair size={13}/><span>{selectedLink === 'carrier_front_plate_link' ? '已选中输出盖板,可切换显示状态' : '点击模型中的零件可进行选择'}</span></div>
<div className="panel-section-label structure-label">装配结构</div>
<div className="structure-list">
<div><i className="part-color motor"/><span>定子与线圈</span><b>FIXED</b></div>
<div><i className="part-color rotor"/><span>转子与电机轴</span><b>1 : 1</b></div>
<div><i className="part-color gear"/><span>行星传动组</span><b>MIMIC</b></div>
<div className={selectedLink === 'carrier_front_plate_link' ? 'active' : ''} onClick={() => setSelectedLink('carrier_front_plate_link')}><i className="part-color cover"/><span>输出盖板</span><button aria-label={coverVisible ? '隐藏输出盖板' : '显示输出盖板'} onClick={event => { event.stopPropagation(); setCoverVisible(v => !v) }}>{coverVisible ? <Eye size={13}/> : <EyeOff size={13}/>}</button></div>
<div><i className="part-color output"/><span>输出行星架</span><b>1 / 7.5</b></div>
</div>
<div className="panel-foot"><span className="live-dot"/>运动约束已同步</div>
</div>
<button className="motion-control" onClick={() => setPlaying(v => !v)}>{playing ? <CirclePause size={18}/> : <CirclePlay size={18}/>}<span>{playing ? '暂停传动' : '继续传动'}</span></button>
<div className="telemetry">
<div><CircleGauge size={17}/><span>传动比<b>{project?.ratio || item.ratio}:1</b></span></div>
<div><Rotate3D size={17}/><span>太阳轮<b>{project?.parameters?.z_sun || 20} T</b></span></div>
<div><SlidersHorizontal size={17}/><span>行星轮<b>{project?.parameters?.planet_count || item.planets} × {project?.parameters?.z_planet || 55} T</b></span></div>
</div>
</section>
}
export default function App() {
const [generated, setGenerated] = useState(true)
const [generating, setGenerating] = useState(false)
const [selected, setSelected] = useState(null)
const [project, setProject] = useState(null)
useEffect(() => { fetch('/api/project-summary').then(r => r.json()).then(d => !d.error && setProject(d)).catch(() => {}) }, [])
const generate = () => {
setGenerating(true); setGenerated(false); setSelected(null)
setTimeout(() => { setGenerated(true); setGenerating(false) }, 1700)
}
return <main className="app-shell">
<ChatPanel onGenerate={generate} generating={generating}/>
<div className="workspace">
<header className="topbar"><div className="project-id"><span>PROJECT / 01</span><b>7N·m 机器人关节模组</b></div><nav><span className="active">设计空间</span><span>装配数据</span><span>验证报告</span></nav><div className="top-actions"><span className="source-pill"><span/>LIVE PROJECT DATA</span><button aria-label="界面设置"><SlidersHorizontal size={16}/></button><div className="account-avatar">J</div></div></header>
<AnimatePresence mode="wait" initial={false}>{selected ? <motion.div key="detail" className="route-layer" initial={{ opacity: 0, scale: .965 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 1.025 }} transition={{ duration: .52, ease: [.22, 1, .36, 1] }}><DetailView item={selected} onBack={() => setSelected(null)} project={project}/></motion.div> : <motion.section key="gallery" className="gallery route-layer" initial={{ opacity: 0, y: 18 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: .97 }} transition={{ duration: .48, ease: [.22, 1, .36, 1] }}>
<div className="gallery-heading"><div><span><SparklesIcon size={15}/> GENERATED ASSET / RUN 01</span><h1>从参数到真实装配</h1><p>本页直接加载项目生成并验证通过的 URDF不使用静态替代模型</p></div><div className="gallery-status"><span><Check size={13}/> GENERATION COMPLETE</span><b>01</b><small>有效方案</small></div></div>
{generating && <div className="generating"><div className="generator-orb"><OrbitIcon/><span/><span/></div><b>正在展开设计空间</b><p>枚举齿数模数行星数量与外壳包络</p><div className="progress"><i/></div></div>}
<div className={`candidate-grid ${generating ? 'muted' : ''}`}>
{CANDIDATES.map((item, i) => <CandidateCard key={item.id} item={item} onSelect={setSelected} show={generated} />)}
</div>
<div className="data-note"><span/>真实数据读取自 <b>frontend_split_motor_7nm</b> · 减速器 {project?.reducerValidation?.passed || 260}/{project?.reducerValidation?.total || 260} 验证通过</div>
</motion.section>}</AnimatePresence>
</div>
</main>
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>,
)
+59
View File
@@ -0,0 +1,59 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap');
:root { font-family: Manrope, system-ui, sans-serif; color: #e9edf1; background: #090b0e; font-synthesis: none; --line: rgba(255,255,255,.085); --muted: #7e8791; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 1180px; min-height: 100vh; overflow: hidden; }
button, textarea { font: inherit; }
button { color: inherit; }
.app-shell { height: 100vh; display: grid; grid-template-columns: 340px 1fr; background: #0b0e12; }
.chat-panel { background: #0d1014; border-right: 1px solid var(--line); display: flex; flex-direction: column; position: relative; z-index: 20; }
.brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 22px; border-bottom: 1px solid var(--line); }
.brand-mark { width: 35px; height: 35px; border: 1px solid #f1a64b55; display: grid; place-items: center; border-radius: 9px; background: #f1a64b0c; }
.brand b { display:block; font-size: 15px; letter-spacing: .2em; }.brand span { display:block; font: 9px DM Mono; letter-spacing: .22em; color:#787f87; margin-top:3px; }
.orbit-icon { width: 24px; height: 24px; position: relative; display: grid; place-items: center; }.orbit-icon:after { content:''; width:6px;height:6px;border-radius:50%;background:#f1a64b;box-shadow:0 0 14px #f1a64b; }
.orbit-icon i { position:absolute; width:22px;height:8px;border:1px solid #f1a64b;border-radius:50%;transform:rotate(0deg); }.orbit-icon i:nth-child(2){transform:rotate(60deg)}.orbit-icon i:nth-child(3){transform:rotate(-60deg)}
.chat-heading { height: 56px; padding: 0 20px; border-bottom:1px solid var(--line); display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.04em; }.chat-heading button{margin-left:auto;background:none;border:0;color:#6e7780;cursor:pointer}.live-dot{width:6px;height:6px;border-radius:50%;background:#68d391;box-shadow:0 0 9px #68d391}
.messages { flex:1; overflow-y:auto; padding:18px 17px; scrollbar-width:none; }.date-label{text-align:center;font:9px DM Mono;color:#59616a;margin-bottom:18px}.message{display:flex;gap:8px;margin:0 0 16px;align-items:flex-start}.message.user{justify-content:flex-end}.avatar{width:25px;height:25px;border:1px solid #f1a64b44;border-radius:7px;display:grid;place-items:center;color:#f1a64b;flex:none}.bubble{max-width:250px;padding:11px 13px;border:1px solid var(--line);border-radius:4px 13px 13px 13px;background:#14181d;color:#b7bec6;font-size:12px;line-height:1.65}.user .bubble{background:#e6973f;color:#16110a;border-color:#e6973f;border-radius:13px 4px 13px 13px;font-weight:600}.spin{animation:spin 1s linear infinite;vertical-align:middle;margin-right:4px}@keyframes spin{to{transform:rotate(360deg)}}
.prompt-suggestions{display:flex;gap:6px;padding:0 17px 9px}.prompt-suggestions button{border:1px solid var(--line);background:#12161a;border-radius:20px;padding:6px 10px;font-size:10px;color:#858e98;cursor:pointer}.prompt-suggestions button:hover{border-color:#f1a64b66;color:#f1a64b}
.composer{margin:0 15px 16px;border:1px solid #ffffff18;background:#12161a;border-radius:13px;padding:11px;box-shadow:0 14px 40px #0005}.composer:focus-within{border-color:#f1a64b66}.composer textarea{width:100%;height:61px;resize:none;border:0;outline:0;background:transparent;color:#e5e9ed;font-size:12px;line-height:1.5}.composer-foot{display:flex;align-items:center;justify-content:space-between}.composer-foot span{display:flex;align-items:center;gap:5px;color:#68717b;font-size:9px}.composer-foot button{border:0;width:30px;height:30px;border-radius:8px;background:#efa246;color:#17100a;display:grid;place-items:center;cursor:pointer;transition:.2s}.composer-foot button:hover{transform:translateY(-2px);box-shadow:0 8px 20px #efa24644}
.workspace{min-width:0;background:#0b0e12}.topbar{height:78px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 28px;background:#0c0f13}.topbar>div:first-child{display:flex;align-items:center;gap:12px}.topbar>div:first-child span{font:9px DM Mono;color:#59616a;letter-spacing:.15em}.topbar>div:first-child b{font-size:13px}.top-actions{display:flex;align-items:center;gap:10px}.source-pill{font:9px DM Mono;color:#7c858f;border:1px solid var(--line);padding:7px 10px;border-radius:20px}.source-pill span{display:inline-block;width:5px;height:5px;border-radius:50%;background:#69d18f;margin-right:7px}.top-actions button,.account-avatar{width:32px;height:32px;border:1px solid var(--line);background:#12161a;border-radius:8px;display:grid;place-items:center}.account-avatar{background:#ddd;color:#17191b;font-size:11px;font-weight:800}
.route-layer{height:calc(100vh - 78px)}.gallery{height:calc(100vh - 78px);overflow:auto;padding:38px 40px 28px;position:relative;background-image:linear-gradient(#ffffff07 1px,transparent 1px),linear-gradient(90deg,#ffffff07 1px,transparent 1px);background-size:40px 40px}.gallery:before{content:'';position:fixed;inset:78px 0 0 340px;pointer-events:none;background:radial-gradient(circle at 60% 30%,#f1a64b0a,transparent 37%)}
.gallery-heading{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:26px;position:relative}.gallery-heading>div>span{display:flex;align-items:center;gap:7px;font:9px DM Mono;color:#e8a34d;letter-spacing:.15em}.gallery-heading h1{font-size:28px;letter-spacing:-.04em;margin:10px 0 4px}.gallery-heading p{color:#6e7781;font-size:11px;margin:0}.result-count{text-align:right;border-left:1px solid var(--line);padding-left:24px}.result-count b{display:block;font:26px DM Mono;color:#d5d9de}.result-count span{font-size:9px;color:#656e77}
.candidate-grid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:14px;transition:.4s}.candidate-grid.muted{opacity:.1;filter:blur(5px)}.candidate{height:303px;border:1px solid var(--line);background:#101419;position:relative;overflow:hidden;cursor:pointer;opacity:0;transform:translateY(25px) scale(.97);transition:opacity .65s,transform .65s,border-color .25s,box-shadow .25s}.candidate.show{opacity:1;transform:none}.candidate:nth-child(2){transition-delay:.06s}.candidate:nth-child(3){transition-delay:.12s}.candidate:nth-child(4){transition-delay:.18s}.candidate:nth-child(5){transition-delay:.24s}.candidate:nth-child(6){transition-delay:.3s}.candidate:hover{border-color:color-mix(in srgb,var(--accent) 55%,transparent);transform:translateY(-4px);box-shadow:0 18px 50px #0007}.candidate-top{height:34px;position:absolute;z-index:5;top:0;left:0;right:0;display:flex;justify-content:space-between;padding:11px 12px;font:9px DM Mono;color:#5c646d}.candidate-top em{font-style:normal;color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);background:color-mix(in srgb,var(--accent) 8%,transparent);padding:3px 7px;border-radius:10px}.mini-view{height:190px;position:relative}.mini-view canvas{transition:transform .6s}.candidate:hover canvas{transform:scale(1.08)}.scan-line{position:absolute;left:12%;right:12%;bottom:10px;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.4}.candidate-info{height:57px;padding:11px 14px;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line)}.candidate-info b{display:block;font:16px DM Mono}.candidate-info span{font-size:9px;color:#69727c}.candidate-info svg{color:var(--accent)}.candidate-metrics{height:46px;display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}.candidate-metrics span{font:11px DM Mono;display:flex;align-items:center;justify-content:center;gap:3px;border-right:1px solid var(--line)}.candidate-metrics span:last-child{border:0}.candidate-metrics small{font-size:8px;color:#59616a}.data-note{margin-top:15px;color:#5c6570;font:9px DM Mono;display:flex;align-items:center;gap:7px}.data-note>span{width:5px;height:5px;background:#67ce8b;border-radius:50%}.data-note b{color:#838c96}.generating{position:absolute;z-index:20;inset:170px 0 0;display:flex;flex-direction:column;align-items:center;padding-top:100px}.generating b{font-size:15px;margin-top:25px}.generating p{font-size:10px;color:#666f78}.generator-orb{width:84px;height:84px;display:grid;place-items:center;position:relative}.generator-orb .orbit-icon{transform:scale(1.8)}.generator-orb>span{position:absolute;inset:0;border:1px solid #f1a64b33;border-radius:50%;animation:pulse 1.5s ease-out infinite}.generator-orb>span:nth-child(3){animation-delay:.6s}@keyframes pulse{to{transform:scale(1.7);opacity:0}}.progress{width:220px;height:2px;background:#ffffff0b;margin-top:15px;overflow:hidden}.progress i{display:block;height:100%;width:50%;background:#f1a64b;animation:progress 1.3s ease-in-out infinite}@keyframes progress{from{transform:translateX(-100%)}to{transform:translateX(220%)}}
.detail-view{height:calc(100vh - 78px);position:relative;overflow:hidden;background:#11151a;animation:detailIn .75s cubic-bezier(.2,.8,.2,1)}@keyframes detailIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}.detail-toolbar{height:54px;position:absolute;z-index:10;left:0;right:0;top:0;display:flex;align-items:center;padding:0 20px;border-bottom:1px solid var(--line);background:#0d1014dd;backdrop-filter:blur(14px)}.back{background:transparent;border:0;display:flex;align-items:center;gap:8px;font-size:11px;cursor:pointer}.back:hover{color:#f1a64b}.breadcrumb{margin-left:24px;padding-left:24px;border-left:1px solid var(--line);display:flex;align-items:center;gap:7px;font:9px DM Mono;color:#606974}.breadcrumb b{color:#abb2ba}.validated{margin-left:auto;color:#6dd596;background:#6dd5960e;border:1px solid #6dd59633;border-radius:20px;padding:6px 9px;font:9px DM Mono;display:flex;gap:5px;align-items:center}.detail-canvas{position:absolute;inset:0}.detail-gradient{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle at 50% 46%,transparent 20%,#11151a00 43%,#11151ae5 100%),linear-gradient(90deg,#11151a 0%,transparent 18%,transparent 77%,#11151a 100%)}.model-title{position:absolute;left:30px;bottom:36px}.model-title>span{font:9px DM Mono;color:#f1a64b;letter-spacing:.18em}.model-title h2{font-size:30px;margin:6px 0 3px;letter-spacing:-.04em}.model-title h2 em{font-style:normal;color:#464e57;font-weight:400}.model-title p{margin:0;color:#68717b;font-size:9px}.part-panel{position:absolute;z-index:10;right:20px;top:74px;width:244px;background:#0c1014e8;border:1px solid var(--line);backdrop-filter:blur(16px);border-radius:12px;overflow:hidden;box-shadow:0 22px 60px #0008}.part-panel-title{height:43px;padding:0 13px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.part-panel-title>div{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:600}.part-panel-title>span{font:8px DM Mono;color:#59616a}.part-list{padding:6px}.part-list button{width:100%;height:37px;border:1px solid transparent;background:transparent;display:flex;align-items:center;gap:8px;padding:0 8px;border-radius:7px;cursor:pointer;color:#8c959e;font-size:10px}.part-list button:hover,.part-list button.active{background:#ffffff08;color:#e8ecef}.part-list button.active{border-color:#ffffff0d}.part-list button>i{width:8px;height:8px;border-radius:2px}.part-list button>span{flex:1;text-align:left}.part-list button>div{padding:5px;color:#606a74}.opacity-control{border-top:1px solid var(--line);padding:12px 13px}.opacity-control>div{display:flex;justify-content:space-between;font-size:9px;color:#7a838d}.opacity-control b{font:9px DM Mono;color:#c7cdd3}.opacity-control input{width:100%;accent-color:#f1a64b;height:3px;margin-top:11px}.isolate,.restore{margin:0 10px 8px;width:calc(100% - 20px);height:32px;border:1px solid var(--line);border-radius:7px;background:#14191e;font-size:9px;display:flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}.restore{background:transparent;color:#68727c}.isolate:hover{border-color:#f1a64b55}.telemetry{position:absolute;bottom:28px;left:50%;transform:translateX(-50%);display:flex;border:1px solid var(--line);background:#0d1116dd;backdrop-filter:blur(12px);border-radius:10px}.telemetry>div{width:128px;height:48px;display:flex;align-items:center;gap:8px;padding:0 12px;border-right:1px solid var(--line);color:#f1a64b}.telemetry>div:last-child{border:0}.telemetry span{font-size:8px;color:#66707a}.telemetry b{display:block;color:#d8dde2;font:10px DM Mono;margin-top:2px}
.route-layer .detail-view{height:100%}.housing-panel{position:absolute;z-index:10;right:20px;top:74px;width:244px;background:#0c1014e8;border:1px solid var(--line);backdrop-filter:blur(16px);border-radius:12px;overflow:hidden;box-shadow:0 22px 60px #0008}.housing-title{height:43px;padding:0 13px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.housing-title>div{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:600}.housing-title>span{font:8px DM Mono;color:#59616a}.housing-toggle{width:calc(100% - 20px);height:42px;margin:10px;border:1px solid var(--line);background:#12171c;border-radius:8px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;cursor:pointer}.housing-toggle span{display:flex;align-items:center;gap:8px;font-size:10px}.housing-toggle i{width:29px;height:16px;border-radius:12px;background:#303740;position:relative;transition:.3s}.housing-toggle i:after{content:'';position:absolute;width:12px;height:12px;left:2px;top:2px;background:#76808b;border-radius:50%;transition:.3s}.housing-toggle.on i{background:#f1a64b55}.housing-toggle.on i:after{left:15px;background:#f1a64b}.housing-panel p{margin:0;padding:0 13px 13px;color:#626b75;font-size:9px;line-height:1.6}.housing-panel input:disabled{opacity:.25}
@media(max-width:1300px){.candidate-grid{grid-template-columns:repeat(2,1fr)}.candidate{height:290px}.mini-view{height:177px}}
/* Light industrial design theme */
:root{color:#28343d;background:#dfe6e9;--line:rgba(45,67,78,.13);--muted:#73818a}
body{background:#dfe6e9}.app-shell{background:#e6ecef}.chat-panel{background:#f7f8f7;border-color:#cfd8dc}.brand{background:#fbfbfa}.brand-mark{background:#e9f2f2;border-color:#8db6ba66}.brand b{color:#24333b}.brand span{color:#849198}.orbit-icon:after{background:#2b8d91;box-shadow:0 0 12px #2b8d9166}.orbit-icon i{border-color:#2b8d91}.chat-heading{color:#33434b;background:#f4f6f6}.chat-heading button{color:#829097}.live-dot{background:#45a77a;box-shadow:0 0 8px #45a77a66}.date-label{color:#9aa5aa}.bubble{background:#fff;border-color:#d8e0e3;color:#596870;box-shadow:0 5px 18px #40545e0b}.message.user .bubble{background:#2b8d91;border-color:#2b8d91;color:#fff}.avatar{border-color:#2b8d9155;color:#2b8d91;background:#edf6f5}.prompt-suggestions button{background:#f0f4f4;color:#718087;border-color:#d8e0e2}.prompt-suggestions button:hover{border-color:#2b8d9188;color:#217b7f}.composer{background:#fff;border-color:#d3dcdf;box-shadow:0 12px 34px #536b7615}.composer textarea{color:#35444b}.composer-foot span{color:#8b989e}.composer-foot button{background:#2b8d91;color:#fff}.composer-foot button:hover{box-shadow:0 8px 20px #2b8d9140}.workspace{background:#e6ecef}.topbar{background:#f9faf9;border-color:#ced8dc}.topbar>div:first-child span{color:#89969c}.topbar>div:first-child b{color:#304048}.source-pill{background:#f0f6f4;color:#5b7770;border-color:#cfe0da}.source-pill span{background:#46a978}.top-actions button{background:#eef2f3;color:#53636b}.account-avatar{background:#2b8d91;color:#fff;border-color:#2b8d91}.gallery{background-color:#e8edef;background-image:linear-gradient(#6d838c0c 1px,transparent 1px),linear-gradient(90deg,#6d838c0c 1px,transparent 1px)}.gallery:before{background:radial-gradient(circle at 62% 28%,#5aaeb516,transparent 39%)}.gallery-heading>div>span{color:#2b8d91}.gallery-heading h1{color:#26353d}.gallery-heading p{color:#78868d}.result-count b{color:#33434b}.result-count span{color:#8b979d}.candidate{background:#f8faf9;border-color:#ccd7db;box-shadow:0 7px 24px #62757e12}.candidate:hover{box-shadow:0 18px 45px #586d7625}.candidate-top{color:#9ba6ab}.candidate-info{background:#fbfcfb}.candidate-info b{color:#304047}.candidate-info span{color:#7d8a90}.candidate-metrics{background:#f2f6f6}.candidate-metrics span{color:#405159}.candidate-metrics small{color:#8b989e}.data-note{color:#839097}.data-note b{color:#596a72}.detail-view{background:#e8edf0}.detail-toolbar{background:#f9faf9e8;border-color:#ced8dc}.back{color:#41525a}.back:hover{color:#2b8d91}.breadcrumb{color:#8a979d}.breadcrumb b{color:#4b5d65}.validated{color:#27835e;background:#e9f6ef;border-color:#77bd9c66}.detail-gradient{background:radial-gradient(circle at 50% 46%,transparent 24%,#e8edf000 55%,#dfe7eab8 100%),linear-gradient(90deg,#e4eaed88 0%,transparent 20%,transparent 78%,#e4eaed99 100%)}.model-title>span{color:#2b8d91}.model-title h2{color:#26353d}.model-title h2 em{color:#9aa6ab}.model-title p{color:#74838a}.housing-panel{background:#fafbfaf0;border-color:#ccd7db;box-shadow:0 22px 55px #586d7628}.housing-title{color:#34464e}.housing-title>span{color:#8b989e}.housing-toggle{background:#f1f5f5;border-color:#d4dde0;color:#45575f}.housing-toggle i{background:#bac5ca}.housing-toggle.on i{background:#2b8d9140}.housing-toggle.on i:after{background:#2b8d91}.opacity-control{border-color:#d4dde0}.opacity-control>div{color:#7b898f}.opacity-control b{color:#42545c}.opacity-control input{accent-color:#2b8d91}.housing-panel p{color:#7b898f}.telemetry{background:#fafbfaf0;border-color:#ccd7db;box-shadow:0 12px 34px #536b7618}.telemetry>div{color:#2b8d91}.telemetry span{color:#7b898f}.telemetry b{color:#34464e}.generating b{color:#34464e}.generating p{color:#7c898f}.progress{background:#aebbc344}.progress i{background:#2b8d91}
.real-asset-badge{position:absolute;right:12px;bottom:10px;z-index:3;padding:5px 8px;border:1px solid rgba(77,150,154,.24);border-radius:999px;background:rgba(248,252,251,.82);color:#397d80;font:600 9px/1 Manrope,system-ui,sans-serif;letter-spacing:.13em;backdrop-filter:blur(10px)}
.candidate-grid{grid-template-columns:minmax(380px,540px)}
/* Refined industrial workspace */
:root{--ink:#21323a;--sub:#718189;--paper:#f8faf9;--canvas:#e9eef0;--teal:#347f82;--teal-soft:#e1efed;--hair:rgba(43,66,76,.14)}
.app-shell{grid-template-columns:318px 1fr}.chat-panel{box-shadow:12px 0 34px rgba(48,69,78,.035)}
.brand{height:70px;padding:0 24px}.brand-mark{width:36px;height:36px;border-radius:11px}.brand b{font-size:14px}.chat-heading{height:52px;padding:0 22px}.messages{padding:22px 18px}.bubble{border-radius:5px 15px 15px 15px;line-height:1.72}.user .bubble{border-radius:15px 5px 15px 15px}.composer{margin:0 16px 18px;border-radius:16px;padding:13px}.composer-foot button{border-radius:10px}
.topbar{height:70px;padding:0 24px}.project-id{display:flex;align-items:center;gap:14px}.topbar nav{position:absolute;left:50%;transform:translateX(-50%);height:70px;display:flex;align-items:center;gap:26px}.topbar nav span{height:70px;display:flex;align-items:center;position:relative;color:#87949a;font-size:10px;cursor:default}.topbar nav span.active{color:#31444c;font-weight:650}.topbar nav span.active:after{content:'';position:absolute;left:0;right:0;bottom:0;height:2px;background:#347f82}.top-actions button{border-radius:10px}.source-pill{letter-spacing:.08em}
.route-layer,.gallery,.detail-view{height:calc(100vh - 70px)}.gallery{padding:34px 38px 24px;background-size:48px 48px}.gallery:before{inset:70px 0 0 318px}.gallery-heading{align-items:flex-start;margin-bottom:26px}.gallery-heading h1{font-size:32px;font-weight:650;margin:9px 0 7px}.gallery-heading p{font-size:11px;max-width:600px}.gallery-status{display:grid!important;grid-template-columns:auto auto;align-items:end;column-gap:9px;text-align:right}.gallery-status>span{grid-column:1/3;justify-self:end!important;padding:6px 9px;border:1px solid #83b9aa66;border-radius:999px;background:#eff7f3;color:#45816f!important;font-size:8px!important;letter-spacing:.09em!important}.gallery-status>b{font:30px DM Mono;color:#2d424b}.gallery-status>small{font-size:9px;color:#849198;padding-bottom:5px}
.candidate-grid{display:block}.candidate{width:min(920px,calc(100vw - 400px));height:410px;display:grid;grid-template-columns:minmax(430px,1.25fr) minmax(300px,.75fr);border-radius:18px;background:rgba(250,252,251,.9);box-shadow:0 18px 55px rgba(61,80,89,.12);border-color:rgba(69,92,101,.16);overflow:hidden}.candidate:hover{transform:translateY(-5px);box-shadow:0 24px 70px rgba(54,75,84,.18)}.candidate-visual{position:relative;min-width:0;background:#e6ecee;border-right:1px solid var(--hair)}.candidate-top{height:48px;padding:16px 18px;position:absolute}.candidate-top em{display:flex;align-items:center;gap:5px;padding:5px 9px;background:rgba(244,250,248,.8);font-size:8px;letter-spacing:.06em}.mini-view{height:100%}.candidate:hover canvas{transform:scale(1.025)}.model-aura{position:absolute;inset:14% 18%;border:1px solid rgba(73,114,121,.1);border-radius:50%;pointer-events:none}.model-aura:after,.model-aura:before{content:'';position:absolute;inset:12%;border:1px solid rgba(73,114,121,.08);border-radius:50%}.model-aura:after{inset:29%}.scan-line{left:24%;right:24%;bottom:50px}.real-asset-badge{right:18px;bottom:18px}.view-caption{position:absolute;left:18px;right:18px;bottom:17px;display:flex;align-items:center;gap:7px;color:#6f8087;font-size:9px;pointer-events:none}.view-caption b{margin-left:auto;font:8px DM Mono;color:#87959b;letter-spacing:.06em}.candidate-copy{padding:32px 32px 26px;display:flex;flex-direction:column;background:rgba(251,252,251,.96)}.candidate-kicker{font:9px DM Mono;color:#347f82;letter-spacing:.12em}.candidate-copy h2{margin:15px 0 12px;font-size:27px;line-height:1.28;letter-spacing:-.04em;color:#263941}.candidate-copy>p{font-size:11px;line-height:1.75;color:#738189;margin:0}.candidate-specs{display:grid;grid-template-columns:repeat(3,1fr);margin-top:auto;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair)}.candidate-specs>div{padding:15px 10px 14px 0;border-right:1px solid var(--hair)}.candidate-specs>div+div{padding-left:12px}.candidate-specs>div:last-child{border:0}.candidate-specs span{display:block;font-size:8px;color:#8a979d;margin-bottom:6px}.candidate-specs b{font:17px DM Mono;color:#33474f}.candidate-specs small{display:block;font:8px Manrope;color:#819097;margin-top:3px}.open-design{height:40px;margin-top:17px;border:0;border-radius:9px;background:#347f82;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 14px;font-size:10px;font-weight:650;cursor:pointer;transition:.25s}.open-design:hover{background:#286f72;transform:translateX(2px)}.data-note{margin-top:16px}
.detail-toolbar{height:52px;padding:0 18px}.breadcrumb{margin-left:18px;padding-left:18px}.detail-mode{position:absolute;left:50%;transform:translateX(-50%);height:52px;display:flex;align-items:center;gap:6px}.detail-mode span{padding:7px 12px;border-radius:7px;color:#87949a;font-size:9px}.detail-mode span.active{background:#e6efef;color:#347f82;font-weight:650}.detail-canvas{top:52px}.cad-grid{position:absolute;inset:0;pointer-events:none;opacity:.34;background-image:linear-gradient(rgba(72,99,109,.07) 1px,transparent 1px),linear-gradient(90deg,rgba(72,99,109,.07) 1px,transparent 1px);background-size:46px 46px;mask-image:radial-gradient(circle at 50% 45%,transparent 18%,#000 77%)}.view-tools{position:absolute;z-index:12;top:70px;left:18px;display:flex;gap:4px;padding:5px;border:1px solid var(--hair);border-radius:11px;background:rgba(250,252,251,.86);box-shadow:0 12px 28px rgba(52,72,81,.12);backdrop-filter:blur(14px)}.view-tools button{width:32px;height:32px;border:0;border-radius:7px;background:transparent;color:#75858c;display:grid;place-items:center;cursor:pointer}.view-tools button:hover,.view-tools button.active{background:#e1ecec;color:#347f82}.view-tools i{width:1px;height:22px;align-self:center;background:var(--hair);margin:0 2px}.housing-panel{top:70px;right:18px;width:252px;border-radius:14px}.housing-title{height:48px;padding:0 14px}.panel-section-label{padding:13px 14px 0;color:#8b989e;font-size:8px;letter-spacing:.08em;text-transform:uppercase}.housing-toggle{margin:9px 10px 7px}.opacity-control{padding:11px 14px 14px}.structure-label{border-top:1px solid var(--hair);padding-top:13px}.structure-list{padding:7px 10px 10px}.structure-list>div{height:34px;display:flex;align-items:center;gap:8px;padding:0 7px;border-radius:7px;color:#53666e;font-size:9px}.structure-list>div:hover{background:#edf3f3}.structure-list b{margin-left:auto;font:7px DM Mono;color:#93a0a5}.part-color{width:7px;height:7px;border-radius:2px}.part-color.motor{background:#bd7568}.part-color.gear{background:#d4b35d}.part-color.output{background:#648eae}.panel-foot{height:38px;border-top:1px solid var(--hair);display:flex;align-items:center;gap:7px;padding:0 14px;color:#718188;font-size:8px}.panel-foot .live-dot{width:5px;height:5px}.motion-control{position:absolute;z-index:12;left:18px;bottom:112px;border:1px solid var(--hair);border-radius:10px;background:rgba(250,252,251,.88);color:#43575f;height:38px;padding:0 13px;display:flex;align-items:center;gap:8px;box-shadow:0 10px 30px rgba(52,72,81,.11);backdrop-filter:blur(12px);cursor:pointer}.motion-control span{font-size:9px}.motion-control:hover{color:#347f82}.model-title{left:20px;bottom:28px}.model-title h2{font-size:28px}.telemetry{bottom:24px}.telemetry>div{width:134px;height:50px}.validated{letter-spacing:.03em}
@media(max-width:1300px){.topbar nav{display:none}.candidate{width:100%;grid-template-columns:1.15fr .85fr}.candidate-grid{display:block}.candidate{height:390px}.mini-view{height:100%}}
.part-visibility-toggle{width:calc(100% - 20px);height:39px;margin:0 10px 10px;padding:0 10px;border:1px solid var(--hair);border-radius:8px;background:#f5f8f7;color:#51656d;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:.2s}.part-visibility-toggle:hover,.part-visibility-toggle.selected{border-color:rgba(100,142,174,.45);background:#edf3f6}.part-visibility-toggle>span{display:flex;align-items:center;gap:7px;font-size:9px}.part-visibility-toggle>b{font:8px DM Mono;color:#86949a}.part-visibility-toggle:not(.on){opacity:.68}.part-color.cover{background:#7fa5bd}.pick-hint{margin:0 10px 12px;padding:8px 9px;border-radius:7px;background:#eef3f3;color:#718189;display:flex;align-items:flex-start;gap:7px;font-size:8px;line-height:1.45}.pick-hint svg{flex:none;color:#4d8a8d}.structure-list>div{cursor:default}.structure-list>div.active{background:#e7f0f2;color:#365b69}.structure-list button{margin-left:auto;width:25px;height:25px;border:0;border-radius:6px;background:transparent;color:#788990;display:grid;place-items:center;cursor:pointer}.structure-list button:hover{background:#dbe9ec;color:#347f82}
.message{width:100%;min-width:0}.message.user{width:100%;height:auto;min-height:0;border:0;background:transparent;border-radius:0;display:flex;place-items:initial}.message .bubble{min-width:0;max-width:248px;overflow-wrap:anywhere;word-break:break-word;white-space:pre-wrap}.message.user .bubble{width:auto;height:auto;max-width:248px;display:block}.messages{min-width:0;overflow-x:hidden}
.part-color.rotor{background:#7897a4}
/* Real URDF pipeline status — shown only during the first parse/assembly. */
.manufacturing-loader{width:310px;padding:20px 21px 17px;border:1px solid rgba(73,112,120,.2);border-radius:16px;background:rgba(249,252,251,.92);box-shadow:0 22px 70px rgba(63,82,90,.18);backdrop-filter:blur(18px);color:#2f424a;display:grid;grid-template-columns:46px 1fr;column-gap:14px;white-space:nowrap;overflow:hidden}
.manufacturing-loader.compact{width:276px;padding:16px 17px 14px;transform:scale(.86)}
.manufacturing-mark{width:46px;height:46px;position:relative;display:grid;place-items:center;border-radius:13px;background:linear-gradient(145deg,#e3efed,#f8fbfa);box-shadow:inset 0 0 0 1px rgba(52,127,130,.15)}
.manufacturing-mark span{position:absolute;width:26px;height:26px;border:1.5px solid #347f82;border-radius:50%;border-right-color:transparent;animation:manufacturing-turn 1.75s cubic-bezier(.55,.15,.45,.85) infinite}
.manufacturing-mark span:nth-child(2){width:17px;height:17px;border-color:#87aeb1 transparent #87aeb1 #87aeb1;animation-direction:reverse;animation-duration:1.15s}
.manufacturing-mark i{width:5px;height:5px;border-radius:50%;background:#347f82;box-shadow:0 0 0 4px rgba(52,127,130,.11)}
.manufacturing-copy{min-width:0;padding-top:1px}.manufacturing-copy small{display:block;color:#4c8a8d;font:500 8px DM Mono;letter-spacing:.14em}.manufacturing-copy b{display:block;margin-top:6px;color:#2d4149;font-size:13px;letter-spacing:.01em}.manufacturing-copy p{margin:3px 0 0;color:#7c8a90;font-size:9px;line-height:1.5}
.manufacturing-progress{grid-column:1/3;height:3px;margin-top:16px;border-radius:999px;background:#dfe8e8;overflow:hidden}.manufacturing-progress i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#6ea6a8,#2f8588);transition:width .28s ease}
.manufacturing-stages{grid-column:1/3;display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:11px}.manufacturing-stages span{display:flex;align-items:center;gap:5px;color:#9aa5aa;font:8px DM Mono}.manufacturing-stages span i{width:5px;height:5px;border-radius:50%;background:#c8d2d4;transition:.25s}.manufacturing-stages span.active{color:#347f82}.manufacturing-stages span.active i{background:#347f82;box-shadow:0 0 0 3px rgba(52,127,130,.11);animation:manufacturing-pulse 1.2s ease-in-out infinite}.manufacturing-stages span.done{color:#6f8987}.manufacturing-stages span.done i{background:#67a18c}
@keyframes manufacturing-turn{to{transform:rotate(360deg)}}
@keyframes manufacturing-pulse{50%{transform:scale(.7);opacity:.55}}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8000',
'/project-assets': 'http://127.0.0.1:8000',
},
},
})