project
Ghost Hand
Public Unreviewedby John Lauer
A small battery gadget that keeps an automatic sink faucet running while you use the sink: it learns the faucet's timeout, re-arms with the shortest blip, and stops after 10 minutes or when picked up.
main
John Lauer
Rev 2 hero 3D: brushed-metal faucet; opens on the assembled gadget, Replay plays the assembly
f11a3f5
1d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
"""Bathroom sink scene for the Ghost Hand hero: Moen 8551 (spec sheet rev 3/17 dimensions), a 17 x 14 in basin
in a counter, the water stream, and the size-reference hand washing under it. Output frame: metres, Z up,
counter top Z = 0, origin = Ghost Hand case centre, +Y = the way its IR window points (at the faucet window)."""
import numpy as np, trimesh, math
from shapely.geometry import Polygon, Point
from shapely import affinity
mm = 1e-3
def mat(name, rgba, metal=0.0, rough=0.6):
return trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(
name=name, baseColorFactor=rgba, metallicFactor=metal, roughnessFactor=rough,
alphaMode='BLEND' if rgba[3] < 255 else 'OPAQUE', doubleSided=True))
# ---- faucet frame F: x across, y forward over the basin, z up, window (column front face) at y = 0
W, D, H = 53, 57, 166 # column wide, deep, tall
REACH_FRONT, OUT_Z = 105, 142 # column front to outlet centre; outlet underside height
WIN_Z = 40 # sensor window centre, pre-2017 unit (John's)
BA, BB, LIP = 432 / 2, 356 / 2, 25
BC = LIP + BB # basin centre y
parts = {}
col = trimesh.creation.extrude_polygon(Polygon([(-W/2, -D), (W/2, -D), (W/2, 0), (-W/2, 0)]).buffer(-8).buffer(8), H - 22)
parts['faucet'] = [col]
spout = trimesh.creation.extrude_polygon(Polygon([(-W/2, -D), (W/2, -D), (W/2, REACH_FRONT + 14), (-W/2, REACH_FRONT + 14)]).buffer(-6).buffer(6), 22)
spout.apply_translation([0, 0, H - 22]); parts['faucet'].append(spout)
win = trimesh.creation.extrude_polygon(Point(0, 0).buffer(1).simplify(0.01), 1.0)
win.apply_scale([7, 11, 1]); win.apply_transform(trimesh.transformations.rotation_matrix(-math.pi/2, [1, 0, 0]))
win.apply_translation([0, 0.6, WIN_Z]); parts['faucet_window'] = [win]
aer = trimesh.creation.cylinder(radius=10, height=3, sections=32); aer.apply_translation([0, REACH_FRONT, OUT_Z - 1.5])
parts['faucet'].append(aer)
# counter with the basin opening, 900 x 600, 30 thick
ctr = Polygon([(-450, -D - 60), (450, -D - 60), (450, 600 - D - 60), (-450, 600 - D - 60)])
hole = affinity.scale(Point(0, BC).buffer(1, 128), BA, BB)
counter = trimesh.creation.extrude_polygon(ctr.difference(hole), 30); counter.apply_translation([0, 0, -30])
parts['counter'] = [counter]
# basin: half ellipsoid bowl, 150 deep
bowl = trimesh.creation.uv_sphere(radius=1, count=[48, 96])
bowl.update_faces(bowl.triangles_center[:, 2] < 0.0); bowl.remove_unreferenced_vertices()
bowl.apply_scale([BA - 2, BB - 2, 150]); bowl.apply_translation([0, BC, -2])
parts['basin'] = [bowl]
drain = trimesh.creation.cylinder(radius=18, height=1, sections=32); drain.apply_translation([0, BC + 40, -150])
parts['basin'].append(drain)
# hand washing under the stream: palm down, fingers toward the faucet, tilted down 25 deg, the stream on the back of the hand
hand = trimesh.load('hand_ref.glb', force='mesh') # wrist y = 0, fingers +Y, Z = 0 at its lowest point
Lh = hand.bounds[1][1]
hand.apply_translation([0, -Lh, 0]) # fingertips at y = 0
hand.apply_transform(trimesh.transformations.rotation_matrix(math.pi, [0, 0, 1])) # fingers toward -y (the faucet)
hand.apply_transform(trimesh.transformations.rotation_matrix(math.radians(25), [1, 0, 0])) # fingertips down
hand.apply_scale(1000) # hand_ref is in metres
hand.apply_translation([-10, REACH_FRONT - 55, 10]) # knuckles under the stream, fingertips over the bowl
parts['hand'] = [hand]
# water: from the outlet onto the back of the hand, then off the fingertips into the bowl
near = np.hypot(hand.vertices[:, 0], hand.vertices[:, 1] - REACH_FRONT) < 4
top = hand.vertices[near, 2].max() if near.any() else 0.0
w1 = trimesh.creation.cylinder(radius=3.5, height=OUT_Z - 3 - top, sections=24); w1.apply_translation([0, REACH_FRONT, (OUT_Z - 3 + top) / 2])
tips = hand.vertices[np.argmin(hand.vertices[:, 1])] # fingertip nearest the faucet
bz = -150 * math.sqrt(max(0.0, 1 - ((tips[1] - BC) / BB) ** 2 - (tips[0] / BA) ** 2))
w2 = trimesh.creation.cylinder(radius=2.5, height=tips[2] - bz, sections=16); w2.apply_translation([tips[0], tips[1], (tips[2] + bz) / 2])
parts['water'] = [w1, w2]
print('stream lands on the hand at z', round(top, 1), 'mm; runs off at', tips.round(1))
# ---- place: Ghost Hand on the counter beside the basin, 330 mm from the window, 49 deg off the faucet axis
GX, GY = 250, 215
dist = math.hypot(GX, GY); yaw = math.atan2(-GX, -GY) # direction from device to window in F
# transform F -> device frame: translate device to origin, rotate so the device->window direction is +Y
T = trimesh.transformations.translation_matrix([-GX, -GY, 0])
R = trimesh.transformations.rotation_matrix(-(math.atan2(0 - GY, 0 - GX) - math.pi/2), [0, 0, 1])
M = trimesh.transformations.scale_matrix(mm) @ R @ T
looks = {'faucet': ([205, 210, 216, 255], 0.35, 0.28), 'faucet_window': ([18, 18, 22, 255], 0.0, 0.05),
'counter': ([226, 214, 190, 255], 0.0, 0.7), 'basin': ([246, 246, 244, 255], 0.0, 0.25),
'water': ([200, 225, 245, 120], 0.0, 0.05), 'hand': ([224, 172, 140, 255], 0.0, 0.65)}
sc = trimesh.Scene()
for k, ms in parts.items():
m = trimesh.util.concatenate(ms); m.apply_transform(M)
if k == 'basin': m.update_faces(np.arange(len(m.faces)))
rgba, metal, rough = looks[k]; m.visual = mat(k, rgba, metal, rough)
sc.add_geometry(m, node_name='sink_' + k if k != 'hand' else 'size_reference_hand', geom_name=k)
w = trimesh.transformations.transform_points([[0, 0, WIN_Z]], M)[0]
print('window in device frame (mm):', (w * 1000).round(1), ' distance', round(dist), 'mm, off axis', round(math.degrees(math.atan2(GX, GY))), 'deg')
sc.export('sink_scene.glb'); import os; print(os.path.getsize('sink_scene.glb') // 1024, 'KB')