Skip to content

Commit 8017850

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): boolean-exact-volume quality pass: labelled operations, ghost-glass operands, walnut plinth
- Brass UNION, DIFFERENCE and INTERSECT plaques stand in front of the three results. - The results shown are render-only copies of the checked meshes with a 12 mm machined chamfer. The measured objects are not rendered, so the volumes stay 9 / 7 / 1 m^3. - Neon outline tubes are replaced by steel and amber edge rods plus frosted ghost glass for the removed material. - A satin walnut plinth replaces the mirror slab. - The default check passes on 5.2.1, 4.5.11 and 5.1.2, and --float-solver exits 3 on 5.2.1 and 4.5.11. - Contact sheet: luma 0.254, in family. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5ef7e2e commit 8017850

7 files changed

Lines changed: 496 additions & 115 deletions

File tree

-1.15 KB
Loading
1.79 KB
Loading

‎docs/gallery/boolean-exact-volume/index.html‎

Lines changed: 310 additions & 59 deletions
Large diffs are not rendered by default.
-1.02 KB
Loading

‎examples/boolean-exact-volume/README.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,18 @@ from 5.0, so a script that hard-codes either name breaks on the other series
3434
(`fast_solver_id()` branches on `bpy.app.version`). `'EXACT'` is the same identifier on
3535
4.5, 5.1 and 5.2.
3636

37-
The still shows the three operations side by side on a walnut plinth, each with its
38-
two operands drawn as thin outlines (the cube in steel, the coplanar slab in glowing
39-
orange): on the left the union in teal glaze with the slab's free end grown out of the
40-
cube; in the middle the difference in brass with the slab's notch cut down through the
41-
top face; on the right the intersection, the 1 m³ overlap cube, in orange. The outlines
42-
are render-only rods built after the check has run, and the cutter objects themselves
43-
never render.
37+
The still shows the three operations side by side on a walnut plinth, each named by a
38+
brass plaque in front of it. On the left, the union in teal glaze, with the slab's free
39+
end grown out of the cube. In the middle, the difference in machined bronze, with the
40+
notch cut down through the top face and the removed slab drawn as frosted amber glass.
41+
On the right, the intersection: the 1 m³ overlap cube in orange glaze, inside frosted
42+
ghosts of both operands. Thin rods trace the operands' edges (steel for the cube, amber
43+
for the coplanar slab).
44+
45+
Everything shown is render-only and is built after the check has run. The results on
46+
display are frozen copies of the checked meshes with a 12 mm chamfer; the measured
47+
modifier objects and the cutter objects never render. The ghosts are grown 2 mm and
48+
4 mm so none of their faces is coplanar with a result or with each other.
4449

4550
## Run
4651

‎examples/boolean-exact-volume/boolean_exact_volume.py‎

Lines changed: 174 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
215274
def 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+
249320
def 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:
984 Bytes
Loading

0 commit comments

Comments
 (0)