# ilda/frame.py """ ILDA frame (ILDA - image data transfer format for laser shows) """ import math import re from typing import List, Tuple, Dict, Any, Optional from xml.etree.ElementTree import Element, SubElement, ElementTree from svgpathtools import svg2paths2, Path from shapely.geometry import LineString, box import numpy as np from .utils import order_paths_greedy_with_2opt PointF = Tuple[float, float] PointI = Tuple[int, int, int] # x,y,z as signed 16-bit integers RGB = Tuple[int, int, int] ILDA_CANVAS = 65536 ILDA_HALF = ILDA_CANVAS // 2 # 32768 INT16_MIN = -32768 INT16_MAX = 32767 # Global/default palette (64 entries) as (index, (r,g,b)) DEFAULT_ilda_palette: List[Tuple[int, RGB]] = [ (0, (255, 0, 0)), (1, (255, 16, 0)), (2, (255, 32, 0)), (3, (255, 48, 0)), (4, (255, 64, 0)), (5, (255, 80, 0)), (6, (255, 96, 0)), (7, (255, 112, 0)), (8, (255, 128, 0)), (9, (255, 144, 0)), (10, (255, 160, 0)), (11, (255, 176, 0)), (12, (255, 192, 0)), (13, (255, 208, 0)), (14, (255, 224, 0)), (15, (255, 240, 0)), (16, (255, 255, 0)), (17, (224, 255, 0)), (18, (192, 255, 0)), (19, (160, 255, 0)), (20, (128, 255, 0)), (21, ( 96, 255, 0)), (22, ( 64, 255, 0)), (23, ( 32, 255, 0)), (24, ( 0, 255, 0)), (25, ( 0, 255, 36)), (26, ( 0, 255, 73)), (27, ( 0, 255, 109)), (28, ( 0, 255, 146)), (29, ( 0, 255, 182)), (30, ( 0, 255, 219)), (31, ( 0, 255, 255)), (32, ( 0, 227, 255)), (33, ( 0, 198, 255)), (34, ( 0, 170, 255)), (35, ( 0, 142, 255)), (36, ( 0, 113, 255)), (37, ( 0, 85, 255)), (38, ( 0, 56, 255)), (39, ( 0, 28, 255)), (40, ( 0, 0, 255)), (41, ( 32, 0, 255)), (42, ( 64, 0, 255)), (43, ( 96, 0, 255)), (44, (128, 0, 255)), (45, (160, 0, 255)), (46, (192, 0, 255)), (47, (224, 0, 255)), (48, (255, 0, 255)), (49, (255, 32, 255)), (50, (255, 64, 255)), (51, (255, 96, 255)), (52, (255, 128, 255)), (53, (255, 160, 255)), (54, (255, 192, 255)), (55, (255, 224, 255)), (56, (255, 255, 255)), (57, (255, 224, 224)), (58, (255, 192, 192)), (59, (255, 160, 160)), (60, (255, 128, 128)), (61, (255, 96, 96)), (62, (255, 64, 64)), (63, (255, 32, 32)) ] class Frame: # Public instance state: only normalized ILDA-format points and colors plus original svg size points_list: List[List[PointI]] # each path -> list of (x,y,z) int16 colors_rgb: List[RGB] # per-path colors as 8-bit rgb colors_indexed: List[int] # per-path palette index (0-255) svg_size: Tuple[float, float] # (width, height) of original SVG in user units (floats) point_cnt: int # total points of all paths point_cnt_simpl: int # total points of all paths simplified def __init__(self) -> None: self.points_list = [] self.colors_rgb = [] self.colors_indexed = [] self.svg_size = (0.0, 0.0) self.point_cnt = 0 self.point_cnt_simpl = 0 def read_svg(self, infile: str, simplify: bool = False, tol: float = 1.0, mintravel: bool = False) -> None: """ Read SVG paths and convert to internal normalized ILDA integer coordinates. Args: infile: SVG file path simplify: if True apply Ramer-Douglas-Peucker simplification with tolerance tol tol: tolerance for simplification (same units as SVG coordinates) mintravel: optional order polylines to minimize blank travel Result: self.points_list: list of paths; each path is list of (x,y,z) int16 with z=0 self.colors_rgb: per-path (r,g,b) tuples self.colors_indexed: per-path 8bit color index from DEFAULT_ilda_palette self.svg_size: (width, height) from svg attributes (floats) Notes: the viewbox passed to _path_to_points is (xmin, ymin, xmax, ymax) derived from svg size. """ # Helper: compare two polylines with tolerance def _poly_equal(a: List[Tuple[float, float]], b: List[Tuple[float, float]], eps: float = 1e-6) -> bool: if len(a) != len(b): return False for (x1, y1), (x2, y2) in zip(a, b): if abs(x1 - x2) > eps or abs(y1 - y2) > eps: return False return True # Helper: append one polyline (list of (x,y) floats) and its attributes to internal lists def _append_poly_and_attr(poly: List[Tuple[float, float]], attr: Dict[str, str]) -> None: # If poly too short, append empty placeholder and still record color info if not poly or len(poly) < 2: self.points_list.append([]) color = self._extract_stroke(attr) rgb = self._parse_color_to_rgb(color) self.colors_rgb.append(rgb) idx = self._find_best_palette_index(rgb) self.colors_indexed.append(idx) return # Simplify polyline using class RDP if requested if simplify: processed = self._rdp(poly, tol) else: processed = poly if not processed or len(processed) < 2: self.points_list.append([]) color = self._extract_stroke(attr) rgb = self._parse_color_to_rgb(color) self.colors_rgb.append(rgb) idx = self._find_best_palette_index(rgb) self.colors_indexed.append(idx) return # Normalize to ILDA coordinates (use svg_size width/height) width, height = self.svg_size ptsi = [self._normalize_point_to_ilda((x, y), width, height) for (x, y) in processed] ptsi3 = [(int(x), int(y), 0) for (x, y) in ptsi] self.points_list.append(ptsi3) # Color handling (duplicate original attr for this fragment) color = self._extract_stroke(attr) rgb = self._parse_color_to_rgb(color) self.colors_rgb.append(rgb) idx = self._find_best_palette_index(rgb) self.colors_indexed.append(idx) # Update simplified point counter self.point_cnt_simpl += len(processed) # ---- main body ---- paths, attribs, svg_attrib = svg2paths2(infile) width, height = self._extract_svg_size(svg_attrib) self.svg_size = (width, height) # Reset internal lists and counters self.points_list = [] self.colors_rgb = [] self.colors_indexed = [] sample_step = max(0.5, tol / 3.0) self.point_cnt = 0 self.point_cnt_simpl = 0 # Collect all polylines (after sampling+clipping) and their attributes all_polylines: List[List[Tuple[float, float]]] = [] all_attrs: List[Dict[str, str]] = [] for i, path_obj in enumerate(paths): # Pass viewbox as (xmin, ymin, xmax, ymax) to _path_to_points xmin, ymin = 0.0, 0.0 xmax, ymax = width, height ptsf_parts = self._path_to_points(path_obj, sample_step, viewbox=(xmin, ymin, xmax, ymax)) # ptsf_parts is list of polylines (each polyline is list of (x,y)) self.point_cnt += sum(len(row) for row in ptsf_parts) attr = attribs[i] if i < len(attribs) else {} for part in ptsf_parts: all_polylines.append(part) all_attrs.append(attr) # If nothing found, keep behavior: print and return if not all_polylines: print(f"Processing file: {infile} Points: {self.point_cnt} Points (simplified): {self.point_cnt_simpl}") return # optional order polylines to minimize blank travel if mintravel: ordered_result = order_paths_greedy_with_2opt(all_polylines, start_point=None, do_2opt=True) ordered_polylines = ordered_result.paths else: ordered_polylines = list(all_polylines) # Map ordered polylines back to original attributes by matching geometry (or reversed) used_indices = set() for poly in ordered_polylines: matched_idx = None matched_attr: Dict[str, str] = {} for j, orig in enumerate(all_polylines): if j in used_indices: continue if _poly_equal(orig, poly) or _poly_equal(list(reversed(orig)), poly): matched_idx = j matched_attr = all_attrs[j] used_indices.add(j) break if matched_idx is None: # If no exact match found, use empty attr (rare) matched_attr = {} _append_poly_and_attr(poly, matched_attr) print(f"Processing file: {infile} Points: {self.point_cnt} Points (simplified): {self.point_cnt_simpl}") def get_ilda(self, format_code: int = 0, frame_name: str = '00000001', company_name: str = 'MIKLOBIT', frame_number: int = 0, total_frames: int = 1, projector: int = 0) -> bytes: """ Prepare ILDA binary data for this frame: header (32B) + records. - Does not append EOF header (NumberOfRecords = 0) — this is left to the Animation class. - If the frame does not contain points, it raises a ValueError (to avoid accidentally inserting EOF in the middle of an animation). Params: format_code: ILDA format (only 0 implemented) frame_name, company_name: ASCII, up to 8 characters (will be truncated/padded) frame_number, total_frames, projector: header fields Returns: bytes with header + records (without EOF) """ if format_code != 0: raise NotImplementedError("Only Format 0 (3D Indexed) is implemented") # helper creating a 32-byte header def _make_header(fmt: int, name: str, company: str, num_records: int, frame_number: int = 0, total_frames: int = 1, projector: int = 0) -> bytes: b = bytearray(32) b[0:4] = b'ILDA' b[7] = fmt & 0xFF name_enc = name.encode('ascii', errors='ignore')[:8] b[8:8+len(name_enc)] = name_enc comp_enc = company.encode('ascii', errors='ignore')[:8] b[16:16+len(comp_enc)] = comp_enc b[24:26] = num_records.to_bytes(2, byteorder='big', signed=False) b[26:28] = frame_number.to_bytes(2, byteorder='big', signed=False) b[28:30] = total_frames.to_bytes(2, byteorder='big', signed=False) b[30] = projector & 0xFF return bytes(b) # total number of records (each record = 8 bytes) total_points = 0 for path in self.points_list: total_points += max(0, len(path)) if total_points == 0: # Note: a header with num_records=0 is treated as EOF — we do not want to include this # in the middle of the animation; we return an error so that Animation can skip this frame. raise ValueError("Frame contains no points; get_ilda() would produce EOF header") records = bytearray() # determine the index of the last point in this frame -> set the LastPoint bit at the last point of this section last_path_idx = -1 last_point_idx = -1 for pi, path in enumerate(self.points_list): if path: last_path_idx = pi last_point_idx = len(path) - 1 for pi, path in enumerate(self.points_list): if not path: continue # first point as blanking (duplicate of first point) with Blanking bit set fx, fy, fz = path[0] status = 0 status |= (1 << 6) # Blanking bit ci = 0 if pi < len(self.colors_indexed): ci = int(self.colors_indexed[pi]) & 0xFF records += int(fx).to_bytes(2, byteorder='big', signed=True) records += int(fy).to_bytes(2, byteorder='big', signed=True) records += int(fz).to_bytes(2, byteorder='big', signed=True) records += bytes([status, ci]) # subsequent points (draw), set LastPoint to the last point of this frame/section for pti, (x, y, z) in enumerate(path): status = 0 if (pi == last_path_idx) and (pti == last_point_idx): status |= (1 << 7) # LastPoint bit ci = 0 if pi < len(self.colors_indexed): ci = int(self.colors_indexed[pi]) & 0xFF records += int(x).to_bytes(2, byteorder='big', signed=True) records += int(y).to_bytes(2, byteorder='big', signed=True) records += int(z).to_bytes(2, byteorder='big', signed=True) records += bytes([status, ci]) header = _make_header(format_code, frame_name, company_name, len(records) // 8, frame_number=frame_number, total_frames=total_frames, projector=projector) return header + bytes(records) def write_ild(self, filename: str, format_code: int = 0, frame_name: str = '00000001', company_name: str = 'MIKLO', frame_number: int = 0, total_frames: int = 1, projector: int = 0) -> None: """ Save a single ILDA file for this frame (including the EOF header). The implementation uses get_ilda() to generate the frame section and then adds the EOF header (NumberOfRecords = 0). """ # Get binary for (header + records) section = self.get_ilda(format_code=format_code, frame_name=frame_name, company_name=company_name, frame_number=frame_number, total_frames=total_frames, projector=projector) # Create EOF header (format_code, 0 records) def _make_eof_header(fmt: int, name: str, company: str, frame_number: int = 0, total_frames: int = 0, projector: int = 0) -> bytes: b = bytearray(32) b[0:4] = b'ILDA' b[7] = fmt & 0xFF name_enc = name.encode('ascii', errors='ignore')[:8] b[8:8+len(name_enc)] = name_enc comp_enc = company.encode('ascii', errors='ignore')[:8] b[16:16+len(comp_enc)] = comp_enc b[24:26] = (0).to_bytes(2, byteorder='big', signed=False) # NumberOfRecords = 0 -> EOF b[26:28] = frame_number.to_bytes(2, byteorder='big', signed=False) b[28:30] = total_frames.to_bytes(2, byteorder='big', signed=False) b[30] = projector & 0xFF return bytes(b) eof_header = _make_eof_header(format_code, frame_name, company_name, frame_number=0, total_frames=0, projector=projector) with open(filename, 'wb') as f: f.write(section) f.write(eof_header) def write_svg(self, outfile: str, canvas_size: Optional[Tuple[float, float]] = None) -> None: """ Build an SVG with paths from internal ILDA-normalized integer coordinates. Args: outfile: output SVG filename canvas_size: optional (width, height) in target SVG units. If None, uses ILDA canvas size (65536,65536). If provided, ILDA coordinates are scaled to fit this canvas preserving the ILDA aspect. """ if canvas_size is None: target_w, target_h = (ILDA_CANVAS, ILDA_CANVAS) else: target_w, target_h = canvas_size svg_attrs = { 'xmlns': 'http://www.w3.org/2000/svg', 'width': str(target_w), 'height': str(target_h), 'viewBox': f"0 0 {target_w} {target_h}" } root = Element('svg', svg_attrs) scale_x = target_w / float(ILDA_CANVAS) scale_y = target_h / float(ILDA_CANVAS) for idx, pts in enumerate(self.points_list): if not pts: continue ptsf = [] for (xi, yi, zi) in pts: nx = (xi / ILDA_CANVAS) + 0.5 ny = 0.5 - (yi / ILDA_CANVAS) fx = nx * ILDA_CANVAS * scale_x fy = ny * ILDA_CANVAS * scale_y ptsf.append((float(fx), float(fy))) closed = False d = self._points_to_path_d(ptsf, closed) el = SubElement(root, 'path') el.set('d', d) rgb = self.colors_rgb[idx] if idx < len(self.colors_rgb) else (0, 0, 0) el.set('stroke', f"rgb({rgb[0]},{rgb[1]},{rgb[2]})") el.set('fill', 'none') el.set('stroke-width', '1.0') tree = ElementTree(root) tree.write(outfile, encoding='utf-8', xml_declaration=True) def _extract_svg_size(self, svg_attrib: Dict[str, str]) -> Tuple[float, float]: vb = svg_attrib.get('viewBox') or svg_attrib.get('viewbox') if vb: parts = vb.strip().replace(',', ' ').split() if len(parts) >= 4: try: w = float(parts[2]) h = float(parts[3]) if w > 0 and h > 0: return (w, h) except Exception: pass def parse_dim(v: Optional[str]) -> Optional[float]: if not v: return None m = re.match(r'([0-9.+-eE]+)', v.strip()) if m: try: return float(m.group(1)) except Exception: return None return None w = parse_dim(svg_attrib.get('width')) h = parse_dim(svg_attrib.get('height')) if w and h: return (w, h) return (ILDA_CANVAS, ILDA_CANVAS) def _extract_stroke(self, attr: Dict[str, str]) -> str: if 'stroke' in attr and attr['stroke'].strip(): return attr['stroke'].strip() style = attr.get('style', '') for part in style.split(';'): if not part: continue if ':' not in part: continue k, v = part.split(':', 1) if k.strip() == 'stroke' and v.strip(): return v.strip() return '' def _parse_color_to_rgb(self, color_str: str) -> RGB: if not color_str: return (0, 0, 0) s = color_str.strip() if s.startswith('#'): s2 = s[1:] if len(s2) == 6: try: r = int(s2[0:2], 16) g = int(s2[2:4], 16) b = int(s2[4:6], 16) return (r, g, b) except Exception: return (0, 0, 0) if len(s2) == 3: try: r = int(s2[0]*2, 16) g = int(s2[1]*2, 16) b = int(s2[2]*2, 16) return (r, g, b) except Exception: return (0, 0, 0) m = re.match(r'rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)', s, re.IGNORECASE) if m: try: r = max(0, min(255, int(m.group(1)))) g = max(0, min(255, int(m.group(2)))) b = max(0, min(255, int(m.group(3)))) return (r, g, b) except Exception: return (0, 0, 0) named = { 'black': (0,0,0), 'white': (255,255,255), 'red': (255,0,0), 'green': (0,128,0), 'blue': (0,0,255) } lc = s.lower() if lc in named: return named[lc] return (0, 0, 0) def _path_to_points(self, path: Path, max_segment_length: float, viewbox: Optional[Tuple[float, float, float, float]] = None) -> List[List[PointF]]: """ Sample an svgpathtools Path into one or more polylines (list of point lists). - If viewbox is provided as (xmin, ymin, width, height) or (xmin,ymin,xmax,ymax), the resulting single polyline is clipped to the rectangle and may produce multiple pieces. - Returns list of polylines (each polyline is a list of (x,y) floats). """ # existing sampling logic (produce single polyline) pts: List[PointF] = [] for seg in path: seg_pts = self._sample_segment(seg, max_segment_length) if not seg_pts: continue if not pts: pts.extend(seg_pts) else: if abs(pts[-1][0] - seg_pts[0][0]) < 1e-9 and abs(pts[-1][1] - seg_pts[0][1]) < 1e-9: pts.extend(seg_pts[1:]) else: pts.extend(seg_pts) # ensure final endpoint included if len(path) > 0: try: endc = path[-1].point(1.0) endpt = (endc.real, endc.imag) if not pts or (abs(pts[-1][0] - endpt[0]) > 1e-9 or abs(pts[-1][1] - endpt[1]) > 1e-9): pts.append(endpt) except Exception: pass # If no clipping requested, return single polyline as single-item list (or empty list if too short) if not viewbox: return [pts] if len(pts) >= 2 else [] # Convert to (xmin,ymin,xmax,ymax) exactly (read_svg should provide this form) xmin, ymin, xmax, ymax = viewbox if len(pts) < 2: return [] pieces = self._clip_polyline_to_rect(pts, (xmin, ymin, xmax, ymax)) return pieces def _sample_segment(self, seg: Any, max_segment_length: float) -> List[PointF]: try: seg_len = seg.length(error=1e-5) except Exception: bbox = seg.bbox() dx = bbox[2] - bbox[0] dy = bbox[3] - bbox[1] seg_len = math.hypot(dx, dy) if seg_len == 0: return [] n = max(1, int(math.ceil(seg_len / max_segment_length))) pts: List[PointF] = [] for i in range(n): t = i / n c = seg.point(t) pts.append((c.real, c.imag)) return pts def _rdp(self, points: List[PointF], eps: float) -> List[PointF]: if len(points) < 3: return points[:] start = np.array(points[0]) end = np.array(points[-1]) line_vec = end - start line_len_sq = np.dot(line_vec, line_vec) if line_len_sq == 0.0: dists = [np.linalg.norm(np.array(p) - start) for p in points] else: def point_line_distance(pt: PointF) -> float: v = np.array(pt) - start t = np.dot(v, line_vec) / line_len_sq t = max(0.0, min(1.0, t)) proj = start + t * line_vec return float(np.linalg.norm(np.array(pt) - proj)) dists = [point_line_distance(p) for p in points] idx = int(np.argmax(dists)) dmax = dists[idx] if dmax > eps: left = self._rdp(points[:idx+1], eps) right = self._rdp(points[idx:], eps) return left[:-1] + right else: return [points[0], points[-1]] def _normalize_point_to_ilda(self, p: PointF, svg_w: float, svg_h: float) -> Tuple[int, int]: x, y = p if svg_w <= 0 or svg_h <= 0: svg_w, svg_h = (ILDA_CANVAS, ILDA_CANVAS) nx = x / svg_w ny = y / svg_h ix = int(round((nx - 0.5) * ILDA_CANVAS)) iy = int(round((0.5 - ny) * ILDA_CANVAS)) ix = max(INT16_MIN, min(INT16_MAX, ix)) iy = max(INT16_MIN, min(INT16_MAX, iy)) return (ix, iy) def _ilda_to_canvas_float(self, p3: PointI) -> Tuple[float, float]: x_int, y_int, _ = p3 nx = (x_int / ILDA_CANVAS) + 0.5 ny = 0.5 - (y_int / ILDA_CANVAS) fx = nx * ILDA_CANVAS fy = ny * ILDA_CANVAS return (float(fx), float(fy)) def _points_to_path_d(self, points: List[Tuple[float, float]], closed: bool) -> str: if not points: return "" parts: List[str] = [] x0, y0 = points[0] parts.append(f"M {x0:.6f} {y0:.6f}") for (x, y) in points[1:]: parts.append(f"L {x:.6f} {y:.6f}") if closed: parts.append("Z") return " ".join(parts) def _find_best_palette_index(self, rgb: RGB) -> int: # If no default palette provided, return 0 if not DEFAULT_ilda_palette: return 0 r0, g0, b0 = rgb best_idx = DEFAULT_ilda_palette[0][0] best_dist = None for idx, (pr, pg, pb) in DEFAULT_ilda_palette: dr = abs(pr - r0) dg = abs(pg - g0) db = abs(pb - b0) dist = dr*dr + dg*dg + db*db if best_dist is None or dist < best_dist: best_dist = dist best_idx = idx # Ensure 0-255 range return max(0, min(255, int(best_idx))) def _clip_polyline_to_rect(self, poly: List[PointF], rect: Tuple[float, float, float, float], eps: float = 1e-9) -> List[List[PointF]]: """ Clip a polyline to axis-aligned rectangle. - poly: list of (x,y) floats representing the sampled polyline - rect: (xmin, ymin, xmax, ymax) - Returns list of polylines (each a list of (x,y)) that lie inside the rect. - Uses Shapely """ xmin, ymin, xmax, ymax = rect if not poly or xmin >= xmax or ymin >= ymax: return [] if LineString is not None: # robust, uses geometric intersection ls = LineString(poly) clip = box(xmin, ymin, xmax, ymax) inter = clip.intersection(ls) parts: List[List[PointF]] = [] # inter can be LineString or MultiLineString or GeometryCollection def _extract_lines(geom): if geom.is_empty: return geom_type = geom.geom_type if geom_type == 'LineString': coords = list(geom.coords) if len(coords) >= 2: parts.append([(float(x), float(y)) for x, y in coords]) elif geom_type == 'MultiLineString': for g in geom.geoms: _extract_lines(g) # ignore points etc. _extract_lines(inter) return parts