Remove gcode-preview dependency and related code
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Deleted the gcode-preview package from package.json and pnpm-lock.yaml. - Removed references to gcode-preview in vite.config.js. - Introduced new GCodePreview component and related files for G-code visualization. - Added GCodeViewer components including Bed, HotEnd, and SegmentMesh for rendering. - Implemented color modes and feature types for enhanced G-code rendering. - Added GCodeLegend for displaying feature information and toggling visibility. - Established GCodeScene for managing the 3D rendering context and controls.
This commit is contained in:
parent
608eba41cf
commit
b3e366e813
@ -2600,6 +2600,14 @@ body.objectKanbanColumnResizing * {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewStatsOverlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.previewZoomCard {
|
.previewZoomCard {
|
||||||
background-color: color-mix(
|
background-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
|
|||||||
@ -61,7 +61,6 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"gcode-preview": "3.0.0-alpha.6",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
"keycloak-js": "^26.2.0",
|
"keycloak-js": "^26.2.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -172,9 +172,6 @@ importers:
|
|||||||
electron-store:
|
electron-store:
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2
|
version: 11.0.2
|
||||||
gcode-preview:
|
|
||||||
specifier: 3.0.0-alpha.6
|
|
||||||
version: 3.0.0-alpha.6
|
|
||||||
json-schema-traverse:
|
json-schema-traverse:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@ -3371,9 +3368,6 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
gcode-preview@3.0.0-alpha.6:
|
|
||||||
resolution: {integrity: sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ==}
|
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -3807,9 +3801,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
lil-gui@0.20.0:
|
|
||||||
resolution: {integrity: sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==}
|
|
||||||
|
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
@ -9273,11 +9264,6 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
gcode-preview@3.0.0-alpha.6:
|
|
||||||
dependencies:
|
|
||||||
lil-gui: 0.20.0
|
|
||||||
three: 0.179.1
|
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@ -9745,8 +9731,6 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
lil-gui@0.20.0: {}
|
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
linkify-it@5.0.0:
|
linkify-it@5.0.0:
|
||||||
|
|||||||
825
src/components/Components/GCodePreview/GCodePreview.jsx
Normal file
825
src/components/Components/GCodePreview/GCodePreview.jsx
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Button, Card, Flex, Select, Slider, Typography } from 'antd'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
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 PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx'
|
||||||
|
import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
|
||||||
|
import KeyboardShortcut from '../../Dashboard/common/KeyboardShortcut.jsx'
|
||||||
|
import ProgressDisplay from '../../Dashboard/common/ProgressDisplay'
|
||||||
|
import usePinchZoom, {
|
||||||
|
clampPreviewScale,
|
||||||
|
MAX_PREVIEW_SCALE,
|
||||||
|
MIN_PREVIEW_SCALE
|
||||||
|
} from '../../Dashboard/hooks/usePinchZoom.js'
|
||||||
|
import { useThemeContext } from '../../Dashboard/context/ThemeContext'
|
||||||
|
import GCodeScene from './GCodeViewer/GCodeScene.js'
|
||||||
|
import useGCodeLoader from './GCodeViewer/useGCodeLoader.js'
|
||||||
|
import GCodeLegend from './GCodeViewer/GCodeLegend.jsx'
|
||||||
|
import {
|
||||||
|
COLOR_MODES,
|
||||||
|
COLOR_MODE_BY_VALUE,
|
||||||
|
TOOL_PALETTE,
|
||||||
|
formatDuration
|
||||||
|
} from './GCodeViewer/colorModes.js'
|
||||||
|
import { ALL_TYPES_MASK, FEATURE } from './GCodeViewer/featureTypes.js'
|
||||||
|
import {
|
||||||
|
fileOffsetForPrinter,
|
||||||
|
layerIndexForSegment,
|
||||||
|
pointOnSegment,
|
||||||
|
refineSegmentByPosition
|
||||||
|
} from './GCodeViewer/tracking.js'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// Ranges used for scalar colouring until the worker reports real statistics.
|
||||||
|
const DEFAULT_RANGES = {
|
||||||
|
height: { min: 0.05, max: 0.4 },
|
||||||
|
width: { min: 0.2, max: 1 },
|
||||||
|
feed: { min: 10, max: 300 },
|
||||||
|
actualSpeed: { min: 10, max: 300 },
|
||||||
|
accel: { min: 500, max: 10000 },
|
||||||
|
fan: { min: 0, max: 100 },
|
||||||
|
temp: { min: 150, max: 300 },
|
||||||
|
flow: { min: 0, max: 30 },
|
||||||
|
actualFlow: { min: 0, max: 30 },
|
||||||
|
layerTime: { min: 0, max: 600 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MODE_OPTIONS = COLOR_MODES.map((mode) => ({
|
||||||
|
value: mode.value,
|
||||||
|
label: mode.label
|
||||||
|
}))
|
||||||
|
|
||||||
|
const SCRUB_SPEEDS = [1, 2, 5, 10, 20].map((speed) => ({
|
||||||
|
value: speed,
|
||||||
|
label: `${speed}x`
|
||||||
|
}))
|
||||||
|
|
||||||
|
function getCameraDistance(scene) {
|
||||||
|
const distance = scene.camera.position.distanceTo(scene.controls.target)
|
||||||
|
return Number.isFinite(distance) && distance > 0 ? distance : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraScale(scene, baseDistance) {
|
||||||
|
const distance = getCameraDistance(scene)
|
||||||
|
return distance == null ? null : clampPreviewScale(baseDistance / distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCameraScale(scene, baseDistance, scale) {
|
||||||
|
const { camera, controls } = scene
|
||||||
|
const distance = getCameraDistance(scene)
|
||||||
|
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 isFinitePosition(position) {
|
||||||
|
return (
|
||||||
|
Array.isArray(position) &&
|
||||||
|
position.length >= 3 &&
|
||||||
|
Number.isFinite(position[0]) &&
|
||||||
|
Number.isFinite(position[1]) &&
|
||||||
|
Number.isFinite(position[2])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GCodePreview(props) {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
startLayer,
|
||||||
|
endLayer,
|
||||||
|
style = {},
|
||||||
|
showFollowCurrentSubJob = false,
|
||||||
|
gcodePosition = null,
|
||||||
|
toolPosition = null
|
||||||
|
} = props
|
||||||
|
const { isDarkMode } = useThemeContext()
|
||||||
|
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const paneRef = useRef(null)
|
||||||
|
const sceneRef = useRef(null)
|
||||||
|
const baseDistanceRef = useRef(1)
|
||||||
|
const applyingScaleRef = useRef(false)
|
||||||
|
const userMovedRef = useRef(false)
|
||||||
|
|
||||||
|
const [scene, setScene] = useState(null)
|
||||||
|
const [previewScale, setPreviewScale] = useState(1)
|
||||||
|
const [panMode, setPanMode] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [renderMode, setRenderMode] = useState('extrusion')
|
||||||
|
const [showTravel, setShowTravel] = useState(false)
|
||||||
|
const [colorMode, setColorMode] = useState('feature')
|
||||||
|
const [hiddenTypes, setHiddenTypes] = useState(() => new Set())
|
||||||
|
const [rangeOverrides, setRangeOverrides] = useState({})
|
||||||
|
const [layerRange, setLayerRange] = useState(null)
|
||||||
|
const [followSelection, setFollowSelection] = useState('follow')
|
||||||
|
const [scrubSegment, setScrubSegment] = useState(null)
|
||||||
|
const [scrubPlaying, setScrubPlaying] = useState(false)
|
||||||
|
const [scrubSpeed, setScrubSpeed] = useState(1)
|
||||||
|
|
||||||
|
const loader = useGCodeLoader(src)
|
||||||
|
const { model, revision } = loader
|
||||||
|
const ready = loader.status === 'ready'
|
||||||
|
const layers = useMemo(
|
||||||
|
() => model?.layers || [],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[model, revision]
|
||||||
|
)
|
||||||
|
const layerCount = layers.length
|
||||||
|
// Segment count is read through the revision so React re-evaluates it as
|
||||||
|
// chunks stream in.
|
||||||
|
const segmentCount = useMemo(
|
||||||
|
() => (model ? model.store.count : 0),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[model, revision]
|
||||||
|
)
|
||||||
|
|
||||||
|
const mode = COLOR_MODE_BY_VALUE.get(colorMode) || COLOR_MODES[0]
|
||||||
|
const tracking =
|
||||||
|
showFollowCurrentSubJob &&
|
||||||
|
followSelection === 'follow' &&
|
||||||
|
gcodePosition != null &&
|
||||||
|
gcodePosition.isActive !== false
|
||||||
|
|
||||||
|
// ---- scene lifecycle --------------------------------------------------
|
||||||
|
|
||||||
|
const resizePreview = useCallback(() => {
|
||||||
|
sceneRef.current?.resize()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const captureBaseDistance = useCallback((instance) => {
|
||||||
|
const base = getCameraDistance(instance) || 1
|
||||||
|
baseDistanceRef.current = base
|
||||||
|
instance.controls.minDistance = base / MAX_PREVIEW_SCALE
|
||||||
|
instance.controls.maxDistance = base / MIN_PREVIEW_SCALE
|
||||||
|
instance.camera.far = Math.max(
|
||||||
|
instance.camera.far,
|
||||||
|
(base / MIN_PREVIEW_SCALE) * 2
|
||||||
|
)
|
||||||
|
instance.camera.updateProjectionMatrix()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return undefined
|
||||||
|
const instance = new GCodeScene(canvas)
|
||||||
|
sceneRef.current = instance
|
||||||
|
captureBaseDistance(instance)
|
||||||
|
const { controls } = instance
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
userMovedRef.current = true
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
const handleControlsEnd = () => setIsDragging(false)
|
||||||
|
controls.addEventListener('change', handleControlsChange)
|
||||||
|
controls.addEventListener('start', handleControlsStart)
|
||||||
|
controls.addEventListener('end', handleControlsEnd)
|
||||||
|
|
||||||
|
const pane = paneRef.current
|
||||||
|
const resizeObserver =
|
||||||
|
pane && typeof ResizeObserver === 'function'
|
||||||
|
? new ResizeObserver(resizePreview)
|
||||||
|
: null
|
||||||
|
resizeObserver?.observe(pane)
|
||||||
|
window.addEventListener('resize', resizePreview)
|
||||||
|
instance.resize()
|
||||||
|
setScene(instance)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', resizePreview)
|
||||||
|
controls.removeEventListener('change', handleControlsChange)
|
||||||
|
controls.removeEventListener('start', handleControlsStart)
|
||||||
|
controls.removeEventListener('end', handleControlsEnd)
|
||||||
|
setScene(null)
|
||||||
|
setIsDragging(false)
|
||||||
|
instance.dispose()
|
||||||
|
if (sceneRef.current === instance) sceneRef.current = null
|
||||||
|
}
|
||||||
|
}, [captureBaseDistance, resizePreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
scene.setTheme({
|
||||||
|
background: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
|
grid: isDarkMode ? '#7a7a7a' : '#9e9e9e'
|
||||||
|
})
|
||||||
|
}, [scene, isDarkMode])
|
||||||
|
|
||||||
|
// ---- model binding ----------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
scene.setStore(model ? model.store : null)
|
||||||
|
userMovedRef.current = false
|
||||||
|
setLayerRange(null)
|
||||||
|
setScrubSegment(null)
|
||||||
|
setScrubPlaying(false)
|
||||||
|
setRangeOverrides({})
|
||||||
|
}, [scene, model])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene || !model) return
|
||||||
|
scene.syncStore()
|
||||||
|
if (!model.done) return
|
||||||
|
const changed = scene.setBuildVolume(
|
||||||
|
model.metadata?.buildVolume,
|
||||||
|
model.bounds
|
||||||
|
)
|
||||||
|
if (changed && !userMovedRef.current) {
|
||||||
|
scene.fitToVolume(scene.buildVolume)
|
||||||
|
captureBaseDistance(scene)
|
||||||
|
setPreviewScale(1)
|
||||||
|
}
|
||||||
|
}, [scene, model, revision, captureBaseDistance])
|
||||||
|
|
||||||
|
// Initial layer window from props, applied once the layer list is known.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model?.done || layerCount === 0) return
|
||||||
|
if (typeof endLayer !== 'number' && typeof startLayer !== 'number') return
|
||||||
|
setLayerRange((prev) => {
|
||||||
|
if (prev) return prev
|
||||||
|
const lo = Math.max(1, (startLayer || 0) + 1)
|
||||||
|
const hi = Math.min(
|
||||||
|
layerCount,
|
||||||
|
typeof endLayer === 'number' ? endLayer : layerCount
|
||||||
|
)
|
||||||
|
return [Math.min(lo, hi), hi]
|
||||||
|
})
|
||||||
|
}, [model, layerCount, startLayer, endLayer])
|
||||||
|
|
||||||
|
// ---- uniform driven options -------------------------------------------
|
||||||
|
|
||||||
|
const autoRange = mode.stat ? model?.stats?.[mode.stat] || null : null
|
||||||
|
const activeRange = rangeOverrides[colorMode] || autoRange || null
|
||||||
|
const displayRange =
|
||||||
|
activeRange || (mode.stat ? DEFAULT_RANGES[mode.stat] : null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
const segments = scene.segments
|
||||||
|
segments.setColorMode(mode.shaderMode, Boolean(mode.logScale))
|
||||||
|
if (mode.stat) {
|
||||||
|
const range = activeRange || DEFAULT_RANGES[mode.stat]
|
||||||
|
const min = range.min
|
||||||
|
const max = range.max > min ? range.max : min + 1
|
||||||
|
segments.setRange(min, max)
|
||||||
|
}
|
||||||
|
if (mode.kind === 'tool') {
|
||||||
|
const fileColors = model?.metadata?.filamentColors || []
|
||||||
|
segments.setToolColors(mode.useFilamentColors ? fileColors : TOOL_PALETTE)
|
||||||
|
}
|
||||||
|
scene.requestRender()
|
||||||
|
}, [scene, mode, activeRange, model, revision])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
let mask = ALL_TYPES_MASK & ~1
|
||||||
|
for (const type of hiddenTypes) mask &= ~(1 << type)
|
||||||
|
if (!showTravel) {
|
||||||
|
mask &= ~(1 << FEATURE.TRAVEL)
|
||||||
|
mask &= ~(1 << FEATURE.WIPE)
|
||||||
|
}
|
||||||
|
scene.segments.setRenderMode(renderMode)
|
||||||
|
scene.segments.setVisibleMask(mask)
|
||||||
|
scene.requestRender()
|
||||||
|
}, [scene, hiddenTypes, showTravel, renderMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
if (layerRange && layerCount > 0) {
|
||||||
|
scene.segments.setLayerRange(layerRange[0] - 1, layerRange[1] - 1)
|
||||||
|
} else {
|
||||||
|
scene.segments.setLayerRange(0, 65535)
|
||||||
|
}
|
||||||
|
scene.requestRender()
|
||||||
|
}, [scene, layerRange, layerCount])
|
||||||
|
|
||||||
|
// ---- progress: live tracking or scrubbing ------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene || !model) return
|
||||||
|
const store = model.store
|
||||||
|
const segments = scene.segments
|
||||||
|
|
||||||
|
if (tracking) {
|
||||||
|
const offset = fileOffsetForPrinter(
|
||||||
|
gcodePosition.filePosition,
|
||||||
|
gcodePosition.fileSize,
|
||||||
|
model.byteLength || model.size
|
||||||
|
)
|
||||||
|
const fileSegment = store.segmentAtByteOffset(offset)
|
||||||
|
const live = isFinitePosition(toolPosition)
|
||||||
|
? toolPosition
|
||||||
|
: isFinitePosition(gcodePosition.position)
|
||||||
|
? gcodePosition.position
|
||||||
|
: null
|
||||||
|
if (fileSegment < 0) {
|
||||||
|
segments.setProgress(0, 1)
|
||||||
|
} else {
|
||||||
|
const refined = refineSegmentByPosition(store, fileSegment, live)
|
||||||
|
segments.setProgress(refined.segment + 1, refined.t)
|
||||||
|
const point = live || pointOnSegment(store, refined.segment, refined.t)
|
||||||
|
if (point) scene.setToolPosition(point[0], point[1], point[2])
|
||||||
|
}
|
||||||
|
if (fileSegment < 0 && live) {
|
||||||
|
scene.setToolPosition(live[0], live[1], live[2])
|
||||||
|
}
|
||||||
|
scene.setHotEndVisible(live != null || fileSegment >= 0)
|
||||||
|
} else if (scrubSegment != null && ready) {
|
||||||
|
const end = Math.max(0, Math.min(store.count, Math.round(scrubSegment)))
|
||||||
|
segments.setProgress(end, 1)
|
||||||
|
if (end > 0) {
|
||||||
|
const point = pointOnSegment(store, end - 1, 1)
|
||||||
|
if (point) scene.setToolPosition(point[0], point[1], point[2])
|
||||||
|
scene.setHotEndVisible(true)
|
||||||
|
} else {
|
||||||
|
scene.setHotEndVisible(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
segments.showAll()
|
||||||
|
scene.setHotEndVisible(false)
|
||||||
|
}
|
||||||
|
scene.requestRender()
|
||||||
|
}, [
|
||||||
|
scene,
|
||||||
|
model,
|
||||||
|
revision,
|
||||||
|
ready,
|
||||||
|
tracking,
|
||||||
|
gcodePosition,
|
||||||
|
toolPosition,
|
||||||
|
scrubSegment
|
||||||
|
])
|
||||||
|
|
||||||
|
const scrubRef = useRef(scrubSegment)
|
||||||
|
scrubRef.current = scrubSegment
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrubPlaying || !ready || segmentCount === 0) return undefined
|
||||||
|
let frame = 0
|
||||||
|
let last = performance.now()
|
||||||
|
const rate = Math.max(300, segmentCount / 90) * scrubSpeed
|
||||||
|
const tick = (now) => {
|
||||||
|
const dt = Math.min(0.1, (now - last) / 1000)
|
||||||
|
last = now
|
||||||
|
const current = scrubRef.current == null ? 0 : scrubRef.current
|
||||||
|
const next = current + rate * dt
|
||||||
|
if (next >= segmentCount) {
|
||||||
|
setScrubSegment(segmentCount)
|
||||||
|
setScrubPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setScrubSegment(next)
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [scrubPlaying, ready, segmentCount, scrubSpeed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tracking) setScrubPlaying(false)
|
||||||
|
}, [tracking])
|
||||||
|
|
||||||
|
// ---- camera controls ----------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
applyingScaleRef.current = true
|
||||||
|
applyCameraScale(scene, baseDistanceRef.current, previewScale)
|
||||||
|
applyingScaleRef.current = false
|
||||||
|
scene.requestRender()
|
||||||
|
}, [scene, previewScale])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return
|
||||||
|
const { controls } = scene
|
||||||
|
controls.mouseButtons = {
|
||||||
|
LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
|
RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN
|
||||||
|
}
|
||||||
|
controls.touches = {
|
||||||
|
ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN
|
||||||
|
}
|
||||||
|
}, [scene, panMode])
|
||||||
|
|
||||||
|
usePinchZoom({
|
||||||
|
containerRef: paneRef,
|
||||||
|
scale: previewScale,
|
||||||
|
onScaleChange: setPreviewScale,
|
||||||
|
enabled: scene != null
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
const instance = sceneRef.current
|
||||||
|
if (!instance) return
|
||||||
|
instance.controls.reset()
|
||||||
|
captureBaseDistance(instance)
|
||||||
|
setPreviewScale(1)
|
||||||
|
instance.requestRender()
|
||||||
|
}, [captureBaseDistance])
|
||||||
|
|
||||||
|
// ---- handlers -----------------------------------------------------------
|
||||||
|
|
||||||
|
const toggleType = useCallback((type) => {
|
||||||
|
setHiddenTypes((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(type)) next.delete(type)
|
||||||
|
else next.add(type)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRangeChange = useCallback(
|
||||||
|
(range) => {
|
||||||
|
setRangeOverrides((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
if (range == null) delete next[colorMode]
|
||||||
|
else next[colorMode] = range
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[colorMode]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleScrubChange = useCallback((value) => {
|
||||||
|
setScrubPlaying(false)
|
||||||
|
setScrubSegment(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (scrubPlaying) {
|
||||||
|
setScrubPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setScrubSegment((prev) => (prev == null || prev >= segmentCount ? 0 : prev))
|
||||||
|
setScrubPlaying(true)
|
||||||
|
}, [scrubPlaying, segmentCount])
|
||||||
|
|
||||||
|
// ---- derived display values -------------------------------------------
|
||||||
|
|
||||||
|
const showScrubber = ready && !tracking && segmentCount > 0
|
||||||
|
const scrubValue =
|
||||||
|
scrubSegment == null ? segmentCount : Math.min(scrubSegment, segmentCount)
|
||||||
|
|
||||||
|
const statsMoveIndex = useMemo(() => {
|
||||||
|
if (!ready || !model) return 0
|
||||||
|
if (tracking && gcodePosition) {
|
||||||
|
const offset = fileOffsetForPrinter(
|
||||||
|
gcodePosition.filePosition,
|
||||||
|
gcodePosition.fileSize,
|
||||||
|
model.byteLength || model.size
|
||||||
|
)
|
||||||
|
const fileSegment = model.store.segmentAtByteOffset(offset)
|
||||||
|
if (fileSegment < 0) return 0
|
||||||
|
const live = isFinitePosition(toolPosition)
|
||||||
|
? toolPosition
|
||||||
|
: isFinitePosition(gcodePosition.position)
|
||||||
|
? gcodePosition.position
|
||||||
|
: null
|
||||||
|
const refined = refineSegmentByPosition(model.store, fileSegment, live)
|
||||||
|
return refined.segment + 1
|
||||||
|
}
|
||||||
|
return scrubValue
|
||||||
|
// revision: segment store grows while chunks stream in during tracking.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, model, tracking, gcodePosition, toolPosition, scrubValue, revision])
|
||||||
|
|
||||||
|
const statsLayerIndex =
|
||||||
|
layerCount > 0 && statsMoveIndex > 0
|
||||||
|
? layerIndexForSegment(
|
||||||
|
layers,
|
||||||
|
Math.max(0, Math.round(statsMoveIndex) - 1)
|
||||||
|
)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
const layerSliderValue = layerRange || [1, Math.max(1, layerCount)]
|
||||||
|
const layerTooltip = useCallback(
|
||||||
|
(value) => {
|
||||||
|
const layer = layers[value - 1]
|
||||||
|
if (!layer) return value
|
||||||
|
return `Layer ${value} · Z ${layer.z.toFixed(2)}`
|
||||||
|
},
|
||||||
|
[layers]
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadingPercent = Math.round((loader.progress || 0) * 100)
|
||||||
|
const loadingLabel = loader.planning
|
||||||
|
? 'Calculating motion...'
|
||||||
|
: `Parsing G-code... ${segmentCount.toLocaleString()} moves`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={'middle'}
|
||||||
|
style={{ width: '100%', height: '100%', minHeight: 0, ...style }}
|
||||||
|
>
|
||||||
|
<Flex gap={'small'} wrap='wrap' align='center'>
|
||||||
|
<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))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={colorMode}
|
||||||
|
options={COLOR_MODE_OPTIONS}
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setColorMode}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={renderMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'extrusion', label: 'Extrusion' },
|
||||||
|
{ value: 'lines', label: 'Lines' }
|
||||||
|
]}
|
||||||
|
style={{ minWidth: 105 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setRenderMode}
|
||||||
|
/>
|
||||||
|
{showFollowCurrentSubJob ? (
|
||||||
|
<Select
|
||||||
|
value={followSelection}
|
||||||
|
options={[
|
||||||
|
{ value: 'follow', label: 'Follow current sub job' },
|
||||||
|
{ value: 'file', label: 'Entire file' }
|
||||||
|
]}
|
||||||
|
style={{ minWidth: 190 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setFollowSelection}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showScrubber ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={scrubPlaying ? <PauseCircleIcon /> : <PlayCircleIcon />}
|
||||||
|
onClick={togglePlay}
|
||||||
|
title={scrubPlaying ? 'Pause' : 'Play'}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={scrubSpeed}
|
||||||
|
options={SCRUB_SPEEDS}
|
||||||
|
style={{ minWidth: 70 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setScrubSpeed}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={segmentCount}
|
||||||
|
step={1}
|
||||||
|
value={scrubValue}
|
||||||
|
onChange={handleScrubChange}
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
style={{ flex: '1 1 160px', minWidth: 120, margin: '0 8px' }}
|
||||||
|
styles={{ track: { background: 'var(--color-primary)' } }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{model && (ready || segmentCount > 0) ? (
|
||||||
|
<GCodeLegend
|
||||||
|
mode={mode}
|
||||||
|
model={model}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
hiddenTypes={hiddenTypes}
|
||||||
|
onToggleType={toggleType}
|
||||||
|
showTravel={showTravel}
|
||||||
|
onToggleTravel={() => setShowTravel((prev) => !prev)}
|
||||||
|
range={displayRange}
|
||||||
|
autoRange={autoRange || displayRange}
|
||||||
|
onRangeChange={handleRangeChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={paneRef}
|
||||||
|
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,
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
></canvas>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{layerCount > 1 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
top: 12,
|
||||||
|
bottom: 76,
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 18, height: '100%' }}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: '17px 10px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='previewZoomCard'
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
vertical
|
||||||
|
range
|
||||||
|
min={1}
|
||||||
|
max={layerCount}
|
||||||
|
step={1}
|
||||||
|
value={layerSliderValue}
|
||||||
|
onChange={(value) => setLayerRange(value)}
|
||||||
|
tooltip={{ formatter: layerTooltip, placement: 'left' }}
|
||||||
|
style={{ margin: 0, height: '100%' }}
|
||||||
|
classNames={{ handle: 'previewZoomSliderHandle' }}
|
||||||
|
styles={{ track: { background: 'var(--color-primary)' } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loader.status === 'loading' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 3,
|
||||||
|
width: 'min(320px, calc(100% - 24px))',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
size='small'
|
||||||
|
className='previewZoomCard'
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
styles={{ body: { padding: '8px 12px' } }}
|
||||||
|
>
|
||||||
|
<ProgressDisplay percent={loadingPercent} status='active'>
|
||||||
|
{loadingLabel}
|
||||||
|
</ProgressDisplay>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loader.status === 'error' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 3,
|
||||||
|
background:
|
||||||
|
'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loader.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ready && segmentCount > 0 ? (
|
||||||
|
<div className='previewStatsOverlay'>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 18 }}
|
||||||
|
styles={{ body: { padding: '10px 14px' } }}
|
||||||
|
className='previewZoomCard'
|
||||||
|
>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type='secondary' style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||||
|
Total Time:{' '}
|
||||||
|
{model?.totalTime > 0 ? formatDuration(model.totalTime) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type='secondary' style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||||
|
Layer:{' '}
|
||||||
|
{statsLayerIndex >= 0
|
||||||
|
? `${statsLayerIndex + 1} / ${layerCount}`
|
||||||
|
: layerCount > 0
|
||||||
|
? `— / ${layerCount}`
|
||||||
|
: '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type='secondary' style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||||
|
Moves:{' '}
|
||||||
|
{Math.round(statsMoveIndex).toLocaleString()} /{' '}
|
||||||
|
{segmentCount.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodePreview.propTypes = {
|
||||||
|
src: PropTypes.string,
|
||||||
|
startLayer: PropTypes.number,
|
||||||
|
endLayer: PropTypes.number,
|
||||||
|
// Accepted for backwards compatibility; line width is derived from the file.
|
||||||
|
lineWidth: PropTypes.number,
|
||||||
|
topLayerColor: PropTypes.string,
|
||||||
|
lastSegmentColor: PropTypes.string,
|
||||||
|
style: PropTypes.object,
|
||||||
|
showFollowCurrentSubJob: PropTypes.bool,
|
||||||
|
gcodePosition: PropTypes.shape({
|
||||||
|
position: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
filePosition: PropTypes.number,
|
||||||
|
fileSize: PropTypes.number,
|
||||||
|
progress: PropTypes.number,
|
||||||
|
isActive: PropTypes.bool,
|
||||||
|
currentLayer: PropTypes.number,
|
||||||
|
totalLayer: PropTypes.number
|
||||||
|
}),
|
||||||
|
toolPosition: PropTypes.arrayOf(PropTypes.number)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GCodePreview
|
||||||
164
src/components/Components/GCodePreview/GCodeViewer/Bed.js
Normal file
164
src/components/Components/GCodePreview/GCodeViewer/Bed.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
export const DEFAULT_BUILD_VOLUME = Object.freeze({
|
||||||
|
minX: 0,
|
||||||
|
maxX: 250,
|
||||||
|
minY: 0,
|
||||||
|
maxY: 220,
|
||||||
|
maxZ: 150
|
||||||
|
})
|
||||||
|
|
||||||
|
export function normalizeBuildVolume(volume) {
|
||||||
|
const v = { ...DEFAULT_BUILD_VOLUME, ...(volume || {}) }
|
||||||
|
if (!(v.maxX > v.minX)) {
|
||||||
|
v.minX = DEFAULT_BUILD_VOLUME.minX
|
||||||
|
v.maxX = DEFAULT_BUILD_VOLUME.maxX
|
||||||
|
}
|
||||||
|
if (!(v.maxY > v.minY)) {
|
||||||
|
v.minY = DEFAULT_BUILD_VOLUME.minY
|
||||||
|
v.maxY = DEFAULT_BUILD_VOLUME.maxY
|
||||||
|
}
|
||||||
|
if (!(v.maxZ > 0)) v.maxZ = DEFAULT_BUILD_VOLUME.maxZ
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grows the configured volume so a model that pokes outside the bed (or a
|
||||||
|
// file with no bed metadata) is still framed.
|
||||||
|
export function expandVolumeToBounds(volume, bounds) {
|
||||||
|
if (!bounds) return volume
|
||||||
|
const pad = 5
|
||||||
|
return {
|
||||||
|
minX: Math.min(volume.minX, Math.floor(bounds.minX - pad)),
|
||||||
|
maxX: Math.max(volume.maxX, Math.ceil(bounds.maxX + pad)),
|
||||||
|
minY: Math.min(volume.minY, Math.floor(bounds.minY - pad)),
|
||||||
|
maxY: Math.max(volume.maxY, Math.ceil(bounds.maxY + pad)),
|
||||||
|
maxZ: Math.max(volume.maxZ, Math.ceil(bounds.maxZ + pad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid, outline and axis indicator for the printer bed. Z is up.
|
||||||
|
export default class Bed {
|
||||||
|
constructor() {
|
||||||
|
this.group = new THREE.Group()
|
||||||
|
this.minorMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x888888,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.18
|
||||||
|
})
|
||||||
|
this.majorMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x888888,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.45
|
||||||
|
})
|
||||||
|
this.outlineMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x888888,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9
|
||||||
|
})
|
||||||
|
this.axisMaterial = new THREE.LineBasicMaterial({ vertexColors: true })
|
||||||
|
this.objects = []
|
||||||
|
this.volume = normalizeBuildVolume(null)
|
||||||
|
this.setVolume(this.volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
for (const object of this.objects) {
|
||||||
|
this.group.remove(object)
|
||||||
|
object.geometry.dispose()
|
||||||
|
}
|
||||||
|
this.objects = []
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume) {
|
||||||
|
this.volume = normalizeBuildVolume(volume)
|
||||||
|
this.clear()
|
||||||
|
const { minX, maxX, minY, maxY } = this.volume
|
||||||
|
const minor = []
|
||||||
|
const major = []
|
||||||
|
const push = (list, x0, y0, x1, y1) => list.push(x0, y0, 0, x1, y1, 0)
|
||||||
|
|
||||||
|
const step = 10
|
||||||
|
const majorEvery = 50
|
||||||
|
for (let x = Math.ceil(minX / step) * step; x <= maxX; x += step) {
|
||||||
|
const list = x % majorEvery === 0 ? major : minor
|
||||||
|
push(list, x, minY, x, maxY)
|
||||||
|
}
|
||||||
|
for (let y = Math.ceil(minY / step) * step; y <= maxY; y += step) {
|
||||||
|
const list = y % majorEvery === 0 ? major : minor
|
||||||
|
push(list, minX, y, maxX, y)
|
||||||
|
}
|
||||||
|
const outline = []
|
||||||
|
push(outline, minX, minY, maxX, minY)
|
||||||
|
push(outline, maxX, minY, maxX, maxY)
|
||||||
|
push(outline, maxX, maxY, minX, maxY)
|
||||||
|
push(outline, minX, maxY, minX, minY)
|
||||||
|
|
||||||
|
const axisLength = Math.max(15, Math.min(40, (maxX - minX) * 0.1))
|
||||||
|
const axes = [
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
0,
|
||||||
|
minX + axisLength,
|
||||||
|
minY,
|
||||||
|
0,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
0,
|
||||||
|
minX,
|
||||||
|
minY + axisLength,
|
||||||
|
0,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
0,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
axisLength
|
||||||
|
]
|
||||||
|
const axisColors = [
|
||||||
|
1, 0.3, 0.3, 1, 0.3, 0.3, 0.3, 1, 0.3, 0.3, 1, 0.3, 0.3, 0.5, 1, 0.3, 0.5,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
|
||||||
|
const add = (positions, material, colors) => {
|
||||||
|
if (!positions.length) return
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
new THREE.Float32BufferAttribute(positions, 3)
|
||||||
|
)
|
||||||
|
if (colors) {
|
||||||
|
geometry.setAttribute(
|
||||||
|
'color',
|
||||||
|
new THREE.Float32BufferAttribute(colors, 3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const lines = new THREE.LineSegments(geometry, material)
|
||||||
|
lines.renderOrder = -1
|
||||||
|
this.group.add(lines)
|
||||||
|
this.objects.push(lines)
|
||||||
|
}
|
||||||
|
add(minor, this.minorMaterial)
|
||||||
|
add(major, this.majorMaterial)
|
||||||
|
add(outline, this.outlineMaterial)
|
||||||
|
add(axes, this.axisMaterial, axisColors)
|
||||||
|
}
|
||||||
|
|
||||||
|
setColor(hex) {
|
||||||
|
this.minorMaterial.color.set(hex)
|
||||||
|
this.majorMaterial.color.set(hex)
|
||||||
|
this.outlineMaterial.color.set(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
center(out = new THREE.Vector3()) {
|
||||||
|
const { minX, maxX, minY, maxY } = this.volume
|
||||||
|
return out.set((minX + maxX) / 2, (minY + maxY) / 2, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.clear()
|
||||||
|
this.minorMaterial.dispose()
|
||||||
|
this.majorMaterial.dispose()
|
||||||
|
this.outlineMaterial.dispose()
|
||||||
|
this.axisMaterial.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import { MAX_FEATURE_TYPES } from './featureTypes.js'
|
||||||
|
import { RAMP_STOPS } from './colorModes.js'
|
||||||
|
|
||||||
|
export const MAX_TOOLS = 16
|
||||||
|
|
||||||
|
// Uniforms shared by the box (extrusion) and line materials so a single
|
||||||
|
// setter updates both draw calls.
|
||||||
|
export function createSharedUniforms() {
|
||||||
|
const typeColors = []
|
||||||
|
for (let i = 0; i < MAX_FEATURE_TYPES; i++) typeColors.push(new THREE.Color())
|
||||||
|
const toolColors = []
|
||||||
|
for (let i = 0; i < MAX_TOOLS; i++) toolColors.push(new THREE.Color())
|
||||||
|
return {
|
||||||
|
uColorMode: { value: 0 },
|
||||||
|
uRangeMin: { value: 0 },
|
||||||
|
uRangeMax: { value: 1 },
|
||||||
|
uLogScale: { value: 0 },
|
||||||
|
uLayerMin: { value: 0 },
|
||||||
|
uLayerMax: { value: 65535 },
|
||||||
|
uVisibleEnd: { value: 0 },
|
||||||
|
uProgressT: { value: 1 },
|
||||||
|
uTypeColors: { value: typeColors },
|
||||||
|
uToolColors: { value: toolColors },
|
||||||
|
uRamp: { value: RAMP_STOPS.map((c) => new THREE.Color(c)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_VERTEX = /* glsl */ `
|
||||||
|
uniform int uColorMode;
|
||||||
|
uniform float uRangeMin;
|
||||||
|
uniform float uRangeMax;
|
||||||
|
uniform int uLogScale;
|
||||||
|
uniform int uTypeMask;
|
||||||
|
uniform float uLayerMin;
|
||||||
|
uniform float uLayerMax;
|
||||||
|
uniform int uVisibleEnd;
|
||||||
|
uniform float uProgressT;
|
||||||
|
uniform vec3 uTypeColors[${MAX_FEATURE_TYPES}];
|
||||||
|
uniform vec3 uToolColors[${MAX_TOOLS}];
|
||||||
|
uniform vec3 uRamp[${RAMP_STOPS.length}];
|
||||||
|
|
||||||
|
in vec3 instanceStart;
|
||||||
|
in vec3 instanceEnd;
|
||||||
|
in float aType;
|
||||||
|
in float aTool;
|
||||||
|
in float aFan;
|
||||||
|
in float aLayer;
|
||||||
|
in float aTemp;
|
||||||
|
in float aWidth;
|
||||||
|
in float aHeight;
|
||||||
|
in float aFeed;
|
||||||
|
in float aAccel;
|
||||||
|
in float aActualSpeed;
|
||||||
|
in float aLayerTime;
|
||||||
|
|
||||||
|
bool segmentVisible(int t) {
|
||||||
|
if (t == 0) return false;
|
||||||
|
if (((uTypeMask >> t) & 1) == 0) return false;
|
||||||
|
if (aLayer < uLayerMin - 0.5 || aLayer > uLayerMax + 0.5) return false;
|
||||||
|
if (gl_InstanceID >= uVisibleEnd) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
float scalarValue(int mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case 1: return aHeight;
|
||||||
|
case 2: return aWidth;
|
||||||
|
case 3: return aFeed;
|
||||||
|
case 4: return aActualSpeed;
|
||||||
|
case 5: return aAccel;
|
||||||
|
case 6: return aFan * (100.0 / 255.0);
|
||||||
|
case 7: return aTemp;
|
||||||
|
case 8: return aWidth * aHeight * aFeed;
|
||||||
|
case 9: return aWidth * aHeight * aActualSpeed;
|
||||||
|
case 10: return aLayerTime;
|
||||||
|
default: return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 rampColor(float t) {
|
||||||
|
t = clamp(t, 0.0, 1.0) * float(${RAMP_STOPS.length - 1});
|
||||||
|
int i = int(floor(t));
|
||||||
|
if (i >= ${RAMP_STOPS.length - 1}) i = ${RAMP_STOPS.length - 2};
|
||||||
|
return mix(uRamp[i], uRamp[i + 1], t - float(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 segmentColor(int t) {
|
||||||
|
// Travel, wipe and category mode use the per-type palette.
|
||||||
|
if (uColorMode == 0 || t <= 1 || t == 17) return uTypeColors[t];
|
||||||
|
if (uColorMode == 11) return uToolColors[int(aTool + 0.5) & ${MAX_TOOLS - 1}];
|
||||||
|
float v = scalarValue(uColorMode);
|
||||||
|
float lo = uRangeMin;
|
||||||
|
float hi = uRangeMax;
|
||||||
|
if (uLogScale == 1) {
|
||||||
|
v = log(max(v, 1e-3));
|
||||||
|
lo = log(max(lo, 1e-3));
|
||||||
|
hi = log(max(hi, 1e-3));
|
||||||
|
}
|
||||||
|
float span = hi - lo;
|
||||||
|
float n = span > 1e-6 ? (v - lo) / span : 0.5;
|
||||||
|
return rampColor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 segmentEnd() {
|
||||||
|
if (gl_InstanceID == uVisibleEnd - 1) {
|
||||||
|
return mix(instanceStart, instanceEnd, uProgressT);
|
||||||
|
}
|
||||||
|
return instanceEnd;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const BOX_VERTEX = /* glsl */ `
|
||||||
|
${COMMON_VERTEX}
|
||||||
|
out vec3 vNormal;
|
||||||
|
out vec3 vColor;
|
||||||
|
out vec3 vViewPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
int t = int(aType + 0.5);
|
||||||
|
if (!segmentVisible(t) || aWidth <= 0.0 || aHeight <= 0.0) {
|
||||||
|
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
|
||||||
|
vNormal = vec3(0.0, 0.0, 1.0);
|
||||||
|
vColor = vec3(0.0);
|
||||||
|
vViewPos = vec3(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vec3 start = instanceStart;
|
||||||
|
vec3 end = segmentEnd();
|
||||||
|
vec3 d = end - start;
|
||||||
|
float len = length(d);
|
||||||
|
vec3 f = len > 1e-6 ? d / len : vec3(1.0, 0.0, 0.0);
|
||||||
|
// Build a right-handed (f, side, up) basis so the box keeps its winding;
|
||||||
|
// a mirrored basis would flip the faces and back-face culling would then
|
||||||
|
// discard the outside of every line, exposing its interior.
|
||||||
|
vec3 side = cross(vec3(0.0, 0.0, 1.0), f);
|
||||||
|
float sideLen = length(side);
|
||||||
|
side = sideLen > 1e-4 ? side / sideLen : vec3(0.0, 1.0, 0.0);
|
||||||
|
vec3 up = cross(f, side);
|
||||||
|
// Draw slightly narrower than the nominal width so adjacent lines keep a
|
||||||
|
// visible seam instead of merging into a flat sheet.
|
||||||
|
float w = aWidth * 0.85;
|
||||||
|
float h = aHeight;
|
||||||
|
// Extend both ends by half a width so consecutive boxes overlap at corners.
|
||||||
|
float along = position.x * len + (position.x * 2.0 - 1.0) * w * 0.5;
|
||||||
|
vec3 p = start + f * along + side * (position.y * w) + up * ((position.z - 0.5) * h);
|
||||||
|
vec3 n = normal.x * f + normal.y * side + normal.z * up;
|
||||||
|
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||||
|
vViewPos = mv.xyz;
|
||||||
|
vNormal = normalMatrix * n;
|
||||||
|
vColor = segmentColor(t);
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const BOX_FRAGMENT = /* glsl */ `
|
||||||
|
// View-space directions of the key and fill lights (set per frame).
|
||||||
|
uniform vec3 uKeyLightDir;
|
||||||
|
uniform vec3 uFillLightDir;
|
||||||
|
in vec3 vNormal;
|
||||||
|
in vec3 vColor;
|
||||||
|
in vec3 vViewPos;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 n = normalize(vNormal);
|
||||||
|
vec3 v = normalize(-vViewPos);
|
||||||
|
// Fixed directional key light gives neighbouring lines distinct shading
|
||||||
|
// on their top and side faces regardless of where the camera is.
|
||||||
|
float key = max(dot(n, uKeyLightDir), 0.0);
|
||||||
|
float fill = max(dot(n, uFillLightDir), 0.0);
|
||||||
|
// Weak headlight so faces pointing at the camera never go black.
|
||||||
|
float head = max(dot(n, v), 0.0);
|
||||||
|
vec3 h = normalize(uKeyLightDir + v);
|
||||||
|
float spec = pow(max(dot(n, h), 0.0), 32.0) * 0.18 * key;
|
||||||
|
// Keep the lit side below full brightness so saturated palette colours
|
||||||
|
// (cyan, yellow, orange) still show the top/side shading instead of
|
||||||
|
// clipping to a flat sheet.
|
||||||
|
vec3 base = vColor * 0.8;
|
||||||
|
float light = 0.28 + 0.62 * key + 0.18 * fill + 0.12 * head;
|
||||||
|
vec3 c = min(base * light + vec3(spec), vec3(1.0));
|
||||||
|
fragColor = linearToOutputTexel(vec4(c, 1.0));
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const LINE_VERTEX = /* glsl */ `
|
||||||
|
${COMMON_VERTEX}
|
||||||
|
out vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
int t = int(aType + 0.5);
|
||||||
|
if (!segmentVisible(t)) {
|
||||||
|
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
|
||||||
|
vColor = vec3(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vec3 p = mix(instanceStart, segmentEnd(), position.x);
|
||||||
|
vColor = segmentColor(t);
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const LINE_FRAGMENT = /* glsl */ `
|
||||||
|
uniform float uOpacity;
|
||||||
|
in vec3 vColor;
|
||||||
|
layout(location = 0) out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
fragColor = linearToOutputTexel(vec4(vColor, uOpacity));
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export function createExtrusionMaterial(shared) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
glslVersion: THREE.GLSL3,
|
||||||
|
uniforms: {
|
||||||
|
...shared,
|
||||||
|
uTypeMask: { value: 0 },
|
||||||
|
uKeyLightDir: { value: new THREE.Vector3(0, 0, 1) },
|
||||||
|
uFillLightDir: { value: new THREE.Vector3(0, 0, 1) }
|
||||||
|
},
|
||||||
|
vertexShader: BOX_VERTEX,
|
||||||
|
fragmentShader: BOX_FRAGMENT,
|
||||||
|
side: THREE.FrontSide
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLineMaterial(shared) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
glslVersion: THREE.GLSL3,
|
||||||
|
uniforms: {
|
||||||
|
...shared,
|
||||||
|
uTypeMask: { value: 0 },
|
||||||
|
uOpacity: { value: 0.75 }
|
||||||
|
},
|
||||||
|
vertexShader: LINE_VERTEX,
|
||||||
|
fragmentShader: LINE_FRAGMENT,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Button, Flex, InputNumber, Typography } from 'antd'
|
||||||
|
import { FEATURE, FEATURE_INFO } from './featureTypes.js'
|
||||||
|
import { TOOL_PALETTE, rampCss } from './colorModes.js'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
function Swatch({ color }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: color,
|
||||||
|
flex: '0 0 auto'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Swatch.propTypes = { color: PropTypes.string }
|
||||||
|
|
||||||
|
function LegendItem({ color, label, textColor, disabled, onClick }) {
|
||||||
|
const interactive = typeof onClick === 'function'
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
gap={6}
|
||||||
|
align='center'
|
||||||
|
role={interactive ? 'button' : undefined}
|
||||||
|
tabIndex={interactive ? 0 : undefined}
|
||||||
|
aria-pressed={interactive ? !disabled : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={
|
||||||
|
interactive
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
cursor: interactive ? 'pointer' : 'default',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'opacity 0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Swatch color={color} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textDecoration: disabled ? 'line-through' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LegendItem.propTypes = {
|
||||||
|
color: PropTypes.string,
|
||||||
|
label: PropTypes.string,
|
||||||
|
textColor: PropTypes.string,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
onClick: PropTypes.func
|
||||||
|
}
|
||||||
|
|
||||||
|
function GCodeLegend({
|
||||||
|
mode,
|
||||||
|
model,
|
||||||
|
isDarkMode,
|
||||||
|
hiddenTypes,
|
||||||
|
onToggleType,
|
||||||
|
showTravel,
|
||||||
|
onToggleTravel,
|
||||||
|
range,
|
||||||
|
autoRange,
|
||||||
|
onRangeChange
|
||||||
|
}) {
|
||||||
|
if (!model || !mode) return null
|
||||||
|
const textColor = isDarkMode ? '#d9d9d9' : '#595959'
|
||||||
|
|
||||||
|
if (mode.kind === 'category') {
|
||||||
|
const counts = model.typeCounts || []
|
||||||
|
const rows = FEATURE_INFO.filter(
|
||||||
|
(info) =>
|
||||||
|
!info.hidden && info.id !== FEATURE.TRAVEL && (counts[info.id] || 0) > 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Flex gap='middle' align='center' wrap='wrap' justify='center'>
|
||||||
|
{rows.map((info) => (
|
||||||
|
<LegendItem
|
||||||
|
key={info.id}
|
||||||
|
color={info.color}
|
||||||
|
label={info.label}
|
||||||
|
textColor={textColor}
|
||||||
|
disabled={hiddenTypes.has(info.id)}
|
||||||
|
onClick={() => onToggleType(info.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LegendItem
|
||||||
|
color={FEATURE_INFO[FEATURE.TRAVEL].color}
|
||||||
|
label='Travel'
|
||||||
|
textColor={textColor}
|
||||||
|
disabled={!showTravel}
|
||||||
|
onClick={onToggleTravel}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode.kind === 'tool') {
|
||||||
|
const tools = model.tools?.length ? model.tools : [0]
|
||||||
|
const fileColors = model.metadata?.filamentColors || []
|
||||||
|
return (
|
||||||
|
<Flex gap='middle' align='center' wrap='wrap' justify='center'>
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const color =
|
||||||
|
(mode.useFilamentColors && fileColors[tool]) ||
|
||||||
|
TOOL_PALETTE[tool % TOOL_PALETTE.length]
|
||||||
|
return (
|
||||||
|
<LegendItem
|
||||||
|
key={tool}
|
||||||
|
color={color}
|
||||||
|
textColor={textColor}
|
||||||
|
label={
|
||||||
|
mode.useFilamentColors && fileColors[tool]
|
||||||
|
? `Filament ${tool + 1} (${fileColors[tool]})`
|
||||||
|
: `Tool ${tool}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = mode.format || ((v) => String(v))
|
||||||
|
const min = range?.min ?? autoRange?.min ?? 0
|
||||||
|
const max = range?.max ?? autoRange?.max ?? 1
|
||||||
|
const overridden =
|
||||||
|
range != null &&
|
||||||
|
autoRange != null &&
|
||||||
|
(range.min !== autoRange.min || range.max !== autoRange.max)
|
||||||
|
const isDuration = mode.stat === 'layerTime'
|
||||||
|
return (
|
||||||
|
<Flex gap='small' align='center' wrap='wrap' justify='center'>
|
||||||
|
<Text style={{ color: textColor, fontSize: 14, lineHeight: 1 }}>
|
||||||
|
{mode.label}
|
||||||
|
</Text>
|
||||||
|
{isDuration ? (
|
||||||
|
<Text style={{ color: textColor, fontSize: 14 }}>{format(min)}</Text>
|
||||||
|
) : (
|
||||||
|
<InputNumber
|
||||||
|
size='small'
|
||||||
|
value={Number(min.toFixed(2))}
|
||||||
|
onChange={(value) =>
|
||||||
|
Number.isFinite(value) && onRangeChange({ min: value, max })
|
||||||
|
}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: rampCss()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isDuration ? (
|
||||||
|
<Text style={{ color: textColor, fontSize: 14 }}>{format(max)}</Text>
|
||||||
|
) : (
|
||||||
|
<InputNumber
|
||||||
|
size='small'
|
||||||
|
value={Number(max.toFixed(2))}
|
||||||
|
onChange={(value) =>
|
||||||
|
Number.isFinite(value) && onRangeChange({ min, max: value })
|
||||||
|
}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode.unit ? (
|
||||||
|
<Text type='secondary' style={{ fontSize: 12 }}>
|
||||||
|
{mode.unit}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{overridden ? (
|
||||||
|
<Button size='small' type='link' onClick={() => onRangeChange(null)}>
|
||||||
|
Reset range
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeLegend.propTypes = {
|
||||||
|
mode: PropTypes.object,
|
||||||
|
model: PropTypes.object,
|
||||||
|
isDarkMode: PropTypes.bool,
|
||||||
|
hiddenTypes: PropTypes.instanceOf(Set).isRequired,
|
||||||
|
onToggleType: PropTypes.func.isRequired,
|
||||||
|
showTravel: PropTypes.bool,
|
||||||
|
onToggleTravel: PropTypes.func.isRequired,
|
||||||
|
range: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }),
|
||||||
|
autoRange: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }),
|
||||||
|
onRangeChange: PropTypes.func.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GCodeLegend
|
||||||
169
src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js
Normal file
169
src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||||
|
import Bed, { expandVolumeToBounds, normalizeBuildVolume } from './Bed.js'
|
||||||
|
import HotEnd from './HotEnd.js'
|
||||||
|
import SegmentMesh from './SegmentMesh.js'
|
||||||
|
|
||||||
|
// Owns the renderer, camera, controls and the scene graph. Rendering is on
|
||||||
|
// demand: anything that changes what is on screen calls requestRender().
|
||||||
|
export default class GCodeScene {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas
|
||||||
|
this.disposed = false
|
||||||
|
this.renderRequested = false
|
||||||
|
this.frame = this.frame.bind(this)
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: false,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
|
})
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
this.camera = new THREE.PerspectiveCamera(40, 1, 1, 50000)
|
||||||
|
this.camera.up.set(0, 0, 1)
|
||||||
|
this.scene.add(this.camera)
|
||||||
|
|
||||||
|
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x606060, 1.6)
|
||||||
|
this.scene.add(hemisphere)
|
||||||
|
// Fixed world-space key light (matches the extrusion shader's key light)
|
||||||
|
// plus a softer camera-attached fill.
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 2.8)
|
||||||
|
key.position.set(-0.45, -0.35, 1)
|
||||||
|
this.scene.add(key)
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 1.1)
|
||||||
|
fill.position.set(0.3, 0.2, 1)
|
||||||
|
this.camera.add(fill)
|
||||||
|
this.lights = [hemisphere, key, fill]
|
||||||
|
|
||||||
|
this.controls = new OrbitControls(this.camera, canvas)
|
||||||
|
this.controls.enableDamping = false
|
||||||
|
this.controls.screenSpacePanning = true
|
||||||
|
this.controls.addEventListener('change', () => this.requestRender())
|
||||||
|
|
||||||
|
this.bed = new Bed()
|
||||||
|
this.segments = new SegmentMesh()
|
||||||
|
this.hotEnd = new HotEnd()
|
||||||
|
this.scene.add(this.bed.group, this.segments.group, this.hotEnd.group)
|
||||||
|
|
||||||
|
this.buildVolume = normalizeBuildVolume(null)
|
||||||
|
this.resize()
|
||||||
|
this.fitToVolume(this.buildVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- camera -----------------------------------------------------------
|
||||||
|
|
||||||
|
fitToVolume(volume) {
|
||||||
|
const { minX, maxX, minY, maxY, maxZ } = volume
|
||||||
|
const sizeX = maxX - minX
|
||||||
|
const sizeY = maxY - minY
|
||||||
|
// Frame the bed footprint; a tall build volume should not push the
|
||||||
|
// camera away from where the first layers are printed.
|
||||||
|
const center = new THREE.Vector3(
|
||||||
|
(minX + maxX) / 2,
|
||||||
|
(minY + maxY) / 2,
|
||||||
|
Math.min(maxZ * 0.2, Math.max(sizeX, sizeY) * 0.15)
|
||||||
|
)
|
||||||
|
const radius = 0.5 * Math.sqrt(sizeX * sizeX + sizeY * sizeY)
|
||||||
|
const fov = THREE.MathUtils.degToRad(this.camera.fov)
|
||||||
|
const aspect = this.camera.aspect || 1
|
||||||
|
const horizontalFov = 2 * Math.atan(Math.tan(fov / 2) * aspect)
|
||||||
|
const limitingFov = Math.min(fov, horizontalFov)
|
||||||
|
const distance = (radius / Math.sin(limitingFov / 2)) * 0.8
|
||||||
|
const direction = new THREE.Vector3(0, -1, 0.75).normalize()
|
||||||
|
this.camera.position.copy(center).addScaledVector(direction, distance)
|
||||||
|
this.camera.near = Math.max(0.5, distance / 1000)
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.controls.target.copy(center)
|
||||||
|
this.controls.update()
|
||||||
|
this.controls.saveState()
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuildVolume(volume, bounds) {
|
||||||
|
const next = expandVolumeToBounds(normalizeBuildVolume(volume), bounds)
|
||||||
|
const changed =
|
||||||
|
next.minX !== this.buildVolume.minX ||
|
||||||
|
next.maxX !== this.buildVolume.maxX ||
|
||||||
|
next.minY !== this.buildVolume.minY ||
|
||||||
|
next.maxY !== this.buildVolume.maxY ||
|
||||||
|
next.maxZ !== this.buildVolume.maxZ
|
||||||
|
if (!changed) return false
|
||||||
|
this.buildVolume = next
|
||||||
|
this.bed.setVolume(next)
|
||||||
|
this.requestRender()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const canvas = this.canvas
|
||||||
|
const width = canvas.clientWidth || 1
|
||||||
|
const height = canvas.clientHeight || 1
|
||||||
|
this.renderer.setSize(width, height, false)
|
||||||
|
this.camera.aspect = width / height
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering --------------------------------------------------------
|
||||||
|
|
||||||
|
requestRender() {
|
||||||
|
if (this.renderRequested || this.disposed) return
|
||||||
|
this.renderRequested = true
|
||||||
|
requestAnimationFrame(this.frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
this.renderRequested = false
|
||||||
|
if (this.disposed) return
|
||||||
|
const animating = this.hotEnd.update()
|
||||||
|
this.camera.updateMatrixWorld()
|
||||||
|
this.segments.updateLights(this.camera)
|
||||||
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
if (animating) this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- model ------------------------------------------------------------
|
||||||
|
|
||||||
|
setStore(store) {
|
||||||
|
this.segments.setStore(store)
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStore() {
|
||||||
|
this.segments.sync()
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolPosition(x, y, z, immediate = false) {
|
||||||
|
if (![x, y, z].every(Number.isFinite)) return
|
||||||
|
this.hotEnd.setTarget(x, y, z, immediate)
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
setHotEndVisible(visible) {
|
||||||
|
if (this.hotEnd.group.visible === visible) return
|
||||||
|
this.hotEnd.setVisible(visible)
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme({ background, grid }) {
|
||||||
|
if (background) this.renderer.setClearColor(new THREE.Color(background), 1)
|
||||||
|
if (grid) this.bed.setColor(grid)
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true
|
||||||
|
this.controls.dispose()
|
||||||
|
this.segments.dispose()
|
||||||
|
this.bed.dispose()
|
||||||
|
this.hotEnd.dispose()
|
||||||
|
for (const light of this.lights) light.dispose?.()
|
||||||
|
// No forceContextLoss: React StrictMode re-mounts onto the same canvas
|
||||||
|
// and a lost context cannot be re-created on it.
|
||||||
|
this.renderer.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/components/Components/GCodePreview/GCodeViewer/HotEnd.js
Normal file
106
src/components/Components/GCodePreview/GCodeViewer/HotEnd.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
// Stylised hot end (nozzle, heater block, heat break, finned heatsink) with
|
||||||
|
// the nozzle tip at the group origin pointing down -Z. Dimensions in mm.
|
||||||
|
export default class HotEnd {
|
||||||
|
constructor() {
|
||||||
|
this.group = new THREE.Group()
|
||||||
|
this.group.visible = false
|
||||||
|
this.target = new THREE.Vector3()
|
||||||
|
this.hasTarget = false
|
||||||
|
this.materials = []
|
||||||
|
this.geometries = []
|
||||||
|
this.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
material(color, options = {}) {
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
metalness: options.metalness ?? 0.6,
|
||||||
|
roughness: options.roughness ?? 0.4
|
||||||
|
})
|
||||||
|
this.materials.push(material)
|
||||||
|
return material
|
||||||
|
}
|
||||||
|
|
||||||
|
cylinder(radiusTop, radiusBottom, height, z, material, segments = 24) {
|
||||||
|
const geometry = new THREE.CylinderGeometry(
|
||||||
|
radiusTop,
|
||||||
|
radiusBottom,
|
||||||
|
height,
|
||||||
|
segments
|
||||||
|
)
|
||||||
|
geometry.rotateX(Math.PI / 2)
|
||||||
|
geometry.translate(0, 0, z + height / 2)
|
||||||
|
this.geometries.push(geometry)
|
||||||
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
this.group.add(mesh)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
box(width, depth, height, x, y, z, material) {
|
||||||
|
const geometry = new THREE.BoxGeometry(width, depth, height)
|
||||||
|
geometry.translate(x, y, z + height / 2)
|
||||||
|
this.geometries.push(geometry)
|
||||||
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
this.group.add(mesh)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
const brass = this.material(0xc9a227, { metalness: 0.8, roughness: 0.35 })
|
||||||
|
const steel = this.material(0x8d9299, { metalness: 0.7, roughness: 0.5 })
|
||||||
|
const block = this.material(0x5c6066, { metalness: 0.5, roughness: 0.6 })
|
||||||
|
const fins = this.material(0xb9bec6, { metalness: 0.7, roughness: 0.45 })
|
||||||
|
const sock = this.material(0x2f6fdb, { metalness: 0.1, roughness: 0.9 })
|
||||||
|
|
||||||
|
// Nozzle tip and hex body.
|
||||||
|
this.cylinder(1.6, 0.35, 2.5, 0, brass)
|
||||||
|
this.cylinder(3.2, 3.2, 3, 2.5, brass, 6)
|
||||||
|
// Heater block with a silicone sock.
|
||||||
|
this.box(16, 10, 11, 3, 0, 5.5, block)
|
||||||
|
this.box(17, 11, 9, 3, 0, 6.5, sock)
|
||||||
|
// Heat break.
|
||||||
|
this.cylinder(1.6, 1.6, 6, 16.5, steel)
|
||||||
|
// Heatsink core and fins.
|
||||||
|
this.cylinder(4, 4, 24, 22.5, fins)
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
this.cylinder(10.5, 10.5, 1, 23 + i * 3, fins, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTarget(x, y, z, immediate = false) {
|
||||||
|
this.target.set(x, y, z)
|
||||||
|
this.hasTarget = true
|
||||||
|
if (immediate || !this.group.visible) {
|
||||||
|
this.group.position.copy(this.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(visible) {
|
||||||
|
if (visible && !this.group.visible && this.hasTarget) {
|
||||||
|
this.group.position.copy(this.target)
|
||||||
|
}
|
||||||
|
this.group.visible = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eases towards the target; returns true while still moving.
|
||||||
|
update() {
|
||||||
|
if (!this.group.visible || !this.hasTarget) return false
|
||||||
|
const position = this.group.position
|
||||||
|
const distance = position.distanceTo(this.target)
|
||||||
|
if (distance < 0.01) {
|
||||||
|
position.copy(this.target)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
position.lerp(this.target, distance > 50 ? 1 : 0.35)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const geometry of this.geometries) geometry.dispose()
|
||||||
|
for (const material of this.materials) material.dispose()
|
||||||
|
this.geometries = []
|
||||||
|
this.materials = []
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,311 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import {
|
||||||
|
createExtrusionMaterial,
|
||||||
|
createLineMaterial,
|
||||||
|
createSharedUniforms,
|
||||||
|
MAX_TOOLS
|
||||||
|
} from './ExtrusionMaterial.js'
|
||||||
|
import {
|
||||||
|
ALL_TYPES_MASK,
|
||||||
|
EXTRUSION_TYPES_MASK,
|
||||||
|
FEATURE_INFO,
|
||||||
|
LINE_TYPES_MASK,
|
||||||
|
MAX_FEATURE_TYPES
|
||||||
|
} from './featureTypes.js'
|
||||||
|
import { TOOL_PALETTE } from './colorModes.js'
|
||||||
|
|
||||||
|
// GPU representation of a SegmentStore. Every segment is one instance; the
|
||||||
|
// box geometry renders extrusions and the two-vertex line geometry renders
|
||||||
|
// travels (and everything in "lines" mode). Both geometries share the same
|
||||||
|
// attribute objects, so each field lives in GPU memory exactly once.
|
||||||
|
|
||||||
|
function createBoxBase() {
|
||||||
|
const box = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
box.translate(0.5, 0, 0)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLineBase() {
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3)
|
||||||
|
)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTANCE_FIELDS = [
|
||||||
|
['aType', 'type'],
|
||||||
|
['aTool', 'tool'],
|
||||||
|
['aFan', 'fan'],
|
||||||
|
['aLayer', 'layer'],
|
||||||
|
['aTemp', 'temp'],
|
||||||
|
['aWidth', 'width'],
|
||||||
|
['aHeight', 'height'],
|
||||||
|
['aFeed', 'feed'],
|
||||||
|
['aAccel', 'accel'],
|
||||||
|
['aActualSpeed', 'actualSpeed'],
|
||||||
|
['aLayerTime', 'layerTime']
|
||||||
|
]
|
||||||
|
|
||||||
|
export default class SegmentMesh {
|
||||||
|
constructor() {
|
||||||
|
this.group = new THREE.Group()
|
||||||
|
this.shared = createSharedUniforms()
|
||||||
|
this.boxMaterial = createExtrusionMaterial(this.shared)
|
||||||
|
this.lineMaterial = createLineMaterial(this.shared)
|
||||||
|
this.boxBase = createBoxBase()
|
||||||
|
this.lineBase = createLineBase()
|
||||||
|
// World-space light directions; converted to view space per frame.
|
||||||
|
this.keyLightWorld = new THREE.Vector3(-0.45, -0.35, 1).normalize()
|
||||||
|
this.fillLightWorld = new THREE.Vector3(0.7, 0.6, 0.25).normalize()
|
||||||
|
|
||||||
|
this.boxGeometry = null
|
||||||
|
this.lineGeometry = null
|
||||||
|
this.mesh = null
|
||||||
|
this.lines = null
|
||||||
|
this.positionBuffer = null
|
||||||
|
this.attributes = {}
|
||||||
|
|
||||||
|
this.store = null
|
||||||
|
this.generation = -1
|
||||||
|
this.uploaded = 0
|
||||||
|
this.plannerUploaded = false
|
||||||
|
|
||||||
|
this.renderMode = 'extrusion'
|
||||||
|
this.visibleMask = ALL_TYPES_MASK & ~(1 << 1) // travel hidden by default
|
||||||
|
this.applyMasks()
|
||||||
|
this.setTypeColors()
|
||||||
|
this.setToolColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore(store) {
|
||||||
|
this.store = store
|
||||||
|
this.generation = -1
|
||||||
|
this.uploaded = 0
|
||||||
|
this.plannerUploaded = false
|
||||||
|
this.shared.uVisibleEnd.value = 0
|
||||||
|
this.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload anything new since the last sync. Cheap when nothing changed.
|
||||||
|
sync() {
|
||||||
|
const store = this.store
|
||||||
|
if (!store) {
|
||||||
|
this.disposeGeometry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.generation !== this.generation) {
|
||||||
|
this.rebuild()
|
||||||
|
} else if (store.count > this.uploaded) {
|
||||||
|
this.uploadRange(this.uploaded, store.count)
|
||||||
|
}
|
||||||
|
if (store.plannerReady && !this.plannerUploaded) {
|
||||||
|
this.uploadPlanner()
|
||||||
|
}
|
||||||
|
this.uploaded = store.count
|
||||||
|
if (this.boxGeometry) this.boxGeometry.instanceCount = store.count
|
||||||
|
if (this.lineGeometry) this.lineGeometry.instanceCount = store.count
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild() {
|
||||||
|
this.disposeGeometry()
|
||||||
|
const store = this.store
|
||||||
|
this.generation = store.generation
|
||||||
|
|
||||||
|
const positionBuffer = new THREE.InstancedInterleavedBuffer(
|
||||||
|
store.positions,
|
||||||
|
3,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
positionBuffer.setUsage(THREE.DynamicDrawUsage)
|
||||||
|
this.positionBuffer = positionBuffer
|
||||||
|
const instanceStart = new THREE.InterleavedBufferAttribute(
|
||||||
|
positionBuffer,
|
||||||
|
3,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const instanceEnd = new THREE.InterleavedBufferAttribute(
|
||||||
|
positionBuffer,
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
)
|
||||||
|
|
||||||
|
this.attributes = {}
|
||||||
|
for (const [name, field] of INSTANCE_FIELDS) {
|
||||||
|
const attribute = new THREE.InstancedBufferAttribute(store[field], 1)
|
||||||
|
attribute.setUsage(THREE.DynamicDrawUsage)
|
||||||
|
this.attributes[name] = attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyInstanceAttributes = (geometry) => {
|
||||||
|
geometry.setAttribute('instanceStart', instanceStart)
|
||||||
|
geometry.setAttribute('instanceEnd', instanceEnd)
|
||||||
|
for (const name of Object.keys(this.attributes)) {
|
||||||
|
geometry.setAttribute(name, this.attributes[name])
|
||||||
|
}
|
||||||
|
geometry.instanceCount = store.count
|
||||||
|
// The bounding volume would otherwise be computed from the base
|
||||||
|
// geometry only; culling is skipped since the model fills the view.
|
||||||
|
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6)
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxGeometry = new THREE.InstancedBufferGeometry()
|
||||||
|
boxGeometry.index = this.boxBase.index
|
||||||
|
boxGeometry.setAttribute('position', this.boxBase.attributes.position)
|
||||||
|
boxGeometry.setAttribute('normal', this.boxBase.attributes.normal)
|
||||||
|
applyInstanceAttributes(boxGeometry)
|
||||||
|
|
||||||
|
const lineGeometry = new THREE.InstancedBufferGeometry()
|
||||||
|
lineGeometry.setAttribute('position', this.lineBase.attributes.position)
|
||||||
|
applyInstanceAttributes(lineGeometry)
|
||||||
|
|
||||||
|
this.boxGeometry = boxGeometry
|
||||||
|
this.lineGeometry = lineGeometry
|
||||||
|
|
||||||
|
this.mesh = new THREE.Mesh(boxGeometry, this.boxMaterial)
|
||||||
|
this.mesh.frustumCulled = false
|
||||||
|
this.mesh.visible = this.renderMode === 'extrusion'
|
||||||
|
this.lines = new THREE.LineSegments(lineGeometry, this.lineMaterial)
|
||||||
|
this.lines.frustumCulled = false
|
||||||
|
this.group.add(this.mesh, this.lines)
|
||||||
|
|
||||||
|
this.uploaded = store.count
|
||||||
|
this.plannerUploaded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadRange(from, to) {
|
||||||
|
if (to <= from) return
|
||||||
|
const count = to - from
|
||||||
|
// Segment `to - 1` ends at vertex `to`, so include one extra vertex.
|
||||||
|
this.positionBuffer.addUpdateRange(from * 3, (count + 1) * 3)
|
||||||
|
this.positionBuffer.needsUpdate = true
|
||||||
|
for (const [name] of INSTANCE_FIELDS) {
|
||||||
|
if (name === 'aActualSpeed' || name === 'aLayerTime') continue
|
||||||
|
const attribute = this.attributes[name]
|
||||||
|
attribute.addUpdateRange(from, count)
|
||||||
|
attribute.needsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadPlanner() {
|
||||||
|
for (const name of ['aActualSpeed', 'aLayerTime']) {
|
||||||
|
const attribute = this.attributes[name]
|
||||||
|
if (!attribute) continue
|
||||||
|
attribute.addUpdateRange(0, this.store.count)
|
||||||
|
attribute.needsUpdate = true
|
||||||
|
}
|
||||||
|
this.plannerUploaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
disposeGeometry() {
|
||||||
|
if (this.mesh) this.group.remove(this.mesh)
|
||||||
|
if (this.lines) this.group.remove(this.lines)
|
||||||
|
this.boxGeometry?.dispose()
|
||||||
|
this.lineGeometry?.dispose()
|
||||||
|
this.boxGeometry = null
|
||||||
|
this.lineGeometry = null
|
||||||
|
this.mesh = null
|
||||||
|
this.lines = null
|
||||||
|
this.positionBuffer = null
|
||||||
|
this.attributes = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposeGeometry()
|
||||||
|
this.boxBase.dispose()
|
||||||
|
this.lineBase.dispose()
|
||||||
|
this.boxMaterial.dispose()
|
||||||
|
this.lineMaterial.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- uniform driven state -------------------------------------------
|
||||||
|
|
||||||
|
// Call before rendering so the fixed world lights follow camera rotation.
|
||||||
|
updateLights(camera) {
|
||||||
|
const { uKeyLightDir, uFillLightDir } = this.boxMaterial.uniforms
|
||||||
|
uKeyLightDir.value
|
||||||
|
.copy(this.keyLightWorld)
|
||||||
|
.transformDirection(camera.matrixWorldInverse)
|
||||||
|
uFillLightDir.value
|
||||||
|
.copy(this.fillLightWorld)
|
||||||
|
.transformDirection(camera.matrixWorldInverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMasks() {
|
||||||
|
const mask = this.visibleMask & ALL_TYPES_MASK
|
||||||
|
if (this.renderMode === 'lines') {
|
||||||
|
this.boxMaterial.uniforms.uTypeMask.value = 0
|
||||||
|
this.lineMaterial.uniforms.uTypeMask.value = mask
|
||||||
|
this.lineMaterial.uniforms.uOpacity.value = 1
|
||||||
|
this.lineMaterial.transparent = false
|
||||||
|
this.lineMaterial.depthWrite = true
|
||||||
|
} else {
|
||||||
|
this.boxMaterial.uniforms.uTypeMask.value = mask & EXTRUSION_TYPES_MASK
|
||||||
|
this.lineMaterial.uniforms.uTypeMask.value = mask & LINE_TYPES_MASK
|
||||||
|
this.lineMaterial.uniforms.uOpacity.value = 0.7
|
||||||
|
this.lineMaterial.transparent = true
|
||||||
|
this.lineMaterial.depthWrite = false
|
||||||
|
}
|
||||||
|
if (this.mesh) this.mesh.visible = this.renderMode === 'extrusion'
|
||||||
|
}
|
||||||
|
|
||||||
|
setRenderMode(mode) {
|
||||||
|
this.renderMode = mode === 'lines' ? 'lines' : 'extrusion'
|
||||||
|
this.applyMasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleMask(mask) {
|
||||||
|
this.visibleMask = mask
|
||||||
|
this.applyMasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
setColorMode(shaderMode, logScale = false) {
|
||||||
|
this.shared.uColorMode.value = shaderMode | 0
|
||||||
|
this.shared.uLogScale.value = logScale ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setRange(min, max) {
|
||||||
|
this.shared.uRangeMin.value = Number.isFinite(min) ? min : 0
|
||||||
|
this.shared.uRangeMax.value = Number.isFinite(max) ? max : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerRange(min, max) {
|
||||||
|
this.shared.uLayerMin.value = min
|
||||||
|
this.shared.uLayerMax.value = max
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show segments [0, end) fully and fraction `t` of segment `end - 1`.
|
||||||
|
setProgress(end, t = 1) {
|
||||||
|
const count = this.store ? this.store.count : 0
|
||||||
|
const clamped = Math.max(0, Math.min(count, end | 0))
|
||||||
|
this.shared.uVisibleEnd.value = clamped
|
||||||
|
this.shared.uProgressT.value = Math.max(0, Math.min(1, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
showAll() {
|
||||||
|
this.shared.uVisibleEnd.value = this.store ? this.store.count : 0
|
||||||
|
this.shared.uProgressT.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
setTypeColors(overrides = {}) {
|
||||||
|
const colors = this.shared.uTypeColors.value
|
||||||
|
for (let i = 0; i < MAX_FEATURE_TYPES; i++) {
|
||||||
|
const info = FEATURE_INFO[i]
|
||||||
|
const hex = overrides[i] || info?.color || '#888888'
|
||||||
|
colors[i].set(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolColors(hexColors = []) {
|
||||||
|
const colors = this.shared.uToolColors.value
|
||||||
|
for (let i = 0; i < MAX_TOOLS; i++) {
|
||||||
|
const hex = hexColors[i] || TOOL_PALETTE[i % TOOL_PALETTE.length]
|
||||||
|
try {
|
||||||
|
colors[i].set(hex)
|
||||||
|
} catch {
|
||||||
|
colors[i].set(TOOL_PALETTE[i % TOOL_PALETTE.length])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/components/Components/GCodePreview/GCodeViewer/colorModes.js
Normal file
200
src/components/Components/GCodePreview/GCodeViewer/colorModes.js
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
// Colour modes mirror Orca Slicer's preview "view type" list, plus
|
||||||
|
// acceleration. `shaderMode` is the integer the shaders switch on; `stat` is
|
||||||
|
// the key in the worker's range statistics used for auto ranging.
|
||||||
|
|
||||||
|
export const SHADER_MODE = Object.freeze({
|
||||||
|
FEATURE: 0,
|
||||||
|
HEIGHT: 1,
|
||||||
|
WIDTH: 2,
|
||||||
|
SPEED: 3,
|
||||||
|
ACTUAL_SPEED: 4,
|
||||||
|
ACCEL: 5,
|
||||||
|
FAN: 6,
|
||||||
|
TEMP: 7,
|
||||||
|
FLOW: 8,
|
||||||
|
ACTUAL_FLOW: 9,
|
||||||
|
LAYER_TIME: 10,
|
||||||
|
TOOL: 11
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatNumber = (digits) => (value) =>
|
||||||
|
Number.isFinite(value) ? value.toFixed(digits) : '-'
|
||||||
|
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (!Number.isFinite(seconds)) return '-'
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const rest = Math.round(seconds - minutes * 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ${rest}s`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
return `${hours}h ${minutes - hours * 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_MODES = [
|
||||||
|
{
|
||||||
|
value: 'feature',
|
||||||
|
label: 'Line type',
|
||||||
|
kind: 'category',
|
||||||
|
shaderMode: SHADER_MODE.FEATURE
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'height',
|
||||||
|
label: 'Height',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.HEIGHT,
|
||||||
|
stat: 'height',
|
||||||
|
unit: 'mm',
|
||||||
|
format: formatNumber(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'width',
|
||||||
|
label: 'Width',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.WIDTH,
|
||||||
|
stat: 'width',
|
||||||
|
unit: 'mm',
|
||||||
|
format: formatNumber(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'speed',
|
||||||
|
label: 'Speed',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.SPEED,
|
||||||
|
stat: 'feed',
|
||||||
|
unit: 'mm/s',
|
||||||
|
format: formatNumber(0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'actualSpeed',
|
||||||
|
label: 'Actual speed',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.ACTUAL_SPEED,
|
||||||
|
stat: 'actualSpeed',
|
||||||
|
unit: 'mm/s',
|
||||||
|
format: formatNumber(0),
|
||||||
|
requiresPlanner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'accel',
|
||||||
|
label: 'Acceleration',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.ACCEL,
|
||||||
|
stat: 'accel',
|
||||||
|
unit: 'mm/s²',
|
||||||
|
format: formatNumber(0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'fan',
|
||||||
|
label: 'Fan speed',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.FAN,
|
||||||
|
stat: 'fan',
|
||||||
|
unit: '%',
|
||||||
|
format: formatNumber(0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'temp',
|
||||||
|
label: 'Temperature',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.TEMP,
|
||||||
|
stat: 'temp',
|
||||||
|
unit: '°C',
|
||||||
|
format: formatNumber(0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'flow',
|
||||||
|
label: 'Volumetric flow',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.FLOW,
|
||||||
|
stat: 'flow',
|
||||||
|
unit: 'mm³/s',
|
||||||
|
format: formatNumber(1)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'actualFlow',
|
||||||
|
label: 'Actual volumetric flow',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.ACTUAL_FLOW,
|
||||||
|
stat: 'actualFlow',
|
||||||
|
unit: 'mm³/s',
|
||||||
|
format: formatNumber(1),
|
||||||
|
requiresPlanner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'layerTime',
|
||||||
|
label: 'Layer time',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.LAYER_TIME,
|
||||||
|
stat: 'layerTime',
|
||||||
|
unit: '',
|
||||||
|
format: formatDuration,
|
||||||
|
requiresPlanner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'layerTimeLog',
|
||||||
|
label: 'Layer time (log)',
|
||||||
|
kind: 'scalar',
|
||||||
|
shaderMode: SHADER_MODE.LAYER_TIME,
|
||||||
|
stat: 'layerTime',
|
||||||
|
unit: '',
|
||||||
|
format: formatDuration,
|
||||||
|
logScale: true,
|
||||||
|
requiresPlanner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tool',
|
||||||
|
label: 'Tool',
|
||||||
|
kind: 'tool',
|
||||||
|
shaderMode: SHADER_MODE.TOOL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'filament',
|
||||||
|
label: 'Filament',
|
||||||
|
kind: 'tool',
|
||||||
|
shaderMode: SHADER_MODE.TOOL,
|
||||||
|
useFilamentColors: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const COLOR_MODE_BY_VALUE = new Map(
|
||||||
|
COLOR_MODES.map((mode) => [mode.value, mode])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Orca Slicer's scalar ramp (blue -> green -> yellow -> red), 11 stops.
|
||||||
|
export const RAMP_STOPS = [
|
||||||
|
'#0b2c7a',
|
||||||
|
'#135985',
|
||||||
|
'#1c8891',
|
||||||
|
'#04d60f',
|
||||||
|
'#aaf200',
|
||||||
|
'#fcf803',
|
||||||
|
'#f5ce0a',
|
||||||
|
'#e38820',
|
||||||
|
'#d16830',
|
||||||
|
'#c2523c',
|
||||||
|
'#942616'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Distinct colours for tools when the file has no filament colours.
|
||||||
|
export const TOOL_PALETTE = [
|
||||||
|
'#00a2ff',
|
||||||
|
'#ff8b2d',
|
||||||
|
'#2dd36f',
|
||||||
|
'#ff4d6d',
|
||||||
|
'#b36bff',
|
||||||
|
'#ffd43b',
|
||||||
|
'#20c9b8',
|
||||||
|
'#ff6b00',
|
||||||
|
'#7a9cff',
|
||||||
|
'#d6336c',
|
||||||
|
'#69db7c',
|
||||||
|
'#f783ac',
|
||||||
|
'#4dabf7',
|
||||||
|
'#ffa94d',
|
||||||
|
'#9775fa',
|
||||||
|
'#a9e34b'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function rampCss() {
|
||||||
|
return `linear-gradient(to right, ${RAMP_STOPS.join(', ')})`
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
// Canonical feature (line) types shared by the worker, the shaders and the UI.
|
||||||
|
// Index 0 is reserved for segments that must never render (position jumps),
|
||||||
|
// index 1 is travel. Everything above is an extrusion type.
|
||||||
|
|
||||||
|
export const FEATURE = Object.freeze({
|
||||||
|
NONE: 0,
|
||||||
|
TRAVEL: 1,
|
||||||
|
OUTER_WALL: 2,
|
||||||
|
INNER_WALL: 3,
|
||||||
|
OVERHANG_WALL: 4,
|
||||||
|
SPARSE_INFILL: 5,
|
||||||
|
SOLID_INFILL: 6,
|
||||||
|
TOP_SURFACE: 7,
|
||||||
|
BOTTOM_SURFACE: 8,
|
||||||
|
IRONING: 9,
|
||||||
|
BRIDGE: 10,
|
||||||
|
GAP_FILL: 11,
|
||||||
|
SKIRT: 12,
|
||||||
|
BRIM: 13,
|
||||||
|
SUPPORT: 14,
|
||||||
|
SUPPORT_INTERFACE: 15,
|
||||||
|
PRIME_TOWER: 16,
|
||||||
|
WIPE: 17,
|
||||||
|
CUSTOM: 18,
|
||||||
|
UNKNOWN: 19
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FEATURE_COUNT = 20
|
||||||
|
export const MAX_FEATURE_TYPES = 32
|
||||||
|
|
||||||
|
// Orca Slicer palette.
|
||||||
|
export const FEATURE_INFO = [
|
||||||
|
{ id: FEATURE.NONE, label: 'Hidden', color: '#000000', hidden: true },
|
||||||
|
{ id: FEATURE.TRAVEL, label: 'Travel', color: '#8c8c8c', travel: true },
|
||||||
|
{ id: FEATURE.OUTER_WALL, label: 'Outer wall', color: '#ff7d38' },
|
||||||
|
{ id: FEATURE.INNER_WALL, label: 'Inner wall', color: '#ffe64d' },
|
||||||
|
{ id: FEATURE.OVERHANG_WALL, label: 'Overhang wall', color: '#1f4cff' },
|
||||||
|
{ id: FEATURE.SPARSE_INFILL, label: 'Sparse infill', color: '#b03c7f' },
|
||||||
|
{
|
||||||
|
id: FEATURE.SOLID_INFILL,
|
||||||
|
label: 'Internal solid infill',
|
||||||
|
color: '#9c0ed0'
|
||||||
|
},
|
||||||
|
{ id: FEATURE.TOP_SURFACE, label: 'Top surface', color: '#ff3d3d' },
|
||||||
|
{ id: FEATURE.BOTTOM_SURFACE, label: 'Bottom surface', color: '#ff6669' },
|
||||||
|
{ id: FEATURE.IRONING, label: 'Ironing', color: '#ffc8ff' },
|
||||||
|
{ id: FEATURE.BRIDGE, label: 'Bridge', color: '#4d80bd' },
|
||||||
|
{ id: FEATURE.GAP_FILL, label: 'Gap infill', color: '#ffffff' },
|
||||||
|
{ id: FEATURE.SKIRT, label: 'Skirt', color: '#00ffff' },
|
||||||
|
{ id: FEATURE.BRIM, label: 'Brim', color: '#6fe0ff' },
|
||||||
|
{ id: FEATURE.SUPPORT, label: 'Support', color: '#00ff00' },
|
||||||
|
{
|
||||||
|
id: FEATURE.SUPPORT_INTERFACE,
|
||||||
|
label: 'Support interface',
|
||||||
|
color: '#4dff80'
|
||||||
|
},
|
||||||
|
{ id: FEATURE.PRIME_TOWER, label: 'Prime tower', color: '#b3b3b3' },
|
||||||
|
{ id: FEATURE.WIPE, label: 'Wipe', color: '#ffff7d' },
|
||||||
|
{ id: FEATURE.CUSTOM, label: 'Custom', color: '#e6b35c' },
|
||||||
|
{ id: FEATURE.UNKNOWN, label: 'Other', color: '#b8b8b8' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Normalised slicer strings -> feature. Keys are lower-cased and have
|
||||||
|
// whitespace, dashes and underscores stripped so the same table handles
|
||||||
|
// Orca, Bambu, Prusa, SuperSlicer, Cura and ideaMaker variants.
|
||||||
|
const FEATURE_LOOKUP = new Map(
|
||||||
|
Object.entries({
|
||||||
|
// Orca / Bambu
|
||||||
|
outerwall: FEATURE.OUTER_WALL,
|
||||||
|
innerwall: FEATURE.INNER_WALL,
|
||||||
|
overhangwall: FEATURE.OVERHANG_WALL,
|
||||||
|
sparseinfill: FEATURE.SPARSE_INFILL,
|
||||||
|
internalsolidinfill: FEATURE.SOLID_INFILL,
|
||||||
|
topsurface: FEATURE.TOP_SURFACE,
|
||||||
|
bottomsurface: FEATURE.BOTTOM_SURFACE,
|
||||||
|
ironing: FEATURE.IRONING,
|
||||||
|
bridge: FEATURE.BRIDGE,
|
||||||
|
internalbridge: FEATURE.BRIDGE,
|
||||||
|
gapinfill: FEATURE.GAP_FILL,
|
||||||
|
skirt: FEATURE.SKIRT,
|
||||||
|
brim: FEATURE.BRIM,
|
||||||
|
support: FEATURE.SUPPORT,
|
||||||
|
supportinterface: FEATURE.SUPPORT_INTERFACE,
|
||||||
|
supporttransition: FEATURE.SUPPORT,
|
||||||
|
primetower: FEATURE.PRIME_TOWER,
|
||||||
|
custom: FEATURE.CUSTOM,
|
||||||
|
multiple: FEATURE.UNKNOWN,
|
||||||
|
wipe: FEATURE.WIPE,
|
||||||
|
// Prusa / SuperSlicer
|
||||||
|
perimeter: FEATURE.INNER_WALL,
|
||||||
|
externalperimeter: FEATURE.OUTER_WALL,
|
||||||
|
overhangperimeter: FEATURE.OVERHANG_WALL,
|
||||||
|
internalinfill: FEATURE.SPARSE_INFILL,
|
||||||
|
solidinfill: FEATURE.SOLID_INFILL,
|
||||||
|
topsolidinfill: FEATURE.TOP_SURFACE,
|
||||||
|
bottomsolidinfill: FEATURE.BOTTOM_SURFACE,
|
||||||
|
bridgeinfill: FEATURE.BRIDGE,
|
||||||
|
internalbridgeinfill: FEATURE.BRIDGE,
|
||||||
|
thinwall: FEATURE.GAP_FILL,
|
||||||
|
gapfill: FEATURE.GAP_FILL,
|
||||||
|
'skirt/brim': FEATURE.SKIRT,
|
||||||
|
skirtbrim: FEATURE.SKIRT,
|
||||||
|
supportmaterial: FEATURE.SUPPORT,
|
||||||
|
supportmaterialinterface: FEATURE.SUPPORT_INTERFACE,
|
||||||
|
wipetower: FEATURE.PRIME_TOWER,
|
||||||
|
// Cura
|
||||||
|
wallouter: FEATURE.OUTER_WALL,
|
||||||
|
wallinner: FEATURE.INNER_WALL,
|
||||||
|
skin: FEATURE.TOP_SURFACE,
|
||||||
|
fill: FEATURE.SPARSE_INFILL,
|
||||||
|
supportinterfacecura: FEATURE.SUPPORT_INTERFACE,
|
||||||
|
// ideaMaker / Simplify3D
|
||||||
|
outershell: FEATURE.OUTER_WALL,
|
||||||
|
innershell: FEATURE.INNER_WALL,
|
||||||
|
infill: FEATURE.SPARSE_INFILL,
|
||||||
|
solidlayer: FEATURE.SOLID_INFILL,
|
||||||
|
raft: FEATURE.SUPPORT,
|
||||||
|
ooze: FEATURE.PRIME_TOWER
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export function featureFromSlicerString(value) {
|
||||||
|
if (!value) return FEATURE.UNKNOWN
|
||||||
|
const key = String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\-_]+/g, '')
|
||||||
|
return FEATURE_LOOKUP.get(key) ?? FEATURE.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel and wipe moves carry no material and are drawn as lines.
|
||||||
|
export function isLineFeature(type) {
|
||||||
|
return type <= FEATURE.TRAVEL || type === FEATURE.WIPE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExtrusionFeature(type) {
|
||||||
|
return type > FEATURE.TRAVEL && type !== FEATURE.WIPE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_TYPES_MASK = (1 << FEATURE_COUNT) - 1
|
||||||
|
export const LINE_TYPES_MASK = (1 << FEATURE.TRAVEL) | (1 << FEATURE.WIPE)
|
||||||
|
export const EXTRUSION_TYPES_MASK = ALL_TYPES_MASK & ~LINE_TYPES_MASK & ~1
|
||||||
1313
src/components/Components/GCodePreview/GCodeViewer/parser.worker.js
Normal file
1313
src/components/Components/GCodePreview/GCodeViewer/parser.worker.js
Normal file
File diff suppressed because it is too large
Load Diff
169
src/components/Components/GCodePreview/GCodeViewer/planner.js
Normal file
169
src/components/Components/GCodePreview/GCodeViewer/planner.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { FEATURE } from './featureTypes.js'
|
||||||
|
|
||||||
|
// Simplified Klipper-style motion planner. For every segment it computes the
|
||||||
|
// trapezoid the firmware would run given the requested feedrate, the active
|
||||||
|
// acceleration and the junction limits between consecutive moves, which yields
|
||||||
|
// the speed the toolhead actually reaches and how long each segment takes.
|
||||||
|
//
|
||||||
|
// It ignores extruder-specific limits, minimum cruise ratio smoothing and
|
||||||
|
// input shaper, so times are approximate but relative values are faithful.
|
||||||
|
|
||||||
|
const SQRT2_MINUS_1 = Math.SQRT2 - 1
|
||||||
|
|
||||||
|
export function planMotion(store, options = {}) {
|
||||||
|
const {
|
||||||
|
maxVelocity = 500,
|
||||||
|
squareCornerVelocity = 5,
|
||||||
|
defaultAccel = 3000
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const n = store.count
|
||||||
|
const positions = store.positions
|
||||||
|
const feed = store.feed
|
||||||
|
const accelField = store.accel
|
||||||
|
const type = store.type
|
||||||
|
|
||||||
|
const distance = new Float32Array(n)
|
||||||
|
const deltaV2 = new Float32Array(n)
|
||||||
|
const maxStartV2 = new Float32Array(n)
|
||||||
|
const maxCruiseV2 = new Float32Array(n)
|
||||||
|
const startV2 = new Float32Array(n)
|
||||||
|
const actualSpeed = new Float32Array(n)
|
||||||
|
const segmentTime = new Float32Array(n)
|
||||||
|
|
||||||
|
const scv2 = squareCornerVelocity * squareCornerVelocity
|
||||||
|
|
||||||
|
let prevValid = false
|
||||||
|
let prevDx = 0
|
||||||
|
let prevDy = 0
|
||||||
|
let prevDz = 0
|
||||||
|
let prevAccel = defaultAccel
|
||||||
|
let prevJunctionDeviation = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const p = i * 3
|
||||||
|
const dx = positions[p + 3] - positions[p]
|
||||||
|
const dy = positions[p + 4] - positions[p + 1]
|
||||||
|
const dz = positions[p + 5] - positions[p + 2]
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
distance[i] = d
|
||||||
|
|
||||||
|
if (type[i] === FEATURE.NONE || d <= 0) {
|
||||||
|
// Position jump: no motion, acts as a full stop.
|
||||||
|
deltaV2[i] = 0
|
||||||
|
maxStartV2[i] = 0
|
||||||
|
maxCruiseV2[i] = 0
|
||||||
|
prevValid = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let accel = accelField[i]
|
||||||
|
if (!(accel > 0)) accel = defaultAccel
|
||||||
|
let vReq = feed[i]
|
||||||
|
if (!(vReq > 0)) vReq = maxVelocity
|
||||||
|
if (vReq > maxVelocity) vReq = maxVelocity
|
||||||
|
const cruiseV2 = vReq * vReq
|
||||||
|
maxCruiseV2[i] = cruiseV2
|
||||||
|
deltaV2[i] = 2 * d * accel
|
||||||
|
const junctionDeviation = (scv2 * SQRT2_MINUS_1) / accel
|
||||||
|
|
||||||
|
const ux = dx / d
|
||||||
|
const uy = dy / d
|
||||||
|
const uz = dz / d
|
||||||
|
|
||||||
|
if (!prevValid) {
|
||||||
|
maxStartV2[i] = 0
|
||||||
|
} else {
|
||||||
|
let cosTheta = -(ux * prevDx + uy * prevDy + uz * prevDz)
|
||||||
|
if (cosTheta > 0.999999) {
|
||||||
|
maxStartV2[i] = 0
|
||||||
|
} else {
|
||||||
|
if (cosTheta < -0.999999) cosTheta = -0.999999
|
||||||
|
const sinThetaD2 = Math.sqrt(0.5 * (1 - cosTheta))
|
||||||
|
const rJd = sinThetaD2 / (1 - sinThetaD2)
|
||||||
|
let limit = rJd * junctionDeviation * accel
|
||||||
|
const prevLimit = rJd * prevJunctionDeviation * prevAccel
|
||||||
|
if (prevLimit < limit) limit = prevLimit
|
||||||
|
if (cruiseV2 < limit) limit = cruiseV2
|
||||||
|
const prevCruise = maxCruiseV2[i - 1]
|
||||||
|
if (prevCruise < limit) limit = prevCruise
|
||||||
|
const reachable = maxStartV2[i - 1] + deltaV2[i - 1]
|
||||||
|
if (reachable < limit) limit = reachable
|
||||||
|
maxStartV2[i] = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevValid = true
|
||||||
|
prevDx = ux
|
||||||
|
prevDy = uy
|
||||||
|
prevDz = uz
|
||||||
|
prevAccel = accel
|
||||||
|
prevJunctionDeviation = junctionDeviation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward pass: nothing may end faster than it can decelerate into the
|
||||||
|
// next junction (the file ends at rest).
|
||||||
|
let nextStartV2 = 0
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
const reachable = nextStartV2 + deltaV2[i]
|
||||||
|
const v2 = maxStartV2[i] < reachable ? maxStartV2[i] : reachable
|
||||||
|
startV2[i] = v2
|
||||||
|
nextStartV2 = v2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward pass and trapezoid generation.
|
||||||
|
let currentStartV2 = 0
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const d = distance[i]
|
||||||
|
if (type[i] === FEATURE.NONE || d <= 0) {
|
||||||
|
currentStartV2 = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let sV2 = startV2[i] < currentStartV2 ? startV2[i] : currentStartV2
|
||||||
|
const nextS = i + 1 < n ? startV2[i + 1] : 0
|
||||||
|
const reachableEnd = sV2 + deltaV2[i]
|
||||||
|
let eV2 = nextS < reachableEnd ? nextS : reachableEnd
|
||||||
|
if (eV2 < 0) eV2 = 0
|
||||||
|
let cV2 = (sV2 + eV2 + deltaV2[i]) * 0.5
|
||||||
|
if (cV2 > maxCruiseV2[i]) cV2 = maxCruiseV2[i]
|
||||||
|
if (cV2 < sV2) cV2 = sV2
|
||||||
|
if (cV2 < eV2) cV2 = eV2
|
||||||
|
|
||||||
|
let accel = accelField[i]
|
||||||
|
if (!(accel > 0)) accel = defaultAccel
|
||||||
|
const sV = Math.sqrt(sV2)
|
||||||
|
const eV = Math.sqrt(eV2)
|
||||||
|
const cV = Math.sqrt(cV2)
|
||||||
|
const halfInvAccel = 0.5 / accel
|
||||||
|
const accelD = (cV2 - sV2) * halfInvAccel
|
||||||
|
const decelD = (cV2 - eV2) * halfInvAccel
|
||||||
|
let cruiseD = d - accelD - decelD
|
||||||
|
if (cruiseD < 0) cruiseD = 0
|
||||||
|
const t =
|
||||||
|
(cV - sV) / accel + (cV > 0 ? cruiseD / cV : 0) + (cV - eV) / accel
|
||||||
|
|
||||||
|
actualSpeed[i] = cV
|
||||||
|
segmentTime[i] = t
|
||||||
|
currentStartV2 = eV2
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actualSpeed, segmentTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sums segment times per layer and expands them back to one value per
|
||||||
|
// segment so the shader can colour by layer time with a plain attribute.
|
||||||
|
export function computeLayerTimes(store, segmentTime, layers) {
|
||||||
|
const n = store.count
|
||||||
|
const layerTime = new Float32Array(n)
|
||||||
|
const perLayer = new Float32Array(layers.length)
|
||||||
|
let total = 0
|
||||||
|
for (let l = 0; l < layers.length; l++) {
|
||||||
|
const { start, end } = layers[l]
|
||||||
|
let sum = 0
|
||||||
|
for (let i = start; i < end && i < n; i++) sum += segmentTime[i]
|
||||||
|
perLayer[l] = sum
|
||||||
|
total += sum
|
||||||
|
for (let i = start; i < end && i < n; i++) layerTime[i] = sum
|
||||||
|
}
|
||||||
|
return { layerTime, perLayer, total }
|
||||||
|
}
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
// Structure-of-arrays storage for parsed G-code segments.
|
||||||
|
//
|
||||||
|
// Segment i runs from positions[i] to positions[i + 1], so the path is a
|
||||||
|
// single shared polyline and every other field is one entry per segment. The
|
||||||
|
// same class is used by the parser worker (filling it) and the main thread
|
||||||
|
// (receiving transferred chunks and feeding GPU buffers).
|
||||||
|
|
||||||
|
export const SEGMENT_FIELDS = [
|
||||||
|
['type', Uint8Array],
|
||||||
|
['tool', Uint8Array],
|
||||||
|
['fan', Uint8Array],
|
||||||
|
['layer', Uint16Array],
|
||||||
|
['temp', Uint16Array],
|
||||||
|
['width', Float32Array],
|
||||||
|
['height', Float32Array],
|
||||||
|
['feed', Float32Array],
|
||||||
|
['accel', Float32Array],
|
||||||
|
['offset', Uint32Array]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Filled after the planner pass, not part of streamed chunks.
|
||||||
|
export const PLANNER_FIELDS = [
|
||||||
|
['actualSpeed', Float32Array],
|
||||||
|
['layerTime', Float32Array]
|
||||||
|
]
|
||||||
|
|
||||||
|
const ALL_FIELDS = [...SEGMENT_FIELDS, ...PLANNER_FIELDS]
|
||||||
|
|
||||||
|
export default class SegmentStore {
|
||||||
|
constructor(capacity = 1024) {
|
||||||
|
this.count = 0
|
||||||
|
this.capacity = 0
|
||||||
|
// Bumped whenever the backing arrays are reallocated so GPU-side
|
||||||
|
// consumers know to rebuild their attribute bindings.
|
||||||
|
this.generation = 0
|
||||||
|
this.positions = null
|
||||||
|
this.plannerReady = false
|
||||||
|
this.allocate(Math.max(16, capacity | 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
allocate(capacity) {
|
||||||
|
const oldCount = this.count
|
||||||
|
const oldPositions = this.positions
|
||||||
|
const nextPositions = new Float32Array((capacity + 1) * 3)
|
||||||
|
if (oldPositions) {
|
||||||
|
nextPositions.set(oldPositions.subarray(0, (oldCount + 1) * 3))
|
||||||
|
}
|
||||||
|
this.positions = nextPositions
|
||||||
|
for (const [name, Type] of ALL_FIELDS) {
|
||||||
|
const next = new Type(capacity)
|
||||||
|
if (this[name]) next.set(this[name].subarray(0, oldCount))
|
||||||
|
this[name] = next
|
||||||
|
}
|
||||||
|
this.capacity = capacity
|
||||||
|
this.generation++
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCapacity(required) {
|
||||||
|
if (required <= this.capacity) return
|
||||||
|
let next = this.capacity
|
||||||
|
while (next < required) next = Math.ceil(next * 1.5) + 1024
|
||||||
|
this.allocate(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker side: append one segment. The start point is written here and the
|
||||||
|
// end point is whatever the next push (or setEnd) writes.
|
||||||
|
push(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
type,
|
||||||
|
tool,
|
||||||
|
fan,
|
||||||
|
layer,
|
||||||
|
temp,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
feed,
|
||||||
|
accel,
|
||||||
|
offset
|
||||||
|
) {
|
||||||
|
const i = this.count
|
||||||
|
if (i + 1 > this.capacity) this.ensureCapacity(i + 1)
|
||||||
|
const p = i * 3
|
||||||
|
this.positions[p] = x
|
||||||
|
this.positions[p + 1] = y
|
||||||
|
this.positions[p + 2] = z
|
||||||
|
this.type[i] = type
|
||||||
|
this.tool[i] = tool
|
||||||
|
this.fan[i] = fan
|
||||||
|
this.layer[i] = layer > 65535 ? 65535 : layer
|
||||||
|
this.temp[i] = temp < 0 ? 0 : temp > 65535 ? 65535 : temp
|
||||||
|
this.width[i] = width
|
||||||
|
this.height[i] = height
|
||||||
|
this.feed[i] = feed
|
||||||
|
this.accel[i] = accel
|
||||||
|
this.offset[i] = offset
|
||||||
|
this.count = i + 1
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnd(x, y, z) {
|
||||||
|
const p = this.count * 3
|
||||||
|
this.positions[p] = x
|
||||||
|
this.positions[p + 1] = y
|
||||||
|
this.positions[p + 2] = z
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker side: copy segments [from, to) into transferable buffers.
|
||||||
|
sliceChunk(from, to) {
|
||||||
|
const count = to - from
|
||||||
|
const chunk = { start: from, count }
|
||||||
|
const transfer = []
|
||||||
|
// Start points for every segment in the range plus the end of the last
|
||||||
|
// one, which is just the next start (or the final end point).
|
||||||
|
const positions = this.positions.slice(from * 3, (to + 1) * 3)
|
||||||
|
chunk.positions = positions
|
||||||
|
transfer.push(positions.buffer)
|
||||||
|
for (const [name] of SEGMENT_FIELDS) {
|
||||||
|
const copy = this[name].slice(from, to)
|
||||||
|
chunk[name] = copy
|
||||||
|
transfer.push(copy.buffer)
|
||||||
|
}
|
||||||
|
return { chunk, transfer }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main thread side: append a chunk produced by sliceChunk.
|
||||||
|
appendChunk(chunk) {
|
||||||
|
const { start, count } = chunk
|
||||||
|
if (start !== this.count) {
|
||||||
|
throw new Error(
|
||||||
|
`SegmentStore chunk out of order (expected ${this.count}, got ${start})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.ensureCapacity(start + count)
|
||||||
|
this.positions.set(chunk.positions, start * 3)
|
||||||
|
for (const [name] of SEGMENT_FIELDS) {
|
||||||
|
this[name].set(chunk[name], start)
|
||||||
|
}
|
||||||
|
this.count = start + count
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPlanner({ actualSpeed, layerTime }) {
|
||||||
|
if (actualSpeed) this.actualSpeed.set(actualSpeed.subarray(0, this.count))
|
||||||
|
if (layerTime) this.layerTime.set(layerTime.subarray(0, this.count))
|
||||||
|
this.plannerReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
getStart(i, out) {
|
||||||
|
const p = i * 3
|
||||||
|
out[0] = this.positions[p]
|
||||||
|
out[1] = this.positions[p + 1]
|
||||||
|
out[2] = this.positions[p + 2]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnd(i, out) {
|
||||||
|
return this.getStart(i + 1, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index of the last segment whose source line starts at or before the given
|
||||||
|
// byte offset, or -1 when the offset precedes every segment.
|
||||||
|
segmentAtByteOffset(byteOffset) {
|
||||||
|
const offsets = this.offset
|
||||||
|
let lo = 0
|
||||||
|
let hi = this.count - 1
|
||||||
|
let result = -1
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1
|
||||||
|
if (offsets[mid] <= byteOffset) {
|
||||||
|
result = mid
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/components/Components/GCodePreview/GCodeViewer/tracking.js
Normal file
102
src/components/Components/GCodePreview/GCodeViewer/tracking.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// Maps live printer state onto a segment index for progress rendering.
|
||||||
|
|
||||||
|
const REFINE_WINDOW = 400
|
||||||
|
const MAX_SNAP_DISTANCE = 3 // mm
|
||||||
|
|
||||||
|
// Klipper reports virtual_sdcard.file_position in bytes. Our offsets are
|
||||||
|
// bytes into the same file, so this is exact unless the printer's copy has
|
||||||
|
// a different size, in which case we fall back to proportional scaling.
|
||||||
|
export function fileOffsetForPrinter(
|
||||||
|
filePosition,
|
||||||
|
printerFileSize,
|
||||||
|
byteLength
|
||||||
|
) {
|
||||||
|
if (!(filePosition >= 0)) return 0
|
||||||
|
if (
|
||||||
|
printerFileSize > 0 &&
|
||||||
|
byteLength > 0 &&
|
||||||
|
Math.abs(printerFileSize - byteLength) > 64
|
||||||
|
) {
|
||||||
|
return (filePosition / printerFileSize) * byteLength
|
||||||
|
}
|
||||||
|
return filePosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klipper's lookahead means file_position runs ahead of physical motion.
|
||||||
|
// Scan back from the file-derived segment for the segment closest to the
|
||||||
|
// live toolhead position and return where along it the head is.
|
||||||
|
export function refineSegmentByPosition(store, segment, position) {
|
||||||
|
const count = store.count
|
||||||
|
if (count === 0) return { segment: -1, t: 1 }
|
||||||
|
let seg = Math.min(Math.max(segment, 0), count - 1)
|
||||||
|
if (!position || position.length < 3) return { segment: seg, t: 1 }
|
||||||
|
const [px, py, pz] = position
|
||||||
|
if (![px, py, pz].every(Number.isFinite)) return { segment: seg, t: 1 }
|
||||||
|
|
||||||
|
const positions = store.positions
|
||||||
|
const from = Math.max(0, seg - REFINE_WINDOW)
|
||||||
|
let bestSeg = -1
|
||||||
|
let bestT = 1
|
||||||
|
let bestD2 = Infinity
|
||||||
|
for (let i = seg; i >= from; i--) {
|
||||||
|
const p = i * 3
|
||||||
|
const ax = positions[p]
|
||||||
|
const ay = positions[p + 1]
|
||||||
|
const az = positions[p + 2]
|
||||||
|
const bx = positions[p + 3]
|
||||||
|
const by = positions[p + 4]
|
||||||
|
const bz = positions[p + 5]
|
||||||
|
const dx = bx - ax
|
||||||
|
const dy = by - ay
|
||||||
|
const dz = bz - az
|
||||||
|
const len2 = dx * dx + dy * dy + dz * dz
|
||||||
|
let t = 0
|
||||||
|
if (len2 > 1e-12) {
|
||||||
|
t = ((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / len2
|
||||||
|
if (t < 0) t = 0
|
||||||
|
else if (t > 1) t = 1
|
||||||
|
}
|
||||||
|
const cx = ax + dx * t - px
|
||||||
|
const cy = ay + dy * t - py
|
||||||
|
const cz = az + dz * t - pz
|
||||||
|
const d2 = cx * cx + cy * cy + cz * cz
|
||||||
|
if (d2 < bestD2) {
|
||||||
|
bestD2 = d2
|
||||||
|
bestSeg = i
|
||||||
|
bestT = t
|
||||||
|
if (d2 < 1e-6) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestSeg < 0 || bestD2 > MAX_SNAP_DISTANCE * MAX_SNAP_DISTANCE) {
|
||||||
|
return { segment: seg, t: 1 }
|
||||||
|
}
|
||||||
|
return { segment: bestSeg, t: bestT }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointOnSegment(store, segment, t, out = [0, 0, 0]) {
|
||||||
|
const count = store.count
|
||||||
|
if (count === 0) return null
|
||||||
|
const i = Math.min(Math.max(segment, 0), count - 1)
|
||||||
|
const p = i * 3
|
||||||
|
const positions = store.positions
|
||||||
|
out[0] = positions[p] + (positions[p + 3] - positions[p]) * t
|
||||||
|
out[1] = positions[p + 1] + (positions[p + 4] - positions[p + 1]) * t
|
||||||
|
out[2] = positions[p + 2] + (positions[p + 5] - positions[p + 2]) * t
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layerIndexForSegment(layers, segment) {
|
||||||
|
let lo = 0
|
||||||
|
let hi = layers.length - 1
|
||||||
|
let result = -1
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1
|
||||||
|
if (layers[mid].start <= segment) {
|
||||||
|
result = mid
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import SegmentStore from './segmentStore.js'
|
||||||
|
|
||||||
|
const IDLE = {
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
planning: false,
|
||||||
|
model: null,
|
||||||
|
revision: 0,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModel(size) {
|
||||||
|
const capacity = size > 0 ? Math.ceil(size / 28) : 65536
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
store: new SegmentStore(Math.min(capacity, 50_000_000)),
|
||||||
|
layers: [],
|
||||||
|
metadata: null,
|
||||||
|
stats: null,
|
||||||
|
bounds: null,
|
||||||
|
tools: [],
|
||||||
|
typeCounts: null,
|
||||||
|
totalTime: 0,
|
||||||
|
byteLength: 0,
|
||||||
|
lineCount: 0,
|
||||||
|
hasTypeComments: false,
|
||||||
|
done: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams a G-code file through the parser worker. `model` is a stable
|
||||||
|
// object per load that is mutated as chunks arrive; `revision` increments
|
||||||
|
// on every change so consumers can resync without deep comparisons.
|
||||||
|
export default function useGCodeLoader(src) {
|
||||||
|
const [state, setState] = useState(IDLE)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) {
|
||||||
|
setState(IDLE)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const worker = new Worker(new URL('./parser.worker.js', import.meta.url), {
|
||||||
|
type: 'module'
|
||||||
|
})
|
||||||
|
let model = createModel(0)
|
||||||
|
setState({
|
||||||
|
status: 'loading',
|
||||||
|
progress: 0,
|
||||||
|
planning: false,
|
||||||
|
model,
|
||||||
|
revision: 1,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const message = event.data
|
||||||
|
switch (message.type) {
|
||||||
|
case 'start': {
|
||||||
|
if (message.size > 0 && model.store.count === 0) {
|
||||||
|
model = createModel(message.size)
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model,
|
||||||
|
revision: prev.revision + 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'progress': {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
progress: message.progress,
|
||||||
|
planning: Boolean(message.planning)
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'chunk': {
|
||||||
|
model.store.appendChunk(message.chunk)
|
||||||
|
setState((prev) => ({ ...prev, revision: prev.revision + 1 }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'done': {
|
||||||
|
const { store } = model
|
||||||
|
store.setEnd(message.end[0], message.end[1], message.end[2])
|
||||||
|
store.applyPlanner(message)
|
||||||
|
model.layers = message.layers
|
||||||
|
model.metadata = message.metadata
|
||||||
|
model.stats = message.stats
|
||||||
|
model.bounds = message.bounds
|
||||||
|
model.tools = message.tools
|
||||||
|
model.typeCounts = message.typeCounts
|
||||||
|
model.totalTime = message.totalTime
|
||||||
|
model.byteLength = message.byteLength
|
||||||
|
model.lineCount = message.lineCount
|
||||||
|
model.hasTypeComments = message.hasTypeComments
|
||||||
|
model.done = true
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'ready',
|
||||||
|
progress: 1,
|
||||||
|
planning: false,
|
||||||
|
revision: prev.revision + 1
|
||||||
|
}))
|
||||||
|
worker.terminate()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
planning: false,
|
||||||
|
error: message.message || 'Failed to parse G-code'
|
||||||
|
}))
|
||||||
|
worker.terminate()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worker.onerror = (event) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: event?.message || 'G-code worker failed'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.postMessage({ type: 'load', src })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
worker.terminate()
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ import {
|
|||||||
memo,
|
memo,
|
||||||
useRef
|
useRef
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import GCodePreview from './GCodePreview'
|
import GCodePreview from './GCodeViewer'
|
||||||
import ThreeDPreview from './ThreeDPreview'
|
import ThreeDPreview from './ThreeDPreview'
|
||||||
import PDFPreview from './PDFPreview'
|
import PDFPreview from './PDFPreview'
|
||||||
import ProgressDisplay from './ProgressDisplay'
|
import ProgressDisplay from './ProgressDisplay'
|
||||||
@ -111,13 +111,7 @@ const FilePreview = ({ file, style = {} }) => {
|
|||||||
|
|
||||||
if (isGcode && fileObjectUrl) {
|
if (isGcode && fileObjectUrl) {
|
||||||
return (
|
return (
|
||||||
<GCodePreview
|
<GCodePreview src={fileObjectUrl} style={style} />
|
||||||
src={fileObjectUrl}
|
|
||||||
startLayer={0}
|
|
||||||
endLayer={undefined}
|
|
||||||
lineWidth={1}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,526 +0,0 @@
|
|||||||
import { GCodePreview } from 'gcode-preview'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { Button, Card, Flex, Select, Slider } from 'antd'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
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 toCssColor(color, fallback) {
|
|
||||||
try {
|
|
||||||
return `#${new THREE.Color(color || fallback).getHexString()}`
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(sceneManager) {
|
|
||||||
const distance = sceneManager.camera.position.distanceTo(
|
|
||||||
sceneManager.controls.target
|
|
||||||
)
|
|
||||||
return Number.isFinite(distance) && distance > 0 ? distance : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCameraScale(sceneManager, baseDistance) {
|
|
||||||
const distance = getCameraDistance(sceneManager)
|
|
||||||
return distance == null ? null : clampPreviewScale(baseDistance / distance)
|
|
||||||
}
|
|
||||||
|
|
||||||
function layerEndForGcodePosition(preview, gcodePosition) {
|
|
||||||
const layers = preview?.job?.layers
|
|
||||||
const count = layers?.length || preview?.countLayers || 0
|
|
||||||
if (!count || !gcodePosition) return undefined
|
|
||||||
if (gcodePosition.isActive === false) return undefined
|
|
||||||
|
|
||||||
const currentLayer = gcodePosition.currentLayer
|
|
||||||
if (typeof currentLayer === 'number' && currentLayer > 0) {
|
|
||||||
return Math.min(Math.round(currentLayer), count)
|
|
||||||
}
|
|
||||||
|
|
||||||
const z = gcodePosition.position?.[2]
|
|
||||||
if (typeof z !== 'number' || !Number.isFinite(z) || !layers?.length) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = -1
|
|
||||||
for (let i = 0; i < layers.length; i++) {
|
|
||||||
if (layers[i].z <= z + 0.05) index = i
|
|
||||||
else break
|
|
||||||
}
|
|
||||||
return index < 0 ? 1 : index + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCameraScale(sceneManager, baseDistance, scale) {
|
|
||||||
const { camera, controls } = sceneManager
|
|
||||||
const distance = getCameraDistance(sceneManager)
|
|
||||||
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) {
|
|
||||||
const {
|
|
||||||
src,
|
|
||||||
topLayerColor = '',
|
|
||||||
lastSegmentColor = '',
|
|
||||||
startLayer,
|
|
||||||
endLayer,
|
|
||||||
lineWidth,
|
|
||||||
style = {},
|
|
||||||
showFollowCurrentSubJob = false,
|
|
||||||
gcodePosition = null
|
|
||||||
} = props
|
|
||||||
const { isDarkMode, themeConfig } = useThemeContext()
|
|
||||||
const { colorPrimary, colorWarning, colorPink } = themeConfig.token
|
|
||||||
const canvasRef = useRef(null)
|
|
||||||
const previewPaneRef = 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 [renderMode, setRenderMode] = useState('lines')
|
|
||||||
const [travelVisibility, setTravelVisibility] = useState('hide')
|
|
||||||
const [followSelection, setFollowSelection] = useState('follow')
|
|
||||||
const renderModeRef = useRef(renderMode)
|
|
||||||
const travelVisibilityRef = useRef(travelVisibility)
|
|
||||||
const followRef = useRef({
|
|
||||||
enabled: false,
|
|
||||||
gcodePosition: null,
|
|
||||||
endLayer
|
|
||||||
})
|
|
||||||
renderModeRef.current = renderMode
|
|
||||||
travelVisibilityRef.current = travelVisibility
|
|
||||||
followRef.current = {
|
|
||||||
enabled: showFollowCurrentSubJob && followSelection === 'follow',
|
|
||||||
gcodePosition,
|
|
||||||
endLayer
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyFollowEndLayer = useCallback((instance) => {
|
|
||||||
if (!instance || previewRef.current !== instance) return
|
|
||||||
const sceneManager = instance.sceneManager
|
|
||||||
if (!sceneManager) return
|
|
||||||
const follow = followRef.current
|
|
||||||
const next = follow.enabled
|
|
||||||
? layerEndForGcodePosition(instance, follow.gcodePosition)
|
|
||||||
: follow.endLayer
|
|
||||||
if (sceneManager.endLayer === next) return
|
|
||||||
sceneManager.endLayer = next
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resizePreview = useCallback(() => {
|
|
||||||
previewRef.current?.sceneManager?.resize()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) return
|
|
||||||
|
|
||||||
const themeColors = getPreviewThemeColors({
|
|
||||||
isDarkMode,
|
|
||||||
token: { colorPrimary, colorWarning, colorPink },
|
|
||||||
topLayerColor,
|
|
||||||
lastSegmentColor
|
|
||||||
})
|
|
||||||
|
|
||||||
previewRef.current?.dispose()
|
|
||||||
const instance = new GCodePreview({
|
|
||||||
canvas: canvasRef.current,
|
|
||||||
startLayer,
|
|
||||||
endLayer,
|
|
||||||
lineWidth,
|
|
||||||
backgroundColor: toCssColor(themeColors.backgroundColor, '#ffffff'),
|
|
||||||
extrusionColor: toCssColor(themeColors.extrusionColor, '#0091ff'),
|
|
||||||
travelColor: toCssColor(themeColors.travelColor, '#8c8c8c'),
|
|
||||||
topLayerColor: toCssColor(themeColors.topLayerColor, '#ff9230'),
|
|
||||||
lastSegmentColor: toCssColor(themeColors.lastSegmentColor, '#ff69b4'),
|
|
||||||
buildVolume: { x: 250, y: 220, z: 150 },
|
|
||||||
initialCameraPosition: [0, 400, 450],
|
|
||||||
droppable: false,
|
|
||||||
renderTubes: renderModeRef.current === 'pipes',
|
|
||||||
renderTravel: travelVisibilityRef.current === 'show'
|
|
||||||
})
|
|
||||||
previewRef.current = instance
|
|
||||||
|
|
||||||
const { camera, controls } = instance.sceneManager
|
|
||||||
baseDistanceRef.current = getCameraDistance(instance.sceneManager) || 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.sceneManager,
|
|
||||||
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.onJobUpdated = () => {
|
|
||||||
applyFollowEndLayer(instance)
|
|
||||||
}
|
|
||||||
instance.sceneManager.resize()
|
|
||||||
setPreview(instance)
|
|
||||||
|
|
||||||
const pane = previewPaneRef.current
|
|
||||||
const resizeObserver =
|
|
||||||
pane && typeof ResizeObserver === 'function'
|
|
||||||
? new ResizeObserver(resizePreview)
|
|
||||||
: null
|
|
||||||
resizeObserver?.observe(pane)
|
|
||||||
window.addEventListener('resize', resizePreview)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
instance.onJobUpdated = undefined
|
|
||||||
resizeObserver?.disconnect()
|
|
||||||
window.removeEventListener('resize', resizePreview)
|
|
||||||
controls.removeEventListener('change', handleControlsChange)
|
|
||||||
controls.removeEventListener('start', handleControlsStart)
|
|
||||||
controls.removeEventListener('end', handleControlsEnd)
|
|
||||||
setPreview(null)
|
|
||||||
setIsDragging(false)
|
|
||||||
instance.clear()
|
|
||||||
instance.dispose()
|
|
||||||
if (previewRef.current === instance) {
|
|
||||||
previewRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
colorPink,
|
|
||||||
colorPrimary,
|
|
||||||
colorWarning,
|
|
||||||
endLayer,
|
|
||||||
isDarkMode,
|
|
||||||
lastSegmentColor,
|
|
||||||
lineWidth,
|
|
||||||
startLayer,
|
|
||||||
topLayerColor,
|
|
||||||
resizePreview,
|
|
||||||
applyFollowEndLayer
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preview) return
|
|
||||||
applyingScaleRef.current = true
|
|
||||||
applyCameraScale(
|
|
||||||
preview.sceneManager,
|
|
||||||
baseDistanceRef.current,
|
|
||||||
previewScale
|
|
||||||
)
|
|
||||||
applyingScaleRef.current = false
|
|
||||||
}, [preview, previewScale])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preview) return
|
|
||||||
const { sceneManager } = preview
|
|
||||||
const renderTubes = renderMode === 'pipes'
|
|
||||||
const renderTravel = travelVisibility === 'show'
|
|
||||||
if (
|
|
||||||
sceneManager.renderTubes === renderTubes &&
|
|
||||||
sceneManager.renderTravel === renderTravel
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sceneManager.renderTubes = renderTubes
|
|
||||||
sceneManager.renderTravel = renderTravel
|
|
||||||
sceneManager.render()
|
|
||||||
}, [preview, renderMode, travelVisibility])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preview) return
|
|
||||||
const { controls } = preview.sceneManager
|
|
||||||
controls.mouseButtons = {
|
|
||||||
LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE,
|
|
||||||
MIDDLE: THREE.MOUSE.DOLLY,
|
|
||||||
RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN
|
|
||||||
}
|
|
||||||
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?.sceneManager.controls.reset()
|
|
||||||
setPreviewScale(1)
|
|
||||||
}, [preview])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
const loadFromSrc = async () => {
|
|
||||||
const instance = previewRef.current
|
|
||||||
if (!src || !instance) return
|
|
||||||
try {
|
|
||||||
const response = await fetch(src, { signal: abortController.signal })
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`Failed to load G-code (${response.status})`)
|
|
||||||
}
|
|
||||||
if (abortController.signal.aborted || previewRef.current !== instance) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (instance.countLayers > 0) {
|
|
||||||
instance.clear()
|
|
||||||
instance.sceneManager.startLayer = startLayer
|
|
||||||
instance.sceneManager.endLayer = endLayer
|
|
||||||
}
|
|
||||||
const stream = response.body.pipeThrough(new TextDecoderStream())
|
|
||||||
await instance.processGCodeStream(stream)
|
|
||||||
if (abortController.signal.aborted || previewRef.current !== instance) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof startLayer === 'number') {
|
|
||||||
instance.sceneManager.startLayer = startLayer
|
|
||||||
}
|
|
||||||
if (typeof endLayer === 'number') {
|
|
||||||
instance.sceneManager.endLayer = endLayer
|
|
||||||
}
|
|
||||||
applyFollowEndLayer(instance)
|
|
||||||
} catch (e) {
|
|
||||||
if (abortController.signal.aborted) return
|
|
||||||
console.error('Failed to load G-code from src', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadFromSrc()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
abortController.abort()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
colorPink,
|
|
||||||
colorPrimary,
|
|
||||||
colorWarning,
|
|
||||||
endLayer,
|
|
||||||
isDarkMode,
|
|
||||||
lastSegmentColor,
|
|
||||||
lineWidth,
|
|
||||||
src,
|
|
||||||
startLayer,
|
|
||||||
topLayerColor,
|
|
||||||
applyFollowEndLayer
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyFollowEndLayer(previewRef.current)
|
|
||||||
}, [
|
|
||||||
applyFollowEndLayer,
|
|
||||||
followSelection,
|
|
||||||
gcodePosition,
|
|
||||||
preview,
|
|
||||||
showFollowCurrentSubJob
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
vertical
|
|
||||||
gap={'middle'}
|
|
||||||
style={{ width: '100%', height: '100%', minHeight: 0, ...style }}
|
|
||||||
>
|
|
||||||
<Flex gap={'small'} wrap='wrap' align='center'>
|
|
||||||
<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))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={renderMode}
|
|
||||||
options={[
|
|
||||||
{ value: 'lines', label: 'Lines' },
|
|
||||||
{ value: 'pipes', label: 'Pipes' }
|
|
||||||
]}
|
|
||||||
style={{ minWidth: 77 }}
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
onChange={setRenderMode}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={travelVisibility}
|
|
||||||
options={[
|
|
||||||
{ value: 'show', label: 'Show Travel' },
|
|
||||||
{ value: 'hide', label: 'Hide Travel' }
|
|
||||||
]}
|
|
||||||
style={{ minWidth: 118 }}
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
onChange={setTravelVisibility}
|
|
||||||
/>
|
|
||||||
{showFollowCurrentSubJob ? (
|
|
||||||
<Select
|
|
||||||
value={followSelection}
|
|
||||||
options={[
|
|
||||||
{ value: 'follow', label: 'Follow current sub job' },
|
|
||||||
{ value: 'file', label: 'Entire file' }
|
|
||||||
]}
|
|
||||||
style={{ minWidth: 190 }}
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
onChange={setFollowSelection}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</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 = {
|
|
||||||
src: PropTypes.string,
|
|
||||||
topLayerColor: PropTypes.string,
|
|
||||||
lastSegmentColor: PropTypes.string,
|
|
||||||
startLayer: PropTypes.number,
|
|
||||||
endLayer: PropTypes.number,
|
|
||||||
lineWidth: PropTypes.number,
|
|
||||||
style: PropTypes.object,
|
|
||||||
showFollowCurrentSubJob: PropTypes.bool,
|
|
||||||
gcodePosition: PropTypes.shape({
|
|
||||||
position: PropTypes.arrayOf(PropTypes.number),
|
|
||||||
filePosition: PropTypes.number,
|
|
||||||
fileSize: PropTypes.number,
|
|
||||||
progress: PropTypes.number,
|
|
||||||
isActive: PropTypes.bool,
|
|
||||||
currentLayer: PropTypes.number,
|
|
||||||
totalLayer: PropTypes.number
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GCodePreviewUI
|
|
||||||
1
src/components/Dashboard/common/GCodeViewer.jsx
Normal file
1
src/components/Dashboard/common/GCodeViewer.jsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '../../Components/GCodePreview/GCodePreview.jsx'
|
||||||
@ -5,7 +5,7 @@ import PropTypes from 'prop-types'
|
|||||||
import merge from 'lodash/merge'
|
import merge from 'lodash/merge'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import GCodePreview from './GCodePreview'
|
import GCodePreview from './GCodeViewer'
|
||||||
import MissingPlaceholder from './MissingPlaceholder'
|
import MissingPlaceholder from './MissingPlaceholder'
|
||||||
import ProgressDisplay from './ProgressDisplay'
|
import ProgressDisplay from './ProgressDisplay'
|
||||||
|
|
||||||
@ -28,6 +28,9 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
|
|||||||
const objectUrlRef = useRef(null)
|
const objectUrlRef = useRef(null)
|
||||||
|
|
||||||
const [gcodePosition, setGcodePosition] = useState(emptyGcodePosition)
|
const [gcodePosition, setGcodePosition] = useState(emptyGcodePosition)
|
||||||
|
// Physical toolhead position from motion_report.live_position; trails the
|
||||||
|
// commanded position and is what the hot end model follows.
|
||||||
|
const [livePosition, setLivePosition] = useState(null)
|
||||||
const [src, setSrc] = useState(null)
|
const [src, setSrc] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@ -36,21 +39,42 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offline) {
|
if (offline) {
|
||||||
setGcodePosition(emptyGcodePosition)
|
setGcodePosition(emptyGcodePosition)
|
||||||
|
setLivePosition(null)
|
||||||
}
|
}
|
||||||
}, [offline])
|
}, [offline])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && connected == true && offline == false) {
|
if (id && connected == true && offline == false) {
|
||||||
|
const printerId = id?.toLowerCase()
|
||||||
const gcodePositionUnsubscribe = subscribeToObjectEvent(
|
const gcodePositionUnsubscribe = subscribeToObjectEvent(
|
||||||
id?.toLowerCase(),
|
printerId,
|
||||||
'printer',
|
'printer',
|
||||||
'gcodePosition',
|
'gcodePosition',
|
||||||
(event) => {
|
(event) => {
|
||||||
setGcodePosition((prev) => merge({}, prev, event.data))
|
setGcodePosition((prev) => merge({}, prev, event.data))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const motionUnsubscribe = subscribeToObjectEvent(
|
||||||
|
printerId,
|
||||||
|
'printer',
|
||||||
|
'motion',
|
||||||
|
(event) => {
|
||||||
|
const live = event.data?.livePosition
|
||||||
|
if (Array.isArray(live) && live.length >= 3) {
|
||||||
|
setLivePosition((prev) =>
|
||||||
|
prev &&
|
||||||
|
prev[0] === live[0] &&
|
||||||
|
prev[1] === live[1] &&
|
||||||
|
prev[2] === live[2]
|
||||||
|
? prev
|
||||||
|
: [live[0], live[1], live[2]]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
return () => {
|
return () => {
|
||||||
if (gcodePositionUnsubscribe) gcodePositionUnsubscribe()
|
if (gcodePositionUnsubscribe) gcodePositionUnsubscribe()
|
||||||
|
if (motionUnsubscribe) motionUnsubscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id, connected, subscribeToObjectEvent, offline])
|
}, [id, connected, subscribeToObjectEvent, offline])
|
||||||
@ -183,11 +207,9 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
|
|||||||
) : (
|
) : (
|
||||||
<GCodePreview
|
<GCodePreview
|
||||||
src={src}
|
src={src}
|
||||||
startLayer={0}
|
|
||||||
endLayer={undefined}
|
|
||||||
lineWidth={1}
|
|
||||||
showFollowCurrentSubJob={true}
|
showFollowCurrentSubJob={true}
|
||||||
gcodePosition={gcodePosition}
|
gcodePosition={gcodePosition}
|
||||||
|
toolPosition={livePosition}
|
||||||
style={{ width: '100%', height: '60vh' }}
|
style={{ width: '100%', height: '60vh' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -144,11 +144,6 @@ export default defineConfig({
|
|||||||
return 'three'
|
return 'three'
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GCode Preview
|
|
||||||
if (id.includes('node_modules/gcode-preview')) {
|
|
||||||
return 'gcode-preview'
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Online 3D Viewer
|
// --- Online 3D Viewer
|
||||||
if (id.includes('node_modules/online-3d-viewer')) {
|
if (id.includes('node_modules/online-3d-viewer')) {
|
||||||
return 'online-3d-viewer'
|
return 'online-3d-viewer'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user