生成无重叠的 0.2 mm Xynova 笔画图案
在打印页毫米坐标中筛选候选笔画,更新双 UV 产物。38 项测试通过;SVG 检查无交叠。
@@ -39,6 +39,7 @@ script_dir = str(Path(__file__).resolve().parent)
|
|||||||
if script_dir not in sys.path:
|
if script_dir not in sys.path:
|
||||||
sys.path.insert(0, script_dir)
|
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 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"
|
MODE = "camera_compensated_width"
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ DEFAULT_PARAMS: dict[str, float] = {
|
|||||||
"length_ratio": 2.4,
|
"length_ratio": 2.4,
|
||||||
"coverage_cap": 0.45,
|
"coverage_cap": 0.45,
|
||||||
"neighbourhood_mm": 0.5,
|
"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})")
|
raise ValueError(f"params.{key}: must be finite (got {value!r})")
|
||||||
if params["min_cell_pixels"] < 1:
|
if params["min_cell_pixels"] < 1:
|
||||||
raise ValueError("params.min_cell_pixels must be >= 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:
|
if not 0.0 <= params["jitter_fraction"] <= 1.0:
|
||||||
raise ValueError("params.jitter_fraction must be within [0, 1]")
|
raise ValueError("params.jitter_fraction must be within [0, 1]")
|
||||||
if params["angle_span_deg"] < 0.0:
|
if params["angle_span_deg"] < 0.0:
|
||||||
@@ -146,7 +153,8 @@ def resolve_params(args: argparse.Namespace) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def cell_code(
|
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]:
|
) -> tuple[float, float, float]:
|
||||||
"""Return bounded offsets and angle from one exact source colour.
|
"""Return bounded offsets and angle from one exact source colour.
|
||||||
|
|
||||||
@@ -154,7 +162,8 @@ def cell_code(
|
|||||||
views without storing anything.
|
views without storing anything.
|
||||||
"""
|
"""
|
||||||
digest = hashlib.blake2b(
|
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_size=8,
|
||||||
).digest()
|
).digest()
|
||||||
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
|
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
|
return points
|
||||||
|
|
||||||
|
|
||||||
def draw_mark(
|
def draw_mark_candidate(
|
||||||
canvas: np.ndarray,
|
canvas: np.ndarray,
|
||||||
region,
|
region,
|
||||||
params: dict,
|
params: dict,
|
||||||
@@ -295,6 +304,8 @@ def draw_mark(
|
|||||||
patterned: np.ndarray | None = None,
|
patterned: np.ndarray | None = None,
|
||||||
camera_cell: tuple[int, int] | None = None,
|
camera_cell: tuple[int, int] | None = None,
|
||||||
vector_shapes: list | None = None,
|
vector_shapes: list | None = None,
|
||||||
|
accepted_capsules: list | None = None,
|
||||||
|
candidate_index: int = 0,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
|
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
|
||||||
colour, cell_x, cell_y, width, height = region
|
colour, cell_x, cell_y, width, height = region
|
||||||
@@ -304,6 +315,7 @@ def draw_mark(
|
|||||||
params["seed"],
|
params["seed"],
|
||||||
params["jitter_fraction"],
|
params["jitter_fraction"],
|
||||||
params["angle_span_deg"],
|
params["angle_span_deg"],
|
||||||
|
candidate_index,
|
||||||
)
|
)
|
||||||
span = min(width, height)
|
span = min(width, height)
|
||||||
centre_x = cell_x + dx * width
|
centre_x = cell_x + dx * width
|
||||||
@@ -359,7 +371,15 @@ def draw_mark(
|
|||||||
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
|
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
|
||||||
if (existing + added) / local_area > params["coverage_cap"]:
|
if (existing + added) / local_area > params["coverage_cap"]:
|
||||||
return 0.0
|
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
|
canvas[y0:y1, x0:x1] |= footprint
|
||||||
|
if accepted_capsules is not None:
|
||||||
|
accepted_capsules.append(capsule)
|
||||||
if vector_shapes is not None:
|
if vector_shapes is not None:
|
||||||
vector_shapes.append(
|
vector_shapes.append(
|
||||||
(
|
(
|
||||||
@@ -370,6 +390,19 @@ def draw_mark(
|
|||||||
return 2 * radius
|
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:
|
def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None:
|
||||||
height, width = marks.shape
|
height, width = marks.shape
|
||||||
rgba = np.zeros((height, width, 4), dtype=np.float32)
|
rgba = np.zeros((height, width, 4), dtype=np.float32)
|
||||||
@@ -569,6 +602,7 @@ def run(
|
|||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
marks = np.zeros((height, width), dtype=bool)
|
marks = np.zeros((height, width), dtype=bool)
|
||||||
|
accepted_capsules = []
|
||||||
vector_shapes = [] if vector_page is not None else None
|
vector_shapes = [] if vector_page is not None else None
|
||||||
mode_regions = [
|
mode_regions = [
|
||||||
region
|
region
|
||||||
@@ -584,6 +618,7 @@ def run(
|
|||||||
patterned,
|
patterned,
|
||||||
camera_cell=lookup[region[0]],
|
camera_cell=lookup[region[0]],
|
||||||
vector_shapes=vector_shapes,
|
vector_shapes=vector_shapes,
|
||||||
|
accepted_capsules=accepted_capsules,
|
||||||
)
|
)
|
||||||
for region in mode_regions
|
for region in mode_regions
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"angle_span_deg": 180.0,
|
||||||
|
"candidate_count": 12,
|
||||||
|
"clearance_mm": 0.03,
|
||||||
|
"coverage_cap": 0.45,
|
||||||
|
"jitter_fraction": 0.66,
|
||||||
|
"length_ratio": 2.4,
|
||||||
|
"min_cell_pixels": 20,
|
||||||
|
"neighbourhood_mm": 0.5,
|
||||||
|
"seed": 7,
|
||||||
|
"width_fraction": 0.5,
|
||||||
|
"width_max_mm": 0.28,
|
||||||
|
"width_min_mm": 0.2
|
||||||
|
}
|
||||||
@@ -12,7 +12,29 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"mode": "camera_compensated_width",
|
"mode": "camera_compensated_width",
|
||||||
"seed": 7,
|
"seed": 7,
|
||||||
"camera_width_fraction": 0.5
|
"camera_width_fraction": 0.5,
|
||||||
|
"resolved": {
|
||||||
|
"seed": 7,
|
||||||
|
"min_cell_pixels": 20,
|
||||||
|
"jitter_fraction": 0.66,
|
||||||
|
"angle_span_deg": 180.0,
|
||||||
|
"width_fraction": 0.5,
|
||||||
|
"width_min_mm": 0.2,
|
||||||
|
"width_max_mm": 0.28,
|
||||||
|
"length_ratio": 2.4,
|
||||||
|
"coverage_cap": 0.45,
|
||||||
|
"neighbourhood_mm": 0.5,
|
||||||
|
"clearance_mm": 0.03,
|
||||||
|
"candidate_count": 12
|
||||||
|
},
|
||||||
|
"design_yaml": {
|
||||||
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
|
||||||
|
"sha256": "428a2f9c50ec7283c3030de830e24467669a12967be60b348007f6381c160b32"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
|
||||||
|
"sha256": "95a2d8a7a1a775d03dbc98e98bc4b2ae28f87c815acadf12fe8bdaa5dd073fe8"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
@@ -30,15 +52,15 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"png": {
|
"png": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
|
||||||
"sha256": "18be631386542efce2269df36c3eaa806ff9a1e42281dbd3db6caf0459793be7"
|
"sha256": "f76dc820bef5fbc3dd004578683e8166d8ef8ce5666d2b6e2d1a94538a6f01cc"
|
||||||
},
|
},
|
||||||
"svg": {
|
"svg": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
|
||||||
"sha256": "5dd5725a3777b4c79a485ae3c073559de4160076d7821713c7b8437fb0e6b535"
|
"sha256": "c2d90aa1c033f26820fe007af239e9f09f2cf8d3c8ed560119355f9a0bc0cf5c"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf",
|
||||||
"sha256": "ba25a36e82a9d979707e89f09aea0471cd90ba648909fc812c3c940fe6551af6"
|
"sha256": "2e1be9d1718f05e0a6898ba72abcfaae185a413843b2096fb71ffcd1200da48b"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -57,15 +79,15 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"png": {
|
"png": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
|
||||||
"sha256": "7a9b5504cce2b95982adc035a4e11b13cb721d17b2ecaf865a4cfcd95d73a013"
|
"sha256": "119d7c47b7454f1f36534fd888381e3c9c0300d82458c44b13aa534697375f2a"
|
||||||
},
|
},
|
||||||
"svg": {
|
"svg": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
|
||||||
"sha256": "dbd1771f6a5b7437c4d9f5b1f2d91c529b0a2a5950819bad51840441f04531e6"
|
"sha256": "340eb5dd9f1a06a8266a5f7ded3868513ceb0a9d1c5ca629df8a46cba18174c3"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
|
||||||
"sha256": "ed9df4cf7b8fb2fa6b4b9b508f83261dbe34db9c32ad356e915e94e94a20af04"
|
"sha256": "0a431236abae3171fb67cc6f5864d59296c0f28d9bcd5006c74edff4e39c7516"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 619 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 934 KiB |
@@ -92,9 +92,8 @@ texture_patch_views:
|
|||||||
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera
|
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera
|
||||||
# cell's print-page footprint so a cell the projection stretches gets a proportionally
|
# cell's print-page footprint so a cell the projection stretches gets a proportionally
|
||||||
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
|
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
|
||||||
# Field reference: the inline comments below. These defaults are an image-space experiment,
|
# Field reference: the inline comments below. This is an image-space experiment, not a
|
||||||
# not a print master: `width_min_mm: 0.11` is below the demonstrated 0.2 mm dot (see
|
# print master. The resolved values are written to _stroke_pattern_params.json and
|
||||||
# docs/probe-findings.md). The resolved values are written to _stroke_pattern_params.json and
|
|
||||||
# recorded in the stroke-texture manifest, which is what makes a run reproducible.
|
# recorded in the stroke-texture manifest, which is what makes a run reproducible.
|
||||||
stroke_pattern:
|
stroke_pattern:
|
||||||
seed: 7 # per-cell offset and angle; reproducible
|
seed: 7 # per-cell offset and angle; reproducible
|
||||||
@@ -102,11 +101,13 @@ stroke_pattern:
|
|||||||
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre
|
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre
|
||||||
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel
|
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel
|
||||||
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm)
|
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm)
|
||||||
width_min_mm: 0.11
|
width_min_mm: 0.20
|
||||||
width_max_mm: 0.28
|
width_max_mm: 0.28
|
||||||
length_ratio: 2.4 # length = ratio x width
|
length_ratio: 2.4 # length = ratio x width
|
||||||
coverage_cap: 0.45 # skip a mark that would exceed this local fill
|
coverage_cap: 0.45 # skip a mark that would exceed this local fill
|
||||||
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
|
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
|
||||||
|
clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page
|
||||||
|
candidate_count: 12 # try alternatives before skipping a cell
|
||||||
|
|
||||||
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
||||||
# Millimetres are measured on the vector page declared above.
|
# Millimetres are measured on the vector page declared above.
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
"source_blend": {"path": str(paths.source_blend),
|
"source_blend": {"path": str(paths.source_blend),
|
||||||
"sha256": sha256_file(paths.source_blend)},
|
"sha256": sha256_file(paths.source_blend)},
|
||||||
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
|
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
|
||||||
# overrides actually produced, because with ten knobs the flags alone no longer
|
# overrides actually produced, because the flags alone no longer
|
||||||
# describe the run. The design.yaml and params hashes pin the tuning either way.
|
# describe the run. The design.yaml and params hashes pin the tuning either way.
|
||||||
"settings": {
|
"settings": {
|
||||||
"mode": STROKE_MODE,
|
"mode": STROKE_MODE,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
|
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
|
||||||
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
|
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
|
||||||
footprint on the print page — so a cell the projection stretches gets a proportionally larger
|
footprint on the print page — so a cell the projection stretches gets a proportionally larger
|
||||||
mark — then clamped. That gives ten live knobs, and this module owns their names, defaults and
|
mark — then clamped. This module owns the live knobs, their defaults and
|
||||||
ranges on the host side.
|
ranges on the host side.
|
||||||
|
|
||||||
Why the keys are exactly these
|
Why the keys are exactly these
|
||||||
@@ -45,12 +45,14 @@ STROKE_DEFAULTS: dict[str, Any] = {
|
|||||||
"length_ratio": 2.4,
|
"length_ratio": 2.4,
|
||||||
"coverage_cap": 0.45,
|
"coverage_cap": 0.45,
|
||||||
"neighbourhood_mm": 0.5,
|
"neighbourhood_mm": 0.5,
|
||||||
|
"clearance_mm": 0.0,
|
||||||
|
"candidate_count": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
|
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
|
||||||
|
|
||||||
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
|
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
|
||||||
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels"})
|
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels", "candidate_count"})
|
||||||
|
|
||||||
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
|
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
|
||||||
|
|
||||||
@@ -93,6 +95,10 @@ def validate_stroke_block(block: Any) -> list[str]:
|
|||||||
|
|
||||||
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
|
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
|
||||||
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
|
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
|
||||||
|
if "candidate_count" in block and block["candidate_count"] < 1:
|
||||||
|
errors.append(f"{where}.candidate_count: must be >= 1 (got {block['candidate_count']!r})")
|
||||||
|
if "clearance_mm" in block and block["clearance_mm"] < 0:
|
||||||
|
errors.append(f"{where}.clearance_mm: must be >= 0 (got {block['clearance_mm']!r})")
|
||||||
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
|
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
|
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Check laser-mark clearance in physical page coordinates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "blender" / "scripts"))
|
||||||
|
|
||||||
|
from stroke_geometry import has_clearance # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossing_capsules_are_rejected() -> None:
|
||||||
|
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
||||||
|
assert not has_clearance(((0, -0.2), (0, 0.2), 0.1), existing, 0.03)
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_parallel_capsules_need_edge_spacing() -> None:
|
||||||
|
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
||||||
|
assert not has_clearance(((-0.2, 0.22), (0.2, 0.22), 0.1), existing, 0.03)
|
||||||
|
assert has_clearance(((-0.2, 0.24), (0.2, 0.24), 0.1), existing, 0.03)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_collinear_capsules_can_clear() -> None:
|
||||||
|
existing = [((0, 0), (0.2, 0), 0.1)]
|
||||||
|
assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03)
|
||||||
@@ -114,6 +114,8 @@ def test_boolean_is_not_a_number() -> None:
|
|||||||
("coverage_cap", 0.0, "within (0, 1]"),
|
("coverage_cap", 0.0, "within (0, 1]"),
|
||||||
("coverage_cap", 1.5, "within (0, 1]"),
|
("coverage_cap", 1.5, "within (0, 1]"),
|
||||||
("neighbourhood_mm", 0.0, "must be > 0"),
|
("neighbourhood_mm", 0.0, "must be > 0"),
|
||||||
|
("clearance_mm", -0.01, "must be >= 0"),
|
||||||
|
("candidate_count", 0, "must be >= 1"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_range_rules(key: str, value, expected: str) -> None:
|
def test_range_rules(key: str, value, expected: str) -> None:
|
||||||
@@ -173,10 +175,14 @@ def test_written_params_round_trip(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
||||||
def test_shipped_revisions_declare_the_block_at_the_defaults(sensor: str) -> None:
|
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
|
||||||
"""The yaml documents the shipped values, so an untouched revision changes nothing."""
|
"""The YAML records both legacy and Xynova manufacturing settings."""
|
||||||
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
|
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
|
||||||
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
|
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
|
||||||
block = design.get("stroke_pattern")
|
block = design.get("stroke_pattern")
|
||||||
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
|
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
|
||||||
assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
|
resolved = stroke_pattern.resolve_stroke_settings(block)
|
||||||
|
expected = dict(stroke_pattern.STROKE_DEFAULTS)
|
||||||
|
if sensor == "finger-xynova":
|
||||||
|
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12)
|
||||||
|
assert resolved == expected
|
||||||
|
|||||||