Enclose rainwater tanks in a shipping-container module

This commit is contained in:
2026-09-10 21:15:34 +10:00
parent 62713b9e38
commit 79c5eeddbb
18 changed files with 5865 additions and 20 deletions

View File

@@ -0,0 +1,138 @@
import bpy,math,os,json
from mathutils import Vector,Matrix
from math import cos,sin,pi
O=os.path.dirname(__file__);s=bpy.context.scene;detail=bpy.data.collections['DETAIL - full geometry for renders'];fast=bpy.data.collections['00 FAST TOUR - lightweight viewport'];services=bpy.data.collections['05 Water and services']
c=bpy.data.collections.new('35 Shipping container - rainwater storage');detail.children.link(c);COL={'new':c};active='new'
src=open(O+'/../samford-modular/build_scene.py').read();exec(src[src.index('def mesh('):src.index('timber=textured(')])
cubefs=[tuple(reversed(f)) for f in cubefs]
green=textured('Container olive green powder-coated steel',(.065,.12,.080),(.095,.17,.11),(95,95,95),.43,.0006);green.node_tree.nodes['Principled BSDF'].inputs['Metallic'].default_value=.65
steel=bpy.data.materials['Reusable dark steel bolts and shoes'];stone=bpy.data.materials['Split sandstone facing - mineral grain'];metal=bpy.data.materials['Appliance brushed metal'];wood=bpy.data.materials['Unpainted hardwood - long X grain'];gravel=bpy.data.materials['Loose local gravel - fine stones']
watermat=mat('Container enclosed water tanks pale grey polymer',(.59,.66,.57),.50);pipe=mat('Rainwater pipe dark polyethylene',(.03,.047,.035),.43);labelmat=mat('Container cream stencil lettering',(.74,.75,.62),.65)
def ground(x,y):return -.024*y+.004*x+.12*sin(x*.19)*cos(y*.14)+.055*sin(x*.72+y*.32)
removed=[]
for ob in list(services.objects):
if ob.name.startswith(('Gravel tank pad','2000 mm dark green poly rainwater tank','Poly tank','Tank service lid','Accessible tank outlet')):removed.append(ob.name);bpy.data.objects.remove(ob,do_unlink=True)
cx,cy,base=-25,-8,.42;L,W,H=6.06,2.44,2.59;xs,xe=cx-L/2,cx+L/2;ys,yn=cy-W/2,cy+W/2
# Rectangular container corner posts, ISO-style castings and structural rails.
box('CONTAINER WATER - steel base frame',(cx,cy,base+.075),(L,W,.15),green,.01)
box('Container non-absorbent interior floor',(cx,cy,base+.165),(L-.15,W-.15,.03),steel,.005)
for x in [xs+.08,xe-.08]:
for y in [ys+.08,yn-.08]:
g=ground(x,y);box('Container support pier',(x,y,(g+base)/2),(.38,.38,max(.1,base-g)),stone,.012)
box('Container structural corner post',(x,y,base+H/2),(.15,.15,H),green,.01)
for z in [base+.10,base+H-.10]:
box('ISO corner casting',(x,y,z),(.19,.18,.19),metal,.015)
box('ISO casting lifting recess',(x,y+(.093 if y>cy else -.093),z),(.075,.006,.065),steel,.014)
for y in [ys+.06,yn-.06]:
for z in [base+.18,base+H-.075]:box('Container long structural rail',(cx,y,z),(L,.13,.15),green,.007)
for x in [xs+.06,xe-.06]:box('Container upper end rail',(x,cy,base+H-.075),(.13,W,.15),green,.007)
# Corrugated long walls: folded sheet geometry rather than flat paint stripes.
for y,sign in [(ys,-1),(yn,1)]:
vs=[];faces=[];n=160
for i in range(n+1):
x=xs+.15+(L-.30)*i/n;fold=.028 if i%4 in [1,2] else -.028
vs.extend([(x,y+sign*fold,base+.24),(x,y+sign*fold,base+H-.15)])
for i in range(n):k=i*2;faces.append((k,k+2,k+3,k+1) if sign<0 else (k,k+1,k+3,k+2))
mesh('Container corrugated '+('south' if sign<0 else 'north')+' steel wall',vs,faces,green)
box('Container rain-shedding roof',(cx,cy,base+H-.035),(L-.12,W-.12,.055),green,.008)
for i in range(28):box('Container roof corrugation',(xs+.18+i*.21,cy,base+H+.005),(.055,W-.22,.025),green,.006)
# Recognisable double cargo doors at the east end, seals and locking bars.
for y in [cy-.56,cy+.56]:
box('Container cargo door dark gasket',(xe-.055,y,base+1.38),(.045,1.115,2.25),steel,.006)
box('Container cargo door panel',(xe-.021,y,base+1.38),(.04,1.065,2.20),green,.007)
for yy in [y-.25,y+.25]:
rod('Container door locking bar',(xe+.036,yy,base+.30),(xe+.036,yy,base+2.42),.015,metal,sides=16)
for z in [base+.42,base+1.19,base+2.28]:box('Door locking bar keeper',(xe+.051,yy,z),(.035,.075,.075),green,.007)
rod('Cargo door latch handle',(xe+.085,yy,base+1.13),(xe+.085,yy+.16,base+1.13),.013,metal,sides=12)
for z in [base+.48,base+1.36,base+2.21]:box('Container door hinge',(xe+.016,y+(.52 if y>cy else -.52),z),(.07,.12,.095),metal,.008)
# West end is a presentation cutaway, matching the other building cutaway views.
cut=mat('VIEW container west end transparent steel',(.07,.13,.085),.45,.6);ns=cut.node_tree.nodes;ls=cut.node_tree.links;tr=ns.new('ShaderNodeBsdfTransparent');mix=ns.new('ShaderNodeMixShader');mix.inputs[0].default_value=.07;ls.new(tr.outputs[0],mix.inputs[1]);ls.new(ns.get('Principled BSDF').outputs[0],mix.inputs[2]);ls.new(mix.outputs[0],ns.get('Material Output').inputs['Surface'])
cut.diffuse_color=(.07,.13,.085,.07)
ob=box('CONTAINER - west inspection cutaway',(xs+.035,cy,base+H/2),(.04,W-.20,H-.30),cut);ob.hide_set(True);ob['Presentation only']='West wall transparent for inspection. Restore olive container steel material for a closed exterior.'
# Two separate sealed tank vessels; the container shell itself is not a pressure/water vessel.
for x in [cx-1.55,cx+.75]:
z=base+.20
rod('INTERNAL RAINWATER TANK - sealed vessel',(x,cy,z),(x,cy,z+1.88),.92,watermat,sides=64)
rod('Internal water tank lid',(x,cy,z+1.88),(x,cy,z+1.92),.89,watermat,sides=64)
rod('Tank inspection hatch',(x,cy,z+1.92),(x,cy,z+1.965),.17,steel,sides=32)
for j in range(9):
zz=z+.15+j*.19;rod('Tank internal reinforcing band',(x,cy,zz),(x,cy,zz+.02),.933,watermat,sides=64)
rod('Tank top inlet connection',(x,cy,z+1.96),(x,cy,z+2.09),.033,pipe,sides=20)
rod('Tank manifold branch',(x,cy,z+2.09),(x,cy+.63,z+2.09),.033,pipe,sides=20)
rod('Tank low service connection',(x,cy-.88,z+.13),(x,cy-1.02,z+.13),.031,pipe,sides=20)
rod('Rainwater shared inlet manifold',(cx-1.55,cy+.63,base+2.29),(xe-.6,cy+.63,base+2.29),.035,pipe,sides=20)
rod('Rainwater shared outlet manifold',(cx-1.55,cy-1.02,base+.33),(xe-.6,cy-1.02,base+.33),.035,pipe,sides=20)
rod('Rainwater external inlet riser',(xe-.6,yn+.2,base+.1),(xe-.6,yn+.2,base+2.29),.04,pipe,sides=24)
rod('First flush collection chamber',(xe-1.0,yn+.2,base+.25),(xe-1.0,yn+.2,base+1.15),.10,watermat,sides=32)
box('Water service pump plinth',(xe-.7,ys-.65,base+.10),(.64,.72,.20),stone,.01)
rod('Water service pump motor',(xe-.7,ys-.89,base+.35),(xe-.7,ys-.41,base+.35),.13,metal,sides=32)
for x in [xe-.95,xe-.45]:box('Water pump fixing foot',(x,ys-.65,base+.245),(.075,.30,.09),steel,.007)
box('Rainwater outlet isolation valve',(xe-.6,ys-.25,base+.35),(.10,.11,.12),metal,.009)
box('Rainwater valve lever',(xe-.6,ys-.25,base+.46),(.21,.026,.02),green,.004)
rod('Inlet through-wall connection',(xe-.6,cy+.63,base+2.29),(xe-.6,yn+.2,base+2.29),.035,pipe,sides=20)
rod('Outlet through-wall connection',(xe-.6,cy-1.02,base+.33),(xe-.6,ys-.4,base+.33),.035,pipe,sides=20)
# Collection route toward the existing parents-wing downpipe, clear of paths above.
for a,b in [((-15.7,-7.83,-.15),(-18.4,-8.0,-.15)),((-18.4,-8,-.15),(xe-.6,yn+.2,-.15))]:rod('Buried roof collection pipe - concept route',a,b,.04,pipe,sides=20)
# Long-side stencil helps the object read clearly as a service container.
cu=bpy.data.curves.new('Rainwater container stencil','FONT');cu.body='RAINWATER';cu.size=.19;cu.extrude=.0005;cu.materials.append(labelmat)
t=bpy.data.objects.new('Container RAINWATER stencil',cu);c.objects.link(t);t.location=(cx+.7,yn+.035,base+1.5);t.rotation_euler=(pi/2,0,pi)
# Adjust the former tank path to approach the container's east cargo doors.
gardens=bpy.data.collections['34 Garden court - aligned pavilions and flowing landscape'];old=bpy.data.objects.get('Rainwater service garden path')
if old:bpy.data.objects.remove(old,do_unlink=True)
layout=json.load(open(O+'/garden-flow-layout.json'));layout['version']=5;layout['water_container']=[cx,cy,L,W,H]
points=[(-23,8.1),(-28.5,8.2),(-29.3,1),(-29.1,-5),(-29.1,-11.5),(-25,-12),(-20.5,-11.4),(-20.5,-8),(xe,-8)]
# Smooth Catmull-Rom route follows terrain and stops at the east cargo doors.
pts=[];p=[Vector(q) for q in points]
for i in range(len(p)-1):
a=p[max(0,i-1)];b=p[i];cc=p[i+1];d=p[min(i+2,len(p)-1)]
for j in range(20):
f=j/20;v=.5*(2*b+(-a+cc)*f+(2*a-5*b+4*cc-d)*f*f+(-a+3*b-3*cc+d)*f*f*f);pts.append(tuple(v))
pts.append(tuple(p[-1]));vs=[];fs=[]
for i,q in enumerate(pts):
v=Vector(q);d=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(i-1,0)]);side=Vector((-d.y,d.x)).normalized()*.575
for vv in [v-side,v+side]:vs.append((vv.x,vv.y,ground(*vv)+.085))
for i in range(len(pts)-1):k=i*2;fs.append((k,k+1,k+3,k+2))
mesh('Rainwater container service garden path',vs,fs,gravel)
for path in layout['paths']:
if path['name']=='Rainwater service garden path':path.update(name='Rainwater container service garden path',points=pts)
# Refresh only the affected fast display meshes, leaving tree simplification untouched.
def combine(coll,name):
old=bpy.data.objects.get(name)
if old:bpy.data.objects.remove(old,do_unlink=True)
vs=[];fs=[];indices=[];mats=[];lookup={}
for ob in coll.objects:
if ob.type!='MESH' or ob.hide_render or ob.hide_get():continue
remap=[]
for m in ob.data.materials:
if m not in lookup:lookup[m]=len(mats);mats.append(m)
remap.append(lookup[m])
mw=Matrix.LocRotScale(ob.location,ob.rotation_euler.to_quaternion(),ob.scale);off=len(vs);vs.extend(tuple(mw@v.co) for v in ob.data.vertices)
for poly in ob.data.polygons:fs.append(tuple(off+j for j in poly.vertices));indices.append(remap[poly.material_index])
me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update()
for m in mats:me.materials.append(m)
me.polygons.foreach_set('material_index',indices);ob=bpy.data.objects.new(name,me);fast.objects.link(ob)
combine(services,'FAST | 05 Water and services');combine(gardens,'FAST | Aligned garden court and flowers');combine(c,'FAST | Container rainwater storage')
cam=bpy.data.objects['PLAY TOUR camera'];chapters=json.load(open(O+'/tour-chapters-garden-flow.json'));chapters[12]['title']='13 / CONTAINER RAINWATER STORAGE'
for f,pos in [(1441,(-17,-2.5,4.6)),(1560,(-17.5,-3.1,4.3))]:
s.frame_set(f);cam.location=pos;cam.rotation_euler=(Vector((-25,-8,1.6))-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f);cam.data.lens=40;cam.data.keyframe_insert('lens',frame=f)
fonts=[o for o in bpy.data.collections['22 PLAY - guided camera tour'].objects if o.type=='FONT']
for o in fonts:
if o.data.body.startswith('13 /'):o.data.body=chapters[12]['title'];half=36/40;o.location=(-half*.91,half*9/16*.84,-2)
for m in s.timeline_markers:
if m.frame==1441:m.name=chapters[12]['title']
s['Water storage']='20-foot container-style enclosure with two separate sealed internal tanks, corrugated steel shell, cargo doors, plumbing and a west inspection cutaway. Container shell is not modelled as the water-retaining vessel. Structural/loading, tank product and water-system sizing remain concept design.'
s['Current design']=s.get('Current design','')+' Exposed cylindrical tanks replaced by a container water-storage module in version 005.'
s.frame_set(1441);s.render.resolution_percentage=100
json.dump(layout,open(O+'/container-water-layout.json','w'),indent=2);json.dump(chapters,open(O+'/tour-chapters-container-water.json','w'),indent=2)
report={'container_dimensions_m':[L,W,H],'internal_tanks':sum(o.name.startswith('INTERNAL RAINWATER TANK') for o in s.objects),'old_exposed_tanks_removed':not any(o.name.startswith('2000 mm dark green poly rainwater tank') for o in s.objects),'west_inspection_cutaway':True,'tour_chapters':len(chapters),'removed_old_objects':len(removed)}
assert report['internal_tanks']==2 and report['old_exposed_tanks_removed']
json.dump(report,open(O+'/container-water-checks.json','w'),indent=2)
bpy.ops.wm.save_as_mainfile(filepath=O+'/samford-solarpunk-container-water-tour.blend')
for o in fonts:o.hide_render=True
s.render.resolution_percentage=60;s.cycles.samples=32;s.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='METAL';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='METAL'
s.cycles.device='GPU'
for name,f in [('container-water.png',1480),('container-water-overview.png',45)]:s.frame_set(f);s.render.filepath=O+'/'+name;bpy.ops.render.render(write_still=True)
cam.animation_data_clear();cam.data.animation_data_clear();cam.location=(-33,-3.5,4);cam.rotation_euler=(Vector((-25.5,-8,1.6))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.lens=38;s.render.filepath=O+'/container-water-cutaway.png';bpy.ops.render.render(write_still=True)
print('CONTAINER_WATER_READY '+json.dumps(report),flush=True)

View File

@@ -1,6 +1,6 @@
from pathlib import Path
import json,html
O=Path(__file__).parent;d=json.loads((O/'garden-flow-layout.json').read_text());p=[]
O=Path(__file__).parent;d=json.loads((O/('container-water-layout.json' if (O/'container-water-layout.json').exists() else 'garden-flow-layout.json')).read_text());p=[]
p.append('<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1180" viewBox="0 0 1400 1180"><rect width="1400" height="1180" fill="#f3f0e7"/><style>text{font-family:Arial,sans-serif;fill:#30433b}.sub{fill:#68756b}</style><text x="60" y="58" font-size="31" font-weight="600">A home held together by its garden.</text><text x="60" y="91" font-size="16" class="sub">SAMFORD VALLEY / Aligned pavilions, soft garden curves and room to grow · Concept 004</text>')
def pt(x,y):return (600+x*13,500-y*13)
def line(pts,color,w,dash=''):
@@ -39,8 +39,11 @@ rect(*d['solar'],'#849a9d');label(9.4,-18.7,['GROUND SOLAR'],9)
label(0,-25,['Aligned garage, training and energy zone'],11)
x,y=d['sauna'];ellipse(x,y,2.2,2.0,'#ba9b75');label(x,y+.15,['SAUNA'],10)
rect(-27,6.8,1.3,1.3,'#b0c5c1');label(-29,10,['Outdoor shower'],10)
for x in [-23.95,-26.25]:ellipse(x,-8,1,1,'#6d8563')
label(-25,-10.8,['Rainwater tanks'],10)
if 'water_container' in d:
rect(*d['water_container'][:4],'#6d8563');label(-25,-13.8,['CONTAINER WATER','2 enclosed tanks'],10)
else:
for x in [-23.95,-26.25]:ellipse(x,-8,1,1,'#6d8563')
label(-25,-10.8,['Rainwater tanks'],10)
rect(24.2,-7.5,4.3,5,'#c3d7c2');label(24.2,-7.4,['GREENHOUSE'],9)
for x in [23.8,27.5]:
for y in [-1.7,1.5,4.7]:rect(x,y,2.3,1.3,'#ad9573')
@@ -51,4 +54,4 @@ label(12,-7,['FLOWER','GARDEN'],10)
rect(*d['future_basketball'],'#edf0e6','stroke-dasharray="6 6"');label(13,-35,['FUTURE','BASKETBALL','Unbuilt reserve'],11)
label(27,-29,['4 m gravel access'],11)
p.extend(['<path d="M1250 220V153m0 0l-9 18m9-18l9 18" fill="none" stroke="#30433b" stroke-width="3"/><text x="1250" y="140" text-anchor="middle" font-size="17">N</text>','<line x1="65" y1="1030" x2="195" y2="1030" stroke="#30433b" stroke-width="4"/><text x="65" y="1060" font-size="13">10 metres</text>','<line x1="60" y1="1090" x2="1340" y2="1090" stroke="#c5cebd"/><text x="60" y="1120" font-size="14">13 planted garden beds · Smooth pedestrian routes · Future pavilions remain shadows · Table-tennis deck removed</text>','<text x="60" y="1147" font-size="13" class="sub">Concept only: illustrative terrain, no surveyed boundary. Plant forms and structural connections require site-specific design development.</text></svg>'])
(O/'garden-flow-plan.svg').write_text(''.join(p))
(O/('container-water-plan.svg' if d['version']==5 else 'garden-flow-plan.svg')).write_text(''.join(p).replace('Concept 004','Concept 005' if d['version']==5 else 'Concept 004'))

View File

@@ -0,0 +1,19 @@
import bpy,json,os
from mathutils import Matrix,Vector
O=os.path.dirname(__file__);s=bpy.context.scene
chapters=json.load(open(O+'/tour-chapters-container-water.json'));fonts=[o for o in bpy.data.collections['22 PLAY - guided camera tour'].objects if o.type=='FONT'];issues=[]
for c in chapters:
s.frame_set(c['start']+30);shown=[o.data.body for o in fonts if o.scale.length>.5]
if shown!=[c['title']]:issues.append([c['title'],shown])
names=[o.name for o in s.objects]
report={'internal_tanks':sum(n.startswith('INTERNAL RAINWATER TANK') for n in names),'old_exposed_tanks_removed':not any(n.startswith('2000 mm dark green poly rainwater tank') for n in names),'container':all(n in names for n in ['CONTAINER WATER - steel base frame','Container corrugated north steel wall','CONTAINER - west inspection cutaway']),'tour_chapters':len(chapters),'caption_issues':issues,'render_size':[s.render.resolution_x,s.render.resolution_y,s.render.resolution_percentage],'kitchen_preserved':'ISLAND - honed marble countertop' in names,'no_table_tennis':not any('table tennis' in n.lower() for n in names)}
assert report['internal_tanks']==2 and report['old_exposed_tanks_removed'] and report['container'] and not issues and report['kitchen_preserved'] and report['no_table_tennis']
assert report['render_size']==[2560,1440,100] and s.frame_end==3600
# Internal tank bounding boxes fit within the structural enclosure.
for o in s.objects:
if o.name.startswith('INTERNAL RAINWATER TANK'):
m=Matrix.LocRotScale(o.location,o.rotation_euler.to_quaternion(),o.scale)
for b in o.bound_box:
v=m@Vector(b);assert -27.98<v.x<-22.02 and -9.17<v.y<-6.83 and .58<v.z<2.9
s.frame_set(1441);bpy.ops.wm.save_as_mainfile(filepath=O+'/samford-solarpunk-container-water-tour.blend')
json.dump(report,open(O+'/container-water-verification.json','w'),indent=2);print('VERIFIED '+json.dumps(report),flush=True)