Files
onefinity-firmware/src/js/path-viewer.js
muehe 41d720c1d0 kiosk: pi-friendly compact mode + chromium 72 fallbacks
- Detect kiosk mode (localhost / ?kiosk=1) and add html.kiosk-mode
- Suppress 3D path-viewer in kiosk mode (Pi 3B too slow)
- Compact 1366x768 layout: 56px header, smaller jog grid, 4-col macros
  2-col status, 540px jog column
- Flex-gap fallbacks for Chromium 72 (header tabs, sys-btn, state-badge,
  ktab, sp-row, etc.) using "> * + *" margin-* rules
- Path-viewer: opaque WebGL canvas, ResizeObserver-gated render loop,
  no first-frame size flash
- Path-viewer renderer cleared properly on component teardown
- W axis row: W- | W+ | Probe XYZ | Probe Z (was W-|HomeW|W+|Probe)
- Running panel only for actual program execution (not jogging)
- Settings sectioned (Display+Units / Probing / G-code+Motion)
- Routed component now keep-alive across tab swaps
- FA4 -> FA6 webfonts
2026-05-01 11:05:39 +02:00

844 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const orbit = require("./orbit");
const cookie = require("./cookie")("bbctrl-");
const font = require("./helvetiker_regular.typeface.json");
module.exports = {
template: "#path-viewer-template",
props: [ "toolpath" ],
data: function() {
return {
enabled: false,
loading: false,
dirty: true,
snapView: cookie.get("snap-view", "isometric"),
small: cookie.get_bool("small-path-view", true),
surfaceMode: "cut",
showPath: cookie.get_bool("show-path", true),
showTool: cookie.get_bool("show-tool", true),
showBBox: cookie.get_bool("show-bbox", true),
showAxes: cookie.get_bool("show-axes", true),
showIntensity: cookie.get_bool("show-intensity", false)
};
},
computed: {
target: function() {
return this.$el.querySelector(".path-viewer-content");
},
webglAvailable: function() {
// Create canvas element. The canvas is not added to the
// document itself, so it is never displayed in the
// browser window.
const canvas = document.createElement("canvas");
// Get WebGLRenderingContext from canvas element.
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
// Report the result.
return gl && gl instanceof WebGLRenderingContext;
}
},
watch: {
toolpath: function() {
Vue.nextTick(this.update);
},
surfaceMode: function(mode) {
this.update_surface_mode(mode);
},
small: function(enable) {
cookie.set_bool("small-path-view", enable);
Vue.nextTick(this.update_view);
},
showPath: function(enable) {
cookie.set_bool("show-path", enable);
this.set_visible(this.pathView, enable);
},
showTool: function(enable) {
cookie.set_bool("show-tool", enable);
this.set_visible(this.toolView, enable);
},
showAxes: function(enable) {
cookie.set_bool("show-axes", enable);
this.set_visible(this.axesView, enable);
},
showIntensity: function(enable) {
cookie.set_bool("show-intensity", enable);
Vue.nextTick(this.update);
},
showBBox: function(enable) {
cookie.set_bool("show-bbox", enable);
this.set_visible(this.bboxView, enable);
this.set_visible(this.envelopeView, enable);
},
x: function() {
this.axis_changed();
},
y: function() {
this.axis_changed();
},
z: function() {
this.axis_changed();
}
},
ready: function() {
this.graphics();
Vue.nextTick(this.update);
},
beforeDestroy: function() {
if (this._sizeWatcher) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
}
},
methods: {
update: async function() {
if (!this.webglAvailable) {
return;
}
if (!this.state.selected) {
this.dirty = true;
this.scene = new THREE.Scene();
} else if (!this.toolpath.filename && !this.loading) {
this.loading = true;
this.dirty = true;
this.draw_loading();
}
if (!this.enabled || !this.toolpath.filename) {
return;
}
async function get(url) {
const response = await fetch(`${url}`, { cache: "no-cache" });
const arrayBuffer = await response.arrayBuffer();
return new Float32Array(arrayBuffer);
}
const [ positions, speeds ] = await Promise.all([
get(`/api/path/${this.toolpath.filename}/positions`),
get(`/api/path/${this.toolpath.filename}/speeds`)
]);
this.positions = positions;
this.speeds = speeds;
this.loading = false;
// Update scene
this.scene = new THREE.Scene();
this.draw(this.scene);
this.snap(this.snapView);
this.update_view();
},
update_surface_mode: function(mode) {
if (!this.enabled) {
return;
}
if (typeof this.surfaceMaterial != "undefined") {
this.surfaceMaterial.wireframe = mode == "wire";
this.surfaceMaterial.needsUpdate = true;
}
this.set_visible(this.surfaceMesh, mode == "cut" || mode == "wire");
this.set_visible(this.workpieceMesh, mode == "solid");
},
load_surface: function(surface) {
if (typeof surface == "undefined") {
this.vertices = undefined;
this.normals = undefined;
return;
}
this.vertices = surface.vertices;
// Expand normals
this.normals = [];
for (let i = 0; i < surface.normals.length / 3; i++) {
for (let j = 0; j < 3; j++) {
for (let k = 0; k < 3; k++) {
this.normals.push(surface.normals[i * 3 + k]);
}
}
}
},
set_visible: function(target, visible) {
if (typeof target != "undefined") {
target.visible = visible;
}
this.dirty = true;
},
get_dims: function() {
const computedStyle = window.getComputedStyle(this.target);
return {
width: parseInt(computedStyle.width),
height: parseInt(computedStyle.height)
};
},
update_view: function() {
if (!this.enabled) {
return;
}
const dims = this.get_dims();
// Skip layouts where the target has no measurable size.
// The render loop guard below will not draw frames until
// a real size has been observed at least once.
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
this.camera.aspect = dims.width / dims.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(dims.width, dims.height);
if (this.loading) {
this.controls.reset();
this.camera.position.copy(new THREE.Vector3(0, 0, 600));
this.camera.lookAt(new THREE.Vector3(0, 0, 0));
}
this.dirty = true;
},
update_tool: function(tool) {
if (!this.enabled) {
return;
}
if (typeof tool == "undefined") {
tool = this.toolView;
}
if (typeof tool == "undefined") {
return;
}
tool.position.x = this.x.pos;
tool.position.y = this.y.pos;
tool.position.z = this.z.pos;
},
update_envelope: function(envelope) {
if (!this.enabled || !this.axes.homed) {
return;
}
if (typeof envelope == "undefined") {
envelope = this.envelopeView;
}
if (typeof envelope == "undefined") {
return;
}
const min = new THREE.Vector3();
const max = new THREE.Vector3();
for (const axis of "xyz") {
min[axis] = this[axis].min - this[axis].off;
max[axis] = this[axis].max - this[axis].off;
}
const bounds = new THREE.Box3(min, max);
if (bounds.isEmpty()) {
envelope.geometry = this.create_empty_geom();
} else {
envelope.geometry = this.create_bbox_geom(bounds);
}
},
axis_changed: function() {
this.update_tool();
this.update_envelope();
this.dirty = true;
},
graphics: function() {
if (!this.webglAvailable) {
return;
}
try {
// Renderer. Use an opaque canvas with a clear color
// that matches the page-side gradient so the moment
// the canvas is appended (and before the first 3D
// frame is drawn) the user does not see a flash from
// the page background through transparency.
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0x222222, 1);
// Same color on the DOM element itself so the very
// first paint (before the WebGL context has cleared)
// is dark too.
this.renderer.domElement.style.background = "#222222";
this.renderer.domElement.style.display = "block";
this.target.appendChild(this.renderer.domElement);
} catch (e) {
console.log("WebGL not supported: ", e);
return;
}
this.enabled = true;
// Camera
this.camera = new THREE.PerspectiveCamera(45, 4 / 3, 1, 10000);
// Lighting
this.ambient = new THREE.AmbientLight(0xffffff, 0.5);
const keyLight = new THREE.DirectionalLight(new THREE.Color("hsl(30, 100%, 75%)"), 0.75);
keyLight.position.set(-100, 0, 100);
const fillLight = new THREE.DirectionalLight(new THREE.Color("hsl(240, 100%, 75%)"), 0.25);
fillLight.position.set(100, 0, 100);
const backLight = new THREE.DirectionalLight(0xffffff, 0.5);
backLight.position.set(100, 0, -100).normalize();
this.lights = new THREE.Group();
this.lights.add(keyLight);
this.lights.add(fillLight);
this.lights.add(backLight);
// Surface material
this.surfaceMaterial = this.create_surface_material();
// Controls
this.controls = new orbit(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.2;
this.controls.rotateSpeed = 0.25;
this.controls.enableZoom = true;
// Move lights with scene
this.controls.addEventListener("change", function(scope) {
return function() {
keyLight.position.copy(scope.camera.position);
fillLight.position.copy(scope.camera.position);
backLight.position.copy(scope.camera.position);
keyLight.lookAt(scope.controls.target);
fillLight.lookAt(scope.controls.target);
backLight.lookAt(scope.controls.target);
scope.dirty = true;
};
}(this));
// Events
window.addEventListener("resize", this.update_view, false);
// Start the render loop only after the target has a real,
// stable size. Without this, the first frame paints into
// a 0×0 / collapsed-flex canvas and a second frame paints
// again at the right size — visible as a flash on the
// very first mount of the Program tab.
const startRendering = () => {
if (this._rendering) return;
this._rendering = true;
this.update_view();
this.render();
};
const dims = this.get_dims();
if (dims.width > 0 && dims.height > 0) {
startRendering();
} else if (typeof ResizeObserver !== "undefined") {
this._sizeWatcher = new ResizeObserver(entries => {
for (const entry of entries) {
const r = entry.contentRect;
if (r.width > 0 && r.height > 0) {
this._sizeWatcher.disconnect();
this._sizeWatcher = null;
startRendering();
return;
}
}
});
this._sizeWatcher.observe(this.target);
} else {
// Old browser fallback: poll for a non-zero size.
const tick = () => {
const d = this.get_dims();
if (d.width > 0 && d.height > 0) {
startRendering();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}
},
create_surface_material: function() {
return new THREE.MeshPhongMaterial({
specular: 0x111111,
shininess: 10,
side: THREE.FrontSide,
color: 0x0c2d53
});
},
draw_loading: function() {
this.scene = new THREE.Scene();
const geometry = new THREE.TextGeometry("Loading 3D View...", {
font: new THREE.Font(font),
size: 40,
height: 5,
curveSegments: 12,
bevelEnabled: true,
bevelThickness: 10,
bevelSize: 8,
bevelSegments: 5
});
geometry.computeBoundingBox();
const mesh = new THREE.Mesh(geometry, this.surfaceMaterial);
this.scene.add(mesh);
this.scene.add(this.ambient);
this.scene.add(this.lights);
this.update_view();
},
draw_workpiece: function(scene, material) {
if (typeof this.workpiece == "undefined") {
return;
}
let min = this.workpiece.min;
let max = this.workpiece.max;
min = new THREE.Vector3(min[0], min[1], min[2]);
max = new THREE.Vector3(max[0], max[1], max[2]);
const dims = max.clone().sub(min);
const geometry = new THREE.BoxGeometry(dims.x, dims.y, dims.z);
const mesh = new THREE.Mesh(geometry, material);
const offset = dims.clone();
offset.divideScalar(2);
offset.add(min);
mesh.position.add(offset);
geometry.computeBoundingBox();
scene.add(mesh);
return mesh;
},
draw_surface: function(scene, material) {
if (typeof this.vertices == "undefined") {
return;
}
const geometry = new THREE.BufferGeometry();
geometry.addAttribute("position", new THREE.Float32BufferAttribute(this.vertices, 3));
geometry.addAttribute("normal", new THREE.Float32BufferAttribute(this.normals, 3));
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
return new THREE.Mesh(geometry, material);
},
draw_tool: function(scene, bbox) {
// Tool size is relative to bounds
const size = bbox.getSize(new THREE.Vector3());
let length = (size.x + size.y + size.z) / 24;
if (length < 1) {
length = 1;
}
const material = new THREE.MeshPhongMaterial({
transparent: true,
opacity: 0.75,
specular: 0x161616,
shininess: 10,
color: 0xffa500 // Orange
});
const geometry = new THREE.CylinderGeometry(length / 2, 0, length, 128);
geometry.translate(0, length / 2, 0);
geometry.rotateX(0.5 * Math.PI);
const mesh = new THREE.Mesh(geometry, material);
this.update_tool(mesh);
mesh.visible = this.showTool;
scene.add(mesh);
return mesh;
},
draw_axis: function(axis, up, length, radius) {
let color;
if (axis == 0) {
color = 0xff0000;
} else if (axis == 1) {
color = 0x00ff00;
} else if (axis == 2) {
color = 0x0000ff;
}
const group = new THREE.Group();
const material = new THREE.MeshPhongMaterial({
specular: 0x161616, shininess: 10, color: color
});
let geometry = new THREE.CylinderGeometry(radius, radius, length, 128);
geometry.translate(0, -length / 2, 0);
group.add(new THREE.Mesh(geometry, material));
geometry = new THREE.CylinderGeometry(1.5 * radius, 0, 2 * radius, 128);
geometry.translate(0, -length - radius, 0);
group.add(new THREE.Mesh(geometry, material));
if (axis == 0) {
group.rotateZ((up ? 0.5 : 1.5) * Math.PI);
} else if (axis == 1) {
group.rotateX((up ? 0 : 1 ) * Math.PI);
} else if (axis == 2) {
group.rotateX((up ? 1.5 : 0.5) * Math.PI);
}
return group;
},
draw_axes: function(scene, bbox) {
const size = bbox.getSize(new THREE.Vector3());
let length = (size.x + size.y + size.z) / 3;
length /= 10;
if (length < 1) {
length = 1;
}
const radius = length / 20;
const group = new THREE.Group();
for (let axis = 0; axis < 3; axis++) {
for (let up = 0; up < 2; up++) {
group.add(this.draw_axis(axis, up, length, radius));
}
}
group.visible = this.showAxes;
scene.add(group);
return group;
},
get_color: function(speed) {
if (isNaN(speed)) {
return [ 255, 0, 0 ];
} // Rapid
let intensity = speed / this.toolpath.maxSpeed;
if (typeof speed == "undefined" || !this.showIntensity) {
intensity = 1;
}
return [ 0, 255 * intensity, 127 * (1 - intensity) ];
},
draw_path: function(scene) {
const geometry = new THREE.BufferGeometry();
const material = new THREE.LineBasicMaterial({
vertexColors: THREE.VertexColors,
linewidth: 1.5
});
const positions = new THREE.Float32BufferAttribute(this.positions, 3);
geometry.addAttribute("position", positions);
let colors = [];
for (let i = 0; i < this.speeds.length; i++) {
const color = this.get_color(this.speeds[i]);
Array.prototype.push.apply(colors, color);
}
colors = new THREE.Uint8BufferAttribute(colors, 3, true);
geometry.addAttribute("color", colors);
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
const line = new THREE.Line(geometry, material);
line.visible = this.showPath;
scene.add(line);
return line;
},
create_empty_geom: function() {
const geometry = new THREE.BufferGeometry();
geometry.addAttribute("position",
new THREE.Float32BufferAttribute([], 3));
return geometry;
},
create_bbox_geom: function(bbox) {
const vertices = [];
if (!bbox.isEmpty()) {
// Top
vertices.push(bbox.min.x, bbox.min.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.min.y, bbox.min.z);
// Bottom
vertices.push(bbox.min.x, bbox.max.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.max.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.max.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.max.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.max.y, bbox.min.z);
// Sides
vertices.push(bbox.min.x, bbox.min.y, bbox.min.z);
vertices.push(bbox.min.x, bbox.max.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.min.z);
vertices.push(bbox.max.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.max.x, bbox.max.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.min.y, bbox.max.z);
vertices.push(bbox.min.x, bbox.max.y, bbox.max.z);
}
const geometry = new THREE.BufferGeometry();
geometry.addAttribute("position",
new THREE.Float32BufferAttribute(vertices, 3));
return geometry;
},
draw_bbox: function(scene, bbox) {
const geometry = this.create_bbox_geom(bbox);
const material = new THREE.LineBasicMaterial({ color: 0xffffff });
const line = new THREE.LineSegments(geometry, material);
line.visible = this.showBBox;
scene.add(line);
return line;
},
draw_envelope: function(scene) {
const geometry = this.create_empty_geom();
const material = new THREE.LineBasicMaterial({ color: 0x00f7ff });
const line = new THREE.LineSegments(geometry, material);
line.visible = this.showBBox;
scene.add(line);
this.update_envelope(line);
return line;
},
draw: function(scene) {
// Lights
scene.add(this.ambient);
scene.add(this.lights);
// Model
this.pathView = this.draw_path(scene);
this.surfaceMesh = this.draw_surface(scene, this.surfaceMaterial);
this.workpieceMesh = this.draw_workpiece(scene, this.surfaceMaterial);
this.update_surface_mode(this.surfaceMode);
// Compute bounding box
const bbox = this.get_model_bounds();
// Tool, axes & bounds
this.toolView = this.draw_tool(scene, bbox);
this.axesView = this.draw_axes(scene, bbox);
this.bboxView = this.draw_bbox(scene, bbox);
this.envelopeView = this.draw_envelope(scene);
},
render: function() {
window.requestAnimationFrame(this.render);
if (typeof this.scene == "undefined") {
return;
}
// Don't paint frames while the target has no size; this
// prevents an initial single-frame clear from painting
// before the layout has settled (visible as a dark flash).
const dims = this.get_dims();
if (!(dims.width > 0 && dims.height > 0)) {
return;
}
if (this.controls.update() || this.dirty) {
this.dirty = false;
this.renderer.render(this.scene, this.camera);
}
},
get_model_bounds: function() {
const bbox = new THREE.Box3(new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0.00001, 0.00001, 0.00001));
function add(o) {
if (typeof o != "undefined") {
const oBBox = new THREE.Box3();
oBBox.setFromObject(o);
bbox.union(oBBox);
}
}
add(this.pathView);
add(this.surfaceMesh);
add(this.workpieceMesh);
return bbox;
},
snap: function(view) {
if (this.loading) {
return;
}
if (view != this.snapView) {
this.snapView = view;
cookie.set("snap-view", view);
}
const bbox = this.get_model_bounds();
this.controls.reset();
bbox.getCenter(this.controls.target);
this.update_view();
// Compute new camera position
const center = bbox.getCenter(new THREE.Vector3());
const offset = new THREE.Vector3();
switch (view) {
case "isometric": offset.y -= 1; offset.z += 1; break;
case "front": offset.y -= 1; break;
case "back": offset.y += 1; break;
case "left": offset.x -= 1; break;
case "right": offset.x += 1; break;
case "top": offset.z += 1; break;
case "bottom": offset.z -= 1; break;
}
offset.normalize();
// Initial camera position
const position = new THREE.Vector3().copy(center).add(offset);
this.camera.position.copy(position);
this.camera.lookAt(center); // Get correct camera orientation
const theta = this.camera.fov / 180 * Math.PI; // View angle
const cameraLine = new THREE.Line3(center, position);
const cameraUp = new THREE.Vector3()
.copy(this.camera.up)
.applyQuaternion(this.camera.quaternion);
const cameraLeft = new THREE.Vector3()
.copy(offset)
.cross(cameraUp)
.normalize();
const corners = [
new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.min.z),
new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.max.z),
new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.min.z),
new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.max.z),
new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.min.z),
new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.max.z),
new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.min.z),
new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.max.z),
];
let dist = this.camera.near; // Min camera dist
for (let i = 0; i < corners.length; i++) {
// Project on to camera line
const p1 = cameraLine.closestPointToPoint(corners[i], false, new THREE.Vector3());
// Compute distance from projection to center
let d = p1.distanceTo(center);
if (cameraLine.closestPointToPointParameter(p1, false) < 0) {
d = -d;
}
// Compute up line
const up = new THREE.Line3(p1, new THREE.Vector3().copy(p1).add(cameraUp));
// Project on to up line
const p2 = up.closestPointToPoint(corners[i], false, new THREE.Vector3());
// Compute length
let l = p1.distanceTo(p2);
// Update min camera distance
dist = Math.max(dist, d + l / Math.tan(theta / 2));
// Compute left line
const left = new THREE.Line3(p1, new THREE.Vector3().copy(p1).add(cameraLeft));
// Project on to left line
const p3 = left.closestPointToPoint(corners[i], false, new THREE.Vector3());
// Compute length
l = p1.distanceTo(p3);
// Update min camera distance
dist = Math.max(dist, d + l / Math.tan(theta / 2) / this.camera.aspect);
}
this.camera.position.copy(offset.multiplyScalar(dist * 1.2).add(center));
}
},
mixins: [ require("./axis-vars") ]
};