# ilda/animation.py from pathlib import Path from typing import List, Tuple, Optional from .frame import Frame class Animation: """ A class representing an ILDA animation consisting of multiple frames (SVG files). It stores the default ILDA format (e.g. 0), company_name, and projector, which are used when creating sections for each frame. """ def __init__(self, ilda_format: int = 0, company_name: str = "MIKLOBIT", projector: int = 0) -> None: self.ilda_format: int = ilda_format self.company_name: str = company_name self.projector: int = projector self.frames: List[Frame] = [] self.frame_names: List[str] = [] def set_ilda_format(self, fmt: int) -> None: self.ilda_format = fmt def set_company_name(self, name: str) -> None: self.company_name = name def set_projector(self, projector: int) -> None: self.projector = projector def create_from_file(self, svg_path: str, simplify: bool = False, tol: float = 1.0, clear_existing: bool = True) -> None: """ Load a single SVG file and add a Frame object to the animation. The frame name is the file name (stem) truncated to 8 characters. The simplify and tol parameters are passed to Frame.read_svg. """ p = Path(svg_path) if clear_existing: self.frames = [] self.frame_names = [] if not p.exists(): raise FileNotFoundError(f"SVG file not found: {svg_path}") frame = Frame() frame.read_svg(str(p), simplify=simplify, tol=tol) name8 = p.stem[:8] self.frames.append(frame) self.frame_names.append(name8) def create_from_folder(self, folder_path: str, simplify: bool = False, tol: float = 1.0, recursive: bool = False, clear_existing: bool = True) -> None: """ Load all .svg files from the folder (alphabetically) and create Frame objects. If recursive=True, it searches subdirectories. """ p = Path(folder_path) if not p.exists() or not p.is_dir(): raise NotADirectoryError(f"Folder not found: {folder_path}") if clear_existing: self.frames = [] self.frame_names = [] if recursive: glob_iter = p.rglob("*.svg") else: glob_iter = p.glob("*.svg") files = sorted(list(glob_iter), key=lambda x: x.name.lower()) for fp in files: try: self.create_from_file(str(fp), simplify=simplify, tol=tol, clear_existing=False) except Exception as e: print(f"Warning: failed to read SVG {fp}: {e}") def _make_eof_header(self) -> bytes: """ Returns a 32-byte ILDA header indicating EOF (NumberOfRecords = 0). Uses the ilda_format/company_name/projector settings from the instance. """ b = bytearray(32) b[0:4] = b'ILDA' b[7] = int(self.ilda_format) & 0xFF name_enc = self.company_name.encode('ascii', errors='ignore')[:8] b[16:16+len(name_enc)] = name_enc # company name field (optional) b[24:26] = (0).to_bytes(2, byteorder='big', signed=False) # NumberOfRecords = 0 -> EOF b[30] = int(self.projector) & 0xFF return bytes(b) def write_ild(self, out_path: str, overwrite: bool = True) -> None: """ Save the compiled .ilda file: concatenate all sections obtained from Frame.get_ilda and add a single EOF header. - Skip frames for which get_ilda() will throw a ValueError (e.g., no points). - FrameNumber and TotalFrames are set automatically. """ if not self.frames: raise RuntimeError("No frames to write. Use create_from_* first.") outp = Path(out_path) if outp.exists() and not overwrite: raise FileExistsError(f"File exists: {out_path}") total_frames = len(self.frames) bin_stream = bytearray() for idx, frame in enumerate(self.frames): frame_name = self.frame_names[idx] if idx < len(self.frame_names) else f"{idx:08d}" try: section = frame.get_ilda(format_code=self.ilda_format, frame_name=frame_name, company_name=self.company_name, frame_number=idx, total_frames=total_frames, projector=self.projector) except ValueError as ve: # return ValueError if frame empty (--> header of 0 records = EOF) print(f"Info: skipping empty frame '{frame_name}': {ve}") continue bin_stream += section bin_stream += self._make_eof_header() with open(outp, 'wb') as f: f.write(bin_stream)