Preserve homestead context and garden-centred Blender design
This commit is contained in:
212
source/samford-spacious-tour/build_tour.py
Normal file
212
source/samford-spacious-tour/build_tour.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import bpy, math, os, json, random, bmesh
|
||||
from mathutils import Vector
|
||||
from math import sin,cos,pi
|
||||
OUT=os.path.dirname(os.path.abspath(__file__)); s=bpy.context.scene
|
||||
def center(o): return o.matrix_world@(sum((Vector(v) for v in o.bound_box),Vector())/8)
|
||||
def ground(x,y): return -.024*y+.004*x+.12*sin(x*.19)*cos(y*.14)+.055*sin(x*.72+y*.32)
|
||||
def move(o,delta): o.location+=Vector(delta)
|
||||
def shift_collection(name,d):
|
||||
for o in bpy.data.collections[name].objects: move(o,d)
|
||||
sleep=(-5,-7,0); sauna=(-5,-2,0); tanks=(-5,-4,0); garden=(6,1,0)
|
||||
shift_collection('02 Sleeping pavilion 8 x 5 m',sleep)
|
||||
shift_collection('16 Oval Finnish sauna',sauna)
|
||||
shift_collection('17 Outdoor shower and stone terrace',sauna)
|
||||
shift_collection('06 Kitchen garden',garden)
|
||||
for o in bpy.data.collections['05 Water and services'].objects:
|
||||
p=center(o); n=o.name
|
||||
if n.startswith(('2000 mm','Poly tank','Tank service lid','Accessible tank outlet','Gravel tank pad')): move(o,tanks)
|
||||
elif n.startswith('Exposed rainwater downpipe') and p.x<-10: move(o,sleep)
|
||||
elif p.y<-8 and ('PV ' in n or 'Photovoltaic' in n or 'Ground array' in n): move(o,(3,-6,0))
|
||||
for o in bpy.data.collections['18 Starlink and single-server homelab'].objects:
|
||||
if o.name.startswith('Starlink'): move(o,sleep)
|
||||
if o.name=='Overview sauna label': o.hide_render=True; o.hide_set(True)
|
||||
for o in bpy.data.collections['19 Clay finishes and hemp wall infill'].objects:
|
||||
if o.name.startswith('Sleeping'): move(o,sleep)
|
||||
for o in list(bpy.data.collections['12 Natural materials and reversible connections'].objects):
|
||||
p=center(o)
|
||||
if o.name.startswith('Sleeping west expansion'): move(o,sleep)
|
||||
elif o.name.startswith('Stone facing bed joint'):
|
||||
if p.x<-12 and p.y<-5: move(o,sleep)
|
||||
elif p.x<-6: bpy.data.objects.remove(o,do_unlink=True)
|
||||
# Guest and office are optional attached rooms; short connecting frames will be rebuilt.
|
||||
for o in list(bpy.data.collections['13 NEXT - optional attached room modules'].objects):
|
||||
if o.name.startswith('02A'): move(o,(5,0,0))
|
||||
elif o.name.startswith('02B'): move(o,(-7,-7,0))
|
||||
else: bpy.data.objects.remove(o,do_unlink=True)
|
||||
for o in list(bpy.data.collections['14 LATER - independent dwelling and workshop'].objects):
|
||||
if o.name.startswith('03A'): move(o,(0,9,0))
|
||||
elif o.name.startswith('03B'): move(o,(0,-8,0))
|
||||
else: bpy.data.objects.remove(o,do_unlink=True)
|
||||
for o in bpy.data.collections['15 Expansion labels - toggle to hide'].objects: o.hide_set(True); o.hide_render=True
|
||||
# Move the tree whose former location is inside the newly separated guest reservation.
|
||||
tree_delta=(0,0,0)
|
||||
for o in bpy.data.collections['09 Eucalyptus and fruit trees'].objects:
|
||||
if 'trunk' in o.name:
|
||||
p=center(o); tree_delta=(-7,0,0) if -35<p.x<-33 and -17<p.y<-13 else (0,0,0)
|
||||
if 'Fruit tree' in o.name: tree_delta=garden
|
||||
if tree_delta!=(0,0,0): move(o,tree_delta)
|
||||
# Remove old hard-edged routes and rebuild a connected, terrain-following gravel network.
|
||||
for o in list(bpy.data.collections['07 Terrain and paths'].objects):
|
||||
if 'path' in o.name.lower() or 'gravel' in o.name.lower(): bpy.data.objects.remove(o,do_unlink=True)
|
||||
for o in list(bpy.data.collections['17 Outdoor shower and stone terrace'].objects):
|
||||
if o.name.startswith('Loose gravel sauna access'): bpy.data.objects.remove(o,do_unlink=True)
|
||||
for o in list(bpy.data.collections['03 Covered breezeway'].objects): bpy.data.objects.remove(o,do_unlink=True)
|
||||
COL={}; active='new'
|
||||
for key,name in [('new','20 Spacious site connections'),('bath','21 Bathroom fittings'),('tour','22 PLAY - guided camera tour')]:
|
||||
c=bpy.data.collections.new(name); s.collection.children.link(c); COL[key]=c
|
||||
src=open(os.path.join(OUT,'../samford-modular/build_scene.py')).read(); exec(src[src.index('def mesh('):src.index('timber=textured(')])
|
||||
wood=bpy.data.materials['Unpainted hardwood - long X grain']; woodz=bpy.data.materials['Oiled spotted gum posts - vertical grain']; steel=bpy.data.materials['Reusable dark steel bolts and shoes']; stone=bpy.data.materials['Split sandstone facing - mineral grain']; roof=bpy.data.materials['Pale Colorbond - powder coated steel']; gravel=bpy.data.materials['Loose local gravel - fine stones']; clay=bpy.data.materials['Soft warm clay plaster - natural mineral texture']; glass=bpy.data.materials['Clear glazing']; F=1.12
|
||||
for cx,cy,w,d in [(-11.1,-1.8,10.2,1.6),(-16.2,-7.45,1.6,11.3)]:
|
||||
box('Extended covered walkway bearer',(cx,cy,F-.18),(w,d,.18),wood)
|
||||
for i in range(int(w/.14)):
|
||||
box('Extended walkway timber deck board',(cx-w/2+.07+i*.14,cy,F-.035),(.13,d,.06),wood,.003)
|
||||
box('Extended walkway pale roof',(cx,cy,F+2.7),(w+.20,d+.20,.065),roof,.008)
|
||||
count=max(2,int(max(w,d)/2.4)+1)
|
||||
for j in range(count):
|
||||
a=j/(count-1)
|
||||
for side in [-1,1]:
|
||||
x=cx-w/2+.12+a*(w-.24) if w>d else cx+side*(w/2-.12)
|
||||
y=cy+side*(d/2-.12) if w>d else cy-d/2+.12+a*(d-.24)
|
||||
g=ground(x,y); box('Walkway stone pier',(x,y,(g+F-.25)/2),(.27,.27,F-.25-g),stone,.01)
|
||||
box('Walkway exposed timber post',(x,y,F+1.32),(.10,.10,2.64),woodz,.005)
|
||||
if w>d:
|
||||
for j in range(int(w/.6)+1): box('Walkway exposed rafter',(cx-w/2+j*.6,cy,F+2.64),(.055,d,.10),wood)
|
||||
else:
|
||||
for j in range(int(d/.6)+1): box('Walkway exposed rafter',(cx,cy-d/2+j*.6,F+2.64),(w,.055,.10),wood)
|
||||
nextmat=bpy.data.materials['NEXT - warm timber ghost frame']
|
||||
for x1,x2,y in [(6.25,12.8,.8),(-27,-23.25,-15.2)]:
|
||||
for yy in [y-.8,y+.8]:
|
||||
beam('Optional extension link floor',(x1,yy,F),(x2,yy,F),.07,.10,nextmat)
|
||||
beam('Optional extension link roof',(x1,yy,F+2.75),(x2,yy,F+2.75),.07,.10,nextmat)
|
||||
def path(name,points,width):
|
||||
vs=[]; fs=[]
|
||||
for a,b in zip(points,points[1:]):
|
||||
av,bv=Vector(a),Vector(b); dv=bv-av; side=Vector((-dv.y,dv.x)).normalized()*width/2
|
||||
n=max(2,int(dv.length/.4))
|
||||
for i in range(n):
|
||||
p=av+dv*i/(n-1)
|
||||
for sidep in [p-side,p+side]: vs.append((sidep.x,sidep.y,ground(sidep.x,sidep.y)+.045))
|
||||
k=len(vs)-n*2
|
||||
fs.extend([(k+i*2,k+i*2+1,k+i*2+3,k+i*2+2) for i in range(n-1)])
|
||||
mesh(name,vs,fs,gravel)
|
||||
path('Main meandering gravel arrival',[(1,17),(0,10),(0,7)],1.6)
|
||||
path('Sauna and tanks gravel walk',[(0,7),(-8,6),(-15,6),(-22,6.6),(-25,5.9),(-27,2),(-27,-5),(-24,-8)],1.2)
|
||||
path('Garden and greenhouse gravel walk',[(0,7),(9,9),(19,9),(26,6),(26,-4),(24,-5)],1.25)
|
||||
path('Sleeping pavilion garden walk',[(-8,6),(-12,2),(-12,-11),(-15.5,-12.5)],1.1)
|
||||
path('Future north dwelling reserved access',[(-8,6),(-7,15),(-9,21)],.8)
|
||||
path('Future workshop reserved access',[(0,-3.5),(0,-18),(0,-25)],.8)
|
||||
# Stretch ground-contact supports to the actual ground after relocation.
|
||||
for o in s.objects:
|
||||
if o.type!='MESH': continue
|
||||
if any(t in o.name.lower() for t in ['pier','foundation support','tank pad']):
|
||||
p=center(o); zmin=min((o.matrix_world@v.co).z for v in o.data.vertices); g=ground(p.x,p.y)
|
||||
if g<max((o.matrix_world@v.co).z for v in o.data.vertices)-.05:
|
||||
inv=o.matrix_world.inverted()
|
||||
for v in o.data.vertices:
|
||||
q=o.matrix_world@v.co
|
||||
if abs(q.z-zmin)<.001: q.z=g; v.co=inv@q
|
||||
# Complete the bathroom alcove so the interior tour can identify its actual purpose.
|
||||
active='bath'; ceramic=mat('Bathroom warm white ceramic',(.76,.73,.65),.23); water=mat('Bathroom satin stainless',(.34,.38,.38),.32,.75)
|
||||
box('Bathroom mineral floor',(-15.94,-15.92,F+.02),(1.50,3.85,.045),stone,.008)
|
||||
box('Bathroom toilet cistern',(-15.83,-17.74,F+.65),(.44,.19,.65),ceramic,.08)
|
||||
box('Bathroom toilet pedestal',(-15.83,-17.38,F+.22),(.29,.40,.43),ceramic,.10)
|
||||
box('Bathroom toilet bowl',(-15.83,-17.29,F+.43),(.43,.59,.15),ceramic,.15)
|
||||
box('Bathroom toilet seat',(-15.83,-17.25,F+.52),(.40,.55,.055),ceramic,.12)
|
||||
box('Bathroom floating timber vanity',(-15.50,-15.7,F+.64),(.45,.82,.28),wood,.02)
|
||||
box('Bathroom basin',(-15.50,-15.7,F+.85),(.42,.65,.11),ceramic,.055)
|
||||
rod('Bathroom tap',(-15.30,-15.8,F+.9),(-15.30,-15.8,F+1.13),.015,water)
|
||||
box('Bathroom mirror',(-15.12,-15.7,F+1.51),(.022,.72,.8),water,.025)
|
||||
box('Bathroom shower tray',(-15.93,-14.0,F+.04),(1.45,1.18,.08),stone,.012)
|
||||
rod('Bathroom shower riser',(-15.15,-13.97,F+.35),(-15.15,-13.97,F+2.15),.017,water)
|
||||
rod('Bathroom shower arm',(-15.15,-13.97,F+2.15),(-15.58,-13.97,F+2.15),.017,water)
|
||||
rod('Bathroom shower head',(-15.58,-13.97,F+2.10),(-15.58,-13.97,F+2.14),.10,water,sides=24)
|
||||
# Gentle interior fill lights are visible fixtures, rather than relying on outdoor sunlight.
|
||||
for name,p,power in [('Living ceiling light',(-2,-.5,3.7),220),('Sleeping ceiling light',(-19,-15,3.7),130),('Bathroom ceiling light',(-15.9,-15.4,3.55),65)]:
|
||||
d=bpy.data.lights.new(name,'AREA'); d.energy=power; d.size=2; d.color=(1,.85,.68); ob=bpy.data.objects.new(name,d); COL[active].objects.link(ob); ob.location=p
|
||||
# One animated camera with explicit shot-boundary cuts is dependable in Timeline playback.
|
||||
active='tour'; s.timeline_markers.clear()
|
||||
camdata=bpy.data.cameras.new('PLAY TOUR camera'); cam=bpy.data.objects.new('PLAY TOUR camera',camdata); COL[active].objects.link(cam); camdata.clip_start=.04; camdata.clip_end=450; camdata.lens=34; s.camera=cam
|
||||
shots=[
|
||||
('01 / THE SPACIOUS HOMESTEAD',(51,71,48),(45,65,43),(-4,-3,1),(-5,-4,1),44),
|
||||
('02 / LIVING PAVILION & VERANDA',(12,17,6),(8,12,5),(0,3,2),(0,2.8,2),36),
|
||||
('03 / KITCHEN & EXPOSED STRUCTURE',(-.2,1.8,2.9),(-1.2,1.25,2.75),(-3,-2.35,2.0),(-3.2,-2.35,2.1),30),
|
||||
('04 / DINING & OPEN LIVING',(-4.8,1.8,2.9),(-4.1,2,2.7),(.2,.1,1.9),(.9,-.1,2),30),
|
||||
('05 / ONE SERVER - LOCAL AI',(1,-.85,3.55),(1.25,-1,3.3),(3.9,-2.3,1.95),(4.2,-2.3,1.9),36),
|
||||
('06 / LONG COVERED WALK',(-7.2,-.8,2.75),(-9.5,-.8,2.75),(-16.2,-2.4,2),(-16.2,-4,2),30),
|
||||
('07 / BEDROOM + BATHROOM PAVILION',(-8,-5,7),(-10,-7,6),(-19,-15.5,2),(-19,-15.5,2),35),
|
||||
('08 / THE BEDROOM',(-17.6,-13.8,2.8),(-17.9,-14,2.7),(-20,-15.8,1.7),(-20,-16,1.7),26),
|
||||
('09 / THE BATHROOM',(-16.25,-14.9,2.95),(-16.15,-15.1,2.9),(-15.7,-17,1.7),(-15.7,-17.1,1.7),22),
|
||||
('10 / FINNISH SAUNA & OUTDOOR SHOWER',(-14,15,6.5),(-16,12,5.5),(-24,2.7,1.7),(-24,2.6,1.6),37),
|
||||
('11 / SAUNA BENCHES & STONE HEATER',(-23.0,3.62,2.18),(-23.25,3.62,2.18),(-23,1.2,1.95),(-23.2,1.2,1.95),20),
|
||||
('12 / OUTDOOR SHOWER',(-24.5,7,2.9),(-25,6.5,2.7),(-27,4.4,1.4),(-27,4.4,1.4),34),
|
||||
('13 / TWO RAINWATER TANKS',(-18,-1,4.4),(-19,-2.5,4.1),(-25,-8,1),(-25,-8,1),40),
|
||||
('14 / ROOFTOP SOLAR & STARLINK',(-4,-3,10),(-7,-5,9),(-15.5,-13.1,4.6),(-15.5,-13.1,4.6),36),
|
||||
('15 / SOLAR ARRAY & ENERGY STORAGE',(22,-7,7),(20,-9,6),(13,-17.5,1.5),(13,-17.5,1.5),36),
|
||||
('16 / FOOD GARDEN & GREENHOUSE',(34,10,9),(32,7,7),(25,-3,1),(25,-4,1),34),
|
||||
('17 / INSIDE THE GREENHOUSE',(24.2,-5.7,2.6),(24.2,-6.3,2.5),(24.2,-9.0,1.3),(24.2,-9.5,1.3),24),
|
||||
('18 / ADD ROOMS WHEN NEEDED',(26,12,11),(22,10,10),(15.8,1,2),(15.8,1,2),37),
|
||||
('19 / FUTURE DWELLING & WORKSHOP',(48,69,51),(44,64,47),(-6,-2,1),(-6,-2,1),40),
|
||||
('20 / LIVE FIRST. ADD LATER.',(44,64,47),(51,71,51),(-6,-2,1),(-6,-2,1),40),
|
||||
]
|
||||
FPS=24; N=120; s.render.fps=FPS; s.frame_start=1; s.frame_end=N*len(shots); s.sync_mode='FRAME_DROP'
|
||||
caption=mat('Tour captions ivory',(.92,.94,.86),.6); caption.node_tree.nodes['Principled BSDF'].inputs['Emission Color'].default_value=(.92,.94,.86,1); caption.node_tree.nodes['Principled BSDF'].inputs['Emission Strength'].default_value=1
|
||||
schedule=[]
|
||||
for i,(title,a,b,ta,tb,lens) in enumerate(shots):
|
||||
start=i*N+1; end=(i+1)*N
|
||||
for frame,pos,target in [(start,a,ta),(end,b,tb)]:
|
||||
cam.location=pos; cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler(); cam.keyframe_insert('location',frame=frame); cam.keyframe_insert('rotation_euler',frame=frame); camdata.lens=lens; camdata.keyframe_insert('lens',frame=frame)
|
||||
marker=s.timeline_markers.new(title,frame=start)
|
||||
cu=bpy.data.curves.new(title,'FONT'); cu.body=title; cu.size=.036; cu.align_x='LEFT'; ob=bpy.data.objects.new(title,cu); COL[active].objects.link(ob); cu.materials.append(caption); ob.parent=cam
|
||||
half=36/lens; ob.location=(-half*.91,half*(9/16)*.84,-2); ob.show_in_front=True
|
||||
for f,value in [(0,0),(start-1,0),(start,1),(end,1),(end+1,0)]: ob.scale=(value,)*3; ob.keyframe_insert('scale',frame=f)
|
||||
schedule.append({'title':title,'start':start,'end':end,'seconds':5})
|
||||
# Linear moves within a shot; constant title visibility changes.
|
||||
def curves(action):
|
||||
if not action: return []
|
||||
if hasattr(action,'fcurves'): return action.fcurves
|
||||
result=[]
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
for slot in action.slots:
|
||||
bag=strip.channelbag(slot)
|
||||
if bag: result.extend(bag.fcurves)
|
||||
return result
|
||||
for owner in [cam,camdata]:
|
||||
for fc in curves(owner.animation_data.action):
|
||||
for k in fc.keyframe_points: k.interpolation='LINEAR'
|
||||
for o in COL['tour'].objects:
|
||||
if o.type=='FONT':
|
||||
for fc in curves(o.animation_data.action):
|
||||
for k in fc.keyframe_points: k.interpolation='CONSTANT'
|
||||
# Viewport tour uses material colours for responsive playback. Glazing is outlined so interiors remain visible.
|
||||
for o in s.objects:
|
||||
if o.type=='MESH' and any(sl.material==glass for sl in o.material_slots): o.display_type='WIRE'
|
||||
if o.type=='MESH' and o.name.startswith(('Native grasses','Eucalyptus individual canopy leaves','Mango / avocado individual canopy leaves')): o.hide_set(True)
|
||||
for screen in bpy.data.screens:
|
||||
for area in screen.areas:
|
||||
if area.type=='VIEW_3D':
|
||||
sp=area.spaces.active; sp.region_3d.view_perspective='CAMERA'; sp.region_3d.view_camera_zoom=0; sp.shading.type='SOLID'; sp.shading.color_type='MATERIAL'; sp.shading.light='STUDIO'; sp.shading.show_shadows=True; sp.shading.show_cavity=True; sp.overlay.show_overlays=False; sp.clip_end=450
|
||||
if hasattr(sp,'use_local_camera'): sp.use_local_camera=False
|
||||
for area in screen.areas:
|
||||
if area.type=='DOPESHEET_EDITOR': area.spaces.active.show_region_ui=False
|
||||
s.frame_set(1); s.render.engine='CYCLES'; s.render.resolution_x=2560; s.render.resolution_y=1440; s.render.resolution_percentage=100; s.cycles.samples=40; s.cycles.use_denoising=True
|
||||
s['PLAY THE TOUR']='Press Space with the mouse over the 3D viewport, or click Play in Timeline. Shift+Left Arrow returns to the beginning. 100 seconds / 20 scenes at 24 fps. Saved in active camera view.'
|
||||
s['Spacious layout']='Sleeping pavilion moved 5m west and 7m south: 10m north-south clear gap from living footprint. Covered L walkway rebuilt. Sauna moved 5m west/2m south; tanks 5m west/4m south; garden 6m east/1m north. Optional future sites spaced farther apart.'
|
||||
s['Adjoining pavilion explanation']='The second rectangle is the bedroom and bathroom pavilion, not a standalone toilet. Bathroom fixtures now modelled for the tour.'
|
||||
s['Viewport performance']='Dense leaf/grass meshes are hidden only in viewport, retained for final renders. Glass uses outline display in solid viewport. Restore via Alt+H if desired.'
|
||||
txt=bpy.data.texts.new('START HERE - PRESS PLAY'); txt.write(s['PLAY THE TOUR']+'\n\n'+s['Spacious layout']+'\n\n'+s['Adjoining pavilion explanation']+'\n\n'+s['Viewport performance'])
|
||||
json.dump(schedule,open(os.path.join(OUT,'tour-chapters.json'),'w'),indent=2)
|
||||
names=[o.name for o in s.objects]
|
||||
report={'duration_seconds':100,'frames':s.frame_end,'shots':len(shots),'tank_count':sum(n.startswith('2000 mm') for n in names),'solar_panels':sum(n.startswith('PV panel glass and cells') for n in names),'garden_beds':sum(n.startswith('Raised vegetable bed soil') for n in names),'servers':sum(n.startswith('ONE LOCAL AI SERVER') for n in names),'sleeping_center':[-19,-15.5],'north_south_clear_gap_metres':10,'active_camera':cam.name,'bathroom_fitted':True}
|
||||
assert report['tank_count']==2 and report['solar_panels']==30 and report['garden_beds']==6 and report['servers']==1
|
||||
json.dump(report,open(os.path.join(OUT,'tour-checks.json'),'w'),indent=2)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'samford-spacious-guided-tour.blend'))
|
||||
# Contact sheets can be made from quick workbench renders without waiting for a full movie.
|
||||
for o in s.objects:
|
||||
if o.type=='MESH' and any(sl.material==glass for sl in o.material_slots): o.hide_render=True
|
||||
if o.name=='Subtle atmosphere over hillside': o.hide_render=True
|
||||
if o.type=='MESH' and o.name.startswith(('Native grasses','Eucalyptus individual canopy leaves','Mango / avocado individual canopy leaves')): o.hide_render=True
|
||||
s.render.engine='BLENDER_WORKBENCH'; s.render.resolution_percentage=30; s.display.shading.light='STUDIO'; s.display.shading.color_type='MATERIAL'; s.display.shading.show_shadows=True; s.display.shading.show_cavity=True; s.display.shading.background_type='WORLD'; s.world.color=(.19,.23,.22)
|
||||
for i in range(len(shots)):
|
||||
s.frame_set(i*N+60); s.render.filepath=os.path.join(OUT,'check-%02d.png'%(i+1)); bpy.ops.render.render(write_still=True)
|
||||
print('TOUR_BUILT_AND_CHAPTERS_RENDERED',flush=True)
|
||||
Reference in New Issue
Block a user