Compare commits
2 Commits
8dd55a4257
...
5f776f896f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f776f896f | |||
| 49e23299da |
@ -206,12 +206,20 @@ const FileInfo = () => {
|
|||||||
collapseKey='preview'
|
collapseKey='preview'
|
||||||
>
|
>
|
||||||
{objectFormState?.objectData?._id ? (
|
{objectFormState?.objectData?._id ? (
|
||||||
<Card styles={{ body: { minHeight: 'calc(100vh - 218px)' } }}>
|
<Spin
|
||||||
<FilePreview
|
spinning={objectFormState.loading}
|
||||||
file={objectFormState?.objectData}
|
indicator={<LoadingOutlined />}
|
||||||
style={{ width: '100%', height: '100%' }}
|
>
|
||||||
/>
|
<Card
|
||||||
</Card>
|
style={{ height: 'calc(100vh - 218px)' }}
|
||||||
|
styles={{ body: { height: '100%', minHeight: 0 } }}
|
||||||
|
>
|
||||||
|
<FilePreview
|
||||||
|
file={objectFormState?.objectData}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
) : (
|
) : (
|
||||||
<MissingPlaceholder message={'No file.'} />
|
<MissingPlaceholder message={'No file.'} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import { Card, Flex } from 'antd'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -8,10 +9,10 @@ import {
|
|||||||
memo,
|
memo,
|
||||||
useRef
|
useRef
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import LoadingPlaceholder from './LoadingPlaceholder'
|
|
||||||
import GCodePreview from './GCodePreview'
|
import GCodePreview from './GCodePreview'
|
||||||
import ThreeDPreview from './ThreeDPreview'
|
import ThreeDPreview from './ThreeDPreview'
|
||||||
import PDFPreview from './PDFPreview'
|
import PDFPreview from './PDFPreview'
|
||||||
|
import ProgressDisplay from './ProgressDisplay'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
|
||||||
const hasExplicitPreviewHeight = (height) =>
|
const hasExplicitPreviewHeight = (height) =>
|
||||||
@ -25,6 +26,10 @@ const FilePreview = ({ file, style = {} }) => {
|
|||||||
const [fileObjectUrl, setFileObjectUrl] = useState(null)
|
const [fileObjectUrl, setFileObjectUrl] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState({
|
||||||
|
progress: 0,
|
||||||
|
message: 'Loading file preview...'
|
||||||
|
})
|
||||||
|
|
||||||
const currentId = useRef(null)
|
const currentId = useRef(null)
|
||||||
|
|
||||||
@ -39,80 +44,147 @@ const FilePreview = ({ file, style = {} }) => {
|
|||||||
if (error != null) {
|
if (error != null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const requestId = file._id
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const objectUrl = await fetchFileContent(file, false)
|
setDownloadProgress({ progress: 0, message: 'Starting download...' })
|
||||||
|
const objectUrl = await fetchFileContent(file, false, (progress) => {
|
||||||
|
if (currentId.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDownloadProgress(progress)
|
||||||
|
})
|
||||||
|
if (currentId.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (objectUrl == null) {
|
if (objectUrl == null) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setDownloadProgress(null)
|
||||||
console.error('Failed to fetch file content', file)
|
console.error('Failed to fetch file content', file)
|
||||||
setError('Failed to fetch file content')
|
setError('Failed to fetch file content')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setFileObjectUrl(objectUrl)
|
setFileObjectUrl(objectUrl)
|
||||||
|
setDownloadProgress(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [file, fetchFileContent, error])
|
}, [file, fetchFileContent, error])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (file._id !== currentId.current) {
|
||||||
|
setFileObjectUrl(null)
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
setDownloadProgress({
|
||||||
|
progress: 0,
|
||||||
|
message: 'Loading file preview...'
|
||||||
|
})
|
||||||
|
}
|
||||||
if (file?.type && token != null && file._id !== currentId.current) {
|
if (file?.type && token != null && file._id !== currentId.current) {
|
||||||
currentId.current = file._id
|
currentId.current = file._id
|
||||||
fetchPreview()
|
fetchPreview()
|
||||||
}
|
}
|
||||||
}, [file._id, file?.type, fetchPreview, token])
|
}, [file._id, file?.type, fetchPreview, token])
|
||||||
|
|
||||||
if (isPdf) {
|
const showProgressOverlay = downloadProgress != null
|
||||||
if (error != null) {
|
const wrapperHeight = hasExplicitPreviewHeight(style.height)
|
||||||
return <div style={{ color: 'red' }}>{error}</div>
|
? style.height
|
||||||
|
: isPdf
|
||||||
|
? '72vh'
|
||||||
|
: style.height
|
||||||
|
|
||||||
|
const renderPreview = () => {
|
||||||
|
if (isPdf) {
|
||||||
|
return (
|
||||||
|
<PDFPreview
|
||||||
|
file={fileObjectUrl}
|
||||||
|
loading={loading}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (loading == true || !file?.type) {
|
||||||
<PDFPreview
|
return null
|
||||||
file={fileObjectUrl}
|
}
|
||||||
loading={loading}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
height: hasExplicitPreviewHeight(style.height)
|
|
||||||
? style.height
|
|
||||||
: '72vh'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading == true || !file?.type) {
|
if (isGcode && fileObjectUrl) {
|
||||||
return <LoadingPlaceholder message={'Loading file preview...'} />
|
return (
|
||||||
|
<GCodePreview
|
||||||
|
src={fileObjectUrl}
|
||||||
|
startLayer={0}
|
||||||
|
endLayer={undefined}
|
||||||
|
lineWidth={1}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is3DModel && fileObjectUrl) {
|
||||||
|
return (
|
||||||
|
<ThreeDPreview
|
||||||
|
src={fileObjectUrl}
|
||||||
|
style={style}
|
||||||
|
extension={file.extension}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImage && fileObjectUrl) {
|
||||||
|
return <img src={fileObjectUrl} style={style}></img>
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
return <div style={{ color: 'red' }}>{error}</div>
|
return <div style={{ color: 'red' }}>{error}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGcode && fileObjectUrl) {
|
return (
|
||||||
return (
|
<div
|
||||||
<GCodePreview
|
style={{
|
||||||
src={fileObjectUrl}
|
...style,
|
||||||
topLayerColor={'#ff9800'}
|
position: 'relative',
|
||||||
lastSegmentColor={'#e91e63'}
|
height: wrapperHeight,
|
||||||
startLayer={0}
|
minHeight:
|
||||||
endLayer={undefined}
|
showProgressOverlay && !wrapperHeight ? 240 : style.minHeight
|
||||||
lineWidth={1}
|
}}
|
||||||
style={style}
|
>
|
||||||
/>
|
<div
|
||||||
)
|
className={`previewProgressOverlay ${showProgressOverlay ? 'visible' : 'hidden'}`}
|
||||||
}
|
>
|
||||||
|
{showProgressOverlay ? (
|
||||||
if (is3DModel && fileObjectUrl) {
|
<Flex
|
||||||
return (
|
style={{ width: '100%', height: '100%' }}
|
||||||
<ThreeDPreview
|
align='center'
|
||||||
src={fileObjectUrl}
|
justify='center'
|
||||||
style={style}
|
>
|
||||||
extension={file.extension}
|
<Card
|
||||||
/>
|
style={{
|
||||||
)
|
maxWidth: 360,
|
||||||
}
|
minWidth: 100,
|
||||||
|
width: '100%',
|
||||||
if (isImage && fileObjectUrl) {
|
margin: 24
|
||||||
return <img src={fileObjectUrl} style={style}></img>
|
}}
|
||||||
}
|
styles={{ body: { padding: 18 } }}
|
||||||
return null
|
>
|
||||||
|
<ProgressDisplay
|
||||||
|
percent={Math.round(
|
||||||
|
(Number(downloadProgress.progress) || 0) * 100
|
||||||
|
)}
|
||||||
|
status='active'
|
||||||
|
>
|
||||||
|
{downloadProgress.message || 'Downloading...'}
|
||||||
|
</ProgressDisplay>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{renderPreview()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
FilePreview.propTypes = {
|
FilePreview.propTypes = {
|
||||||
@ -124,6 +196,8 @@ FilePreview.propTypes = {
|
|||||||
const areEqual = (prevProps, nextProps) => {
|
const areEqual = (prevProps, nextProps) => {
|
||||||
return (
|
return (
|
||||||
prevProps.file?._id === nextProps.file?._id &&
|
prevProps.file?._id === nextProps.file?._id &&
|
||||||
|
prevProps.file?.type === nextProps.file?.type &&
|
||||||
|
prevProps.file?.size === nextProps.file?.size &&
|
||||||
JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)
|
JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,67 @@
|
|||||||
import * as GCodePreview from 'gcode-preview'
|
import * as GCodePreview from 'gcode-preview'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { Button, Card, Flex, Slider } from 'antd'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import PlusIcon from '../../Icons/PlusIcon.jsx'
|
||||||
|
import MinusIcon from '../../Icons/MinusIcon.jsx'
|
||||||
|
import PanIcon from '../../Icons/PanIcon.jsx'
|
||||||
|
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut.jsx'
|
||||||
|
import usePinchZoom, {
|
||||||
|
clampPreviewScale,
|
||||||
|
MAX_PREVIEW_SCALE,
|
||||||
|
MIN_PREVIEW_SCALE
|
||||||
|
} from '../hooks/usePinchZoom.js'
|
||||||
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
function toThreeColorHex(color, fallback) {
|
||||||
|
try {
|
||||||
|
return new THREE.Color(color || fallback).getHex()
|
||||||
|
} catch {
|
||||||
|
return new THREE.Color(fallback).getHex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewThemeColors({
|
||||||
|
isDarkMode,
|
||||||
|
token,
|
||||||
|
topLayerColor,
|
||||||
|
lastSegmentColor
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
|
extrusionColor: token.colorPrimary,
|
||||||
|
travelColor: isDarkMode ? '#8c8c8c' : '#bfbfbf',
|
||||||
|
topLayerColor: topLayerColor || token.colorWarning,
|
||||||
|
lastSegmentColor: lastSegmentColor || token.colorPink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraDistance(preview) {
|
||||||
|
const distance = preview.camera.position.distanceTo(preview.controls.target)
|
||||||
|
return Number.isFinite(distance) && distance > 0 ? distance : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraScale(preview, baseDistance) {
|
||||||
|
const distance = getCameraDistance(preview)
|
||||||
|
return distance == null ? null : clampPreviewScale(baseDistance / distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCameraScale(preview, baseDistance, scale) {
|
||||||
|
const { camera, controls } = preview
|
||||||
|
const distance = getCameraDistance(preview)
|
||||||
|
if (distance == null) return
|
||||||
|
|
||||||
|
const nextDistance = baseDistance / scale
|
||||||
|
if (Math.abs(nextDistance - distance) <= distance * 1e-4) return
|
||||||
|
|
||||||
|
const offset = camera.position.clone().sub(controls.target)
|
||||||
|
camera.position
|
||||||
|
.copy(controls.target)
|
||||||
|
.addScaledVector(offset, nextDistance / distance)
|
||||||
|
controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
function GCodePreviewUI(props) {
|
function GCodePreviewUI(props) {
|
||||||
const {
|
const {
|
||||||
@ -13,40 +73,107 @@ function GCodePreviewUI(props) {
|
|||||||
lineWidth,
|
lineWidth,
|
||||||
style = {}
|
style = {}
|
||||||
} = props
|
} = props
|
||||||
|
const { isDarkMode, themeConfig } = useThemeContext()
|
||||||
|
const { colorPrimary, colorWarning, colorPink } = themeConfig.token
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
|
const previewPaneRef = useRef(null)
|
||||||
const previewRef = useRef(null)
|
const previewRef = useRef(null)
|
||||||
|
const baseDistanceRef = useRef(1)
|
||||||
|
const applyingScaleRef = useRef(false)
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
const [previewScale, setPreviewScale] = useState(1)
|
||||||
|
const [panMode, setPanMode] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
const resizePreview = useCallback(() => {
|
const resizePreview = useCallback(() => {
|
||||||
previewRef.current?.resize()
|
previewRef.current?.resize()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Ex-ref methods removed; this component is now a regular functional component
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current) return
|
if (!canvasRef.current) return
|
||||||
|
|
||||||
|
const themeColors = getPreviewThemeColors({
|
||||||
|
isDarkMode,
|
||||||
|
token: { colorPrimary, colorWarning, colorPink },
|
||||||
|
topLayerColor,
|
||||||
|
lastSegmentColor
|
||||||
|
})
|
||||||
|
|
||||||
previewRef.current?.dispose?.()
|
previewRef.current?.dispose?.()
|
||||||
previewRef.current = GCodePreview.init({
|
const instance = GCodePreview.init({
|
||||||
canvas: canvasRef.current,
|
canvas: canvasRef.current,
|
||||||
startLayer,
|
startLayer,
|
||||||
endLayer,
|
endLayer,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
topLayerColor: new THREE.Color(topLayerColor).getHex(),
|
backgroundColor: toThreeColorHex(themeColors.backgroundColor, '#ffffff'),
|
||||||
lastSegmentColor: new THREE.Color(lastSegmentColor).getHex(),
|
extrusionColor: toThreeColorHex(themeColors.extrusionColor, '#0091FF'),
|
||||||
|
travelColor: toThreeColorHex(themeColors.travelColor, '#8c8c8c'),
|
||||||
|
topLayerColor: toThreeColorHex(themeColors.topLayerColor, '#FF9230'),
|
||||||
|
lastSegmentColor: toThreeColorHex(
|
||||||
|
themeColors.lastSegmentColor,
|
||||||
|
'#FF69B4'
|
||||||
|
),
|
||||||
buildVolume: { x: 250, y: 220, z: 150 },
|
buildVolume: { x: 250, y: 220, z: 150 },
|
||||||
initialCameraPosition: [0, 400, 450],
|
initialCameraPosition: [0, 400, 450],
|
||||||
allowDragNDrop: false
|
allowDragNDrop: false
|
||||||
})
|
})
|
||||||
|
previewRef.current = instance
|
||||||
|
|
||||||
|
const { camera, controls } = instance
|
||||||
|
baseDistanceRef.current = getCameraDistance(instance) || 1
|
||||||
|
// The camera dollies instead of scaling pixels, so the frustum has to
|
||||||
|
// cover the full zoom range.
|
||||||
|
camera.far = Math.max(
|
||||||
|
camera.far,
|
||||||
|
(baseDistanceRef.current / MIN_PREVIEW_SCALE) * 2
|
||||||
|
)
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
controls.minDistance = baseDistanceRef.current / MAX_PREVIEW_SCALE
|
||||||
|
controls.maxDistance = baseDistanceRef.current / MIN_PREVIEW_SCALE
|
||||||
|
|
||||||
|
const handleControlsChange = () => {
|
||||||
|
if (applyingScaleRef.current) return
|
||||||
|
const scale = getCameraScale(instance, baseDistanceRef.current)
|
||||||
|
if (scale == null) return
|
||||||
|
setPreviewScale((prev) => (Math.abs(prev - scale) < 0.001 ? prev : scale))
|
||||||
|
}
|
||||||
|
const handleControlsStart = () => setIsDragging(true)
|
||||||
|
const handleControlsEnd = () => setIsDragging(false)
|
||||||
|
|
||||||
|
controls.addEventListener('change', handleControlsChange)
|
||||||
|
controls.addEventListener('start', handleControlsStart)
|
||||||
|
controls.addEventListener('end', handleControlsEnd)
|
||||||
|
|
||||||
|
instance.resize()
|
||||||
|
setPreview(instance)
|
||||||
|
|
||||||
|
const pane = previewPaneRef.current
|
||||||
|
const resizeObserver =
|
||||||
|
pane && typeof ResizeObserver === 'function'
|
||||||
|
? new ResizeObserver(resizePreview)
|
||||||
|
: null
|
||||||
|
resizeObserver?.observe(pane)
|
||||||
window.addEventListener('resize', resizePreview)
|
window.addEventListener('resize', resizePreview)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
window.removeEventListener('resize', resizePreview)
|
window.removeEventListener('resize', resizePreview)
|
||||||
previewRef.current?.dispose?.()
|
controls.removeEventListener('change', handleControlsChange)
|
||||||
previewRef.current = null
|
controls.removeEventListener('start', handleControlsStart)
|
||||||
|
controls.removeEventListener('end', handleControlsEnd)
|
||||||
|
setPreview(null)
|
||||||
|
setIsDragging(false)
|
||||||
|
instance.dispose?.()
|
||||||
|
if (previewRef.current === instance) {
|
||||||
|
previewRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
colorPink,
|
||||||
|
colorPrimary,
|
||||||
|
colorWarning,
|
||||||
endLayer,
|
endLayer,
|
||||||
|
isDarkMode,
|
||||||
lastSegmentColor,
|
lastSegmentColor,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
startLayer,
|
startLayer,
|
||||||
@ -54,17 +181,49 @@ function GCodePreviewUI(props) {
|
|||||||
resizePreview
|
resizePreview
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
applyingScaleRef.current = true
|
||||||
|
applyCameraScale(preview, baseDistanceRef.current, previewScale)
|
||||||
|
applyingScaleRef.current = false
|
||||||
|
}, [preview, previewScale])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
preview.controls.mouseButtons = {
|
||||||
|
LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
|
RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN
|
||||||
|
}
|
||||||
|
preview.controls.touches = {
|
||||||
|
ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN
|
||||||
|
}
|
||||||
|
}, [preview, panMode])
|
||||||
|
|
||||||
|
usePinchZoom({
|
||||||
|
containerRef: previewPaneRef,
|
||||||
|
scale: previewScale,
|
||||||
|
onScaleChange: setPreviewScale,
|
||||||
|
enabled: preview != null
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
preview?.controls.reset()
|
||||||
|
setPreviewScale(1)
|
||||||
|
}, [preview])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const loadFromSrc = async () => {
|
const loadFromSrc = async () => {
|
||||||
const preview = previewRef.current
|
const instance = previewRef.current
|
||||||
if (!src || !preview) return
|
if (!src || !instance) return
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src)
|
const response = await fetch(src)
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
if (cancelled || previewRef.current !== preview) return
|
if (cancelled || previewRef.current !== instance) return
|
||||||
preview.processGCode(text)
|
instance.processGCode(text)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
console.error('Failed to load G-code from src', e)
|
console.error('Failed to load G-code from src', e)
|
||||||
@ -75,9 +234,124 @@ function GCodePreviewUI(props) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [endLayer, lastSegmentColor, lineWidth, src, startLayer, topLayerColor])
|
}, [
|
||||||
|
colorPink,
|
||||||
|
colorPrimary,
|
||||||
|
colorWarning,
|
||||||
|
endLayer,
|
||||||
|
isDarkMode,
|
||||||
|
lastSegmentColor,
|
||||||
|
lineWidth,
|
||||||
|
src,
|
||||||
|
startLayer,
|
||||||
|
topLayerColor
|
||||||
|
])
|
||||||
|
|
||||||
return <canvas ref={canvasRef} style={style}></canvas>
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={'middle'}
|
||||||
|
style={{ width: '100%', height: '100%', minHeight: 0, ...style }}
|
||||||
|
>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<KeyboardShortcut
|
||||||
|
shortcut={'alt+p'}
|
||||||
|
hint={'ALT P'}
|
||||||
|
onTrigger={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
panMode ? (
|
||||||
|
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||||
|
) : (
|
||||||
|
<PanIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardShortcut>
|
||||||
|
<Button
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
readOnly={true}
|
||||||
|
style={{ minWidth: '70px' }}
|
||||||
|
onClick={resetView}
|
||||||
|
>
|
||||||
|
{previewScale.toFixed(2)}x
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<MinusIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={previewPaneRef}
|
||||||
|
style={{ flex: '1 1 auto', minHeight: 0, position: 'relative' }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--layout-modal-bg)',
|
||||||
|
cursor: panMode ? (isDragging ? 'grabbing' : 'grab') : undefined
|
||||||
|
}}
|
||||||
|
></canvas>
|
||||||
|
</Card>
|
||||||
|
<div className='previewZoomOverlay'>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 18 }}
|
||||||
|
styles={{ body: { padding: '10px 17px', width: 200 } }}
|
||||||
|
className='previewZoomCard'
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={MIN_PREVIEW_SCALE}
|
||||||
|
max={MAX_PREVIEW_SCALE}
|
||||||
|
step={0.05}
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
value={previewScale}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
onChange={(value) => setPreviewScale(value)}
|
||||||
|
classNames={{
|
||||||
|
handle: 'previewZoomSliderHandle'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
track: {
|
||||||
|
background: 'var(--color-primary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
GCodePreviewUI.propTypes = {
|
GCodePreviewUI.propTypes = {
|
||||||
|
|||||||
@ -1,7 +1,190 @@
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import { Button, Card, Flex, Slider } from 'antd'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as OV from 'online-3d-viewer'
|
import * as OV from 'online-3d-viewer'
|
||||||
import LoadingPlaceholder from './LoadingPlaceholder'
|
import LoadingPlaceholder from './LoadingPlaceholder'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
|
import PlusIcon from '../../Icons/PlusIcon.jsx'
|
||||||
|
import MinusIcon from '../../Icons/MinusIcon.jsx'
|
||||||
|
import PanIcon from '../../Icons/PanIcon.jsx'
|
||||||
|
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
||||||
|
import usePinchZoom, {
|
||||||
|
clampPreviewScale,
|
||||||
|
nextScaleFromWheel,
|
||||||
|
MAX_PREVIEW_SCALE,
|
||||||
|
MIN_PREVIEW_SCALE
|
||||||
|
} from '../hooks/usePinchZoom.js'
|
||||||
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
const DEG_TO_RAD = Math.PI / 180
|
||||||
|
|
||||||
|
function parseHexColor(color) {
|
||||||
|
if (!color) return null
|
||||||
|
const match = String(color)
|
||||||
|
.trim()
|
||||||
|
.match(/^#([0-9a-f]{3,8})$/i)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
let hex = match[1]
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((char) => char + char)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
if (hex.length !== 6 && hex.length !== 8) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(0, 2), 16),
|
||||||
|
g: parseInt(hex.slice(2, 4), 16),
|
||||||
|
b: parseInt(hex.slice(4, 6), 16),
|
||||||
|
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(color, fallbackHex) {
|
||||||
|
const fallback = parseHexColor(fallbackHex) || {
|
||||||
|
r: 255,
|
||||||
|
g: 255,
|
||||||
|
b: 255,
|
||||||
|
a: 255
|
||||||
|
}
|
||||||
|
if (!color) return fallback
|
||||||
|
|
||||||
|
const hex = parseHexColor(color)
|
||||||
|
if (hex) return hex
|
||||||
|
|
||||||
|
const rgbMatch = String(color)
|
||||||
|
.trim()
|
||||||
|
.match(
|
||||||
|
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i
|
||||||
|
)
|
||||||
|
if (!rgbMatch) return fallback
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Number(rgbMatch[1]),
|
||||||
|
g: Number(rgbMatch[2]),
|
||||||
|
b: Number(rgbMatch[3]),
|
||||||
|
a:
|
||||||
|
rgbMatch[4] === undefined
|
||||||
|
? 255
|
||||||
|
: Math.round(Math.min(1, Number(rgbMatch[4])) * 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCssVarColor(name, fallbackHex) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return parseCssColor(fallbackHex, fallbackHex)
|
||||||
|
}
|
||||||
|
const value = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(name)
|
||||||
|
.trim()
|
||||||
|
return parseCssColor(value, fallbackHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewerThemeColors(backgroundColorOverride) {
|
||||||
|
return {
|
||||||
|
background: backgroundColorOverride
|
||||||
|
? parseCssColor(backgroundColorOverride, '#ffffff')
|
||||||
|
: getCssVarColor('--layout-modal-bg', '#ffffff'),
|
||||||
|
defaultColor: getCssVarColor('--color-primary', '#0091FF'),
|
||||||
|
edgeColor: getCssVarColor('--color-text', '#000000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgbaColor(color) {
|
||||||
|
return new OV.RGBAColor(color.r, color.g, color.b, color.a ?? 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgbColor(color) {
|
||||||
|
return new OV.RGBColor(color.r, color.g, color.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyViewerTheme(embeddedViewer, colors) {
|
||||||
|
if (!embeddedViewer?.GetViewer) return
|
||||||
|
const viewer3d = embeddedViewer.GetViewer()
|
||||||
|
viewer3d.SetBackgroundColor(toRgbaColor(colors.background))
|
||||||
|
viewer3d.SetEdgeSettings(
|
||||||
|
new OV.EdgeSettings(false, toRgbColor(colors.edgeColor), 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitModelToViewport(embeddedViewer) {
|
||||||
|
const viewer3d = embeddedViewer?.GetViewer?.()
|
||||||
|
if (!viewer3d) return
|
||||||
|
embeddedViewer.Resize?.()
|
||||||
|
const boundingSphere = viewer3d.GetBoundingSphere?.(() => true)
|
||||||
|
if (!boundingSphere) return
|
||||||
|
viewer3d.AdjustClippingPlanesToSphere?.(boundingSphere)
|
||||||
|
viewer3d.FitSphereToWindow?.(boundingSphere, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewer3d(embeddedViewer) {
|
||||||
|
return embeddedViewer?.GetViewer?.() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraDistance(camera) {
|
||||||
|
return OV.CoordDistance3D(camera.eye, camera.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The viewer derives its clipping planes from the model size alone, which leaves
|
||||||
|
// the model behind the far plane once the eye is far enough away, so widen them
|
||||||
|
// based on the eye distance as well.
|
||||||
|
function adjustClippingToDistance(viewer3d, distance) {
|
||||||
|
const boundingSphere = viewer3d.GetBoundingSphere?.(() => true)
|
||||||
|
if (!boundingSphere) return
|
||||||
|
const radius = Math.max(
|
||||||
|
boundingSphere.radius,
|
||||||
|
(distance + boundingSphere.radius) / 10
|
||||||
|
)
|
||||||
|
viewer3d.AdjustClippingPlanesToSphere?.({
|
||||||
|
center: boundingSphere.center,
|
||||||
|
radius
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zooming moves the eye along the view axis, keeping the look-at point fixed.
|
||||||
|
function setCameraDistance(viewer3d, distance) {
|
||||||
|
const camera = viewer3d.GetCamera().Clone()
|
||||||
|
const eyeDirection = OV.SubCoord3D(camera.eye, camera.center).Normalize()
|
||||||
|
camera.eye = camera.center.Clone().Offset(eyeDirection, distance)
|
||||||
|
adjustClippingToDistance(viewer3d, distance)
|
||||||
|
viewer3d.SetCamera(camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorldUnitsPerPixel(viewer3d, camera) {
|
||||||
|
const canvas = viewer3d.GetCanvas?.()
|
||||||
|
const canvasHeight = canvas?.clientHeight || canvas?.height || 1
|
||||||
|
const viewportHeight =
|
||||||
|
2 * getCameraDistance(camera) * Math.tan((camera.fov / 2) * DEG_TO_RAD)
|
||||||
|
return viewportHeight / canvasHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panning moves the eye and the look-at point together, so the model tracks the
|
||||||
|
// pointer one to one at the current zoom level.
|
||||||
|
function panCameraByPixels(viewer3d, pixelsX, pixelsY) {
|
||||||
|
const camera = viewer3d.GetCamera().Clone()
|
||||||
|
const viewDirection = OV.SubCoord3D(camera.center, camera.eye).Normalize()
|
||||||
|
const horizontalDirection = OV.CrossVector3D(
|
||||||
|
viewDirection,
|
||||||
|
camera.up
|
||||||
|
).Normalize()
|
||||||
|
const verticalDirection = OV.CrossVector3D(
|
||||||
|
horizontalDirection,
|
||||||
|
viewDirection
|
||||||
|
).Normalize()
|
||||||
|
|
||||||
|
const unitsPerPixel = getWorldUnitsPerPixel(viewer3d, camera)
|
||||||
|
const moveX = pixelsX * unitsPerPixel
|
||||||
|
const moveY = pixelsY * unitsPerPixel
|
||||||
|
|
||||||
|
camera.eye.Offset(horizontalDirection, -moveX)
|
||||||
|
camera.center.Offset(horizontalDirection, -moveX)
|
||||||
|
camera.eye.Offset(verticalDirection, moveY)
|
||||||
|
camera.center.Offset(verticalDirection, moveY)
|
||||||
|
|
||||||
|
viewer3d.SetCamera(camera)
|
||||||
|
}
|
||||||
|
|
||||||
function ThreeDPreview(props) {
|
function ThreeDPreview(props) {
|
||||||
const {
|
const {
|
||||||
@ -10,25 +193,59 @@ function ThreeDPreview(props) {
|
|||||||
width = 500,
|
width = 500,
|
||||||
height = 500,
|
height = 500,
|
||||||
style = {},
|
style = {},
|
||||||
backgroundColor = '#ffffff'
|
backgroundColor,
|
||||||
|
enableControls = true
|
||||||
} = props
|
} = props
|
||||||
|
const { isDarkMode } = useThemeContext()
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const viewer = useRef(null)
|
const viewer = useRef(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [zoomScale, setZoomScale] = useState(1)
|
||||||
|
const [panMode, setPanMode] = useState(false)
|
||||||
|
const [isPanning, setIsPanning] = useState(false)
|
||||||
|
// Eye to center distance of the fitted view, which is what 1.00x means here.
|
||||||
|
const fitDistanceRef = useRef(null)
|
||||||
|
const zoomScaleRef = useRef(zoomScale)
|
||||||
|
zoomScaleRef.current = zoomScale
|
||||||
|
const panStartRef = useRef({ x: 0, y: 0 })
|
||||||
|
|
||||||
const resizeViewer = useCallback(() => {
|
const resizeViewer = useCallback(() => {
|
||||||
if (viewer.current && containerRef.current) {
|
viewer.current?.Resize?.()
|
||||||
// Resize the viewer container
|
}, [])
|
||||||
const container = containerRef.current
|
|
||||||
if (container.style.width !== width + 'px') {
|
const applyZoomScale = useCallback((nextScale) => {
|
||||||
container.style.width = width + 'px'
|
const viewer3d = getViewer3d(viewer.current)
|
||||||
}
|
const fitDistance = fitDistanceRef.current
|
||||||
if (container.style.height !== height + 'px') {
|
if (!viewer3d || !fitDistance) return
|
||||||
container.style.height = height + 'px'
|
const scale = clampPreviewScale(nextScale)
|
||||||
}
|
setCameraDistance(viewer3d, fitDistance / scale)
|
||||||
}
|
setZoomScale(scale)
|
||||||
}, [viewer, width, height])
|
}, [])
|
||||||
|
|
||||||
|
const captureFittedZoom = useCallback(() => {
|
||||||
|
const viewer3d = getViewer3d(viewer.current)
|
||||||
|
if (!viewer3d) return
|
||||||
|
fitDistanceRef.current = getCameraDistance(viewer3d.GetCamera())
|
||||||
|
setZoomScale(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The viewer handles orbit and ctrl/two finger zoom itself, so read the camera
|
||||||
|
// back once a gesture ends to keep the readout and the zoom limits in step.
|
||||||
|
const syncZoomScaleFromCamera = useCallback(() => {
|
||||||
|
const viewer3d = getViewer3d(viewer.current)
|
||||||
|
const fitDistance = fitDistanceRef.current
|
||||||
|
if (!viewer3d || !fitDistance) return
|
||||||
|
const distance = getCameraDistance(viewer3d.GetCamera())
|
||||||
|
if (distance <= 0) return
|
||||||
|
applyZoomScale(fitDistance / distance)
|
||||||
|
}, [applyZoomScale])
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
if (!viewer.current) return
|
||||||
|
fitModelToViewport(viewer.current)
|
||||||
|
captureFittedZoom()
|
||||||
|
}, [captureFittedZoom])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Variable to track the viewer instance created in this effect
|
// Variable to track the viewer instance created in this effect
|
||||||
@ -40,6 +257,8 @@ function ThreeDPreview(props) {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
fitDistanceRef.current = null
|
||||||
|
setZoomScale(1)
|
||||||
|
|
||||||
// Clear any existing viewer
|
// Clear any existing viewer
|
||||||
if (viewer.current) {
|
if (viewer.current) {
|
||||||
@ -60,6 +279,8 @@ function ThreeDPreview(props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeColors = getViewerThemeColors(backgroundColor)
|
||||||
|
|
||||||
// Initialize the online-3d-viewer using OV.EmbeddedViewer
|
// Initialize the online-3d-viewer using OV.EmbeddedViewer
|
||||||
const newViewer = new OV.EmbeddedViewer(containerRef.current, {
|
const newViewer = new OV.EmbeddedViewer(containerRef.current, {
|
||||||
camera: new OV.Camera(
|
camera: new OV.Camera(
|
||||||
@ -68,15 +289,34 @@ function ThreeDPreview(props) {
|
|||||||
new OV.Coord3D(0.0, 1.0, 0.0),
|
new OV.Coord3D(0.0, 1.0, 0.0),
|
||||||
45.0
|
45.0
|
||||||
),
|
),
|
||||||
backgroundColor: new OV.RGBAColor(255, 255, 255, 255),
|
backgroundColor: toRgbaColor(themeColors.background),
|
||||||
defaultColor: new OV.RGBColor(200, 200, 200),
|
defaultColor: toRgbColor(themeColors.defaultColor),
|
||||||
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
|
edgeSettings: new OV.EdgeSettings(
|
||||||
environmentSettings: new OV.EnvironmentSettings([], false)
|
false,
|
||||||
|
toRgbColor(themeColors.edgeColor),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
environmentSettings: new OV.EnvironmentSettings([], false),
|
||||||
|
onModelLoaded: () => {
|
||||||
|
if (currentViewer !== newViewer) return
|
||||||
|
fitModelToViewport(newViewer)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (currentViewer !== newViewer) return
|
||||||
|
fitModelToViewport(newViewer)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (currentViewer !== newViewer) return
|
||||||
|
fitModelToViewport(newViewer)
|
||||||
|
captureFittedZoom()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store the viewer instance in the ref and local variable
|
// Store the viewer instance in the ref and local variable
|
||||||
viewer.current = newViewer
|
viewer.current = newViewer
|
||||||
currentViewer = newViewer
|
currentViewer = newViewer
|
||||||
|
newViewer.Resize?.()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@ -92,8 +332,7 @@ function ThreeDPreview(props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Load model from file using LoadModelFromFileList
|
// Load model from file using LoadModelFromFileList
|
||||||
await newViewer.LoadModelFromFileList([file])
|
newViewer.LoadModelFromFileList([file])
|
||||||
setIsLoading(false)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load 3D model from src', err)
|
console.error('Failed to load 3D model from src', err)
|
||||||
setError('Failed to load 3D model')
|
setError('Failed to load 3D model')
|
||||||
@ -108,54 +347,342 @@ function ThreeDPreview(props) {
|
|||||||
|
|
||||||
initializeViewer()
|
initializeViewer()
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
const resizeObserver =
|
||||||
|
container && typeof ResizeObserver !== 'undefined'
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
resizeViewer()
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
resizeObserver?.observe(container)
|
||||||
window.addEventListener('resize', resizeViewer)
|
window.addEventListener('resize', resizeViewer)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
window.removeEventListener('resize', resizeViewer)
|
window.removeEventListener('resize', resizeViewer)
|
||||||
if (currentViewer && currentViewer.dispose) {
|
if (currentViewer && currentViewer.dispose) {
|
||||||
currentViewer.dispose()
|
currentViewer.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [width, height, src, extension, resizeViewer])
|
}, [src, extension, resizeViewer, backgroundColor, captureFittedZoom])
|
||||||
|
|
||||||
const containerStyle = {
|
useEffect(() => {
|
||||||
width: width + 'px',
|
let cancelled = false
|
||||||
height: height + 'px',
|
|
||||||
backgroundColor,
|
const applyTheme = () => {
|
||||||
position: 'relative',
|
if (cancelled || !viewer.current) return
|
||||||
...style
|
applyViewerTheme(viewer.current, getViewerThemeColors(backgroundColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme()
|
||||||
|
// ThemeContext writes CSS vars in a parent effect; re-read after it runs.
|
||||||
|
queueMicrotask(applyTheme)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isDarkMode, backgroundColor])
|
||||||
|
|
||||||
|
// Trackpad pinch and ctrl+wheel, shared with the PDF and template previews.
|
||||||
|
usePinchZoom({
|
||||||
|
containerRef,
|
||||||
|
scale: zoomScale,
|
||||||
|
onScaleChange: applyZoomScale,
|
||||||
|
enabled: enableControls
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plain wheel zooms too, but through our clamped scale instead of the
|
||||||
|
// viewer's own unbounded wheel handler.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!enableControls || !container) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { capture: true, passive: false }
|
||||||
|
const handleWheel = (event) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.target?.closest?.('.previewZoomOverlay')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
applyZoomScale(nextScaleFromWheel(zoomScaleRef.current, event))
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('wheel', handleWheel, options)
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleWheel, options)
|
||||||
|
}
|
||||||
|
}, [applyZoomScale, enableControls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!enableControls || !container) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('pointerup', syncZoomScaleFromCamera)
|
||||||
|
container.addEventListener('pointercancel', syncZoomScaleFromCamera)
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('pointerup', syncZoomScaleFromCamera)
|
||||||
|
container.removeEventListener('pointercancel', syncZoomScaleFromCamera)
|
||||||
|
}
|
||||||
|
}, [enableControls, syncZoomScaleFromCamera])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panMode) {
|
||||||
|
setIsPanning(false)
|
||||||
|
}
|
||||||
|
}, [panMode])
|
||||||
|
|
||||||
|
// In pan mode a left drag pans instead of orbiting, so the viewer's own
|
||||||
|
// navigation must not see those events.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!panMode || !container) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { capture: true, passive: false }
|
||||||
|
const blockViewerNavigation = (event) => {
|
||||||
|
if (event.type === 'mousedown' && event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panStartRef.current = { x: event.clientX, y: event.clientY }
|
||||||
|
setIsPanning(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('mousedown', blockViewerNavigation, options)
|
||||||
|
container.addEventListener('touchstart', blockViewerNavigation, options)
|
||||||
|
container.addEventListener('touchmove', blockViewerNavigation, options)
|
||||||
|
container.addEventListener('pointerdown', handlePointerDown)
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('mousedown', blockViewerNavigation, options)
|
||||||
|
container.removeEventListener(
|
||||||
|
'touchstart',
|
||||||
|
blockViewerNavigation,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
container.removeEventListener('touchmove', blockViewerNavigation, options)
|
||||||
|
container.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
}
|
||||||
|
}, [panMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPanning) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCursor = document.body.style.cursor
|
||||||
|
const previousUserSelect = document.body.style.userSelect
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
|
||||||
|
const handlePointerMove = (event) => {
|
||||||
|
const viewer3d = getViewer3d(viewer.current)
|
||||||
|
if (!viewer3d) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { x, y } = panStartRef.current
|
||||||
|
panStartRef.current = { x: event.clientX, y: event.clientY }
|
||||||
|
panCameraByPixels(viewer3d, event.clientX - x, event.clientY - y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
setIsPanning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp)
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = previousCursor
|
||||||
|
document.body.style.userSelect = previousUserSelect
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [isPanning])
|
||||||
|
|
||||||
|
// The wrapper owns the size the caller asked for; the preview pane takes
|
||||||
|
// whatever is left once the toolbar has been laid out.
|
||||||
|
const { width: styleWidth, height: styleHeight, ...paneStyle } = style
|
||||||
|
|
||||||
|
const wrapperStyle = {
|
||||||
|
width: styleWidth ?? width + 'px',
|
||||||
|
height: styleHeight ?? height + 'px',
|
||||||
|
minHeight: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: backgroundColor || 'var(--layout-modal-bg)',
|
||||||
|
position: 'relative',
|
||||||
|
...paneStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlsDisabled = isLoading || error != null
|
||||||
|
const panCursor = panMode ? (isPanning ? 'grabbing' : 'grab') : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<Flex vertical gap={'middle'} style={wrapperStyle}>
|
||||||
<div
|
{enableControls ? (
|
||||||
ref={containerRef}
|
<Flex gap={'small'}>
|
||||||
style={{
|
<KeyboardShortcut
|
||||||
width: '100%',
|
shortcut={'alt+p'}
|
||||||
height: '100%',
|
hint={'ALT P'}
|
||||||
position: 'relative'
|
onTrigger={() => {
|
||||||
}}
|
setPanMode((prev) => !prev)
|
||||||
/>
|
}}
|
||||||
{isLoading && <LoadingPlaceholder message={'Loading 3D preview...'} />}
|
>
|
||||||
{error && (
|
<Button
|
||||||
|
icon={
|
||||||
|
panMode ? (
|
||||||
|
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||||
|
) : (
|
||||||
|
<PanIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardShortcut>
|
||||||
|
<Button
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
applyZoomScale(zoomScale + 0.05)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
readOnly={true}
|
||||||
|
style={{ minWidth: '70px' }}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onClick={resetView}
|
||||||
|
>
|
||||||
|
{zoomScale.toFixed(2)}x
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<MinusIcon />}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
applyZoomScale(zoomScale - 0.05)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
|
<div style={containerStyle}>
|
||||||
|
{enableControls ? (
|
||||||
|
<div className='previewZoomOverlay'>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 18 }}
|
||||||
|
styles={{ body: { padding: '10px 17px', width: 200 } }}
|
||||||
|
className='previewZoomCard'
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={MIN_PREVIEW_SCALE}
|
||||||
|
max={MAX_PREVIEW_SCALE}
|
||||||
|
step={0.05}
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
value={zoomScale}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
onChange={applyZoomScale}
|
||||||
|
classNames={{
|
||||||
|
handle: 'previewZoomSliderHandle'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
track: {
|
||||||
|
background: 'var(--color-primary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: backgroundColor || 'var(--layout-modal-bg)'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
opacity: isLoading ? 0 : 1,
|
||||||
|
minHeight: 0,
|
||||||
|
cursor: panCursor,
|
||||||
|
touchAction: panMode ? 'none' : undefined,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
// The viewer appends a plain inline canvas, whose baseline gap
|
||||||
|
// would otherwise push it past the bottom of the card.
|
||||||
|
lineHeight: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
inset: 0,
|
||||||
left: '50%',
|
opacity: isLoading ? 1 : 0,
|
||||||
transform: 'translate(-50%, -50%)',
|
zIndex: 1,
|
||||||
background: 'rgba(255, 0, 0, 0.1)',
|
display: 'flex',
|
||||||
color: '#d32f2f',
|
pointerEvents: !isLoading ? 'none' : 'auto',
|
||||||
padding: '10px',
|
alignItems: 'center',
|
||||||
borderRadius: '4px',
|
justifyContent: 'center'
|
||||||
fontSize: '14px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
<LoadingPlaceholder message={'Loading 3D preview...'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background:
|
||||||
|
'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1829,8 +1829,11 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download GCode file content
|
// Download GCode file content
|
||||||
const fetchFileContent = async (file, download = false) => {
|
const fetchFileContent = async (file, download = false, onProgress = null) => {
|
||||||
try {
|
try {
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
onProgress({ progress: 0, message: 'Starting download...' })
|
||||||
|
}
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${config.backendUrl}/files/${file._id}/content`,
|
`${config.backendUrl}/files/${file._id}/content`,
|
||||||
{
|
{
|
||||||
@ -1838,13 +1841,32 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
Accept: '*/*',
|
Accept: '*/*',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
onDownloadProgress: (progressEvent) => {
|
||||||
|
if (typeof onProgress !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const total =
|
||||||
|
progressEvent.total > 0
|
||||||
|
? progressEvent.total
|
||||||
|
: typeof file.size === 'number' && file.size > 0
|
||||||
|
? file.size
|
||||||
|
: 0
|
||||||
|
const progress = total > 0 ? progressEvent.loaded / total : 0
|
||||||
|
onProgress({
|
||||||
|
progress: Math.min(progress, 1),
|
||||||
|
message: 'Downloading...'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const blob = new Blob([response.data], {
|
const blob = new Blob([response.data], {
|
||||||
type: response.headers['content-type']
|
type: response.headers['content-type']
|
||||||
})
|
})
|
||||||
const fileURL = window.URL.createObjectURL(blob)
|
const fileURL = window.URL.createObjectURL(blob)
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
onProgress({ progress: 1, message: 'Download complete' })
|
||||||
|
}
|
||||||
if (download == true) {
|
if (download == true) {
|
||||||
const fileLink = document.createElement('a')
|
const fileLink = document.createElement('a')
|
||||||
fileLink.href = fileURL
|
fileLink.href = fileURL
|
||||||
@ -1858,7 +1880,7 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
showError(err, () => {
|
showError(err, () => {
|
||||||
fetchFileContent(file, download)
|
fetchFileContent(file, download, onProgress)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const clampPreviewScale = (value) => {
|
|||||||
return Math.round(clamped * 10000) / 10000
|
return Math.round(clamped * 10000) / 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextScaleFromWheel = (scale, event) => {
|
export const nextScaleFromWheel = (scale, event) => {
|
||||||
const intensity = event.deltaMode === 1 ? 0.12 : 0.0045
|
const intensity = event.deltaMode === 1 ? 0.12 : 0.0045
|
||||||
return clampPreviewScale(scale * Math.exp(-event.deltaY * intensity))
|
return clampPreviewScale(scale * Math.exp(-event.deltaY * intensity))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,6 +111,7 @@ export const File = {
|
|||||||
columns: [
|
columns: [
|
||||||
'_reference',
|
'_reference',
|
||||||
'name',
|
'name',
|
||||||
|
'extension',
|
||||||
'type',
|
'type',
|
||||||
'size',
|
'size',
|
||||||
'temp',
|
'temp',
|
||||||
@ -125,9 +126,18 @@ export const File = {
|
|||||||
'extension',
|
'extension',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference'
|
'_reference',
|
||||||
|
'extension'
|
||||||
|
],
|
||||||
|
sorters: [
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'size',
|
||||||
|
'createdAt',
|
||||||
|
'temp',
|
||||||
|
'updatedAt',
|
||||||
|
'extension'
|
||||||
],
|
],
|
||||||
sorters: ['name', 'type', 'size', 'createdAt', 'temp', 'updatedAt'],
|
|
||||||
group: ['type'],
|
group: ['type'],
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
@ -173,13 +183,14 @@ export const File = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'type',
|
name: 'extension',
|
||||||
label: 'Type',
|
label: 'Extension',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
required: true,
|
required: true,
|
||||||
columnWidth: 190
|
columnWidth: 140
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'size',
|
name: 'size',
|
||||||
label: 'Size',
|
label: 'Size',
|
||||||
@ -222,6 +233,14 @@ export const File = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
required: false,
|
required: false,
|
||||||
columnWidth: 100
|
columnWidth: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
type: 'text',
|
||||||
|
readOnly: true,
|
||||||
|
required: true,
|
||||||
|
columnWidth: 190
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user