import java.awt.*;
import java.awt.event.*;
import java.awt.image.*;
import java.io.*;
import java.nio.ByteBuffer;
import java.util.zip.CRC32;
import javax.imageio.*;
import javax.imageio.metadata.*;
import javax.imageio.stream.ImageOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.*;
import java.util.List;
import java.util.zip.Inflater;
import javax.swing.*;

/** Просмотрщик M3G (JSR-184) на чистой Java 8: парсер формата + программный рендер + анимации. */
public class M3GViewer extends JPanel {

    // ---------- модель ----------
    static class O { List<Tr> tracks = new ArrayList<>(); }
    static class Ctrl extends O { float speed = 1, refSeq; int a0, a1, refWorld; }
    static class KF extends O { int interp, repeat, dur; int[] t; float[][] v; }
    static class Tr extends O { KF seq; Ctrl c; int prop; }
    static class Xf extends O { float[] tr = {0, 0, 0}, sc = {1, 1, 1}, q = {0, 0, 0, 1}, m; }
    static class Nd extends Xf { boolean on = true; }
    static class Grp extends Nd { List<Nd> kids = new ArrayList<>(); }
    static class World extends Grp { Cam cam; Bg bg; }
    static class Cam extends Nd { float fov = 60, near = .05f; }
    static class Lt extends Nd { int mode; float[] col = {1, 1, 1}; float inten = 1, ka = 1, kl, kq, spotAng = 45, spotExp; }
    static class Bg extends O { float[] col = {.1f, .1f, .15f}; }
    static class Mat extends O { float[] amb = {.2f, .2f, .2f}, emi = {0, 0, 0}, dif = {.8f, .8f, .8f, 1}, spec = {0, 0, 0}; float shin; boolean vct; }
    static class PM extends O { int cull = 160, wind = 168; boolean two; }
    static class App extends O { Mat mat; PM pm; Tex tex; }
    static class Img extends O { int w, h; int[] px; }
    static class Tex extends Xf { Img img; int blend = 227, wrapS = 241, wrapT = 241; }
    static class VA extends O { int cs, cc, n; int[] d; }
    static class VB extends O { VA pos, nor, col, uv; float[] bias = {0, 0, 0}, uvBias = {0, 0, 0}, dc = {1, 1, 1, 1}, P, N, U; float scale = 1, uvScale = 1; }
    static class TSA extends O { int[] idx, strips; }
    static class Sub { TSA ib; App ap; }
    static class Mesh extends Nd { VB vb; List<Sub> subs = new ArrayList<>(); }
    static class MD { Mesh m; float[] wv, wn; }
    static class Tri { float z; int[] x = new int[3], y = new int[3]; int c; }

    // ---------- чтение ----------
    static class R {
        byte[] b; int p;
        R(byte[] b, int p) { this.b = b; this.p = p; }
        int u8() { return b[p++] & 255; }
        int u16() { int v = u8(); return v | u8() << 8; }
        int i32() { int v = u16(); return v | u16() << 16; }
        float f() { return Float.intBitsToFloat(i32()); }
        boolean bo() { return u8() != 0; }
        float[] fs(int n) { float[] a = new float[n]; for (int i = 0; i < n; i++) a[i] = f(); return a; }
        float[] rgb(int n) { float[] a = new float[n]; for (int i = 0; i < n; i++) a[i] = u8() / 255f; return a; }
    }

    List<O> objs = new ArrayList<>();
    World world;

    O ref(R s) { int i = s.i32(); return i == 0 ? null : objs.get(i); }

    void load(File f) throws Exception {
        byte[] b = Files.readAllBytes(f.toPath());
        if ((b[0] & 255) != 0xAB || b[1] != 'J' || b[2] != 'S' || b[3] != 'R') throw new Exception("Не M3G файл");
        objs = new ArrayList<>(); objs.add(null); // индекс 0 = null, 1 = заголовок
        World w = null;
        R r = new R(b, 12);
        while (r.p + 13 <= b.length) {
            int comp = r.u8(), total = r.i32(), unc = r.i32(), dl = total - 13;
            byte[] d;
            if (comp == 1) {
                Inflater inf = new Inflater(); inf.setInput(b, r.p, dl);
                d = new byte[unc]; int o = 0;
                while (o < unc && !inf.finished()) o += inf.inflate(d, o, unc - o);
                inf.end();
            } else d = Arrays.copyOfRange(b, r.p, r.p + dl);
            r.p += dl + 4;
            R s = new R(d, 0);
            while (s.p < d.length) {
                int type = s.u8(), len = s.i32(), end = s.p + len;
                O o = null;
                try { o = obj(type, s); } catch (Exception e) { System.err.println("skip type " + type + ": " + e); }
                if (o == null) o = new O();
                objs.add(o);
                if (o instanceof World) w = (World) o;
                s.p = end;
            }
        }
        if (w == null) throw new Exception("В файле нет World");
        world = w; wt = 0; bounds = false; fileCam = true;
    }

    static float[] quat(float deg, float[] a) {
        float l = (float) Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]);
        if (l == 0) return new float[]{0, 0, 0, 1};
        double h = Math.toRadians(deg) / 2; float s = (float) Math.sin(h) / l;
        return new float[]{a[0] * s, a[1] * s, a[2] * s, (float) Math.cos(h)};
    }

    O obj(int t, R s) {
        O o;
        switch (t) {
            case 1: o = new Ctrl(); break;   case 2: o = new Tr(); break;
            case 3: o = new App(); break;    case 4: o = new Bg(); break;
            case 5: o = new Cam(); break;    case 8: o = new PM(); break;
            case 9: o = new Grp(); break;    case 11: o = new TSA(); break;
            case 12: o = new Lt(); break;    case 13: o = new Mat(); break;
            case 14: case 15: case 16: o = new Mesh(); break; // Morphing/Skinned: рисуем базовую сетку
            case 19: o = new KF(); break;    case 20: o = new VA(); break;
            case 21: o = new VB(); break;    case 22: o = new World(); break;
            case 10: o = new Img(); break;   case 17: o = new Tex(); break;
            default: return new O();          // заголовок, текстуры, fog и т.д. пропускаем
        }
        s.i32();
        int n = s.i32();
        for (int i = 0; i < n; i++) o.tracks.add((Tr) objs.get(s.i32()));
        n = s.i32();
        for (int i = 0; i < n; i++) { s.i32(); s.p += s.i32(); }
        if (o instanceof Xf) {
            Xf x = (Xf) o;
            if (s.bo()) { x.tr = s.fs(3); x.sc = s.fs(3); float a = s.f(); x.q = quat(a, s.fs(3)); }
            if (s.bo()) x.m = s.fs(16);
        }
        if (o instanceof Nd) { s.bo(); s.bo(); s.u8(); s.i32(); if (s.bo()) { s.u8(); s.u8(); s.i32(); s.i32(); } }
        switch (t) {
            case 1: { Ctrl c = (Ctrl) o; c.speed = s.f(); s.f(); c.a0 = s.i32(); c.a1 = s.i32(); c.refSeq = s.f(); c.refWorld = s.i32(); break; }
            case 2: { Tr k = (Tr) o; k.seq = (KF) ref(s); k.c = (Ctrl) ref(s); k.prop = s.i32(); break; }
            case 3: { App a = (App) o; s.u8(); ref(s); ref(s); a.pm = (PM) ref(s); a.mat = (Mat) ref(s);
                n = s.i32(); for (int i = 0; i < n; i++) { O tx = ref(s); if (a.tex == null && tx instanceof Tex) a.tex = (Tex) tx; } break; }
            case 4: { ((Bg) o).col = s.rgb(4); break; }
            case 5: { Cam c = (Cam) o;
                if (s.u8() == 48) {
                    float[] pj = s.fs(16);
                    if (pj[5] != 0 && pj[14] != 0) { c.fov = (float) Math.toDegrees(2 * Math.atan(1 / pj[5])); if (pj[10] != 1) c.near = pj[11] / (pj[10] - 1); }
                } else { c.fov = s.f(); s.f(); c.near = s.f(); s.f(); }
                break; }
            case 8: { PM p = (PM) o; p.cull = s.u8(); s.u8(); p.wind = s.u8(); p.two = s.bo(); break; }
            case 9: case 22: {
                Grp g = (Grp) o; n = s.i32();
                for (int i = 0; i < n; i++) { O k = ref(s); if (k instanceof Nd) g.kids.add((Nd) k); }
                if (t == 22) { ((World) o).cam = (Cam) ref(s); ((World) o).bg = (Bg) ref(s); }
                break; }
            case 11: {
                TSA a = (TSA) o; int e = s.u8(), start = 0; int[] idx = null;
                if (e < 128) start = e == 0 ? s.i32() : e == 1 ? s.u8() : s.u16();
                else { idx = new int[s.i32()]; for (int i = 0; i < idx.length; i++) idx[i] = e == 128 ? s.i32() : e == 129 ? s.u8() : s.u16(); }
                a.strips = new int[s.i32()]; int tot = 0;
                for (int i = 0; i < a.strips.length; i++) tot += a.strips[i] = s.i32();
                if (idx == null) { idx = new int[tot]; for (int i = 0; i < tot; i++) idx[i] = start + i; }
                a.idx = idx; break; }
            case 12: { Lt l = (Lt) o; l.ka = s.f(); l.kl = s.f(); l.kq = s.f(); l.col = s.rgb(3); l.mode = s.u8(); l.inten = s.f(); l.spotAng = s.f(); l.spotExp = s.f(); break; }
            case 13: { Mat m = (Mat) o; m.amb = s.rgb(3); m.dif = s.rgb(4); m.emi = s.rgb(3); m.spec = s.rgb(3); m.shin = s.f(); m.vct = s.bo(); break; }
            case 14: case 15: case 16: {
                Mesh m = (Mesh) o; m.vb = (VB) ref(s); n = s.i32();
                for (int i = 0; i < n; i++) { Sub u = new Sub(); u.ib = (TSA) ref(s); u.ap = (App) ref(s); m.subs.add(u); }
                break; }
            case 19: {
                KF k = (KF) o; k.interp = s.u8(); k.repeat = s.u8(); int enc = s.u8(); k.dur = s.i32(); s.i32(); s.i32();
                int cc = s.i32(); n = s.i32(); float[] bias = null, sc = null;
                if (enc != 0) { bias = s.fs(cc); sc = s.fs(cc); }
                k.t = new int[n]; k.v = new float[n][cc];
                for (int i = 0; i < n; i++) {
                    k.t[i] = s.i32();
                    for (int j = 0; j < cc; j++)
                        k.v[i][j] = enc == 0 ? s.f() : bias[j] + sc[j] * (enc == 1 ? s.u8() / 255f : s.u16() / 65535f);
                }
                break; }
            case 10: {
                Img im = (Img) o; int fm = s.u8(); boolean mut = s.bo(); im.w = s.i32(); im.h = s.i32();
                if (mut) break;
                int ps = s.i32(); byte[] pal = Arrays.copyOfRange(s.b, s.p, s.p + ps); s.p += ps; s.i32();
                int ch = fm == 98 ? 2 : fm == 99 ? 3 : fm == 100 ? 4 : 1, base = s.p; im.px = new int[im.w * im.h];
                for (int i = 0; i < im.px.length; i++) {
                    byte[] src = ps > 0 ? pal : s.b; int off = ps > 0 ? (s.b[base + i] & 255) * ch : base + i * ch;
                    int c0 = src[off] & 255, c1 = ch > 1 ? src[off + 1] & 255 : 0, c2 = ch > 2 ? src[off + 2] & 255 : 0, c3 = ch > 3 ? src[off + 3] & 255 : 0;
                    int r = c0, g = c0, b = c0, a = 255;
                    if (fm == 96) { r = g = b = 255; a = c0; } else if (fm == 98) a = c1;
                    else if (fm >= 99) { g = c1; b = c2; if (fm == 100) a = c3; }
                    im.px[i] = a << 24 | r << 16 | g << 8 | b;
                }
                break; }
            case 17: { Tex tx = (Tex) o; tx.img = (Img) ref(s); s.rgb(3); tx.blend = s.u8(); tx.wrapS = s.u8(); tx.wrapT = s.u8(); break; }
            case 20: {
                VA a = (VA) o; a.cs = s.u8(); a.cc = s.u8(); int enc = s.u8(); a.n = s.u16(); a.d = new int[a.n * a.cc];
                for (int i = 0; i < a.n; i++) for (int j = 0; j < a.cc; j++) {
                    int v = a.cs == 1 ? (byte) s.u8() : (short) s.u16();
                    a.d[i * a.cc + j] = enc == 1 && i > 0 ? v + a.d[(i - 1) * a.cc + j] : v;
                }
                break; }
            case 21: {
                VB v = (VB) o; v.dc = s.rgb(4); v.pos = (VA) ref(s); v.bias = s.fs(3); v.scale = s.f(); v.nor = (VA) ref(s); v.col = (VA) ref(s);
                n = s.i32();
                for (int i = 0; i < n; i++) { VA u = (VA) ref(s); float[] ub = s.fs(3); float us = s.f(); if (v.uv == null) { v.uv = u; v.uvBias = ub; v.uvScale = us; } }
                break; }
        }
        return o;
    }

    // ---------- анимация ----------
    static float[] sample(Tr k, float wt) {
        KF q = k.seq; Ctrl c = k.c;
        if (q == null || q.t.length == 0) return null;
        float t = wt;
        if (c != null) {
            if (c.a0 != c.a1 && (wt < c.a0 || wt >= c.a1)) return null;
            t = c.refSeq + c.speed * (wt - c.refWorld);
        }
        if (q.repeat == 193 && q.dur > 0) { t %= q.dur; if (t < 0) t += q.dur; }
        int n = q.t.length;
        if (t <= q.t[0]) return q.v[0];
        if (t >= q.t[n - 1]) return q.v[n - 1];
        int i = 0; while (q.t[i + 1] <= t) i++;
        float[] a = q.v[i], b = q.v[i + 1];
        if (q.interp == 180) return a;                                // STEP
        float u = (t - q.t[i]) / (q.t[i + 1] - q.t[i]);
        float[] r = new float[a.length];
        if (q.interp == 177 && a.length == 4) {                       // SLERP
            float d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3], sg = d < 0 ? -1 : 1; d *= sg;
            float k0 = 1 - u, k1 = u;
            if (d < .9995f) { double th = Math.acos(d), sn = Math.sin(th); k0 = (float) (Math.sin((1 - u) * th) / sn); k1 = (float) (Math.sin(u * th) / sn); }
            for (int j = 0; j < 4; j++) r[j] = k0 * a[j] + k1 * sg * b[j];
        } else for (int j = 0; j < a.length; j++) r[j] = a[j] + (b[j] - a[j]) * u; // LINEAR (SPLINE/SQUAD → linear)
        return r;
    }

    void animate(O o, float wt) {
        for (Tr k : o.tracks) {
            float[] v = sample(k, wt); if (v == null) continue;
            switch (k.prop) {
                case 275: if (o instanceof Xf) ((Xf) o).tr = v; break;
                case 270: if (o instanceof Xf) ((Xf) o).sc = v.length == 1 ? new float[]{v[0], v[0], v[0]} : v; break;
                case 268: if (o instanceof Xf) ((Xf) o).q = v; break;
                case 276: if (o instanceof Nd) ((Nd) o).on = v[0] >= .5f; break;
                case 261: if (o instanceof Mat) ((Mat) o).dif = v; break;
                case 257: if (o instanceof Mat) ((Mat) o).amb = v; break;
                case 262: if (o instanceof Mat) ((Mat) o).emi = v; break;
                case 272: if (o instanceof Mat) ((Mat) o).spec = v; break;
                case 271: if (o instanceof Mat) ((Mat) o).shin = v[0]; break;
                case 264: if (o instanceof Cam) ((Cam) o).fov = v[0]; break;
                case 265: if (o instanceof Lt) ((Lt) o).inten = v[0]; break;
                case 258: if (o instanceof Lt) ((Lt) o).col = v; else if (o instanceof Bg) ((Bg) o).col = v; break;
            }
        }
    }

    // ---------- математика ----------
    static float[] mul(float[] a, float[] b) {
        float[] r = new float[16];
        for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) for (int k = 0; k < 4; k++) r[i * 4 + j] += a[i * 4 + k] * b[k * 4 + j];
        return r;
    }

    static float[] local(Xf x) {
        float[] q = x.q; float n = (float) Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); if (n == 0) n = 1;
        float X = q[0] / n, Y = q[1] / n, Z = q[2] / n, W = q[3] / n;
        float[] r = {1 - 2 * (Y * Y + Z * Z), 2 * (X * Y - Z * W), 2 * (X * Z + Y * W), 0,
                     2 * (X * Y + Z * W), 1 - 2 * (X * X + Z * Z), 2 * (Y * Z - X * W), 0,
                     2 * (X * Z - Y * W), 2 * (Y * Z + X * W), 1 - 2 * (X * X + Y * Y), 0, 0, 0, 0, 1};
        for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) r[i * 4 + j] *= x.sc[j]; r[i * 4 + 3] = x.tr[i]; }
        return x.m != null ? mul(r, x.m) : r;
    }

    // ---------- рендер ----------
    float wt; boolean paused, wire, fileCam = true, bounds;
    float yaw = .6f, pitch = .3f, dist = 5; float[] ctr = {0, 0, 0}; float rad = 1;
    List<Object[]> lights = new ArrayList<>(), meshes = new ArrayList<>();
    List<Tri> tris = new ArrayList<>();
    volatile boolean exporting; volatile String progress = ""; int fitW, fitH; boolean transparent; List<float[]> vec; int triCount; BufferedImage img; int[] pix; float[] zb; Graphics2D gi;
    float[] camM, cpos, cr, cu, cb; float near, foc; int W, H; String name = "";

    void walk(Nd n, float[] p) {
        if (!n.on) return;
        float[] m = mul(p, local(n));
        if (n instanceof Grp) for (Nd k : ((Grp) n).kids) walk(k, m);
        else if (n instanceof Lt) lights.add(new Object[]{n, m});
        else if (n instanceof Cam) { if (n == world.cam) camM = m; }
        else if (n instanceof Mesh) meshes.add(new Object[]{n, m});
    }

    static float[] unit(float x, float y, float z) {
        float l = (float) Math.sqrt(x * x + y * y + z * z); if (l == 0) l = 1;
        return new float[]{x / l, y / l, z / l};
    }

    static int cl(float v) { return Math.max(0, Math.min(255, (int) (v * 255))); }

    /** Освещение как в M3G: emissive + ambient*ambient-света + diffuse*Σ(N·L) + specular; результат КЛАМПИТСЯ до [0,1] до умножения на текстуру. */
    float[] color(VB vb, Mat mt, int v, float px, float py, float pz, float nx, float ny, float nz) {
        float[] vc = null;
        if (vb.col != null && (mt == null || mt.vct)) {
            int cc = vb.col.cc; vc = new float[3];
            for (int j = 0; j < 3; j++) vc[j] = (vb.col.d[v * cc + j] & 255) / 255f;
        }
        if (mt == null) return vc != null ? vc : vb.dc;          // без Material света нет
        float[] dif = vc != null ? vc : mt.dif, amb = vc != null ? vc : mt.amb;
        float[] la = {0, 0, 0}, ld = {0, 0, 0}, ls = {0, 0, 0};
        boolean spec = mt.spec[0] + mt.spec[1] + mt.spec[2] > 0 && mt.shin > 0;
        if (lights.isEmpty()) {                                  // в файле света нет — мягкая подсветка по умолчанию
            float k = Math.max(0, nx * cb[0] + ny * cb[1] + nz * cb[2]) * .7f;
            for (int j = 0; j < 3; j++) { la[j] = .3f; ld[j] = k; }
        }
        for (Object[] lo : lights) {
            Lt L = (Lt) lo[0]; float[] m = (float[]) lo[1];
            if (L.mode == 128) { for (int j = 0; j < 3; j++) la[j] += L.col[j] * L.inten; continue; }
            float[] dir; float k = L.inten;
            if (L.mode == 129) dir = unit(m[2], m[6], m[10]);
            else {
                float dx = m[3] - px, dy = m[7] - py, dz = m[11] - pz, dd = (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
                dir = unit(dx, dy, dz);
                k /= Math.max(1e-6f, L.ka + L.kl * dd + L.kq * dd * dd);
                if (L.mode == 131) {
                    float[] ax = unit(m[2], m[6], m[10]); float cs = dir[0] * ax[0] + dir[1] * ax[1] + dir[2] * ax[2];
                    k *= cs < Math.cos(Math.toRadians(L.spotAng)) ? 0 : (float) Math.pow(cs, L.spotExp);
                }
            }
            float nd = nx * dir[0] + ny * dir[1] + nz * dir[2];
            if (nd <= 0) continue;
            for (int j = 0; j < 3; j++) ld[j] += L.col[j] * k * nd;
            if (spec) {
                float[] h = unit(dir[0] + cb[0], dir[1] + cb[1], dir[2] + cb[2]);
                float sp = (float) Math.pow(Math.max(0, nx * h[0] + ny * h[1] + nz * h[2]), mt.shin) * k;
                for (int j = 0; j < 3; j++) ls[j] += L.col[j] * sp;
            }
        }
        float[] r = new float[3];
        for (int j = 0; j < 3; j++) r[j] = Math.max(0, Math.min(1, mt.emi[j] + amb[j] * la[j] + dif[j] * ld[j] + mt.spec[j] * ls[j]));
        return r;
    }

    void addTri(MD d, App ap, int a, int b, int c) {
        float[] w = d.wv; VB vb = d.m.vb;
        float ax = w[a * 3], ay = w[a * 3 + 1], az = w[a * 3 + 2];
        float ux = w[b * 3] - ax, uy = w[b * 3 + 1] - ay, uz = w[b * 3 + 2] - az;
        float vx = w[c * 3] - ax, vy = w[c * 3 + 1] - ay, vz = w[c * 3 + 2] - az;
        float nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
        PM pm = ap != null ? ap.pm : null; int cull = pm != null ? pm.cull : 160;
        boolean two = pm != null && pm.two;
        if (pm != null && pm.wind == 169) { nx = -nx; ny = -ny; nz = -nz; }
        boolean front = nx * (cpos[0] - ax) + ny * (cpos[1] - ay) + nz * (cpos[2] - az) > 0;
        if ((cull == 160 && !front && !two) || (cull == 161 && front)) return;
        float[] nn = unit(nx, ny, nz); float sg = front ? 1 : -1; nx = nn[0] * sg; ny = nn[1] * sg; nz = nn[2] * sg;
        Mat mt = ap != null ? ap.mat : null; Tex tx = ap != null ? ap.tex : null;
        if (tx != null && (tx.img == null || tx.img.px == null || vb.U == null)) tx = null;
        int[] vi = {a, b, c}; float[] X = new float[3], Y = new float[3], Z = new float[3]; float[][] C = new float[3][], T = new float[3][2];
        for (int k = 0; k < 3; k++) {
            int v = vi[k]; float qx = w[v * 3], qy = w[v * 3 + 1], qz = w[v * 3 + 2];
            float dx = qx - cpos[0], dy = qy - cpos[1], dz = qz - cpos[2];
            float dep = -(dx * cb[0] + dy * cb[1] + dz * cb[2]);
            if (dep < near) return;
            X[k] = W / 2f + (dx * cr[0] + dy * cr[1] + dz * cr[2]) * foc / dep;
            Y[k] = H / 2f - (dx * cu[0] + dy * cu[1] + dz * cu[2]) * foc / dep; Z[k] = dep;
            C[k] = d.wn != null ? color(vb, mt, v, qx, qy, qz, d.wn[v * 3] * sg, d.wn[v * 3 + 1] * sg, d.wn[v * 3 + 2] * sg)
                                : color(vb, mt, v, qx, qy, qz, nx, ny, nz);
            if (tx != null) { T[k][0] = vb.U[v * 2] * tx.sc[0] + tx.tr[0]; T[k][1] = vb.U[v * 2 + 1] * tx.sc[1] + tx.tr[1]; }
        }
        raster(X, Y, Z, C, tx != null ? T : null, tx);
    }

    /** z-буфер, перспективно-корректные цвет и UV. */
    void raster(float[] X, float[] Y, float[] Z, float[][] C, float[][] T, Tex tx) {
        triCount++;
        if (vec != null) {
            float r = (C[0][0] + C[1][0] + C[2][0]) / 3, g = (C[0][1] + C[1][1] + C[2][1]) / 3, b = (C[0][2] + C[1][2] + C[2][2]) / 3;
            if (T != null && tx != null && tx.img != null) { // текстура: цвет в центре треугольника
                int q = texel(tx, (T[0][0] + T[1][0] + T[2][0]) / 3, (T[0][1] + T[1][1] + T[2][1]) / 3);
                if ((q >>> 24) < 128) return;
                float tr = ((q >> 16) & 255) / 255f, tg = ((q >> 8) & 255) / 255f, tb = (q & 255) / 255f;
                if (tx.blend == 228 || tx.blend == 226) { r = tr; g = tg; b = tb; } else { r *= tr; g *= tg; b *= tb; }
            }
            vec.add(new float[]{(Z[0] + Z[1] + Z[2]) / 3, X[0], Y[0], X[1], Y[1], X[2], Y[2], r, g, b});
            return;
        }
        if (wire) {
            gi.setColor(new Color(cl(C[0][0]), cl(C[0][1]), cl(C[0][2])));
            for (int k = 0; k < 3; k++) { int n = (k + 1) % 3; gi.drawLine((int) X[k], (int) Y[k], (int) X[n], (int) Y[n]); }
            return;
        }
        float area = (X[1] - X[0]) * (Y[2] - Y[0]) - (X[2] - X[0]) * (Y[1] - Y[0]);
        if (Math.abs(area) < 1e-6f) return;
        int x0 = Math.max(0, (int) Math.floor(Math.min(X[0], Math.min(X[1], X[2])))), x1 = Math.min(W - 1, (int) Math.ceil(Math.max(X[0], Math.max(X[1], X[2]))));
        int y0 = Math.max(0, (int) Math.floor(Math.min(Y[0], Math.min(Y[1], Y[2])))), y1 = Math.min(H - 1, (int) Math.ceil(Math.max(Y[0], Math.max(Y[1], Y[2]))));
        float i0 = 1 / Z[0], i1 = 1 / Z[1], i2 = 1 / Z[2];
        Img im = tx != null && T != null ? tx.img : null;
        for (int y = y0; y <= y1; y++) for (int x = x0; x <= x1; x++) {
            float px = x + .5f, py = y + .5f;
            float w1 = ((px - X[0]) * (Y[2] - Y[0]) - (X[2] - X[0]) * (py - Y[0])) / area;
            float w2 = ((X[1] - X[0]) * (py - Y[0]) - (px - X[0]) * (Y[1] - Y[0])) / area;
            float w0 = 1 - w1 - w2;
            if (w0 < -1e-4f || w1 < -1e-4f || w2 < -1e-4f) continue;
            float iw = w0 * i0 + w1 * i1 + w2 * i2; int idx = y * W + x;
            if (iw <= zb[idx]) continue;
            float a0 = w0 * i0 / iw, a1 = w1 * i1 / iw, a2 = w2 * i2 / iw;
            float r = a0 * C[0][0] + a1 * C[1][0] + a2 * C[2][0], g = a0 * C[0][1] + a1 * C[1][1] + a2 * C[2][1], b = a0 * C[0][2] + a1 * C[1][2] + a2 * C[2][2];
            if (im != null) {
                float u = a0 * T[0][0] + a1 * T[1][0] + a2 * T[2][0], v = a0 * T[0][1] + a1 * T[1][1] + a2 * T[2][1];
                int tp = texel(tx, u, v);
                if ((tp >>> 24) < 128) continue;
                float tr = ((tp >> 16) & 255) / 255f, tg = ((tp >> 8) & 255) / 255f, tb = (tp & 255) / 255f;
                if (tx.blend == 228 || tx.blend == 226) { r = tr; g = tg; b = tb; } else { r *= tr; g *= tg; b *= tb; }
            }
            zb[idx] = iw; pix[idx] = 0xFF000000 | cl(r) << 16 | cl(g) << 8 | cl(b);
        }
    }

    @Override protected void paintComponent(Graphics g0) {
        super.paintComponent(g0);
        if (exporting) {
            g0.setColor(new Color(25, 25, 38)); g0.fillRect(0, 0, getWidth(), getHeight());
            g0.setColor(Color.WHITE); g0.drawString("Экспорт анимации: " + progress, 20, 30); return;
        }
        render((Graphics2D) g0, getWidth(), getHeight(), true);
    }

    void render(Graphics2D g, int w, int h, boolean hud) {
        W = w; H = h;
        if (!transparent) { g.setColor(new Color(25, 25, 38)); g.fillRect(0, 0, W, H); }
        if (world == null) { g.setColor(Color.WHITE); g.drawString("Нажмите O, чтобы открыть .m3g файл", 20, 30); return; }
        for (O o : objs) if (o != null && !o.tracks.isEmpty()) animate(o, wt);
        lights.clear(); meshes.clear(); triCount = 0; camM = null;
        walk(world, new float[]{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1});
        List<MD> mds = new ArrayList<>();
        float[] mn = {1e30f, 1e30f, 1e30f}, mx = {-1e30f, -1e30f, -1e30f};
        for (Object[] mo : meshes) {
            Mesh m = (Mesh) mo[0]; float[] M = (float[]) mo[1]; VB vb = m.vb;
            if (vb == null || vb.pos == null) continue;
            if (vb.P == null) {
                VA p = vb.pos; vb.P = new float[p.n * 3];
                for (int i = 0; i < p.n; i++) for (int j = 0; j < 3; j++) vb.P[i * 3 + j] = j < p.cc ? p.d[i * p.cc + j] * vb.scale + vb.bias[j] : 0;
            }
            if (vb.U == null && vb.uv != null) {
                vb.U = new float[vb.uv.n * 2];
                for (int i = 0; i < vb.uv.n; i++) for (int j = 0; j < 2; j++) vb.U[i * 2 + j] = vb.uv.d[i * vb.uv.cc + j] * vb.uvScale + vb.uvBias[j];
            }
            if (vb.N == null && vb.nor != null && vb.nor.cc >= 3) {
                vb.N = new float[vb.nor.n * 3];
                for (int i = 0; i < vb.nor.n; i++) for (int j = 0; j < 3; j++) vb.N[i * 3 + j] = vb.nor.d[i * vb.nor.cc + j];
            }
            MD d = new MD(); d.m = m; d.wv = new float[vb.P.length];
            for (int i = 0; i < vb.P.length; i += 3) {
                float x = vb.P[i], y = vb.P[i + 1], z = vb.P[i + 2];
                for (int j = 0; j < 3; j++) {
                    float v = M[j * 4] * x + M[j * 4 + 1] * y + M[j * 4 + 2] * z + M[j * 4 + 3];
                    d.wv[i + j] = v; if (!bounds) { mn[j] = Math.min(mn[j], v); mx[j] = Math.max(mx[j], v); }
                }
            }
            if (vb.N != null) {
                d.wn = new float[vb.N.length];
                for (int i = 0; i < vb.N.length; i += 3) {
                    float x = vb.N[i], y = vb.N[i + 1], z = vb.N[i + 2];
                    float[] u = unit(M[0] * x + M[1] * y + M[2] * z, M[4] * x + M[5] * y + M[6] * z, M[8] * x + M[9] * y + M[10] * z);
                    d.wn[i] = u[0]; d.wn[i + 1] = u[1]; d.wn[i + 2] = u[2];
                }
            }
            mds.add(d);
        }
        if (!bounds && !mds.isEmpty()) {
            for (int j = 0; j < 3; j++) ctr[j] = (mn[j] + mx[j]) / 2;
            rad = Math.max(1e-4f, (float) Math.sqrt(
                    Math.pow(mx[0] - mn[0], 2) + Math.pow(mx[1] - mn[1], 2) + Math.pow(mx[2] - mn[2], 2)) / 2);
            dist = rad * 2.6f; bounds = true;
        }
        float fov = 45; near = rad * .01f;
        if (fileCam && camM != null) {
            cpos = new float[]{camM[3], camM[7], camM[11]};
            cr = unit(camM[0], camM[4], camM[8]); cu = unit(camM[1], camM[5], camM[9]); cb = unit(camM[2], camM[6], camM[10]);
            fov = world.cam.fov; near = Math.max(world.cam.near, 1e-4f);
        } else {
            float cy = (float) Math.cos(yaw), sy = (float) Math.sin(yaw), cp = (float) Math.cos(pitch), sp = (float) Math.sin(pitch);
            cb = new float[]{cp * sy, sp, cp * cy};
            cpos = new float[]{ctr[0] + dist * cb[0], ctr[1] + dist * cb[1], ctr[2] + dist * cb[2]};
            cr = unit(cb[2], 0, -cb[0]);
            cu = new float[]{cb[1] * cr[2] - cb[2] * cr[1], cb[2] * cr[0] - cb[0] * cr[2], cb[0] * cr[1] - cb[1] * cr[0]};
        }
        foc = (H / 2f) / (float) Math.tan(Math.toRadians(fov) / 2);
        if (fitW > 0) foc *= Math.min(W / (float) fitW, H / (float) fitH) * fitH / H; // видимая область окна целиком помещается в кадр, без растяжения
        if (img == null || img.getWidth() != W || img.getHeight() != H) {
            img = new BufferedImage(Math.max(1, W), Math.max(1, H), BufferedImage.TYPE_INT_ARGB);
            pix = ((DataBufferInt) img.getRaster().getDataBuffer()).getData(); zb = new float[pix.length];
        }
        float[] bc = world.bg != null ? world.bg.col : new float[]{.1f, .1f, .15f};
        Arrays.fill(pix, transparent ? 0 : 0xFF000000 | (cl(bc[0]) << 16) | (cl(bc[1]) << 8) | cl(bc[2])); Arrays.fill(zb, 0);
        gi = img.createGraphics();
        for (MD d : mds)
            for (Sub s : d.m.subs) {
                if (s.ib == null) continue;
                int[] I = s.ib.idx; int o = 0;
                for (int len : s.ib.strips) {
                    for (int i = 0; i + 2 < len; i++) {
                        int a = I[o + i], b = I[o + i + 1], c = I[o + i + 2];
                        if ((i & 1) == 1) { int t = a; a = b; b = t; }
                        addTri(d, s.ap, a, b, c);
                    }
                    o += len;
                }
            }
        gi.dispose(); g.drawImage(img, 0, 0, null);
        if (!hud) return;
        g.setColor(Color.WHITE);
        g.drawString(name + "   t=" + (int) wt + " ms   tris=" + triCount + "   tracks=" + countTracks() + (paused ? "   [пауза]" : "") + (fileCam ? (camM != null ? "   [камера из файла]" : "   [в файле нет активной камеры]") : "   [орбита]"), 10, 16);
        g.drawString("ЛКМ — вращение, колесо — зум | Пробел — пауза | R — с начала | V / C — камера из файла | W — сетка | F — вписать | O — открыть | E — экспорт GIF/APNG/SVG", 10, H - 8);
    }

    /** Переход в орбитальный режим: стартуем с текущей позиции камеры из файла. */
    void toOrbit() {
        if (fileCam && camM != null && cpos != null) {
            float[] d = unit(cpos[0] - ctr[0], cpos[1] - ctr[1], cpos[2] - ctr[2]);
            dist = Math.max(rad * .05f, (float) Math.sqrt(Math.pow(cpos[0] - ctr[0], 2) + Math.pow(cpos[1] - ctr[1], 2) + Math.pow(cpos[2] - ctr[2], 2)));
            pitch = (float) Math.asin(Math.max(-1, Math.min(1, d[1]))); yaw = (float) Math.atan2(d[0], d[2]);
        }
        fileCam = false;
    }

    int countTracks() { int n = 0; for (O o : objs) if (o != null) n += o.tracks.size(); return n; }

    // ---------- экспорт GIF / APNG ----------
    float animLength() {
        float m = 0;
        for (O o : objs) if (o != null) for (Tr k : o.tracks) if (k.seq != null) {
            float sp = k.c != null ? Math.abs(k.c.speed) : 1; if (sp > 1e-6f) m = Math.max(m, k.seq.dur / sp);
        }
        return m > 0 ? m : 3000;
    }

    void export() {
        if (world == null || exporting) return;
        JComboBox<String> fmt = new JComboBox<>(new String[]{"GIF", "APNG", "SVG (вектор)", "SVG (растр)"});
        JComboBox<String> asp = new JComboBox<>(new String[]{"Как в окне", "1:1 (квадрат)", "16:9", "4:3", "9:16"});
        JCheckBox trc = new JCheckBox("Прозрачный фон (APNG / SVG)");
        JTextField wf = new JTextField(String.valueOf(Math.max(16, getWidth()))), ff = new JTextField("20"), df = new JTextField(String.valueOf((int) animLength()));
        JPanel pn = new JPanel(new GridLayout(0, 2, 6, 6));
        pn.add(new JLabel("Формат")); pn.add(fmt); pn.add(new JLabel("Ширина, px")); pn.add(wf);
        pn.add(new JLabel("Кадров/с")); pn.add(ff); pn.add(new JLabel("Длительность, мс")); pn.add(df);
        pn.add(new JLabel("Пропорции")); pn.add(asp); pn.add(new JLabel("Фон")); pn.add(trc);
        if (JOptionPane.showConfirmDialog(this, pn, "Экспорт анимации", JOptionPane.OK_CANCEL_OPTION) != JOptionPane.OK_OPTION) return;
        final int w, fps, dur;
        try {
            w = Math.max(16, Math.min(2048, Integer.parseInt(wf.getText().trim())));
            fps = Math.max(1, Math.min(50, Integer.parseInt(ff.getText().trim())));
            dur = Math.max(100, Math.min(60000, Integer.parseInt(df.getText().trim())));
        } catch (NumberFormatException e) { JOptionPane.showMessageDialog(this, "Некорректные числа"); return; }
        final int mode = fmt.getSelectedIndex(); final boolean gif = mode == 0;
        JFileChooser fc = new JFileChooser(); fc.setSelectedFile(new File(new String[]{"animation.gif", "animation.png", "animation.svg", "animation.svg"}[mode]));
        if (fc.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;
        final File out = fc.getSelectedFile();
        double ar = asp.getSelectedIndex() == 1 ? 1 : asp.getSelectedIndex() == 2 ? 9 / 16.0 : asp.getSelectedIndex() == 3 ? .75
                : asp.getSelectedIndex() == 4 ? 16 / 9.0 : getHeight() / (double) Math.max(1, getWidth());
        int hh = Math.max(1, (int) Math.round(w * ar)), ww = w;
        if (hh > 4096) { hh = 4096; ww = Math.max(1, (int) Math.round(hh / ar)); }
        final int h = hh, w2 = ww; final boolean tp = !gif && trc.isSelected();
        fitW = Math.max(1, getWidth()); fitH = Math.max(1, getHeight());
        exporting = true;
        new Thread(() -> {
            String msg;
            try { if (mode >= 2) doExportSvg(out, mode == 2, w2, h, fps, dur, tp); else doExport(out, gif, w2, h, fps, dur, tp); msg = "Сохранено: " + out; }
            catch (Exception ex) { ex.printStackTrace(); msg = "Ошибка экспорта: " + ex; }
            finally { exporting = false; fitW = 0; transparent = false; }
            final String m = msg; SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(this, m));
        }).start();
    }

    static IIOMetadataNode node(IIOMetadataNode root, String name) {
        for (int i = 0; i < root.getLength(); i++) if (root.item(i).getNodeName().equalsIgnoreCase(name)) return (IIOMetadataNode) root.item(i);
        IIOMetadataNode n = new IIOMetadataNode(name); root.appendChild(n); return n;
    }

    void doExport(File out, boolean gif, int w, int h, int fps, int dur, boolean tp) throws Exception {
        transparent = tp;
        int cs = Math.max(2, Math.round(100f / fps)), n = Math.max(1, Math.min(600, (int) Math.ceil(dur / (cs * 10f))));
        float saved = wt;
        ImageWriter wr = null; ImageOutputStream os = null;
        List<byte[]> idats = new ArrayList<>(); byte[] ihdr = null;
        try {
            if (gif) {
                wr = ImageIO.getImageWritersByFormatName("gif").next();
                os = ImageIO.createImageOutputStream(out); wr.setOutput(os); wr.prepareWriteSequence(null);
            }
            for (int i = 0; i < n; i++) {
                progress = (i + 1) + " / " + n; repaint();
                wt = i * cs * 10f;
                BufferedImage fr = new BufferedImage(w, h, tp ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
                Graphics2D fg = fr.createGraphics(); render(fg, w, h, false); fg.dispose();
                if (gif) {
                    ImageWriteParam pr = wr.getDefaultWriteParam();
                    IIOMetadata md = wr.getDefaultImageMetadata(ImageTypeSpecifier.createFromRenderedImage(fr), pr);
                    String f = md.getNativeMetadataFormatName();
                    IIOMetadataNode root = (IIOMetadataNode) md.getAsTree(f);
                    IIOMetadataNode gce = node(root, "GraphicControlExtension");
                    gce.setAttribute("disposalMethod", "none"); gce.setAttribute("userInputFlag", "FALSE");
                    gce.setAttribute("transparentColorFlag", "FALSE"); gce.setAttribute("delayTime", String.valueOf(cs));
                    gce.setAttribute("transparentColorIndex", "0");
                    if (i == 0) {
                        IIOMetadataNode ae = new IIOMetadataNode("ApplicationExtension");
                        ae.setAttribute("applicationID", "NETSCAPE"); ae.setAttribute("authenticationCode", "2.0");
                        ae.setUserObject(new byte[]{1, 0, 0}); node(root, "ApplicationExtensions").appendChild(ae);
                    }
                    md.setFromTree(f, root);
                    wr.writeToSequence(new IIOImage(fr, null, md), pr);
                } else {
                    ByteArrayOutputStream bo = new ByteArrayOutputStream(); ImageIO.write(fr, "png", bo);
                    DataInputStream in = new DataInputStream(new ByteArrayInputStream(bo.toByteArray())); in.skipBytes(8);
                    ByteArrayOutputStream idat = new ByteArrayOutputStream();
                    while (in.available() > 0) {
                        int len = in.readInt(); byte[] t = new byte[4]; in.readFully(t); byte[] d = new byte[len]; in.readFully(d); in.readInt();
                        String ty = new String(t, "US-ASCII");
                        if (ty.equals("IHDR") && ihdr == null) ihdr = d; else if (ty.equals("IDAT")) idat.write(d);
                    }
                    idats.add(idat.toByteArray());
                }
            }
            if (gif) wr.endWriteSequence();
            else {
                DataOutputStream o = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(out)));
                o.write(new byte[]{(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10});
                chunk(o, "IHDR", ihdr);
                chunk(o, "acTL", ByteBuffer.allocate(8).putInt(n).putInt(0).array());
                int seq = 0;
                for (int i = 0; i < n; i++) {
                    chunk(o, "fcTL", ByteBuffer.allocate(26).putInt(seq++).putInt(w).putInt(h).putInt(0).putInt(0)
                            .putShort((short) (cs * 10)).putShort((short) 1000).put((byte) 0).put((byte) 0).array());
                    byte[] d = idats.get(i);
                    if (i == 0) chunk(o, "IDAT", d);
                    else chunk(o, "fdAT", ByteBuffer.allocate(d.length + 4).putInt(seq++).put(d).array());
                }
                chunk(o, "IEND", new byte[0]); o.close();
            }
        } finally {
            wt = saved;
            if (os != null) os.close();
            if (wr != null) wr.dispose();
        }
    }

    static int texel(Tex tx, float u, float v) {
        Img im = tx.img;
        u = tx.wrapS == 240 ? Math.max(0, Math.min(1, u)) : u - (float) Math.floor(u);
        v = tx.wrapT == 240 ? Math.max(0, Math.min(1, v)) : v - (float) Math.floor(v);
        return im.px[Math.min(im.h - 1, (int) (v * im.h)) * im.w + Math.min(im.w - 1, (int) (u * im.w))];
    }

    static String n1(float v) { float r = Math.round(v * 10) / 10f; return r == (int) r ? String.valueOf((int) r) : String.valueOf(r); }
    static String d5(double v) { return String.valueOf(Math.round(v * 100000) / 100000.0); }
    static String hex(float r, float g, float b) { return "#" + Integer.toHexString(0x1000000 | cl(r) << 16 | cl(g) << 8 | cl(b)).substring(1); }

    /** Animated SVG: каждый кадр — группа; CSS-анимация по очереди показывает кадры (step-end). vector=false — PNG-кадры внутри SVG. */
    void doExportSvg(File out, boolean vector, int w, int h, int fps, int dur, boolean tp) throws Exception {
        transparent = tp;
        int cs = Math.max(2, Math.round(100f / fps)), n = Math.max(1, Math.min(vector ? 300 : 600, (int) Math.ceil(dur / (cs * 10f))));
        float saved = wt; double dt = cs / 100.0;
        BufferedImage dummy = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
        try (Writer o = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(out), StandardCharsets.UTF_8))) {
            o.write("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"" + w + "\" height=\"" + h + "\" viewBox=\"0 0 " + w + " " + h + "\">\n");
            o.write("<style>.f{opacity:0;animation:s " + d5(n * dt) + "s step-end infinite}@keyframes s{0%{opacity:1}" + d5(100.0 / n) + "%{opacity:0}100%{opacity:0}}</style>\n");
            for (int i = 0; i < n; i++) {
                progress = (i + 1) + " / " + n; repaint();
                wt = i * cs * 10f;
                String delay = "animation-delay:" + d5(i * dt) + "s";
                if (vector) {
                    vec = new ArrayList<>(); Graphics2D fg = dummy.createGraphics(); render(fg, w, h, false); fg.dispose();
                    List<float[]> v = vec; vec = null;
                    v.sort((a, b) -> Float.compare(b[0], a[0]));   // от дальних к ближним
                    if (i == 0 && !tp) {
                        float[] bc = world.bg != null ? world.bg.col : new float[]{.1f, .1f, .15f};
                        o.write("<rect width=\"100%\" height=\"100%\" fill=\"" + hex(bc[0], bc[1], bc[2]) + "\"/>\n");
                    }
                    o.write("<g class=\"f\" style=\"" + delay + "\" stroke-width=\".7\" stroke-linejoin=\"round\">");
                    for (float[] t : v) {
                        String c = hex(t[7], t[8], t[9]);
                        o.write("<path fill=\"" + c + "\" stroke=\"" + c + "\" d=\"M" + n1(t[1]) + " " + n1(t[2]) + "L" + n1(t[3]) + " " + n1(t[4]) + "L" + n1(t[5]) + " " + n1(t[6]) + "Z\"/>");
                    }
                    o.write("</g>\n");
                } else {
                    BufferedImage fr = new BufferedImage(w, h, tp ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
                    Graphics2D fg = fr.createGraphics(); render(fg, w, h, false); fg.dispose();
                    ByteArrayOutputStream bo = new ByteArrayOutputStream(); ImageIO.write(fr, "png", bo);
                    o.write("<image class=\"f\" style=\"" + delay + "\" width=\"" + w + "\" height=\"" + h + "\" xlink:href=\"data:image/png;base64,"
                            + Base64.getEncoder().encodeToString(bo.toByteArray()) + "\"/>\n");
                }
            }
            o.write("</svg>\n");
        } finally { wt = saved; vec = null; }
    }

    static void chunk(DataOutputStream o, String type, byte[] d) throws IOException {
        byte[] t = type.getBytes("US-ASCII"); CRC32 c = new CRC32(); c.update(t); c.update(d);
        o.writeInt(d.length); o.write(t); o.write(d); o.writeInt((int) c.getValue());
    }

    // ---------- UI ----------
    void open() {
        JFileChooser fc = new JFileChooser();
        if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) open(fc.getSelectedFile());
    }

    void open(File f) {
        try { load(f); name = f.getName(); }
        catch (Exception e) { e.printStackTrace(); JOptionPane.showMessageDialog(this, "Ошибка: " + e); }
    }

    M3GViewer() {
        setPreferredSize(new Dimension(900, 650)); setFocusable(true);
        MouseAdapter ma = new MouseAdapter() {
            int px, py;
            public void mousePressed(MouseEvent e) { px = e.getX(); py = e.getY(); requestFocusInWindow(); }
            public void mouseDragged(MouseEvent e) {
                yaw -= (e.getX() - px) * .01f; pitch = Math.max(-1.55f, Math.min(1.55f, pitch + (e.getY() - py) * .01f));
                px = e.getX(); py = e.getY(); toOrbit();
            }
            public void mouseWheelMoved(MouseWheelEvent e) { toOrbit(); dist *= Math.pow(1.1, e.getWheelRotation()); }
        };
        addMouseListener(ma); addMouseMotionListener(ma); addMouseWheelListener(ma);
        addKeyListener(new KeyAdapter() {
            public void keyPressed(KeyEvent e) {
                switch (e.getKeyCode()) {
                    case KeyEvent.VK_SPACE: paused = !paused; break;
                    case KeyEvent.VK_R: wt = 0; break;
                    case KeyEvent.VK_C: if (fileCam) toOrbit(); else fileCam = true; break;
                    case KeyEvent.VK_V: fileCam = true; break;
                    case KeyEvent.VK_W: wire = !wire; break;
                    case KeyEvent.VK_F: bounds = false; break;
                    case KeyEvent.VK_O: open(); break;
                    case KeyEvent.VK_E: export(); break;
                }
            }
        });
        final long[] last = {System.nanoTime()};
        new javax.swing.Timer(16, e -> {
            long n = System.nanoTime(); if (!paused && !exporting) wt += (n - last[0]) / 1e6f; last[0] = n; repaint();
        }).start();
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            M3GViewer v = new M3GViewer();
            JFrame f = new JFrame("M3G Viewer");
            f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); f.add(v); f.pack(); f.setLocationRelativeTo(null); f.setVisible(true);
            if (args.length > 0) v.open(new File(args[0])); else v.open();
            v.requestFocusInWindow();
        });
    }
}