added svg patch output
This commit is contained in:
@@ -6,10 +6,12 @@ and invokes this script. Blender only needs stdlib json — not PyYAML:
|
||||
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
|
||||
--job <abs/build/textures/finger/r01/_texture_job.json>
|
||||
|
||||
Manual single-target mode (cannot be combined with --job):
|
||||
Manual single-target mode (cannot be combined with --job) derives same-stem
|
||||
SVG/PDF paths from the PNG path:
|
||||
|
||||
--uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
|
||||
--width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
|
||||
--width 3280 --height 3200 --vector-width-mm 164 --vector-height-mm 160 \
|
||||
--nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
|
||||
|
||||
Scope: the colored "patches" mode only. checker/dots helpers remain available to
|
||||
callers but are not reachable from the headless entry point. The mesh must
|
||||
@@ -34,39 +36,40 @@ from mathutils import Vector
|
||||
# ----------------------------------------------------------------------
|
||||
# Common Utilities
|
||||
# ----------------------------------------------------------------------
|
||||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||
"""
|
||||
Draw a cross (plus sign) in pixel space.
|
||||
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
|
||||
length_px: total length of each arm (pixels).
|
||||
thickness_px: line thickness (odd number gives symmetric result).
|
||||
"""
|
||||
# Convert UV centre to pixel coordinates (y from bottom)
|
||||
def cross_rectangles(width, height, cx_uv, cy_uv, length_px, thickness_px):
|
||||
"""Return horizontal/vertical cross arms as bottom-left pixel rectangles."""
|
||||
cx = int(round(cx_uv * (width - 1)))
|
||||
cy = int(round(cy_uv * (height - 1)))
|
||||
|
||||
half_len = length_px // 2
|
||||
half_thick = thickness_px // 2
|
||||
|
||||
# Horizontal arm
|
||||
x0 = max(0, cx - half_len)
|
||||
x1 = min(width - 1, cx + half_len)
|
||||
y0 = max(0, cy - half_thick)
|
||||
y1 = min(height - 1, cy + half_thick)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
horizontal = (
|
||||
max(0, cx - half_len),
|
||||
max(0, cy - half_thick),
|
||||
min(width - 1, cx + half_len),
|
||||
min(height - 1, cy + half_thick),
|
||||
)
|
||||
vertical = (
|
||||
max(0, cx - half_thick),
|
||||
max(0, cy - half_len),
|
||||
min(width - 1, cx + half_thick),
|
||||
min(height - 1, cy + half_len),
|
||||
)
|
||||
return [
|
||||
(x0, y0, x1 - x0 + 1, y1 - y0 + 1)
|
||||
for x0, y0, x1, y1 in (horizontal, vertical)
|
||||
]
|
||||
|
||||
# Vertical arm
|
||||
x0 = max(0, cx - half_thick)
|
||||
x1 = min(width - 1, cx + half_thick)
|
||||
y0 = max(0, cy - half_len)
|
||||
y1 = min(height - 1, cy + half_len)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||
"""Draw a cross (plus sign), using the same rectangles as vector output."""
|
||||
for x0, y0, rect_width, rect_height in cross_rectangles(
|
||||
width, height, cx_uv, cy_uv, length_px, thickness_px
|
||||
):
|
||||
for y in range(y0, y0 + rect_height):
|
||||
for x in range(x0, x0 + rect_width):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def fill_background(pixels, width, height, color):
|
||||
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
|
||||
@@ -353,6 +356,25 @@ def sample_patch_uv_polygon(
|
||||
|
||||
return convex_hull_uv(sampled_uvs)
|
||||
|
||||
def mesh_uv_pixel_polygons(obj, tex_width, tex_height, uv_layer=None):
|
||||
"""Return every mesh face as seam-aware pixel-space polygons."""
|
||||
me = obj.data
|
||||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||
if not layer:
|
||||
return []
|
||||
uv_data = layer.data
|
||||
pixel_polygons = []
|
||||
for poly in me.polygons:
|
||||
uv_poly = []
|
||||
for loop_idx in poly.loop_indices:
|
||||
uv = uv_data[loop_idx].uv
|
||||
uv_poly.append((uv.x, uv.y))
|
||||
pixel_polygons.extend(
|
||||
uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height)
|
||||
)
|
||||
return pixel_polygons
|
||||
|
||||
|
||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
|
||||
"""
|
||||
Fill all triangles of one UV map with 'color' (default white).
|
||||
@@ -361,23 +383,14 @@ def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_l
|
||||
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
|
||||
behaviour used by the checker/dot helpers.
|
||||
"""
|
||||
me = obj.data
|
||||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||
if not layer:
|
||||
pixel_polygons = mesh_uv_pixel_polygons(
|
||||
obj, tex_width, tex_height, uv_layer=uv_layer
|
||||
)
|
||||
if not pixel_polygons:
|
||||
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
||||
return
|
||||
uv_data = layer.data
|
||||
for poly in me.polygons:
|
||||
# Get loop indices for this polygon
|
||||
loops = poly.loop_indices
|
||||
# Build UV polygon
|
||||
uv_poly = []
|
||||
for loop_idx in loops:
|
||||
uv = uv_data[loop_idx].uv
|
||||
uv_poly.append((uv.x, uv.y))
|
||||
# Convert UV (0‑1) to pixel coordinates
|
||||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||||
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||
for pixel_poly in pixel_polygons:
|
||||
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||
|
||||
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
||||
"""Rasterize the active UV map to a boolean mesh mask."""
|
||||
@@ -515,6 +528,315 @@ def checker_patch_color(i, j):
|
||||
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
|
||||
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Native vector writers (SVG + PDF; no raster image embedding)
|
||||
# ----------------------------------------------------------------------
|
||||
def _format_number(value):
|
||||
"""Compact deterministic decimal formatting for SVG/PDF coordinates."""
|
||||
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
|
||||
return "0" if text in ("", "-0") else text
|
||||
|
||||
|
||||
def _clamp_channel(value):
|
||||
return max(0.0, min(1.0, float(value)))
|
||||
|
||||
|
||||
def _svg_fill_attributes(color):
|
||||
r, g, b, a = (_clamp_channel(channel) for channel in color)
|
||||
fill = f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
|
||||
opacity = "" if a >= 0.999999 else f' fill-opacity="{_format_number(a)}"'
|
||||
return f'fill="{fill}"{opacity} stroke="none"'
|
||||
|
||||
|
||||
def _svg_polygon_element(element_id, polygon, color, indent=" "):
|
||||
points = " ".join(
|
||||
f"{_format_number(x)},{_format_number(y)}" for x, y in polygon
|
||||
)
|
||||
return (
|
||||
f'{indent}<polygon id="{element_id}" points="{points}" '
|
||||
f'{_svg_fill_attributes(color)}/>'
|
||||
)
|
||||
|
||||
|
||||
def _xml_escape(value):
|
||||
return (
|
||||
str(value)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def _warn_if_page_aspect_differs(
|
||||
texture_width, texture_height, width_mm, height_mm, tolerance=1e-6
|
||||
):
|
||||
"""Warn when the vector page would non-uniformly scale the artwork.
|
||||
|
||||
SVG/PDF use the full page box, so a page whose aspect ratio differs from the
|
||||
raster's stretches patches along one axis. The default metadata matches
|
||||
(164x160 mm for 3280x3200 px), so this only fires on deliberate mismatch.
|
||||
"""
|
||||
raster_aspect = float(texture_width) / float(texture_height)
|
||||
page_aspect = float(width_mm) / float(height_mm)
|
||||
if abs(raster_aspect - page_aspect) <= tolerance * max(raster_aspect, page_aspect):
|
||||
return
|
||||
print(
|
||||
f"WARNING: vector page aspect {page_aspect:.6f} "
|
||||
f"({width_mm:g}x{height_mm:g} mm) differs from the raster aspect "
|
||||
f"{raster_aspect:.6f} ({texture_width}x{texture_height} px); geometry is "
|
||||
f"scaled non-uniformly ({width_mm / texture_width:g} mm/px horizontally "
|
||||
f"vs {height_mm / texture_height:g} mm/px vertically).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _clip_polygon_half_plane(points, axis, boundary, keep_greater):
|
||||
"""Clip a polygon against one half-plane (Sutherland-Hodgman step)."""
|
||||
if not points:
|
||||
return []
|
||||
|
||||
def inside(point):
|
||||
return point[axis] >= boundary if keep_greater else point[axis] <= boundary
|
||||
|
||||
result = []
|
||||
previous = points[-1]
|
||||
previous_inside = inside(previous)
|
||||
for current in points:
|
||||
current_inside = inside(current)
|
||||
if current_inside != previous_inside:
|
||||
denominator = current[axis] - previous[axis]
|
||||
if denominator != 0.0:
|
||||
t = (boundary - previous[axis]) / denominator
|
||||
result.append(
|
||||
(
|
||||
previous[0] + t * (current[0] - previous[0]),
|
||||
previous[1] + t * (current[1] - previous[1]),
|
||||
)
|
||||
)
|
||||
if current_inside:
|
||||
result.append(current)
|
||||
previous, previous_inside = current, current_inside
|
||||
return result
|
||||
|
||||
|
||||
def clip_polygon_to_page(polygon, width, height):
|
||||
"""Clip a pixel-space polygon to the page rectangle; [] when fully outside.
|
||||
|
||||
Seam-crossing UV faces and patches are duplicated at ±1 UV period, so their
|
||||
pixel polygons can extend past the texture. The raster clamps each scanline,
|
||||
but vector output would otherwise carry stray objects outside the artwork.
|
||||
"""
|
||||
points = [(float(x), float(y)) for x, y in polygon]
|
||||
for axis, boundary, keep_greater in (
|
||||
(0, 0.0, True),
|
||||
(0, float(width), False),
|
||||
(1, 0.0, True),
|
||||
(1, float(height), False),
|
||||
):
|
||||
points = _clip_polygon_half_plane(points, axis, boundary, keep_greater)
|
||||
if len(points) < 3:
|
||||
return []
|
||||
return points
|
||||
|
||||
|
||||
def _clipped_polygons(polygons, width, height):
|
||||
clipped = []
|
||||
for polygon in polygons:
|
||||
page_polygon = clip_polygon_to_page(polygon, width, height)
|
||||
if page_polygon:
|
||||
clipped.append(page_polygon)
|
||||
return clipped
|
||||
|
||||
|
||||
def _clipped_shapes(shapes, width, height):
|
||||
clipped = []
|
||||
for polygon, color in shapes:
|
||||
page_polygon = clip_polygon_to_page(polygon, width, height)
|
||||
if page_polygon:
|
||||
clipped.append((page_polygon, color))
|
||||
return clipped
|
||||
|
||||
|
||||
def write_svg_vector(
|
||||
output_path, texture_width, texture_height, width_mm, height_mm,
|
||||
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map
|
||||
):
|
||||
"""Write editable grouped SVG geometry; no raster data is embedded."""
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'width="{_format_number(width_mm)}mm" '
|
||||
f'height="{_format_number(height_mm)}mm" '
|
||||
f'viewBox="0 0 {texture_width} {texture_height}" '
|
||||
f'preserveAspectRatio="none">'
|
||||
),
|
||||
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>",
|
||||
(
|
||||
" <desc>Native vector geometry grouped as background, mesh, "
|
||||
"camera-space patches, and reference cross.</desc>"
|
||||
),
|
||||
(
|
||||
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
|
||||
),
|
||||
' <g id="background">',
|
||||
(
|
||||
f' <rect id="background-fill" x="0" y="0" '
|
||||
f'width="{texture_width}" height="{texture_height}" '
|
||||
f'fill="#000000" stroke="none"/>'
|
||||
),
|
||||
" </g>",
|
||||
' <g id="mesh">',
|
||||
]
|
||||
for index, polygon in enumerate(mesh_polygons, start=1):
|
||||
lines.append(
|
||||
_svg_polygon_element(
|
||||
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
)
|
||||
lines.extend([" </g>", ' <g id="patches">'])
|
||||
for index, (polygon, color) in enumerate(patch_shapes, start=1):
|
||||
lines.append(
|
||||
_svg_polygon_element(f"patch-{index:04d}", polygon, color)
|
||||
)
|
||||
lines.extend([" </g>", ' <g id="reference-cross">'])
|
||||
cross_attributes = _svg_fill_attributes(cross_color)
|
||||
for index, (x, y, rect_width, rect_height) in enumerate(cross_rects, start=1):
|
||||
lines.append(
|
||||
f' <rect id="cross-arm-{index}" x="{_format_number(x)}" '
|
||||
f'y="{_format_number(y)}" width="{_format_number(rect_width)}" '
|
||||
f'height="{_format_number(rect_height)}" {cross_attributes}/>'
|
||||
)
|
||||
lines.extend([" </g>", " </g>", "</svg>", ""])
|
||||
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"SVG vector texture saved to {path}")
|
||||
|
||||
|
||||
def _pdf_set_fill(color):
|
||||
r, g, b = (_clamp_channel(channel) for channel in color[:3])
|
||||
return f"{_format_number(r)} {_format_number(g)} {_format_number(b)} rg"
|
||||
|
||||
|
||||
def _pdf_polygon_commands(polygon, scale_x, scale_y):
|
||||
if len(polygon) < 3:
|
||||
return []
|
||||
first_x, first_y = polygon[0]
|
||||
commands = [
|
||||
f"{_format_number(first_x * scale_x)} {_format_number(first_y * scale_y)} m"
|
||||
]
|
||||
commands.extend(
|
||||
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} l"
|
||||
for x, y in polygon[1:]
|
||||
)
|
||||
commands.extend(["h", "f"])
|
||||
return commands
|
||||
|
||||
|
||||
def _build_pdf(objects):
|
||||
"""Assemble a minimal PDF 1.4 document from already-numbered objects."""
|
||||
document = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = [0]
|
||||
for object_number, payload in enumerate(objects, start=1):
|
||||
offsets.append(len(document))
|
||||
document.extend(f"{object_number} 0 obj\n".encode("ascii"))
|
||||
document.extend(payload)
|
||||
if not payload.endswith(b"\n"):
|
||||
document.extend(b"\n")
|
||||
document.extend(b"endobj\n")
|
||||
|
||||
xref_offset = len(document)
|
||||
document.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
||||
document.extend(b"0000000000 65535 f \n")
|
||||
for offset in offsets[1:]:
|
||||
document.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
||||
document.extend(
|
||||
(
|
||||
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||
f"startxref\n{xref_offset}\n%%EOF\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
return bytes(document)
|
||||
|
||||
|
||||
def write_pdf_vector(
|
||||
output_path, texture_width, texture_height, width_mm, height_mm,
|
||||
mesh_polygons, patch_shapes, cross_rects, cross_color
|
||||
):
|
||||
"""Write native vector PDF paths at the configured physical page size."""
|
||||
page_width_pt = float(width_mm) * 72.0 / 25.4
|
||||
page_height_pt = float(height_mm) * 72.0 / 25.4
|
||||
scale_x = page_width_pt / float(texture_width)
|
||||
scale_y = page_height_pt / float(texture_height)
|
||||
|
||||
content = ["q", "% background", "0 0 0 rg"]
|
||||
content.append(
|
||||
f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f"
|
||||
)
|
||||
content.extend(["% mesh", "1 1 1 rg"])
|
||||
for polygon in mesh_polygons:
|
||||
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
|
||||
|
||||
content.append("% camera-space patches")
|
||||
for polygon, color in patch_shapes:
|
||||
content.append(_pdf_set_fill(color))
|
||||
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
|
||||
|
||||
cross_alpha = _clamp_channel(cross_color[3])
|
||||
has_cross_alpha = bool(cross_rects) and cross_alpha < 0.999999
|
||||
content.extend(["% reference cross", "q"])
|
||||
if has_cross_alpha:
|
||||
content.append("/CrossAlpha gs")
|
||||
content.append(_pdf_set_fill(cross_color))
|
||||
for x, y, rect_width, rect_height in cross_rects:
|
||||
content.append(
|
||||
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} "
|
||||
f"{_format_number(rect_width * scale_x)} "
|
||||
f"{_format_number(rect_height * scale_y)} re f"
|
||||
)
|
||||
content.extend(["Q", "Q", ""])
|
||||
content_bytes = "\n".join(content).encode("ascii")
|
||||
|
||||
if has_cross_alpha:
|
||||
resources = b"<< /ExtGState << /CrossAlpha 5 0 R >> >>"
|
||||
else:
|
||||
resources = b"<< >>"
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
(
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "
|
||||
+ _format_number(page_width_pt).encode("ascii")
|
||||
+ b" "
|
||||
+ _format_number(page_height_pt).encode("ascii")
|
||||
+ b"] /Resources "
|
||||
+ resources
|
||||
+ b" /Contents 4 0 R >>"
|
||||
),
|
||||
(
|
||||
f"<< /Length {len(content_bytes)} >>\nstream\n".encode("ascii")
|
||||
+ content_bytes
|
||||
+ b"endstream"
|
||||
),
|
||||
]
|
||||
if has_cross_alpha:
|
||||
objects.append(
|
||||
(
|
||||
f"<< /Type /ExtGState /ca {_format_number(cross_alpha)} "
|
||||
f"/CA {_format_number(cross_alpha)} >>"
|
||||
).encode("ascii")
|
||||
)
|
||||
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(_build_pdf(objects))
|
||||
print(f"PDF vector texture saved to {path}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode 1: Colored Patches
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -522,12 +844,18 @@ def generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=40, ny=30, r=0.05,
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path=None, uv_centre=None, cross_length=40,
|
||||
output_path=None, svg_path=None, pdf_path=None,
|
||||
vector_width_mm=164.0, vector_height_mm=160.0,
|
||||
uv_centre=None, cross_length=40,
|
||||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||
uv_layer=None, image_name="PatchTexture"
|
||||
):
|
||||
if not output_path:
|
||||
raise ValueError("output_path is required (absolute PNG path)")
|
||||
if svg_path or pdf_path:
|
||||
_warn_if_page_aspect_differs(
|
||||
texture_width, texture_height, vector_width_mm, vector_height_mm
|
||||
)
|
||||
cam_data = cam.data
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
@@ -583,12 +911,19 @@ def generate_patch_texture(
|
||||
# Fill entire image with black (no mesh areas)
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
|
||||
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
||||
rasterize_mesh_uvs(
|
||||
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
|
||||
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
|
||||
# the exact same polygons for native SVG/PDF output.
|
||||
mesh_polygons = mesh_uv_pixel_polygons(
|
||||
obj, texture_width, texture_height, uv_layer=uv_layer
|
||||
)
|
||||
for pixel_poly in mesh_polygons:
|
||||
fill_polygon(
|
||||
None, pixels, texture_width, texture_height,
|
||||
pixel_poly, (1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
|
||||
# Now draw the visible patches on top
|
||||
# Draw the visible patches and retain each editable vector shape.
|
||||
patch_shapes = []
|
||||
idx = 0
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
@@ -597,10 +932,16 @@ def generate_patch_texture(
|
||||
color = patch_colors[j,i]
|
||||
rgba = (color[0], color[1], color[2], 1.0)
|
||||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
||||
patch_shapes.append((pixel_poly, rgba))
|
||||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||
|
||||
# (after drawing all patches)
|
||||
cross_shapes = []
|
||||
if uv_centre is not None:
|
||||
cross_shapes = cross_rectangles(
|
||||
texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness,
|
||||
)
|
||||
draw_cross(pixels, texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness, cross_color)
|
||||
@@ -612,7 +953,27 @@ def generate_patch_texture(
|
||||
image.filepath_raw = str(out_path)
|
||||
image.file_format = 'PNG'
|
||||
image.save()
|
||||
print(f"Patch texture saved to {out_path}")
|
||||
print(f"PNG raster texture saved to {out_path}")
|
||||
|
||||
uv_map_name = uv_layer.name if uv_layer is not None else "active"
|
||||
if svg_path or pdf_path:
|
||||
# Clip seam-wrapped duplicates to the page so nothing sits outside the
|
||||
# artwork once the geometry is opened in an editor.
|
||||
page_mesh_polygons = _clipped_polygons(mesh_polygons, texture_width, texture_height)
|
||||
page_patch_shapes = _clipped_shapes(patch_shapes, texture_width, texture_height)
|
||||
if svg_path:
|
||||
write_svg_vector(
|
||||
svg_path, texture_width, texture_height,
|
||||
vector_width_mm, vector_height_mm,
|
||||
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
|
||||
uv_map_name,
|
||||
)
|
||||
if pdf_path:
|
||||
write_pdf_vector(
|
||||
pdf_path, texture_width, texture_height,
|
||||
vector_width_mm, vector_height_mm,
|
||||
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
|
||||
)
|
||||
return image
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -874,7 +1235,7 @@ def parse_args(argv):
|
||||
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
|
||||
parser.add_argument(
|
||||
"--job", type=Path, default=None,
|
||||
help="JSON texture job written by orisys-design (output + objects + settings)",
|
||||
help="JSON texture job written by orisys-design (artifacts + objects + settings)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=None,
|
||||
@@ -888,6 +1249,14 @@ def parse_args(argv):
|
||||
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
|
||||
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
|
||||
parser.add_argument("--height", type=int, default=None, help="Texture height (overrides job)")
|
||||
parser.add_argument(
|
||||
"--vector-width-mm", dest="vector_width_mm", type=float, default=None,
|
||||
help="SVG/PDF physical width in millimetres (overrides job)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vector-height-mm", dest="vector_height_mm", type=float, default=None,
|
||||
help="SVG/PDF physical height in millimetres (overrides job)",
|
||||
)
|
||||
parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)")
|
||||
parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)")
|
||||
parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation")
|
||||
@@ -924,6 +1293,12 @@ def _as_int(value, label, minimum=1):
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_positive_number(value, label):
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
||||
raise ConfigError(f"{label}: must be a positive number (got {value!r})")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _as_color(value, label):
|
||||
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
||||
raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})")
|
||||
@@ -938,27 +1313,35 @@ def _as_color(value, label):
|
||||
return tuple(rgba)
|
||||
|
||||
|
||||
def _as_output_path(raw, label="output"):
|
||||
_ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||
|
||||
|
||||
def _as_output_path(raw, format_name, label):
|
||||
if raw is None or raw == "":
|
||||
raise ConfigError(f"{label}: a PNG path is required")
|
||||
raise ConfigError(f"{label}: a {format_name.upper()} path is required")
|
||||
if not isinstance(raw, (str, Path)):
|
||||
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
||||
if path.suffix.lower() != ".png":
|
||||
raise ConfigError(f"{label} must be a .png file (got {raw!r})")
|
||||
expected_suffix = f".{format_name}"
|
||||
if path.suffix.lower() != expected_suffix:
|
||||
raise ConfigError(
|
||||
f"{label} must be a {expected_suffix} file (got {raw!r})"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_targets(args, job):
|
||||
"""Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags."""
|
||||
"""Return ordered ``[(uv_map, {png, svg, pdf})]`` output targets."""
|
||||
if args.job is not None:
|
||||
if args.output is not None or args.uv_map is not None:
|
||||
raise ConfigError("--output/--uv-map cannot be combined with --job")
|
||||
entries = job.get("outputs")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ConfigError("job 'outputs' must be a non-empty list of {uv_map, output}")
|
||||
raise ConfigError(
|
||||
"job 'outputs' must be a non-empty list of {uv_map, artifacts}"
|
||||
)
|
||||
targets = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
@@ -966,30 +1349,62 @@ def _resolve_targets(args, job):
|
||||
uv_map = entry.get("uv_map")
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
raise ConfigError(
|
||||
f"job outputs[{index}]: 'uv_map' must be a non-empty string (got {uv_map!r})"
|
||||
f"job outputs[{index}]: 'uv_map' must be a non-empty string "
|
||||
f"(got {uv_map!r})"
|
||||
)
|
||||
targets.append(
|
||||
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
|
||||
)
|
||||
raw_artifacts = entry.get("artifacts")
|
||||
if not isinstance(raw_artifacts, dict):
|
||||
raise ConfigError(f"job outputs[{index}].artifacts must be a mapping")
|
||||
unknown = sorted(set(raw_artifacts) - set(_ARTIFACT_FORMATS))
|
||||
missing = [name for name in _ARTIFACT_FORMATS if name not in raw_artifacts]
|
||||
if unknown:
|
||||
raise ConfigError(
|
||||
f"job outputs[{index}].artifacts has unknown format(s): "
|
||||
f"{', '.join(unknown)}"
|
||||
)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"job outputs[{index}].artifacts is missing: {', '.join(missing)}"
|
||||
)
|
||||
artifacts = {
|
||||
format_name: _as_output_path(
|
||||
raw_artifacts[format_name], format_name,
|
||||
f"job outputs[{index}].artifacts.{format_name}",
|
||||
)
|
||||
for format_name in _ARTIFACT_FORMATS
|
||||
}
|
||||
targets.append((uv_map, artifacts))
|
||||
else:
|
||||
if args.uv_map is None or args.output is None:
|
||||
raise ConfigError(
|
||||
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
|
||||
)
|
||||
targets = [(args.uv_map, _as_output_path(args.output))]
|
||||
png_path = _as_output_path(args.output, "png", "output")
|
||||
targets = [
|
||||
(
|
||||
args.uv_map,
|
||||
{
|
||||
format_name: png_path.with_suffix(f".{format_name}")
|
||||
for format_name in _ARTIFACT_FORMATS
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
seen_names = set()
|
||||
seen_paths = {}
|
||||
for uv_map, path in targets:
|
||||
for uv_map, artifacts in targets:
|
||||
if uv_map in seen_names:
|
||||
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
||||
seen_names.add(uv_map)
|
||||
key = str(path).lower()
|
||||
if key in seen_paths:
|
||||
raise ConfigError(
|
||||
f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}"
|
||||
)
|
||||
seen_paths[key] = uv_map
|
||||
for format_name, path in artifacts.items():
|
||||
key = str(path).lower()
|
||||
if key in seen_paths:
|
||||
previous_uv, previous_format = seen_paths[key]
|
||||
raise ConfigError(
|
||||
f"duplicate output path for {previous_uv!r}/{previous_format} and "
|
||||
f"{uv_map!r}/{format_name}: {path}"
|
||||
)
|
||||
seen_paths[key] = (uv_map, format_name)
|
||||
return targets
|
||||
|
||||
|
||||
@@ -1011,6 +1426,9 @@ def _resolve_config(args):
|
||||
job_cross = settings.get("cross") or {}
|
||||
if not isinstance(job_cross, dict):
|
||||
raise ConfigError("job 'settings.cross' must be a mapping")
|
||||
job_vector = settings.get("vector") or {}
|
||||
if not isinstance(job_vector, dict):
|
||||
raise ConfigError("job 'settings.vector' must be a mapping")
|
||||
|
||||
def pick(flag, key, default):
|
||||
if flag is not None:
|
||||
@@ -1024,11 +1442,25 @@ def _resolve_config(args):
|
||||
value = job_cross.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
def pick_vector(flag, key, default):
|
||||
if flag is not None:
|
||||
return flag
|
||||
value = job_vector.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
|
||||
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
|
||||
|
||||
width = _as_int(pick(args.width, "width", 2048), "width")
|
||||
height = _as_int(pick(args.height, "height", 2048), "height")
|
||||
vector_width_mm = _as_positive_number(
|
||||
pick_vector(args.vector_width_mm, "width_mm", 164.0),
|
||||
"vector.width_mm",
|
||||
)
|
||||
vector_height_mm = _as_positive_number(
|
||||
pick_vector(args.vector_height_mm, "height_mm", 160.0),
|
||||
"vector.height_mm",
|
||||
)
|
||||
nx = _as_int(pick(args.nx, "nx", 80), "nx")
|
||||
ny = _as_int(pick(args.ny, "ny", 45), "ny")
|
||||
edge_sample_grid = _as_int(
|
||||
@@ -1062,6 +1494,8 @@ def _resolve_config(args):
|
||||
"camera": str(camera),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"vector_width_mm": vector_width_mm,
|
||||
"vector_height_mm": vector_height_mm,
|
||||
"nx": nx,
|
||||
"ny": ny,
|
||||
"color_r": float(color_r),
|
||||
@@ -1149,23 +1583,24 @@ def main():
|
||||
obj = _prepare_mesh_object(config["mesh"])
|
||||
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
||||
targets = [
|
||||
(uv_map, output, _require_uv_layer(obj, uv_map))
|
||||
for uv_map, output in config["outputs"]
|
||||
(uv_map, artifacts, _require_uv_layer(obj, uv_map))
|
||||
for uv_map, artifacts in config["outputs"]
|
||||
]
|
||||
except SceneError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for uv_map, output, _layer in targets:
|
||||
try:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"ERROR: cannot create output directory for UV map {uv_map!r} "
|
||||
f"({output.parent}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
for uv_map, artifacts, _layer in targets:
|
||||
for format_name, output in artifacts.items():
|
||||
try:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"ERROR: cannot create {format_name.upper()} output directory for "
|
||||
f"UV map {uv_map!r} ({output.parent}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
@@ -1178,16 +1613,20 @@ def main():
|
||||
rng_state = np.random.get_state()
|
||||
|
||||
start_time = time.time()
|
||||
for uv_map, output, uv_layer in targets:
|
||||
for uv_map, artifacts, uv_layer in targets:
|
||||
np.random.set_state(rng_state)
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
print(f"--- UV map {uv_map!r} -> {output}")
|
||||
print(f"--- UV map {uv_map!r}")
|
||||
try:
|
||||
generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
||||
texture_width=config["width"], texture_height=config["height"],
|
||||
output_path=str(output),
|
||||
output_path=str(artifacts["png"]),
|
||||
svg_path=str(artifacts["svg"]),
|
||||
pdf_path=str(artifacts["pdf"]),
|
||||
vector_width_mm=config["vector_width_mm"],
|
||||
vector_height_mm=config["vector_height_mm"],
|
||||
uv_centre=uv_centre,
|
||||
cross_length=config["cross_length"],
|
||||
cross_thickness=config["cross_thickness"],
|
||||
@@ -1203,17 +1642,25 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
if not output.is_file() or output.stat().st_size == 0:
|
||||
print(
|
||||
f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
for format_name, output in artifacts.items():
|
||||
if not output.is_file() or output.stat().st_size == 0:
|
||||
print(
|
||||
f"ERROR: {format_name.upper()} texture was not written for "
|
||||
f"UV map {uv_map!r}: {output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"Texture complete: {len(targets)} file(s)")
|
||||
for uv_map, output, _layer in targets:
|
||||
print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)")
|
||||
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
|
||||
artifact_count = sum(len(artifacts) for _, artifacts, _ in targets)
|
||||
print(f"Texture complete: {artifact_count} file(s)")
|
||||
for uv_map, artifacts, _layer in targets:
|
||||
print(f" [{uv_map}]")
|
||||
for format_name, output in artifacts.items():
|
||||
print(f" {format_name.upper()}: {output} ({output.stat().st_size} bytes)")
|
||||
print(
|
||||
f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']} "
|
||||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g}mm"
|
||||
)
|
||||
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user