Add enhanced house presentation, client film and walkable Unreal prototype
This commit is contained in:
15
source/samford-v007-client-walkthrough/DESIGN-AND-PLAN.md
Normal file
15
source/samford-v007-client-walkthrough/DESIGN-AND-PLAN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Samford presentation and first walkable homestead — v007
|
||||
|
||||
Goal: improve the existing approved Blender concept and provide an actual shareable video plus an editable first-person Unreal prototype of the whole site. Preserve all v006 layout decisions and historical files.
|
||||
|
||||
Design: natural material detail at human scale; finished edges, soft mineral surfaces, timber end grain and grain variation, fuller garden planting and visible lighting details. Retain the open presentation sides and future ghost pavilions. No new architectural programme or claim of surveyed/construction accuracy.
|
||||
|
||||
Implementation:
|
||||
- [x] Inspect the v006 saved scene and improve materials, architectural detail and planting in a new v007 file. Keep the 31-chapter inspection tour and FAST/DETAIL split.
|
||||
- [x] Render a small set of actual Cycles views, inspect them, and build a separate short cinematic with deliberate camera moves. Encode an H.264 MP4 for ordinary client playback.
|
||||
- [x] Export evaluated full-site meshes in logical groups, with transferable material maps. Import into a separate Unreal 5.8 project, preserve the existing meditation study.
|
||||
- [x] Configure the bundled ArchVisCharacter with WASD/mouse, real floor/terrain collision and furniture/wall obstacles. Save an opening level and presentation cameras.
|
||||
- [x] Test the saved Unreal level, floor traces and actual first-person movement; capture a real UE preview. Label the result a prototype rather than a packaged production simulation.
|
||||
- [ ] Save model, video, Unreal assets and usage/context documentation to the public server project; verify repository and published gallery.
|
||||
|
||||
Validation: saved-file object/material checks; unchanged building coordinates and preserved features; view actual rendered images and video contact sheet; inspect video dimensions/duration; verify Unreal assets, collision and pawn movement. The MP4 is a concept presentation, not engineering approval or an exact representation of a surveyed property.
|
||||
39
source/samford-v007-client-walkthrough/README.md
Normal file
39
source/samford-v007-client-walkthrough/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Samford Valley — presentation and walkable prototype, v007
|
||||
|
||||
This revision develops the approved Samford Valley concept for design conversations and client presentations. It preserves the modular layout, future shadow pavilions, natural material direction and all previous house features.
|
||||
|
||||
## Watch the film
|
||||
|
||||
`video/Samford-Valley-Concept-Film-v007.mp4` is the client presentation master. It contains real animated Blender camera views, introductory and closing cards, and short captions. H.264 MP4, 1920 × 1080, 24 fps, 30 seconds, no audio. It plays in QuickTime or a normal browser.
|
||||
|
||||
The film uses Cycles source frames rendered at 1440 × 810, six source frames per second, then optical-flow interpolation within each shot to 24 fps and upscale to 1080p. The editable cinematic retains the camera animation at 24 fps and 1920 × 1080 for a future full-frame, higher-sample render. This is a first concept film rather than final photorealistic marketing output.
|
||||
|
||||
## Walk in Unreal Engine
|
||||
|
||||
Open `SamfordHomestead/SamfordHomestead.uproject` in Unreal Engine 5.8.2 on this Mac. Allow the first shader load to finish. The homestead level opens automatically.
|
||||
|
||||
1. Press the green Play button, then click inside the viewport.
|
||||
2. Use W/A/S/D to walk and the mouse to look around.
|
||||
3. Escape stops play. Shift+F1 releases the mouse while playing.
|
||||
4. Start on the main verandah, walk into the kitchen/living room or down the front steps to the garden. The curved covered walkway leads to the meditation room.
|
||||
|
||||
The pawn uses Epic's bundled ArchVisCharacter plugin. It has a human-scale collision capsule and walking movement rather than free flight. The new project contains the entire site as grouped meshes with transferred material maps, collision, lights and presentation cameras. Python Editor Script, Editor Scripting Utilities, ArchVisCharacter, Sequencer Scripting and Movie Render Pipeline are bundled plugins enabled in the project; no external paid plugin is required.
|
||||
|
||||
This is an editable editor-play prototype, not a packaged application or browser-hosted simulation. Some large mesh groups and procedural material approximations will need finer optimization and art direction before production delivery. Open presentation faces are retained to match the Blender concept. Unbuilt pavilions have no collision. There are no interactive doors, product switches, VR controls or construction-stage simulation yet.
|
||||
|
||||
## Blender files
|
||||
|
||||
- `samford-solarpunk-presentation-tour.blend`: current enhanced whole-site scene, Cycles 2560 × 1440, all 31 inspection-tour chapters. FAST TOUR is the lightweight viewport; DETAIL contains the editable render geometry.
|
||||
- `samford-client-cinematic.blend`: separate 24-second, eight-shot camera edit with the detailed geometry and no inspection captions. The opening/closing cards and film labels are added during encoding.
|
||||
|
||||
Changes: finer natural-finish variation and roughness, 420 softened construction/furniture edges, original woven cane kitchen pendants, stoneware table settings, a woven living rug and loose cushions, visible track/bolt details, fuller curved-leaf planting, and mineral chips along garden paths. Building positions remain unchanged.
|
||||
|
||||
## Continue development
|
||||
|
||||
`source/` contains the Blender enhancement, export, render, encode and verification scripts. `transfer/` contains the full-site FBX, portable textures and a manifest. Unreal setup and validation scripts live under `SamfordHomestead/Content/Python/`. The previous meditation-only Unreal study remains a separate historical project.
|
||||
|
||||
Next useful work: choose a surveyed property; refine human-scale access and all room routes; replace illustrative vegetation and some material approximations with selected production assets; optimize mesh grouping, LODs and collision; add optional closed-façade mode and interactive doors; author an Unreal Sequencer film; render a final full-frame presentation; package and test a client application for their target computer. LiDAR import and construction coordination remain later stages.
|
||||
|
||||
The design has no supplied cadastral boundary, surveyed terrain or engineering documentation. Treat the video and walkable model as concept communication, not an approved or site-verified construction proposal.
|
||||
|
||||
Final presentation review also repaired the kitchen mixer riser, which had been hidden by the clay lining, and replaced the obstructed exterior bedroom film angle with an interior view. These adjustments are retained in the final Blender model/cinematic and in the Unreal map. The base FBX is supplemented by `fix_tap_connection.py` for the final copper riser.
|
||||
14
source/samford-v007-client-walkthrough/source/audit_scene.py
Normal file
14
source/samford-v007-client-walkthrough/source/audit_scene.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import bpy,json,os
|
||||
from mathutils import Matrix
|
||||
s=bpy.context.scene
|
||||
r={'collections':{c.name:len(c.objects) for c in bpy.data.collections},'materials':[],'objects':[],'cameras':[]}
|
||||
for m in bpy.data.materials:
|
||||
p=next((n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED'),None) if m.node_tree else None
|
||||
r['materials'].append({'name':m.name,'color':list(m.diffuse_color),'roughness':p.inputs['Roughness'].default_value if p else None,'nodes':[n.type for n in m.node_tree.nodes] if m.node_tree else []})
|
||||
for o in s.objects:
|
||||
if o.type=='MESH' and not o.name.startswith('FAST'):
|
||||
r['objects'].append({'name':o.name,'loc':list(o.location),'dim':list(o.dimensions),'mats':[m.name if m else '' for m in o.data.materials],'polys':len(o.data.polygons),'collections':[c.name for c in o.users_collection]})
|
||||
for f in [1,121,241,361,481,601,721,841,961,1081,1201,1321,1441,1561,1681,1801,1921,2041,2161,2281,2401,2521,2641,2761,2881,3001,3121,3241,3361,3481,3601]:
|
||||
s.frame_set(f);c=s.camera;r['cameras'].append({'f':f,'pos':list(c.location),'rot':list(c.rotation_euler),'lens':c.data.lens})
|
||||
json.dump(r,open(os.path.dirname(__file__)+'/../scene-audit.json','w'),indent=2)
|
||||
print('AUDIT',len(r['objects']),len(r['materials']))
|
||||
24
source/samford-v007-client-walkthrough/source/encode_film.py
Normal file
24
source/samford-v007-client-walkthrough/source/encode_film.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
import subprocess,json
|
||||
from PIL import Image,ImageDraw,ImageFont
|
||||
O=Path(__file__).resolve().parents[1];V=O/'video';F=V/'frames6';parts=V/'parts';parts.mkdir(exist_ok=True)
|
||||
font='/System/Library/Fonts/Supplemental/Arial.ttf';bold='/System/Library/Fonts/Supplemental/Arial Bold.ttf'
|
||||
def run(a):subprocess.run(['ffmpeg','-hide_banner','-loglevel','error','-y',*a],check=True)
|
||||
labels=['A home held together by its garden','Open living and a shaded verandah','Natural materials / a shared kitchen','Gather around the outdoor table','A separate private sleeping wing','A quiet place for meditation','Growing food and living with the landscape','Leave space for the years ahead']
|
||||
for i,label in enumerate(labels):
|
||||
frames=list(F.glob('shot%02d_*.jpg'%(i+1)));assert len(frames)==18,(i,len(frames))
|
||||
cached=parts/('shot%02d.mp4'%(i+1))
|
||||
if cached.exists() and cached.stat().st_mtime>max(f.stat().st_mtime for f in frames):continue
|
||||
overlay=Image.new('RGBA',(1920,1080),(0,0,0,0));d=ImageDraw.Draw(overlay);d.rectangle((0,960,1920,1080),fill=(0,0,0,82));d.text((64,992),label,font=ImageFont.truetype(font,30),fill='white');labelpath=parts/('label%02d.png'%(i+1));overlay.save(labelpath)
|
||||
vf="[0:v]tpad=stop_mode=clone:stop_duration=0.5,minterpolate=fps=24:mi_mode=mci:mc_mode=aobmc:vsbmc=1,trim=duration=3,setpts=PTS-STARTPTS,scale=1920:1080:flags=lanczos[base];[base][1:v]overlay=0:0,format=yuv420p[v]"
|
||||
run(['-framerate','6','-i',str(F/('shot%02d_%%03d.jpg'%(i+1))),'-i',str(labelpath),'-filter_complex',vf,'-map','[v]','-t','3','-an','-c:v','libx264','-preset','medium','-crf','18','-r','24',str(parts/('shot%02d.mp4'%(i+1)))])
|
||||
for name,title,subtitle in [('opening','SAMFORD VALLEY','A home that grows with you'),('closing','Start simple. Leave room to grow.','DESIGN CONCEPT / SEPTEMBER 2026')]:
|
||||
img=F/('shot01_000.jpg' if name=='opening' else 'shot08_017.jpg')
|
||||
overlay=Image.new('RGBA',(1920,1080),(20,37,30,168));d=ImageDraw.Draw(overlay);d.text((960,445),title,font=ImageFont.truetype(bold,68 if name=='opening' else 54),fill='white',anchor='mt');d.text((960,550),subtitle,font=ImageFont.truetype(font,30),fill=(229,221,201),anchor='mt');labelpath=parts/(name+'.png');overlay.save(labelpath)
|
||||
vf="[0:v]scale=1920:1080[base];[base][1:v]overlay=0:0,fade=t=in:st=0:d=0.3,fade=t=out:st=2.7:d=0.3,format=yuv420p[v]"
|
||||
run(['-loop','1','-i',str(img),'-i',str(labelpath),'-t','3','-filter_complex',vf,'-map','[v]','-an','-c:v','libx264','-preset','medium','-crf','18','-r','24',str(parts/(name+'.mp4'))])
|
||||
order=['opening']+['shot%02d'%(i+1) for i in range(8)]+['closing'];lst=V/'concat.txt';lst.write_text('\n'.join("file '%s'"%(parts/(name+'.mp4')) for name in order))
|
||||
output=V/'Samford-Valley-Concept-Film-v007.mp4';run(['-f','concat','-safe','0','-i',str(lst),'-c','copy','-movflags','+faststart',str(output)])
|
||||
probe=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_streams','-show_format','-of','json',str(output)]));stream=probe['streams'][0];assert stream['width']==1920 and stream['height']==1080 and 29.9<float(probe['format']['duration'])<30.1
|
||||
(V/'video-verification.json').write_text(json.dumps({'status':'PASS','output':output.name,'width':1920,'height':1080,'fps':stream['r_frame_rate'],'duration_seconds':probe['format']['duration'],'codec':stream['codec_name'],'size_bytes':output.stat().st_size,'source':'Animated Cycles renders, 1440x810 at 6 source frames/s, per-shot optical-flow interpolation to 24 fps and upscale to 1080p. No audio.'},indent=2))
|
||||
run(['-i',str(output),'-vf','fps=1/3,scale=480:270,tile=5x2','-frames:v','1',str(V/'film-contact-sheet.jpg')]);print('CLIENT_FILM_READY',output,flush=True)
|
||||
152
source/samford-v007-client-walkthrough/source/enhance_scene.py
Normal file
152
source/samford-v007-client-walkthrough/source/enhance_scene.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import bpy,os,json,math,random
|
||||
from mathutils import Matrix,Vector
|
||||
from math import sin,cos,pi
|
||||
random.seed(712);O=os.path.abspath(os.path.join(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']
|
||||
c=bpy.data.collections.new('37 Presentation - crafted details and fuller planting');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]
|
||||
wood=bpy.data.materials['Unpainted hardwood - long X grain'];steel=bpy.data.materials['Reusable dark steel bolts and shoes'];linen=bpy.data.materials['Undyed flax linen'];stone=bpy.data.materials['Split sandstone facing - mineral grain'];ceramic=bpy.data.materials['Ceramic tea bowls warm white']
|
||||
rattan=textured('Handwoven cane - fine warm fibres',(.22,.135,.062),(.48,.34,.17),(8,100,25),.72,.002)
|
||||
terracotta=textured('Unglazed terracotta - fine mineral surface',(.22,.092,.044),(.38,.19,.10),(60,60,60),.86,.0015)
|
||||
pebble=textured('Path aggregate - warm river stone',(.18,.17,.14),(.37,.34,.26),(25,25,25),.86,.002)
|
||||
print('PHASE # Existing anisotropic',flush=True)
|
||||
# Existing anisotropic grain retains direction; subtle roughness variation and random board tone.
|
||||
material_changes=[]
|
||||
for m in list(bpy.data.materials):
|
||||
if not m.node_tree or m.name.startswith('VIEW'):continue
|
||||
p=next((n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED'),None)
|
||||
if not p:continue
|
||||
ns=m.node_tree.nodes;ls=m.node_tree.links
|
||||
if any(k in m.name.lower() for k in ['hardwood','spotted gum','timber','bamboo']):
|
||||
old=p.inputs['Base Color'].links[0].from_socket if p.inputs['Base Color'].is_linked else None
|
||||
if old:
|
||||
oi=ns.new('ShaderNodeObjectInfo');ramp=ns.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(.73,.70,.65,1);ramp.color_ramp.elements[1].color=(1,1,.98,1);ls.new(oi.outputs['Random'],ramp.inputs[0]);mix=ns.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.30;ls.new(old,mix.inputs[1]);ls.new(ramp.outputs[0],mix.inputs[2]);ls.new(mix.outputs[0],p.inputs['Base Color'])
|
||||
p.inputs['Roughness'].default_value=.57;material_changes.append(m.name)
|
||||
if any(k in m.name.lower() for k in ['clay plaster','concrete','sandstone','marble','linen','textile']):
|
||||
tex=ns.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=135;tex.inputs['Detail'].default_value=2
|
||||
ramp=ns.new('ShaderNodeMapRange');base=p.inputs['Roughness'].default_value;ramp.inputs['To Min'].default_value=max(.20,base-.12);ramp.inputs['To Max'].default_value=min(.96,base+.08);ls.new(tex.outputs['Fac'],ramp.inputs['Value']);ls.new(ramp.outputs[0],p.inputs['Roughness']);material_changes.append(m.name)
|
||||
print('PHASE # Accurate small',flush=True)
|
||||
# Accurate small highlights on edges and softer upholstered furniture.
|
||||
beveled=0
|
||||
for ob in list(detail.all_objects):
|
||||
if ob.type!='MESH' or len(ob.data.polygons)>12 or ob.modifiers:continue
|
||||
if not ob.data.materials:continue
|
||||
names=' '.join(m.name for m in ob.data.materials if m)
|
||||
if any(x in names.lower() for x in ['timber','hardwood','clay','marble','concrete','linen']):
|
||||
ds=sorted(ob.dimensions)
|
||||
if ds[0]<.018:continue
|
||||
width=min(.004,ds[0]*.15)
|
||||
if 'sofa' in ob.name.lower() or 'cushion' in ob.name.lower():width=min(.065,ds[0]*.35)
|
||||
mod=ob.modifiers.new('Presentation softened real-world edge','BEVEL');mod.width=width;mod.segments=3;ob.modifiers.new('Presentation weighted corner normals','WEIGHTED_NORMAL');beveled+=1
|
||||
print('PHASE # Simple lathed',flush=True)
|
||||
# Simple lathed ceramic and woven forms, all original procedural assets.
|
||||
def lathe(n,xy,profile,ma,sides=48):
|
||||
vs=[];fs=[]
|
||||
for z,r in profile:
|
||||
for i in range(sides):a=i*2*pi/sides;vs.append((xy[0]+r*cos(a),xy[1]+r*sin(a),z))
|
||||
for j in range(len(profile)-1):
|
||||
for i in range(sides):k=j*sides+i;l=j*sides+(i+1)%sides;fs.append((k,l,l+sides,k+sides))
|
||||
ob=mesh(n,vs,fs,ma)
|
||||
for p in ob.data.polygons:p.use_smooth=True
|
||||
return ob
|
||||
def curve(n,pts,r,ma):
|
||||
cu=bpy.data.curves.new(n,'CURVE');cu.dimensions='3D';cu.bevel_depth=r;cu.bevel_resolution=1;sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
|
||||
for p,co in zip(sp.points,pts):p.co=(*co,1)
|
||||
cu.materials.append(ma);ob=bpy.data.objects.new(n,cu);c.objects.link(ob);return ob
|
||||
print('PHASE # Cane pendant',flush=True)
|
||||
# Cane pendant shades and warm concealed bulbs over kitchen island.
|
||||
for x in [-4.37,-3.22]:
|
||||
rod('Island pendant black suspension',(x,.1,3.32),(x,.1,4.10),.007,steel,sides=10)
|
||||
profile=[(3.12,.29),(3.18,.30),(3.34,.25),(3.45,.12)]
|
||||
for i in range(40):
|
||||
a=i*2*pi/40;curve('Cane pendant vertical weave',[(x+r*cos(a),.1+r*sin(a),z) for z,r in profile],.0038,rattan)
|
||||
for j in range(17):
|
||||
t=j/16;z=3.12+.33*t;r=.30-.18*t*t;curve('Cane pendant horizontal weave',[(x+r*cos(i*2*pi/64),.1+r*sin(i*2*pi/64),z) for i in range(65)],.003,rattan)
|
||||
bulb=mat('Pendant warm diffuser '+str(x),(.9,.65,.35),.7);p=bulb.node_tree.nodes['Principled BSDF'];p.inputs['Emission Color'].default_value=(1,.62,.28,1);p.inputs['Emission Strength'].default_value=2
|
||||
lathe('Warm pendant diffuser',(x,.1),[(3.18,.02),(3.20,.055),(3.26,.055),(3.28,.01)],bulb)
|
||||
ld=bpy.data.lights.new('Warm island practical light','POINT');ld.energy=12;ld.color=(1,.72,.45);ld.shadow_soft_size=.14;ob=bpy.data.objects.new(ld.name,ld);c.objects.link(ob);ob.location=(x,.1,3.14)
|
||||
print('PHASE # Stoneware',flush=True)
|
||||
# Stoneware table settings make dimensions legible at eye level.
|
||||
for x in [-.65,.1,.85]:
|
||||
for y in [-.02,.63]:
|
||||
lathe('Handmade stoneware dinner plate',(x,y),[(1.921,0),(1.922,.085),(1.94,.118),(1.947,.118),(1.938,.083),(1.937,0)],ceramic)
|
||||
box('Folded flax table napkin',(x+.19,y,1.928),(.12,.16,.014),linen,.005)
|
||||
lathe('Handmade tea cup',(x-.17,y+.04),[(1.92,.026),(2.00,.033),(2.004,.037),(1.92,.033)],ceramic,32)
|
||||
lathe('Dining table terracotta bud vase',(.1,.30),[(1.92,.04),(1.95,.065),(2.07,.057),(2.13,.025),(2.15,.025)],terracotta)
|
||||
for dx,dy in [(-.04,.015),(.045,.025),(0,-.03)]:rod('Vase dried grass stem',(.1,.30,2.06),(.1+dx,.30+dy,2.36),.002,rattan,sides=6)
|
||||
print('PHASE # Low woven',flush=True)
|
||||
# Low woven living rug, extra loose cushions with softened edges.
|
||||
box('Living woven jute rug',(3.2,1.10,1.136),(3.25,2.15,.016),rattan,.008)
|
||||
for x,a in [(2.47,-.12),(3.9,.10)]:
|
||||
ob=box('Loose linen sofa pillow',(x,-.75,1.88),(.48,.17,.43),linen,.065);ob.rotation_euler[1]=a
|
||||
print('PHASE # Stainless',flush=True)
|
||||
# Stainless track rails and small timber fixings visible in close camera views.
|
||||
for y in [2.89,3.05,3.21]:box('Sliding door stainless track inset',(0,y,1.152),(11.82,.013,.009),bpy.data.materials['Appliance brushed metal'],.002)
|
||||
for x in [-6,-3,0,3,6]:
|
||||
for z in [1.3,3.43]:
|
||||
for dx in [-.032,.032]:rod('Verandah exposed bolt head',(x+dx,5.466,z),(x+dx,5.475,z),.008,steel,sides=6)
|
||||
print('PHASE # Richer planted',flush=True)
|
||||
# Richer planted borders: broad curved leaves, fine flowering stems, aggregate along paths.
|
||||
layout=json.load(open(O+'/../samford-retreat-garage/buddha-garden-layout.json'))
|
||||
def ground(x,y):return -.024*y+.004*x+.12*sin(x*.19)*cos(y*.14)+.055*sin(x*.72+y*.32)
|
||||
leafm=[bpy.data.materials['Garden leaf '+str(i)] for i in range(4)];flower=bpy.data.materials['Garden flower soft ivory']
|
||||
vs=[];fs=[];mi=[]
|
||||
for bed in layout['beds']:
|
||||
x,y=bed['center'];rx,ry=bed['radii']
|
||||
for j in range(max(8,int(rx*ry*14))):
|
||||
a=random.random()*2*pi;r=math.sqrt(random.random())*.9;xx=x+rx*r*cos(a);yy=y+ry*r*sin(a);zz=ground(xx,yy)+.10
|
||||
for k in range(7):
|
||||
a=random.random()*2*pi;L=random.uniform(.18,.47);w=L*.15;off=len(vs)
|
||||
for t in [0,.25,.5,.75,1]:
|
||||
reach=L*t;z=zz+L*(sin(t*pi*.65)*.65+.08*t);width=w*sin(pi*t)
|
||||
for sg in [-1,0,1]:vs.append((xx+cos(a)*reach-sin(a)*width*sg,yy+sin(a)*reach+cos(a)*width*sg,z+(.014*sin(pi*t) if sg==0 else 0)))
|
||||
for k in range(4):
|
||||
for h in range(2):idx=off+k*3+h;fs.append((idx,idx+1,idx+4,idx+3));mi.append(random.randrange(4))
|
||||
ob=mesh('Fuller garden borders - curved lanceolate leaves',vs,fs)
|
||||
for m in leafm:ob.data.materials.append(m)
|
||||
ob.data.polygons.foreach_set('material_index',mi);ob.data.polygons.foreach_set('use_smooth',[True]*len(fs))
|
||||
print('PHASE # Small mineral',flush=True)
|
||||
# Small mineral chips follow the path ribbons, with low height to preserve walking.
|
||||
vs=[];fs=[]
|
||||
for path in layout['paths']:
|
||||
if path['kind']=='roofed walk' or 'VEHICLE' in path['name']:continue
|
||||
pts=path['points']
|
||||
for i in range(1,len(pts)-1,2):
|
||||
q=Vector(pts[i]);v=(Vector(pts[i+1])-Vector(pts[i-1])).normalized();side=Vector((-v.y,v.x))
|
||||
for j in range(4):
|
||||
pos=q+side*random.uniform(-.46,.46)*path['width'];z=ground(*pos)+.095;rx=random.uniform(.012,.038);ry=random.uniform(.009,.025);off=len(vs)
|
||||
vs.extend([(pos.x+rx*cos(k*pi/3),pos.y+ry*sin(k*pi/3),z+random.uniform(0,.009)) for k in range(6)]+[(pos.x,pos.y,z+.013)])
|
||||
fs.extend((off+k,off+(k+1)%6,off+6) for k in range(6))
|
||||
mesh('Garden paths - scattered mineral aggregate',vs,fs,pebble)
|
||||
print('PHASE # A more neutral',flush=True)
|
||||
# A more neutral, readable morning exposure; practical lights remain gentle.
|
||||
s.view_settings.view_transform='AgX';s.view_settings.look='AgX - Medium High Contrast';s.view_settings.exposure=.20
|
||||
s.cycles.samples=96;s.cycles.use_denoising=True;s.render.resolution_x=2560;s.render.resolution_y=1440;s.render.resolution_percentage=100
|
||||
print('PHASE # Refresh additional',flush=True)
|
||||
# Refresh additional detail proxy (existing geometry only changed through render modifiers).
|
||||
print('PHASE detail.hide_viewport=False',flush=True)
|
||||
detail.hide_viewport=False;bpy.context.view_layer.update();dg=bpy.context.evaluated_depsgraph_get();vs=[];fs=[];mi=[];mats=[];smooth=[]
|
||||
for ob in c.objects:
|
||||
if ob.type not in ['MESH','CURVE']:continue
|
||||
ev=ob.evaluated_get(dg);me=ev.to_mesh();off=len(vs);mw=Matrix.LocRotScale(ob.location,ob.rotation_euler.to_quaternion(),ob.scale);vs.extend(tuple(mw@v.co) for v in me.vertices)
|
||||
real_mats=[bpy.data.materials.get(m.name) for m in me.materials]
|
||||
for m in real_mats:
|
||||
if m not in mats:mats.append(m)
|
||||
for p in me.polygons:fs.append(tuple(off+i for i in p.vertices));mi.append(mats.index(real_mats[p.material_index]));smooth.append(p.use_smooth)
|
||||
ev.to_mesh_clear()
|
||||
me=bpy.data.meshes.new('Presentation detail proxy');me.from_pydata(vs,[],fs);me.update()
|
||||
for m in mats:me.materials.append(m)
|
||||
me.polygons.foreach_set('material_index',mi);me.polygons.foreach_set('use_smooth',smooth);ob=bpy.data.objects.new('FAST | Crafted details and fuller gardens',me);fast.objects.link(ob)
|
||||
detail.hide_viewport=True
|
||||
s['Current revision']='v007 — presentation detail, textured materials, fuller garden borders and a separate cinematic/Unreal walkthrough prototype.'
|
||||
print('PHASE s.frame_set(1);bpy.ops.wm.save',flush=True)
|
||||
s.frame_set(1);bpy.ops.wm.save_as_mainfile(filepath=O+'/samford-solarpunk-presentation-tour.blend')
|
||||
json.dump({'version':7,'beveled_objects':beveled,'materials_refined':material_changes,'new_detail_objects':len(c.objects),'new_leaf_faces':len(bpy.data.objects['Fuller garden borders - curved lanceolate leaves'].data.polygons),'tour_chapters':31},open(O+'/blender-enhancement-checks.json','w'),indent=2)
|
||||
# Actual Cycles stills for presentation review.
|
||||
for ob in bpy.data.collections['22 PLAY - guided camera tour'].objects:
|
||||
if ob.type=='FONT':ob.hide_render=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';s.cycles.samples=48;s.render.resolution_percentage=75
|
||||
for name,f in [('presentation-overview',45),('presentation-kitchen',3520),('presentation-garden',3270)]:s.frame_set(f);s.render.filepath=O+'/renders/'+name+'.png';bpy.ops.render.render(write_still=True)
|
||||
print('PRESENTATION_READY',beveled,len(c.objects),flush=True)
|
||||
@@ -0,0 +1,57 @@
|
||||
import bpy,os,json,re,math
|
||||
from mathutils import Vector,Matrix
|
||||
from collections import defaultdict
|
||||
O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));OUT=O+'/transfer';os.makedirs(OUT+'/Textures',exist_ok=True);source=bpy.context.scene;detail=bpy.data.collections['DETAIL - full geometry for renders'];detail.hide_viewport=False;source.frame_set(1);bpy.context.view_layer.update();deps=bpy.context.evaluated_depsgraph_get()
|
||||
objects=[o for o in detail.all_objects if o.type in ['MESH','CURVE'] and not o.hide_render and not any(c.name.startswith(('15 ','22 ','30 ')) for c in o.users_collection)]
|
||||
used=[]
|
||||
for o in objects:
|
||||
for m in o.data.materials:
|
||||
if m and not m.name.startswith('VIEW') and m not in used:used.append(m)
|
||||
new=bpy.data.scenes.new('Homestead Unreal transfer');new.unit_settings.system='METRIC';new.unit_settings.scale_length=1;bpy.context.window.scene=new
|
||||
manifest={'version':7,'source_blend':'samford-solarpunk-presentation-tour.blend','coordinate_transform':'UE centimetres: (100*x,-100*y,100*z)','objects':[],'materials':{},'source_objects':len(objects)}
|
||||
# Bake portable colour and tangent normal maps using the existing procedural shaders.
|
||||
new.render.engine='CYCLES';new.cycles.samples=1;new.cycles.device='CPU';new.render.bake.margin=4
|
||||
bpy.ops.mesh.primitive_plane_add(size=2);tile=bpy.context.object
|
||||
replacement={}
|
||||
for i,m in enumerate(used):
|
||||
key='M_%03d_'%i+re.sub('[^a-zA-Z0-9_]','_',m.name)[:65];p=next((n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED'),None) if m.node_tree else None
|
||||
base=list(m.diffuse_color);rough=float(p.inputs['Roughness'].default_value) if p else .7;metal=float(p.inputs['Metallic'].default_value) if p else 0
|
||||
record={'source':m.name,'base_color':base,'roughness':rough,'metallic':metal,'emission':float(p.inputs['Emission Strength'].default_value) if p else 0,'transmission':float(p.inputs['Transmission Weight'].default_value) if p else 0}
|
||||
exported=bpy.data.materials.new(key);exported.diffuse_color=base;replacement[m]=exported
|
||||
if p and p.inputs['Base Color'].is_linked:
|
||||
bake=m.copy();tile.data.materials.clear();tile.data.materials.append(bake);ns=bake.node_tree.nodes;ls=bake.node_tree.links;bp=next(n for n in ns if n.type=='BSDF_PRINCIPLED');out=next(n for n in ns if n.type=='OUTPUT_MATERIAL');im=bpy.data.images.new(key+'_BaseColor',width=512,height=512);im.filepath_raw=OUT+'/Textures/'+key+'_BaseColor.png';im.file_format='PNG';target=ns.new('ShaderNodeTexImage');target.image=im;ns.active=target;em=ns.new('ShaderNodeEmission');ls.new(bp.inputs['Base Color'].links[0].from_socket,em.inputs['Color']);ls.new(em.outputs[0],out.inputs['Surface']);bpy.context.view_layer.objects.active=tile;tile.select_set(True);bpy.ops.object.bake(type='EMIT');im.save();record['base_color_texture']='Textures/'+key+'_BaseColor.png'
|
||||
if bp.inputs['Normal'].is_linked:
|
||||
nm=bpy.data.images.new(key+'_Normal',width=512,height=512);nm.colorspace_settings.name='Non-Color';nm.filepath_raw=OUT+'/Textures/'+key+'_Normal.png';nm.file_format='PNG';target.image=nm;ns.active=target;ls.new(bp.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL');nm.save();record['normal_texture']='Textures/'+key+'_Normal.png'
|
||||
bpy.data.materials.remove(bake)
|
||||
manifest['materials'][key]=record
|
||||
print('BAKED',i,key,flush=True)
|
||||
bpy.data.objects.remove(tile,do_unlink=True)
|
||||
# Partition by collection and material: static world transforms, proper normals and box UVs.
|
||||
groups={}
|
||||
for idx,o in enumerate(objects):
|
||||
ev=o.evaluated_get(deps);me=ev.to_mesh();mw=Matrix.LocRotScale(o.location,o.rotation_euler.to_quaternion(),o.scale)
|
||||
category=next((c.name for c in o.users_collection if c.name!='DETAIL - full geometry for renders'),'Other');prefix=re.sub('[^a-zA-Z0-9_]','_',category)[:55]
|
||||
verts=[tuple(mw@v.co) for v in me.vertices]
|
||||
if not verts:ev.to_mesh_clear();continue
|
||||
lo=[min(v[k] for v in verts) for k in range(3)];hi=[max(v[k] for v in verts) for k in range(3)]
|
||||
for mi,em in enumerate(me.materials):
|
||||
m=bpy.data.materials.get(em.name) if em else None
|
||||
if m not in replacement:continue
|
||||
polys=[p for p in me.polygons if p.material_index==mi]
|
||||
if not polys:continue
|
||||
key=(prefix,replacement[m].name);g=groups.setdefault(key,{'verts':[],'faces':[],'uvs':[],'smooth':[],'sources':[]});g['sources'].append(o.name)
|
||||
off=len(g['verts']);g['verts'].extend(verts)
|
||||
for p in polys:
|
||||
g['faces'].append(tuple(off+i for i in p.vertices));g['smooth'].append(p.use_smooth)
|
||||
normal=mw.to_3x3()@p.normal;drop=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=drop]
|
||||
for vi in p.vertices:g['uvs'].append(tuple((verts[vi][k]-lo[k])/max(.08,hi[k]-lo[k]) for k in axes))
|
||||
ev.to_mesh_clear()
|
||||
if idx%800==0:print('GEOMETRY',idx,flush=True)
|
||||
for number,((cat,matname),g) in enumerate(groups.items()):
|
||||
name='SM_%03d_'%number+cat;me=bpy.data.meshes.new(name);me.from_pydata(g['verts'],[],g['faces']);me.update();me.materials.append(bpy.data.materials[matname]);me.polygons.foreach_set('use_smooth',g['smooth']);uv=me.uv_layers.new();uv.data.foreach_set('uv',[v for q in g['uvs'] for v in q]);ob=bpy.data.objects.new(name,me);new.collection.objects.link(ob)
|
||||
noncoll=cat.startswith(('08_','09_','24_')) or any(k in matname.lower() for k in ['leaf','grass','flower','glazing','shadow','haze','flame','annotation']) or all(any(k in n.lower() for k in ['leaves','aggregate','weave','pendant','plate','cup','stem','vase','track inset','bolt head']) for n in g['sources'])
|
||||
manifest['objects'].append({'name':name,'material':matname,'category':cat,'collision':not noncoll,'vertices':len(g['verts']),'faces':len(g['faces']),'source_count':len(g['sources'])})
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.export_scene.fbx(filepath=OUT+'/SamfordHomestead.fbx',use_selection=True,object_types={'MESH'},use_mesh_modifiers=False,add_leaf_bones=False,bake_anim=False,axis_forward='-Y',axis_up='Z',path_mode='RELATIVE')
|
||||
json.dump(manifest,open(OUT+'/homestead-manifest.json','w'),indent=2)
|
||||
print('HOMESTEAD_EXPORT_READY',len(groups),len(used),flush=True)
|
||||
@@ -0,0 +1,22 @@
|
||||
import bpy,os,json
|
||||
from mathutils import Vector
|
||||
from math import sin,cos,pi
|
||||
O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));s=bpy.context.scene;mode='cinematic' if 'cinematic' in bpy.data.filepath else 'model';c=bpy.data.collections['37 Presentation - crafted details and fuller planting'];fast=bpy.data.collections['00 FAST TOUR - lightweight viewport']
|
||||
a=Vector((-4.32,-2.74,2.067));b=Vector((-4.32,-2.74,2.475));q=(b-a).to_track_quat('Z','Y');N=24
|
||||
vs=[tuple(a+q@Vector((.014*cos(i*2*pi/N),.014*sin(i*2*pi/N),z))) for z in [0,(b-a).length] for i in range(N)];fs=[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
|
||||
me=bpy.data.meshes.new('Connected copper mixer riser');me.from_pydata(vs,[],fs);me.update();me.materials.append(bpy.data.materials['Copper cookware - brushed warm patina'])
|
||||
for p in me.polygons:p.use_smooth=len(p.vertices)==4
|
||||
ob=bpy.data.objects.new('Kitchen mixer - visible connected copper riser',me);c.objects.link(ob);proxy=bpy.data.objects.new('FAST | Connected kitchen mixer riser',me);fast.objects.link(proxy);s.frame_set(1);bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||
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';s.cycles.use_denoising=True;s.render.use_persistent_data=True;s.render.resolution_percentage=75
|
||||
if mode=='model':
|
||||
for c in ['22 PLAY - guided camera tour','15 Expansion labels - toggle to hide']:
|
||||
for ob in bpy.data.collections[c].objects:
|
||||
if ob.type=='FONT':ob.hide_render=True
|
||||
s.cycles.samples=48;s.frame_set(3520);s.render.filepath=O+'/renders/presentation-kitchen.png';bpy.ops.render.render(write_still=True)
|
||||
r=json.load(open(O+'/blender-enhancement-checks.json'));r['new_detail_objects']+=1;r['tap_connection_corrected']=True;json.dump(r,open(O+'/blender-enhancement-checks.json','w'),indent=2)
|
||||
else:
|
||||
s.cycles.samples=8;s.cycles.adaptive_threshold=.12;s.render.image_settings.file_format='JPEG';s.render.image_settings.quality=95
|
||||
for j in range(18):s.frame_set(145+j*4);s.render.filepath=O+'/video/frames6/shot03_%03d.jpg'%j;bpy.ops.render.render(write_still=True)
|
||||
print('TAP_CONNECTION_REPAIRED',mode,flush=True)
|
||||
@@ -0,0 +1,37 @@
|
||||
import bpy,os,json,math
|
||||
from mathutils import Vector
|
||||
O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));s=bpy.context.scene
|
||||
for c in ['22 PLAY - guided camera tour','15 Expansion labels - toggle to hide']:
|
||||
for o in bpy.data.collections[c].objects:
|
||||
if o.type=='FONT':o.hide_render=True
|
||||
cam=s.camera;cam.animation_data_clear();cam.data.animation_data_clear();cam.data.dof.use_dof=False
|
||||
# Eight compact camera movements; no fly-through through walls or furniture.
|
||||
shots=[
|
||||
('A home in the garden',(35,43,29),(32,40,27),(0,-3,1),38),
|
||||
('Timber verandah and open living',(7,11,3.3),(5.8,9.7,3.1),(0,1.5,2.1),31),
|
||||
('Kitchen / natural materials',(1.5,2.35,2.85),(.7,2.1,2.75),(-3.5,-1.3,2.15),27),
|
||||
('Room for shared meals',(17,9,4.3),(16,8.2,4.0),(10,3.5,1.9),36),
|
||||
('A private sleeping wing',(-6.4,.7,4.2),(-7.3,.4,3.9),(-12,-5,2),34),
|
||||
('Meditation in the garden',(-6.9,18.5,3),(-7.7,17.7,2.6),(-12.4,15.35,.65),42),
|
||||
('Growing food / collecting water',(34,15,17),(32,13,15),(21,-3,1),37),
|
||||
('Space to grow over time',(23,33,20),(20,30,18),(0,13,1),35)]
|
||||
s.render.fps=24;s.frame_start=1;s.frame_end=8*72
|
||||
for i,(label,a,b,target,lens) in enumerate(shots):
|
||||
start=i*72+1;end=(i+1)*72
|
||||
for f,p in [(start,a),(end,b)]:
|
||||
cam.location=p;cam.rotation_euler=(Vector(target)-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=lens;cam.data.keyframe_insert('lens',frame=f)
|
||||
for owner in [cam,cam.data]:
|
||||
for layer in owner.animation_data.action.layers:
|
||||
for strip in layer.strips:
|
||||
for bag in strip.channelbags:
|
||||
for fc in bag.fcurves:
|
||||
for k in fc.keyframe_points:k.interpolation='LINEAR'
|
||||
s.render.engine='CYCLES';s.cycles.samples=12;s.cycles.use_denoising=True;s.cycles.use_adaptive_sampling=True;s.cycles.adaptive_threshold=.08;s.render.resolution_x=1920;s.render.resolution_y=1080;s.render.resolution_percentage=100;s.render.image_settings.file_format='PNG';s.render.filepath=O+'/video/frames/frame_';s.render.film_transparent=False
|
||||
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';s.render.use_persistent_data=True;s.frame_set(1)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=O+'/samford-client-cinematic.blend')
|
||||
json.dump([dict(title=sh[0],start=i*72+1,end=(i+1)*72,seconds=3) for i,sh in enumerate(shots)],open(O+'/video/cinematic-shots.json','w'),indent=2)
|
||||
# Compare a real-time preview frame before choosing the final video renderer.
|
||||
s.render.engine='CYCLES';s.cycles.samples=12;s.frame_set(145);s.render.filepath=O+'/renders/cinematic-kitchen-test.png';bpy.ops.render.render(write_still=True)
|
||||
print('CINEMATIC_PREPARED',flush=True)
|
||||
@@ -0,0 +1,13 @@
|
||||
import bpy,os
|
||||
from mathutils import Vector
|
||||
O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));s=bpy.context.scene;cam=s.camera
|
||||
for f,p in [(289,(-10.7,-6.95,3.30)),(360,(-10.9,-6.75,3.15))]:
|
||||
s.frame_set(f);cam.location=p;cam.rotation_euler=(Vector((-12.5,-5,2.02))-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=23;cam.data.keyframe_insert('lens',frame=f)
|
||||
s.frame_set(1);bpy.ops.wm.save_as_mainfile(filepath=O+'/samford-client-cinematic.blend')
|
||||
s.render.resolution_percentage=75;s.cycles.samples=8;s.cycles.use_denoising=True;s.cycles.adaptive_threshold=.12;s.render.use_persistent_data=True;s.render.image_settings.file_format='JPEG';s.render.image_settings.quality=95
|
||||
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 j in range(18):
|
||||
s.frame_set(289+j*4);s.render.filepath=O+'/video/frames6/shot05_%03d.jpg'%j;bpy.ops.render.render(write_still=True)
|
||||
print('BEDROOM_SHOT_REFINED',flush=True)
|
||||
16
source/samford-v007-client-walkthrough/source/render_film.py
Normal file
16
source/samford-v007-client-walkthrough/source/render_film.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import bpy,os,time,json
|
||||
O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));s=bpy.context.scene
|
||||
# Six source frames per second, interpolated per shot to the 24 fps presentation master.
|
||||
s.render.resolution_percentage=75;s.cycles.samples=8;s.cycles.adaptive_threshold=.12;s.render.use_persistent_data=True;s.render.image_settings.file_format='JPEG';s.render.image_settings.quality=95
|
||||
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'
|
||||
started=time.time()
|
||||
for shot in range(8):
|
||||
for j in range(18):
|
||||
f=shot*72+1+j*4;path=O+'/video/frames6/shot%02d_%03d.jpg'%(shot+1,j)
|
||||
if os.path.exists(path):continue
|
||||
s.frame_set(f);s.render.filepath=path;bpy.ops.render.render(write_still=True)
|
||||
if j%12==0:print('FILM_PROGRESS',shot+1,j,round(time.time()-started),flush=True)
|
||||
json.dump({'source_size':[1440,810],'source_fps':6,'shots':8,'source_frames':144,'seconds':24,'renderer':'Cycles','samples':8},open(O+'/video/render-checks.json','w'),indent=2)
|
||||
print('FILM_FRAMES_READY',flush=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
import bpy,json,os
|
||||
s=bpy.context.scene;O=os.path.abspath(os.path.join(os.path.dirname(__file__),'..'));names=[o.name for o in s.objects];r={}
|
||||
r['required_features']={n:n in names for n in ['ISLAND - honed marble countertop','FAST | Garden Buddha statue','CONTAINER WATER - steel base frame','Living woven jute rug','Fuller garden borders - curved lanceolate leaves','FAST | Crafted details and fuller gardens']}
|
||||
assert all(r['required_features'].values())
|
||||
r['render']=[s.render.engine,s.render.resolution_x,s.render.resolution_y,s.render.resolution_percentage];assert r['render']==['CYCLES',2560,1440,100]
|
||||
r['ghosts']=len(bpy.data.collections['24 LATER - Shadow spaces only'].objects);assert r['ghosts']==26
|
||||
r['captions']=[];fonts=[o for o in bpy.data.collections['22 PLAY - guided camera tour'].objects if o.type=='FONT']
|
||||
for f in range(1,3721,120):s.frame_set(f+20);visible=[o.data.body for o in fonts if o.scale.length>.5];assert len(visible)==1,(f,visible);r['captions']+=visible
|
||||
assert len(r['captions'])==31 and s.frame_end==3720
|
||||
r['no_ping_pong']=not any('table tennis' in n.lower() for n in names);assert r['no_ping_pong']
|
||||
r['material_references_valid']=all(m in bpy.data.materials.values() for o in s.objects if o.type=='MESH' for m in o.data.materials if m);assert r['material_references_valid']
|
||||
r['status']='PASS';json.dump(r,open(O+'/blender-verification.json','w'),indent=2);print('BLENDER_VERIFIED',flush=True)
|
||||
Reference in New Issue
Block a user