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);