Tom Butcher b3e366e813
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Remove gcode-preview dependency and related code
- 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.
2026-11-08 18:08:12 +00:00

312 lines
9.2 KiB
JavaScript

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])
}
}
}
}