#import "GlassesRenderer.h"
#import "LoaderUtils.h"
#import "MatrixUtils.h"
#import "OcclusionConstants.h"
#import "LensConstants.h"

#include <filament/Engine.h>
#include <filament/Scene.h>
#include <filament/TransformManager.h>
#include <filament/RenderableManager.h>
#include <filament/MaterialInstance.h>
#include <filament/Box.h>
#include <gltfio/AssetLoader.h>
#include <gltfio/ResourceLoader.h>
#include <gltfio/MaterialProvider.h>
#include <gltfio/TextureProvider.h>
#include <gltfio/materials/uberarchive.h>
#include <gltfio/FilamentAsset.h>
#include <utils/EntityManager.h>
#include <math/mat4.h>
#include <math/vec3.h>

using namespace filament;
using namespace filament::gltfio;
using namespace utils;

static NSString *const TAG = @"GlassesRenderer";

// Transform of `node` relative to `root`, composed from local transforms by
// walking up the parent chain. Independent of any committed world transform,
// so it is valid immediately after load. Identity when node == root.
static filament::math::mat4f transformRelativeToRoot(filament::TransformManager &tm,
                                                     Entity node, Entity root) {
    filament::math::mat4f acc;  // identity
    Entity e = node;
    while (!e.isNull() && e != root) {
        filament::TransformManager::Instance inst = tm.getInstance(e);
        if (!inst.isValid()) break;
        acc = tm.getTransform(inst) * acc;
        e = tm.getParent(inst);
    }
    return acc;
}

// Temple tip in asset-root-local space: the rear-most (min root-local Z) corner
// of the temple renderable's bounding box. Temples extend back toward the ears,
// so that corner is the tip whose lateral position we articulate.
static filament::math::float3 templeTipRootLocal(filament::TransformManager &tm,
                                                 filament::RenderableManager &rm,
                                                 Entity temple, Entity root) {
    using namespace filament::math;
    mat4f toRoot = transformRelativeToRoot(tm, temple, root);
    filament::Box box = rm.getAxisAlignedBoundingBox(rm.getInstance(temple));
    float3 c = box.center, h = box.halfExtent;
    float3 best{}; bool first = true;
    for (float sx : {-1.0f, 1.0f})
        for (float sy : {-1.0f, 1.0f})
            for (float sz : {-1.0f, 1.0f}) {
                float4 w = toRoot * float4{c.x + sx * h.x, c.y + sy * h.y, c.z + sz * h.z, 1.0f};
                if (first || w.z < best.z) { best = float3{w.x, w.y, w.z}; first = false; }
            }
    return best;
}

@interface GlassesRenderer ()

@property (nonatomic, assign) Engine *engine;
@property (nonatomic, assign) Scene *scene;
@property (nonatomic, assign) AssetLoader *assetLoader;
@property (nonatomic, assign) ResourceLoader *resourceLoader;
@property (nonatomic, assign) FilamentAsset *glassesAsset;
@property (nonatomic, assign) MaterialProvider *materialProvider;
@property (nonatomic, assign) TextureProvider *textureProvider;

// Thread management
@property (nonatomic, strong) dispatch_queue_t loadQueue;

// Loading state
@property (nonatomic, assign) BOOL isLoading;

// Current model info
@property (nonatomic, copy) NSString *currentModelUrl;

// Forward offset for glasses positioning (in meters)
@property (nonatomic, assign) float forwardOffset;

// Fires onGlassesDisplayed once per loaded model — reset on every successful load.
@property (nonatomic, assign) BOOL hasDisplayedCurrentModel;

// Temple articulation state, populated when the loaded glb exposes the
// expected hinge node names. articulationEnabled stays NO if any of the four
// nodes is missing — articulation becomes a no-op for that asset.
//
// Everything is captured in asset-root-local space, which is convention-
// invariant: regardless of each glb's units (cm vs m), root scale, exporter
// (Fbx vs Blender) or hierarchy depth, the hinge lands at the same metric pose
// here and the temple extends back by the same metric lever. *LocalRest is the
// parent-relative transform setTransform expects; *RootRest is relative to the
// asset root; the lever (length + bearing in the root-local X–Z plane) is what
// the swing solver needs to drive the tip's X to the ear target.
@property (nonatomic, assign) BOOL articulationEnabled;
@property (nonatomic, assign) Entity hingeLEntity;
@property (nonatomic, assign) Entity hingeREntity;
@property (nonatomic, assign) filament::math::mat4f hingeLLocalRest;
@property (nonatomic, assign) filament::math::mat4f hingeRLocalRest;
@property (nonatomic, assign) filament::math::mat4f hingeLRootRest;
@property (nonatomic, assign) filament::math::mat4f hingeRRootRest;
@property (nonatomic, assign) float templeLLeverLen;    // meters
@property (nonatomic, assign) float templeRLeverLen;
@property (nonatomic, assign) float templeLLeverAngle;  // radians, atan2(dz, dx)
@property (nonatomic, assign) float templeRLeverAngle;

// Lens-center height above the model origin (root-local meters). Some models
// are authored with the frame shifted vertically off the origin, so anchoring
// the origin to the nose bridge makes them ride too high. We instead anchor the
// lens-center, shifting placement down by this. ~0 for well-authored models.
@property (nonatomic, assign) float lensVerticalOffset;

// Whether the current model is a clip-on / solar (tinted sunglass) frame. Set
// from the JS `isClipOn` prop; drives the clip-on lens treatment in
// configureLensMaterial.
@property (nonatomic, assign) BOOL isClipOn;

// Clip-on lens material (unlit flat tint) and its reused instance. When a model
// is a clip-on, the lens primitives' material is swapped to this so the glossy
// glb lens stops reflecting the IBL. _lensBaseColorRgb caches the glb's authored
// lens tint (read before the swap) so each model keeps its own color.
@property (nonatomic, assign) Material *cliponLensMaterial;
@property (nonatomic, assign) MaterialInstance *cliponLensInstance;
@property (nonatomic, assign) filament::math::float3 lensBaseColorRgb;

- (void)swingHinge:(Entity)hinge
         localRest:(filament::math::mat4f)Lr
          rootRest:(filament::math::mat4f)Hr
               phi:(float)phi
    outwardYawSign:(float)outwardYawSign
                tm:(filament::TransformManager &)tm;
- (void)configureLensMaterial;
- (void)cacheLensVerticalOffset;

@end

@implementation GlassesRenderer

- (instancetype)init {
    self = [super init];
    if (self) {
        _loadQueue = dispatch_queue_create("com.nitrovto.glassesloader", DISPATCH_QUEUE_SERIAL);
        _isLoading = NO;
        _forwardOffset = kForwardOffset;
    }
    return self;
}

- (void)setupWithEngine:(Engine *)engine
                  scene:(Scene *)scene
               modelUrl:(NSString *)modelUrl {
    _engine = engine;
    _scene = scene;
    _currentModelUrl = modelUrl;

    // Setup GLTF loader
    _materialProvider = createUbershaderProvider(engine, UBERARCHIVE_DEFAULT_DATA, UBERARCHIVE_DEFAULT_SIZE);
    _assetLoader = AssetLoader::create({
        .engine = engine,
        .materials = _materialProvider,
        .names = nullptr,
        .entities = &EntityManager::get()
    });
    _resourceLoader = new ResourceLoader({engine, ".", true});

    // Create texture provider for PNG/JPEG decoding using stb_image
    _textureProvider = createStbProvider(engine);
    _resourceLoader->addTextureProvider("image/png", _textureProvider);
    _resourceLoader->addTextureProvider("image/jpeg", _textureProvider);

    // Clip-on lens material (unlit flat tint) — swapped onto clip-on lenses so
    // the glb's glossy lens doesn't reflect the IBL as chrome. One instance is
    // reused; its `tint` uniform is updated per model from the glb baseColor.
    NSData *cliponData = [LoaderUtils loadAssetNamed:@"materials/clipon_lens.filamat"];
    if (cliponData) {
        _cliponLensMaterial = Material::Builder()
            .package(cliponData.bytes, cliponData.length)
            .build(*engine);
        if (_cliponLensMaterial) {
            _cliponLensInstance = _cliponLensMaterial->createInstance();
        }
    }
    if (!_cliponLensInstance) {
        NSLog(@"%@: Failed to load clip-on lens material; clip-on tint disabled", TAG);
    }

    // Load model
    [self loadModelFromUrl:modelUrl];
}

- (void)loadModelFromUrl:(NSString *)url {
    if (url.length == 0) {
        NSLog(@"%@: Empty URL, skipping model load", TAG);
        return;
    }

    if (_isLoading) {
        NSLog(@"%@: Already loading a model, skipping request for: %@", TAG, url);
        return;
    }

    _isLoading = YES;
    NSLog(@"%@: Starting download from URL: %@", TAG, url);

    __weak __typeof__(self) weakSelf = self;
    dispatch_async(_loadQueue, ^{
        __strong __typeof__(weakSelf) strongSelf = weakSelf;
        if (!strongSelf) return;

        NSError *error = nil;
        NSData *modelData = [LoaderUtils loadFromUrl:url error:&error];

        dispatch_async(dispatch_get_main_queue(), ^{
            if (error) {
                NSLog(@"%@: Failed to download GLB from URL: %@", TAG, error.localizedDescription);
                strongSelf.isLoading = NO;
                return;
            }

            [strongSelf loadModelFromData:modelData];

            if (strongSelf.onModelLoaded) {
                strongSelf.onModelLoaded(url);
            }
            strongSelf.isLoading = NO;
        });
    });
}

- (void)loadModelFromData:(NSData *)data {
    if (!_assetLoader || !_resourceLoader || !_scene) return;

    _glassesAsset = _assetLoader->createAsset((const uint8_t *)data.bytes, (uint32_t)data.length);

    if (_glassesAsset) {
        _resourceLoader->loadResources(_glassesAsset);
        _glassesAsset->releaseSourceData();

        // Add all entities to scene
        const Entity *entities = _glassesAsset->getEntities();
        size_t entityCount = _glassesAsset->getEntityCount();
        for (size_t i = 0; i < entityCount; i++) {
            _scene->addEntity(entities[i]);
        }

        NSLog(@"%@: Glasses model loaded: %zu entities", TAG, entityCount);
        // Re-arm onGlassesDisplayed for this freshly-loaded model. Next successful
        // updateTransformWithFace will fire the callback.
        _hasDisplayedCurrentModel = NO;
        [self cacheTempleArticulationState];
        // Reset the cached lens tint to a neutral default before configuring.
        // configureLensMaterial only overwrites it when a lens prim exposes
        // baseColorFactor; without this reset a lens that lacks one would inherit
        // the previous model's tint (or black on first load). White caps to a
        // neutral grey, never black/stale.
        _lensBaseColorRgb = filament::math::float3{1.0f, 1.0f, 1.0f};
        [self configureLensMaterial];
        [self cacheLensVerticalOffset];
        [self hide];
    } else {
        NSLog(@"%@: Failed to create glasses asset", TAG);
    }
}

// Configure the lens material instances on the loaded asset.
//
// Culling: the lenses are thin single-sided shells. Tinted (solar/clip-on)
// lenses vanish at view angles where the camera sees their back face — backface
// culling drops the only face, so the lens shows the background instead of its
// tint. Disable culling so both faces always render. Clear lenses are
// unaffected (invisible either way); other geometry keeps its normal culling.
//
// Clip-on treatment (when _isClipOn): the glb's lens is a glossy PBR transmission
// material whose smooth surface reflects the bright IBL as "chrome" (an artifact
// of inconsistent authoring — see the working clip-ons that are authored matte).
// We can't fix that through the ubershader at runtime (alphaMode / specular weight
// aren't settable), so we SWAP the lens material to our own unlit flat-tint
// material (clipon_lens.mat): no IBL sampling → no reflection, see-through via the
// tint's alpha, per-SKU color carried in the `tint` uniform from the glb baseColor.
- (void)configureLensMaterial {
    if (!_glassesAsset || !_engine) return;
    RenderableManager &rm = _engine->getRenderableManager();
    const char *lensNodes[] = {"LensL_geometry", "LensR_geometry"};

    // Pass 1 — cache the glb's authored lens tint (only the original gltfio glass
    // material exposes baseColorFactor; after we swap, our material has `tint`).
    for (const char *name : lensNodes) {
        Entity e = _glassesAsset->getFirstEntityByName(name);
        if (e.isNull()) continue;
        RenderableManager::Instance ri = rm.getInstance(e);
        if (!ri.isValid()) continue;
        for (size_t p = 0; p < rm.getPrimitiveCount(ri); p++) {
            MaterialInstance *mi = rm.getMaterialInstanceAt(ri, p);
            if (mi && mi->getMaterial()->hasParameter("baseColorFactor")) {
                filament::math::float4 bc = mi->getParameter<filament::math::float4>("baseColorFactor");
                _lensBaseColorRgb = filament::math::float3{bc.x, bc.y, bc.z};
            }
        }
    }

    // Update the shared clip-on instance: per-SKU tint (brightness-capped,
    // hue-preserving) plus the reflection/roughness knobs.
    if (_isClipOn && _cliponLensInstance) {
        filament::math::float3 c = _lensBaseColorRgb;
        float maxc = fmaxf(c.x, fmaxf(c.y, c.z));
        float s = (maxc > kLensClipOnMaxChannel) ? (kLensClipOnMaxChannel / maxc) : 1.0f;
        _cliponLensInstance->setParameter("tint", filament::math::float3{c.x * s, c.y * s, c.z * s});
        _cliponLensInstance->setParameter("reflectance", kLensClipOnReflectance);
    }

    // Pass 2 — for clip-ons swap in our material; otherwise keep the glb lens
    // (just disable culling so the thin single-sided shell renders both faces).
    for (const char *name : lensNodes) {
        Entity e = _glassesAsset->getFirstEntityByName(name);
        if (e.isNull()) continue;
        RenderableManager::Instance ri = rm.getInstance(e);
        if (!ri.isValid()) continue;
        for (size_t p = 0; p < rm.getPrimitiveCount(ri); p++) {
            if (_isClipOn && _cliponLensInstance) {
                rm.setMaterialInstanceAt(ri, p, _cliponLensInstance);
            } else {
                MaterialInstance *mi = rm.getMaterialInstanceAt(ri, p);
                if (mi) mi->setCullingMode(MaterialInstance::CullingMode::NONE);
            }
        }
    }
}

// Cache the lens-center height (root-local Y) so updateTransformWithFace can
// anchor the lens-center — not the model origin — to the nose bridge. Stays 0
// (no correction) if the lens nodes are missing.
- (void)cacheLensVerticalOffset {
    _lensVerticalOffset = 0.0f;
    if (!_glassesAsset || !_engine) return;
    TransformManager &tm = _engine->getTransformManager();
    RenderableManager &rm = _engine->getRenderableManager();
    Entity root = _glassesAsset->getRoot();
    float sum = 0.0f; int n = 0;
    for (const char *name : {"LensL_geometry", "LensR_geometry"}) {
        Entity e = _glassesAsset->getFirstEntityByName(name);
        if (e.isNull()) continue;
        RenderableManager::Instance ri = rm.getInstance(e);
        if (!ri.isValid()) continue;
        filament::Box box = rm.getAxisAlignedBoundingBox(ri);
        filament::math::mat4f toRoot = transformRelativeToRoot(tm, e, root);
        filament::math::float4 c = toRoot * filament::math::float4{box.center.x, box.center.y, box.center.z, 1.0f};
        sum += c.y; n++;
    }
    if (n > 0) _lensVerticalOffset = sum / (float)n;
}

- (void)cacheTempleArticulationState {
    _articulationEnabled = NO;
    if (!_glassesAsset || !_engine) return;

    Entity hingeL = _glassesAsset->getFirstEntityByName("HingeL_temple");
    Entity hingeR = _glassesAsset->getFirstEntityByName("HingeR_temple");
    Entity templeL = _glassesAsset->getFirstEntityByName("TempleL_geometry");
    Entity templeR = _glassesAsset->getFirstEntityByName("TempleR_geometry");

    if (hingeL.isNull() || hingeR.isNull() || templeL.isNull() || templeR.isNull()) {
        NSLog(@"%@: Hinge/temple nodes not found — articulation disabled for this asset", TAG);
        return;
    }

    TransformManager &tm = _engine->getTransformManager();
    RenderableManager &rm = _engine->getRenderableManager();
    Entity root = _glassesAsset->getRoot();

    _hingeLEntity = hingeL;
    _hingeREntity = hingeR;
    _hingeLLocalRest = tm.getTransform(tm.getInstance(hingeL));
    _hingeRLocalRest = tm.getTransform(tm.getInstance(hingeR));
    _hingeLRootRest = transformRelativeToRoot(tm, hingeL, root);
    _hingeRRootRest = transformRelativeToRoot(tm, hingeR, root);

    // Rest temple tips in root-local space, then the hinge→tip lever in the
    // horizontal (X–Z) plane. Articulation swings the temple about the
    // root-local vertical (Y) to drive the tip's X to the ear target; length +
    // bearing of the lever are all the solver needs.
    filament::math::float3 tipL = templeTipRootLocal(tm, rm, templeL, root);
    filament::math::float3 tipR = templeTipRootLocal(tm, rm, templeR, root);
    float dxL = tipL.x - _hingeLRootRest[3][0], dzL = tipL.z - _hingeLRootRest[3][2];
    float dxR = tipR.x - _hingeRRootRest[3][0], dzR = tipR.z - _hingeRRootRest[3][2];
    _templeLLeverLen = hypotf(dxL, dzL);
    _templeRLeverLen = hypotf(dxR, dzR);
    _templeLLeverAngle = atan2f(dzL, dxL);
    _templeRLeverAngle = atan2f(dzR, dxR);

    if (_templeLLeverLen <= 0.0f || _templeRLeverLen <= 0.0f) {
        NSLog(@"%@: Degenerate temple lever — articulation disabled", TAG);
        return;
    }

    _articulationEnabled = YES;
    NSLog(@"%@: Temple articulation enabled (L pivotX=%.3f m, len=%.3f m; R pivotX=%.3f m, len=%.3f m)",
          TAG, _hingeLRootRest[3][0], _templeLLeverLen, _hingeRRootRest[3][0], _templeRLeverLen);
}

- (void)updateTransformWithFace:(ARFaceAnchor *)face frame:(ARFrame *)frame {
    if (!_glassesAsset || !_engine) return;

    if (!_hasDisplayedCurrentModel) {
        _hasDisplayedCurrentModel = YES;
        if (self.onGlassesDisplayed) {
            self.onGlassesDisplayed(_currentModelUrl);
        }
    }

    TransformManager &transformManager = _engine->getTransformManager();
    TransformManager::Instance instance = transformManager.getInstance(_glassesAsset->getRoot());

    // Get nose bridge position in world space
    simd_float3 noseBridgeWorld = [self getNoseBridgeWorldPosWithFace:face];

    // Get face rotation from transform (world space)
    simd_quatf faceRotationWorld = simd_quaternion(face.transform);

    // Build world-space transform matrix (no scaling - models are in real-world meters)
    simd_float4x4 rotationMatrix = [MatrixUtils quaternionToMatrix:faceRotationWorld];

    // Offset glasses along face's Z axis (forward/backward)
    simd_float3 forward = simd_make_float3(rotationMatrix.columns[2].x,
                                            rotationMatrix.columns[2].y,
                                            rotationMatrix.columns[2].z);

    // Anchor the lens-center (not the model origin) to the nose bridge: shift
    // down along the face's up axis by the cached lens-center height, so models
    // authored with a vertical offset don't ride too high. ~0 for normal models.
    simd_float3 up = simd_make_float3(rotationMatrix.columns[1].x,
                                       rotationMatrix.columns[1].y,
                                       rotationMatrix.columns[1].z);

    // Set world-space position with forward offset and lens-center anchoring.
    rotationMatrix.columns[3].x = noseBridgeWorld.x + forward.x * _forwardOffset - up.x * _lensVerticalOffset;
    rotationMatrix.columns[3].y = noseBridgeWorld.y + forward.y * _forwardOffset - up.y * _lensVerticalOffset;
    rotationMatrix.columns[3].z = noseBridgeWorld.z + forward.z * _forwardOffset - up.z * _lensVerticalOffset;

    // Convert simd matrix to filament matrix
    filament::math::mat4f filamentTransform;
    for (int col = 0; col < 4; col++) {
        for (int row = 0; row < 4; row++) {
            filamentTransform[col][row] = rotationMatrix.columns[col][row];
        }
    }

    transformManager.setTransform(instance, filamentTransform);
}

- (simd_float3)getNoseBridgeWorldPosWithFace:(ARFaceAnchor *)face {
    // ARKit face mesh vertex indices flanking the nose bridge. Only Y/Z are
    // taken from these — see ADR 0016 for why X comes from the face anchor's
    // symmetry axis instead.
    const int aIndex = 818;
    const int bIndex = 366;

    ARFaceGeometry *geometry = face.geometry;
    const simd_float3 *vertices = geometry.vertices;
    NSUInteger vertexCount = geometry.vertexCount;

    if (aIndex >= vertexCount || bIndex >= vertexCount) {
        // Fallback to face center
        return simd_make_float3(face.transform.columns[3].x,
                                face.transform.columns[3].y,
                                face.transform.columns[3].z);
    }

    simd_float3 a = vertices[aIndex];
    simd_float3 b = vertices[bIndex];

    float centerX = 0.0f;
    float centerY = (a.y + b.y) / 2.0f;
    float centerZ = (a.z + b.z) / 2.0f;

    simd_float4 localPos = simd_make_float4(centerX, centerY, centerZ, 1.0f);
    simd_float4 worldPos = simd_mul(face.transform, localPos);

    return simd_make_float3(worldPos.x, worldPos.y, worldPos.z);
}

// HARNESS (dev/simulator only). Park the glasses ~0.4 m in front of the camera
// (which looks down -Z) so they're visible without a tracked face. Rotated 180°
// about Y so the front of the frame faces the camera.
- (void)setStaticPreviewTransform {
    if (!_glassesAsset || !_engine) return;
    using namespace filament::math;
    TransformManager &tm = _engine->getTransformManager();
    auto inst = tm.getInstance(_glassesAsset->getRoot());
    // Angled 3/4 + slightly-top view so the temples (which extend back from the
    // hinges) are visible — needed to inspect temple articulation/splay (#3).
    mat4f t = mat4f::translation(float3{0.0f, 0.0f, -0.42f}) *
              mat4f::rotation(0.45f, float3{1.0f, 0.0f, 0.0f}) *           // tilt top toward camera
              mat4f::rotation((float)M_PI + 0.6f, float3{0.0f, 1.0f, 0.0f}); // face camera + 3/4 yaw
    tm.setTransform(inst, t);
}

- (void)hide {
    if (!_glassesAsset || !_engine) return;

    TransformManager &transformManager = _engine->getTransformManager();
    TransformManager::Instance instance = transformManager.getInstance(_glassesAsset->getRoot());

    filament::math::mat4f hideMatrix = [MatrixUtils createHideMatrix];
    transformManager.setTransform(instance, hideMatrix);
}

- (void)setForwardOffset:(float)offset {
    _forwardOffset = offset;
}

- (void)setIsClipOn:(BOOL)isClipOn {
    if (_isClipOn == isClipOn) return;
    _isClipOn = isClipOn;
    // Re-apply the lens treatment if a model is already loaded; otherwise it
    // runs at load time (configureLensMaterial reads _isClipOn).
    if (_glassesAsset && _engine) {
        [self configureLensMaterial];
    }
}

- (void)updateTempleArticulationWithEarHalfWidth:(float)earHalfWidth {
    if (!_articulationEnabled || !_engine) return;
    if (earHalfWidth <= 0.0f) return;

    TransformManager &tm = _engine->getTransformManager();

    // Temple tips target ±(earHalfWidth · kTempleTipScale) on the glasses' own
    // left/right axis (root-local X, metric — no unit conversion).
    float targetX = earHalfWidth * kTempleTipScale;

    // The two temples are mirror-symmetric by construction, so we solve ONE
    // swing from mirror-folded, averaged geometry and apply it as equal-and-
    // opposite rotations. Solving each side independently against its own
    // hinge→tip lever was not mirror-consistent: the rest lever is derived from
    // the temple's bounding-box rear corner, a phantom point that lands several
    // degrees apart between the two meshes, so the per-side solve rotated the
    // temples by different amounts and broke symmetry on a symmetric rest pose.
    auto wrap = [](float a) { while (a > M_PI) a -= 2.0f * M_PI; while (a < -M_PI) a += 2.0f * M_PI; return a; };
    float pivotXSym = 0.5f * (fabsf(_hingeLRootRest[3][0]) + fabsf(_hingeRRootRest[3][0]));
    float leverSym  = 0.5f * (_templeLLeverLen + _templeRLeverLen);
    // betaL already lives in the +X half-space; mirror betaR (x→−x: atan2(z,−x))
    // and take the circular mean so the shared bearing is exporter-agnostic.
    float betaRFolded = atan2f(sinf(_templeRLeverAngle), -cosf(_templeRLeverAngle));
    float betaSym = atan2f(sinf(_templeLLeverAngle) + sinf(betaRFolded),
                           cosf(_templeLLeverAngle) + cosf(betaRFolded));

    float c = fmaxf(-1.0f, fminf(1.0f, (targetX - pivotXSym) / leverSym));
    float d = acosf(c);
    float phiA = wrap(betaSym + d), phiB = wrap(betaSym - d);
    float phiSym = (fabsf(phiA) <= fabsf(phiB)) ? phiA : phiB;  // +X-side swing

    // HingeL on +X swings by +phiSym; HingeR mirrors with −phiSym. Each rotates
    // about its own pivot (correct conjugation); the outward yaw and down pitch
    // are applied inside swingHinge, already mirrored via outwardYawSign.
    [self swingHinge:_hingeLEntity localRest:_hingeLLocalRest rootRest:_hingeLRootRest
                 phi:+phiSym outwardYawSign:-1.0f tm:tm];
    [self swingHinge:_hingeREntity localRest:_hingeRLocalRest rootRest:_hingeRRootRest
                 phi:-phiSym outwardYawSign:+1.0f tm:tm];
}

// Swing one temple about the root-local vertical (Y) through its hinge pivot by
// `phi`, plus a small outward yaw (off the facemesh occluder) and a down pitch
// (tip to ear height). The root-local rotation M is conjugated into the hinge's
// parent-relative frame: newLocal = Lr · Hr⁻¹ · M · Hr (which collapses to Lr
// when the total rotation is zero), so it is correct whatever the hierarchy
// above the hinge.
- (void)swingHinge:(Entity)hinge
         localRest:(filament::math::mat4f)Lr
          rootRest:(filament::math::mat4f)Hr
               phi:(float)phi
    outwardYawSign:(float)outwardYawSign
                tm:(filament::TransformManager &)tm {
    using namespace filament::math;

    float yaw = outwardYawSign * kTempleOutwardYawDeg * (float)(M_PI / 180.0);
    float pitch = -kTempleDownPitchDeg * (float)(M_PI / 180.0);

    float3 pivot{Hr[3][0], Hr[3][1], Hr[3][2]};
    mat4f M = mat4f::translation(pivot)
            * mat4f::rotation(phi + yaw, float3{0.0f, 1.0f, 0.0f})
            * mat4f::rotation(pitch, float3{1.0f, 0.0f, 0.0f})
            * mat4f::translation(-pivot);
    mat4f newLocal = Lr * inverse(Hr) * M * Hr;
    tm.setTransform(tm.getInstance(hinge), newLocal);
}

- (void)switchModelWithUrl:(NSString *)modelUrl {
    if (!_scene || !_assetLoader) return;

    // Disable articulation immediately — the cached hinge entities belong to
    // the asset we're about to destroy. cacheTempleArticulationState reruns
    // when the new asset finishes loading.
    _articulationEnabled = NO;

    // Remove current model from scene
    if (_glassesAsset) {
        const Entity *entities = _glassesAsset->getEntities();
        size_t entityCount = _glassesAsset->getEntityCount();
        for (size_t i = 0; i < entityCount; i++) {
            _scene->remove(entities[i]);
        }
        _assetLoader->destroyAsset(_glassesAsset);
        _glassesAsset = nullptr;
    }

    // Update current model info
    _currentModelUrl = modelUrl;

    // Load new model
    [self loadModelFromUrl:modelUrl];
    NSLog(@"%@: Switched to model: %@", TAG, modelUrl);
}

- (void)destroy {
    if (!_assetLoader) return;

    if (_glassesAsset) {
        if (_scene) {
            const Entity *entities = _glassesAsset->getEntities();
            size_t entityCount = _glassesAsset->getEntityCount();
            for (size_t i = 0; i < entityCount; i++) {
                _scene->remove(entities[i]);
            }
        }
        _assetLoader->destroyAsset(_glassesAsset);
    }

    // ResourceLoader must be destroyed before TextureProvider
    if (_resourceLoader) {
        delete _resourceLoader;
    }
    if (_textureProvider) {
        delete _textureProvider;
    }
    if (_assetLoader) {
        AssetLoader::destroy(&_assetLoader);
    }
    if (_materialProvider) {
        _materialProvider->destroyMaterials();
        delete _materialProvider;
    }
    // Our own clip-on lens material/instance (not owned by gltfio).
    if (_cliponLensInstance && _engine) {
        _engine->destroy(_cliponLensInstance);
        _cliponLensInstance = nullptr;
    }
    if (_cliponLensMaterial && _engine) {
        _engine->destroy(_cliponLensMaterial);
        _cliponLensMaterial = nullptr;
    }
}

@end
