生成无重叠的 0.2 mm Xynova 笔画图案

在打印页毫米坐标中筛选候选笔画,更新双 UV 产物。38 项测试通过;SVG 检查无交叠。
This commit is contained in:
ykcheungab
2026-10-05 14:56:34 +08:00
parent 0a3c3995f3
commit b2c39bd1b5
15 changed files with 34996 additions and 69989 deletions
+38 -3
View File
@@ -39,6 +39,7 @@ script_dir = str(Path(__file__).resolve().parent)
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path
from stroke_geometry import capsule_segment, has_clearance # noqa: E402 - Blender script directory is on sys.path
MODE = "camera_compensated_width"
@@ -56,6 +57,8 @@ DEFAULT_PARAMS: dict[str, float] = {
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
}
@@ -72,6 +75,10 @@ def validate_params(params: dict) -> None:
raise ValueError(f"params.{key}: must be finite (got {value!r})")
if params["min_cell_pixels"] < 1:
raise ValueError("params.min_cell_pixels must be >= 1")
if not isinstance(params["candidate_count"], int) or params["candidate_count"] < 1:
raise ValueError("params.candidate_count must be an integer >= 1")
if params["clearance_mm"] < 0:
raise ValueError("params.clearance_mm must be >= 0")
if not 0.0 <= params["jitter_fraction"] <= 1.0:
raise ValueError("params.jitter_fraction must be within [0, 1]")
if params["angle_span_deg"] < 0.0:
@@ -146,7 +153,8 @@ def resolve_params(args: argparse.Namespace) -> dict:
def cell_code(
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float,
candidate_index: int = 0,
) -> tuple[float, float, float]:
"""Return bounded offsets and angle from one exact source colour.
@@ -154,7 +162,8 @@ def cell_code(
views without storing anything.
"""
digest = hashlib.blake2b(
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True),
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True)
+ (candidate_index.to_bytes(4, "little") if candidate_index else b""),
digest_size=8,
).digest()
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
@@ -287,7 +296,7 @@ def capsule_polygon(
return points
def draw_mark(
def draw_mark_candidate(
canvas: np.ndarray,
region,
params: dict,
@@ -295,6 +304,8 @@ def draw_mark(
patterned: np.ndarray | None = None,
camera_cell: tuple[int, int] | None = None,
vector_shapes: list | None = None,
accepted_capsules: list | None = None,
candidate_index: int = 0,
) -> float:
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
colour, cell_x, cell_y, width, height = region
@@ -304,6 +315,7 @@ def draw_mark(
params["seed"],
params["jitter_fraction"],
params["angle_span_deg"],
candidate_index,
)
span = min(width, height)
centre_x = cell_x + dx * width
@@ -359,7 +371,15 @@ def draw_mark(
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
if (existing + added) / local_area > params["coverage_cap"]:
return 0.0
capsule = (*capsule_segment(centre_x / px_per_mm, centre_y / px_per_mm,
half_length / px_per_mm, angle), radius / px_per_mm)
if accepted_capsules is not None and not has_clearance(
capsule, accepted_capsules, params["clearance_mm"]
):
return 0.0
canvas[y0:y1, x0:x1] |= footprint
if accepted_capsules is not None:
accepted_capsules.append(capsule)
if vector_shapes is not None:
vector_shapes.append(
(
@@ -370,6 +390,19 @@ def draw_mark(
return 2 * radius
def draw_mark(canvas, region, params, px_per_mm, patterned=None,
camera_cell=None, vector_shapes=None, accepted_capsules=None) -> float:
"""Try deterministic alternatives without relaxing width or clearance."""
for candidate_index in range(params["candidate_count"]):
width = draw_mark_candidate(
canvas, region, params, px_per_mm, patterned, camera_cell,
vector_shapes, accepted_capsules, candidate_index,
)
if width:
return width
return 0.0
def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None:
height, width = marks.shape
rgba = np.zeros((height, width, 4), dtype=np.float32)
@@ -569,6 +602,7 @@ def run(
flush=True,
)
marks = np.zeros((height, width), dtype=bool)
accepted_capsules = []
vector_shapes = [] if vector_page is not None else None
mode_regions = [
region
@@ -584,6 +618,7 @@ def run(
patterned,
camera_cell=lookup[region[0]],
vector_shapes=vector_shapes,
accepted_capsules=accepted_capsules,
)
for region in mode_regions
]
+51
View File
@@ -0,0 +1,51 @@
"""Physical-space clearance checks for capsule-shaped laser marks."""
from __future__ import annotations
import math
def point_segment_distance_sq(point, start, end):
dx, dy = end[0] - start[0], end[1] - start[1]
length_sq = dx * dx + dy * dy
if length_sq == 0:
return (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2
t = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_sq))
return (point[0] - start[0] - t * dx) ** 2 + (point[1] - start[1] - t * dy) ** 2
def segments_distance_sq(a0, a1, b0, b1):
"""Minimum squared distance, including a proper crossing at zero."""
def cross(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
ab0, ab1 = cross(a0, a1, b0), cross(a0, a1, b1)
ba0, ba1 = cross(b0, b1, a0), cross(b0, b1, a1)
if ab0 * ab1 < 0 and ba0 * ba1 < 0:
return 0.0
return min(
point_segment_distance_sq(a0, b0, b1),
point_segment_distance_sq(a1, b0, b1),
point_segment_distance_sq(b0, a0, a1),
point_segment_distance_sq(b1, a0, a1),
)
def capsule_segment(cx, cy, half_length, angle):
dx, dy = half_length * math.cos(angle), half_length * math.sin(angle)
return (cx - dx, cy - dy), (cx + dx, cy + dy)
def has_clearance(candidate, accepted, clearance):
"""Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate
for other_start, other_end, other_radius in accepted:
limit = radius + other_radius + clearance
if (max(start[0], end[0]) + limit < min(other_start[0], other_end[0])
or max(other_start[0], other_end[0]) + limit < min(start[0], end[0])
or max(start[1], end[1]) + limit < min(other_start[1], other_end[1])
or max(other_start[1], other_end[1]) + limit < min(start[1], end[1])):
continue
if segments_distance_sq(start, end, other_start, other_end) < limit * limit:
return False
return True