@@ -187,7 +187,9 @@ def eevee_engine_id():
187187# Render staging only (runs after the check; never part of it)
188188# ---------------------------------------------------------------------------
189189
190- def principled (name , base , rough , metal = 0.0 , noise = None , coat = 0.0 , emit = None ):
190+ def principled (name , base , rough , metal = 0.0 , coat = 0.0 , coat_rough = 0.1 , rough_var = 0.0 ):
191+ """A designed Principled material. *rough_var* adds a low-frequency
192+ roughness drift (no fine noise: on metal that reads as glitter)."""
191193 mat = bpy .data .materials .new (name )
192194 mat .use_nodes = True
193195 nt = mat .node_tree
@@ -197,21 +199,78 @@ def principled(name, base, rough, metal=0.0, noise=None, coat=0.0, emit=None):
197199 b .inputs ["Metallic" ].default_value = metal
198200 if coat :
199201 b .inputs ["Coat Weight" ].default_value = coat
200- if emit :
201- b .inputs ["Emission Color" ].default_value = (* emit [0 ], 1.0 )
202- b .inputs ["Emission Strength" ].default_value = emit [1 ]
203- if noise :
202+ b .inputs ["Coat Roughness" ].default_value = coat_rough
203+ if rough_var :
204204 tex = nt .nodes .new ("ShaderNodeTexNoise" )
205- tex .inputs ["Scale" ].default_value = noise
206- tex .inputs ["Detail" ].default_value = 8 .0
205+ tex .inputs ["Scale" ].default_value = 3.0
206+ tex .inputs ["Detail" ].default_value = 2 .0
207207 mr = nt .nodes .new ("ShaderNodeMapRange" )
208- mr .inputs ["To Min" ].default_value = max (rough - 0.08 , 0.0 )
209- mr .inputs ["To Max" ].default_value = rough + 0.14
208+ mr .inputs ["To Min" ].default_value = max (rough - rough_var , 0.0 )
209+ mr .inputs ["To Max" ].default_value = rough + rough_var
210210 nt .links .new (tex .outputs ["Fac" ], mr .inputs ["Value" ])
211211 nt .links .new (mr .outputs ["Result" ], b .inputs ["Roughness" ])
212212 return mat
213213
214214
215+ def ghost_glass (name , tint ):
216+ """Frosted, faintly tinted glass for an operand volume the result lost."""
217+ mat = bpy .data .materials .new (name )
218+ mat .use_nodes = True
219+ b = mat .node_tree .nodes ["Principled BSDF" ]
220+ b .inputs ["Base Color" ].default_value = (* tint , 1.0 )
221+ b .inputs ["Roughness" ].default_value = 0.22
222+ b .inputs ["IOR" ].default_value = 1.45
223+ b .inputs ["Transmission Weight" ].default_value = 1.0
224+ return mat
225+
226+
227+ def walnut_material ():
228+ """Walnut with grain running along the plinth (world X): distorted wave
229+ bands mixed between two walnut tones, a soft blotch layer, a light bump,
230+ and a satin coat that does not mirror the scene."""
231+ mat = bpy .data .materials .new ("Walnut" )
232+ mat .use_nodes = True
233+ nt = mat .node_tree
234+ b = nt .nodes ["Principled BSDF" ]
235+ coord = nt .nodes .new ("ShaderNodeTexCoord" )
236+ mapn = nt .nodes .new ("ShaderNodeMapping" )
237+ mapn .inputs ["Scale" ].default_value = (0.35 , 2.4 , 1.0 )
238+ nt .links .new (coord .outputs ["Object" ], mapn .inputs ["Vector" ])
239+ wave = nt .nodes .new ("ShaderNodeTexWave" )
240+ wave .wave_type = 'BANDS'
241+ wave .bands_direction = 'Y'
242+ wave .inputs ["Scale" ].default_value = 2.2
243+ wave .inputs ["Distortion" ].default_value = 7.0
244+ wave .inputs ["Detail" ].default_value = 3.0
245+ wave .inputs ["Detail Scale" ].default_value = 1.4
246+ nt .links .new (mapn .outputs ["Vector" ], wave .inputs ["Vector" ])
247+ ramp = nt .nodes .new ("ShaderNodeValToRGB" )
248+ ramp .color_ramp .elements [0 ].position = 0.25
249+ ramp .color_ramp .elements [0 ].color = (0.085 , 0.040 , 0.020 , 1.0 )
250+ ramp .color_ramp .elements [1 ].position = 0.85
251+ ramp .color_ramp .elements [1 ].color = (0.19 , 0.095 , 0.045 , 1.0 )
252+ nt .links .new (wave .outputs ["Fac" ], ramp .inputs ["Fac" ])
253+ blot = nt .nodes .new ("ShaderNodeTexNoise" )
254+ blot .inputs ["Scale" ].default_value = 1.6
255+ blot .inputs ["Detail" ].default_value = 3.0
256+ nt .links .new (coord .outputs ["Object" ], blot .inputs ["Vector" ])
257+ mix = nt .nodes .new ("ShaderNodeMix" )
258+ mix .data_type = 'RGBA'
259+ mix .blend_type = 'MULTIPLY'
260+ mix .inputs ["Factor" ].default_value = 0.35
261+ nt .links .new (ramp .outputs ["Color" ], mix .inputs ["A" ])
262+ nt .links .new (blot .outputs ["Color" ], mix .inputs ["B" ])
263+ nt .links .new (mix .outputs ["Result" ], b .inputs ["Base Color" ])
264+ bump = nt .nodes .new ("ShaderNodeBump" )
265+ bump .inputs ["Strength" ].default_value = 0.04
266+ nt .links .new (wave .outputs ["Fac" ], bump .inputs ["Height" ])
267+ nt .links .new (bump .outputs ["Normal" ], b .inputs ["Normal" ])
268+ b .inputs ["Roughness" ].default_value = 0.48
269+ b .inputs ["Coat Weight" ].default_value = 0.2
270+ b .inputs ["Coat Roughness" ].default_value = 0.32
271+ return mat
272+
273+
215274def tube_mesh (name , segments , radius , sides = 10 ):
216275 """Render-only rods along line segments (the operand outlines)."""
217276 me = bpy .data .meshes .new (name )
@@ -246,51 +305,114 @@ def box_edges(lo, hi, mw):
246305 return edges
247306
248307
308+ def grown (lo , hi , pad ):
309+ return tuple (v - pad for v in lo ), tuple (v + pad for v in hi )
310+
311+
312+ # Which operand volumes each result no longer contains: those are drawn as
313+ # frosted ghosts so the eye can see what the solver removed.
314+ GHOSTS = {'UNION' : (), 'DIFFERENCE' : ('B' ,), 'INTERSECT' : ('A' , 'B' )}
315+ # Outline rods: B everywhere (the coplanar cutter); A only where A is a ghost.
316+ OUTLINES = {'UNION' : ('B' ,), 'DIFFERENCE' : ('B' ,), 'INTERSECT' : ('A' , 'B' )}
317+ LABELS = {'UNION' : "UNION" , 'DIFFERENCE' : "DIFFERENCE" , 'INTERSECT' : "INTERSECT" }
318+
319+
249320def render_still (results , cutters , path , engine ):
250321 scene = bpy .context .scene
251- glazes = {
252- 'UNION' : principled ("UnionGlaze" , (0.07 , 0.30 , 0.34 ), 0.28 , coat = 0.5 ),
253- 'DIFFERENCE' : principled ("DifferenceBrass" , (0.86 , 0.56 , 0.26 ), 0.30 , metal = 1.0 , noise = 60.0 ),
254- 'INTERSECT' : principled ("IntersectOrange" , (0.95 , 0.36 , 0.05 ), 0.32 , coat = 0.4 ),
322+ finishes = {
323+ # glazed ceramic, machined bronze, glazed ceramic: three readable hues
324+ 'UNION' : principled ("UnionGlaze" , (0.05 , 0.29 , 0.32 ), 0.32 , coat = 0.6 , coat_rough = 0.08 ,
325+ rough_var = 0.06 ),
326+ 'DIFFERENCE' : principled ("DifferenceBronze" , (0.78 , 0.52 , 0.27 ), 0.30 , metal = 1.0 ,
327+ rough_var = 0.07 ),
328+ 'INTERSECT' : principled ("IntersectGlaze" , (0.90 , 0.31 , 0.05 ), 0.32 , coat = 0.6 ,
329+ coat_rough = 0.08 , rough_var = 0.06 ),
255330 }
256- steel = principled ("OperandA" , (0.62 , 0.64 , 0.68 ), 0.25 , metal = 1.0 )
257- cutter_mat = principled ("OperandB" , (1.0 , 0.45 , 0.06 ), 0.35 ,
258- emit = ((1.0 , 0.45 , 0.06 ), 1.2 ))
259- walnut = principled ("Walnut" , (0.13 , 0.055 , 0.025 ), 0.45 , noise = 40.0 , coat = 0.4 )
331+ steel = principled ("OperandASteel" , (0.70 , 0.72 , 0.75 ), 0.24 , metal = 1.0 )
332+ amber = principled ("OperandBAmber" , (0.95 , 0.47 , 0.10 ), 0.30 , metal = 0.6 )
333+ ghost = {'A' : ghost_glass ("GhostA" , (0.86 , 0.90 , 0.95 )),
334+ 'B' : ghost_glass ("GhostB" , (1.0 , 0.80 , 0.55 ))}
335+ brass = principled ("PlaqueBrass" , (0.84 , 0.62 , 0.32 ), 0.40 , metal = 1.0 , rough_var = 0.05 )
336+ ink = principled ("PlaqueInk" , (0.012 , 0.010 , 0.008 ), 0.6 )
337+ walnut = walnut_material ()
260338
261339 parts = []
262340
263- def add (name , me , mat ):
341+ def add (name , me , mat , mw = None ):
264342 me .materials .append (mat )
265343 ob = bpy .data .objects .new (name , me )
344+ if mw is not None :
345+ ob .matrix_world = mw
266346 scene .collection .objects .link (ob )
267347 parts .append (ob )
268348 return ob
269349
350+ # Display copies: the checked results, frozen, with a 12 mm machined
351+ # chamfer. The modifier objects themselves (already measured) are hidden
352+ # from the render, so the chamfer never touches the volume check.
353+ deps = bpy .context .evaluated_depsgraph_get ()
270354 for i , op in enumerate (OPS ):
271355 res = results [op ]
272- res .data .materials .append (glazes [op ])
356+ shown = bpy .data .meshes .new_from_object (res .evaluated_get (deps ))
357+ # the boolean output carries the operands' (empty) material slots;
358+ # start clean so every face takes the finish below
359+ shown .materials .clear ()
360+ for poly in shown .polygons :
361+ poly .material_index = 0
362+ ob = add (f"{ op .title ()} Shown" , shown , finishes [op ], res .matrix_world .copy ())
363+ bev = ob .modifiers .new ("MachinedChamfer" , 'BEVEL' )
364+ bev .width = 0.012
365+ bev .segments = 3
366+ bev .limit_method = 'ANGLE'
367+ bev .harden_normals = True
368+ res .hide_render = True
273369 mw = frame (i )
274- # thin outlines of both operands: A in steel, the coplanar cutter B in orange
275- add ( f" { op . title () } OutlineA" , tube_mesh ( f" { op . title () } OutlineA" ,
276- box_edges ( A_LO , A_HI , mw ), 0.016 ), steel )
277- add ( f" { op . title () } OutlineB" , tube_mesh ( f" { op . title () } OutlineB" ,
278- box_edges ( B_LO , B_HI , mw ), 0.020 ), cutter_mat )
279-
280- half_x = SPACING + 2.2
281- plinth = add ( "Plinth" , bpy . data . meshes . new ( "Plinth" ), walnut )
282- bm = bmesh . new ( )
283- try :
284- res = bmesh . ops . create_cube ( bm , size = 1.0 )
285- lo , hi = ( - half_x , - 2.3 , - 0.16 ), ( half_x , 2.3 , - 0.004 )
286- for vert in res [ "verts" ]:
287- vert . co = Vector ( tuple ( lo [ k ] + ( vert . co [ k ] + 0.5 ) * ( hi [ k ] - lo [ k ]) for k in range ( 3 )))
288- bm . to_mesh ( plinth . data )
289- finally :
290- bm . free ( )
370+ boxes = { 'A' : ( A_LO , A_HI ), 'B' : ( B_LO , B_HI )}
371+ for key in GHOSTS [ op ]:
372+ # grown 2 / 4 mm so no ghost face is coplanar with the result or
373+ # with the other ghost (their top faces share z = 2)
374+ lo , hi = grown ( * boxes [ key ], 0.002 if key == 'A' else 0.004 )
375+ add ( f" { op . title () } Ghost { key } " , box_mesh ( f" { op . title () } Ghost { key } " , lo , hi ),
376+ ghost [ key ], mw )
377+ for key in OUTLINES [ op ]:
378+ lo , hi = grown ( * boxes [ key ], 0.006 if key == 'A' else 0.010 )
379+ add ( f" { op . title () } Outline { key } " ,
380+ tube_mesh ( f" { op . title () } Outline { key } " , box_edges ( lo , hi , mw ),
381+ 0.009 if key == 'A' else 0.011 ),
382+ steel if key == 'A' else amber )
383+
384+ half_x , half_y , top , depth = SPACING + 2.0 , 2.75 , - 0.004 , 0.22
385+ plinth = add ( "Plinth" , box_mesh ( "Plinth" , ( - half_x , - half_y , top - depth ),
386+ ( half_x , half_y , top )), walnut )
291387 bev = plinth .modifiers .new ("Chamfer" , 'BEVEL' )
292- bev .width = 0.03
293- bev .segments = 2
388+ bev .width = 0.035
389+ bev .segments = 3
390+
391+ # Brass name plaques lying on the plinth in front of each set, tilted
392+ # back 32 degrees so they face the camera; raised dark lettering.
393+ tilt = math .radians (32.0 )
394+ plate_w , plate_d , plate_t = 2.1 , 0.46 , 0.028
395+ for i , op in enumerate (OPS ):
396+ base = Matrix .Translation (((i - 1 ) * SPACING , - half_y + 0.16 , top )) \
397+ @ Matrix .Rotation (tilt , 4 , 'X' )
398+ plate = add (f"{ op .title ()} Plaque" , box_mesh (f"{ op .title ()} Plaque" ,
399+ (- plate_w / 2 , 0.0 , 0.0 ),
400+ (plate_w / 2 , plate_d , plate_t )), brass , base )
401+ pb = plate .modifiers .new ("Chamfer" , 'BEVEL' )
402+ pb .width = 0.006
403+ pb .segments = 2
404+ cu = bpy .data .curves .new (f"{ op .title ()} Label" , 'FONT' )
405+ cu .body = LABELS [op ]
406+ cu .size = 0.25
407+ cu .space_character = 1.12
408+ cu .extrude = 0.005
409+ cu .align_x = 'CENTER'
410+ cu .align_y = 'CENTER'
411+ cu .materials .append (ink )
412+ txt = bpy .data .objects .new (f"{ op .title ()} Label" , cu )
413+ txt .matrix_world = base @ Matrix .Translation ((0.0 , plate_d / 2 , plate_t + 0.0055 ))
414+ scene .collection .objects .link (txt )
415+ parts .append (txt )
294416
295417 floor_me = bpy .data .meshes .new ("Floor" )
296418 bm = bmesh .new ()
@@ -306,7 +428,7 @@ def add(name, me, mat):
306428 fb .inputs ["Roughness" ].default_value = 0.7
307429 floor_me .materials .append (fmat )
308430 floor = bpy .data .objects .new ("Floor" , floor_me )
309- floor .location = (0.0 , 0.0 , - 0.16 )
431+ floor .location = (0.0 , 0.0 , top - depth )
310432 scene .collection .objects .link (floor )
311433 wall = bpy .data .objects .new ("Wall" , floor_me .copy ())
312434 wall .location = (0.0 , 8.0 , 0.0 )
@@ -318,7 +440,7 @@ def add(name, me, mat):
318440 world .node_tree .nodes ["Background" ].inputs ["Color" ].default_value = (0.02 , 0.021 , 0.025 , 1.0 )
319441 scene .world = world
320442
321- centre = Vector ((0.0 , 0.0 , 1.0 ))
443+ centre = Vector ((0.0 , - 0.4 , 0.9 ))
322444
323445 def light (name , loc , energy , size , col , aim ):
324446 ld = bpy .data .lights .new (name , 'AREA' )
@@ -328,18 +450,21 @@ def light(name, loc, energy, size, col, aim):
328450 ob .rotation_euler = (Vector (aim ) - Vector (loc )).to_track_quat ('-Z' , 'Y' ).to_euler ()
329451 scene .collection .objects .link (ob )
330452
331- light ("Key" , (- 5.0 , - 6.0 , 7.5 ), 700 .0 , 5.0 , (1.0 , 0.96 , 0.9 ), centre )
332- light ("Fill" , (7.0 , - 5.5 , 3.0 ), 140 .0 , 8.0 , (0.75 , 0.85 , 1.0 ), centre )
453+ light ("Key" , (- 5.0 , - 6.5 , 7.5 ), 760 .0 , 5.0 , (1.0 , 0.96 , 0.9 ), centre )
454+ light ("Fill" , (7.0 , - 5.5 , 3.0 ), 260 .0 , 8.0 , (0.75 , 0.85 , 1.0 ), centre )
333455 light ("Rim" , (2.0 , 5.0 , 7.0 ), 420.0 , 4.0 , (0.6 , 0.78 , 1.0 ), centre )
334456 light ("Wedge" , (3.0 , 5.0 , 3.0 ), 480.0 , 6.0 , (1.0 , 0.76 , 0.5 ), (5.0 , 8.0 , 1.0 ))
457+ # low softbox in front so the tilted brass plaques read (they mirror the
458+ # dark studio otherwise)
459+ light ("PlaqueSoft" , (1.0 , - 10.0 , 3.2 ), 260.0 , 12.0 , (1.0 , 0.97 , 0.92 ), (0.0 , - 2.4 , 0.0 ))
335460
336461 cam_data = bpy .data .cameras .new ("Cam" )
337462 cam_data .lens = 50.0
338463 cam = bpy .data .objects .new ("Cam" , cam_data )
339- cam .location = (3.2 , - 20.9 , 11.0 )
464+ cam .location = (3.0 , - 21.3 , 10.8 )
340465 scene .collection .objects .link (cam )
341466 aim = bpy .data .objects .new ("Aim" , None )
342- aim .location = centre + Vector (( 0.0 , 0.0 , - 0.1 ))
467+ aim .location = centre
343468 scene .collection .objects .link (aim )
344469 tr = cam .constraints .new ('TRACK_TO' )
345470 tr .target = aim
@@ -349,7 +474,8 @@ def light(name, loc, energy, size, col, aim):
349474
350475 scene .render .engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id ()
351476 if engine == 'cycles' :
352- scene .cycles .samples = 48
477+ scene .cycles .samples = 64
478+ scene .cycles .transmission_bounces = 8
353479 else :
354480 try :
355481 scene .eevee .taa_render_samples = 64
@@ -359,16 +485,15 @@ def light(name, loc, energy, size, col, aim):
359485 scene .render .resolution_y = 720
360486 scene .render .image_settings .file_format = 'PNG'
361487 scene .render .filepath = path
362- # AgX would wash the glazes and the orange cutter toward pastel (docs/VISUAL-STYLE.md)
488+ # AgX would wash the glazes and the amber cutter toward pastel (docs/VISUAL-STYLE.md)
363489 scene .view_settings .view_transform = 'Standard'
364490 bpy .context .view_layer .update ()
365491 # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
366492 # the beauty render so a defective composition ships no artifact
367- shown = list (results .values ()) + parts
368493 fcode = gallery_framing .check_framing (
369494 scene , cam ,
370- hero = shown ,
371- elements = shown ,
495+ hero = parts ,
496+ elements = parts ,
372497 stage = [floor , wall ],
373498 )
374499 if fcode :
0 commit comments