www/gcode_viewer_card.js
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
import * as THREE from "./three.module.js";
|
||||
|
||||
const EXT_COLOR = "var(--gcode-extrusion-color, #ffb300)";
|
||||
const TRAVEL_COLOR = "var(--gcode-travel-color, #7986cb)";
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
const v = getComputedStyle(document.body).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
class GcodeViewerCard extends HTMLElement {
|
||||
static getConfigElement() {
|
||||
return document.createElement("gcode-viewer-card-editor");
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return {};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._config = {};
|
||||
this._hass = null;
|
||||
this._threeReady = false;
|
||||
this._loading = false;
|
||||
this._renderer = null;
|
||||
this._scene = null;
|
||||
this._camera = null;
|
||||
this._extSegs = null;
|
||||
this._travelSegs = null;
|
||||
this._grid = null;
|
||||
this._target = new THREE.Vector3();
|
||||
this._radius = 100;
|
||||
this._yaw = 0.8;
|
||||
this._pitch = 0.6;
|
||||
this._attached = false;
|
||||
this._resizeObs = null;
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this._config = { ...config };
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
if (this._attached && !this._loading && !this._loadedOnce && this._threeReady) {
|
||||
this._load();
|
||||
}
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 8;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._attached = true;
|
||||
if (this._threeReady) {
|
||||
this._ensureBuilt();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._attached = false;
|
||||
if (this._resizeObs) {
|
||||
this._resizeObs.disconnect();
|
||||
this._resizeObs = null;
|
||||
}
|
||||
if (this._renderer) {
|
||||
this._renderer.dispose();
|
||||
this._renderer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async _initThree() {
|
||||
try {
|
||||
await import("./three.module.js");
|
||||
} catch (err) {
|
||||
this._setStatus(`Failed to load three.js: ${err.message}`, true);
|
||||
return;
|
||||
}
|
||||
this._threeReady = true;
|
||||
if (this._attached) {
|
||||
this._ensureBuilt();
|
||||
}
|
||||
}
|
||||
|
||||
_buildDom() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
height: 520px;
|
||||
min-height: 420px;
|
||||
--gcode-extrusion-color: #ffb300;
|
||||
--gcode-travel-color: #7986cb;
|
||||
}
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--ha-card-border-radius, 12px);
|
||||
box-shadow: var(--ha-card-box-shadow, 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12));
|
||||
background: var(--card-background-color, var(--primary-background-color, #0b0b10));
|
||||
}
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
.toolbar ha-icon-button {
|
||||
background: rgba(0,0,0,.35);
|
||||
border-radius: 50%;
|
||||
color: var(--primary-text-color, #fff);
|
||||
--mdc-icon-button-size: 32px;
|
||||
}
|
||||
.statusbar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
color: var(--secondary-text-color, #aaa);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.statusbar .msg { text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex: 1; }
|
||||
.statusbar .pct { flex: none; }
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,.12);
|
||||
z-index: 3;
|
||||
display: none;
|
||||
}
|
||||
.bar .fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--gcode-extrusion-color, #ffb300);
|
||||
transition: width .15s linear;
|
||||
}
|
||||
.bar.show { display: block; }
|
||||
.err {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color, #aaa);
|
||||
font-size: 14px;
|
||||
padding: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
.err.show { display: flex; }
|
||||
.err .inner { max-width: 340px; }
|
||||
.err ha-icon { color: var(--error-color, #f24); }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
`;
|
||||
this.shadowRoot.appendChild(style);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "wrapper";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "bar";
|
||||
bar.innerHTML = '<div class="fill"></div>';
|
||||
wrapper.appendChild(bar);
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "toolbar";
|
||||
|
||||
const fitBtn = document.createElement("ha-icon-button");
|
||||
fitBtn.icon = "mdi:fit-to-screen-outline";
|
||||
fitBtn.title = "Fit view";
|
||||
fitBtn.addEventListener("click", () => this._fit());
|
||||
toolbar.appendChild(fitBtn);
|
||||
|
||||
const refreshBtn = document.createElement("ha-icon-button");
|
||||
refreshBtn.icon = "mdi:refresh";
|
||||
refreshBtn.title = "Reload G-code";
|
||||
refreshBtn.addEventListener("click", () => this._load());
|
||||
toolbar.appendChild(refreshBtn);
|
||||
|
||||
wrapper.appendChild(toolbar);
|
||||
|
||||
const statusbar = document.createElement("div");
|
||||
statusbar.className = "statusbar";
|
||||
statusbar.innerHTML =
|
||||
'<span class="msg"></span><span class="pct"></span>';
|
||||
wrapper.appendChild(statusbar);
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "err";
|
||||
err.innerHTML =
|
||||
'<div class="inner"><ha-icon icon="mdi:alert-circle-outline" style="font-size:40px"></ha-icon><div></div></div>';
|
||||
wrapper.appendChild(err);
|
||||
|
||||
this.shadowRoot.appendChild(wrapper);
|
||||
|
||||
this._els = {
|
||||
bar,
|
||||
fill: bar.querySelector(".fill"),
|
||||
statusbar,
|
||||
msg: statusbar.querySelector(".msg"),
|
||||
pct: statusbar.querySelector(".pct"),
|
||||
err,
|
||||
errMsg: err.querySelector("div div"),
|
||||
};
|
||||
|
||||
this._setupThree();
|
||||
this._setupControls(wrapper);
|
||||
this._resizeObs = new ResizeObserver(() => this._resize());
|
||||
this._resizeObs.observe(wrapper);
|
||||
}
|
||||
|
||||
_ensureBuilt() {
|
||||
if (!this._renderer) {
|
||||
this._buildDom();
|
||||
}
|
||||
}
|
||||
|
||||
_setupThree() {
|
||||
const canvas = document.createElement("canvas");
|
||||
this.shadowRoot.querySelector(".wrapper").appendChild(canvas);
|
||||
this._canvas = canvas;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
this._renderer = renderer;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b0b10);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
dir.position.set(150, 250, 200);
|
||||
scene.add(dir);
|
||||
this._scene = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 20000);
|
||||
camera.position.set(120, 120, 180);
|
||||
camera.lookAt(0, 0, 0);
|
||||
this._camera = camera;
|
||||
|
||||
this._resize();
|
||||
this._render();
|
||||
}
|
||||
|
||||
_setupControls(wrapper) {
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const onPointerDown = (ev) => {
|
||||
dragging = true;
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
this._canvas.setPointerCapture(ev.pointerId);
|
||||
};
|
||||
const onPointerMove = (ev) => {
|
||||
if (!dragging) return;
|
||||
const dx = ev.clientX - lastX;
|
||||
const dy = ev.clientY - lastY;
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
this._yaw -= dx * 0.008;
|
||||
this._pitch = Math.max(0.05, Math.min(Math.PI / 2 - 0.05, this._pitch + dy * 0.008));
|
||||
this._updateCamera();
|
||||
this._render();
|
||||
};
|
||||
const onPointerUp = (ev) => {
|
||||
dragging = false;
|
||||
this._canvas.releasePointerCapture(ev.pointerId);
|
||||
};
|
||||
const onWheel = (ev) => {
|
||||
ev.preventDefault();
|
||||
this._radius *= Math.exp(ev.deltaY * 0.001);
|
||||
this._radius = Math.max(5, Math.min(5000, this._radius));
|
||||
this._updateCamera();
|
||||
this._render();
|
||||
};
|
||||
|
||||
this._canvas.addEventListener("pointerdown", onPointerDown);
|
||||
this._canvas.addEventListener("pointermove", onPointerMove);
|
||||
this._canvas.addEventListener("pointerup", onPointerUp);
|
||||
this._canvas.addEventListener("pointercancel", onPointerUp);
|
||||
this._canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
}
|
||||
|
||||
_updateCamera() {
|
||||
const pos = new THREE.Vector3(
|
||||
this._radius * Math.cos(this._pitch) * Math.sin(this._yaw),
|
||||
this._radius * Math.sin(this._pitch),
|
||||
this._radius * Math.cos(this._pitch) * Math.cos(this._yaw)
|
||||
).add(this._target);
|
||||
this._camera.position.copy(pos);
|
||||
this._camera.lookAt(this._target);
|
||||
}
|
||||
|
||||
_resize() {
|
||||
if (!this._renderer) return;
|
||||
const w = this.shadowRoot.querySelector(".wrapper");
|
||||
const width = w ? w.clientWidth : 0;
|
||||
const height = w ? w.clientHeight : 0;
|
||||
if (!width || !height) return;
|
||||
this._renderer.setSize(width, height, false);
|
||||
this._camera.aspect = width / height;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._render();
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (this._renderer) {
|
||||
this._renderer.render(this._scene, this._camera);
|
||||
}
|
||||
}
|
||||
|
||||
_fit() {
|
||||
if (!this._extSegs && !this._travelSegs) return;
|
||||
const box = new THREE.Box3();
|
||||
if (this._extSegs) box.expandByObject(this._extSegs);
|
||||
if (this._travelSegs) box.expandByObject(this._travelSegs);
|
||||
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||
if (!isFinite(sphere.radius) || sphere.radius <= 0) return;
|
||||
this._target.copy(sphere.center);
|
||||
this._radius = sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360);
|
||||
this._radius *= 1.35;
|
||||
this._updateCamera();
|
||||
this._render();
|
||||
}
|
||||
|
||||
_setStatus(msg, isError = false) {
|
||||
if (this._els) {
|
||||
this._els.msg.textContent = msg;
|
||||
this._els.err.classList.toggle("show", isError);
|
||||
if (isError) this._els.errMsg.textContent = msg;
|
||||
}
|
||||
}
|
||||
|
||||
_setProgress(pct, pctText) {
|
||||
if (!this._els) return;
|
||||
this._els.bar.classList.toggle("show", pct >= 0 && pct < 1);
|
||||
this._els.fill.style.width = `${Math.round((pct || 0) * 100)}%`;
|
||||
this._els.pct.textContent = pctText || "";
|
||||
}
|
||||
|
||||
async _load() {
|
||||
if (this._loading) return;
|
||||
if (!this._hass || !this._hass.auth) {
|
||||
this._setStatus("Waiting for Home Assistant…");
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._els.err.classList.remove("show");
|
||||
this._setStatus("Fetching G-code…");
|
||||
this._setProgress(0, "0%");
|
||||
try {
|
||||
const resp = await fetch("/api/octoprint_gcode_proxy/current", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._hass.auth.accessToken}`,
|
||||
},
|
||||
});
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
throw new Error("Authentication failed — token rejected.");
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await resp.json()).error || "";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const map = {
|
||||
no_active_file: "No active print file on the printer.",
|
||||
octoprint_unreachable: "OctoPrint is unreachable.",
|
||||
octoprint_auth_failed: "OctoPrint rejected the API key.",
|
||||
missing_secret: "Missing octoprint_api_key secret.",
|
||||
};
|
||||
throw new Error(map[detail] || `Request failed (HTTP ${resp.status}).`);
|
||||
}
|
||||
const filename = resp.headers.get("X-OctoPrint-File");
|
||||
const contentLength = Number(resp.headers.get("Content-Length")) || 0;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let text = "";
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (contentLength) {
|
||||
this._setProgress(received / contentLength, `${Math.round((received / contentLength) * 100)}%`);
|
||||
}
|
||||
}
|
||||
text += decoder.decode();
|
||||
this._setProgress(0, "");
|
||||
await this._parse(text, filename);
|
||||
this._loadedOnce = true;
|
||||
} catch (err) {
|
||||
this._setProgress(0, "");
|
||||
this._setStatus(err.message, true);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _parse(text, filename) {
|
||||
this._setStatus(`Parsing ${filename || "G-code"}…`);
|
||||
this._setProgress(0, "0%");
|
||||
const lines = text.split("\n");
|
||||
const extPos = [];
|
||||
const travPos = [];
|
||||
|
||||
let cx = 0, cy = 0, cz = 0, ce = 0;
|
||||
let cur = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
const push = (list, a, b) => {
|
||||
list.push(a.x, a.y, a.z, b.x, b.y, b.z);
|
||||
};
|
||||
|
||||
const slice = 30000;
|
||||
for (let start = 0; start < lines.length; start += slice) {
|
||||
const end = Math.min(start + slice, lines.length);
|
||||
for (let i = start; i < end; i++) {
|
||||
const line = lines[i];
|
||||
const semi = line.indexOf(";");
|
||||
const code = semi >= 0 ? line.slice(0, semi) : line;
|
||||
const m = code.match(/^G[01]\s/i);
|
||||
if (!m) continue;
|
||||
|
||||
let hasX = false, hasY = false, hasZ = false, hasE = false, nx = cx, ny = cy, nz = cz, ne = ce;
|
||||
const params = code.slice(2);
|
||||
let match;
|
||||
const re = /([XYZEF])(-?[\d.]+)/gi;
|
||||
while ((match = re.exec(params))) {
|
||||
const v = parseFloat(match[2]);
|
||||
switch (match[1].toUpperCase()) {
|
||||
case "X": nx = v; hasX = true; break;
|
||||
case "Y": ny = v; hasY = true; break;
|
||||
case "Z": nz = v; hasZ = true; break;
|
||||
case "E": ne = v; hasE = true; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasX && !hasY && !hasZ && !hasE) continue;
|
||||
|
||||
const prev = cur;
|
||||
cur = new THREE.Vector3(nx, ny, nz);
|
||||
|
||||
const isG0 = /^G0\b/i.test(code.trim());
|
||||
const extruding = !isG0 && hasE && ne - ce > 0.0001;
|
||||
const list = extruding ? extPos : travPos;
|
||||
push(list, prev, cur);
|
||||
|
||||
cx = nx; cy = ny; cz = nz; ce = ne;
|
||||
}
|
||||
this._setProgress(start / lines.length, `${Math.round((start / lines.length) * 100)}%`);
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
const mk = (positions, color) => {
|
||||
if (!positions.length) return null;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
return new THREE.LineSegments(
|
||||
geo,
|
||||
new THREE.LineBasicMaterial({ color: new THREE.Color(color), vertexColors: false })
|
||||
);
|
||||
};
|
||||
|
||||
if (this._extSegs) { this._scene.remove(this._extSegs); this._extSegs.geometry.dispose(); }
|
||||
if (this._travelSegs) { this._scene.remove(this._travelSegs); this._travelSegs.geometry.dispose(); }
|
||||
|
||||
const extColor = cssVar("--gcode-extrusion-color", EXT_COLOR.replace("var(--gcode-extrusion-color, ", "").replace(")", ""));
|
||||
const travColor = cssVar("--gcode-travel-color", TRAVEL_COLOR.replace("var(--gcode-travel-color, ", "").replace(")", ""));
|
||||
|
||||
this._extSegs = mk(extPos, extColor);
|
||||
this._travelSegs = mk(travPos, travColor);
|
||||
if (this._extSegs) this._scene.add(this._extSegs);
|
||||
if (this._travelSegs) this._scene.add(this._travelSegs);
|
||||
|
||||
if (!this._extSegs && !this._travelSegs) {
|
||||
this._setStatus("No G0/G1 moves found in the file.");
|
||||
} else {
|
||||
const segCount = extPos.length / 6 + travPos.length / 6;
|
||||
this._setStatus(
|
||||
`${filename || "G-code"} · ${lines.length.toLocaleString()} lines · ${segCount.toLocaleString()} segments`
|
||||
);
|
||||
}
|
||||
|
||||
this._setProgress(0, "");
|
||||
this._fit();
|
||||
}
|
||||
}
|
||||
|
||||
class GcodeViewerCardEditor extends HTMLElement {
|
||||
setConfig(config) {
|
||||
this._config = config;
|
||||
}
|
||||
get value() {
|
||||
return { ...this._config };
|
||||
}
|
||||
set hass(hass) {
|
||||
/* no editable options yet */
|
||||
}
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<ha-formfield label="G-code viewer has no configuration options.">
|
||||
</ha-formfield>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("gcode-viewer-card", GcodeViewerCard);
|
||||
customElements.define("gcode-viewer-card-editor", GcodeViewerCardEditor);
|
||||
Reference in New Issue
Block a user