Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
938 lines
32 KiB
Python
938 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
Point = tuple[float, float]
|
|
Matrix = tuple[float, float, float, float, float, float]
|
|
Segment = tuple[int, float, float, float, float, float, float, float, float]
|
|
|
|
SVG_NS_RE = re.compile(r"\{[^}]+\}")
|
|
PATH_TOKEN_RE = re.compile(
|
|
r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?"
|
|
)
|
|
TRANSFORM_RE = re.compile(r"([a-zA-Z]+)\(([^)]*)\)")
|
|
NUMBER_RE = re.compile(r"[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?")
|
|
|
|
PATH_PARAM_COUNTS = {
|
|
"a": 7,
|
|
"c": 6,
|
|
"h": 1,
|
|
"l": 2,
|
|
"m": 2,
|
|
"q": 4,
|
|
"s": 4,
|
|
"t": 2,
|
|
"v": 1,
|
|
}
|
|
|
|
DPI = 96
|
|
MM_PER_INCH = 25.4
|
|
IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SvgLineSpacingResult:
|
|
percentile: float
|
|
spacingPx: float
|
|
spacingMm: float
|
|
minSpacingPx: float
|
|
minSpacingMm: float
|
|
sampleStep: float
|
|
curveCount: int
|
|
segmentCount: int
|
|
nearestCount: int
|
|
sourceWidth: float
|
|
sourceHeight: float
|
|
elementWidth: float
|
|
elementHeight: float
|
|
computedAt: str
|
|
closestPoints: dict[str, float] | None = None
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"percentile": self.percentile,
|
|
"spacingPx": self.spacingPx,
|
|
"spacingMm": self.spacingMm,
|
|
"minSpacingPx": self.minSpacingPx,
|
|
"minSpacingMm": self.minSpacingMm,
|
|
"sampleStep": self.sampleStep,
|
|
"curveCount": self.curveCount,
|
|
"segmentCount": self.segmentCount,
|
|
"nearestCount": self.nearestCount,
|
|
"sourceWidth": self.sourceWidth,
|
|
"sourceHeight": self.sourceHeight,
|
|
"elementWidth": self.elementWidth,
|
|
"elementHeight": self.elementHeight,
|
|
"computedAt": self.computedAt,
|
|
"closestPoints": self.closestPoints,
|
|
}
|
|
|
|
|
|
def analyze_svg_line_spacing_file(
|
|
svg_path: Path,
|
|
*,
|
|
percentile: float,
|
|
element_width: float,
|
|
element_height: float,
|
|
sample_step: float = 2.0,
|
|
) -> SvgLineSpacingResult:
|
|
svg_text = svg_path.read_text(encoding="utf-8", errors="replace")
|
|
return analyze_svg_line_spacing(
|
|
svg_text,
|
|
percentile=percentile,
|
|
element_width=element_width,
|
|
element_height=element_height,
|
|
sample_step=sample_step,
|
|
)
|
|
|
|
|
|
def analyze_svg_line_spacing(
|
|
svg_text: str,
|
|
*,
|
|
percentile: float,
|
|
element_width: float,
|
|
element_height: float,
|
|
sample_step: float = 2.0,
|
|
) -> SvgLineSpacingResult:
|
|
if element_width <= 0 or element_height <= 0:
|
|
raise ValueError("element size must be positive")
|
|
|
|
percentile = max(0.0, min(100.0, float(percentile)))
|
|
sample_step = max(0.5, float(sample_step or 2.0))
|
|
|
|
try:
|
|
root = ET.fromstring(svg_text)
|
|
except ET.ParseError as exc:
|
|
raise ValueError("无法解析 SVG") from exc
|
|
|
|
source_width, source_height = read_svg_size(root)
|
|
segments, curve_count = collect_segments(root, sample_step)
|
|
if len(segments) < 2:
|
|
raise ValueError("SVG 中可分析的轮廓线太少")
|
|
|
|
nearest_distances, min_distance, closest_points = compute_nearest_spacing(
|
|
segments,
|
|
max(8.0, sample_step * 8.0),
|
|
)
|
|
if not nearest_distances or not math.isfinite(min_distance):
|
|
raise ValueError("没有找到可比较的不同轮廓曲线")
|
|
|
|
nearest_distances.sort()
|
|
percentile_distance = percentile_value(nearest_distances, percentile)
|
|
scale = min(element_width / source_width, element_height / source_height)
|
|
spacing_px = percentile_distance * scale
|
|
min_spacing_px = min_distance * scale
|
|
|
|
return SvgLineSpacingResult(
|
|
percentile=percentile,
|
|
spacingPx=spacing_px,
|
|
spacingMm=px_to_mm(spacing_px),
|
|
minSpacingPx=min_spacing_px,
|
|
minSpacingMm=px_to_mm(min_spacing_px),
|
|
sampleStep=sample_step,
|
|
curveCount=curve_count,
|
|
segmentCount=len(segments),
|
|
nearestCount=len(nearest_distances),
|
|
sourceWidth=source_width,
|
|
sourceHeight=source_height,
|
|
elementWidth=element_width,
|
|
elementHeight=element_height,
|
|
computedAt=datetime.now(timezone.utc).isoformat(),
|
|
closestPoints=scale_closest_points(closest_points, scale),
|
|
)
|
|
|
|
|
|
def read_svg_size(root: ET.Element) -> tuple[float, float]:
|
|
view_box = root.attrib.get("viewBox") or root.attrib.get("viewbox")
|
|
if view_box:
|
|
values = [parse_float(item) for item in re.split(r"[\s,]+", view_box.strip()) if item]
|
|
if len(values) >= 4 and all(math.isfinite(v) for v in values[:4]) and values[2] > 0 and values[3] > 0:
|
|
return values[2], values[3]
|
|
|
|
width = parse_svg_length(root.attrib.get("width"))
|
|
height = parse_svg_length(root.attrib.get("height"))
|
|
if width > 0 and height > 0:
|
|
return width, height
|
|
return 1000.0, 1000.0
|
|
|
|
|
|
def collect_segments(root: ET.Element, sample_step: float) -> tuple[list[Segment], int]:
|
|
segments: list[Segment] = []
|
|
next_curve_id = 0
|
|
|
|
def visit(node: ET.Element, matrix: Matrix, hidden: bool) -> None:
|
|
nonlocal next_curve_id
|
|
hidden = hidden or element_hidden(node)
|
|
node_matrix = multiply_matrix(matrix, parse_transform(node.attrib.get("transform", "")))
|
|
tag = strip_ns(node.tag)
|
|
|
|
if not hidden and drawable_geometry(node):
|
|
if tag == "path":
|
|
next_curve_id = append_path_segments(
|
|
node.attrib.get("d", ""),
|
|
next_curve_id,
|
|
sample_step,
|
|
node_matrix,
|
|
segments,
|
|
)
|
|
elif tag == "line":
|
|
next_curve_id = append_line_element(node, next_curve_id, sample_step, node_matrix, segments)
|
|
elif tag in {"polyline", "polygon"}:
|
|
next_curve_id = append_poly_element(node, next_curve_id, sample_step, node_matrix, segments, tag == "polygon")
|
|
elif tag == "rect":
|
|
next_curve_id = append_rect_element(node, next_curve_id, sample_step, node_matrix, segments)
|
|
elif tag in {"circle", "ellipse"}:
|
|
next_curve_id = append_ellipse_element(node, next_curve_id, sample_step, node_matrix, segments, tag)
|
|
|
|
if tag not in {"defs", "clipPath", "mask", "pattern", "symbol"}:
|
|
for child in list(node):
|
|
visit(child, node_matrix, hidden)
|
|
|
|
visit(root, IDENTITY, False)
|
|
return segments, next_curve_id
|
|
|
|
|
|
def strip_ns(tag: str) -> str:
|
|
return SVG_NS_RE.sub("", tag)
|
|
|
|
|
|
def element_hidden(node: ET.Element) -> bool:
|
|
style = parse_style(node.attrib.get("style", ""))
|
|
display = (node.attrib.get("display") or style.get("display") or "").strip().lower()
|
|
visibility = (node.attrib.get("visibility") or style.get("visibility") or "").strip().lower()
|
|
return display == "none" or visibility == "hidden"
|
|
|
|
|
|
def drawable_geometry(node: ET.Element) -> bool:
|
|
tag = strip_ns(node.tag)
|
|
if tag not in {"path", "line", "polyline", "polygon", "rect", "circle", "ellipse"}:
|
|
return False
|
|
|
|
style = parse_style(node.attrib.get("style", ""))
|
|
stroke = (node.attrib.get("stroke") or style.get("stroke") or "").strip().lower()
|
|
fill = (node.attrib.get("fill") or style.get("fill") or "").strip().lower()
|
|
if stroke and stroke not in {"none", "transparent"}:
|
|
return True
|
|
if not fill or fill not in {"none", "transparent"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def parse_style(raw: str) -> dict[str, str]:
|
|
result: dict[str, str] = {}
|
|
for part in raw.split(";"):
|
|
if ":" not in part:
|
|
continue
|
|
key, value = part.split(":", 1)
|
|
result[key.strip().lower()] = value.strip()
|
|
return result
|
|
|
|
|
|
def append_path_segments(
|
|
path_data: str,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
) -> int:
|
|
tokens = tokenize_path(path_data)
|
|
if not tokens:
|
|
return curve_id
|
|
|
|
index = 0
|
|
command = ""
|
|
current: Point = (0.0, 0.0)
|
|
subpath_start: Point = (0.0, 0.0)
|
|
active_curve_id = curve_id - 1
|
|
last_cubic_control: Point | None = None
|
|
last_quad_control: Point | None = None
|
|
previous_command = ""
|
|
|
|
while index < len(tokens):
|
|
token = tokens[index]
|
|
if isinstance(token, str):
|
|
command = token
|
|
index += 1
|
|
if not command:
|
|
break
|
|
|
|
lower = command.lower()
|
|
if lower == "z":
|
|
if active_curve_id >= curve_id:
|
|
append_sampled_line(current, subpath_start, active_curve_id, sample_step, matrix, output)
|
|
current = subpath_start
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
previous_command = command
|
|
command = ""
|
|
continue
|
|
|
|
param_count = PATH_PARAM_COUNTS.get(lower)
|
|
if not param_count:
|
|
break
|
|
|
|
first_move = lower == "m"
|
|
while has_number_run(tokens, index, param_count):
|
|
values = [float(tokens[index + offset]) for offset in range(param_count)]
|
|
index += param_count
|
|
|
|
if lower == "m":
|
|
point = absolute_point(command, current, values[0], values[1])
|
|
if first_move:
|
|
active_curve_id = curve_id
|
|
curve_id += 1
|
|
subpath_start = point
|
|
current = point
|
|
first_move = False
|
|
else:
|
|
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
elif lower == "l":
|
|
point = absolute_point(command, current, values[0], values[1])
|
|
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
elif lower == "h":
|
|
x = current[0] + values[0] if command.islower() else values[0]
|
|
point = (x, current[1])
|
|
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
elif lower == "v":
|
|
y = current[1] + values[0] if command.islower() else values[0]
|
|
point = (current[0], y)
|
|
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
elif lower == "c":
|
|
p1 = absolute_point(command, current, values[0], values[1])
|
|
p2 = absolute_point(command, current, values[2], values[3])
|
|
point = absolute_point(command, current, values[4], values[5])
|
|
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = p2
|
|
last_quad_control = None
|
|
elif lower == "s":
|
|
p1 = reflect_point(current, last_cubic_control) if previous_command.lower() in {"c", "s"} else current
|
|
p2 = absolute_point(command, current, values[0], values[1])
|
|
point = absolute_point(command, current, values[2], values[3])
|
|
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = p2
|
|
last_quad_control = None
|
|
elif lower == "q":
|
|
p1 = absolute_point(command, current, values[0], values[1])
|
|
point = absolute_point(command, current, values[2], values[3])
|
|
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_quad_control = p1
|
|
last_cubic_control = None
|
|
elif lower == "t":
|
|
p1 = reflect_point(current, last_quad_control) if previous_command.lower() in {"q", "t"} else current
|
|
point = absolute_point(command, current, values[0], values[1])
|
|
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_quad_control = p1
|
|
last_cubic_control = None
|
|
elif lower == "a":
|
|
rx, ry, angle, large_arc, sweep, x, y = values
|
|
point = absolute_point(command, current, x, y)
|
|
append_arc(current, rx, ry, angle, large_arc, sweep, point, active_curve_id, sample_step, matrix, output)
|
|
current = point
|
|
last_cubic_control = None
|
|
last_quad_control = None
|
|
|
|
previous_command = command
|
|
if index < len(tokens) and isinstance(tokens[index], str):
|
|
break
|
|
|
|
return curve_id
|
|
|
|
|
|
def tokenize_path(path_data: str) -> list[str | float]:
|
|
tokens: list[str | float] = []
|
|
for match in PATH_TOKEN_RE.finditer(path_data):
|
|
raw = match.group(0)
|
|
if re.fullmatch(r"[AaCcHhLlMmQqSsTtVvZz]", raw):
|
|
tokens.append(raw)
|
|
else:
|
|
tokens.append(float(raw))
|
|
return tokens
|
|
|
|
|
|
def has_number_run(tokens: list[str | float], index: int, count: int) -> bool:
|
|
if index + count > len(tokens):
|
|
return False
|
|
return all(not isinstance(tokens[index + offset], str) for offset in range(count))
|
|
|
|
|
|
def absolute_point(command: str, current: Point, x: float, y: float) -> Point:
|
|
if command.islower():
|
|
return current[0] + x, current[1] + y
|
|
return x, y
|
|
|
|
|
|
def reflect_point(origin: Point, point: Point | None) -> Point:
|
|
if point is None:
|
|
return origin
|
|
return 2 * origin[0] - point[0], 2 * origin[1] - point[1]
|
|
|
|
|
|
def append_line_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
|
|
p1 = (parse_svg_length(node.attrib.get("x1")), parse_svg_length(node.attrib.get("y1")))
|
|
p2 = (parse_svg_length(node.attrib.get("x2")), parse_svg_length(node.attrib.get("y2")))
|
|
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
|
return curve_id + 1
|
|
|
|
|
|
def append_poly_element(
|
|
node: ET.Element,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
close: bool,
|
|
) -> int:
|
|
points = parse_points(node.attrib.get("points", ""))
|
|
if len(points) < 2:
|
|
return curve_id
|
|
for p1, p2 in zip(points, points[1:]):
|
|
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
|
if close:
|
|
append_sampled_line(points[-1], points[0], curve_id, sample_step, matrix, output)
|
|
return curve_id + 1
|
|
|
|
|
|
def append_rect_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
|
|
x = parse_svg_length(node.attrib.get("x"))
|
|
y = parse_svg_length(node.attrib.get("y"))
|
|
width = parse_svg_length(node.attrib.get("width"))
|
|
height = parse_svg_length(node.attrib.get("height"))
|
|
if width <= 0 or height <= 0:
|
|
return curve_id
|
|
points = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)]
|
|
for p1, p2 in zip(points, points[1:] + points[:1]):
|
|
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
|
return curve_id + 1
|
|
|
|
|
|
def append_ellipse_element(
|
|
node: ET.Element,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
tag: str,
|
|
) -> int:
|
|
if tag == "circle":
|
|
cx = parse_svg_length(node.attrib.get("cx"))
|
|
cy = parse_svg_length(node.attrib.get("cy"))
|
|
rx = ry = parse_svg_length(node.attrib.get("r"))
|
|
else:
|
|
cx = parse_svg_length(node.attrib.get("cx"))
|
|
cy = parse_svg_length(node.attrib.get("cy"))
|
|
rx = parse_svg_length(node.attrib.get("rx"))
|
|
ry = parse_svg_length(node.attrib.get("ry"))
|
|
if rx <= 0 or ry <= 0:
|
|
return curve_id
|
|
circumference = math.pi * (3 * (rx + ry) - math.sqrt((3 * rx + ry) * (rx + 3 * ry)))
|
|
steps = max(24, int(math.ceil(circumference / sample_step)))
|
|
raw_points = [
|
|
(cx + math.cos((math.tau * i) / steps) * rx, cy + math.sin((math.tau * i) / steps) * ry)
|
|
for i in range(steps + 1)
|
|
]
|
|
append_points(raw_points, curve_id, matrix, output)
|
|
return curve_id + 1
|
|
|
|
|
|
def append_sampled_line(p1: Point, p2: Point, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> None:
|
|
if curve_id < 0:
|
|
return
|
|
tp1 = transform_point(matrix, p1)
|
|
tp2 = transform_point(matrix, p2)
|
|
distance = point_distance(tp1, tp2)
|
|
steps = max(1, int(math.ceil(distance / sample_step)))
|
|
points = [lerp_point(p1, p2, i / steps) for i in range(steps + 1)]
|
|
append_points(points, curve_id, matrix, output)
|
|
|
|
|
|
def append_quadratic(
|
|
p0: Point,
|
|
p1: Point,
|
|
p2: Point,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
) -> None:
|
|
tp = [transform_point(matrix, p) for p in (p0, p1, p2)]
|
|
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2])
|
|
steps = max(4, int(math.ceil(control_length / sample_step)))
|
|
points = []
|
|
for i in range(steps + 1):
|
|
t = i / steps
|
|
mt = 1 - t
|
|
points.append((
|
|
mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0],
|
|
mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1],
|
|
))
|
|
append_points(points, curve_id, matrix, output)
|
|
|
|
|
|
def append_cubic(
|
|
p0: Point,
|
|
p1: Point,
|
|
p2: Point,
|
|
p3: Point,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
) -> None:
|
|
tp = [transform_point(matrix, p) for p in (p0, p1, p2, p3)]
|
|
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2]) + point_distance(tp[2], tp[3])
|
|
steps = max(6, int(math.ceil(control_length / sample_step)))
|
|
points = []
|
|
for i in range(steps + 1):
|
|
t = i / steps
|
|
mt = 1 - t
|
|
points.append((
|
|
mt**3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t**3 * p3[0],
|
|
mt**3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t**3 * p3[1],
|
|
))
|
|
append_points(points, curve_id, matrix, output)
|
|
|
|
|
|
def append_arc(
|
|
start: Point,
|
|
rx: float,
|
|
ry: float,
|
|
angle_degrees: float,
|
|
large_arc: float,
|
|
sweep: float,
|
|
end: Point,
|
|
curve_id: int,
|
|
sample_step: float,
|
|
matrix: Matrix,
|
|
output: list[Segment],
|
|
) -> None:
|
|
if rx == 0 or ry == 0 or start == end:
|
|
append_sampled_line(start, end, curve_id, sample_step, matrix, output)
|
|
return
|
|
|
|
rx = abs(rx)
|
|
ry = abs(ry)
|
|
phi = math.radians(angle_degrees % 360)
|
|
cos_phi = math.cos(phi)
|
|
sin_phi = math.sin(phi)
|
|
dx = (start[0] - end[0]) / 2
|
|
dy = (start[1] - end[1]) / 2
|
|
x1p = cos_phi * dx + sin_phi * dy
|
|
y1p = -sin_phi * dx + cos_phi * dy
|
|
|
|
radii_check = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
|
if radii_check > 1:
|
|
scale = math.sqrt(radii_check)
|
|
rx *= scale
|
|
ry *= scale
|
|
|
|
sign = -1 if bool(large_arc) == bool(sweep) else 1
|
|
numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
|
denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
|
factor = sign * math.sqrt(max(0.0, numerator / denominator)) if denominator else 0.0
|
|
cxp = factor * (rx * y1p / ry)
|
|
cyp = factor * (-ry * x1p / rx)
|
|
cx = cos_phi * cxp - sin_phi * cyp + (start[0] + end[0]) / 2
|
|
cy = sin_phi * cxp + cos_phi * cyp + (start[1] + end[1]) / 2
|
|
|
|
theta1 = vector_angle((1, 0), ((x1p - cxp) / rx, (y1p - cyp) / ry))
|
|
delta = vector_angle(
|
|
((x1p - cxp) / rx, (y1p - cyp) / ry),
|
|
((-x1p - cxp) / rx, (-y1p - cyp) / ry),
|
|
)
|
|
if not sweep and delta > 0:
|
|
delta -= math.tau
|
|
elif sweep and delta < 0:
|
|
delta += math.tau
|
|
|
|
arc_length = abs(delta) * max(rx, ry)
|
|
steps = max(4, int(math.ceil(arc_length / sample_step)))
|
|
points = []
|
|
for i in range(steps + 1):
|
|
theta = theta1 + delta * (i / steps)
|
|
x = cx + cos_phi * rx * math.cos(theta) - sin_phi * ry * math.sin(theta)
|
|
y = cy + sin_phi * rx * math.cos(theta) + cos_phi * ry * math.sin(theta)
|
|
points.append((x, y))
|
|
append_points(points, curve_id, matrix, output)
|
|
|
|
|
|
def vector_angle(u: Point, v: Point) -> float:
|
|
dot_value = u[0] * v[0] + u[1] * v[1]
|
|
det_value = u[0] * v[1] - u[1] * v[0]
|
|
return math.atan2(det_value, dot_value)
|
|
|
|
|
|
def append_points(points: list[Point], curve_id: int, matrix: Matrix, output: list[Segment]) -> None:
|
|
transformed = [transform_point(matrix, point) for point in points]
|
|
for a, b in zip(transformed, transformed[1:]):
|
|
length = point_distance(a, b)
|
|
if length <= 0.0001:
|
|
continue
|
|
ax, ay = a
|
|
bx, by = b
|
|
output.append((
|
|
curve_id,
|
|
ax,
|
|
ay,
|
|
bx,
|
|
by,
|
|
min(ax, bx),
|
|
min(ay, by),
|
|
max(ax, bx),
|
|
max(ay, by),
|
|
))
|
|
|
|
|
|
def compute_nearest_spacing(
|
|
segments: list[Segment],
|
|
cell_size: float,
|
|
) -> tuple[list[float], float, dict[str, float] | None]:
|
|
try:
|
|
return compute_nearest_spacing_kdtree(segments)
|
|
except ImportError:
|
|
return compute_nearest_spacing_grid(segments, cell_size)
|
|
|
|
|
|
def compute_nearest_spacing_kdtree(segments: list[Segment]) -> tuple[list[float], float, dict[str, float] | None]:
|
|
try:
|
|
import numpy as np
|
|
from scipy.spatial import cKDTree
|
|
except ImportError:
|
|
scipy_site = os.environ.get("WORDCLOUD_SCIPY_SITE", "")
|
|
if scipy_site and scipy_site not in sys.path:
|
|
sys.path.append(scipy_site)
|
|
import numpy as np
|
|
from scipy.spatial import cKDTree
|
|
|
|
points = sample_points_from_segments(segments)
|
|
if len(points) < 2:
|
|
return [], math.inf, None
|
|
|
|
coordinates = np.asarray([(point[1], point[2]) for point in points], dtype=np.float64)
|
|
curve_ids = np.asarray([point[0] for point in points], dtype=np.int32)
|
|
tree = cKDTree(coordinates)
|
|
k = min(128, len(points))
|
|
chunk_size = 50000
|
|
nearest_distances: list[float] = []
|
|
min_distance = math.inf
|
|
closest_points: dict[str, float] | None = None
|
|
|
|
for start in range(0, len(points), chunk_size):
|
|
stop = min(start + chunk_size, len(points))
|
|
try:
|
|
distances, indices = tree.query(coordinates[start:stop], k=k, workers=-1)
|
|
except TypeError:
|
|
distances, indices = tree.query(coordinates[start:stop], k=k)
|
|
if k == 1:
|
|
distances = distances[:, np.newaxis]
|
|
indices = indices[:, np.newaxis]
|
|
|
|
candidate_curve_ids = curve_ids[indices]
|
|
current_curve_ids = curve_ids[start:stop, np.newaxis]
|
|
valid = candidate_curve_ids != current_curve_ids
|
|
found = valid.any(axis=1)
|
|
if not found.any():
|
|
continue
|
|
|
|
first_valid = valid.argmax(axis=1)
|
|
row_numbers = np.arange(stop - start)
|
|
found_rows = row_numbers[found]
|
|
found_columns = first_valid[found]
|
|
values = distances[found_rows, found_columns]
|
|
finite = np.isfinite(values)
|
|
if not finite.any():
|
|
continue
|
|
|
|
values = values[finite]
|
|
found_rows = found_rows[finite]
|
|
found_columns = found_columns[finite]
|
|
nearest_distances.extend(values.tolist())
|
|
|
|
local_min_index = int(np.argmin(values))
|
|
local_min = float(values[local_min_index])
|
|
if local_min < min_distance:
|
|
source_index = start + int(found_rows[local_min_index])
|
|
target_index = int(indices[found_rows[local_min_index], found_columns[local_min_index]])
|
|
min_distance = local_min
|
|
closest_points = {
|
|
"ax": float(coordinates[source_index, 0]),
|
|
"ay": float(coordinates[source_index, 1]),
|
|
"bx": float(coordinates[target_index, 0]),
|
|
"by": float(coordinates[target_index, 1]),
|
|
}
|
|
|
|
return nearest_distances, min_distance, closest_points
|
|
|
|
|
|
def compute_nearest_spacing_grid(
|
|
segments: list[Segment],
|
|
cell_size: float,
|
|
) -> tuple[list[float], float, dict[str, float] | None]:
|
|
points = sample_points_from_segments(segments)
|
|
grid: dict[tuple[int, int], list[int]] = {}
|
|
min_grid_x = min_grid_y = math.inf
|
|
max_grid_x = max_grid_y = -math.inf
|
|
|
|
for index, (_, x, y) in enumerate(points):
|
|
cx = math.floor(x / cell_size)
|
|
cy = math.floor(y / cell_size)
|
|
min_grid_x = min(min_grid_x, cx)
|
|
min_grid_y = min(min_grid_y, cy)
|
|
max_grid_x = max(max_grid_x, cx)
|
|
max_grid_y = max(max_grid_y, cy)
|
|
grid.setdefault((cx, cy), []).append(index)
|
|
|
|
max_ring = int(max(max_grid_x - min_grid_x, max_grid_y - min_grid_y) + 2) if math.isfinite(min_grid_x) else 0
|
|
nearest_distances: list[float] = []
|
|
min_distance = math.inf
|
|
closest_points: dict[str, float] | None = None
|
|
|
|
for index, point in enumerate(points):
|
|
curve_id, x, y = point
|
|
base_cx = math.floor(x / cell_size)
|
|
base_cy = math.floor(y / cell_size)
|
|
seen: set[int] = set()
|
|
nearest_sq = math.inf
|
|
|
|
for ring in range(max_ring + 1):
|
|
for cx in range(base_cx - ring, base_cx + ring + 1):
|
|
for cy in range(base_cy - ring, base_cy + ring + 1):
|
|
if ring > 0 and base_cx - ring < cx < base_cx + ring and base_cy - ring < cy < base_cy + ring:
|
|
continue
|
|
bucket = grid.get((cx, cy))
|
|
if not bucket:
|
|
continue
|
|
for candidate_index in bucket:
|
|
if candidate_index == index or candidate_index in seen:
|
|
continue
|
|
seen.add(candidate_index)
|
|
candidate_curve_id, candidate_x, candidate_y = points[candidate_index]
|
|
if candidate_curve_id == curve_id:
|
|
continue
|
|
dx = x - candidate_x
|
|
dy = y - candidate_y
|
|
distance_sq = dx * dx + dy * dy
|
|
if distance_sq < nearest_sq:
|
|
nearest_sq = distance_sq
|
|
if distance_sq < min_distance * min_distance:
|
|
distance = math.sqrt(distance_sq)
|
|
min_distance = distance
|
|
closest_points = {
|
|
"ax": x,
|
|
"ay": y,
|
|
"bx": candidate_x,
|
|
"by": candidate_y,
|
|
}
|
|
if math.isfinite(nearest_sq) and ring * cell_size > math.sqrt(nearest_sq) + cell_size * 2:
|
|
break
|
|
|
|
if math.isfinite(nearest_sq):
|
|
nearest_distances.append(math.sqrt(nearest_sq))
|
|
|
|
return nearest_distances, min_distance, closest_points
|
|
|
|
|
|
def sample_points_from_segments(segments: list[Segment]) -> list[tuple[int, float, float]]:
|
|
points: list[tuple[int, float, float]] = []
|
|
for index, segment in enumerate(segments):
|
|
curve_id = int(segment[0])
|
|
points.append((curve_id, segment[1], segment[2]))
|
|
next_segment = segments[index + 1] if index + 1 < len(segments) else None
|
|
if next_segment is None or int(next_segment[0]) != curve_id:
|
|
points.append((curve_id, segment[3], segment[4]))
|
|
return points
|
|
|
|
|
|
def cell_bounds(segment: Segment, cell_size: float) -> tuple[int, int, int, int]:
|
|
return (
|
|
math.floor(segment[5] / cell_size),
|
|
math.floor(segment[6] / cell_size),
|
|
math.floor(segment[7] / cell_size),
|
|
math.floor(segment[8] / cell_size),
|
|
)
|
|
|
|
|
|
def segment_distance(a: Segment, b: Segment) -> tuple[float, Point, Point]:
|
|
pa, pb = closest_segment_points((a[1], a[2]), (a[3], a[4]), (b[1], b[2]), (b[3], b[4]))
|
|
return point_distance(pa, pb), pa, pb
|
|
|
|
|
|
def closest_segment_points(p1: Point, q1: Point, p2: Point, q2: Point) -> tuple[Point, Point]:
|
|
d1 = sub(q1, p1)
|
|
d2 = sub(q2, p2)
|
|
r = sub(p1, p2)
|
|
a = dot(d1, d1)
|
|
e = dot(d2, d2)
|
|
f = dot(d2, r)
|
|
s = 0.0
|
|
t = 0.0
|
|
epsilon = 1e-9
|
|
|
|
if a <= epsilon and e <= epsilon:
|
|
return p1, p2
|
|
if a <= epsilon:
|
|
t = clamp01(f / e)
|
|
else:
|
|
c = dot(d1, r)
|
|
if e <= epsilon:
|
|
s = clamp01(-c / a)
|
|
else:
|
|
b = dot(d1, d2)
|
|
denom = a * e - b * b
|
|
s = clamp01((b * f - c * e) / denom) if denom != 0 else 0.0
|
|
t_nom = b * s + f
|
|
if t_nom < 0:
|
|
t = 0.0
|
|
s = clamp01(-c / a)
|
|
elif t_nom > e:
|
|
t = 1.0
|
|
s = clamp01((b - c) / a)
|
|
else:
|
|
t = t_nom / e
|
|
|
|
return add(p1, mul(d1, s)), add(p2, mul(d2, t))
|
|
|
|
|
|
def parse_transform(raw: str) -> Matrix:
|
|
matrix = IDENTITY
|
|
for name, args_raw in TRANSFORM_RE.findall(raw):
|
|
args = [parse_float(item) for item in NUMBER_RE.findall(args_raw)]
|
|
name = name.lower()
|
|
next_matrix = IDENTITY
|
|
if name == "matrix" and len(args) >= 6:
|
|
next_matrix = (args[0], args[1], args[2], args[3], args[4], args[5])
|
|
elif name == "translate" and args:
|
|
next_matrix = (1.0, 0.0, 0.0, 1.0, args[0], args[1] if len(args) > 1 else 0.0)
|
|
elif name == "scale" and args:
|
|
sx = args[0]
|
|
sy = args[1] if len(args) > 1 else sx
|
|
next_matrix = (sx, 0.0, 0.0, sy, 0.0, 0.0)
|
|
elif name == "rotate" and args:
|
|
angle = math.radians(args[0])
|
|
cos_a = math.cos(angle)
|
|
sin_a = math.sin(angle)
|
|
rotate = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
|
|
if len(args) >= 3:
|
|
next_matrix = multiply_matrix(
|
|
multiply_matrix((1.0, 0.0, 0.0, 1.0, args[1], args[2]), rotate),
|
|
(1.0, 0.0, 0.0, 1.0, -args[1], -args[2]),
|
|
)
|
|
else:
|
|
next_matrix = rotate
|
|
elif name == "skewx" and args:
|
|
next_matrix = (1.0, 0.0, math.tan(math.radians(args[0])), 1.0, 0.0, 0.0)
|
|
elif name == "skewy" and args:
|
|
next_matrix = (1.0, math.tan(math.radians(args[0])), 0.0, 1.0, 0.0, 0.0)
|
|
matrix = multiply_matrix(matrix, next_matrix)
|
|
return matrix
|
|
|
|
|
|
def multiply_matrix(left: Matrix, right: Matrix) -> Matrix:
|
|
a1, b1, c1, d1, e1, f1 = left
|
|
a2, b2, c2, d2, e2, f2 = right
|
|
return (
|
|
a1 * a2 + c1 * b2,
|
|
b1 * a2 + d1 * b2,
|
|
a1 * c2 + c1 * d2,
|
|
b1 * c2 + d1 * d2,
|
|
a1 * e2 + c1 * f2 + e1,
|
|
b1 * e2 + d1 * f2 + f1,
|
|
)
|
|
|
|
|
|
def transform_point(matrix: Matrix, point: Point) -> Point:
|
|
a, b, c, d, e, f = matrix
|
|
x, y = point
|
|
return a * x + c * y + e, b * x + d * y + f
|
|
|
|
|
|
def parse_points(raw: str) -> list[Point]:
|
|
values = [parse_float(item) for item in NUMBER_RE.findall(raw)]
|
|
return [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)]
|
|
|
|
|
|
def parse_svg_length(raw: str | None) -> float:
|
|
if not raw:
|
|
return 0.0
|
|
return parse_float(raw)
|
|
|
|
|
|
def parse_float(raw: str | float) -> float:
|
|
if isinstance(raw, float):
|
|
return raw
|
|
try:
|
|
return float(raw)
|
|
except ValueError:
|
|
match = NUMBER_RE.search(raw)
|
|
return float(match.group(0)) if match else 0.0
|
|
|
|
|
|
def percentile_value(sorted_values: list[float], percentile: float) -> float:
|
|
if not sorted_values:
|
|
return math.inf
|
|
if percentile <= 0:
|
|
return sorted_values[0]
|
|
index = math.floor((percentile / 100.0) * (len(sorted_values) - 1))
|
|
return sorted_values[max(0, min(len(sorted_values) - 1, index))]
|
|
|
|
|
|
def px_to_mm(px: float) -> float:
|
|
return (px / DPI) * MM_PER_INCH
|
|
|
|
|
|
def scale_closest_points(points: dict[str, float] | None, scale: float) -> dict[str, float] | None:
|
|
if not points:
|
|
return None
|
|
return {key: value * scale for key, value in points.items()}
|
|
|
|
|
|
def point_distance(a: Point, b: Point) -> float:
|
|
return math.hypot(a[0] - b[0], a[1] - b[1])
|
|
|
|
|
|
def lerp_point(a: Point, b: Point, t: float) -> Point:
|
|
return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
|
|
|
|
def sub(a: Point, b: Point) -> Point:
|
|
return a[0] - b[0], a[1] - b[1]
|
|
|
|
|
|
def add(a: Point, b: Point) -> Point:
|
|
return a[0] + b[0], a[1] + b[1]
|
|
|
|
|
|
def mul(a: Point, value: float) -> Point:
|
|
return a[0] * value, a[1] * value
|
|
|
|
|
|
def dot(a: Point, b: Point) -> float:
|
|
return a[0] * b[0] + a[1] * b[1]
|
|
|
|
|
|
def clamp01(value: float) -> float:
|
|
return max(0.0, min(1.0, value))
|