将 Xynova 笔画延长至 50% 黑色覆盖率
沿现有笔画向空白处增长,保留最小线宽和笔画间隙。41 项测试通过,双 UV 矢量图无重叠。
This commit is contained in:
@@ -15,6 +15,10 @@ One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of `
|
||||
page so a cell the projection stretches receives a proportionally larger mark. The width is then
|
||||
clamped to ``width_min_mm``..``width_max_mm``.
|
||||
|
||||
When ``target_coverage`` is positive, accepted capsules grow from their endpoints into free
|
||||
space, up to twice their initial length. Width and edge clearance stay fixed. The reported
|
||||
coverage is measured only inside the eligible colour-pattern region.
|
||||
|
||||
**This is an image-space experiment, not a print master.** Clipping and merged neighbours change
|
||||
the visible widths, so verify dimensions on the printed part before manufacturing.
|
||||
|
||||
@@ -59,6 +63,7 @@ DEFAULT_PARAMS: dict[str, float] = {
|
||||
"neighbourhood_mm": 0.5,
|
||||
"clearance_mm": 0.0,
|
||||
"candidate_count": 1,
|
||||
"target_coverage": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +96,8 @@ def validate_params(params: dict) -> None:
|
||||
raise ValueError("params.width_min_mm must be <= params.width_max_mm")
|
||||
if not 0.0 < params["coverage_cap"] <= 1.0:
|
||||
raise ValueError("params.coverage_cap must be within (0, 1]")
|
||||
if not 0.0 <= params["target_coverage"] < 1.0:
|
||||
raise ValueError("params.target_coverage must be within [0, 1)")
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
@@ -403,6 +410,87 @@ def draw_mark(canvas, region, params, px_per_mm, patterned=None,
|
||||
return 0.0
|
||||
|
||||
|
||||
def capsule_footprint(capsule, px_per_mm, shape):
|
||||
"""Raster footprint of a millimetre-space capsule, or None outside the page."""
|
||||
start, end, radius_mm = capsule
|
||||
cx = (start[0] + end[0]) * px_per_mm / 2
|
||||
cy = (start[1] + end[1]) * px_per_mm / 2
|
||||
dx = (end[0] - start[0]) * px_per_mm / 2
|
||||
dy = (end[1] - start[1]) * px_per_mm / 2
|
||||
half_length = math.hypot(dx, dy)
|
||||
angle = math.atan2(dy, dx)
|
||||
radius = radius_mm * px_per_mm
|
||||
extent = half_length + radius
|
||||
if cx - extent < 0 or cy - extent < 0 or cx + extent >= shape[1] or cy + extent >= shape[0]:
|
||||
return None
|
||||
x0, x1 = math.floor(cx - extent), min(shape[1], math.ceil(cx + extent + 1))
|
||||
y0, y1 = math.floor(cy - extent), min(shape[0], math.ceil(cy + extent + 1))
|
||||
yy, xx = np.ogrid[y0:y1, x0:x1]
|
||||
along = (xx - cx) * math.cos(angle) + (yy - cy) * math.sin(angle)
|
||||
across = -(xx - cx) * math.sin(angle) + (yy - cy) * math.cos(angle)
|
||||
footprint = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 <= radius**2
|
||||
return (slice(y0, y1), slice(x0, x1)), footprint
|
||||
|
||||
|
||||
def extend_marks(marks, patterned, capsules, vector_shapes, params, px_per_mm):
|
||||
"""Grow existing marks into free space until target occupancy or geometry stops them."""
|
||||
target = math.ceil(params["target_coverage"] * np.count_nonzero(patterned))
|
||||
occupied = np.count_nonzero(marks & patterned)
|
||||
if target <= occupied or not capsules:
|
||||
return occupied
|
||||
step_mm = 2 / px_per_mm
|
||||
initial_lengths = [math.dist(start, end) + 2 * radius
|
||||
for start, end, radius in capsules]
|
||||
blocked = [[False, False] for _ in capsules]
|
||||
while occupied < target and any(not all(pair) for pair in blocked):
|
||||
for index, capsule in enumerate(capsules):
|
||||
for side in (0, 1):
|
||||
if blocked[index][side]:
|
||||
continue
|
||||
start, end, radius = capsule
|
||||
length = math.dist(start, end)
|
||||
if length + step_mm + 2 * radius > 2 * initial_lengths[index]:
|
||||
blocked[index][side] = True
|
||||
continue
|
||||
ux = (end[0] - start[0]) / length
|
||||
uy = (end[1] - start[1]) / length
|
||||
candidate = (
|
||||
(start[0] - ux * step_mm, start[1] - uy * step_mm) if side == 0 else start,
|
||||
(end[0] + ux * step_mm, end[1] + uy * step_mm) if side == 1 else end,
|
||||
radius,
|
||||
)
|
||||
if not has_clearance(candidate, capsules, params["clearance_mm"], index):
|
||||
blocked[index][side] = True
|
||||
continue
|
||||
raster = capsule_footprint(candidate, px_per_mm, marks.shape)
|
||||
if raster is None:
|
||||
blocked[index][side] = True
|
||||
continue
|
||||
region, footprint = raster
|
||||
if np.any(footprint & ~patterned[region]):
|
||||
blocked[index][side] = True
|
||||
continue
|
||||
occupied += np.count_nonzero(footprint & ~marks[region])
|
||||
marks[region] |= footprint
|
||||
capsules[index] = candidate
|
||||
capsule = candidate
|
||||
if occupied >= target:
|
||||
break
|
||||
if occupied >= target:
|
||||
break
|
||||
if vector_shapes is not None:
|
||||
vector_shapes[:] = [
|
||||
(capsule_polygon((start[0] + end[0]) * px_per_mm / 2,
|
||||
(start[1] + end[1]) * px_per_mm / 2,
|
||||
radius * px_per_mm,
|
||||
math.dist(start, end) * px_per_mm / 2,
|
||||
math.atan2(end[1] - start[1], end[0] - start[0])),
|
||||
(0.0, 0.0, 0.0, 1.0))
|
||||
for start, end, radius in capsules
|
||||
]
|
||||
return occupied
|
||||
|
||||
|
||||
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)
|
||||
@@ -622,6 +710,12 @@ def run(
|
||||
)
|
||||
for region in mode_regions
|
||||
]
|
||||
initial_coverage = np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
|
||||
if params["target_coverage"] > initial_coverage:
|
||||
extend_marks(marks, patterned, accepted_capsules, vector_shapes, params, px_per_mm)
|
||||
print(f"{source_id}: endpoint extension {initial_coverage:.1%} -> "
|
||||
f"{np.count_nonzero(marks & patterned) / np.count_nonzero(patterned):.1%} "
|
||||
f"(target {params['target_coverage']:.1%})", flush=True)
|
||||
stem = artifact_stem(source_id, artifact_prefix)
|
||||
if "png" in formats:
|
||||
write_binary_png(output_dir / f"{stem}.png", marks, surface)
|
||||
|
||||
@@ -36,10 +36,12 @@ def capsule_segment(cx, cy, half_length, angle):
|
||||
return (cx - dx, cy - dy), (cx + dx, cy + dy)
|
||||
|
||||
|
||||
def has_clearance(candidate, accepted, clearance):
|
||||
def has_clearance(candidate, accepted, clearance, skip_index=None):
|
||||
"""Capsules are (start, end, radius), all in the same physical units."""
|
||||
start, end, radius = candidate
|
||||
for other_start, other_end, other_radius in accepted:
|
||||
for index, (other_start, other_end, other_radius) in enumerate(accepted):
|
||||
if index == skip_index:
|
||||
continue
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user