--# CubeMapSphere
CubeMapSphere = class()

function CubeMapSphere:init(scene, radius, textureSize)
    self.scene = scene
    self.radius = radius
    self.textureSize = textureSize
    self.drawingColor = color(255, 255, 255)
    self.brushSize = 20
    self.needsTextureUpdate = false
    self.lastTextureUpdate = 0
    self.textureUpdateInterval = 0.1
    self:setDefaultFaces()
    self:_setupSphere()
    self:_setupCaptureRig()
    self:usePatchMaterial()   -- <-- create sliders + bind RT cubemap
    self:bakeFacesToRT()      -- <-- push the initial faces into the RT
end

----------------------------------------------------------------
-- RENDER TARGET RIG (camera + plane -> cubemap faces)
----------------------------------------------------------------
function CubeMapSphere:_setupCaptureRig()
    -- 6 face images -> RT cubemap we draw into
    self.rtCube = craft.cubeTexture(self.cubeImages)
    self.rt     = craft.renderTarget(self.rtCube)
    
    -- a simple plane parented to the camera; we’ll sprite the face image onto it
    self.capPlane = self.scene:entity()
    self.capPlane.parent = self.scene.camera
    self.capPlane.model  = craft.model(asset.builtin.Primitives.Plane)
    self.capPlane.material = craft.material(asset.builtin.Materials.Basic)    self.capPlane.eulerAngles = vec3(0, 270, 90) -- face -Z, spans XZ
    self.capPlane.position    = vec3(0, 0, 1.0)  -- in front of camera
    self.capPlane.scale       = vec3(1, 1, 1)
    self.capPlane.active      = false
end

-- Push one 2D face image into the corresponding cubemap face via RT
function CubeMapSphere:_pushFaceToRT(faceIndex, img)
    local camEnt = self.scene.camera
    local cam    = camEnt:get(craft.camera)
    
    local savedFOV, savedNear, savedFar = cam.fieldOfView, cam.nearPlane, cam.farPlane
    cam.fieldOfView = 90
    cam.nearPlane   = 0.05
    cam.farPlane    = 10.0
    
    self.capPlane.material.map = img
    local wasSphere = self.entity.active
    self.entity.active = false
    self.capPlane.active = true
    
    cam:draw(self.rt, faceIndex)         -- <-- render to this cubemap face
    pcall(function() self.rtCube:generateMipmaps() end)
    
    self.capPlane.active = false
    self.entity.active   = wasSphere
    cam.fieldOfView, cam.nearPlane, cam.farPlane = savedFOV, savedNear, savedFar
end

-- Bake all current self.cubeImages[1..6] into the RT cubemap
function CubeMapSphere:bakeFacesToRT()
    if not self.rt then self:_setupCaptureRig() end
    for i = 1, 6 do
        self:_pushFaceToRT(i, self.cubeImages[i])
    end
end

----------------------------------------------------------------
-- SPHERE + MATERIAL
----------------------------------------------------------------
function CubeMapSphere:_setupSphere()
    self.entity = self.scene:entity()
    self.entity.position = vec3(0, 0, 0)
    
    local sphereModel = craft.model.icosphere(self.radius, 4)
    self.renderer = self.entity:add(craft.renderer, sphereModel)
    
    -- start with a plain cubemap so the sphere is visible even before using patch shader
    self.cubeTexture = craft.cubeTexture(self.cubeImages)

end

-- Switch the sphere to the patch/stretch shader that reads from the RT cubemap
function CubeMapSphere:usePatchMaterial()
    -- Make sure the shader is registered.
    craft.shader.add(CubeCornerStretch)
    
    -- Bail if we don't have a renderer yet.
    if not self.renderer then return end
    
    -- Ensure the material exists and is the right one.
    local mat = self.renderer.material
    if not mat or (mat.name and mat.name ~= "CubeCornerStretch") then
        mat = craft.material("CubeCornerStretch")
        self.renderer.material = mat
    end
    
    -- (Re)bind dynamic properties that can change over time.
    mat.env    = self.rtCube
    mat.facePx = self.textureSize * 1.0
    
    -- Set (or refresh) the rest of the defaults.
    mat.useWorld  = mat.useWorld  or 1.0
    mat.face      = mat.face      or 4.0
    mat.corner    = mat.corner    or 2.0
    mat.sideFrac  = mat.sideFrac  or 0.33205676078796
    mat.offU      = mat.offU      or 0.0
    mat.offV      = mat.offV      or 0.0
    mat.padPix    = (mat.padPix ~= nil) and mat.padPix or 1.15
    mat.mipBias   = (mat.mipBias ~= nil) and mat.mipBias or 0.0
    mat.scale     = mat.scale     or 1.00
    mat.edgeClamp = mat.edgeClamp or 0.995
    mat.seamWidthPx = mat.seamWidthPx or 2.0
    mat.seamMix     = mat.seamMix     or 0.55
    
    -- Build sliders exactly once; each callback always hits the current material.
    -- blocked bu 
    if false then
        if not self._uiMade then
            local function withMat(f)
                local r = self.renderer
                local m = r and r.material
                if m then f(m) end
            end
            
            parameter.number("SeamWidth_px",   0.0,  6.0,  mat.seamWidthPx, function(v) withMat(function(m) m.seamWidthPx = v end) end)
            parameter.number("SeamMix",        0.0,  1.0,  mat.seamMix,     function(v) withMat(function(m) m.seamMix     = v end) end)
            parameter.number("MipBias",       -0.25, 0.75, mat.mipBias,     function(v) withMat(function(m) m.mipBias     = v end) end)
            parameter.number("Inset_px",       0.0,  5.0,  mat.padPix,      function(v) withMat(function(m) m.padPix      = v end) end)
            parameter.number("DBG_sideFrac",   0.3313, 0.3330, mat.sideFrac, function(v) withMat(function(m) m.sideFrac   = v end) end)
            parameter.number("DBG_scale",      0.98,  1.02,  mat.scale,      function(v) withMat(function(m) m.scale      = v end) end)
            parameter.number("DBG_edgeClamp",  0.90,  0.999, mat.edgeClamp,  function(v) withMat(function(m) m.edgeClamp  = v end) end)
            
            self._uiMade = true
        end
    end
end

----------------------------------------------------------------
-- FACE GENERATION / LOADING (same as before, trimmed only where needed)
----------------------------------------------------------------
function CubeMapSphere:setDefaultFaces()
    local faceColors = {
        color(255,  64,  64),  color( 64,  64, 255),
        color( 64, 255,  64),  color(231, 191, 88),
        color( 33, 215, 215),  color(255,  64, 255),
    }
    local W = self.textureSize
    local uvWorldLabels = {
        { u="+U→ = -Z", v="+V↑ = -Y" },
        { u="+U→ = +Z", v="+V↑ = -Y" },
        { u="+U→ = +X", v="+V↑ = +Z" },
        { u="+U→ = +X", v="+V↑ = -Z" },
        { u="+U→ = +X", v="+V↑ = -Y" },
        { u="+U→ = -X", v="+V↑ = -Y" },
    }
    self.cubeImages = {}
    for i = 1, 6 do
        local img = image(W, W)
        setContext(img)
        background(faceColors[i])
        -- border
        stroke(0,0,0,120) strokeWidth(2) noFill() rect(1,1,W-2,W-2)
        -- center+arrows
        local cx,cy = W*0.5,W*0.5
        stroke(255) strokeWidth(3)
        local L = W*0.28
        line(cx,cy,cx+L,cy); line(cx,cy,cx,cy+L)
        fill(255) fontSize(math.max(14,W*0.08)) textAlign(CENTER)
        text(uvWorldLabels[i].u, cx+L*0.85, cy+16)
        text(uvWorldLabels[i].v, cx,        cy+L+18)
        setContext()
        img = flipVertical(img)
        self.cubeImages[i] = img
    end
    self:overlayLatLonGridFast(18, 24)
    self.cubeTexture = craft.cubeTexture(self.cubeImages)
    if self.renderer and self.renderer.material then
        self.renderer.material.map = self.cubeTexture
    end
end

function CubeMapSphere:handleTouch(t)
    if t.state == BEGAN or t.state == MOVING then
        local cam = self.scene.camera:get(craft.camera)
        local ro, rd = cam:screenToRay(vec2(t.x, t.y))
        local hit = self:intersectRaySphere(ro, rd, self.entity.position, self.radius)
        if hit then
            local faceIndex, u, v = self:sphereToCubemapFace(hit - self.entity.position)
            if faceIndex then
                self:drawOnFace(faceIndex, u, v)
                return true
            end
        end
    end
    return false
end

function CubeMapSphere:intersectRaySphere(ro, rd, c, r)
    local oc = ro - c
    local a = rd:dot(rd)
    local b = 2 * oc:dot(rd)
    local c2 = oc:dot(oc) - r*r
    local D = b*b - 4*a*c2
    if D < 0 then return nil end
    local t = (-b - math.sqrt(D)) / (2*a)
    if t < 0 then return nil end
    return ro + rd * t
end

function CubeMapSphere:sphereToCubemapFace(p)
    local ax, ay, az = math.abs(p.x), math.abs(p.y), math.abs(p.z)
    local f,u,v
    if ax>=ay and ax>=az then
        if p.x>0 then f=1 u=-p.z/ax v=-p.y/ax else f=2 u=p.z/ax v=-p.y/ax end
    elseif ay>=ax and ay>=az then
        if p.y>0 then f=3 u=p.x/ay v= p.z/ay else f=4 u=p.x/ay v=-p.z/ay end
    else
        if p.z>0 then f=5 u=p.x/az v=-p.y/az else f=6 u=-p.x/az v=-p.y/az end
    end
    u = (u + 1) / 2 * self.textureSize
    v = (v + 1) / 2 * self.textureSize
    return f,u,v
end

function CubeMapSphere:drawOnFace(faceIndex, u, v)
    local r = self.brushSize * 0.5
    self:drawDiskCrossFace(faceIndex, u, v, r, self.drawingColor)
    self.needsTextureUpdate = true
end

function CubeMapSphere:drawOnFace(faceIndex, u, v)
    local r = self.brushSize * 0.5
    self:drawDiskCrossFace(faceIndex, u, v, r, self.drawingColor)
    -- immediately update the RT cubemap’s matching face
    self:_pushFaceToRT(faceIndex, self.cubeImages[faceIndex])
end

function CubeMapSphere:drawPreview()
    pushStyle() spriteMode(CORNER) noSmooth()
    
    local s = WIDTH/4
    local y = HEIGHT/3.5 - s/2
    local pos = {
        {x = s*2, y = y},     -- +X
        {x = 0,   y = y},     -- -X
        {x = s,   y = y + s}, -- +Y  (was y - s)
        {x = s,   y = y - s}, -- -Y  (was y + s)
        {x = s,   y = y},     -- +Z (center)
        {x = s*3, y = y},     -- -Z
    }
    
    -- vertically flipped draw
    for i = 1, 6 do
        sprite(self.cubeImages[i], pos[i].x, pos[i].y + s, s, -s)
    end
    
    popStyle()
end

function CubeMapSphere:setDrawingColor(c) self.drawingColor = c end
function CubeMapSphere:setBrushSize(s) self.brushSize = s end

-- (… keep your FACE_BASIS, faceUVToDir, dirToFaceUV, dirToFacePixel, routePixel,
-- drawDiskCrossFace, resampler helpers, flipVertical/horizontal exactly as before …)

-- Utility: flip vertical (unchanged)
function flipVertical(img)
    local w, h = img.width, img.height
    local out = image(w,h)
    setContext(out)
    pushMatrix()
    translate(w/2, h/2)
    scale(1, -1)
    spriteMode(CENTER)
    sprite(img, 0, 0, w, h)
    popMatrix()
    setContext()
    return out
end

-- (snip) — keep your resampling/cross-slicing helpers unchanged
-- Make sure your loadCubemapImage(...) ends by setting:
--   self.cubeImages = faces
--   self.cubeTexture = craft.cubeTexture(faces)
--   (no need to set sphere material here; bakeFacesToRT() will mirror to RT)


-- clamp helper
function clamp01(v, lo, hi) return math.max(lo, math.min(hi, v)) end

-- Draw a short segment between two *directions* (not pixels)
-- Samples a few points between and plots them; each sample is routed to its proper face.
function CubeMapSphere:_segmentDir(selfFace, d0, d1, img, r,g,b,a, samples)
    samples = samples or 8
    local W, H = img.width, img.height
    for i = 0, samples do
        local t = i / samples
        -- spherical linear interp (approx: normalized lerp is fine for tiny steps)
        local d = (d0*(1-t) + d1*t):normalize()
        local f,x,y = dirToFacePixel(d, W, H)
        local target = (f == selfFace) and img or self.cubeImages[f]
        plotBlend(target, x, y, r,g,b,a)
    end
end

-- Draw one meridian (constant longitude λ) by stepping latitude θ from -90..+90
function CubeMapSphere:_drawMeridian(faceIndex, lambda, colorA, density)
    local img = self.cubeImages[faceIndex]
    local r,g,b,a = 255,255,255, colorA or 28
    local steps = density or (self.textureSize // 2)
    local prev = nil
    for i = 0, steps do
        local t = i / steps
        local theta = -math.pi*0.5 + t * math.pi          -- latitude
        local c = math.cos(theta)
        local d = vec3(c*math.cos(lambda), math.sin(theta), c*math.sin(lambda))
        if prev then
            self:_segmentDir(faceIndex, prev, d, img, r,g,b,a, 3)
        end
        prev = d
    end
end

-- Draw one parallel (constant latitude θ) by stepping longitude λ 0..360
function CubeMapSphere:_drawParallel(faceIndex, theta, colorA, density)
    local img = self.cubeImages[faceIndex]
    local r,g,b,a = 255,255,255, colorA or 28
    local steps = density or (self.textureSize)            -- needs more samples (wrap-around)
    local prev = nil
    for i = 0, steps do
        local t = i / steps
        local lambda = t * 2*math.pi
        local c = math.cos(theta)
        local d = vec3(c*math.cos(lambda), math.sin(theta), c*math.sin(lambda))
        if prev then
            self:_segmentDir(faceIndex, prev, d, img, r,g,b,a, 3)
        end
        prev = d
    end
end

-- Public: fast grid overlay on all 6 faces
-- stepDeg = grid spacing in degrees (e.g., 15), alpha = line opacity (0..255)
function CubeMapSphere:overlayLatLonGridFast(stepDeg, alpha)
    stepDeg = stepDeg or 15
    alpha = alpha or 28
    -- draw every line once but rasterize onto whichever face each sample lands on.
    -- Just call per-face so we can reuse each face's image as 'img' in helpers.
    for face = 1,6 do
        -- Meridians
        for lonDeg = 0, 360-stepDeg, stepDeg do
            self:_drawMeridian(face, math.rad(lonDeg), alpha, self.textureSize//2)
        end
        -- Parallels (skip poles to avoid overdraw blobs)
        for latDeg = -90+stepDeg, 90-stepDeg, stepDeg do
            self:_drawParallel(face, math.rad(latDeg), alpha, self.textureSize)
        end
    end
end

function plotBlend(img, x, y, r, g, b, a)
    if x < 1 or y < 1 or x > img.width or y > img.height then return end
    local R,G,B,A = img:get(x, y)
    local ia = 255 - a
    img:set(x, y,
    (R*ia + r*a)//255,
    (G*ia + g*a)//255,
    (B*ia + b*a)//255,
    math.min(255, A + ((255-A)*a)//255))
end

-- build cube faces by resampling
function CubeMapSphere:loadCubemapImage(imgAsset, cropPixels)
    if not imgAsset then 
        print("loadCubemapImage: no image")
        return
    end
    local cross = (type(imgAsset) == "userdata" and imgAsset.width) and imgAsset or readImage(imgAsset)
    if not cross then
        print("loadCubemapImage: couldn't read image")
        return
    end
    
    cross = flipVertical(cross)
    
    local w, h = cross.width, cross.height
    local tile = math.floor(w / 4)  -- base face size in the cross
    if tile <= 0 then print("cross too small"); return end
    
    local crop = cropPixels or 2     -- inset to avoid gutters/printed borders
    crop = math.max(0, math.min(crop, math.floor(tile/8))) -- keep sane
    
    local N = self.textureSize       -- output face resolution
    local faces = {}
    for f = 1, 6 do
        faces[f] = bakeFaceFromCross(cross, f, N, tile, crop)
    end
    
    faces[3], faces[4] = faces[4], faces[3]
    
    self.cubeImages  = faces
    self.cubeTexture = craft.cubeTexture(faces)
    if self.cubeTexture.generateMipmaps then
        pcall(function() self.cubeTexture:generateMipmaps() end)
    end
    if self.renderer and self.renderer.material then
        self.renderer.material.map = self.cubeTexture
    end
    collectgarbage()
    -- You can still keep a CPU-side cubeTexture if you like, but what matters is:
    if self.rt then self:bakeFacesToRT() end
end
    
-- direction -> (face, x, y) as 1-based pixel coords for your texture size
function dirToFacePixel(d, W, H)
    local f,u,v = dirToFaceUV(d)
    local eps = 1e-7
    if u<=-1 then u=-1+eps elseif u>=1 then u=1-eps end
    if v<=-1 then v=-1+eps elseif v>=1 then v=1-eps end
    local x = math.floor((u+1)*0.5*W) + 1
    local y = math.floor((v+1)*0.5*H) + 1
    if x<1 then x=1 elseif x>W then x=W end
    if y<1 then y=1 elseif y>H then y=H end
    return f,x,y
end

-- direction -> (face, u, v) with u,v in [-1,1]
function dirToFaceUV(d)
    local ax, ay, az = math.abs(d.x), math.abs(d.y), math.abs(d.z)
    if ax >= ay and ax >= az then
        local m=ax
        if d.x>0 then return 1, -d.z/m, -d.y/m end
        return 2,  d.z/m, -d.y/m
    elseif ay >= ax and ay >= az then
        local m=ay
        if d.y>0 then return 3,  d.x/m,  d.z/m end
        return 4,  d.x/m, -d.z/m
    else
        local m=az
        if d.z>0 then return 5,  d.x/m, -d.y/m end
        return 6, -d.x/m, -d.y/m
    end
end

function CubeMapSphere:setDefaultFaces()
    -- Face order expected by StandardSphere:
    -- 1:+X, 2:-X, 3:+Y, 4:-Y, 5:+Z, 6:-Z
    local faceColors = {
        color(255,  64,  64),  -- +X (right)
        color( 64,  64, 255),  -- -X (left)
        color( 64, 255,  64),  -- +Y (top)
        color(231, 191, 88),  -- -Y (bottom)
        color(33, 215, 215),  -- +Z (front)
        color(255,  64, 255),  -- -Z (back)
    }
    
    -- Human labels for the face normal
    local faceNames = { "+X (Right)", "-X (Left)", "+Y (Top)", "-Y (Bottom)", "+Z (Front)", "-Z (Back)" }
    
    -- These match your mapping (dirToFaceUV / sphereToCubemapFace):
    -- For each face, what **world directions** does +u (→) and +v (↑) correspond to?
    -- We encode a string label for clarity on the texture.
    local uvWorldLabels = {
        -- face 1: +X  (u = -Z, v = -Y)
        { u="+U→ = -Z", v="+V↑ = -Y" },
        -- face 2: -X  (u = +Z, v = -Y)
        { u="+U→ = +Z", v="+V↑ = -Y" },
        -- face 3: +Y  (u = +X, v = +Z)
        { u="+U→ = +X", v="+V↑ = +Z" },
        -- face 4: -Y  (u = +X, v = -Z)
        { u="+U→ = +X", v="+V↑ = -Z" },
        -- face 5: +Z  (u = +X, v = -Y)
        { u="+U→ = +X", v="+V↑ = -Y" },
        -- face 6: -Z  (u = -X, v = -Y)
        { u="+U→ = -X", v="+V↑ = -Y" },
    }
    
    local W = self.textureSize
    local H = self.textureSize
    
    function drawArrow(x0, y0, x1, y1)
        line(x0, y0, x1, y1)
        -- simple arrowhead
        local dx, dy = x1 - x0, y1 - y0
        local len = math.max(1, math.sqrt(dx*dx + dy*dy))
        local ux, uy = dx/len, dy/len
        local ah = math.min(12, len * 0.25)
        local leftx  = -uy; local lefty  = ux
        line(x1, y1, x1 - ux*ah + leftx*ah*0.6, y1 - uy*ah + lefty*ah*0.6)
        line(x1, y1, x1 - ux*ah - leftx*ah*0.6, y1 - uy*ah - lefty*ah*0.6)
    end
    
    self.cubeImages = {}
    
    for i = 1, 6 do
        local img = image(W, H)
        setContext(img)
        pushStyle()
        pushMatrix()
        
        -- Background
        background(faceColors[i])
        
        -- Border & grid cross
        stroke(0, 0, 0, 120)
        strokeWidth(2)
        noFill()
        rect(1, 1, W-2, H-2)
        
        -- Center and arrows from the center
        local cx, cy = W * 0.5, H * 0.5
        stroke(255)
        strokeWidth(3)
        
        -- By definition of our textures: +u is to the right, +v is up
        local arrowLen = math.min(W, H) * 0.28
        -- +u arrow (→)
        drawArrow(cx, cy, cx + arrowLen, cy)
        -- +v arrow (↑)
        drawArrow(cx, cy, cx, cy + arrowLen)
        
        -- Labels
        fill(255)
        fontSize(math.max(14, W * 0.08))
        textAlign(CENTER)
        
        -- Big face label
        text(faceNames[i], cx, 0.4*H)
        
        -- UV→world mapping labels near the arrow tips
        fontSize(math.max(12, W * 0.06))
        text(uvWorldLabels[i].u, cx + arrowLen * 0.85, cy + 16)   -- near +u tip
        text(uvWorldLabels[i].v, cx,                cy + arrowLen + 18) -- near +v tip
        
        -- Small legend bottom-left
        fontSize(math.max(10, W * 0.05))
        textAlign(LEFT)
        fill(255, 210)
        textMode(CORNER)
        text("Texture axes in this face:\n+U → (right)\n+V ↑ (up)", 0.14*H, 0.14*H)
        
        popMatrix()
        popStyle()
        setContext()
        
        img = flipVertical(img)
        
        self.cubeImages[i] = img
    end
    
    -- super fast, true-spherical grid (~milliseconds at 256^2)
    self:overlayLatLonGridFast(18, 24)  -- 15° spacing, ~9% alpha
    
    self.cubeTexture = craft.cubeTexture(self.cubeImages)
    if self.renderer and self.renderer.material then
        self.renderer.material.map = self.cubeTexture
    end
end

-- Convert (face,u,v) to pixel in the 4x3 cross, with a tiny inset "crop"
function faceUVToCrossXY(face, u, v, tileSize, crop)
    local cell = CROSS_CELL[face]
    local s   = tileSize
    local x0  = cell.x * s + crop
    local y0  = cell.y * s + crop
    local span = s - 2*crop
    -- sample at pixel centers
    local sx = x0 + ((u + 1.0) * 0.5) * span + 0.5
    local sy = y0 + ((v + 1.0) * 0.5) * span + 0.5
    return sx, sy
end

-- Bake one face by ray-marching into the cross
function bakeFaceFromCross(srcCross, faceIndex, outSize, tileSize, crop)
    local dst = image(outSize, outSize)
    local eps = 1.0 / outSize  -- keeps lookups off the exact edge
    for y = 1, outSize do
        local v = ((y - 0.5) / outSize) * 2.0 - 1.0
        v = math.max(-1 + eps, math.min(1 - eps, v))
        for x = 1, outSize do
            local u = ((x - 0.5) / outSize) * 2.0 - 1.0
            u = math.max(-1 + eps, math.min(1 - eps, u))
            local dir = faceUVToDir(faceIndex, u, v)
            local f2, uu, vv = dirToFaceUV(dir)             -- may fall onto neighbor across edges
            local sx, sy = faceUVToCrossXY(f2, uu, vv, tileSize, crop)
            -- inside bakeFaceFromCross, replace the old dst:set with:
            local r,g,b,a = sampleBilinear(srcCross, sx, sy)
            -- build a Codea color; clamp to [0,255]
            dst:set(x, y, color(
            clamp01(r, 0, 255),
            clamp01(g, 0, 255),
            clamp01(b, 0, 255),
            clamp01(a, 0, 255)
            ))
        end
    end
    return dst
end


function faceUVToDir(face, u, v)
    local b = FACE_BASIS[face]
    return (b.n + b.u*u + b.v*v):normalize()
end

-- direction -> (face, u, v) with u,v in [-1,1]
function dirToFaceUV(d)
    local ax, ay, az = math.abs(d.x), math.abs(d.y), math.abs(d.z)
    if ax >= ay and ax >= az then
        local m=ax
        if d.x>0 then return 1, -d.z/m, -d.y/m end
        return 2,  d.z/m, -d.y/m
    elseif ay >= ax and ay >= az then
        local m=ay
        if d.y>0 then return 3,  d.x/m,  d.z/m end
        return 4,  d.x/m, -d.z/m
    else
        local m=az
        if d.z>0 then return 5,  d.x/m, -d.y/m end
        return 6, -d.x/m, -d.y/m
    end
end

-- Face frames that match your sphereToCubemapFace mapping
FACE_BASIS = {
    [1]={n=vec3( 1,0,0), u=vec3( 0,0,-1), v=vec3( 0,-1, 0)}, -- +X
    [2]={n=vec3(-1,0,0), u=vec3( 0,0, 1), v=vec3( 0,-1, 0)}, -- -X
    [3]={n=vec3( 0,1,0), u=vec3( 1,0, 0), v=vec3( 0, 0, 1)}, -- +Y
    [4]={n=vec3( 0,-1,0),u=vec3( 1,0, 0), v=vec3( 0, 0,-1)}, -- -Y
    [5]={n=vec3( 0,0,1), u=vec3( 1,0, 0), v=vec3( 0,-1, 0)}, -- +Z
    [6]={n=vec3( 0,0,-1),u=vec3(-1,0, 0), v=vec3( 0,-1, 0)}, -- -Z
}

-- where each face lives in the cross (tile coords)
CROSS_CELL = {
    [1] = vec2(2,1), -- +X
    [2] = vec2(0,1), -- -X
    [3] = vec2(1,2), -- +Y
    [4] = vec2(1,0), -- -Y
    [5] = vec2(1,1), -- +Z
    [6] = vec2(3,1)  -- -Z
}

-- Bilinear sample at float (x,y) from Codea image (1..w, 1..h)
function sampleBilinear(img, x, y)
    local w, h = img.width, img.height
    x = math.max(1.0, math.min(w-0.001, x))
    y = math.max(1.0, math.min(h-0.001, y))
    local x0, y0 = math.floor(x), math.floor(y)
    local x1, y1 = x0 + 1, y0 + 1
    if x1 > w then x1 = w end
    if y1 > h then y1 = h end
    local fx, fy = x - x0, y - y0
    
    local r00,g00,b00,a00 = img:get(x0, y0)
    local r10,g10,b10,a10 = img:get(x1, y0)
    local r01,g01,b01,a01 = img:get(x0, y1)
    local r11,g11,b11,a11 = img:get(x1, y1)
    
    function lerp(a,b,t) return a + (b - a) * t end
    function mix2(a0,a1,b0,b1,tx,ty)
        local m0 = lerp(a0, a1, fx)
        local m1 = lerp(b0, b1, fx)
        return lerp(m0, m1, fy)
    end
    
    return
    mix2(r00,r10,r01,r11,fx,fy),
    mix2(g00,g10,g01,g11,fx,fy),
    mix2(b00,b10,b01,b11,fx,fy),
    mix2(a00,a10,a01,a11,fx,fy)
end

-- Map face-local (u,v in [-1,1]) to direction vector
local FACE_BASIS_RESAMPLE = {
    [1]={n=vec3( 1,0,0), u=vec3( 0,0,-1), v=vec3( 0,-1, 0)}, -- +X
    [2]={n=vec3(-1,0,0), u=vec3( 0,0, 1), v=vec3( 0,-1, 0)}, -- -X
    [3]={n=vec3( 0,1,0), u=vec3( 1,0, 0), v=vec3( 0, 0, 1)}, -- +Y
    [4]={n=vec3( 0,-1,0),u=vec3( 1,0, 0), v=vec3( 0, 0,-1)}, -- -Y
    [5]={n=vec3( 0,0,1), u=vec3( 1,0, 0), v=vec3( 0,-1, 0)}, -- +Z
    [6]={n=vec3( 0,0,-1),u=vec3(-1,0, 0), v=vec3( 0,-1, 0)}, -- -Z
}

-- Utility: flip any image vertically
function flipHorizontal(img)
    local w, h = img.width, img.height
    local out = image(w,h)
    setContext(out)
    pushMatrix()
    translate(w/2, h/2)
    scale(-1, 1)          -- horizontal flip
    spriteMode(CENTER)
    sprite(img, 0, 0, w, h)
    popMatrix()
    setContext()
    return out
end

-- Route a possibly out-of-range pixel on `face` to the correct neighbor face/pixel (1-based out)
function routePixel(face, px0, py0, W, H)
    -- inputs px0,py0 are 0-based *math* pixel indices (can be <0 or >=W/H)
    if px0 >= 0 and px0 < W and py0 >= 0 and py0 < H then
        return face, px0+1, py0+1
    end
    -- convert pixel center to uv in [-1,1], then reproject
    local u = ((px0 + 0.5) / W) * 2 - 1
    local v = ((py0 + 0.5) / H) * 2 - 1
    local dir = faceUVToDir(face, u, v)
    return dirToFacePixel(dir, W, H)
end

-- Draw a solid disk across faces (no falloff). `cx,cy` are in 0..W space (as returned by sphereToCubemapFace)
function CubeMapSphere:drawDiskCrossFace(face, cx, cy, radiusPx, col)
    local W, H = self.textureSize, self.textureSize
    -- integer bounds (unclamped), 0-based
    local minx = math.floor(cx - radiusPx)
    local maxx = math.ceil (cx + radiusPx)
    local miny = math.floor(cy - radiusPx)
    local maxy = math.ceil (cy + radiusPx)
    
    for py = miny, maxy do
        for px = minx, maxx do
            local dx = (px + 0.5) - (cx + 0.5)
            local dy = (py + 0.5) - (cy + 0.5)
            if dx*dx + dy*dy <= radiusPx*radiusPx then
                local f2, qx, qy = routePixel(face, px, py, W, H)  -- 1-based outputs
                local img = self.cubeImages[f2]
                img:set(qx, qy, col)
            end
        end
    end
end