Hi Nis,
Thanks for providing the extra context - it’s very interesting!
There are possibly other methods available to make this happen aside from sending your own serial commands or changing the images - I’m mainly thinking here of the direct manipulation of an absolute mode LightBurn project file.
But perhaps if you could attach a couple of the raw image files, and explain what you want the final output of each image to look that might help determine the best approach.
Aside from that, I was thinking about this over the weekend, and here is python script (made with the help of Google Gemini 2.5 Flash) which demonstrates another kind of workaround using coordinates passed to Python. which launches a modified lbrn2 file of a small line at 0% power in order to bring about the offset.
Example use:
Python OffsetXY.py 100 100
Moves the laser to X100 Y00
Python OffsetXY.py 100 100 Circle.lbrn2
Moves the laser to X100 Y00 and then starts Circle.lbrn2 (which must be set up as a Current Position job - save origin to project)
Python OffsetXY.py 100 100 CurrentPositon.lbrn2 skip_second_start
Moves the laser to X100 Y00 and then loads CurrentPositon.lbrn2 but does not start it. This project file must also have a saved origin, and “skip_second_start” is used because we only need it to load in order to make the origin mode switch from Absolute to Current Position.
This last example might be the most useful for you as it should enable you to easily run your image or gcode exactly where you need it.
Here is the python code: OffsetXY.py
import socket
import time
import sys
import xml.etree.ElementTree as ET
import os
import subprocess
import tempfile # For creating temporary files
# --- Configuration ---
UDP_IP = "127.0.0.1"
UDP_OUT_PORT = 19840
UDP_IN_PORT = 19841
LIGHTBURN_EXE_PATH = "C:\\Program Files\\LightBurn\\LightBurn.exe"
# The template file we will modify and then load
OFFSETXY_TEMPLATE = "C:\\temp\\OffsetXY_template.lbrn2"
SHORT_LINE_WIDTH = 1 # This is the offset for X in your create_dynamic_lightburn_project
# --- Helper Functions ---
def send_udp_command(sock_out, sock_in, message, timeout=5):
"""
Sends a UDP command and waits for a response.
Returns the received data (bytes) if successful, or None on timeout/error.
"""
print(f"Sending: {message}")
sock_out.sendto(message.encode(), (UDP_IP, UDP_OUT_PORT))
try:
sock_in.settimeout(timeout)
data, addr = sock_in.recvfrom(1024)
print(f"Received: {data} from {addr}")
return data
except socket.timeout:
print(f"Timeout waiting for response from LightBurn for: {message}")
return None
except Exception as e:
print(f"Error during UDP communication for {message}: {e}")
return None
def create_dynamic_lightburn_project(template_path, target_x, target_y):
"""
Reads the template, modifies its XForm coordinates, and saves it to a
temporary file. Returns the path to the temporary file or None on error.
"""
temp_file_path = None
try:
tree = ET.parse(template_path)
root = tree.getroot()
shape_element = root.find(".//Shape")
xform_element = shape_element.find("XForm")
if shape_element is None or xform_element is None:
print(f"Error: Required XML elements (<Shape> or <XForm>) not found in {template_path}")
return None
# Calculate new X and Y based on the known '1 0 0 1' matrix
# Note: Using SHORT_LINE_WIDTH as the X offset, Y is direct
calculated_x = int(target_x - SHORT_LINE_WIDTH)
calculated_y = int(target_y)
# Construct the XForm string directly, assuming '1 0 0 1' matrix
xform_element.text = f"1 0 0 1 {calculated_x} {calculated_y}"
print(f"Calculated XForm for dynamic line: {xform_element.text}")
# Create a temporary file to write the modified LBRN data
with tempfile.NamedTemporaryFile(mode='wb', suffix='.lbrn2', delete=False) as temp_file:
temp_file_path = temp_file.name
tree.write(temp_file.name, encoding="UTF-8", xml_declaration=True)
print(f"Modified LightBurn project saved to temporary file: {temp_file_path}")
return temp_file_path
except FileNotFoundError:
print(f"Error: Template file not found at {template_path}")
return None
except ET.ParseError as e:
print(f"Error parsing XML file {template_path}: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred during LBRN file creation: {e}")
return None
def execute_lightburn_job_sequence(sock_out, sock_in, filename_to_load, start_job=True):
"""
Executes a sequence for a LightBurn job: FORCELOAD, (optional) START,
and polls STATUS until completion.
Returns True if the job completes successfully, False otherwise.
"""
print(f"\n--- Processing Job: {filename_to_load} ---")
# Load the project
response = send_udp_command(sock_out, sock_in, f"FORCELOAD:{filename_to_load}")
if response != b'OK':
print(f"Failed to load '{filename_to_load}' (did not get 'OK').")
return False
if start_job:
# Start the job
response = send_udp_command(sock_out, sock_in, "START")
if response != b'OK':
print(f"Failed to start job for '{filename_to_load}' (did not get 'OK').")
return False
# Poll status until finished
print(f"Waiting for job '{filename_to_load}' to complete...")
job_done = False
start_poll_time = time.time()
# Max 60 seconds for job, adjust as needed for longer jobs
while not job_done and (time.time() - start_poll_time) < 60:
response = send_udp_command(sock_out, sock_in, "STATUS", timeout=2) # Shorter timeout for polling
if response == b'OK':
job_done = True
print(f"Job '{filename_to_load}' finished successfully.")
elif response == b'!':
print(f"LightBurn is busy with '{filename_to_load}' (job in progress), continuing to wait...")
time.sleep(2) # Wait before sending next STATUS
elif response is None:
print(f"No response from LightBurn for '{filename_to_load}' (possibly disconnected or crashed), retrying...")
time.sleep(2) # Wait before next attempt
else:
print(f"Unexpected STATUS response for '{filename_to_load}': {response}. Retrying...")
time.sleep(2) # Wait before next attempt
if not job_done:
print(f"Job '{filename_to_load}' did not finish within the allowed time or encountered an unrecoverable issue.")
return False
else:
print(f"Skipping START for job: {filename_to_load} as requested.")
return True
# --- Main Script Logic ---
if __name__ == "__main__":
# Updated usage message for the new optional arguments
if len(sys.argv) < 3 or len(sys.argv) > 5:
print("Usage: python simple_lightburn.py <X_coordinate> <Y_coordinate> [second_filename] [skip_second_start]")
print("Example 1: OffSetXY.py 100 50")
print("Example 2: OffSetXY.py 100 50 C:\\temp\\another_design.lbrn2")
print("Example 3: OffSetXY.py 100 50 C:\\temp\\another_design.lbrn2 skip_second_start")
sys.exit(1)
try:
x_coord = int(sys.argv[1])
y_coord = int(sys.argv[2])
second_filename_path = None
skip_second_start = False
if len(sys.argv) >= 4:
second_filename_path = sys.argv[3]
if not os.path.exists(second_filename_path):
print(f"Error: Second file '{second_filename_path}' not found. Please provide a valid path.")
sys.exit(1)
if len(sys.argv) == 5:
if sys.argv[4].lower() == "skip_second_start":
skip_second_start = True
else:
print("Invalid fourth argument. Use 'skip_second_start' if you want to skip starting the second job.")
sys.exit(1)
except ValueError:
print("Error: X and Y coordinates must be integers.")
sys.exit(1)
except Exception as e:
print(f"Error parsing arguments: {e}")
sys.exit(1)
print(f"Target coordinates for dynamic line: X={x_coord}, Y={y_coord}")
if second_filename_path:
print(f"Second file to load: {second_filename_path}")
if skip_second_start:
print("Option enabled: Will NOT automatically START the second job.")
out_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
temp_lbrn_file = None # Initialize outside try block for finally cleanup
try:
in_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Allows port reuse
in_sock.bind((UDP_IP, UDP_IN_PORT))
except Exception as e:
print(f"Error binding socket: {e}. Is LightBurn or another app using port {UDP_IN_PORT}?")
sys.exit(1)
try:
# 1. Check and Launch LightBurn if needed
print("Checking if LightBurn is running...")
response = send_udp_command(out_sock, in_sock, "PING", timeout=2)
if response != b'OK':
print("LightBurn not detected, attempting to launch...")
try:
subprocess.Popen([LIGHTBURN_EXE_PATH])
time.sleep(5) # Give LightBurn some time to start up
response = send_udp_command(out_sock, in_sock, "PING", timeout=5)
if response != b'OK':
print("Error: LightBurn did not respond after launch. Exiting.")
sys.exit(1)
except FileNotFoundError:
print(f"Error: LightBurn executable not found at '{LIGHTBURN_EXE_PATH}'. Please check path.")
sys.exit(1)
except Exception as e:
print(f"Error launching LightBurn: {e}")
sys.exit(1)
print("LightBurn is ready.")
# 2. Create and execute the first job (dynamic line)
temp_lbrn_file = create_dynamic_lightburn_project(OFFSETXY_TEMPLATE, x_coord, y_coord)
if temp_lbrn_file is None:
sys.exit(1)
if not execute_lightburn_job_sequence(out_sock, in_sock, temp_lbrn_file, start_job=True):
print("First job (dynamic line) failed. Exiting.")
sys.exit(1)
# 3. Handle the optional second job
if second_filename_path:
if not execute_lightburn_job_sequence(out_sock, in_sock, second_filename_path, start_job=not skip_second_start):
print("Second job failed. Exiting.")
sys.exit(1)
else:
print("Second job processed successfully.")
else:
print("\nNo second job specified. Script finished.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
finally:
out_sock.close()
in_sock.close()
print("Sockets closed.")
if temp_lbrn_file and os.path.exists(temp_lbrn_file):
try:
os.remove(temp_lbrn_file)
print(f"Cleaned up temporary file: {temp_lbrn_file}")
except Exception as e:
print(f"Error cleaning up temporary file {temp_lbrn_file}: {e}")
print("Script finished.")
And here are the accompanying files (which I had in C:\Temp):
OffsetXY_template.lbrn2 (795 Bytes)
CurrentPositon.lbrn2 (16.3 KB)