import Vector3 from "../../../../core/geom/Vector3.js";
import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
import { Transform } from "../../../ecs/transform/Transform.js";
import { Collider } from "../../../physics/ecs/Collider.js";
import { compute_penetration } from "../../../physics/narrowphase/compute_penetration.js";
import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";

/**
 * Maximum simultaneous clip planes tracked within a single slide,
 * matching Quake's `MAX_CLIP_PLANES`. A move that contacts more than
 * this many distinct planes in one tick is in pathological geometry
 * (a cone of inward-pointing walls); we dead-stop rather than thrash.
 * @type {number}
 */
const MAX_CLIP_PLANES = 5;

/** Below this speed/length we treat the remaining motion as spent. */
const MIN_MOVE = 1e-6;

/**
 * Kinematic character collision solver — Phase 1 (recover +
 * unified sweep-and-slide). See DESIGN_COLLISION.md.
 *
 * The mover is controller-agnostic: it knows about a capsule pose, a
 * desired velocity, and the physics world. Its job is to take the
 * velocity the control layer wants and return the position it actually
 * reaches plus the velocity corrected for whatever it hit. It invents
 * no motion — gravity / jump impulses live in the control layer and
 * arrive folded into `velocity`.
 *
 * One move is a sequence (Phase 1 implements steps 1-2; ground
 * categorize / stairs / settle land in later phases):
 *
 *   1. RECOVER  — depenetration via `overlap()` + `compute_penetration`,
 *      run unconditionally so the move starts clear even from a
 *      start-solid state a pure sweep can't escape.
 *   2. SLIDE    — unified 3D collide-and-slide via `shapeCast`, clipping
 *      velocity onto true narrowphase contact normals. Crease-aware
 *      (Quake `SV_FlyMove`): a second plane re-violating the first is
 *      handled by sliding along their seam; a third (or a velocity
 *      reversal) dead-stops. Floors / ceilings / walls are all just
 *      contact planes, so vertical anti-tunnelling is automatic.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class KinematicMover {
    /**
     * @param {import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem} physicsSystem
     * @param {import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset} ecd
     *   used to resolve an overlapping `body_id` (via
     *   `physicsSystem.entityOf`) back to its {@link Transform} +
     *   {@link Collider} for `compute_penetration`.
     * @param {object} [options]
     * @param {number} [options.skin=0.005] clearance left after each
     *   sweep stop / push-out so the next query doesn't start in contact.
     * @param {number} [options.maxSlideIterations=4] slide "bumps" per
     *   move (Quake `numbumps`).
     * @param {number} [options.maxRecoverIterations=4] depenetration
     *   passes before giving up (deepest body pushed out per pass).
     * @param {number} [options.minWalkNormal=0.7] minimum ground-normal Y
     *   to count as standable (~45.6°). Below it the surface is "too
     *   steep" — the player slides instead of grounding. Matches Quake3
     *   `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
     * @param {number} [options.stepHeight=0.3] maximum step the player
     *   traverses, both up and down:
     *   - UP: a riser no taller than this is climbed — by the explicit
     *     step-up ({@link _tryStepUp}, up-forward-down, which works
     *     regardless of forward momentum) and by `_categorizeGround`
     *     mounting the player onto a surface within `stepHeight` of the
     *     feet. A taller riser blocks (the slide stops the player; the
     *     step-up's forward clearance cast detects the wall and aborts).
     *   - DOWN: the ground-stick reach — walking off a drop no larger
     *     than this snaps the player onto the lower surface (stays
     *     grounded); a larger drop goes airborne (a real ledge).
     *   Source `sv_stepsize` is ~0.34 m; the default is just under the
     *   capsule radius so low ledges don't feel "magnetic".
     */
    constructor(physicsSystem, ecd, options = {}) {
        this.physicsSystem = physicsSystem;
        this.ecd = ecd;
        this.skin = options.skin !== undefined ? options.skin : 0.005;
        this.maxSlideIterations = options.maxSlideIterations !== undefined ? options.maxSlideIterations : 4;
        this.maxRecoverIterations = options.maxRecoverIterations !== undefined ? options.maxRecoverIterations : 4;
        this.minWalkNormal = options.minWalkNormal !== undefined ? options.minWalkNormal : 0.7;
        this.stepHeight = options.stepHeight !== undefined ? options.stepHeight : 0.3;

        // ── Scratch — reused per move, no per-call allocation ──────────
        this._ray = new Ray3();
        this._hit = new PhysicsSurfacePoint();
        this._overlapBuf = new Uint32Array(16);
        this._penDir = new Float64Array(3);          // compute_penetration out
        this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
        this._cand = new Float64Array(3);            // clipped-velocity candidate
        this._support = new Float64Array(3);         // shape support point (forward extent)

        /**
         * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
         * in Phase 1 they're left at their defaults (the mover doesn't
         * categorize ground yet).
         * @type {{hit:boolean, grounded:boolean, groundNormal:Vector3}}
         */
        this._result = { hit: false, grounded: false, groundNormal: new Vector3(0, 1, 0) };
    }

    /**
     * Resolve one move. Mutates `position` to the resolved location and
     * `velocity` to the corrected value.
     *
     * @param {Vector3} position in/out — current pose, written to the resolved pose
     * @param {{x:number,y:number,z:number,w:number}} rotation capsule orientation (read)
     * @param {import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D} shape convex capsule
     * @param {Vector3} velocity in/out — desired velocity, written to the corrected velocity
     * @param {number} dt
     * @param {(entity:number, collider:Collider)=>boolean} filter excludes
     *   the player's own body in integration; accept-all in isolation
     * @returns {{hit:boolean, grounded:boolean, groundNormal:Vector3}} reused result
     */
    move(position, rotation, shape, velocity, dt, filter) {
        const result = this._result;
        result.hit = false;
        result.grounded = false;
        result.groundNormal.set(0, 1, 0);

        // Capture intent-to-leave BEFORE the slide. A positive input
        // vertical velocity is a jump / launch (the only thing that sets
        // one — gravity is negative); on those ticks we must NOT
        // stick-to-ground or the snap cancels the jump on its first
        // frame. A ramp's upward velocity is produced INSIDE the slide
        // (the ramp is a contact plane), so it doesn't show up here —
        // which is exactly why we gate on the pre-slide value.
        const ascending = velocity.y > MIN_MOVE;

        // 1. Recover — start the move penetration-free.
        this._recover(position, rotation, shape, filter);

        // On WALKABLE ground the surface bears the body's weight, so gravity
        // must not leak into a downhill SLIDE. A biped grips any slope it
        // could walk up; the instant its feet slip it could no longer have
        // climbed either — so "doesn't slip" and "is walkable" are the SAME
        // minWalkNormal gate, not two tunables. Drop the downward (gravity)
        // velocity before the slide whenever the feet rest on walkable
        // ground (`_groundedAt` is precisely that: a walkable contact normal
        // within the stick band). The slide then carries only the player's
        // own intent and the stick in `_categorizeGround` keeps the body
        // glued to the slope. On a TOO-STEEP face `_groundedAt` is false (the
        // normal fails the gate), gravity survives, and the body slides down
        // — as feet with no purchase must. A jump (`velocity.y > 0`) is left
        // alone so the launch is never cancelled.
        if (velocity.y < 0 && this._groundedAt(position.x, position.y, position.z, filter)) {
            velocity.y = 0;
        }

        // Snapshot pre-slide state for a possible stair step-up retry.
        const sx = position.x, sy = position.y, sz = position.z;
        const svx = velocity.x, svy = velocity.y, svz = velocity.z;

        // 2. Sweep-and-slide the desired motion.
        this._slide(position, rotation, shape, velocity, dt, filter, result);

        // 2b. Stairs — if a grounded move was blocked, try to step up and
        //     over a low riser. The capsule's round bottom also rolls up
        //     low steps via the slide, but only with enough forward
        //     momentum; a controller that reads back the slide-clipped
        //     velocity loses that momentum at the riser and gets stuck.
        //     The explicit step-up climbs without depending on momentum
        //     AND restores the horizontal velocity, so the player keeps
        //     moving up the flight.
        if (this.stepHeight > 0 && !ascending && result.hit) {
            this._tryStepUp(position, rotation, shape, velocity,
                sx, sy, sz, svx, svy, svz, dt, filter);
        }

        // 3. Ground categorize + stick + slope velocity clip.
        this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);

        return result;
    }

    /**
     * Stair step-up. Called after a slide that hit something while the
     * player was trying to move horizontally on the ground. Re-runs the
     * horizontal move from the pre-slide position lifted by `stepHeight`,
     * guarded by a forward CLEARANCE cast: if the path is still blocked at
     * the lifted height the obstacle is taller than a step (a wall) and we
     * abandon the attempt; if clear it's a step, so advance over it and
     * drop. Commits only when it advances farther horizontally than the
     * plain slide and the rise is within `stepHeight`. On success the
     * horizontal velocity is restored (the riser wasn't a wall) so the
     * controller doesn't read back a stalled speed; the vertical is left
     * for `_categorizeGround` to settle on the step.
     *
     * @private
     */
    _tryStepUp(position, rotation, shape, velocity, sx, sy, sz, svx, svy, svz, dt, filter) {
        const hdx = svx * dt, hdz = svz * dt;
        const forwardLen = Math.sqrt(hdx * hdx + hdz * hdz);
        if (forwardLen < MIN_MOVE) return; // no horizontal intent
        // Only step from a grounded start — never mid-air (else a player
        // could climb a wall by jumping into it).
        if (!this._groundedAt(sx, sy, sz, filter)) return;

        const slideProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
        const px = position.x, py = position.y, pz = position.z;
        const pvx = velocity.x, pvy = velocity.y, pvz = velocity.z;
        const fnx = hdx / forwardLen, fnz = hdz / forwardLen; // travel direction

        // Direction the slide was BLOCKED in — the inward normal of the
        // obstacle the player walked into = the horizontal velocity the
        // slide removed (pre-slide minus clipped). For a head-on approach
        // this equals the travel direction; for an OBLIQUE one it's the
        // wall's normal, which is what we must probe along: the capsule
        // meets the wall with its normal-direction extent, so a probe cast
        // along TRAVEL would stop short of the face (it only has to reach
        // radius/​cos θ along travel, but radius along the normal) and read a
        // wall as clear, climbing it. Deriving it from the velocity change
        // needs no slide-internal normal plumbed out.
        let bdx = svx - velocity.x, bdz = svz - velocity.z;
        const bdLen = Math.sqrt(bdx * bdx + bdz * bdz);
        if (bdLen < MIN_MOVE) return; // nothing opposed the horizontal move — no wall/step ahead
        bdx /= bdLen; bdz /= bdLen;

        // ── Step-vs-wall decision: a THIN horizontal ray at the step-height
        //    plane ──────────────────────────────────────────────────────
        // The obvious "lift the capsule by stepHeight and sweep it forward"
        // clearance test is fooled by the capsule's ROUNDED bottom: lifted
        // so its tip sits at stepHeight, the hemisphere narrows through the
        // band (stepHeight, stepHeight+radius), so a wall whose top lands in
        // that band is never reached by the swept capsule — it reads "clear"
        // and the player climbs a too-tall wall (and does so speed-
        // dependently, since a faster sweep reaches further into the round-
        // off). A thin ray has no such round-off. Cast it INTO the obstacle
        // (along the blocked normal) at exactly the highest climbable height:
        // anything taller than a step has solid material crossing that plane
        // and is struck on its front face; a genuine step (top below the
        // plane) is passed clean over. This reads the obstacle's true height,
        // independent of how the round bottom would perch on its edge — so
        // "clear it or don't" holds at any speed and any approach angle.
        //
        // Origin at the PRE-slide centre, not post-slide: rounding a convex
        // corner, the slide can carry the centre just past the corner (out
        // of the obstacle's footprint) so a probe from there shoots past it
        // and reads clear, climbing the corner. The pre-slide centre was
        // still in front of what blocked it. Reach spans the swept move
        // (`forwardLen`) plus a forward-extent to the leading edge plus a
        // few skins — enough to cross the front face wherever along the
        // sweep contact happened. The reach tracking the sweep is not the
        // banned speed coupling: the climb-or-block DECISION is the
        // step-height plane alone; the reach only governs how far ahead we
        // look for the thing the slide already hit.
        shape.support(this._support, 0, bdx, 0, bdz);
        const lead = this._support[0] * bdx + this._support[2] * bdz; // extent toward the wall (capsule radius)
        const ray = this._ray;
        ray.setOrigin(sx, sy + this.stepHeight + this.skin, sz);
        ray.setDirection(bdx, 0, bdz);
        ray.tMax = forwardLen + lead + 4 * this.skin;
        if (this.physicsSystem.raycast(ray, this._hit, filter)) return; // taller than a step — a wall; keep the plain slide

        // Confirmed climbable (nothing crosses the step-height plane ahead).
        // Up–forward–down places the capsule onto the step top.
        position.set(sx, sy, sz);
        const up = this._castDistance(position, rotation, shape, 0, 1, 0, this.stepHeight, filter);
        position._add(0, up, 0);
        position._add(fnx * forwardLen, 0, fnz * forwardLen);
        const down = this._castDistance(position, rotation, shape, 0, -1, 0, up + this.skin, filter);
        position._add(0, -down, 0);

        // Reject a climb that ends INSIDE geometry. The thin step-height
        // probe is a single ray, so at a convex CORNER it can thread past
        // the corner point (the contact normal there is near-parallel to a
        // face, so the ray runs alongside the box just outside its
        // footprint) and read clear — then up-forward-down perches the round
        // body on that corner, overlapping it. A genuine step top is rested
        // on `skin` ABOVE, never overlapping; so an overlap at the
        // destination is the tell that we climbed onto a wall, not a step.
        const stepProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
        const heightGain = position.y - sy;
        const overlaps = this.physicsSystem.overlap(shape, position, rotation, this._overlapBuf, 0, filter) > 0;
        if (stepProg > slideProg + 1e-8 && heightGain <= this.stepHeight + this.skin && !overlaps) {
            velocity.set(svx, svy, svz); // restore horizontal; vertical settled by categorize
        } else {
            position.set(px, py, pz);
            velocity.set(pvx, pvy, pvz);
        }
    }

    /**
     * Distance the shape can sweep along a unit axis before contact (less
     * `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
     * @private
     */
    _castDistance(position, rotation, shape, dx, dy, dz, maxDist, filter) {
        const ray = this._ray;
        ray.setOrigin(position.x, position.y, position.z);
        ray.setDirection(dx, dy, dz);
        ray.tMax = maxDist;
        if (!this.physicsSystem.shapeCast(ray, shape, rotation, this._hit, filter)) return maxDist;
        const t = this._hit.t - this.skin;
        return t > 0 ? t : 0;
    }

    /**
     * True when a centre raycast at (x,y,z) finds a walkable surface
     * within stick range — gates step-up on "actually standing".
     * @private
     */
    _groundedAt(x, y, z, filter) {
        const ray = this._ray;
        const lift = this.stepHeight;
        ray.setOrigin(x, y + lift, z);
        ray.setDirection(0, -1, 0);
        ray.tMax = lift + this.stepHeight + this.skin;
        if (!this.physicsSystem.raycast(ray, this._hit, filter)) return false;
        if (this._hit.normal.y < this.minWalkNormal) return false;
        return (y + lift - this._hit.t) <= y + this.skin;
    }

    /**
     * Push the capsule out of any geometry it currently overlaps. Each
     * pass queries overlaps, finds the single deepest penetration via
     * {@link compute_penetration}, and pushes out along its separation
     * axis by `depth + skin`; re-queries until clear or the iteration
     * cap is hit. Deepest-first converges multi-body resting contact
     * (each pass removes the worst offender) without solving a system.
     *
     * @private
     */
    _recover(position, rotation, shape, filter) {
        const physics = this.physicsSystem;
        const ecd = this.ecd;
        const buf = this._overlapBuf;
        const dir = this._penDir;

        for (let iter = 0; iter < this.maxRecoverIterations; iter++) {
            const n = physics.overlap(shape, position, rotation, buf, 0, filter);
            if (n === 0) return;

            let bestDepth = 0;
            let bestX = 0, bestY = 0, bestZ = 0;

            for (let i = 0; i < n; i++) {
                const bodyId = buf[i];
                const entity = physics.entityOf(bodyId);
                if (entity < 0) continue;
                const otherT = ecd.getComponent(entity, Transform);
                const otherC = ecd.getComponent(entity, Collider);
                if (otherT === undefined || otherC === undefined) continue;

                // depth + unit B→A separation (A = our capsule, B = other).
                const depth = compute_penetration(
                    dir,
                    shape, position, rotation,
                    otherC.shape, otherT.position, otherT.rotation,
                );
                if (depth > bestDepth) {
                    bestDepth = depth;
                    bestX = dir[0]; bestY = dir[1]; bestZ = dir[2];
                }
            }

            // Overlap reported but no actionable depth (touching boundary,
            // or MPR/overlap disagreement at the kiss) — treat as clear.
            if (bestDepth <= 0) return;

            const push = bestDepth + this.skin;
            position._add(bestX * push, bestY * push, bestZ * push);
        }
    }

    /**
     * Unified 3D collide-and-slide. Faithful to Quake's `SV_FlyMove`:
     * sweep the velocity for the remaining time, stop at contact, clip
     * the (original) velocity against every plane hit so far, slide
     * along the seam of a two-plane crease, dead-stop on a third plane
     * or a velocity reversal.
     *
     * @private
     */
    _slide(position, rotation, shape, velocity, dt, filter, result) {
        const physics = this.physicsSystem;
        const ray = this._ray;
        const hit = this._hit;
        const planes = this._planes;
        const cand = this._cand;
        const skin = this.skin;

        // Original desired velocity — clipping is always done against
        // this, not the running value (Quake's invariant; prevents
        // accumulated rounding from spiralling the direction).
        const ovx = velocity.x, ovy = velocity.y, ovz = velocity.z;
        let vx = ovx, vy = ovy, vz = ovz;
        let timeLeft = dt;
        let numPlanes = 0;

        for (let bump = 0; bump < this.maxSlideIterations; bump++) {
            const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
            if (speed < MIN_MOVE) break;
            const len = speed * timeLeft;
            if (len < MIN_MOVE) break;

            const inv = 1 / speed;
            const ndx = vx * inv, ndy = vy * inv, ndz = vz * inv;

            ray.setOrigin(position.x, position.y, position.z);
            ray.setDirection(ndx, ndy, ndz);
            ray.tMax = len;

            const didHit = physics.shapeCast(ray, shape, rotation, hit, filter);

            if (!didHit) {
                // Clear path — consume the whole remaining move.
                position._add(ndx * len, ndy * len, ndz * len);
                break;
            }

            // Separating contact: `shapeCast` reports a start-in-contact
            // hit (t≈0) even when we're moving AWAY from the touched
            // surface — e.g. jumping off a floor we're resting on, where
            // the down-normal floor is behind the upward motion. If the
            // sweep direction points along the outward contact normal
            // (dir·n ≥ 0) the surface isn't in our way; take the full
            // remaining move rather than clipping the velocity into it
            // (which would cancel the jump). A genuine blocker is only
            // reachable by moving INTO it (dir·n < 0).
            const dirDotN = ndx * hit.normal.x + ndy * hit.normal.y + ndz * hit.normal.z;
            if (dirDotN >= 0) {
                position._add(ndx * len, ndy * len, ndz * len);
                break;
            }

            result.hit = true;

            // Advance up to the contact (less skin), and consume the
            // corresponding fraction of the remaining time.
            const advance = hit.t - skin > 0 ? hit.t - skin : 0;
            if (advance > 0) position._add(ndx * advance, ndy * advance, ndz * advance);
            const fraction = hit.t / len; // time uses the true TOI, not the skinned advance
            timeLeft -= timeLeft * (fraction > 1 ? 1 : fraction);

            if (numPlanes >= MAX_CLIP_PLANES) {
                // Too many planes — wedged. Stop.
                vx = vy = vz = 0;
                break;
            }
            // Store the true contact plane for velocity clipping. The
            // capsule's round bottom does NOT roll up a too-tall obstacle:
            // standing on the floor it meets a wall with its full-radius
            // cylinder side (a horizontal normal — a clean stop), and
            // CLIMBING a step ≤ stepHeight is the explicit step-up's job,
            // gated by a thin step-height probe that reads true obstacle
            // height regardless of approach speed. So the slide keeps every
            // surface's real normal — flattening steep contacts to vertical
            // here would also rob a too-steep SLOPE of its downhill slide.
            const po = numPlanes * 3;
            planes[po] = hit.normal.x;
            planes[po + 1] = hit.normal.y;
            planes[po + 2] = hit.normal.z;
            numPlanes++;

            // Re-derive velocity: find a plane the ORIGINAL velocity can
            // slide along without violating any other plane.
            let resolved = false;
            for (let i = 0; i < numPlanes; i++) {
                const ix = planes[i * 3], iy = planes[i * 3 + 1], iz = planes[i * 3 + 2];
                this._clip(ovx, ovy, ovz, ix, iy, iz, cand);
                let ok = true;
                for (let j = 0; j < numPlanes; j++) {
                    if (j === i) continue;
                    const jx = planes[j * 3], jy = planes[j * 3 + 1], jz = planes[j * 3 + 2];
                    if (cand[0] * jx + cand[1] * jy + cand[2] * jz < 0) { ok = false; break; }
                }
                if (ok) {
                    vx = cand[0]; vy = cand[1]; vz = cand[2];
                    resolved = true;
                    break;
                }
            }

            if (!resolved) {
                // No single plane works — slide along the crease of two.
                // (More than two simultaneously-violated planes ⇒ corner;
                // dead-stop.)
                if (numPlanes !== 2) {
                    vx = vy = vz = 0;
                    break;
                }
                const ax = planes[0], ay = planes[1], az = planes[2];
                const bx = planes[3], by = planes[4], bz = planes[5];
                // seam = a × b
                let cx = ay * bz - az * by;
                let cy = az * bx - ax * bz;
                let cz = ax * by - ay * bx;
                const clen = Math.sqrt(cx * cx + cy * cy + cz * cz);
                if (clen < MIN_MOVE) { vx = vy = vz = 0; break; }
                const cinv = 1 / clen;
                cx *= cinv; cy *= cinv; cz *= cinv;
                const d = cx * ovx + cy * ovy + cz * ovz;
                vx = cx * d; vy = cy * d; vz = cz * d;
            }

            // Anti-oscillation: if the new velocity opposes the original
            // intent, we'd bounce back into the geometry — stop instead.
            if (vx * ovx + vy * ovy + vz * ovz <= 0) {
                vx = vy = vz = 0;
                break;
            }
        }

        velocity.set(vx, vy, vz);
    }

    /**
     * Ground categorization + stick-to-ground + slope velocity clip.
     * Probes below the feet to decide grounded-ness, the surface height
     * to rest on, and the surface normal for the velocity clip.
     *
     *   - `ascending` (jumped this tick) → never grounded; skip entirely
     *     so the snap can't cancel the jump.
     *   - no surface within `stepHeight` below the feet → airborne.
     *
     * Two probes, with a deliberate division of labour that's what makes
     * stairs AND slopes both work (they're the same steep-normal contact
     * to a single probe, so one probe can't tell them apart):
     *
     *   (A) WALKABILITY + base height — a centre-point RAYCAST. It sees
     *       the actual planar surface under the feet and ignores both a
     *       step's convex top EDGE (whose normal is misleadingly steep)
     *       and a wall's vertical SIDE face. A steep normal here means a
     *       genuine steep SLOPE → not grounded (slide).
     *   (B) STEP height — a footprint capsule SHAPECAST. It raises the
     *       rest height onto a step the leading edge overhangs (which the
     *       centre ray, aimed behind the riser, misses). Used only when
     *       it genuinely swept (`hit.t > skin`, not start-solid in a wall)
     *       and the step is within `stepHeight`.
     *
     * Reference point is the capsule bottom (`position.y`), matching the
     * feet-at-origin player capsule.
     *
     * @private
     */
    _categorizeGround(position, rotation, shape, velocity, filter, result, ascending) {
        if (ascending) return; // jump / launch — stay airborne this tick

        const ray = this._ray;
        const hit = this._hit;
        const lift = this.stepHeight;                       // probe starts this high above the feet
        const reach = lift + this.stepHeight + this.skin;   // …down to stepHeight below the feet

        // (A) Centre raycast — walkability + base surface height. A steep
        //     normal here is a genuine SLOPE (slide). A MISS, though, is NOT
        //     "airborne" by itself: the centre ray is one thin ray, and when
        //     the body straddles a narrow GAP (or overhangs a ledge edge) it
        //     shoots clean through the slot and finds nothing within reach,
        //     while the footprint below is still resting on the box tops. So
        //     a miss just means "no centre surface" — the footprint decides.
        let nx = 0, ny = 1, nz = 0;
        let haveCentre = false;
        let centreSurfaceY = 0;
        ray.setOrigin(position.x, position.y + lift, position.z);
        ray.setDirection(0, -1, 0);
        ray.tMax = reach;
        if (this.physicsSystem.raycast(ray, hit, filter)) {
            nx = hit.normal.x; ny = hit.normal.y; nz = hit.normal.z;
            if (ny < this.minWalkNormal) {
                result.groundNormal.set(nx, ny, nz);
                return; // steep slope under the feet — slide, not grounded
            }
            centreSurfaceY = position.y + lift - hit.t; // the planar ground under the feet
            haveCentre = true;
        }

        // (B) Footprint shapecast — the rest height. WITH a centre surface it
        //     MOUNTS a step the leading edge overhangs; with NO centre surface
        //     it IS the ground (the straddle support the centre ray missed).
        //     Gate the mount on `stepHeight` above the BASE — the centre
        //     surface when we have one (so a wall top, > stepHeight above the
        //     floor the centre sees, never mounts and a capsule that rode up
        //     snaps back), else `feet − stepHeight` (so any footprint within
        //     the stick band, but no higher than the feet, supports us).
        const base = haveCentre ? centreSurfaceY : (position.y - this.stepHeight);
        let surfaceY = haveCentre ? centreSurfaceY : Number.NEGATIVE_INFINITY;
        ray.setOrigin(position.x, position.y + lift, position.z);
        ray.setDirection(0, -1, 0);
        ray.tMax = reach;
        if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
            const stepY = position.y + lift - hit.t;
            // With a centre surface the centre ray already vouched for
            // walkability, so mount any step. With NONE, the footprint is the
            // sole ground source — accept it only if it's a WALKABLE rest (a
            // flat gap-straddle / ledge edge, footprint normal ≈ up), not a
            // steep SLOPE the thin centre ray merely shot past (which must
            // still slide, not stick).
            const ok = haveCentre || hit.normal.y >= this.minWalkNormal;
            if (ok && stepY > surfaceY && stepY - base <= this.stepHeight + this.skin) {
                surfaceY = stepY;
            }
        }

        if (surfaceY === Number.NEGATIVE_INFINITY) return; // no centre AND no footprint — truly airborne

        // A gap-straddle / ledge-edge rest is supported vertically; with no
        // centre normal, treat the ground as up.
        result.groundNormal.set(nx, ny, nz);
        result.grounded = true;

        // Stick: rest the feet `skin` above the surface (snaps down for a
        // descent, up onto a step / out of a grazing contact).
        position.y = surfaceY + this.skin;

        // Clip the into-ground velocity component (gravity on flat ground;
        // the into-slope part on a ramp), leaving tangential motion.
        const vdot = velocity.x * nx + velocity.y * ny + velocity.z * nz;
        if (vdot < 0) {
            velocity.set(
                velocity.x - vdot * nx,
                velocity.y - vdot * ny,
                velocity.z - vdot * nz,
            );
        }
    }

    /**
     * `out = v - n·(v·n)` — project `v` onto the plane with unit normal
     * `n` (overbounce 1.0; clearance handled by `skin`, so no extra
     * nudge — equivalent in practice to Quake3's `OVERCLIP 1.001`).
     * @private
     */
    _clip(vx, vy, vz, nx, ny, nz, out) {
        const backoff = vx * nx + vy * ny + vz * nz;
        out[0] = vx - nx * backoff;
        out[1] = vy - ny * backoff;
        out[2] = vz - nz * backoff;
    }
}
