From fbdc1a9eb891476c2b75f52e192ae2d31a633668 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Thu, 14 May 2026 14:09:09 +0300 Subject: [PATCH] math --- core/nodeslicer/nodes.py | 72 +++--- core/nodeslicer/tree.py | 1 + gcode_snippets/redefine_z_0-1.gcode | 2 - infra/blender_slice_nodes_generic.py | 3 + infra/printer_backends.py | 327 +++++++++++++++++++++++++++ 5 files changed, 371 insertions(+), 34 deletions(-) delete mode 100644 gcode_snippets/redefine_z_0-1.gcode create mode 100644 infra/printer_backends.py diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index 654c0ad..db1f779 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -1,7 +1,7 @@ from __future__ import annotations import os from math import floor -from typing import Any +from typing import Any, TypeAlias import bpy import numpy as np from mathutils import Euler @@ -65,34 +65,25 @@ class SLICENODE_InputObject(InputNode): class SLICENODE_InputIntrinsics(SliceNode): bl_label = "Printer Intrinsics" - bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm] - bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm] - feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm] - extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm] - def init(self, context): self.add_output("SLICESOCK_Intrinsics") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "bed_x") - layout.prop(self, "bed_y") - layout.prop(self, "feedrate") - layout.prop(self, "extrusion_rate") + self.add_input("SLICESOCK_Float", "Bed X").value = 250.0 + self.add_input("SLICESOCK_Float", "Bed Y").value = 210.0 + self.add_input("SLICESOCK_Float", "Feedrate").value = 1200.0 + self.add_input("SLICESOCK_Float", "Extrusion Rate").value = 0.035 def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics: return SliceIntrinsics( - bed_x=self.bed_x, - bed_y=self.bed_y, - feedrate=self.feedrate, - extrusion_rate=self.extrusion_rate + bed_x=self.resolve("Bed X"), + bed_y=self.resolve("Bed Y"), + feedrate=self.resolve("Feedrate"), + extrusion_rate=self.resolve("Extrusion Rate") ) # Math -@register_class -class SLICENODE_IntMath(SliceNode): - bl_label = "Integer Math" +class MathNode(SliceNode): + bl_label = "Float Math" operation: bpy.props.EnumProperty( name="Operation", @@ -100,7 +91,7 @@ class SLICENODE_IntMath(SliceNode): ('ADD', "Add", "Add the two values"), ('SUBTRACT', "Subtract", "Subtract the second value from the first"), ('MULTIPLY', "Multiply", "Multiply the two values"), - ('DIVIDE', "Divide", "Integer division"), + ('DIVIDE', "Divide", "Division"), ('ABS', "Absolute", "Absolute value of the first value"), ('NEGATE', "Negate", "Negate the first value"), ('POWER', "Power", "First value raised to the power of the second"), @@ -116,19 +107,24 @@ class SLICENODE_IntMath(SliceNode): default='ADD' ) # pyright: ignore[reportInvalidTypeForm] - def init(self, context): - self.add_output("SLICESOCK_IntRo", "Value") + math_type: TypeAlias = float + socket_label: str = "Float" - self.add_input("SLICESOCK_Int", "Value") - self.add_input("SLICESOCK_Int", "Value") + def init(self, context): + self.add_output(f"SLICESOCK_{self.socket_label}Ro", "Value") + + self.add_input(f"SLICESOCK_{self.socket_label}", "Value") + self.add_input(f"SLICESOCK_{self.socket_label}", "Value") def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") - def evaluate_output(self, socket_name: str, **_kwargs) -> int: - a: int = resolve_input(self.inputs[0]) - b: int = resolve_input(self.inputs[1]) + def evaluate_output(self, socket_name: str, **_kwargs) -> math_type: + t = self.math_type + + a: t = resolve_input(self.inputs[0]) + b: t = resolve_input(self.inputs[1]) op = self.operation try: @@ -138,19 +134,31 @@ class SLICENODE_IntMath(SliceNode): if op == 'DIVIDE': return a // b if b != 0 else 0 if op == 'ABS': return abs(a) if op == 'NEGATE': return -a - if op == 'POWER': return int(a ** b) if not (a == 0 and b < 0) else 0 + if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0 if op == 'MIN': return min(a, b) if op == 'MAX': return max(a, b) if op == 'SIGN': return (a > 0) - (a < 0) - if op == 'DIVIDE_ROUND': return int(round(a / b)) if b != 0 else 0 + if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0 if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0 - if op == 'DIVIDE_TRUNCATE': return int(a / b) if b != 0 else 0 + if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0 if op == 'MODULO_FLOORED': return a % b if b != 0 else 0 - if op == 'MODULO_TRUNCATED': return a - (b * int(a / b)) if b != 0 else 0 + if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0 except Exception: return 0 return 0 +@register_class +class SLICENODE_FloatMath(MathNode): + bl_label = "Math" + math_type = float + socket_label: str = "Float" + +@register_class +class SLICENODE_IntMath(MathNode): + bl_label = "Integer Math" + math_type = int + socket_label: str = "Int" + @register_class class SLICENODE_GetMesh(SliceNode): bl_label = "Get Mesh" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 2ebee4f..32dd879 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -42,6 +42,7 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" + layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" diff --git a/gcode_snippets/redefine_z_0-1.gcode b/gcode_snippets/redefine_z_0-1.gcode deleted file mode 100644 index 82535ac..0000000 --- a/gcode_snippets/redefine_z_0-1.gcode +++ /dev/null @@ -1,2 +0,0 @@ -G0 Z0.1 F720 ; Move to the desired physical Z clearance height -G92 Z0 ; Redefine this position as Z=0 diff --git a/infra/blender_slice_nodes_generic.py b/infra/blender_slice_nodes_generic.py index 82cb147..20cc53f 100644 --- a/infra/blender_slice_nodes_generic.py +++ b/infra/blender_slice_nodes_generic.py @@ -23,6 +23,9 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName): class SliceNode(Node, AutoBlIdName): def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... + def resolve(self, fieldname) -> Any: + return resolve_input(self.inputs.get(fieldname)) + def add_output(self, bl_idname: str, name: str | None = None): from ..registry import get_label_by_name return self.outputs.new(bl_idname, name or get_label_by_name(bl_idname)) diff --git a/infra/printer_backends.py b/infra/printer_backends.py new file mode 100644 index 0000000..6c2e1e9 --- /dev/null +++ b/infra/printer_backends.py @@ -0,0 +1,327 @@ +from __future__ import annotations +from functools import wraps +from typing import Any, Callable + +def with_api_state(api_state: str) -> Callable[..., Any]: + def decorator(func: Callable[..., Any]) -> Any: + @wraps(func) + def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + self.api_state = api_state + try: return func(self, *args, **kwargs) + finally: self.api_state = None + return wrapped + return decorator + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, Any, Optional +import requests +import time +import json + +@dataclass(frozen=True) +class PrinterStatus: + progress: float = 0.0 + state: str = "OFFLINE" + job_name: str = "" + job_id: str = "" + nozzle_temperature: float = 0.0 + bed_temperature: float = 0.0 + nozzle_diameter: float = 0.0 + +class PrinterBackend(Protocol): + def query_status(self) -> PrinterStatus: ... + def pause(self) -> None: ... + def resume(self) -> None: ... + def stop(self) -> None: ... + def start_print(self, gcode: Path, name: str) -> None: ... + +class HttpBackend: + def __init__(self, host: str, port: int, prefix: str, api_key: Optional[str] = None, timeout: float = 30.0): + self.host = host + self.port = port + self.base = f"http://{host}:{port}{prefix}" + self.timeout = timeout + self.session = requests.Session() + self.headers = {} + self.api_state: str | None = None + if api_key: + self.headers["X-Api-Key"] = api_key + + def _get(self, path: str, **kw) -> requests.Response: + headers = {**self.headers, **kw.pop('headers', {})} + return self.session.get(self.base + path, headers=headers, timeout=self.timeout, **kw) + + def _post(self, path: str, **kw) -> requests.Response: + headers = {**self.headers, **kw.pop('headers', {})} + return self.session.post(self.base + path, headers=headers, timeout=self.timeout, **kw) + + def _put(self, path: str, **kw) -> requests.Response: + headers = {**self.headers, **kw.pop('headers', {})} + return self.session.put(self.base + path, headers=headers, timeout=self.timeout, **kw) + + def _delete(self, path: str, **kw) -> requests.Response: + headers = {**self.headers, **kw.pop('headers', {})} + return self.session.delete(self.base + path, headers=headers, timeout=self.timeout, **kw) + + def _json(self, resp, default: dict = {}, ok=(200, 201, 202, 204)) -> dict: + if resp.status_code not in ok: + resp.raise_for_status() + if resp.status_code == 204 or not resp.content: + return default + return resp.json() + +class PrinterHttpBackend(HttpBackend, PrinterBackend): + pass + +class PrusaLinkBackend(PrinterHttpBackend): + def query_status(self) -> PrinterStatus: + s = self._get("/api/v1/status").json() + info = self._get("/api/v1/info").json() + job = self._json(self._get("/api/v1/job")) + return PrinterStatus( + progress=float(job.get("progress", 0.0)), + state=str(s.get("printer", {}).get("state", "OFFLINE")), + job_name=str(job.get("file", {}).get("display_name", "")), + job_id=str(s.get("job", {}).get("id", "")), + nozzle_temperature=float(s.get("printer", {}).get("temp_nozzle", 0) or 0), + bed_temperature=float(s.get("printer", {}).get("temp_bed", 0) or 0), + nozzle_diameter=float(info.get('nozzle_diameter', 0.0)), + ) + + @with_api_state('PAUSING') + def pause(self) -> None: + jid = self.query_status().job_id + if not jid: raise RuntimeError("No job to pause") + self._put(f"/api/v1/job/{jid}/pause").raise_for_status() + + @with_api_state('RESUMING') + def resume(self) -> None: + jid = self.query_status().job_id + if not jid: raise RuntimeError("No job to resume") + self._put(f"/api/v1/job/{jid}/resume").raise_for_status() + + @with_api_state('STOPPING') + def stop(self) -> None: + jid = self.query_status().job_id + if not jid: raise RuntimeError("No job to stop") + self._delete(f"/api/v1/job/{jid}").raise_for_status() + + @with_api_state("STARTING") + def start_print(self, gcode: Path, name: str) -> None: + storage = self._get("/api/v1/storage").json() + writables = [s for s in storage.get("storage_list", []) if s.get("available") and not s.get("read_only")] + if not writables: + raise RuntimeError("No writable storage") + + path = writables[0]["path"].lstrip("/") + file_size = gcode.stat().st_size + + with open(gcode, "rb") as f: + self._put( + f"/api/v1/files/{path}/{name}", + headers={ + "Overwrite": "?1", + "Print-After-Upload": "?1", + "Content-Type": "text/x.gcode" if Path(name).suffix == ".gcode" else "application/octet-stream", + "Content-Length": str(file_size), + }, + data=f, + ).raise_for_status() + + try: # This block will ensure a start on mk3s printers, which don't seem to respond to Print-After-Upload + # wait until /transfer is gone (204) + for _ in range(int(self.timeout)): + r = self._get("/api/v1/transfer") + if r.status_code == 204: break + time.sleep(1) + else: raise TimeoutError("Transfer timed out") + + # verify remote metadata size matches + for _ in range(int(self.timeout)): + r = self._get(f"/api/v1/files/{path}/{name}", headers={"Accept": "application/json"}) + if r.status_code == 200 and (r.json().get("size") == file_size): break + time.sleep(1) + else: raise TimeoutError("Remote file size did not match expected size") + + + self._post(f"/api/v1/files/{path}/{name}").raise_for_status() + + finally: return + +class CrealityBackend(PrinterHttpBackend): + STATUS_EP = "/protocal.csp?fname=Info&opt=main&function=get" + STORAGE_PATH = "/mmcblk0p1/creality/gztemp/" + STORAGE_PATH_PRINT = "/media/mmcblk0p1/creality/gztemp/" + + def query_status(self) -> PrinterStatus: + r = self._get(self.STATUS_EP) + r.raise_for_status() + d = r.json() + + # Map numeric state to string + state_id = str(d.get("state", "-1")) + state: str = ( + "OFFLINE" if state_id == "-1" else + "PAUSED" if state_id == "5" else + "IDLE" if state_id in {"0", "3", "4"} else + "PRINTING" if state_id == "1" else + "FINISHED" if state_id == "2" else + "UNKNOWN" + ) + + # Defensive casting + def f(x, default=0.0): + try: + return float(x) + except Exception: + return default + + def s(x, default=""): + return str(x) if x is not None else default + + return PrinterStatus( + progress=round(f(d.get("printProgress"), 0.0), 1), + state=state, + job_name=s(d.get("print")), + job_id="", # Not exposed by this API + nozzle_temperature=f(d.get("nozzleTemp"), 0.0), + bed_temperature=f(d.get("bedTemp"), 0.0), + ) + + @with_api_state('PAUSING') + def pause(self) -> None: + r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=1") + r.raise_for_status() + + @with_api_state('RESUMING') + def resume(self) -> None: + r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=0") + r.raise_for_status() + + @with_api_state('STOPPING') + def stop(self) -> None: + r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&stop=1") + r.raise_for_status() + + @with_api_state('STARTING') + def start_print(self, gcode: Path, name: str) -> None: + from ..infra.ftp import ftp_upload, ftp_wipe, ftp_get_filesize + + file_size = gcode.stat().st_size + + # Clean target dir and upload + ftp_wipe(self.host, self.STORAGE_PATH) + ftp_upload(self.host, gcode, self.STORAGE_PATH, filename=name, overwrite=True, timeout=self.timeout) + + for _ in range(10): + print(_) + uploaded_size = ftp_get_filesize(self.host, storage_path=self.STORAGE_PATH, filename=name, timeout=self.timeout) + if uploaded_size == file_size: + break + time.sleep(2) + else: + raise TimeoutError("Uploaded file did not reach expected size on printer") + + # Trigger print + ep = ( + "/protocal.csp?fname=net&opt=iot_conf&function=set" + f"&print={self.STORAGE_PATH_PRINT}{name}" + ) + r = self._get(ep) + r.raise_for_status() + +class MoonrakerBackend(PrinterHttpBackend): + def query_status(self) -> PrinterStatus: + url = f"{self.base}/printer/objects/query?webhooks&virtual_sdcard&print_stats" + r = self.session.get(url, headers=self.headers, timeout=self.timeout) + r.raise_for_status() + data = r.json().get("result", {}).get("status", {}) + + sd = data.get("virtual_sdcard", {}) or {} + ps = data.get("print_stats", {}) or {} + + state = str(ps.get("state", "OFFLINE")).upper() + if state == "STANDBY": + state = "IDLE" + + progress = 0.0 + try: + progress = float(sd.get("progress", 0.0)) * 100.0 + except Exception: + pass + + file_path = sd.get("file_path", "") or "" + + return PrinterStatus( + progress=round(progress, 1), + state=state, + job_name=str(file_path), + job_id="", + nozzle_temperature=0.0, + bed_temperature=0.0, + ) + + @with_api_state('PAUSING') + def pause(self) -> None: + url = f"{self.base}/printer/print/pause" + r = self.session.post(url, headers=self.headers, timeout=self.timeout) + r.raise_for_status() + + @with_api_state('RESUMING') + def resume(self) -> None: + url = f"{self.base}/printer/print/resume" + r = self.session.post(url, headers=self.headers, timeout=self.timeout) + r.raise_for_status() + + @with_api_state('STOPPING') + def stop(self) -> None: + url = f"{self.base}/printer/print/cancel" + r = self.session.post(url, headers=self.headers, timeout=self.timeout) + r.raise_for_status() + + @with_api_state('STARTING') + def start_print(self, gcode: Path, name: str) -> None: + import hashlib + + expected_size = gcode.stat().st_size + h = hashlib.sha256() + with open(gcode, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + checksum = h.hexdigest() + + upload_url = f"{self.base}/server/files/upload" + + with open(gcode, "rb") as f: + files = {"file": (name, f, "application/octet-stream")} + form = { + "root": "gcodes", + "print": "true", + "checksum": checksum, + } + r = self.session.post( + upload_url, + headers=self.headers, + files=files, + data=form, + timeout=self.timeout, + ) + + r.raise_for_status() + result = r.json() + + if not (result.get("print_started") or result.get("print_queued")): + raise RuntimeError(f"Upload succeeded but print did not start: {result}") + +@dataclass +class ManagedPrinter: + name: str + backend: PrinterHttpBackend + status: PrinterStatus = field(default_factory=PrinterStatus) + last_error: str | None = None + last_command_time: str | None = None + last_command_response: str = "" + + def query_status(self): + self.status = self.backend.query_status() \ No newline at end of file