AnasSarkiz/magnetic-shutter-remote-r8
A USB‑C rechargeable battery-powered ESP32‑C3 wireless controller with Li‑ion charging, protected 3.3‑V buck‑boost regulation, thermal/safety monitoring, status LEDs, buttons, programming UART, crystal clock, and 2.4‑GHz antenna matching network.
- Version
- 0.4.0-prototype
- License
- unset
- Stars
- 0
scripts/render-product-studio.py
"""Studio views of the actual native GLB; materials/lighting only, no mesh edits."""
import argparse
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[1]
def material(name, options):
result = bpy.data.materials.new(name)
result.use_nodes = True
principled = result.node_tree.nodes.get('Principled BSDF')
principled.inputs['Base Color'].default_value = (*options['rgb'], 1)
principled.inputs['Roughness'].default_value = options['roughness']
principled.inputs['Metallic'].default_value = options.get('metallic', 0)
if options.get('grain'):
grain = result.node_tree.nodes.new('ShaderNodeTexVoronoi')
grain.feature = 'DISTANCE_TO_EDGE'
grain.inputs['Scale'].default_value = 65
bump = result.node_tree.nodes.new('ShaderNodeBump')
bump.inputs['Strength'].default_value = 0.65
bump.inputs['Distance'].default_value = 0.25
result.node_tree.links.new(grain.outputs['Distance'], bump.inputs['Height'])
result.node_tree.links.new(bump.outputs['Normal'], principled.inputs['Normal'])
return result
def point_at(obj, target):
obj.rotation_euler = (Vector(target)-obj.location).to_track_quat('-Z', 'Y').to_euler()
def area_light(name, options):
lamp = bpy.data.lights.new(name, 'AREA')
lamp.energy = options['energy']
lamp.shape = 'DISK'
lamp.size = options['size']
obj = bpy.data.objects.new(name, lamp)
bpy.context.collection.objects.link(obj)
obj.location = options['position']
point_at(obj, (0, 15, 45))
def smooth_presentation_normals(mesh):
# Native GLB triangles duplicate vertices. Share only shading normals at
# identical coordinates; do not weld, repair, move or decimate any geometry.
geometry_before = hashlib.sha256(json.dumps({
'vertices':[list(vertex.co) for vertex in mesh.vertices],
'faces':[list(polygon.vertices) for polygon in mesh.polygons]},separators=(',',':')).encode()).hexdigest()
normals_by_position = {}
for polygon in mesh.polygons:
normal = tuple(round(c, 6) for c in polygon.normal)
for vertex_index in polygon.vertices:
position = tuple(round(c, 6) for c in mesh.vertices[vertex_index].co)
normals_by_position.setdefault(position, set()).add(normal)
normals = []
minimum_dot = math.cos(math.radians(35))
for polygon in mesh.polygons:
polygon.use_smooth = True
for loop_index in polygon.loop_indices:
vertex = mesh.vertices[mesh.loops[loop_index].vertex_index]
position = tuple(round(c, 6) for c in vertex.co)
total = Vector((0, 0, 0))
for candidate in normals_by_position[position]:
normal = Vector(candidate)
if normal.dot(polygon.normal) > minimum_dot:
total += normal
normals.append(tuple(total.normalized()))
mesh.normals_split_custom_set(normals)
geometry_after = hashlib.sha256(json.dumps({
'vertices':[list(vertex.co) for vertex in mesh.vertices],
'faces':[list(polygon.vertices) for polygon in mesh.polygons]},separators=(',',':')).encode()).hexdigest()
if geometry_before != geometry_after:
raise ValueError('Studio shading changed the native geometry')
return geometry_after
def render(options):
path = ROOT/'dist/product.assembly/3d.glb'
input_sha = hashlib.sha256(path.read_bytes()).hexdigest()
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(path))
imported = list(bpy.context.scene.objects)
frame = bpy.data.objects.new('Native GLB to upright studio frame', None)
bpy.context.collection.objects.link(frame)
# Blender imports glTF Y-up as Z-up; undo that once to make native Z height.
frame.rotation_euler.x = -math.pi/2
frame.location = (-14, 0, 52)
for obj in imported:
if obj.parent is None:
obj.parent = frame
bpy.context.view_layer.update()
review = json.loads((ROOT/'product/geometry-review.json').read_text())
parts = {p['name']: p for p in review['parts']}
materials = {}
proof = []
for name, part in parts.items():
objects = [obj for obj in imported if obj.type == 'MESH' and
(obj.name == name or obj.name.startswith(name+'_') or obj.name.startswith(name+'.'))]
if not objects:
raise ValueError(f'Missing actual native geometry: {name}')
vertices = [obj.matrix_world @ vertex.co for obj in objects for vertex in obj.data.vertices]
measured = [[min(v[a] for v in vertices) for a in range(3)],
[max(v[a] for v in vertices) for a in range(3)]]
lower, upper = part['boundsMm']
expected = [[-upper[0]-14, lower[2], lower[1]+52],
[-lower[0]-14, upper[2], upper[1]+52]]
delta = max(abs(a-b) for v,w in zip(measured,expected) for a,b in zip(v,w))
if delta > 1e-3:
raise ValueError(f'Studio frame differs from actual CAD: {name}: {delta}')
proof.append({'part':name,'geometry_bounds_delta_mm':delta})
settings = {'rgb':(0.03,0.033,0.036),'roughness':0.42}
if name == 'SideShutterPlunger':
settings = {'rgb':(0.48,0.26,0.14),'roughness':0.32,'metallic':0.8}
elif name == 'FingerGripInsert':
settings = {'rgb':(0.02,0.022,0.024),'roughness':0.6,'grain':True}
elif name in ['MagSafeRearCover','MagSafeFaceCover']:
settings = {'rgb':(0.014,0.017,0.02),'roughness':0.7}
materials[name] = material(name, settings)
for obj in objects:
obj.data.materials.clear()
obj.data.materials.append(materials[name])
proof[-1].setdefault('unchanged_mesh_sha256',[]).append(smooth_presentation_normals(obj.data))
bpy.ops.mesh.primitive_plane_add(size=2000, location=(0, 0, -0.05))
bpy.context.object.data.materials.append(material('Studio ground',{'rgb':(0.36,0.31,0.25),'roughness':0.8}))
area_light('Key',{'energy':180000,'size':120,'position':(-100,130,175)})
area_light('Fill',{'energy':80000,'size':100,'position':(130,90,100)})
area_light('Rim',{'energy':160000,'size':90,'position':(30,-90,160)})
scene = bpy.context.scene
scene.world = bpy.data.worlds.new('Studio world')
scene.world.use_nodes = True
scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (0.25,0.22,0.19,1)
scene.world.node_tree.nodes['Background'].inputs['Strength'].default_value = 0.5
camera = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
bpy.context.collection.objects.link(camera)
camera.location = {'front':(105,210,110),'straight':(0,250,45),'side':(250,12,48),
'back':(-95,-210,108)}[options.view]
point_at(camera,(0,16,40))
camera.data.type = 'ORTHO'
camera.data.ortho_scale = 108 if options.view != 'side' else 98
scene.camera = camera
scene.render.engine = 'CYCLES'
scene.cycles.samples = options.samples
scene.cycles.use_denoising = False # Installed Blender4.3.2 lacks OpenImageDenoise.
scene.render.threads_mode = 'FIXED'
scene.render.threads = 4
scene.render.resolution_x = 1400
scene.render.resolution_y = 1200
scene.render.resolution_percentage = options.resolution_percent
scene.view_settings.view_transform = 'AgX'
output = ROOT/'product'/('closed.png' if options.view == 'front' else f'studio-{options.view}.png')
scene.render.filepath = str(output)
bpy.ops.render.render(write_still=True)
assert hashlib.sha256(path.read_bytes()).hexdigest() == input_sha
(ROOT/'product'/f'studio-{options.view}-review.json').write_text(json.dumps({
'input_native_glb_sha256':input_sha,'actual_native_geometry_verified':proof,
'output':str(output.relative_to(ROOT)),'output_sha256':hashlib.sha256(output.read_bytes()).hexdigest(),
'blender_version':bpy.app.version_string,'material_and_lighting_presentation_only':True,'smooth_shading_angle_degrees':35,
'physical_finish_or_fit_claimed':False,'samples':options.samples},indent=2)+'\n')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--view',choices=['front','straight','side','back'],default='front')
parser.add_argument('--samples',type=int,default=64)
parser.add_argument('--resolution-percent',type=int,default=100)
args = sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else []
render(parser.parse_args(args))