初始化卷积测试
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