#!/usr/bin/env python3
"""Adjust vertical height of a GLB model by moving POSITION vertices on the Y axis.

Usage:
  python tools/adjust_glb_height.py input.glb output.glb -5

Offset is in centimeters.
- Use negative value to lower a floating sofa.
- Use positive value to raise a sofa that is sinking below the floor.

Notes:
- glTF/model-viewer uses meters and Y-up coordinates.
- This script supports common GLB files with FLOAT VEC3 POSITION attributes.
- Always keep a backup of the original model.
"""
import argparse
import json
import struct
from pathlib import Path

GLB_MAGIC = b'glTF'
JSON_CHUNK = 0x4E4F534A
BIN_CHUNK = 0x004E4942
COMPONENT_FLOAT = 5126
TYPE_SIZES = {
    'SCALAR': 1,
    'VEC2': 2,
    'VEC3': 3,
    'VEC4': 4,
    'MAT2': 4,
    'MAT3': 9,
    'MAT4': 16,
}


def read_glb(path):
    data = Path(path).read_bytes()
    if len(data) < 20 or data[:4] != GLB_MAGIC:
        raise ValueError('Input is not a GLB file.')

    magic, version, total_length = struct.unpack_from('<4sII', data, 0)
    if version != 2:
        raise ValueError('Only GLB version 2 is supported.')
    if total_length != len(data):
        raise ValueError('GLB length header does not match file length.')

    offset = 12
    chunks = []
    while offset < len(data):
        chunk_length, chunk_type = struct.unpack_from('<II', data, offset)
        offset += 8
        chunk_data = data[offset:offset + chunk_length]
        offset += chunk_length
        chunks.append((chunk_type, bytearray(chunk_data)))

    json_index = next((i for i, (t, _) in enumerate(chunks) if t == JSON_CHUNK), None)
    bin_index = next((i for i, (t, _) in enumerate(chunks) if t == BIN_CHUNK), None)
    if json_index is None or bin_index is None:
        raise ValueError('GLB must contain JSON and BIN chunks.')

    json_text = bytes(chunks[json_index][1]).decode('utf-8').rstrip(' \t\r\n\x00')
    gltf = json.loads(json_text)
    return gltf, chunks, json_index, bin_index


def accessor_byte_range(gltf, accessor_index):
    accessor = gltf['accessors'][accessor_index]
    if accessor.get('componentType') != COMPONENT_FLOAT or accessor.get('type') != 'VEC3':
        return None

    buffer_view = gltf['bufferViews'][accessor['bufferView']]
    count = accessor['count']
    accessor_offset = accessor.get('byteOffset', 0)
    base_offset = buffer_view.get('byteOffset', 0) + accessor_offset
    stride = buffer_view.get('byteStride', 12)
    return base_offset, stride, count, accessor


def adjust_positions(gltf, bin_chunk, offset_meters):
    changed = 0
    for mesh in gltf.get('meshes', []):
        for primitive in mesh.get('primitives', []):
            position_accessor = primitive.get('attributes', {}).get('POSITION')
            if position_accessor is None:
                continue
            info = accessor_byte_range(gltf, position_accessor)
            if info is None:
                continue
            base_offset, stride, count, accessor = info
            for i in range(count):
                y_offset = base_offset + (i * stride) + 4
                y = struct.unpack_from('<f', bin_chunk, y_offset)[0]
                struct.pack_into('<f', bin_chunk, y_offset, y + offset_meters)
            mins = accessor.get('min')
            maxs = accessor.get('max')
            if isinstance(mins, list) and len(mins) >= 2:
                mins[1] = float(mins[1]) + offset_meters
            if isinstance(maxs, list) and len(maxs) >= 2:
                maxs[1] = float(maxs[1]) + offset_meters
            changed += 1
    return changed


def pad4(raw, pad_byte=b' '):
    remainder = len(raw) % 4
    if remainder:
        raw += pad_byte * (4 - remainder)
    return raw


def write_glb(path, gltf, chunks, json_index):
    json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
    json_bytes = pad4(json_bytes, b' ')
    chunks[json_index] = (JSON_CHUNK, bytearray(json_bytes))

    total_length = 12 + sum(8 + len(chunk_data) for _, chunk_data in chunks)
    out = bytearray(struct.pack('<4sII', GLB_MAGIC, 2, total_length))
    for chunk_type, chunk_data in chunks:
        out.extend(struct.pack('<II', len(chunk_data), chunk_type))
        out.extend(chunk_data)
    Path(path).write_bytes(out)


def main():
    parser = argparse.ArgumentParser(description='Adjust GLB model height on Y axis.')
    parser.add_argument('input_glb')
    parser.add_argument('output_glb')
    parser.add_argument('offset_cm', type=float)
    args = parser.parse_args()

    gltf, chunks, json_index, bin_index = read_glb(args.input_glb)
    offset_meters = args.offset_cm / 100.0
    changed = adjust_positions(gltf, chunks[bin_index][1], offset_meters)
    if changed == 0:
        raise RuntimeError('No supported POSITION accessors were changed. Check if the GLB uses FLOAT VEC3 positions.')
    write_glb(args.output_glb, gltf, chunks, json_index)
    print(f'Adjusted {changed} mesh primitive(s) by {args.offset_cm}cm on Y axis.')


if __name__ == '__main__':
    main()
