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 = '
'; 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); 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 = ` `; } } customElements.define("gcode-viewer-card", GcodeViewerCard); customElements.define("gcode-viewer-card-editor", GcodeViewerCardEditor);