Files
2026-10-22 01:22:01 +02:00

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.')