Compare commits

..

No commits in common. "5f776f896fee5b171f611f4f667eeba537a2b8a0" and "8dd55a4257512c24b7c8bfd7cc7fdd5676eee5ce" have entirely different histories.

7 changed files with 123 additions and 1047 deletions

View File

@ -206,20 +206,12 @@ const FileInfo = () => {
collapseKey='preview' collapseKey='preview'
> >
{objectFormState?.objectData?._id ? ( {objectFormState?.objectData?._id ? (
<Spin <Card styles={{ body: { minHeight: 'calc(100vh - 218px)' } }}>
spinning={objectFormState.loading} <FilePreview
indicator={<LoadingOutlined />} file={objectFormState?.objectData}
> style={{ width: '100%', height: '100%' }}
<Card />
style={{ height: 'calc(100vh - 218px)' }} </Card>
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.'} />
)} )}

View File

@ -1,5 +1,4 @@
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,
@ -9,10 +8,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) =>
@ -26,10 +25,6 @@ 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)
@ -44,147 +39,80 @@ const FilePreview = ({ file, style = {} }) => {
if (error != null) { if (error != null) {
return return
} }
const requestId = file._id
setLoading(true) setLoading(true)
setDownloadProgress({ progress: 0, message: 'Starting download...' }) const objectUrl = await fetchFileContent(file, false)
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])
const showProgressOverlay = downloadProgress != null if (isPdf) {
const wrapperHeight = hasExplicitPreviewHeight(style.height) if (error != null) {
? style.height return <div style={{ color: 'red' }}>{error}</div>
: isPdf
? '72vh'
: style.height
const renderPreview = () => {
if (isPdf) {
return (
<PDFPreview
file={fileObjectUrl}
loading={loading}
style={{
...style,
height: '100%'
}}
/>
)
} }
if (loading == true || !file?.type) { return (
return null <PDFPreview
} file={fileObjectUrl}
loading={loading}
style={{
...style,
height: hasExplicitPreviewHeight(style.height)
? style.height
: '72vh'
}}
/>
)
}
if (isGcode && fileObjectUrl) { if (loading == true || !file?.type) {
return ( return <LoadingPlaceholder message={'Loading file preview...'} />
<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>
} }
return ( if (isGcode && fileObjectUrl) {
<div return (
style={{ <GCodePreview
...style, src={fileObjectUrl}
position: 'relative', topLayerColor={'#ff9800'}
height: wrapperHeight, lastSegmentColor={'#e91e63'}
minHeight: startLayer={0}
showProgressOverlay && !wrapperHeight ? 240 : style.minHeight endLayer={undefined}
}} lineWidth={1}
> style={style}
<div />
className={`previewProgressOverlay ${showProgressOverlay ? 'visible' : 'hidden'}`} )
> }
{showProgressOverlay ? (
<Flex if (is3DModel && fileObjectUrl) {
style={{ width: '100%', height: '100%' }} return (
align='center' <ThreeDPreview
justify='center' src={fileObjectUrl}
> style={style}
<Card extension={file.extension}
style={{ />
maxWidth: 360, )
minWidth: 100, }
width: '100%',
margin: 24 if (isImage && fileObjectUrl) {
}} 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 = {
@ -196,8 +124,6 @@ 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)
) )
} }

View File

@ -1,67 +1,7 @@
import * as GCodePreview from 'gcode-preview' import * as GCodePreview from 'gcode-preview'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Button, Card, Flex, Slider } from 'antd' import { useCallback, useEffect, useRef } from 'react'
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 {
@ -73,107 +13,40 @@ 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?.()
const instance = GCodePreview.init({ previewRef.current = GCodePreview.init({
canvas: canvasRef.current, canvas: canvasRef.current,
startLayer, startLayer,
endLayer, endLayer,
lineWidth, lineWidth,
backgroundColor: toThreeColorHex(themeColors.backgroundColor, '#ffffff'), topLayerColor: new THREE.Color(topLayerColor).getHex(),
extrusionColor: toThreeColorHex(themeColors.extrusionColor, '#0091FF'), lastSegmentColor: new THREE.Color(lastSegmentColor).getHex(),
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)
controls.removeEventListener('change', handleControlsChange) previewRef.current?.dispose?.()
controls.removeEventListener('start', handleControlsStart) previewRef.current = null
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,
@ -181,49 +54,17 @@ 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 instance = previewRef.current const preview = previewRef.current
if (!src || !instance) return if (!src || !preview) 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 !== instance) return if (cancelled || previewRef.current !== preview) return
instance.processGCode(text) preview.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)
@ -234,124 +75,9 @@ 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 ( return <canvas ref={canvasRef} style={style}></canvas>
<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 = {

View File

@ -1,190 +1,7 @@
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 {
@ -193,59 +10,25 @@ function ThreeDPreview(props) {
width = 500, width = 500,
height = 500, height = 500,
style = {}, style = {},
backgroundColor, backgroundColor = '#ffffff'
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(() => {
viewer.current?.Resize?.() if (viewer.current && containerRef.current) {
}, []) // Resize the viewer container
const container = containerRef.current
const applyZoomScale = useCallback((nextScale) => { if (container.style.width !== width + 'px') {
const viewer3d = getViewer3d(viewer.current) container.style.width = width + 'px'
const fitDistance = fitDistanceRef.current }
if (!viewer3d || !fitDistance) return if (container.style.height !== height + 'px') {
const scale = clampPreviewScale(nextScale) container.style.height = height + 'px'
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
@ -257,8 +40,6 @@ 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) {
@ -279,8 +60,6 @@ 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(
@ -289,34 +68,15 @@ 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: toRgbaColor(themeColors.background), backgroundColor: new OV.RGBAColor(255, 255, 255, 255),
defaultColor: toRgbColor(themeColors.defaultColor), defaultColor: new OV.RGBColor(200, 200, 200),
edgeSettings: new OV.EdgeSettings( edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
false, environmentSettings: new OV.EnvironmentSettings([], 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)
@ -332,7 +92,8 @@ function ThreeDPreview(props) {
}) })
// Load model from file using LoadModelFromFileList // Load model from file using LoadModelFromFileList
newViewer.LoadModelFromFileList([file]) await 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')
@ -347,342 +108,54 @@ 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()
} }
} }
}, [src, extension, resizeViewer, backgroundColor, captureFittedZoom]) }, [width, height, src, extension, resizeViewer])
useEffect(() => {
let cancelled = false
const applyTheme = () => {
if (cancelled || !viewer.current) return
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 = { const containerStyle = {
flex: '1 1 auto', width: width + 'px',
minHeight: 0, height: height + 'px',
backgroundColor: backgroundColor || 'var(--layout-modal-bg)', backgroundColor,
position: 'relative', position: 'relative',
...paneStyle ...style
} }
const controlsDisabled = isLoading || error != null
const panCursor = panMode ? (isPanning ? 'grabbing' : 'grab') : undefined
return ( return (
<Flex vertical gap={'middle'} style={wrapperStyle}> <div style={containerStyle}>
{enableControls ? ( <div
<Flex gap={'small'}> ref={containerRef}
<KeyboardShortcut style={{
shortcut={'alt+p'} width: '100%',
hint={'ALT P'} height: '100%',
onTrigger={() => { position: 'relative'
setPanMode((prev) => !prev) }}
}} />
> {isLoading && <LoadingPlaceholder message={'Loading 3D preview...'} />}
<Button {error && (
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',
inset: 0, top: '50%',
opacity: isLoading ? 1 : 0, left: '50%',
zIndex: 1, transform: 'translate(-50%, -50%)',
display: 'flex', background: 'rgba(255, 0, 0, 0.1)',
pointerEvents: !isLoading ? 'none' : 'auto', color: '#d32f2f',
alignItems: 'center', padding: '10px',
justifyContent: 'center' borderRadius: '4px',
fontSize: '14px',
textAlign: 'center'
}} }}
> >
<LoadingPlaceholder message={'Loading 3D preview...'} /> {error}
</div> </div>
)}
{error && ( </div>
<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>
) )
} }

View File

@ -1829,11 +1829,8 @@ const ApiServerProvider = ({ children }) => {
} }
// Download GCode file content // Download GCode file content
const fetchFileContent = async (file, download = false, onProgress = null) => { const fetchFileContent = async (file, download = false) => {
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`,
{ {
@ -1841,32 +1838,13 @@ 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
@ -1880,7 +1858,7 @@ const ApiServerProvider = ({ children }) => {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
showError(err, () => { showError(err, () => {
fetchFileContent(file, download, onProgress) fetchFileContent(file, download)
}) })
} }
} }

View File

@ -13,7 +13,7 @@ export const clampPreviewScale = (value) => {
return Math.round(clamped * 10000) / 10000 return Math.round(clamped * 10000) / 10000
} }
export const nextScaleFromWheel = (scale, event) => { 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))
} }

View File

@ -111,7 +111,6 @@ export const File = {
columns: [ columns: [
'_reference', '_reference',
'name', 'name',
'extension',
'type', 'type',
'size', 'size',
'temp', 'temp',
@ -126,18 +125,9 @@ 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: [
{ {
@ -183,14 +173,13 @@ export const File = {
}, },
{ {
name: 'extension', name: 'type',
label: 'Extension', label: 'Type',
type: 'text', type: 'text',
readOnly: true, readOnly: true,
required: true, required: true,
columnWidth: 140 columnWidth: 190
}, },
{ {
name: 'size', name: 'size',
label: 'Size', label: 'Size',
@ -233,14 +222,6 @@ 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
} }
] ]
} }