From 1cc750b7e4ac3ca02b6a9302c1a605eb943741dd Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 05:50:06 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-04-05-41-22=20=E5=A2=9E=E5=BC=BA3D?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E9=A2=84=E8=A7=88=E5=92=8C=E4=BD=8D=E5=A7=BF?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 2 +- WebSite/src/components/ProjectLibrary.tsx | 193 ++++++++++++++++++-- WebSite/src/components/ReverseWorkspace.tsx | 13 +- 工程分析/实现方案-2026-05-04-05-41-22.md | 82 +++++++++ 工程分析/测试方案-2026-05-04-05-41-22.md | 73 ++++++++ 工程分析/经验记录.md | 54 ++++++ 工程分析/需求分析-2026-05-04-05-41-22.md | 51 ++++++ 7 files changed, 444 insertions(+), 24 deletions(-) create mode 100644 工程分析/实现方案-2026-05-04-05-41-22.md create mode 100644 工程分析/测试方案-2026-05-04-05-41-22.md create mode 100644 工程分析/需求分析-2026-05-04-05-41-22.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 738601e..29b7f53 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -545,7 +545,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) { throw new Error('当前仅支持二进制 STL 预览'); } - const sampleLimit = Math.max(100, Math.min(limit, 12000)); + const sampleLimit = Math.max(100, Math.min(limit, 36000)); const step = Math.max(1, Math.ceil(triangleCount / sampleLimit)); const vertices: number[] = []; let sampledTriangles = 0; diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 7b02460..58da0ae 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -25,6 +25,7 @@ import { api, downloadDicomArchive, downloadMask } from '../lib/api'; type Plane = 'axial' | 'sagittal' | 'coronal'; type DisplayMode = DicomPreview['mode']; +type SolidityLevel = 'preview' | 'standard' | 'fine'; interface ModuleStyle { visible: boolean; @@ -32,6 +33,17 @@ interface ModuleStyle { opacity: number; } +interface ModelPose { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; + autoRotate: boolean; +} + interface ModelPreviewPayload { fileName: string; triangleCount: number; @@ -40,6 +52,21 @@ interface ModelPreviewPayload { } const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ + { id: 'preview', label: '预览', limit: 6000 }, + { id: 'standard', label: '标准', limit: 16000 }, + { id: 'fine', label: '精细', limit: 36000 }, +]; +const defaultModelPose: ModelPose = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + autoRotate: true, +}; function drawFallbackModelPreview( canvas: HTMLCanvasElement, @@ -177,15 +204,26 @@ function NativeStlViewer({ projectId, files, styles, + detailLimit, + solidWhite, + pose, }: { projectId: string; files: string[]; styles: Record; + detailLimit: number; + solidWhite: boolean; + pose: ModelPose; }) { const containerRef = useRef(null); + const poseRef = useRef(pose); const [progress, setProgress] = useState(0); const [status, setStatus] = useState('准备加载模型'); + useEffect(() => { + poseRef.current = pose; + }, [pose]); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -225,7 +263,10 @@ function NativeStlViewer({ }) .then((payload) => ({ payload, - style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }, + style: { + ...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }), + color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6', + }, })), ), ).then((results) => { @@ -266,12 +307,14 @@ function NativeStlViewer({ scene.add(fillLight); const group = new THREE.Group(); + let baseScale = 1; + let autoSpin = 0; scene.add(group); let loaded = 0; let failed = 0; visibleFiles.forEach((fileName) => { - fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=6000`) + fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`) .then((response) => { if (!response.ok) { throw new Error('模型预览数据加载失败'); @@ -287,11 +330,11 @@ function NativeStlViewer({ const mesh = new THREE.Mesh( geometry, new THREE.MeshStandardMaterial({ - color: style.color, + color: solidWhite ? '#f4f4f2' : style.color, opacity: style.opacity, transparent: style.opacity < 1, - roughness: 0.48, - metalness: 0.08, + roughness: solidWhite ? 0.34 : 0.48, + metalness: solidWhite ? 0.02 : 0.08, side: THREE.DoubleSide, }), ); @@ -313,7 +356,8 @@ function NativeStlViewer({ } }); group.position.set(0, 0, 0); - group.scale.setScalar(4.2 / maxSize); + baseScale = 4.2 / maxSize; + group.scale.setScalar(baseScale * poseRef.current.scale); camera.lookAt(0, 0, 0); setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); } @@ -336,7 +380,17 @@ function NativeStlViewer({ const animate = () => { if (disposed) return; - group.rotation.y += 0.004; + const currentPose = poseRef.current; + if (currentPose.autoRotate) { + autoSpin += 0.004; + } + group.rotation.set( + THREE.MathUtils.degToRad(currentPose.rotateX), + THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin, + THREE.MathUtils.degToRad(currentPose.rotateZ), + ); + group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); + group.scale.setScalar(baseScale * currentPose.scale); renderer.render(scene, camera); animationId = window.requestAnimationFrame(animate); }; @@ -360,7 +414,7 @@ function NativeStlViewer({ }); container.innerHTML = ''; }; - }, [projectId, files.join('|'), JSON.stringify(styles)]); + }, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]); return (
@@ -396,6 +450,10 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [plane, setPlane] = useState('axial'); const [displayMode, setDisplayMode] = useState('default'); const [rotation, setRotation] = useState(0); + const [isSliceChanging, setIsSliceChanging] = useState(false); + const [solidityLevel, setSolidityLevel] = useState('standard'); + const [solidWhite, setSolidWhite] = useState(true); + const [modelPose, setModelPose] = useState(defaultModelPose); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [dicomError, setDicomError] = useState(''); @@ -406,6 +464,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [editingName, setEditingName] = useState(''); const [actionMessage, setActionMessage] = useState(''); const sliceRepeatRef = useRef(null); + const dicomRequestRef = useRef(0); const refreshProjects = () => { setLoading(true); @@ -448,6 +507,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri ]; const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0; + const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[1]; useEffect(() => { const next: Record = {}; @@ -460,26 +520,33 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri }); setModuleStyles(next); setSliceIndex(0); + setModelPose(defaultModelPose); }, [selectedProject?.id]); useEffect(() => { if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) { setDicomPreview(null); + setIsSliceChanging(false); return; } let cancelled = false; + const requestId = dicomRequestRef.current + 1; + dicomRequestRef.current = requestId; setDicomError(''); + setIsSliceChanging(true); api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode) .then((preview) => { - if (!cancelled) { + if (!cancelled && requestId === dicomRequestRef.current) { setDicomPreview(preview); + setIsSliceChanging(false); } }) .catch((error) => { - if (!cancelled) { + if (!cancelled && requestId === dicomRequestRef.current) { setDicomPreview(null); setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败'); + setIsSliceChanging(false); } }); @@ -546,7 +613,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const startSliceStep = (delta: number) => { stopSliceStep(); stepSlice(delta); - sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 110); + sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 95); + }; + + const updateModelPose = (partial: Partial) => { + setModelPose((current) => ({ + ...current, + autoRotate: partial.autoRotate ?? false, + ...partial, + })); + }; + + const resetModelPose = () => { + setModelPose(defaultModelPose); }; const rotateDicom = (delta: number) => { @@ -837,12 +916,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

SCAN DATE: {selectedProject.createTime}

DICOM PATH: {selectedProject.dicomPath}

-
+
{dicomPreview ? ( ) : (

{dicomError || '正在解析 DICOM 像素...'}

)} + {isSliceChanging && dicomPreview && ( + + 切片切换中 + + )}
WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label} @@ -917,13 +1001,92 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{/* Left: 3D Visualization */}
- +
- MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} + MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
{/* Right: Sub-module List */} -
+
+
+
+
+

模型显示

+ +
+
+ {solidityOptions.map((option) => ( + + ))} +
+
+ + +
+
+ +
+

整体位姿

+ {[ + { key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX }, + { key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY }, + { key: 'rotateZ', label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ }, + { key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX }, + { key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY }, + { key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ }, + { key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale }, + ].map((item) => ( +
+ {item.label} + updateModelPose({ [item.key]: Number(event.target.value) } as Partial)} + className="w-full accent-blue-600" + /> + {Number(item.value).toFixed(item.step < 1 ? 2 : 0)} +
+ ))} +
+
+

构件层级 ({stlFiles.length})