522 lines
15 KiB
JavaScript
522 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() {
|
|
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);
|