636 lines
26 KiB
Python
636 lines
26 KiB
Python
# 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
|