diff --git a/www/gcode_viewer_card.js b/www/gcode_viewer_card.js new file mode 100644 index 00000000..84de9e2f --- /dev/null +++ b/www/gcode_viewer_card.js @@ -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 = '
'; + 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 = + ''; + wrapper.appendChild(statusbar); + + const err = document.createElement("div"); + err.className = "err"; + err.innerHTML = + '
'; + 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 = ` + + `; + } +} + +customElements.define("gcode-viewer-card", GcodeViewerCard); +customElements.define("gcode-viewer-card-editor", GcodeViewerCardEditor);