110 lines
6.5 KiB
Python
110 lines
6.5 KiB
Python
"""Prepare a Blender GLB export for the configurator.
|
|
|
|
python tools/prepare_export.py path/to/edited.glb --out prepared
|
|
Produces prepared/dum.glb and prepared/scene.json. Keeps the original complete
|
|
catalogue texture library, rebinds material/node indices using loceniceId.
|
|
It does not update floor polygons, collision volumes, windows or structural design.
|
|
"""
|
|
from pathlib import Path
|
|
from copy import deepcopy
|
|
import argparse, json, struct
|
|
ROOT=Path(__file__).resolve().parent.parent
|
|
|
|
def read_glb(path):
|
|
data=Path(path).read_bytes()
|
|
if len(data)<28 or struct.unpack_from('<II',data)!=(0x46546c67,2):
|
|
raise ValueError('Expected glTF 2.0 GLB')
|
|
if struct.unpack_from('<I',data,8)[0]!=len(data):raise ValueError('GLB size mismatch')
|
|
offset=12;g=None;b=None
|
|
while offset<len(data):
|
|
size,kind=struct.unpack_from('<II',data,offset)
|
|
if offset+8+size>len(data):raise ValueError('Truncated GLB')
|
|
chunk=data[offset+8:offset+8+size]
|
|
if kind==0x4e4f534a:g=json.loads(chunk)
|
|
elif kind==0x004e4942:b=chunk
|
|
offset+=8+size
|
|
if g is None or b is None:raise ValueError('Missing JSON or binary chunk')
|
|
return g,b
|
|
|
|
def write_glb(path,g,b):
|
|
j=json.dumps(g,ensure_ascii=False,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
|
|
b=bytes(b);b+=b'\0'*((-len(b))%4)
|
|
Path(path).write_bytes(struct.pack('<IIIII',0x46546c67,2,28+len(j)+len(b),len(j),0x4e4f534a)+j+struct.pack('<II',len(b),0x004e4942)+b)
|
|
|
|
def rebind(source,edited,output,metadata=None):
|
|
old,oldbin=read_glb(source);new,newbin=read_glb(edited);newbin=bytearray(newbin)
|
|
if len(new.get('buffers',[]))!=1:raise ValueError('Use a single embedded GLB buffer')
|
|
maps={}
|
|
for plural in ['nodes','materials']:
|
|
by_id={v.get('extras',{}).get('loceniceId'):i for i,v in enumerate(new[plural]) if v.get('extras',{}).get('loceniceId')}
|
|
by_name={v.get('name'):i for i,v in enumerate(new[plural]) if v.get('name')}
|
|
mapping={}
|
|
for i,value in enumerate(old[plural]):
|
|
key=value.get('extras',{}).get('loceniceId');index=by_id.get(key,by_name.get(value.get('name')))
|
|
if index is not None:
|
|
mapping[i]=index
|
|
# Restore unsupported glTF extras lost by an editor, retain edited properties.
|
|
new[plural][index]['extras']={**deepcopy(value.get('extras',{})),**new[plural][index].get('extras',{})}
|
|
maps[plural]=mapping
|
|
def node(i):
|
|
if i not in maps['nodes']:raise ValueError(f'Missing configured object {old["nodes"][i].get("name",i)}. Preserve loceniceId, do not join variants.')
|
|
return maps['nodes'][i]
|
|
def material(i):
|
|
if i not in maps['materials']:raise ValueError(f'Missing material {old["materials"][i].get("name",i)}. Do not merge material slots.')
|
|
return maps['materials'][i]
|
|
meta=deepcopy(metadata or old['extras'])
|
|
h=meta['hardiePlank'];h['boardMaterial']=material(h['boardMaterial']);h['backingMaterial']=material(h['backingMaterial'])
|
|
for key in ['boardNodes','backingNodes']:h[key]=[node(i)for i in h[key]]
|
|
interior=meta['interiorStudy']
|
|
for key in ['roofNodes','cutOriginalNodes']:interior[key]=[node(i)for i in interior[key]]
|
|
interior['wallPaint']['materialIndices']=[material(i)for i in interior['wallPaint']['materialIndices']]
|
|
if 'node'in interior.get('timberReference',{}):interior['timberReference']['node']=node(interior['timberReference']['node'])
|
|
std=meta['allstavStandards'];std['materials']={k:[material(i)for i in v]for k,v in std['materials'].items()}
|
|
std['triangleExcludedNode']=node(std['triangleExcludedNode'])
|
|
for window in std['windows']:window['node']=node(window['node'])
|
|
for value in new['nodes']:
|
|
extras=value.get('extras',{})
|
|
if 'windowNode'in extras:extras['windowNode']=node(extras['windowNode'])
|
|
# The app uses a stable texture library: indices 0..31, including inactive samples.
|
|
# Blender exports only used images; append the complete library from the source GLB.
|
|
count=old.get('extras',{}).get('webTextureLibrarySize',32)
|
|
for value in new.get('materials',[]):
|
|
def shift(obj):
|
|
if not isinstance(obj,dict):return
|
|
for key,val in obj.items():
|
|
if isinstance(val,dict):
|
|
if key.endswith('Texture')and isinstance(val.get('index'),int):val['index']+=count
|
|
else:shift(val)
|
|
shift(value)
|
|
oldsamplers=old.get('samplers',[]);sampler_offset=len(new.setdefault('samplers',[]));new['samplers']+=deepcopy(oldsamplers)
|
|
lib=[];images={}
|
|
for texture in old['textures'][:count]:
|
|
src=texture['source']
|
|
if src not in images:
|
|
im=deepcopy(old['images'][src]);bv=old['bufferViews'][im['bufferView']]
|
|
start=bv.get('byteOffset',0);payload=oldbin[start:start+bv['byteLength']]
|
|
newbin+=b'\0'*((-len(newbin))%4);offset=len(newbin);newbin+=payload
|
|
view_index=len(new.setdefault('bufferViews',[]));new['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(payload)})
|
|
im['bufferView']=view_index;images[src]=len(new.setdefault('images',[]));new['images'].append(im)
|
|
tex=deepcopy(texture);tex['source']=images[src]
|
|
if 'sampler'in tex:tex['sampler']+=sampler_offset
|
|
lib.append(tex)
|
|
new['textures']=lib+new.get('textures',[])
|
|
new['buffers'][0]['byteLength']=len(newbin)
|
|
meta['webTextureLibrarySize']=count;meta['webRevision']='locenice-web-edited'
|
|
new['extras']={**new.get('extras',{}),**meta}
|
|
output=Path(output);output.mkdir(parents=True,exist_ok=True)
|
|
if (output/'dum.glb').exists()or(output/'scene.json').exists():raise FileExistsError('Output already exists; choose a new directory')
|
|
write_glb(output/'dum.glb',new,newbin)
|
|
(output/'scene.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2))
|
|
return {'nodes':len(new['nodes']),'materials':len(new['materials']),'textures':len(new['textures']),'mappedNodes':len(maps['nodes'])}
|
|
|
|
if __name__=='__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('edited',type=Path);parser.add_argument('--out',type=Path,default=Path('prepared'))
|
|
parser.add_argument('--source',type=Path,default=ROOT/'models'/'dum.glb')
|
|
args=parser.parse_args()
|
|
print(rebind(args.source,args.edited,args.out))
|
|
print('Back up the website first. Replace models/dum.glb AND data/scene.json together.')
|
|
print('Use Three.js for exported transforms. Recheck dimensions, variants, collisions and camera presets.')
|