|
11 | 11 | origin. Hygiene family 15–19: ``--stray-vert``, ``--short-shoe``, |
12 | 12 | ``--gap-board``, ``--float-strap``, ``--yaw-boards``. |
13 | 13 |
|
14 | | -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts |
| 14 | +Construction is closed-form; the only RNG is the seeded per-piece wood |
| 15 | +tone. DECIMATE COLLAPSE triangle counts |
15 | 16 | are not byte-identical across Blender versions — the LOD gate is a |
16 | 17 | ratio band, not an exact count. |
17 | 18 |
|
|
22 | 23 | import argparse |
23 | 24 | import math |
24 | 25 | import os |
| 26 | +import random |
25 | 27 | import sys |
26 | 28 | import tempfile |
27 | 29 | import traceback |
|
83 | 85 | BOARD_IDX = 1 |
84 | 86 | METAL_IDX = 2 |
85 | 87 |
|
| 88 | +# Per-piece wood tone jitter and grain frequency, as in shipping-crate. |
| 89 | +PLANK_TONE_JITTER = 0.28 |
| 90 | +TONE_SEED = 29 |
| 91 | +WOOD_GRAIN_SCALE = 30.0 |
| 92 | + |
86 | 93 | DOUBLES_EPS = 1e-5 |
87 | 94 | AREA_EPS = 1e-10 |
88 | 95 | ZMIN_EPS = 1e-4 |
@@ -267,6 +274,10 @@ def build_signpost_mesh( |
267 | 274 |
|
268 | 275 | if bevel_offset > 0.0: |
269 | 276 | edges = list({e for v in wood for e in v.link_edges}) |
| 277 | + # set order follows memory addresses; sort so the bevel, and the |
| 278 | + # face order it produces, are the same on every run |
| 279 | + bm.edges.index_update() |
| 280 | + edges.sort(key=lambda e: e.index) |
270 | 281 | ret = bmesh.ops.bevel( |
271 | 282 | bm, |
272 | 283 | geom=edges, |
@@ -381,6 +392,165 @@ def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): |
381 | 392 | return mat |
382 | 393 |
|
383 | 394 |
|
| 395 | +def _long_axis(pts): |
| 396 | + """Principal axis of a point set, by power iteration on its covariance.""" |
| 397 | + c = sum(pts, Vector()) / len(pts) |
| 398 | + cov = [[0.0] * 3 for _ in range(3)] |
| 399 | + for p in pts: |
| 400 | + d = p - c |
| 401 | + for i in range(3): |
| 402 | + for j in range(3): |
| 403 | + cov[i][j] += d[i] * d[j] |
| 404 | + v = Vector((1.0, 0.3, 0.1)) |
| 405 | + for _ in range(30): |
| 406 | + w = Vector([sum(cov[i][j] * v[j] for j in range(3)) for i in range(3)]) |
| 407 | + if w.length < 1e-12: |
| 408 | + break |
| 409 | + v = w.normalized() |
| 410 | + return v |
| 411 | + |
| 412 | + |
| 413 | +def paint_planks(me): |
| 414 | + """Per-shell ``PlankTone`` and ``GrainDir`` face attributes for the wood shader. |
| 415 | +
|
| 416 | + The post and each finger board is its own shell, so each gets one |
| 417 | + tone and grain running along its own long axis. |
| 418 | + """ |
| 419 | + tone = [0.5] * len(me.polygons) |
| 420 | + grain = [(0.0, 0.0, 1.0)] * len(me.polygons) |
| 421 | + owner = {} |
| 422 | + rng = random.Random(TONE_SEED) |
| 423 | + for g in shells(me): |
| 424 | + pts = [me.vertices[i].co.copy() for i in g] |
| 425 | + d = _long_axis(pts) if len(pts) > 2 else Vector((0.0, 0.0, 1.0)) |
| 426 | + t = 0.5 + rng.uniform(-PLANK_TONE_JITTER, PLANK_TONE_JITTER) |
| 427 | + for i in g: |
| 428 | + owner[i] = (t, tuple(d)) |
| 429 | + for poly in me.polygons: |
| 430 | + t, d = owner[poly.vertices[0]] |
| 431 | + tone[poly.index] = t |
| 432 | + grain[poly.index] = d |
| 433 | + a = me.attributes.new("PlankTone", "FLOAT", "FACE") |
| 434 | + a.data.foreach_set("value", tone) |
| 435 | + b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE") |
| 436 | + b.data.foreach_set("vector", [c for v in grain for c in v]) |
| 437 | + |
| 438 | + |
| 439 | +def _sock(sockets, identifier): |
| 440 | + """A Mix-node socket by identifier; its A/B/Result names repeat per type.""" |
| 441 | + return next(sk for sk in sockets if sk.identifier == identifier) |
| 442 | + |
| 443 | + |
| 444 | +def wood_material(name): |
| 445 | + """Grain along the post (``GrainDir``), tone per piece (``PlankTone``).""" |
| 446 | + mat = bpy.data.materials.new(name) |
| 447 | + mat.use_nodes = True |
| 448 | + nt = mat.node_tree |
| 449 | + bsdf = nt.nodes["Principled BSDF"] |
| 450 | + coord = nt.nodes.new("ShaderNodeTexCoord") |
| 451 | + gdir = nt.nodes.new("ShaderNodeAttribute") |
| 452 | + gdir.attribute_name = "GrainDir" |
| 453 | + tone = nt.nodes.new("ShaderNodeAttribute") |
| 454 | + tone.attribute_name = "PlankTone" |
| 455 | + dot = nt.nodes.new("ShaderNodeVectorMath") |
| 456 | + dot.operation = "DOT_PRODUCT" |
| 457 | + nt.links.new(coord.outputs["Object"], dot.inputs[0]) |
| 458 | + nt.links.new(gdir.outputs["Vector"], dot.inputs[1]) |
| 459 | + squash = nt.nodes.new("ShaderNodeMath") |
| 460 | + squash.operation = "MULTIPLY" |
| 461 | + squash.inputs[1].default_value = 0.94 |
| 462 | + nt.links.new(dot.outputs["Value"], squash.inputs[0]) |
| 463 | + along = nt.nodes.new("ShaderNodeVectorMath") |
| 464 | + along.operation = "SCALE" |
| 465 | + nt.links.new(gdir.outputs["Vector"], along.inputs[0]) |
| 466 | + nt.links.new(squash.outputs["Value"], along.inputs["Scale"]) |
| 467 | + grain_co = nt.nodes.new("ShaderNodeVectorMath") |
| 468 | + grain_co.operation = "SUBTRACT" |
| 469 | + nt.links.new(coord.outputs["Object"], grain_co.inputs[0]) |
| 470 | + nt.links.new(along.outputs["Vector"], grain_co.inputs[1]) |
| 471 | + shift = nt.nodes.new("ShaderNodeVectorMath") |
| 472 | + shift.operation = "ADD" |
| 473 | + nt.links.new(grain_co.outputs["Vector"], shift.inputs[0]) |
| 474 | + nt.links.new(tone.outputs["Fac"], shift.inputs[1]) |
| 475 | + noise = nt.nodes.new("ShaderNodeTexNoise") |
| 476 | + noise.inputs["Scale"].default_value = WOOD_GRAIN_SCALE |
| 477 | + noise.inputs["Detail"].default_value = 6.0 |
| 478 | + noise.inputs["Roughness"].default_value = 0.62 |
| 479 | + nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"]) |
| 480 | + ramp = nt.nodes.new("ShaderNodeValToRGB") |
| 481 | + ramp.color_ramp.elements[0].position = 0.30 |
| 482 | + ramp.color_ramp.elements[0].color = (0.12, 0.052, 0.018, 1.0) |
| 483 | + ramp.color_ramp.elements[1].position = 0.72 |
| 484 | + ramp.color_ramp.elements[1].color = (0.38, 0.18, 0.065, 1.0) |
| 485 | + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) |
| 486 | + gain = nt.nodes.new("ShaderNodeMath") |
| 487 | + gain.operation = "MULTIPLY_ADD" |
| 488 | + gain.inputs[1].default_value = 1.1 |
| 489 | + gain.inputs[2].default_value = 0.45 |
| 490 | + nt.links.new(tone.outputs["Fac"], gain.inputs[0]) |
| 491 | + mix = nt.nodes.new("ShaderNodeMix") |
| 492 | + mix.data_type = "RGBA" |
| 493 | + mix.blend_type = "MULTIPLY" |
| 494 | + _sock(mix.inputs, "Factor_Float").default_value = 1.0 |
| 495 | + nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color")) |
| 496 | + nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color")) |
| 497 | + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) |
| 498 | + rough = nt.nodes.new("ShaderNodeMapRange") |
| 499 | + rough.inputs["To Min"].default_value = 0.72 |
| 500 | + rough.inputs["To Max"].default_value = 0.52 |
| 501 | + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) |
| 502 | + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) |
| 503 | + return mat |
| 504 | + |
| 505 | + |
| 506 | +def board_material(name): |
| 507 | + """Painted finger boards, weathered: cream paint worn through to wood. |
| 508 | +
|
| 509 | + A noise mask on object coordinates picks where the paint has gone and |
| 510 | + the wood grain shows; ``PlankTone`` shifts each board's paint so the |
| 511 | + two boards are not one board twice. |
| 512 | + """ |
| 513 | + mat = wood_material(name) |
| 514 | + nt = mat.node_tree |
| 515 | + bsdf = nt.nodes["Principled BSDF"] |
| 516 | + grain = bsdf.inputs["Base Color"].links[0].from_socket |
| 517 | + coord = nt.nodes.new("ShaderNodeTexCoord") |
| 518 | + wear = nt.nodes.new("ShaderNodeTexNoise") |
| 519 | + wear.inputs["Scale"].default_value = 18.0 |
| 520 | + wear.inputs["Detail"].default_value = 8.0 |
| 521 | + wear.inputs["Roughness"].default_value = 0.65 |
| 522 | + nt.links.new(coord.outputs["Object"], wear.inputs["Vector"]) |
| 523 | + mask = nt.nodes.new("ShaderNodeValToRGB") |
| 524 | + mask.color_ramp.elements[0].position = 0.58 |
| 525 | + mask.color_ramp.elements[1].position = 0.66 |
| 526 | + nt.links.new(wear.outputs["Fac"], mask.inputs["Fac"]) |
| 527 | + tone = nt.nodes.new("ShaderNodeAttribute") |
| 528 | + tone.attribute_name = "PlankTone" |
| 529 | + paint = nt.nodes.new("ShaderNodeMix") |
| 530 | + paint.data_type = "RGBA" |
| 531 | + _sock(paint.inputs, "A_Color").default_value = (0.62, 0.56, 0.42, 1.0) |
| 532 | + _sock(paint.inputs, "B_Color").default_value = (0.74, 0.68, 0.52, 1.0) |
| 533 | + nt.links.new(tone.outputs["Fac"], _sock(paint.inputs, "Factor_Float")) |
| 534 | + mix = nt.nodes.new("ShaderNodeMix") |
| 535 | + mix.data_type = "RGBA" |
| 536 | + nt.links.new(mask.outputs["Color"], _sock(mix.inputs, "Factor_Float")) |
| 537 | + nt.links.new(_sock(paint.outputs, "Result_Color"), _sock(mix.inputs, "A_Color")) |
| 538 | + nt.links.new(grain, _sock(mix.inputs, "B_Color")) |
| 539 | + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) |
| 540 | + return mat |
| 541 | + |
| 542 | + |
| 543 | +def signpost_materials(): |
| 544 | + """(wood, board, iron): shared by the check, the render and inspection.""" |
| 545 | + wood = wood_material("SignpostWood") |
| 546 | + board = board_material("SignpostBoard") |
| 547 | + metal = principled( |
| 548 | + "SignpostIron", (0.17, 0.165, 0.155, 1.0), 0.80, 0.46, |
| 549 | + noise_scale=18.0, wear=(0.20, 0.085, 0.032, 1.0), |
| 550 | + ) |
| 551 | + return wood, board, metal |
| 552 | + |
| 553 | + |
384 | 554 | def assign_slots(obj, wood, board, metal): |
385 | 555 | mats = obj.data.materials |
386 | 556 | wanted = (wood, board, metal) |
@@ -713,18 +883,9 @@ def check( |
713 | 883 | ) |
714 | 884 | low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2, **kw) |
715 | 885 | high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4, **kw) |
716 | | - wood = principled( |
717 | | - "SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62, |
718 | | - noise_scale=14.0, wear=(0.22, 0.10, 0.04, 1.0), |
719 | | - ) |
720 | | - board = principled( |
721 | | - "SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52, |
722 | | - noise_scale=10.0, wear=(0.55, 0.38, 0.14, 1.0), |
723 | | - ) |
724 | | - metal = principled( |
725 | | - "SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38, |
726 | | - noise_scale=18.0, wear=(0.22, 0.18, 0.14, 1.0), |
727 | | - ) |
| 886 | + wood, board, metal = signpost_materials() |
| 887 | + paint_planks(low.data) |
| 888 | + paint_planks(high.data) |
728 | 889 | assign_slots(low, wood, board, metal) |
729 | 890 | assign_slots(high, wood, board, metal) |
730 | 891 |
|
@@ -780,6 +941,10 @@ def check( |
780 | 941 | os.remove(export_path) |
781 | 942 | export_unity(export_path, [low, collider]) |
782 | 943 | export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 |
| 944 | + # Blender points TMPDIR at the working directory, so the export must not |
| 945 | + # outlive the measurement. |
| 946 | + if os.path.exists(export_path): |
| 947 | + os.remove(export_path) |
783 | 948 |
|
784 | 949 | hyg = hygiene_audit(low.data) |
785 | 950 | zf = zfight_pairs(low.data) |
@@ -943,7 +1108,9 @@ def render_still(low, wood, tex, path, engine): |
943 | 1108 | floor_me = bpy.data.meshes.new("Floor") |
944 | 1109 | bm = bmesh.new() |
945 | 1110 | try: |
946 | | - bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) |
| 1111 | + # Oversized so no edge of the set can enter frame; at 14 m the wall's |
| 1112 | + # left edge showed in the corner of the hero. |
| 1113 | + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) |
947 | 1114 | bm.to_mesh(floor_me) |
948 | 1115 | finally: |
949 | 1116 | bm.free() |
|
0 commit comments