生成无重叠的 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: 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
] ]
+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
@@ -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"
} }
} }
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 619 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 920 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 621 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 934 KiB

+5 -4
View File
@@ -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.
+1 -1
View File
@@ -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,
+8 -2
View File
@@ -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})"
+26
View File
@@ -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)
+9 -3
View File
@@ -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