ccf0f5f940
按最终矢量页面汇总每个相机单元的笔画包络,并标出低于 0.2 mm 的数量。 已核对 SVG、清单哈希和 Blender 导出,175 项相关测试通过。
1428 lines
64 KiB
Python
1428 lines
64 KiB
Python
"""Multi-view production texture generator with per-triangle ownership.
|
|
|
|
blender -b sensors/finger/r01/source/geometry.blend \\
|
|
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json>
|
|
|
|
Each *pair* is a production view: a REGION camera, the UV layout it writes into, and the
|
|
artifacts. All pairs share one PATTERN camera (normally the sensing camera), so every layout
|
|
carries the same surface pattern — `uv_1` and `uv_2` are different parameterisations of one
|
|
pattern, not two patterns.
|
|
|
|
Why the mask exists: both layouts are full-surface, so emitting every fragment into both
|
|
files would double-ink essentially the whole part. Every loop triangle is therefore assigned
|
|
to exactly one pair (`texture_merge.assign_ownership`) and each file emits only the fragments
|
|
of the triangles it owns. Zero double ink is then a property of the partition rather than
|
|
something measured after printing.
|
|
|
|
This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are
|
|
untouched, and the helpers below are imported from it rather than forked, so the raster, SVG,
|
|
PDF, clipping and colour code paths stay identical between the two entry points.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import bpy
|
|
import numpy as np
|
|
from mathutils import Vector
|
|
|
|
# Blender -P does not consistently include the script directory on sys.path.
|
|
_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 ( # noqa: E402
|
|
ConfigError,
|
|
SceneError,
|
|
_prepare_mesh_object,
|
|
_require_object,
|
|
_require_uv_layer,
|
|
_uv_centre,
|
|
build_patch_geometry,
|
|
fill_polygon_value,
|
|
generate_patch_texture,
|
|
precompute_cell_colors,
|
|
write_pdf_vector,
|
|
write_svg_vector,
|
|
)
|
|
from patch_geometry import clip_rect # noqa: E402
|
|
from camera_grid_stroke import DEFAULT_PARAMS as STROKE_DEFAULT_PARAMS # noqa: E402
|
|
from camera_grid_stroke import render_stroke_polygons # noqa: E402
|
|
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv # noqa: E402
|
|
from camera_grid_stroke_optimizer import optimize_angles # noqa: E402
|
|
from stroke_production_report import REPORT_FILENAME as STROKE_REPORT_FILENAME # noqa: E402
|
|
from stroke_production_report import write_report as write_stroke_report # noqa: E402
|
|
from texture_merge import ( # noqa: E402
|
|
MergeConfigError,
|
|
MergePolicy,
|
|
ViewFragments,
|
|
assign_ownership,
|
|
components_from_seam,
|
|
validate_seam,
|
|
)
|
|
import seam_gap # noqa: E402
|
|
import page_sizing # noqa: E402
|
|
|
|
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
|
|
GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1"
|
|
GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
|
|
PRINT_VIEWS_SCHEMA = "orisys-sensor-design/print-views-v1"
|
|
PRINT_VIEWS_FILENAME = "_print_views.json"
|
|
|
|
#: Line tags the host routes on (`src/orisys_sensor_design/console.py`). Kept as literals
|
|
#: because one process runs under Blender and the other is stdlib-only; a host-side test
|
|
#: asserts the two agree.
|
|
#:
|
|
#: Everything this script prints is tagged and the **host decides what to show**, rather than
|
|
#: the script suppressing its own output. That matters on failure: the host replays the whole
|
|
#: child stdout at every level, so a quiet run must still have produced something to replay.
|
|
#: Untagged output — Blender's banner, the shared generator's `… saved to …` lines and
|
|
#: `patch_projection`'s census — is treated by the host as internal and shown only at
|
|
#: `--verbose`.
|
|
REPORT_PREFIX = "::report::"
|
|
DETAIL_PREFIX = "::detail::"
|
|
|
|
|
|
def report(message: str) -> None:
|
|
"""A decision or scene fact: shown by default."""
|
|
print(f"{REPORT_PREFIX} {message}", flush=True)
|
|
|
|
|
|
def detail(message: str) -> None:
|
|
"""An internal: shown only with `--verbose`."""
|
|
print(f"{DETAIL_PREFIX} {message}", flush=True)
|
|
DEFAULT_MESH = "sensing_surface"
|
|
ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
|
_DEFAULT_COLOR_R = 0.05
|
|
|
|
|
|
def _argv_after_double_dash() -> list[str]:
|
|
argv = sys.argv
|
|
if "--" in argv:
|
|
return argv[argv.index("--") + 1 :]
|
|
return []
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Multi-view colored-patch texture generator (job JSON only)"
|
|
)
|
|
parser.add_argument("--job", required=True, type=Path, help="path to the job JSON")
|
|
parser.add_argument("--stroke-params", type=Path, default=None)
|
|
parser.add_argument("--stroke-output-dir", type=Path, default=None)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _stroke_job(job: dict[str, Any], params_path: Path, output_dir: Path):
|
|
"""Point a multiview geometry job at stroke artifacts without changing the source job."""
|
|
if job.get("schema") != SCHEMA:
|
|
raise ConfigError(f"stroke views require {SCHEMA}")
|
|
try:
|
|
params = json.loads(params_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise ConfigError(f"cannot read stroke params: {exc}") from exc
|
|
if not isinstance(params, dict) or set(params) != set(STROKE_DEFAULT_PARAMS):
|
|
raise ConfigError("stroke params must contain the complete camera-grid settings")
|
|
if not 0 < params["width_ratio"] <= params["length_ratio"] < 1:
|
|
raise ConfigError("stroke ratios require 0 < width_ratio <= length_ratio < 1")
|
|
if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1:
|
|
raise ConfigError("invalid stroke orientation settings")
|
|
copied = dict(job)
|
|
copied["pairs"] = []
|
|
for pair in job["pairs"]:
|
|
pair_id = pair["id"]
|
|
copied["pairs"].append({
|
|
**pair,
|
|
"artifacts": {
|
|
fmt: str(output_dir / f"stroke_texture_{pair_id}.{fmt}")
|
|
for fmt in ARTIFACT_FORMATS
|
|
},
|
|
})
|
|
return copied, params
|
|
|
|
|
|
def _load_job(path: Path) -> dict[str, Any]:
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except OSError as exc:
|
|
raise ConfigError(f"cannot read job {path}: {exc}") from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ConfigError(f"job {path} is not valid JSON: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ConfigError(f"job {path} must be a JSON object")
|
|
return data
|
|
|
|
|
|
def _as_int(value: Any, label: str, minimum: int = 1) -> int:
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ConfigError(f"{label}: must be an integer (got {value!r})")
|
|
if value < minimum:
|
|
raise ConfigError(f"{label}: must be >= {minimum} (got {value!r})")
|
|
return int(value)
|
|
|
|
|
|
def _as_positive_number(value: Any, label: str) -> float:
|
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
raise ConfigError(f"{label}: must be a number (got {value!r})")
|
|
if float(value) <= 0.0:
|
|
raise ConfigError(f"{label}: must be > 0 (got {value!r})")
|
|
return float(value)
|
|
|
|
|
|
def _as_color(value: Any, label: str) -> tuple[float, float, float, float]:
|
|
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
|
raise ConfigError(f"{label}: must be 3 or 4 numbers (got {value!r})")
|
|
channels = [float(v) for v in value]
|
|
if any(v < 0.0 or v > 1.0 for v in channels):
|
|
raise ConfigError(f"{label}: channels must be in [0, 1] (got {value!r})")
|
|
if len(channels) == 3:
|
|
channels.append(1.0)
|
|
return (channels[0], channels[1], channels[2], channels[3])
|
|
|
|
|
|
def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Validate every pair up front so a partial run is never produced."""
|
|
raw = job.get("pairs")
|
|
if not isinstance(raw, list) or not raw:
|
|
raise ConfigError("job 'pairs' must be a non-empty list")
|
|
default_pattern = job.get("pattern_camera")
|
|
pairs: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
seen_paths: dict[str, str] = {}
|
|
for index, entry in enumerate(raw):
|
|
where = f"pairs[{index}]"
|
|
if not isinstance(entry, dict):
|
|
raise ConfigError(f"{where}: must be a mapping")
|
|
pair_id = entry.get("id")
|
|
if not isinstance(pair_id, str) or not pair_id.strip():
|
|
raise ConfigError(f"{where}.id: must be a non-empty string")
|
|
if pair_id in seen_ids:
|
|
raise ConfigError(f"{where}.id: duplicate pair id {pair_id!r}")
|
|
seen_ids.add(pair_id)
|
|
camera = entry.get("camera")
|
|
if not isinstance(camera, str) or not camera.strip():
|
|
raise ConfigError(f"{where}.camera: must be a non-empty region camera name")
|
|
pattern_camera = entry.get("pattern_camera") or default_pattern
|
|
if not isinstance(pattern_camera, str) or not pattern_camera.strip():
|
|
raise ConfigError(
|
|
f"{where}.pattern_camera: must name the pattern camera (or set the "
|
|
"job-level 'pattern_camera')"
|
|
)
|
|
uv_map = entry.get("uv_map")
|
|
if not isinstance(uv_map, str) or not uv_map.strip():
|
|
raise ConfigError(f"{where}.uv_map: must be a non-empty string")
|
|
artifacts = entry.get("artifacts")
|
|
if not isinstance(artifacts, dict):
|
|
raise ConfigError(f"{where}.artifacts: must be a mapping")
|
|
paths: dict[str, str] = {}
|
|
# The three print formats are required. The `gray_*` laser-engraving set is optional,
|
|
# so it is validated only when the job asks for it.
|
|
for format_name in (*ARTIFACT_FORMATS, *(f"gray_{f}" for f in ARTIFACT_FORMATS)):
|
|
value = artifacts.get(format_name)
|
|
if value is None and format_name.startswith("gray_"):
|
|
continue
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ConfigError(
|
|
f"{where}.artifacts.{format_name}: must be a non-empty path"
|
|
)
|
|
key = str(Path(value)).lower()
|
|
if key in seen_paths:
|
|
raise ConfigError(
|
|
f"{where}.artifacts.{format_name}: {value!r} is also written by "
|
|
f"{seen_paths[key]}"
|
|
)
|
|
seen_paths[key] = f"{pair_id}/{format_name}"
|
|
paths[format_name] = value
|
|
seed = entry.get("seed")
|
|
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
|
raise ConfigError(f"{where}.seed: must be an integer or null")
|
|
pairs.append(
|
|
{
|
|
"id": pair_id,
|
|
"camera": camera,
|
|
"pattern_camera": pattern_camera,
|
|
"uv_map": uv_map,
|
|
"seed": None if seed is None else int(seed),
|
|
# Carried through, not defaulted: dropping this silently ignored every
|
|
# per-pair `page:` override because the global default masked it.
|
|
"page": entry.get("page"),
|
|
"artifacts": paths,
|
|
}
|
|
)
|
|
return pairs
|
|
|
|
|
|
def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
|
if job.get("schema") != SCHEMA:
|
|
raise ConfigError(
|
|
f"job schema must be {SCHEMA!r} (got {job.get('schema')!r})"
|
|
)
|
|
objects = job.get("objects") or {}
|
|
if not isinstance(objects, dict):
|
|
raise ConfigError("job 'objects' must be a mapping")
|
|
settings = job.get("settings") or {}
|
|
if not isinstance(settings, dict):
|
|
raise ConfigError("job 'settings' must be a mapping")
|
|
vector = settings.get("vector") or {}
|
|
if not isinstance(vector, dict):
|
|
raise ConfigError("job 'settings.vector' must be a mapping")
|
|
cross = settings.get("cross") or {}
|
|
if not isinstance(cross, dict):
|
|
raise ConfigError("job 'settings.cross' must be a mapping")
|
|
|
|
seed = settings.get("seed")
|
|
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
|
raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})")
|
|
|
|
merge = job.get("merge")
|
|
if merge is not None and not isinstance(merge, dict):
|
|
raise ConfigError("job 'merge' must be a mapping")
|
|
try:
|
|
policy = MergePolicy(**(merge or {}))
|
|
except (MergeConfigError, TypeError) as exc:
|
|
raise ConfigError(f"job 'merge': {exc}") from exc
|
|
|
|
seam = job.get("seam") or {}
|
|
if not isinstance(seam, dict):
|
|
raise ConfigError("job 'seam' must be a mapping")
|
|
boundary = seam.get("boundary", "use_seam")
|
|
if boundary != "use_seam":
|
|
raise ConfigError(
|
|
f"job 'seam.boundary': only 'use_seam' is supported (got {boundary!r})"
|
|
)
|
|
pins: dict[int, str] = {}
|
|
for index, pin in enumerate(seam.get("pins") or []):
|
|
if not isinstance(pin, dict):
|
|
raise ConfigError(f"job 'seam.pins[{index}]': must be a mapping")
|
|
try:
|
|
component = int(pin["component"])
|
|
view = str(pin["view"])
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise ConfigError(
|
|
f"job 'seam.pins[{index}]': needs integer 'component' and string 'view'"
|
|
) from exc
|
|
if component in pins:
|
|
raise ConfigError(f"job 'seam.pins[{index}]': duplicate pin for component {component}")
|
|
pins[component] = view
|
|
|
|
gap = seam.get("gap") or {}
|
|
if not isinstance(gap, dict):
|
|
raise ConfigError("job 'seam.gap' must be a mapping")
|
|
gap_treatment = str(gap.get("treatment", "no_print"))
|
|
if gap_treatment != "no_print":
|
|
raise ConfigError(
|
|
f"job 'seam.gap.treatment': {gap_treatment!r} is not implemented; only 'no_print' "
|
|
"does anything today"
|
|
)
|
|
gap_enabled = bool(gap.get("enabled", False))
|
|
gap_apply = str(gap.get("apply", "post_process"))
|
|
gap_inset_mm = float(gap.get("inset_mm", 0.0))
|
|
gap_total_mm = float(gap.get("total_mm", 0.0))
|
|
gap_tolerance_mm = float(gap.get("alignment_tolerance_mm", 0.0))
|
|
if gap_enabled and gap_inset_mm <= 0.0:
|
|
raise ConfigError(
|
|
"job 'seam.gap': enabled but inset_mm is not positive; nothing would be removed"
|
|
)
|
|
if gap_apply not in ("post_process", "geometry_trim"):
|
|
raise ConfigError(
|
|
f"job 'seam.gap.apply': must be 'post_process' or 'geometry_trim' (got {gap_apply!r})"
|
|
)
|
|
|
|
print_block = job.get("print")
|
|
if print_block is not None and not isinstance(print_block, dict):
|
|
raise ConfigError("job 'print' must be a mapping")
|
|
base_fill = (print_block or {}).get("base_fill", "none")
|
|
if base_fill not in ("none", "inked", "owned"):
|
|
raise ConfigError(
|
|
f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})"
|
|
)
|
|
|
|
page_block = job.get("page") or {}
|
|
if not isinstance(page_block, dict):
|
|
raise ConfigError("job 'page' must be a mapping")
|
|
page_mode = str(page_block.get("mode", "auto"))
|
|
if page_mode not in ("auto", "fixed"):
|
|
raise ConfigError(f"job 'page.mode': must be 'auto' or 'fixed' (got {page_mode!r})")
|
|
px_per_mm = float(page_block.get("px_per_mm", page_sizing.DEFAULT_PX_PER_MM))
|
|
if px_per_mm <= 0.0:
|
|
raise ConfigError(f"job 'page.px_per_mm': must be > 0 (got {px_per_mm!r})")
|
|
|
|
return {
|
|
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
|
|
"base_fill": base_fill,
|
|
"page": {"mode": page_mode, "px_per_mm": px_per_mm},
|
|
"gap": {
|
|
"enabled": gap_enabled,
|
|
"apply": gap_apply,
|
|
"treatment": gap_treatment,
|
|
"inset_mm": gap_inset_mm,
|
|
"total_mm": gap_total_mm,
|
|
"alignment_tolerance_mm": gap_tolerance_mm,
|
|
},
|
|
"width": _as_int(settings.get("width", 2048), "settings.width"),
|
|
"height": _as_int(settings.get("height", 2048), "settings.height"),
|
|
"vector_width_mm": _as_positive_number(
|
|
vector.get("width_mm", 164.0), "settings.vector.width_mm"
|
|
),
|
|
"vector_height_mm": _as_positive_number(
|
|
vector.get("height_mm", 160.0), "settings.vector.height_mm"
|
|
),
|
|
"nx": _as_int(settings.get("nx", 80), "settings.nx"),
|
|
"ny": _as_int(settings.get("ny", 45), "settings.ny"),
|
|
"edge_sample_grid": _as_int(
|
|
settings.get("edge_sample_grid", 5), "settings.edge_sample_grid", minimum=2
|
|
),
|
|
"color_r": float(settings.get("color_r", _DEFAULT_COLOR_R)),
|
|
"seed": None if seed is None else int(seed),
|
|
"cross_enabled": bool(cross.get("enabled", True)),
|
|
"cross_length": _as_int(cross.get("length_px", 40), "settings.cross.length_px"),
|
|
"cross_thickness": _as_int(
|
|
cross.get("thickness_px", 3), "settings.cross.thickness_px"
|
|
),
|
|
"cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"),
|
|
"policy": policy,
|
|
"pins": pins,
|
|
"pairs": _resolve_pairs(job),
|
|
}
|
|
|
|
|
|
def _seam_edges(obj) -> list[tuple[int, int]]:
|
|
"""Marked ``use_seam`` edges, as vertex-index pairs.
|
|
|
|
Read from the object's own mesh: a seam is a topology flag, not an attribute, and the
|
|
surface has no modifiers, so raw and evaluated vertex indices coincide. (UV *data* is a
|
|
different matter — it is empty on the raw mesh and only materialises on the evaluated
|
|
one, which is why every UV read goes through ``build_patch_geometry``.)
|
|
"""
|
|
return [
|
|
(int(edge.vertices[0]), int(edge.vertices[1]))
|
|
for edge in obj.data.edges
|
|
if edge.use_seam
|
|
]
|
|
|
|
|
|
def _angles_from(cam_pos, centroid):
|
|
"""Azimuth/elevation/polar of one position about one centroid, in whatever frame given."""
|
|
import math
|
|
from mathutils import Vector
|
|
|
|
direction = (Vector(cam_pos) - Vector(centroid))
|
|
if direction.length <= 1e-12:
|
|
raise SceneError("camera coincides with the mesh centroid; angle is undefined")
|
|
distance = direction.length
|
|
direction.normalize()
|
|
return {
|
|
"position_mm": [round(v, 6) for v in cam_pos],
|
|
"view_direction": [round(v, 9) for v in direction],
|
|
"distance_to_centroid_mm": round(distance, 6),
|
|
"azimuth_deg": round(math.degrees(math.atan2(direction.y, direction.x)) % 360.0, 4),
|
|
"elevation_deg": round(
|
|
math.degrees(math.asin(max(-1.0, min(1.0, direction.z)))), 4
|
|
),
|
|
"polar_from_z_deg": round(
|
|
math.degrees(math.acos(max(-1.0, min(1.0, direction.z)))), 4
|
|
),
|
|
}
|
|
|
|
|
|
def _view_angles(obj, cam, centroid_world):
|
|
"""Camera pose as azimuth/elevation, in BOTH the world frame and the part's own frame.
|
|
|
|
Two frames, because they answer different questions and silently picking one is how a
|
|
reader ends up comparing an export against Blender and concluding the export is wrong:
|
|
|
|
* ``world`` — measured against the scene axes. This is the frame Blender's Transform panel
|
|
shows for the camera's location and rotation, so it is the one to check against.
|
|
* ``object_local`` — measured against the mesh object's own axes. Physically meaningful
|
|
for a body of revolution: ``polar_from_z_deg`` is then the angle between the view and the
|
|
part's axis, which is the number that says how obliquely the view meets the surface.
|
|
|
|
They differ whenever the mesh object carries a rotation. On `finger-xynova/r01`
|
|
`sensing_surface` is rotated 110 degrees about X, which alone flips the sign of the
|
|
reported elevation between the two frames.
|
|
|
|
Azimuth is ``atan2(y, x)`` in the XY plane of the chosen frame, from +X, wrapping to
|
|
[0, 360). Elevation is signed from that plane. ``polar_from_z_deg`` is from +Z.
|
|
"""
|
|
import math
|
|
from mathutils import Vector
|
|
|
|
world_pos = cam.matrix_world.translation.copy()
|
|
to_local = obj.matrix_world.inverted()
|
|
local_pos = to_local @ world_pos
|
|
local_centroid = to_local @ Vector(centroid_world)
|
|
|
|
out = {
|
|
"name": cam.name,
|
|
"type": cam.data.type,
|
|
"world": _angles_from(world_pos, centroid_world),
|
|
"object_local": _angles_from(local_pos, local_centroid),
|
|
# Recorded so the two frames above can be reconciled by hand against Blender, and so a
|
|
# non-unit camera scale cannot quietly change the framing unnoticed.
|
|
"camera_transform": {
|
|
"rotation_euler_deg": [
|
|
round(math.degrees(v), 4) for v in cam.matrix_world.to_euler()
|
|
],
|
|
"scale": [round(v, 6) for v in cam.matrix_world.to_scale()],
|
|
"parent": cam.parent.name if cam.parent else None,
|
|
},
|
|
"part_transform": {
|
|
"rotation_euler_deg": [round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()],
|
|
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
|
},
|
|
"ortho_scale_mm": round(float(cam.data.ortho_scale), 6) if cam.data.type == "ORTHO" else None,
|
|
"lens_mm": round(float(cam.data.lens), 6) if cam.data.type == "PERSP" else None,
|
|
"sensor_width_mm": round(float(cam.data.sensor_width), 6),
|
|
"clip_start_mm": round(float(cam.data.clip_start), 6),
|
|
"clip_end_mm": round(float(cam.data.clip_end), 6),
|
|
}
|
|
# Flat aliases in the WORLD frame, so the obvious key name means the obvious thing
|
|
# (what Blender shows) instead of the part frame.
|
|
out.update({
|
|
k: v for k, v in out["world"].items()
|
|
if k.endswith("_deg") or k in ("view_direction", "distance_to_centroid_mm", "position_mm")
|
|
})
|
|
out["azimuth_deg_frame"] = "world"
|
|
return out
|
|
|
|
|
|
def _diagnose_zero_fragments(obj, cam, scene, depsgraph, sample=400):
|
|
"""Explain a camera that produced no fragments, naming whatever steals the rays.
|
|
|
|
The usual cause is not a bad camera but a duplicate or occluding object: the same surface
|
|
present twice at identical coordinates, or housing geometry in front of it. Visibility
|
|
requires the first hit to be *this* object, so either case rejects every fragment — and
|
|
without this message the run just produces blank textures. Report the hit objects so the
|
|
author knows what to remove or hide.
|
|
"""
|
|
from collections import Counter
|
|
from mathutils import Vector
|
|
|
|
evaluated = obj.evaluated_get(depsgraph)
|
|
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
|
try:
|
|
mesh.calc_loop_triangles()
|
|
view = cam.matrix_world.normalized().inverted()
|
|
origin = cam.matrix_world.translation.copy()
|
|
forward = (cam.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0))).normalized()
|
|
triangles = list(mesh.loop_triangles)
|
|
step = max(1, len(triangles) // max(1, sample))
|
|
hits: Counter = Counter()
|
|
in_frame = 0
|
|
for triangle in triangles[::step]:
|
|
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
|
point = (world[0] + world[1] + world[2]) / 3.0
|
|
depth = -(view @ point).z
|
|
if depth <= 0.0:
|
|
hits["behind the camera"] += 1
|
|
continue
|
|
in_frame += 1
|
|
ray_origin = origin if cam.data.type == "PERSP" else point - forward * depth
|
|
direction = point - ray_origin
|
|
distance = direction.length
|
|
if distance <= 1e-9:
|
|
hits["degenerate ray"] += 1
|
|
continue
|
|
direction.normalize()
|
|
tolerance = max(1e-7, distance * 2e-5)
|
|
hit, location, _n, _i, hit_obj, _m = scene.ray_cast(
|
|
depsgraph, ray_origin, direction, distance=distance + tolerance
|
|
)
|
|
if not hit or hit_obj is None:
|
|
hits["nothing (ray missed)"] += 1
|
|
continue
|
|
name = getattr(hit_obj, "original", hit_obj).name
|
|
if name == obj.name:
|
|
hits[f"{name} (this object, but the hit point is off)"] += 1
|
|
else:
|
|
hits[name] += 1
|
|
lines = [
|
|
f"camera {cam.name!r}: sampled {len(triangles[::step])} triangles, "
|
|
f"{in_frame} in front of the camera",
|
|
]
|
|
for name, count in hits.most_common(6):
|
|
lines.append(f" first ray hit -> {name}: {count}")
|
|
top = hits.most_common(1)
|
|
if top and not top[0][0].startswith(obj.name):
|
|
lines.append(
|
|
f" '{top[0][0]}' is intercepting the rays. A duplicate surface at the same "
|
|
f"coordinates will do this: coincident geometry is not occluded by {obj.name!r} "
|
|
"and wins the raycast. Remove or hide the duplicate, or the mesh layer is not "
|
|
"the one being textured."
|
|
)
|
|
return lines
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def _triangle_seam_edges(
|
|
triangle_vertices: list[tuple[int, int, int]],
|
|
seam_edges: list[tuple[int, int]],
|
|
) -> dict[int, list[tuple[int, int]]]:
|
|
"""Map triangle index -> the seam edges that belong to it (at most three).
|
|
|
|
Only triangles touching the seam need a cut, so this keeps the post-process off the other
|
|
~98% of the surface.
|
|
"""
|
|
by_edge: dict[tuple[int, int], list[int]] = {}
|
|
for index, (a, b, c) in enumerate(triangle_vertices):
|
|
for x, y in ((a, b), (b, c), (c, a)):
|
|
by_edge.setdefault((x, y) if x < y else (y, x), []).append(index)
|
|
out: dict[int, list[tuple[int, int]]] = {}
|
|
for a, b in seam_edges:
|
|
key = (a, b) if a < b else (b, a)
|
|
for index in by_edge.get(key, ()):
|
|
out.setdefault(index, []).append(key)
|
|
return out
|
|
|
|
|
|
def _gray_pixel_polygon(uv_polygon, width, height):
|
|
"""The colour path's UV -> pixel mapping, reproduced exactly.
|
|
|
|
`generate_patch_texture` builds this as a nested closure over `texture_width`/`height`, so
|
|
it cannot be imported. Duplicating these two lines is the cost of keeping the gray image
|
|
registered with the colour image: **if the colour mapping ever changes, this must too.**
|
|
"""
|
|
return [
|
|
(u * (width - 1), v * (height - 1))
|
|
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)
|
|
]
|
|
|
|
|
|
def _gray_shapes(patches, patch_colors, width, height):
|
|
"""The laser-engraving artwork as (pixel_polygon, rgba) shapes.
|
|
|
|
**Polarity is inverted relative to the print texture, deliberately.** Most laser software
|
|
engraves what is DARK, and the print texture's background is black -- so a straight
|
|
luminance conversion would engrave the entire sheet and leave the marker cells almost
|
|
untouched. Here the page and the mesh ground are white (untouched) and only the pattern
|
|
cells carry tone, so the laser cuts the marker and nothing else.
|
|
|
|
Tone is Rec.709 luminance of the colour the cell would have been printed in, so engraving
|
|
depth varies cell to cell the way printed colour does. The seam margin is inherited from
|
|
``patches``, which is the already-clipped emitted set, so every gray format carries the
|
|
same alignment band as the colour file.
|
|
"""
|
|
shapes = []
|
|
for i, j, uv_poly in patches:
|
|
pixel_poly = _gray_pixel_polygon(uv_poly, width, height)
|
|
if len(pixel_poly) < 3:
|
|
continue
|
|
red, green, blue = patch_colors[j, i]
|
|
tone = 0.2126 * float(red) + 0.7152 * float(green) + 0.0722 * float(blue)
|
|
shapes.append((pixel_poly, (tone, tone, tone, 1.0)))
|
|
return shapes
|
|
|
|
|
|
def _write_grayscale_png(path, width, height, shapes):
|
|
"""Rasterise the laser-engraving artwork: pattern dark on white."""
|
|
grid = np.ones((height, width), dtype=np.float32)
|
|
for pixel_poly, (tone, _g, _b, _a) in shapes:
|
|
fill_polygon_value(grid, width, height, pixel_poly, tone)
|
|
|
|
rgba = np.ones((height, width, 4), dtype=np.float32)
|
|
rgba[:, :, 0] = grid
|
|
rgba[:, :, 1] = grid
|
|
rgba[:, :, 2] = grid
|
|
|
|
# Same row convention as the colour path: fill_polygon_value indexes grid[y, x] and the
|
|
# colour writer indexes pixels[(y * width + x) * 4], so the two images register exactly.
|
|
out = Path(path)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
name = "LaserGray"
|
|
if name in bpy.data.images:
|
|
bpy.data.images.remove(bpy.data.images[name])
|
|
image = bpy.data.images.new(name, width=width, height=height, alpha=True)
|
|
try:
|
|
image.pixels.foreach_set(rgba.reshape(-1))
|
|
image.update()
|
|
image.filepath_raw = str(out)
|
|
image.file_format = "PNG"
|
|
image.save()
|
|
finally:
|
|
bpy.data.images.remove(image)
|
|
|
|
|
|
def _write_grayscale_vector(
|
|
svg_path, pdf_path, width, height, page_width_mm, page_height_mm, shapes, uv_name
|
|
):
|
|
"""Write the laser-engraving artwork as vector geometry.
|
|
|
|
White page, grey pattern cells, no mesh layer and no reference cross -- the same artwork
|
|
`_write_grayscale_png` rasterises, so the three formats cannot disagree about what is cut.
|
|
|
|
The mesh ground is omitted rather than drawn white: the page background is already white,
|
|
so emitting thousands of coincident white polygons would bloat the file for nothing.
|
|
"""
|
|
# White because a laser cuts what is dark and an unengraved sheet must be light; see
|
|
# `_gray_shapes` for why the polarity is inverted relative to the print texture.
|
|
white = (1.0, 1.0, 1.0)
|
|
no_cross: list = []
|
|
if svg_path:
|
|
write_svg_vector(
|
|
svg_path, width, height, page_width_mm, page_height_mm,
|
|
[], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), uv_name, background=white,
|
|
)
|
|
if pdf_path:
|
|
write_pdf_vector(
|
|
pdf_path, width, height, page_width_mm, page_height_mm,
|
|
[], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), background=white,
|
|
)
|
|
|
|
|
|
def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm):
|
|
"""Derive and verify one pair's page from the geometry its layout projects.
|
|
|
|
Uses the same scale-normalised view matrix `patch_projection` clips with, so the page
|
|
agrees with the geometry that is actually textured. Dividing out the camera's object scale
|
|
is not optional: `finger-xynova`'s cameras carry 0.014988, which would otherwise report a
|
|
1414 mm extent for a 19 mm part.
|
|
"""
|
|
evaluated = obj.evaluated_get(depsgraph)
|
|
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
|
try:
|
|
layer = mesh.uv_layers.get(uv_name)
|
|
if layer is None:
|
|
raise SceneError(f"UV layer {uv_name!r} is missing from the evaluated mesh")
|
|
view = camera.matrix_world.normalized().inverted()
|
|
to_world = evaluated.matrix_world
|
|
uvs: list[tuple[float, float]] = []
|
|
planes: list[tuple[float, float]] = []
|
|
for loop in mesh.loops:
|
|
u, v = layer.data[loop.index].uv
|
|
local = view @ (to_world @ mesh.vertices[loop.vertex_index].co)
|
|
uvs.append((float(u), float(v)))
|
|
planes.append((float(local.x), float(local.y)))
|
|
return page_sizing.derive_page(uvs, planes, px_per_mm=px_per_mm)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def _mesh_centroid_world(obj, depsgraph):
|
|
"""Area-weighted centroid of the evaluated surface, in world mm.
|
|
|
|
Area-weighted rather than vertex-mean: the mesh is not uniformly tessellated, so a vertex
|
|
mean would drift toward whatever region happens to be subdivided more finely.
|
|
"""
|
|
from mathutils import Vector
|
|
|
|
evaluated = obj.evaluated_get(depsgraph)
|
|
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
|
try:
|
|
mesh.calc_loop_triangles()
|
|
total = 0.0
|
|
acc = Vector((0.0, 0.0, 0.0))
|
|
for triangle in mesh.loop_triangles:
|
|
p = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
|
area = (p[1] - p[0]).cross(p[2] - p[0]).length * 0.5
|
|
acc += (p[0] + p[1] + p[2]) / 3.0 * area
|
|
total += area
|
|
if total <= 0.0:
|
|
raise SceneError("sensing surface has no area; cannot report view angles")
|
|
centroid = acc / total
|
|
return [centroid.x, centroid.y, centroid.z]
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None:
|
|
path.write_text(
|
|
json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args(_argv_after_double_dash())
|
|
try:
|
|
job = _load_job(args.job.resolve())
|
|
stroke_params = None
|
|
if args.stroke_params is not None:
|
|
if args.stroke_output_dir is None:
|
|
raise ConfigError("--stroke-params requires --stroke-output-dir")
|
|
job, stroke_params = _stroke_job(
|
|
job, args.stroke_params.resolve(), args.stroke_output_dir.resolve()
|
|
)
|
|
elif args.stroke_output_dir is not None:
|
|
raise ConfigError("--stroke-output-dir requires --stroke-params")
|
|
config = _resolve_config(job)
|
|
except (ConfigError, OSError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
# Resolve every object and UV layer before rendering anything, so a missing camera or
|
|
# UV map cannot leave a half-written set of artifacts behind.
|
|
try:
|
|
obj = _prepare_mesh_object(config["mesh"])
|
|
resolved = []
|
|
for pair in config["pairs"]:
|
|
region_cam = _require_object(
|
|
pair["camera"], "CAMERA", f"view {pair['id']!r} region camera"
|
|
)
|
|
pattern_cam = _require_object(
|
|
pair["pattern_camera"], "CAMERA", f"view {pair['id']!r} pattern camera"
|
|
)
|
|
uv_layer = _require_uv_layer(obj, pair["uv_map"])
|
|
resolved.append((pair, region_cam, pattern_cam, uv_layer))
|
|
except SceneError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
for pair, _rc, _pc, _layer in resolved:
|
|
for format_name, output in pair["artifacts"].items():
|
|
try:
|
|
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
print(
|
|
f"ERROR: cannot create {format_name.upper()} directory for view "
|
|
f"{pair['id']!r} ({Path(output).parent}): {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
scene = bpy.context.scene
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
start_time = time.time()
|
|
policy: MergePolicy = config["policy"]
|
|
priority = [pair["id"] for pair, _a, _b, _c in resolved]
|
|
angle_map = None
|
|
if stroke_params is not None and stroke_params["orientation_mode"] == "optimized":
|
|
angle_map, report_data = optimize_angles(
|
|
config["nx"], config["ny"], stroke_params["seed"],
|
|
stroke_params["width_ratio"], stroke_params["length_ratio"],
|
|
stroke_params["proposals"],
|
|
)
|
|
report_path = args.stroke_output_dir / "stroke_texture_optimization.json"
|
|
report_path.write_text(json.dumps(report_data, indent=2) + "\n", encoding="utf-8")
|
|
|
|
detail(
|
|
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
|
|
f"{config['width']}x{config['height']} raster, "
|
|
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm, "
|
|
f"merge mode={policy.mode} limit={policy.incidence_limit_deg:g}deg"
|
|
)
|
|
|
|
# ---- 1. Region geometry per pair: the ownership authority ----------------------
|
|
region_fragments: dict[str, ViewFragments] = {}
|
|
meta: dict[str, Any] | None = None
|
|
for pair, region_cam, _pattern_cam, _layer in resolved:
|
|
try:
|
|
geometry = build_patch_geometry(
|
|
obj, region_cam, scene, depsgraph, [pair["uv_map"]],
|
|
config["nx"], config["ny"], config["edge_sample_grid"],
|
|
fill_unresolved_leaves=True,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(
|
|
f"ERROR: region geometry failed for view {pair['id']!r} "
|
|
f"(camera {pair['camera']!r}): {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
region_fragments[pair["id"]] = ViewFragments.from_meta(geometry["_meta"])
|
|
if meta is None:
|
|
meta = geometry["_meta"]
|
|
count = len(region_fragments[pair["id"]].triangle)
|
|
detail(
|
|
f" region {pair['id']!r} camera={pair['camera']!r}: {count} fragments"
|
|
)
|
|
if count == 0:
|
|
# Never ship a blank texture and call it success. Zero fragments from a region
|
|
# camera means either the camera is wrong or something is in front of the
|
|
# surface, and both produce an all-background artifact that looks deliberate.
|
|
print(
|
|
f"ERROR: region camera {pair['camera']!r} produced no fragments for view "
|
|
f"{pair['id']!r}; there is nothing to own and the texture would be blank.",
|
|
file=sys.stderr,
|
|
)
|
|
for line in _diagnose_zero_fragments(obj, region_cam, scene, depsgraph):
|
|
print(f" {line}", file=sys.stderr)
|
|
return 1
|
|
assert meta is not None
|
|
|
|
# ---- 2. Seam -> components, validated ------------------------------------------
|
|
components = None
|
|
seam_edges: list[tuple[int, int]] = []
|
|
if policy.mode == "authored":
|
|
seam_edges = _seam_edges(obj)
|
|
triangle_vertices = [tuple(v) for v in meta["triangle_vertices"]]
|
|
components = components_from_seam(triangle_vertices, seam_edges)
|
|
problems = validate_seam(triangle_vertices, seam_edges, components)
|
|
if problems:
|
|
for message in problems:
|
|
print(f"ERROR: seam: {message}", file=sys.stderr)
|
|
return 1
|
|
report(
|
|
f"seam: {len(seam_edges)} marked edges, "
|
|
f"{len(set(components))} component(s) of "
|
|
f"{[sum(1 for c in components if c == k) for k in sorted(set(components))]} triangles"
|
|
)
|
|
|
|
# ---- 3. Ownership --------------------------------------------------------------
|
|
try:
|
|
result = assign_ownership(
|
|
meta["triangle_area_mm2"], region_fragments, priority, policy,
|
|
components=components, pins=config["pins"],
|
|
)
|
|
except MergeConfigError as exc:
|
|
print(f"ERROR: ownership: {exc}", file=sys.stderr)
|
|
return 1
|
|
owner: dict[int, str] = result["owner"]
|
|
report(
|
|
f"ownership: {len(owner)}/{meta['triangle_count']} triangles owned, "
|
|
f"hole {result['hole_mm2']:.3f} mm^2 ({result['hole_fraction']:.4f}), "
|
|
f"per view "
|
|
+ ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items()))
|
|
)
|
|
if result["hole_fraction"] > policy.max_hole_fraction:
|
|
# An unowned region prints nothing, so it is a visible defect, not a statistic. This is
|
|
# the gate that turns "the surface has a bald patch" into a message naming the cause.
|
|
print(
|
|
f"ERROR: hole fraction {result['hole_fraction']:.4f} exceeds max_hole_fraction "
|
|
f"{policy.max_hole_fraction:g}: {result['hole_mm2']:.3f} mm^2 of the surface "
|
|
f"({meta['triangle_count'] - len(owner)} triangles) has no owning view, so it "
|
|
"would print nothing. The usual cause is the incidence limit: a view whose surface "
|
|
"is steeper than `incidence_limit_deg` cannot own it. Raise that limit (or re-pose "
|
|
f"the region cameras), or set `max_hole_fraction` above {result['hole_fraction']:.4f} "
|
|
"to accept the gap deliberately.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------
|
|
# The seam is passed here (not to the region pass) because only the emitted geometry needs
|
|
# to know how far each fragment sits from it.
|
|
gap = config["gap"]
|
|
seam_segments = None
|
|
if gap["enabled"] and seam_edges:
|
|
matrix_world = obj.matrix_world
|
|
vertices = obj.data.vertices
|
|
seam_segments = [
|
|
(matrix_world @ vertices[a].co, matrix_world @ vertices[b].co)
|
|
for a, b in seam_edges
|
|
]
|
|
print(
|
|
f" seam margin: {gap['treatment']}, total {gap['total_mm']:g} mm "
|
|
f"(alignment tolerance {gap['alignment_tolerance_mm']:g} mm), "
|
|
f"inset {gap['inset_mm']:g} mm per file"
|
|
)
|
|
elif gap["enabled"]:
|
|
print(
|
|
" seam margin: requested but there is no authored seam, so nothing is inset",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
triangle_seam_edges: dict[int, list[tuple[int, int]]] = {}
|
|
if gap["enabled"] and gap["apply"] == "post_process" and seam_edges:
|
|
triangle_seam_edges = _triangle_seam_edges(
|
|
[tuple(v) for v in meta["triangle_vertices"]], seam_edges
|
|
)
|
|
print(
|
|
f" seam margin: post-process clip on {len(triangle_seam_edges)} "
|
|
"seam-adjacent triangles"
|
|
)
|
|
|
|
pattern_cameras: dict[str, Any] = {}
|
|
for pair, _rc, pattern_cam, _layer in resolved:
|
|
pattern_cameras[pair["pattern_camera"]] = pattern_cam
|
|
if stroke_params is not None and len(pattern_cameras) != 1:
|
|
print("ERROR: stroke views require one shared pattern camera", file=sys.stderr)
|
|
return 2
|
|
uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved]
|
|
pattern_geometry: dict[str, Any] | None = None
|
|
pattern_meta: dict[str, Any] | None = None
|
|
for name, cam in pattern_cameras.items():
|
|
try:
|
|
built = build_patch_geometry(
|
|
obj, cam, scene, depsgraph, uv_maps,
|
|
config["nx"], config["ny"], config["edge_sample_grid"],
|
|
fill_unresolved_leaves=True,
|
|
seam_segments=seam_segments,
|
|
seam_search_mm=gap["inset_mm"],
|
|
capture_camera_fragments=stroke_params is not None,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(
|
|
f"ERROR: pattern geometry failed for camera {name!r}: {exc}", file=sys.stderr
|
|
)
|
|
return 1
|
|
if pattern_geometry is None:
|
|
pattern_geometry = built
|
|
pattern_meta = built["_meta"]
|
|
detail(
|
|
f" pattern camera {name!r}: "
|
|
f"{len(pattern_meta['fragment_triangle'])} fragments per layout"
|
|
)
|
|
assert pattern_geometry is not None and pattern_meta is not None
|
|
|
|
pattern_triangles = list(pattern_meta["fragment_triangle"])
|
|
stroke_outline_cache = {}
|
|
|
|
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
|
emitted_stats: dict[str, dict[str, Any]] = {}
|
|
stroke_views = []
|
|
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
|
pair_id = pair["id"]
|
|
uv_map = pair["uv_map"]
|
|
detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}")
|
|
if stroke_params is None and pair["seed"] is not None:
|
|
np.random.seed(pair["seed"])
|
|
detail(f" colour seed {pair['seed']}")
|
|
patch_colors = (precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
|
if stroke_params is None else None)
|
|
|
|
# ---- page: derived per view, or inherited from texture_patch ----------------
|
|
pair_page_mode = str(pair.get("page") or config["page"]["mode"])
|
|
if pair_page_mode == "auto":
|
|
try:
|
|
derived = _derive_pair_page(
|
|
obj, region_cam, uv_map, depsgraph, config["page"]["px_per_mm"]
|
|
)
|
|
except (page_sizing.PageSizingError, SceneError) as exc:
|
|
# Never fall back silently: keeping the shared page is what produced a 23%
|
|
# inverted aspect on finger-xynova.
|
|
print(
|
|
f"ERROR: cannot auto-size the page for view {pair_id!r}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
texture_width = derived.raster_width_px
|
|
texture_height = derived.raster_height_px
|
|
page_width_mm = derived.page_width_mm
|
|
page_height_mm = derived.page_height_mm
|
|
page_info = {
|
|
"page_source": "auto",
|
|
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
|
|
"raster_px": [texture_width, texture_height],
|
|
"page_residual": [derived.residual_u, derived.residual_v],
|
|
"page_slope": [derived.slope_u, derived.slope_v],
|
|
}
|
|
detail(f"[{pair_id}] {derived.describe()} (derived from {pair['camera']})")
|
|
else:
|
|
texture_width = config["width"]
|
|
texture_height = config["height"]
|
|
page_width_mm = config["vector_width_mm"]
|
|
page_height_mm = config["vector_height_mm"]
|
|
page_info = {
|
|
"page_source": "fixed",
|
|
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
|
|
"raster_px": [texture_width, texture_height],
|
|
}
|
|
|
|
patches = pattern_geometry[uv_map]["patches"]
|
|
camera_fragments = pattern_geometry[uv_map].get("camera_fragments")
|
|
mesh_layer = pattern_geometry[uv_map]["mesh"]
|
|
|
|
def fragment_polygon(k: int, patches=patches, camera_fragments=camera_fragments):
|
|
if stroke_params is None:
|
|
return patches[k][2]
|
|
i, j, fragment, loop_uvs = camera_fragments[k]
|
|
key = (i, j)
|
|
if key not in stroke_outline_cache:
|
|
stroke_outline_cache[key] = capsule_in_cell(
|
|
i, j, config["nx"], config["ny"], stroke_params["seed"],
|
|
stroke_params["width_ratio"], stroke_params["length_ratio"],
|
|
angle=None if angle_map is None else float(angle_map[j, i]),
|
|
)
|
|
return projected_stroke_uv(fragment, loop_uvs, stroke_outline_cache[key])
|
|
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
|
|
triangle_seam = pattern_meta.get("triangle_seam_distance_mm")
|
|
gap_cfg = config["gap"]
|
|
inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0
|
|
is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges
|
|
|
|
def outside_margin(distance: float | None, inset=inset) -> bool:
|
|
"""True when this fragment/triangle is clear of the seam margin."""
|
|
return distance is None or distance >= inset
|
|
|
|
plane_cache: dict[int, list] = {}
|
|
|
|
def gap_planes(triangle_index: int, plane_cache=plane_cache,
|
|
mesh_layer=mesh_layer, inset=inset):
|
|
"""Cached UV half-planes cutting this triangle back from its seam edges."""
|
|
cached = plane_cache.get(triangle_index)
|
|
if cached is None:
|
|
indices = meta["triangle_vertices"][triangle_index]
|
|
world = [obj.matrix_world @ obj.data.vertices[i].co for i in indices]
|
|
edges = [
|
|
(obj.matrix_world @ obj.data.vertices[a].co,
|
|
obj.matrix_world @ obj.data.vertices[b].co)
|
|
for a, b in triangle_seam_edges.get(triangle_index, ())
|
|
]
|
|
cached = seam_gap.triangle_gap_planes(
|
|
world, mesh_layer[triangle_index], edges, inset
|
|
)
|
|
plane_cache[triangle_index] = cached
|
|
return cached
|
|
|
|
owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id]
|
|
if is_post:
|
|
# Post-process: every owned fragment is kept, then clipped. A fragment is a
|
|
# triangle, so clipping against the seam's offset half-space leaves a straight edge
|
|
# at the requested physical distance instead of a fragment-sized sawtooth.
|
|
keep = list(owned_here)
|
|
emitted_patches = []
|
|
trimmed = 0
|
|
for k in keep:
|
|
i, j, _ = patches[k]
|
|
polygon = fragment_polygon(k)
|
|
if len(polygon) < 3:
|
|
continue
|
|
planes = gap_planes(pattern_triangles[k])
|
|
if planes:
|
|
before_area = seam_gap.polygon_area(polygon)
|
|
clipped = seam_gap.clip_polygon(polygon, planes)
|
|
if len(clipped) < 3:
|
|
continue
|
|
# Most fragments are cut rather than removed, so counting removals alone
|
|
# would understate what the margin did.
|
|
if clipped != list(polygon) and (
|
|
abs(seam_gap.polygon_area(clipped) - before_area) > 1e-12
|
|
):
|
|
trimmed += 1
|
|
polygon = clipped
|
|
emitted_patches.append((i, j, polygon))
|
|
trimmed_polygons = trimmed
|
|
else:
|
|
# Trim mode: drop whole fragments inside the margin.
|
|
keep = [
|
|
k for k in owned_here
|
|
if outside_margin(None if fragment_seam is None else fragment_seam[k])
|
|
]
|
|
emitted_patches = [
|
|
(patches[k][0], patches[k][1], polygon)
|
|
for k in keep
|
|
if len(polygon := fragment_polygon(k)) >= 3
|
|
]
|
|
|
|
owned_triangles = sorted(t for t, v in owner.items() if v == pair_id)
|
|
kept_triangles = sorted({pattern_triangles[k] for k in keep})
|
|
# The mesh layer is the white ground the patches sit on; it has to respect the margin
|
|
# too, or the ground would bridge the very band the margin exists to leave empty.
|
|
if config["base_fill"] == "none":
|
|
base_layer: list[Any] = []
|
|
else:
|
|
candidates = kept_triangles if config["base_fill"] == "inked" else owned_triangles
|
|
base_layer = []
|
|
for t in candidates:
|
|
if is_post:
|
|
planes = gap_planes(t)
|
|
polygon = mesh_layer[t]
|
|
if planes:
|
|
polygon = seam_gap.clip_polygon(polygon, planes)
|
|
if len(polygon) < 3:
|
|
continue
|
|
base_layer.append(polygon)
|
|
elif outside_margin(None if triangle_seam is None else triangle_seam[t]):
|
|
base_layer.append(mesh_layer[t])
|
|
filtered = {"mesh": base_layer, "patches": emitted_patches}
|
|
|
|
# What the margin actually achieved. Post-process hits the requested width by
|
|
# construction (it cuts at the offset half-space); trim mode can only be measured,
|
|
# because it snaps outward to whole fragments.
|
|
if is_post:
|
|
achieved_gap = gap_cfg["total_mm"]
|
|
else:
|
|
surviving = [fragment_seam[k] for k in keep] if fragment_seam is not None else []
|
|
achieved_gap = (2.0 * min(surviving)) if surviving else None
|
|
emitted_stats[pair_id] = {
|
|
"uv_map": uv_map,
|
|
"region_camera": pair["camera"],
|
|
"pattern_camera": pair["pattern_camera"],
|
|
**page_info,
|
|
"owned_triangles": len(owned_triangles),
|
|
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
|
|
"inked_triangles": len(kept_triangles),
|
|
"inked_mm2": (sum(meta["triangle_area_mm2"][t] for t in kept_triangles)
|
|
if stroke_params is None else None),
|
|
"emitted_fragments": len(emitted_patches),
|
|
"total_fragments": len(patches),
|
|
"gap_enabled": gap_cfg["enabled"],
|
|
"gap_apply": gap_cfg["apply"] if gap_cfg["enabled"] else None,
|
|
"gap_requested_total_mm": gap_cfg["total_mm"] if gap_cfg["enabled"] else 0.0,
|
|
"gap_achieved_total_mm": None if achieved_gap is None else round(achieved_gap, 6),
|
|
"gap_removed_fragments": len(owned_here) - len(keep),
|
|
"gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0,
|
|
"gap_trimmed_polygons": trimmed_polygons if is_post else 0,
|
|
"base_triangles": len(base_layer),
|
|
"seed": stroke_params["seed"] if stroke_params is not None else pair["seed"],
|
|
"artifacts": pair["artifacts"],
|
|
}
|
|
summary = (
|
|
f" emitting {len(emitted_patches)} of {len(patches)} fragments over "
|
|
f"{len(kept_triangles)} triangles; base fill={config['base_fill']}"
|
|
)
|
|
if gap_cfg["enabled"]:
|
|
summary += f"; margin[{gap_cfg['apply']}] {gap_cfg['total_mm']:g} mm"
|
|
if is_post:
|
|
summary += (
|
|
f", {trimmed_polygons} trimmed, "
|
|
f"{len(keep) - len(emitted_patches)} clipped away"
|
|
)
|
|
else:
|
|
summary += (
|
|
f", removed {len(owned_here) - len(keep)} fragments, "
|
|
f"achieved {achieved_gap:.4g} mm"
|
|
if achieved_gap is not None else ", nothing removed"
|
|
)
|
|
report(summary)
|
|
|
|
try:
|
|
if stroke_params is None:
|
|
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
|
generate_patch_texture(
|
|
obj, pattern_cameras[pair["pattern_camera"]], scene, depsgraph,
|
|
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
|
texture_width=texture_width, texture_height=texture_height,
|
|
output_path=pair["artifacts"]["png"],
|
|
svg_path=pair["artifacts"]["svg"],
|
|
pdf_path=pair["artifacts"]["pdf"],
|
|
vector_width_mm=page_width_mm, vector_height_mm=page_height_mm,
|
|
uv_centre=uv_centre, cross_length=config["cross_length"],
|
|
cross_thickness=config["cross_thickness"],
|
|
cross_color=config["cross_color"],
|
|
edge_sample_grid=config["edge_sample_grid"], uv_layer=uv_layer,
|
|
image_name=f"PatchTexture_{pair_id}", patch_colors=patch_colors,
|
|
geometry=filtered,
|
|
)
|
|
else:
|
|
stroke_views.append(render_stroke_polygons(
|
|
uv_map, base_layer, emitted_patches,
|
|
texture_width, texture_height,
|
|
{"width_mm": page_width_mm, "height_mm": page_height_mm},
|
|
Path(pair["artifacts"]["png"]).with_suffix(""),
|
|
view_id=pair_id,
|
|
))
|
|
except Exception as exc: # noqa: BLE001
|
|
print(
|
|
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# The laser-engraving set is written before the artifact check below, so it is verified
|
|
# by the same existence test as the print set rather than trusted.
|
|
gray_paths = {
|
|
name: pair["artifacts"].get(f"gray_{name}") for name in ARTIFACT_FORMATS
|
|
}
|
|
if any(gray_paths.values()):
|
|
try:
|
|
gray_shapes = _gray_shapes(
|
|
emitted_patches, patch_colors, texture_width, texture_height
|
|
)
|
|
if gray_paths["png"]:
|
|
_write_grayscale_png(
|
|
gray_paths["png"], texture_width, texture_height, gray_shapes
|
|
)
|
|
_write_grayscale_vector(
|
|
gray_paths["svg"], gray_paths["pdf"],
|
|
texture_width, texture_height, page_width_mm, page_height_mm,
|
|
gray_shapes, uv_map,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(
|
|
f"ERROR: laser-engraving texture failed for view {pair_id!r}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
detail(
|
|
f"[{pair_id}] laser gray {texture_width}x{texture_height} px, "
|
|
f"{len(gray_shapes)} cells -> "
|
|
+ ", ".join(
|
|
Path(path).name for path in gray_paths.values() if path
|
|
)
|
|
)
|
|
|
|
for format_name, output in pair["artifacts"].items():
|
|
path = Path(output)
|
|
if not path.is_file() or path.stat().st_size == 0:
|
|
print(
|
|
f"ERROR: {format_name.upper()} artifact was not written for view "
|
|
f"{pair_id!r}: {path}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# ---- 6. Print-view report: the angle each texture is printed from ---------------
|
|
# Ships next to the textures because the angle is what an operator or a downstream
|
|
# machine needs in order to place the part for each pass.
|
|
centroid_world = _mesh_centroid_world(obj, depsgraph)
|
|
centroid_local = obj.matrix_world.inverted() @ Vector(centroid_world)
|
|
inked_triangles = {t for t in set(pattern_triangles) if owner.get(t) is not None}
|
|
triangle_area = meta["triangle_area_mm2"]
|
|
angle_report: dict[str, Any] = {
|
|
"schema": PRINT_VIEWS_SCHEMA,
|
|
"mesh": config["mesh"],
|
|
"units": "mm",
|
|
"angle_convention": (
|
|
"TWO frames are reported per camera, because they answer different questions. "
|
|
"`world` is measured against the scene axes: that is the frame Blender's "
|
|
"Transform panel shows for the camera's location and rotation, so check the "
|
|
"numbers here against Blender. `object_local` is measured against the mesh "
|
|
"object's own axes, which is the physically meaningful frame when the part is a "
|
|
"body of revolution. They differ whenever the mesh object carries a rotation. "
|
|
"In both: azimuth = atan2(y, x) in the frame's XY plane from +X, wrapped to "
|
|
"[0, 360); elevation = signed angle from that plane; polar_from_z_deg = angle "
|
|
"from that frame's +Z. The flat azimuth_deg/elevation_deg keys are the WORLD frame."
|
|
),
|
|
"centroid_world_mm": [round(v, 6) for v in centroid_world],
|
|
"centroid_object_mm": [round(v, 6) for v in centroid_local],
|
|
"part_transform": {
|
|
"rotation_euler_deg": [
|
|
round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()
|
|
],
|
|
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
|
},
|
|
"settings": {
|
|
"nx": config["nx"],
|
|
"ny": config["ny"],
|
|
# The fallback page/raster for pairs with `page: fixed`; with `page: auto` the
|
|
# per-view values below are authoritative.
|
|
"default_raster": [config["width"], config["height"]],
|
|
"default_page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
|
|
"page": dict(config["page"]),
|
|
"seed": config["seed"],
|
|
},
|
|
"ownership": {
|
|
"mode": policy.mode,
|
|
"incidence_limit_deg": policy.incidence_limit_deg,
|
|
"min_footprint_mm2": policy.min_footprint_mm2,
|
|
"min_triangle_coverage": policy.min_triangle_coverage,
|
|
"max_hole_fraction": policy.max_hole_fraction,
|
|
"triangles": meta["triangle_count"],
|
|
"owned_mm2": round(result["owned_mm2"], 6),
|
|
"hole_mm2": round(result["hole_mm2"], 6),
|
|
"total_mm2": round(result["total_mm2"], 6),
|
|
"hole_fraction": round(result["hole_fraction"], 8),
|
|
"inked_mm2": (round(sum(triangle_area[t] for t in inked_triangles), 6)
|
|
if stroke_params is None else None),
|
|
"inked_fraction": (round(
|
|
sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8
|
|
) if result["total_mm2"] else 0.0) if stroke_params is None else None,
|
|
},
|
|
"seam_margin": dict(config["gap"]),
|
|
"views": [],
|
|
}
|
|
if components is not None:
|
|
angle_report["seam"] = {
|
|
"boundary": "use_seam",
|
|
"edges": len(seam_edges),
|
|
"components": (max(components) + 1) if components else 0,
|
|
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
|
}
|
|
|
|
for pair, region_cam, _pattern_cam, _layer in resolved:
|
|
entry = _view_angles(obj, region_cam, centroid_world)
|
|
entry["id"] = pair["id"]
|
|
entry.update(emitted_stats[pair["id"]])
|
|
angle_report["views"].append(entry)
|
|
|
|
angle_report["pattern_camera"] = _view_angles(
|
|
obj, pattern_cameras[resolved[0][0]["pattern_camera"]], centroid_world
|
|
)
|
|
|
|
report_path = Path(resolved[0][0]["artifacts"]["png"]).parent / PRINT_VIEWS_FILENAME
|
|
report_path.write_text(json.dumps(angle_report, indent=2) + "\n", encoding="utf-8")
|
|
detail("Print views (azimuth/elevation; `world` matches Blender's Transform panel):")
|
|
for entry in angle_report["views"]:
|
|
world = entry["world"]
|
|
local = entry["object_local"]
|
|
detail(
|
|
f" [{entry['id']}] region={entry['region_camera']} {entry['type']}\n"
|
|
f" world az={world['azimuth_deg']:8.2f} el={world['elevation_deg']:+8.2f} "
|
|
f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n"
|
|
f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} "
|
|
f"polar={local['polar_from_z_deg']:7.2f}\n"
|
|
f" owned={entry['owned_mm2']:.3f}mm^2 "
|
|
+ (f"inked={entry['inked_mm2']:.3f}mm^2" if stroke_params is None
|
|
else "stroke ink area not measured")
|
|
)
|
|
pc = angle_report["pattern_camera"]
|
|
detail(
|
|
f" [pattern] {pc['name']} {pc['type']}\n"
|
|
f" world az={pc['world']['azimuth_deg']:8.2f} "
|
|
f"el={pc['world']['elevation_deg']:+8.2f} polar={pc['world']['polar_from_z_deg']:7.2f}\n"
|
|
f" object-local az={pc['object_local']['azimuth_deg']:8.2f} "
|
|
f"el={pc['object_local']['elevation_deg']:+8.2f} "
|
|
f"polar={pc['object_local']['polar_from_z_deg']:7.2f}"
|
|
)
|
|
detail(f" angle report -> {report_path}")
|
|
|
|
# ---- 7. Ground truth: makes every no-Blender test runnable on real fragments ----
|
|
truth = {
|
|
"schema": GROUND_TRUTH_SCHEMA,
|
|
"mesh": config["mesh"],
|
|
"pattern_camera": sorted(pattern_cameras),
|
|
"policy": {
|
|
"mode": policy.mode,
|
|
"incidence_limit_deg": policy.incidence_limit_deg,
|
|
"min_footprint_mm2": policy.min_footprint_mm2,
|
|
"min_triangle_coverage": policy.min_triangle_coverage,
|
|
"max_hole_fraction": policy.max_hole_fraction,
|
|
"component_policy": policy.component_policy,
|
|
},
|
|
"seam": {
|
|
"boundary": "use_seam",
|
|
"edge_count": len(seam_edges),
|
|
"component_count": (max(components) + 1) if components else 0,
|
|
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
|
},
|
|
"ownership": {
|
|
"owner": {str(k): v for k, v in sorted(owner.items())},
|
|
"owned_mm2": result["owned_mm2"],
|
|
"hole_mm2": result["hole_mm2"],
|
|
"total_mm2": result["total_mm2"],
|
|
"hole_fraction": result["hole_fraction"],
|
|
"per_view_mm2": result["per_view_mm2"],
|
|
"per_view_triangles": result["per_view_triangles"],
|
|
},
|
|
"triangle_area_mm2": list(meta["triangle_area_mm2"]),
|
|
"triangle_vertices": [list(v) for v in meta["triangle_vertices"]],
|
|
"components": list(components) if components is not None else None,
|
|
"views": {
|
|
view: {
|
|
"triangle": list(fragments.triangle),
|
|
"incidence_deg": list(fragments.incidence_deg),
|
|
"footprint_mm2": list(fragments.footprint_mm2),
|
|
}
|
|
for view, fragments in region_fragments.items()
|
|
},
|
|
"emitted": {
|
|
pair["id"]: {
|
|
"uv_map": pair["uv_map"],
|
|
"fragment_triangles": [
|
|
pattern_triangles[k]
|
|
for k in range(len(pattern_triangles))
|
|
if owner.get(pattern_triangles[k]) == pair["id"]
|
|
],
|
|
}
|
|
for pair, _a, _b, _c in resolved
|
|
},
|
|
}
|
|
truth_path = Path(resolved[0][0]["artifacts"]["png"]).parent / GROUND_TRUTH_FILENAME
|
|
_write_ground_truth(truth_path, truth)
|
|
detail(f" ground truth -> {truth_path}")
|
|
|
|
if stroke_params is not None:
|
|
stroke_report_path = truth_path.parent / STROKE_REPORT_FILENAME
|
|
write_stroke_report(stroke_report_path, args.job.resolve(), stroke_params,
|
|
stroke_views)
|
|
report(f"stroke production report -> {stroke_report_path}")
|
|
|
|
# The artifact inventory is owned by the HOST, which is the only side that can verify the
|
|
# files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice.
|
|
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|