Files
HomeAssistantVS/www/gcode_viewer_card.js
T

528 lines
15 KiB
JavaScript

import * as THREE from "./three.module.js";
const EXT_COLOR = "#ffb300";
const 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;
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);
const travColor = cssVar("--gcode-travel-color", TRAVEL_COLOR);
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);