初始化卷积测试
This commit is contained in:
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>深度图3D模型修复与刀路生成</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2584
package-lock.json
generated
Normal file
2584
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "depth-map-3d-repair",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"three": "^0.160.0",
|
||||||
|
"@react-three/fiber": "^8.15.0",
|
||||||
|
"@react-three/drei": "^9.92.0",
|
||||||
|
"clipper2-wasm": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/App.css
Normal file
24
src/App.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 2px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
80
src/App.jsx
Normal file
80
src/App.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import ModelViewer from './components/ModelViewer'
|
||||||
|
import EnvelopeViewer from './components/EnvelopeViewer'
|
||||||
|
import ControlPanel from './components/ControlPanel'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const MATERIAL_SIZE = 100 // mm
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [model, setModel] = useState(null)
|
||||||
|
const [upperEnvelope, setUpperEnvelope] = useState(null)
|
||||||
|
const [convolutionEnvelope, setConvolutionEnvelope] = useState(null)
|
||||||
|
const [repairedEnvelope, setRepairedEnvelope] = useState(null)
|
||||||
|
const [toolpathEnvelope, setToolpathEnvelope] = useState(null)
|
||||||
|
const [showUpper, setShowUpper] = useState(false)
|
||||||
|
const [showConvolution, setShowConvolution] = useState(false)
|
||||||
|
const [showRepaired, setShowRepaired] = useState(false)
|
||||||
|
const [showToolpath, setShowToolpath] = useState(false)
|
||||||
|
|
||||||
|
const handleModelImport = useCallback((loadedModel) => {
|
||||||
|
setModel(loadedModel)
|
||||||
|
setUpperEnvelope(null)
|
||||||
|
setConvolutionEnvelope(null)
|
||||||
|
setRepairedEnvelope(null)
|
||||||
|
setToolpathEnvelope(null)
|
||||||
|
setShowUpper(false)
|
||||||
|
setShowConvolution(false)
|
||||||
|
setShowRepaired(false)
|
||||||
|
setShowToolpath(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleGenerateUpperEnvelope = useCallback((envelope) => {
|
||||||
|
setUpperEnvelope(envelope)
|
||||||
|
setShowUpper(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRepairAndGenerate = useCallback((convolution, toolpath, repaired) => {
|
||||||
|
setConvolutionEnvelope(convolution)
|
||||||
|
setRepairedEnvelope(repaired)
|
||||||
|
setToolpathEnvelope(toolpath)
|
||||||
|
setShowConvolution(true)
|
||||||
|
setShowRepaired(true)
|
||||||
|
setShowToolpath(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="left-panel">
|
||||||
|
<ControlPanel
|
||||||
|
model={model}
|
||||||
|
upperEnvelope={upperEnvelope}
|
||||||
|
onModelImport={handleModelImport}
|
||||||
|
onGenerateUpper={handleGenerateUpperEnvelope}
|
||||||
|
onRepairAndGenerate={handleRepairAndGenerate}
|
||||||
|
/>
|
||||||
|
<div className="viewer-container">
|
||||||
|
<ModelViewer model={model} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="right-panel">
|
||||||
|
<EnvelopeViewer
|
||||||
|
upperEnvelope={upperEnvelope}
|
||||||
|
convolutionEnvelope={convolutionEnvelope}
|
||||||
|
repairedEnvelope={repairedEnvelope}
|
||||||
|
toolpathEnvelope={toolpathEnvelope}
|
||||||
|
showUpper={showUpper}
|
||||||
|
showConvolution={showConvolution}
|
||||||
|
showRepaired={showRepaired}
|
||||||
|
showToolpath={showToolpath}
|
||||||
|
onToggleUpper={() => setShowUpper(!showUpper)}
|
||||||
|
onToggleConvolution={() => setShowConvolution(!showConvolution)}
|
||||||
|
onToggleRepaired={() => setShowRepaired(!showRepaired)}
|
||||||
|
onToggleToolpath={() => setShowToolpath(!showToolpath)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
150
src/components/ControlPanel.css
Normal file
150
src/components/ControlPanel.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
.control-panel {
|
||||||
|
padding: 16px;
|
||||||
|
background: #16213e;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section h3 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #1a4a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #16c79a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: #19a87f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #f5a623;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover:not(:disabled) {
|
||||||
|
background: #d4891a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compute-mode-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compute-mode-selector .toggle-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compute-mode-selector .toggle-btn.active {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
background: #1a4a7a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compute-mode-selector .toggle-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-selector label {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-selector input {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #f5a623, #16c79a);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
454
src/components/ControlPanel.jsx
Normal file
454
src/components/ControlPanel.jsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from 'react'
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||||
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||||
|
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { runWebGPUConvolution } from '../utils/webgpuConvolution'
|
||||||
|
import './ControlPanel.css'
|
||||||
|
|
||||||
|
const MATERIAL_SIZE = 100
|
||||||
|
|
||||||
|
function ControlPanel({ model, upperEnvelope, onModelImport, onGenerateUpper, onRepairAndGenerate }) {
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const workerRef = useRef(null)
|
||||||
|
const selectedToolRef = useRef('ball')
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [isRepairing, setIsRepairing] = useState(false)
|
||||||
|
const [repairProgress, setRepairProgress] = useState(0)
|
||||||
|
const [repairIteration, setRepairIteration] = useState(0)
|
||||||
|
const [selectedTool, setSelectedTool] = useState('ball')
|
||||||
|
const [computeMode, setComputeMode] = useState('worker')
|
||||||
|
const [webgpuAvailable, setWebgpuAvailable] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigator.gpu) {
|
||||||
|
navigator.gpu.requestAdapter().then(adapter => {
|
||||||
|
setWebgpuAvailable(!!adapter)
|
||||||
|
}).catch(() => {
|
||||||
|
setWebgpuAvailable(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedToolRef.current = selectedTool
|
||||||
|
}, [selectedTool])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL('../workers/repairWorker.js', import.meta.url),
|
||||||
|
{ type: 'module' }
|
||||||
|
)
|
||||||
|
|
||||||
|
workerRef.current.onmessage = (e) => {
|
||||||
|
if (e.data.type === 'progress') {
|
||||||
|
setRepairProgress(e.data.progress)
|
||||||
|
setRepairIteration(e.data.iteration)
|
||||||
|
} else if (e.data.type === 'complete') {
|
||||||
|
const { convolutionMap, toolpathMap, repairedMap, iterations } = e.data
|
||||||
|
|
||||||
|
const convolutionPoints = []
|
||||||
|
const toolpathPoints = []
|
||||||
|
const repairedPoints = []
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
const resolution = 0.1
|
||||||
|
const gridSize = Math.ceil(MATERIAL_SIZE / resolution)
|
||||||
|
const gridOffset = halfSize
|
||||||
|
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const idx = gy * gridSize + gx
|
||||||
|
const cz = convolutionMap[idx]
|
||||||
|
const tz = toolpathMap[idx]
|
||||||
|
const rz = repairedMap[idx]
|
||||||
|
if (cz !== -Infinity) {
|
||||||
|
const x = gx * resolution - gridOffset
|
||||||
|
const y = gy * resolution - gridOffset
|
||||||
|
convolutionPoints.push({ x, y, z: cz })
|
||||||
|
}
|
||||||
|
if (tz !== -Infinity) {
|
||||||
|
const x = gx * resolution - gridOffset
|
||||||
|
const y = gy * resolution - gridOffset
|
||||||
|
toolpathPoints.push({ x, y, z: tz })
|
||||||
|
}
|
||||||
|
if (rz !== -Infinity) {
|
||||||
|
const x = gx * resolution - gridOffset
|
||||||
|
const y = gy * resolution - gridOffset
|
||||||
|
repairedPoints.push({ x, y, z: rz })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRepairAndGenerate(
|
||||||
|
{
|
||||||
|
depthMap: new Float32Array(convolutionMap),
|
||||||
|
gridSize,
|
||||||
|
resolution,
|
||||||
|
points: convolutionPoints
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depthMap: new Float32Array(toolpathMap),
|
||||||
|
gridSize,
|
||||||
|
resolution,
|
||||||
|
points: toolpathPoints
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depthMap: new Float32Array(repairedMap),
|
||||||
|
gridSize,
|
||||||
|
resolution,
|
||||||
|
points: repairedPoints
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsRepairing(false)
|
||||||
|
setRepairProgress(100)
|
||||||
|
console.log(`修复完成,迭代次数: ${iterations}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onRepairAndGenerate])
|
||||||
|
|
||||||
|
const handleFileSelect = async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let geometry = null
|
||||||
|
|
||||||
|
if (extension === 'stl') {
|
||||||
|
const loader = new STLLoader()
|
||||||
|
geometry = await new Promise((resolve, reject) => {
|
||||||
|
loader.load(url, resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
} else if (extension === 'obj') {
|
||||||
|
const loader = new OBJLoader()
|
||||||
|
const obj = await new Promise((resolve, reject) => {
|
||||||
|
loader.load(url, resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
obj.traverse((child) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
geometry = child.geometry.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (extension === 'gltf' || extension === 'glb') {
|
||||||
|
const loader = new GLTFLoader()
|
||||||
|
const gltf = await new Promise((resolve, reject) => {
|
||||||
|
loader.load(url, resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
gltf.scene.traverse((child) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
geometry = child.geometry.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert('不支持的文件格式,请导入 STL、OBJ 或 GLTF/GLB 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geometry) {
|
||||||
|
alert('无法加载模型几何体')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.computeBoundingBox()
|
||||||
|
const bbox = geometry.boundingBox
|
||||||
|
const size = new THREE.Vector3()
|
||||||
|
bbox.getSize(size)
|
||||||
|
const center = new THREE.Vector3()
|
||||||
|
bbox.getCenter(center)
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
let scale = 1
|
||||||
|
if (maxDim > MATERIAL_SIZE) {
|
||||||
|
scale = MATERIAL_SIZE / maxDim
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.scale(scale, scale, scale)
|
||||||
|
geometry.translate(-center.x * scale, -center.y * scale, -center.z * scale)
|
||||||
|
|
||||||
|
geometry.computeBoundingBox()
|
||||||
|
|
||||||
|
onModelImport({
|
||||||
|
geometry,
|
||||||
|
name: file.name,
|
||||||
|
bbox: geometry.boundingBox.clone()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载模型失败:', error)
|
||||||
|
alert('加载模型失败: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateUpperEnvelope = async () => {
|
||||||
|
if (!model) return
|
||||||
|
setIsGenerating(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const geometry = model.geometry
|
||||||
|
const resolution = 0.1
|
||||||
|
const gridSize = Math.ceil(MATERIAL_SIZE / resolution)
|
||||||
|
|
||||||
|
const depthMap = new Float32Array(gridSize * gridSize).fill(-Infinity)
|
||||||
|
|
||||||
|
const vertices = geometry.attributes.position.array
|
||||||
|
const bbox = geometry.boundingBox
|
||||||
|
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
const gridOffset = halfSize
|
||||||
|
|
||||||
|
for (let i = 0; i < vertices.length; i += 3) {
|
||||||
|
const x = vertices[i]
|
||||||
|
const y = vertices[i + 1]
|
||||||
|
const z = vertices[i + 2]
|
||||||
|
|
||||||
|
const gridX = Math.floor((x + gridOffset) / resolution)
|
||||||
|
const gridY = Math.floor((y + gridOffset) / resolution)
|
||||||
|
|
||||||
|
if (gridX >= 0 && gridX < gridSize && gridY >= 0 && gridY < gridSize) {
|
||||||
|
const idx = gridY * gridSize + gridX
|
||||||
|
if (z > depthMap[idx]) {
|
||||||
|
depthMap[idx] = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('depthMap 采样完成, gridSize:', gridSize, '有效像素:', depthMap.filter(z => z !== -Infinity).length)
|
||||||
|
|
||||||
|
const surfacePoints = []
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const idx = gy * gridSize + gx
|
||||||
|
const z = depthMap[idx]
|
||||||
|
if (z !== -Infinity) {
|
||||||
|
const x = gx * resolution - gridOffset
|
||||||
|
const y = gy * resolution - gridOffset
|
||||||
|
surfacePoints.push({ x, y, z })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onGenerateUpper({
|
||||||
|
depthMap,
|
||||||
|
gridSize,
|
||||||
|
resolution,
|
||||||
|
points: surfacePoints,
|
||||||
|
bbox: bbox.clone()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成上包络面失败:', error)
|
||||||
|
alert('生成上包络面失败: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairAndGenerate = async () => {
|
||||||
|
if (!upperEnvelope) return
|
||||||
|
setIsRepairing(true)
|
||||||
|
setRepairProgress(0)
|
||||||
|
setRepairIteration(0)
|
||||||
|
|
||||||
|
let toolRadius = 0
|
||||||
|
let toolAngle = 0
|
||||||
|
let tipRadius = 0
|
||||||
|
|
||||||
|
if (selectedTool === 'ball') {
|
||||||
|
toolRadius = 3.175 / 2
|
||||||
|
toolAngle = 0
|
||||||
|
tipRadius = 0
|
||||||
|
} else if (selectedTool === 'taper15') {
|
||||||
|
toolRadius = 3.175 / 2
|
||||||
|
toolAngle = 15
|
||||||
|
tipRadius = 0.05
|
||||||
|
} else if (selectedTool === 'taper30') {
|
||||||
|
toolRadius = 3.175 / 2
|
||||||
|
toolAngle = 30
|
||||||
|
tipRadius = 0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
if (computeMode === 'webgpu' && webgpuAvailable) {
|
||||||
|
try {
|
||||||
|
const result = await runWebGPUConvolution(
|
||||||
|
upperEnvelope.depthMap,
|
||||||
|
upperEnvelope.gridSize,
|
||||||
|
upperEnvelope.resolution,
|
||||||
|
selectedTool,
|
||||||
|
toolRadius,
|
||||||
|
toolAngle,
|
||||||
|
tipRadius,
|
||||||
|
(msg) => {
|
||||||
|
if (msg.type === 'progress') {
|
||||||
|
setRepairProgress(msg.progress)
|
||||||
|
setRepairIteration(msg.iteration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { convolutionMap, toolpathMap, repairedMap } = result
|
||||||
|
const { gridSize, resolution } = upperEnvelope
|
||||||
|
|
||||||
|
const surfacePoints = (map) => {
|
||||||
|
const points = []
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const idx = gy * gridSize + gx
|
||||||
|
const z = map[idx]
|
||||||
|
if (z !== -Infinity) {
|
||||||
|
const x = gx * resolution - MATERIAL_SIZE / 2
|
||||||
|
const y = gy * resolution - MATERIAL_SIZE / 2
|
||||||
|
points.push({ x, y, z })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
onRepairAndGenerate(
|
||||||
|
{ depthMap: new Float32Array(convolutionMap), gridSize, resolution, points: surfacePoints(convolutionMap) },
|
||||||
|
{ depthMap: new Float32Array(toolpathMap), gridSize, resolution, points: surfacePoints(toolpathMap) },
|
||||||
|
{ depthMap: new Float32Array(repairedMap), gridSize, resolution, points: surfacePoints(repairedMap) }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebGPU 计算失败:', error)
|
||||||
|
alert('WebGPU 计算失败: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setIsRepairing(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
depthMap: upperEnvelope.depthMap,
|
||||||
|
gridSize: upperEnvelope.gridSize,
|
||||||
|
resolution: upperEnvelope.resolution,
|
||||||
|
toolType: selectedTool,
|
||||||
|
toolRadius,
|
||||||
|
toolAngle,
|
||||||
|
tipRadius
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="control-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>控制面板</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>1. 导入模型</h3>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
选择模型文件
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".stl,.obj,.gltf,.glb"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
{model && <p className="info-text">已加载: {model.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>2. 生成上包络面</h3>
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={generateUpperEnvelope}
|
||||||
|
disabled={!model || isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '生成中...' : '生成上包络面'}
|
||||||
|
</button>
|
||||||
|
{upperEnvelope && <p className="info-text">上包络面已生成</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control-section">
|
||||||
|
<h3>3. 修复并生成刀路</h3>
|
||||||
|
<div className="compute-mode-selector">
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${computeMode === 'worker' ? 'active' : ''}`}
|
||||||
|
onClick={() => setComputeMode('worker')}
|
||||||
|
>
|
||||||
|
Web Worker
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${computeMode === 'webgpu' ? 'active' : ''}`}
|
||||||
|
onClick={() => setComputeMode('webgpu')}
|
||||||
|
disabled={!webgpuAvailable}
|
||||||
|
>
|
||||||
|
WebGPU {webgpuAvailable ? '' : '(不可用)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tool-selector">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tool"
|
||||||
|
value="ball"
|
||||||
|
checked={selectedTool === 'ball'}
|
||||||
|
onChange={() => setSelectedTool('ball')}
|
||||||
|
/>
|
||||||
|
1/8" 球刀
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tool"
|
||||||
|
value="taper15"
|
||||||
|
checked={selectedTool === 'taper15'}
|
||||||
|
onChange={() => setSelectedTool('taper15')}
|
||||||
|
/>
|
||||||
|
1/8" 15° 尖刀 (0.1mm)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tool"
|
||||||
|
value="taper30"
|
||||||
|
checked={selectedTool === 'taper30'}
|
||||||
|
onChange={() => setSelectedTool('taper30')}
|
||||||
|
/>
|
||||||
|
1/8" 30° 尖刀 (0.1mm)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={repairAndGenerate}
|
||||||
|
disabled={!upperEnvelope || isRepairing}
|
||||||
|
>
|
||||||
|
{isRepairing ? '修复中...' : '修复并生成刀路'}
|
||||||
|
</button>
|
||||||
|
{isRepairing && (
|
||||||
|
<div className="progress-container">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${repairProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-text">
|
||||||
|
迭代 {repairIteration} | {repairProgress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlPanel
|
||||||
119
src/components/EnvelopeViewer.css
Normal file
119
src/components/EnvelopeViewer.css
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.envelope-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #16213e;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:not(:disabled):hover {
|
||||||
|
border-color: #666;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
border-color: #16c79a;
|
||||||
|
color: #16c79a;
|
||||||
|
background: rgba(22, 199, 154, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-mode-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label.active {
|
||||||
|
color: #16c79a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #333;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: #aaa;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: #16c79a;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
428
src/components/EnvelopeViewer.jsx
Normal file
428
src/components/EnvelopeViewer.jsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||||
|
import './EnvelopeViewer.css'
|
||||||
|
|
||||||
|
const MATERIAL_SIZE = 100
|
||||||
|
|
||||||
|
function EnvelopeViewer({
|
||||||
|
upperEnvelope,
|
||||||
|
convolutionEnvelope,
|
||||||
|
repairedEnvelope,
|
||||||
|
toolpathEnvelope,
|
||||||
|
showUpper,
|
||||||
|
showConvolution,
|
||||||
|
showRepaired,
|
||||||
|
showToolpath,
|
||||||
|
onToggleUpper,
|
||||||
|
onToggleConvolution,
|
||||||
|
onToggleRepaired,
|
||||||
|
onToggleToolpath
|
||||||
|
}) {
|
||||||
|
const [renderMode, setRenderMode] = useState('points')
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const sceneRef = useRef(null)
|
||||||
|
const cameraRef = useRef(null)
|
||||||
|
const rendererRef = useRef(null)
|
||||||
|
const controlsRef = useRef(null)
|
||||||
|
const upperMeshRef = useRef(null)
|
||||||
|
const upperPointsRef = useRef(null)
|
||||||
|
const convolutionMeshRef = useRef(null)
|
||||||
|
const convolutionPointsRef = useRef(null)
|
||||||
|
const repairedMeshRef = useRef(null)
|
||||||
|
const repairedPointsRef = useRef(null)
|
||||||
|
const toolpathMeshRef = useRef(null)
|
||||||
|
const toolpathPointsRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e)
|
||||||
|
sceneRef.current = scene
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
camera.position.set(80, -80, 80)
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
cameraRef.current = camera
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||||
|
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight)
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio)
|
||||||
|
containerRef.current.appendChild(renderer.domElement)
|
||||||
|
rendererRef.current = renderer
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement)
|
||||||
|
controls.enableDamping = true
|
||||||
|
controls.dampingFactor = 0.05
|
||||||
|
controls.target.set(0, 0, 0)
|
||||||
|
controlsRef.current = controls
|
||||||
|
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
|
||||||
|
const gridHelper = new THREE.GridHelper(MATERIAL_SIZE, 10, 0x444444, 0x222222)
|
||||||
|
gridHelper.rotation.x = Math.PI / 2
|
||||||
|
scene.add(gridHelper)
|
||||||
|
|
||||||
|
const boxGeometry = new THREE.BoxGeometry(MATERIAL_SIZE, MATERIAL_SIZE, MATERIAL_SIZE)
|
||||||
|
const edges = new THREE.EdgesGeometry(boxGeometry)
|
||||||
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x444444 }))
|
||||||
|
scene.add(line)
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||||
|
scene.add(ambientLight)
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||||
|
directionalLight.position.set(50, 100, 50)
|
||||||
|
scene.add(directionalLight)
|
||||||
|
|
||||||
|
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4)
|
||||||
|
directionalLight2.position.set(-50, 50, -50)
|
||||||
|
scene.add(directionalLight2)
|
||||||
|
|
||||||
|
const axesHelper = new THREE.AxesHelper(60)
|
||||||
|
scene.add(axesHelper)
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
controls.update()
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
animate()
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current || !camera || !renderer) return
|
||||||
|
camera.aspect = containerRef.current.clientWidth / containerRef.current.clientHeight
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
if (containerRef.current && renderer.domElement) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement)
|
||||||
|
}
|
||||||
|
renderer.dispose()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createSurfaceMesh = (envelope, color, zOffset = 0) => {
|
||||||
|
if (!envelope || !envelope.depthMap || envelope.depthMap.length === 0) return null
|
||||||
|
|
||||||
|
const { gridSize, resolution, depthMap } = envelope
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
|
||||||
|
let validCount = 0
|
||||||
|
const totalPixels = gridSize * gridSize
|
||||||
|
for (let i = 0; i < totalPixels; i++) {
|
||||||
|
if (depthMap[i] !== -Infinity) validCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const density = validCount / totalPixels
|
||||||
|
console.log('createSurfaceMesh: density =', density.toFixed(4), 'validCount =', validCount)
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
const vertices = []
|
||||||
|
const indices = []
|
||||||
|
|
||||||
|
for (let gy = 0; gy < gridSize - 1; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize - 1; gx++) {
|
||||||
|
const idx1 = gy * gridSize + gx
|
||||||
|
const idx2 = gy * gridSize + (gx + 1)
|
||||||
|
const idx3 = (gy + 1) * gridSize + gx
|
||||||
|
const idx4 = (gy + 1) * gridSize + (gx + 1)
|
||||||
|
|
||||||
|
const z1 = depthMap[idx1]
|
||||||
|
const z2 = depthMap[idx2]
|
||||||
|
const z3 = depthMap[idx3]
|
||||||
|
const z4 = depthMap[idx4]
|
||||||
|
|
||||||
|
if (z1 === -Infinity || z2 === -Infinity || z3 === -Infinity || z4 === -Infinity) continue
|
||||||
|
|
||||||
|
const x1 = gx * resolution - halfSize
|
||||||
|
const y1 = gy * resolution - halfSize
|
||||||
|
const x2 = (gx + 1) * resolution - halfSize
|
||||||
|
const y2 = (gy + 1) * resolution - halfSize
|
||||||
|
|
||||||
|
const idx = vertices.length / 3
|
||||||
|
vertices.push(x1, y1, z1 + zOffset)
|
||||||
|
vertices.push(x2, y1, z2 + zOffset)
|
||||||
|
vertices.push(x1, y2, z3 + zOffset)
|
||||||
|
vertices.push(x2, y2, z4 + zOffset)
|
||||||
|
|
||||||
|
indices.push(idx, idx + 2, idx + 1)
|
||||||
|
indices.push(idx + 1, idx + 2, idx + 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vertices.length === 0) return null
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
|
||||||
|
geometry.setIndex(indices)
|
||||||
|
geometry.computeVertexNormals()
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color,
|
||||||
|
specular: 0x111111,
|
||||||
|
shininess: 30,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.75,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: 1,
|
||||||
|
polygonOffsetUnits: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return new THREE.Mesh(geometry, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPointCloud = (envelope, color, zOffset = 0) => {
|
||||||
|
if (!envelope || !envelope.depthMap || envelope.depthMap.length === 0) return null
|
||||||
|
|
||||||
|
const { gridSize, resolution, depthMap } = envelope
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
|
||||||
|
const positions = []
|
||||||
|
const colors = []
|
||||||
|
const c = new THREE.Color(color)
|
||||||
|
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const idx = gy * gridSize + gx
|
||||||
|
const z = depthMap[idx]
|
||||||
|
if (z === -Infinity) continue
|
||||||
|
|
||||||
|
const x = gx * resolution - halfSize
|
||||||
|
const y = gy * resolution - halfSize
|
||||||
|
positions.push(x, y, z + zOffset)
|
||||||
|
colors.push(c.r, c.g, c.b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positions.length === 0) return null
|
||||||
|
|
||||||
|
console.log('PointCloud 生成完成, 点数:', positions.length / 3)
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
|
||||||
|
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: resolution * 0.8,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
sizeAttenuation: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return new THREE.Points(geometry, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sceneRef.current) return
|
||||||
|
|
||||||
|
if (upperMeshRef.current) {
|
||||||
|
sceneRef.current.remove(upperMeshRef.current)
|
||||||
|
upperMeshRef.current.geometry.dispose()
|
||||||
|
upperMeshRef.current.material.dispose()
|
||||||
|
upperMeshRef.current = null
|
||||||
|
}
|
||||||
|
if (upperPointsRef.current) {
|
||||||
|
sceneRef.current.remove(upperPointsRef.current)
|
||||||
|
upperPointsRef.current.geometry.dispose()
|
||||||
|
upperPointsRef.current.material.dispose()
|
||||||
|
upperPointsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUpper && upperEnvelope) {
|
||||||
|
const mesh = createSurfaceMesh(upperEnvelope, 0x16c79a, 0)
|
||||||
|
if (mesh) {
|
||||||
|
mesh.visible = renderMode === 'mesh'
|
||||||
|
sceneRef.current.add(mesh)
|
||||||
|
upperMeshRef.current = mesh
|
||||||
|
}
|
||||||
|
const points = createPointCloud(upperEnvelope, 0x16c79a, 0)
|
||||||
|
if (points) {
|
||||||
|
points.visible = renderMode === 'points'
|
||||||
|
sceneRef.current.add(points)
|
||||||
|
upperPointsRef.current = points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showUpper, upperEnvelope])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (upperMeshRef.current) upperMeshRef.current.visible = renderMode === 'mesh'
|
||||||
|
if (upperPointsRef.current) upperPointsRef.current.visible = renderMode === 'points'
|
||||||
|
}, [renderMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sceneRef.current) return
|
||||||
|
|
||||||
|
if (convolutionMeshRef.current) {
|
||||||
|
sceneRef.current.remove(convolutionMeshRef.current)
|
||||||
|
convolutionMeshRef.current.geometry.dispose()
|
||||||
|
convolutionMeshRef.current.material.dispose()
|
||||||
|
convolutionMeshRef.current = null
|
||||||
|
}
|
||||||
|
if (convolutionPointsRef.current) {
|
||||||
|
sceneRef.current.remove(convolutionPointsRef.current)
|
||||||
|
convolutionPointsRef.current.geometry.dispose()
|
||||||
|
convolutionPointsRef.current.material.dispose()
|
||||||
|
convolutionPointsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showConvolution && convolutionEnvelope) {
|
||||||
|
const mesh = createSurfaceMesh(convolutionEnvelope, 0x4a90d9, 0)
|
||||||
|
if (mesh) {
|
||||||
|
mesh.visible = renderMode === 'mesh'
|
||||||
|
sceneRef.current.add(mesh)
|
||||||
|
convolutionMeshRef.current = mesh
|
||||||
|
}
|
||||||
|
const points = createPointCloud(convolutionEnvelope, 0x4a90d9, 0)
|
||||||
|
if (points) {
|
||||||
|
points.visible = renderMode === 'points'
|
||||||
|
sceneRef.current.add(points)
|
||||||
|
convolutionPointsRef.current = points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showConvolution, convolutionEnvelope])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (convolutionMeshRef.current) convolutionMeshRef.current.visible = renderMode === 'mesh'
|
||||||
|
if (convolutionPointsRef.current) convolutionPointsRef.current.visible = renderMode === 'points'
|
||||||
|
}, [renderMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sceneRef.current) return
|
||||||
|
|
||||||
|
if (repairedMeshRef.current) {
|
||||||
|
sceneRef.current.remove(repairedMeshRef.current)
|
||||||
|
repairedMeshRef.current.geometry.dispose()
|
||||||
|
repairedMeshRef.current.material.dispose()
|
||||||
|
repairedMeshRef.current = null
|
||||||
|
}
|
||||||
|
if (repairedPointsRef.current) {
|
||||||
|
sceneRef.current.remove(repairedPointsRef.current)
|
||||||
|
repairedPointsRef.current.geometry.dispose()
|
||||||
|
repairedPointsRef.current.material.dispose()
|
||||||
|
repairedPointsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRepaired && repairedEnvelope) {
|
||||||
|
const mesh = createSurfaceMesh(repairedEnvelope, 0xf5a623, 0)
|
||||||
|
if (mesh) {
|
||||||
|
mesh.visible = renderMode === 'mesh'
|
||||||
|
sceneRef.current.add(mesh)
|
||||||
|
repairedMeshRef.current = mesh
|
||||||
|
}
|
||||||
|
const points = createPointCloud(repairedEnvelope, 0xf5a623, 0)
|
||||||
|
if (points) {
|
||||||
|
points.visible = renderMode === 'points'
|
||||||
|
sceneRef.current.add(points)
|
||||||
|
repairedPointsRef.current = points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showRepaired, repairedEnvelope])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (repairedMeshRef.current) repairedMeshRef.current.visible = renderMode === 'mesh'
|
||||||
|
if (repairedPointsRef.current) repairedPointsRef.current.visible = renderMode === 'points'
|
||||||
|
}, [renderMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sceneRef.current) return
|
||||||
|
|
||||||
|
if (toolpathMeshRef.current) {
|
||||||
|
sceneRef.current.remove(toolpathMeshRef.current)
|
||||||
|
toolpathMeshRef.current.geometry.dispose()
|
||||||
|
toolpathMeshRef.current.material.dispose()
|
||||||
|
toolpathMeshRef.current = null
|
||||||
|
}
|
||||||
|
if (toolpathPointsRef.current) {
|
||||||
|
sceneRef.current.remove(toolpathPointsRef.current)
|
||||||
|
toolpathPointsRef.current.geometry.dispose()
|
||||||
|
toolpathPointsRef.current.material.dispose()
|
||||||
|
toolpathPointsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToolpath && toolpathEnvelope) {
|
||||||
|
const mesh = createSurfaceMesh(toolpathEnvelope, 0xe94560, 0)
|
||||||
|
if (mesh) {
|
||||||
|
mesh.visible = renderMode === 'mesh'
|
||||||
|
sceneRef.current.add(mesh)
|
||||||
|
toolpathMeshRef.current = mesh
|
||||||
|
}
|
||||||
|
const points = createPointCloud(toolpathEnvelope, 0xe94560, 0)
|
||||||
|
if (points) {
|
||||||
|
points.visible = renderMode === 'points'
|
||||||
|
sceneRef.current.add(points)
|
||||||
|
toolpathPointsRef.current = points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showToolpath, toolpathEnvelope])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toolpathMeshRef.current) toolpathMeshRef.current.visible = renderMode === 'mesh'
|
||||||
|
if (toolpathPointsRef.current) toolpathPointsRef.current.visible = renderMode === 'points'
|
||||||
|
}, [renderMode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="envelope-viewer">
|
||||||
|
<div className="viewer-controls">
|
||||||
|
<div className="render-mode-switch">
|
||||||
|
<span className={`switch-label ${renderMode === 'points' ? 'active' : ''}`}>点云</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={renderMode === 'mesh'}
|
||||||
|
onChange={() => setRenderMode(renderMode === 'points' ? 'mesh' : 'points')}
|
||||||
|
/>
|
||||||
|
<span className="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span className={`switch-label ${renderMode === 'mesh' ? 'active' : ''}`}>网格</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${showUpper ? 'active' : ''}`}
|
||||||
|
onClick={onToggleUpper}
|
||||||
|
disabled={!upperEnvelope}
|
||||||
|
>
|
||||||
|
上包络面
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${showConvolution ? 'active' : ''}`}
|
||||||
|
onClick={onToggleConvolution}
|
||||||
|
disabled={!convolutionEnvelope}
|
||||||
|
>
|
||||||
|
卷积包络面
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${showRepaired ? 'active' : ''}`}
|
||||||
|
onClick={onToggleRepaired}
|
||||||
|
disabled={!repairedEnvelope}
|
||||||
|
>
|
||||||
|
修复包络面
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn ${showToolpath ? 'active' : ''}`}
|
||||||
|
onClick={onToggleToolpath}
|
||||||
|
disabled={!toolpathEnvelope}
|
||||||
|
>
|
||||||
|
刀路包络面
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="viewer-container" ref={containerRef}>
|
||||||
|
{!upperEnvelope && !convolutionEnvelope && !repairedEnvelope && !toolpathEnvelope && (
|
||||||
|
<div className="placeholder">请先导入模型并生成包络面</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnvelopeViewer
|
||||||
16
src/components/ModelViewer.css
Normal file
16
src/components/ModelViewer.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.model-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
128
src/components/ModelViewer.jsx
Normal file
128
src/components/ModelViewer.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||||
|
import './ModelViewer.css'
|
||||||
|
|
||||||
|
const MATERIAL_SIZE = 100
|
||||||
|
|
||||||
|
function ModelViewer({ model }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const sceneRef = useRef(null)
|
||||||
|
const cameraRef = useRef(null)
|
||||||
|
const rendererRef = useRef(null)
|
||||||
|
const controlsRef = useRef(null)
|
||||||
|
const modelMeshRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e)
|
||||||
|
sceneRef.current = scene
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
camera.position.set(80, -80, 80)
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
cameraRef.current = camera
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||||
|
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight)
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio)
|
||||||
|
containerRef.current.appendChild(renderer.domElement)
|
||||||
|
rendererRef.current = renderer
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement)
|
||||||
|
controls.enableDamping = true
|
||||||
|
controls.dampingFactor = 0.05
|
||||||
|
controls.target.set(0, 0, 0)
|
||||||
|
controlsRef.current = controls
|
||||||
|
|
||||||
|
const halfSize = MATERIAL_SIZE / 2
|
||||||
|
|
||||||
|
const gridHelper = new THREE.GridHelper(MATERIAL_SIZE, 10, 0x444444, 0x222222)
|
||||||
|
gridHelper.rotation.x = Math.PI / 2
|
||||||
|
scene.add(gridHelper)
|
||||||
|
|
||||||
|
const boxGeometry = new THREE.BoxGeometry(MATERIAL_SIZE, MATERIAL_SIZE, MATERIAL_SIZE)
|
||||||
|
const edges = new THREE.EdgesGeometry(boxGeometry)
|
||||||
|
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x444444 }))
|
||||||
|
line.position.set(0, 0, 0)
|
||||||
|
scene.add(line)
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||||
|
scene.add(ambientLight)
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||||
|
directionalLight.position.set(50, 100, 50)
|
||||||
|
scene.add(directionalLight)
|
||||||
|
|
||||||
|
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4)
|
||||||
|
directionalLight2.position.set(-50, 50, -50)
|
||||||
|
scene.add(directionalLight2)
|
||||||
|
|
||||||
|
const axesHelper = new THREE.AxesHelper(60)
|
||||||
|
scene.add(axesHelper)
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
controls.update()
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
animate()
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current || !camera || !renderer) return
|
||||||
|
camera.aspect = containerRef.current.clientWidth / containerRef.current.clientHeight
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
if (containerRef.current && renderer.domElement) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement)
|
||||||
|
}
|
||||||
|
renderer.dispose()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sceneRef.current) return
|
||||||
|
|
||||||
|
if (modelMeshRef.current) {
|
||||||
|
sceneRef.current.remove(modelMeshRef.current)
|
||||||
|
modelMeshRef.current.geometry.dispose()
|
||||||
|
if (modelMeshRef.current.material) {
|
||||||
|
modelMeshRef.current.material.dispose()
|
||||||
|
}
|
||||||
|
modelMeshRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0x4a90d9,
|
||||||
|
specular: 0x111111,
|
||||||
|
shininess: 30,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
})
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(model.geometry, material)
|
||||||
|
sceneRef.current.add(mesh)
|
||||||
|
modelMeshRef.current = mesh
|
||||||
|
}
|
||||||
|
}, [model])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="model-viewer" ref={containerRef}>
|
||||||
|
{!model && <div className="placeholder">请导入3D模型</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelViewer
|
||||||
19
src/index.css
Normal file
19
src/index.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
309
src/utils/webgpuConvolution.js
Normal file
309
src/utils/webgpuConvolution.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
const EMPTY_VALUE = -50.0
|
||||||
|
|
||||||
|
const WEBGPU_COMPUTE_SHADER = `
|
||||||
|
struct Params {
|
||||||
|
gridSize: u32,
|
||||||
|
kernelRadius: i32,
|
||||||
|
kernelSize: u32,
|
||||||
|
centerProfileZ: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KernelEntry {
|
||||||
|
kx: i32,
|
||||||
|
ky: i32,
|
||||||
|
profileZ: f32,
|
||||||
|
_pad: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<storage, read> inputMap: array<f32>;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> outputMap: array<f32>;
|
||||||
|
@group(0) @binding(2) var<uniform> params: Params;
|
||||||
|
@group(0) @binding(3) var<storage, read> kernel: array<KernelEntry>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(16, 16)
|
||||||
|
fn convolutionMain(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||||
|
let gx = global_id.x;
|
||||||
|
let gy = global_id.y;
|
||||||
|
let gs = params.gridSize;
|
||||||
|
|
||||||
|
if (gx >= gs || gy >= gs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerIdx = gy * gs + gx;
|
||||||
|
let currentZ = inputMap[centerIdx];
|
||||||
|
|
||||||
|
if (currentZ < -49.9) {
|
||||||
|
outputMap[centerIdx] = -50.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var convolutionZ = currentZ;
|
||||||
|
|
||||||
|
for (var i: u32 = 0u; i < params.kernelSize; i = i + 1u) {
|
||||||
|
let kx = kernel[i].kx;
|
||||||
|
let ky = kernel[i].ky;
|
||||||
|
let profileZ = kernel[i].profileZ;
|
||||||
|
|
||||||
|
let nx = i32(gx) + kx;
|
||||||
|
let ny = i32(gy) + ky;
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= i32(gs) || ny < 0 || ny >= i32(gs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighborIdx = u32(ny) * gs + u32(nx);
|
||||||
|
let neighborZ = inputMap[neighborIdx];
|
||||||
|
if (neighborZ < -49.9) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateZ = neighborZ + profileZ;
|
||||||
|
if (candidateZ > convolutionZ) {
|
||||||
|
convolutionZ = candidateZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputMap[centerIdx] = convolutionZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(16, 16)
|
||||||
|
fn toolpathMain(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||||
|
let gx = global_id.x;
|
||||||
|
let gy = global_id.y;
|
||||||
|
let gs = params.gridSize;
|
||||||
|
|
||||||
|
if (gx >= gs || gy >= gs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = gy * gs + gx;
|
||||||
|
let convZ = inputMap[idx];
|
||||||
|
|
||||||
|
if (convZ < -49.9) {
|
||||||
|
outputMap[idx] = -50.0;
|
||||||
|
} else {
|
||||||
|
outputMap[idx] = convZ - params.centerProfileZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(16, 16)
|
||||||
|
fn repairedMain(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||||
|
let gx = global_id.x;
|
||||||
|
let gy = global_id.y;
|
||||||
|
let gs = params.gridSize;
|
||||||
|
|
||||||
|
if (gx >= gs || gy >= gs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerIdx = gy * gs + gx;
|
||||||
|
let convolutionZ = inputMap[centerIdx];
|
||||||
|
|
||||||
|
if (convolutionZ < -49.9) {
|
||||||
|
outputMap[centerIdx] = -50.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var repairedZ = convolutionZ;
|
||||||
|
|
||||||
|
for (var i: u32 = 0u; i < params.kernelSize; i = i + 1u) {
|
||||||
|
let kx = kernel[i].kx;
|
||||||
|
let ky = kernel[i].ky;
|
||||||
|
let profileZ = kernel[i].profileZ;
|
||||||
|
|
||||||
|
let nx = i32(gx) + kx;
|
||||||
|
let ny = i32(gy) + ky;
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= i32(gs) || ny < 0 || ny >= i32(gs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighborIdx = u32(ny) * gs + u32(nx);
|
||||||
|
let neighborConvolutionZ = inputMap[neighborIdx];
|
||||||
|
if (neighborConvolutionZ < -49.9) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let surfaceZ = neighborConvolutionZ - profileZ;
|
||||||
|
if (surfaceZ < repairedZ) {
|
||||||
|
repairedZ = surfaceZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputMap[centerIdx] = repairedZ;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function runWebGPUConvolution(depthMap, gridSize, resolution, toolType, toolRadius, toolAngle, tipRadius, onProgress) {
|
||||||
|
if (!navigator.gpu) {
|
||||||
|
throw new Error('WebGPU not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = await navigator.gpu.requestAdapter()
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error('No WebGPU adapter found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await adapter.requestDevice()
|
||||||
|
|
||||||
|
const gpuInputMap = new Float32Array(depthMap.length)
|
||||||
|
for (let i = 0; i < depthMap.length; i++) {
|
||||||
|
gpuInputMap[i] = depthMap[i] === -Infinity ? EMPTY_VALUE : depthMap[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const kernelRadius = Math.ceil(toolRadius / resolution)
|
||||||
|
|
||||||
|
const computeToolProfile = (dist) => {
|
||||||
|
if (toolType === 'ball') {
|
||||||
|
if (dist <= toolRadius) {
|
||||||
|
return Math.sqrt(toolRadius * toolRadius - dist * dist)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
const halfAngle = toolAngle * Math.PI / 180 / 2
|
||||||
|
const tanHalf = Math.tan(halfAngle)
|
||||||
|
if (dist <= tipRadius) {
|
||||||
|
return (toolRadius - tipRadius) / tanHalf
|
||||||
|
}
|
||||||
|
return (toolRadius - dist) / tanHalf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kernel = []
|
||||||
|
const maxDist = toolRadius
|
||||||
|
for (let ky = -kernelRadius; ky <= kernelRadius; ky++) {
|
||||||
|
for (let kx = -kernelRadius; kx <= kernelRadius; kx++) {
|
||||||
|
const dx = kx * resolution
|
||||||
|
const dy = ky * resolution
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (dist <= maxDist) {
|
||||||
|
const profileZ = computeToolProfile(dist)
|
||||||
|
kernel.push({ kx, ky, profileZ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerProfileZ = kernel.find(k => k.kx === 0 && k.ky === 0)?.profileZ || 0
|
||||||
|
|
||||||
|
const kernelData = new ArrayBuffer(kernel.length * 16)
|
||||||
|
const kernelView = new DataView(kernelData)
|
||||||
|
kernel.forEach((k, i) => {
|
||||||
|
kernelView.setInt32(i * 16, k.kx, true)
|
||||||
|
kernelView.setInt32(i * 16 + 4, k.ky, true)
|
||||||
|
kernelView.setFloat32(i * 16 + 8, k.profileZ, true)
|
||||||
|
kernelView.setFloat32(i * 16 + 12, 0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const paramsData = new ArrayBuffer(16)
|
||||||
|
const paramsView = new DataView(paramsData)
|
||||||
|
paramsView.setUint32(0, gridSize, true)
|
||||||
|
paramsView.setInt32(4, kernelRadius, true)
|
||||||
|
paramsView.setUint32(8, kernel.length, true)
|
||||||
|
paramsView.setFloat32(12, centerProfileZ, true)
|
||||||
|
|
||||||
|
const inputBuffer = device.createBuffer({
|
||||||
|
size: depthMap.byteLength,
|
||||||
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputBuffer = device.createBuffer({
|
||||||
|
size: depthMap.byteLength,
|
||||||
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stagingBuffer = device.createBuffer({
|
||||||
|
size: depthMap.byteLength,
|
||||||
|
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||||
|
})
|
||||||
|
|
||||||
|
const kernelBuffer = device.createBuffer({
|
||||||
|
size: kernelData.byteLength,
|
||||||
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||||
|
})
|
||||||
|
|
||||||
|
const paramsBuffer = device.createBuffer({
|
||||||
|
size: paramsData.byteLength,
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
})
|
||||||
|
|
||||||
|
device.queue.writeBuffer(inputBuffer, 0, gpuInputMap)
|
||||||
|
device.queue.writeBuffer(kernelBuffer, 0, kernelData)
|
||||||
|
device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
||||||
|
|
||||||
|
const shaderModule = device.createShaderModule({ code: WEBGPU_COMPUTE_SHADER })
|
||||||
|
|
||||||
|
const runPass = async (entryPoint, input, output) => {
|
||||||
|
const bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||||
|
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||||
|
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
||||||
|
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const pipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
|
||||||
|
compute: { module: shaderModule, entryPoint }
|
||||||
|
})
|
||||||
|
|
||||||
|
const bindGroup = device.createBindGroup({
|
||||||
|
layout: bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: input } },
|
||||||
|
{ binding: 1, resource: { buffer: output } },
|
||||||
|
{ binding: 2, resource: { buffer: paramsBuffer } },
|
||||||
|
{ binding: 3, resource: { buffer: kernelBuffer } },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const commandEncoder = device.createCommandEncoder()
|
||||||
|
const passEncoder = commandEncoder.beginComputePass()
|
||||||
|
passEncoder.setPipeline(pipeline)
|
||||||
|
passEncoder.setBindGroup(0, bindGroup)
|
||||||
|
passEncoder.dispatchWorkgroups(Math.ceil(gridSize / 16), Math.ceil(gridSize / 16))
|
||||||
|
passEncoder.end()
|
||||||
|
commandEncoder.copyBufferToBuffer(output, 0, stagingBuffer, 0, depthMap.byteLength)
|
||||||
|
device.queue.submit([commandEncoder.finish()])
|
||||||
|
|
||||||
|
await stagingBuffer.mapAsync(GPUMapMode.READ)
|
||||||
|
const result = new Float32Array(stagingBuffer.getMappedRange().slice(0))
|
||||||
|
stagingBuffer.unmap()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) onProgress({ type: 'progress', progress: 5, iteration: 1 })
|
||||||
|
|
||||||
|
const convolutionMap = await runPass('convolutionMain', inputBuffer, outputBuffer)
|
||||||
|
if (onProgress) onProgress({ type: 'progress', progress: 35, iteration: 1 })
|
||||||
|
|
||||||
|
device.queue.writeBuffer(inputBuffer, 0, convolutionMap)
|
||||||
|
const toolpathMap = await runPass('toolpathMain', inputBuffer, outputBuffer)
|
||||||
|
if (onProgress) onProgress({ type: 'progress', progress: 50, iteration: 1 })
|
||||||
|
|
||||||
|
device.queue.writeBuffer(inputBuffer, 0, convolutionMap)
|
||||||
|
const repairedMap = await runPass('repairedMain', inputBuffer, outputBuffer)
|
||||||
|
if (onProgress) onProgress({ type: 'progress', progress: 95, iteration: 1 })
|
||||||
|
|
||||||
|
inputBuffer.destroy()
|
||||||
|
outputBuffer.destroy()
|
||||||
|
stagingBuffer.destroy()
|
||||||
|
kernelBuffer.destroy()
|
||||||
|
paramsBuffer.destroy()
|
||||||
|
device.destroy()
|
||||||
|
|
||||||
|
const convertBack = (arr) => {
|
||||||
|
const result = new Float32Array(arr.length)
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
result[i] = arr[i] < -49.9 ? -Infinity : arr[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
convolutionMap: convertBack(convolutionMap),
|
||||||
|
toolpathMap: convertBack(toolpathMap),
|
||||||
|
repairedMap: convertBack(repairedMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/workers/repairWorker.js
Normal file
155
src/workers/repairWorker.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
self.onmessage = function(e) {
|
||||||
|
const { depthMap, gridSize, resolution, toolType, toolRadius, toolAngle, tipRadius } = e.data
|
||||||
|
|
||||||
|
const inputMap = new Float32Array(depthMap)
|
||||||
|
const convolutionMap = new Float32Array(depthMap.length)
|
||||||
|
convolutionMap.fill(-Infinity)
|
||||||
|
const repairedMap = new Float32Array(depthMap.length)
|
||||||
|
repairedMap.fill(-Infinity)
|
||||||
|
|
||||||
|
const kernelRadius = toolType === 'ball'
|
||||||
|
? Math.ceil(toolRadius / resolution)
|
||||||
|
: Math.ceil(toolRadius / resolution)
|
||||||
|
|
||||||
|
const computeToolProfile = (dist) => {
|
||||||
|
if (toolType === 'ball') {
|
||||||
|
if (dist <= toolRadius) {
|
||||||
|
return Math.sqrt(toolRadius * toolRadius - dist * dist)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
const halfAngle = toolAngle * Math.PI / 180 / 2
|
||||||
|
const tanHalf = Math.tan(halfAngle)
|
||||||
|
if (dist <= tipRadius) {
|
||||||
|
return (toolRadius - tipRadius) / tanHalf
|
||||||
|
}
|
||||||
|
return (toolRadius - dist) / tanHalf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const precomputeKernel = () => {
|
||||||
|
const kernel = []
|
||||||
|
const maxDist = toolType === 'ball' ? toolRadius : toolRadius
|
||||||
|
for (let ky = -kernelRadius; ky <= kernelRadius; ky++) {
|
||||||
|
for (let kx = -kernelRadius; kx <= kernelRadius; kx++) {
|
||||||
|
const dx = kx * resolution
|
||||||
|
const dy = ky * resolution
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (dist <= maxDist) {
|
||||||
|
const profileZ = computeToolProfile(dist)
|
||||||
|
kernel.push({ kx, ky, profileZ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kernel
|
||||||
|
}
|
||||||
|
|
||||||
|
const kernel = precomputeKernel()
|
||||||
|
|
||||||
|
let processedCount = 0
|
||||||
|
const totalPixels = gridSize * gridSize
|
||||||
|
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const centerIdx = gy * gridSize + gx
|
||||||
|
const currentZ = inputMap[centerIdx]
|
||||||
|
|
||||||
|
if (currentZ === -Infinity) {
|
||||||
|
processedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let convolutionZ = currentZ
|
||||||
|
|
||||||
|
for (const { kx, ky, profileZ } of kernel) {
|
||||||
|
const nx = gx + kx
|
||||||
|
const ny = gy + ky
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) continue
|
||||||
|
|
||||||
|
const neighborIdx = ny * gridSize + nx
|
||||||
|
const neighborZ = inputMap[neighborIdx]
|
||||||
|
if (neighborZ === -Infinity) continue
|
||||||
|
|
||||||
|
const candidateZ = neighborZ + profileZ
|
||||||
|
if (candidateZ > convolutionZ) {
|
||||||
|
convolutionZ = candidateZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convolutionMap[centerIdx] = convolutionZ
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
if (gy % 10 === 0) {
|
||||||
|
const progress = (processedCount / totalPixels) * 33
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
progress: Math.min(progress, 32.9),
|
||||||
|
iteration: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerProfileZ = kernel.find(k => k.kx === 0 && k.ky === 0)?.profileZ || 0
|
||||||
|
const toolpathMap = new Float32Array(depthMap.length)
|
||||||
|
for (let i = 0; i < depthMap.length; i++) {
|
||||||
|
if (convolutionMap[i] === -Infinity) {
|
||||||
|
toolpathMap[i] = -Infinity
|
||||||
|
} else {
|
||||||
|
toolpathMap[i] = convolutionMap[i] - centerProfileZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount = 0
|
||||||
|
|
||||||
|
for (let gy = 0; gy < gridSize; gy++) {
|
||||||
|
for (let gx = 0; gx < gridSize; gx++) {
|
||||||
|
const centerIdx = gy * gridSize + gx
|
||||||
|
const convolutionZ = convolutionMap[centerIdx]
|
||||||
|
|
||||||
|
if (convolutionZ === -Infinity) {
|
||||||
|
processedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let repairedZ = convolutionZ
|
||||||
|
|
||||||
|
for (const { kx, ky, profileZ } of kernel) {
|
||||||
|
const nx = gx + kx
|
||||||
|
const ny = gy + ky
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) continue
|
||||||
|
|
||||||
|
const neighborIdx = ny * gridSize + nx
|
||||||
|
const neighborConvolutionZ = convolutionMap[neighborIdx]
|
||||||
|
if (neighborConvolutionZ === -Infinity) continue
|
||||||
|
|
||||||
|
const surfaceZ = neighborConvolutionZ - profileZ
|
||||||
|
if (surfaceZ < repairedZ) {
|
||||||
|
repairedZ = surfaceZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repairedMap[centerIdx] = repairedZ
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
if (gy % 10 === 0) {
|
||||||
|
const progress = 50 + (processedCount / totalPixels) * 50
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
progress: Math.min(progress, 99.9),
|
||||||
|
iteration: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'complete',
|
||||||
|
convolutionMap,
|
||||||
|
toolpathMap,
|
||||||
|
repairedMap,
|
||||||
|
iterations: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user