---@class ImageRenderer local ImageRenderer = {} -- ErrorHandler will be injected via init local ErrorHandler = nil --- Initialize ImageRenderer with dependencies ---@param deps table Dependencies table with ErrorHandler function ImageRenderer.init(deps) if deps and deps.ErrorHandler then ErrorHandler = deps.ErrorHandler end end --- Calculate rendering parameters for object-fit modes --- Returns source and destination rectangles for rendering ---@param imageWidth number -- Natural width of the image ---@param imageHeight number -- Natural height of the image ---@param boundsWidth number -- Width of the bounds to fit within ---@param boundsHeight number -- Height of the bounds to fit within ---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill") ---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center") ---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number} function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition) fitMode = fitMode or "fill" objectPosition = objectPosition or "center center" if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then ErrorHandler.error("ImageRenderer", "VAL_002", "Dimensions must be positive", { imageWidth = imageWidth, imageHeight = imageHeight, boundsWidth = boundsWidth, boundsHeight = boundsHeight, }) end local result = { sx = 0, -- Source X sy = 0, -- Source Y sw = imageWidth, -- Source width sh = imageHeight, -- Source height dx = 0, -- Destination X dy = 0, -- Destination Y dw = boundsWidth, -- Destination width dh = boundsHeight, -- Destination height scaleX = 1, -- Scale factor X scaleY = 1, -- Scale factor Y } if fitMode == "fill" then -- Stretch to fill bounds (may distort) result.scaleX = boundsWidth / imageWidth result.scaleY = boundsHeight / imageHeight result.dw = boundsWidth result.dh = boundsHeight elseif fitMode == "contain" then -- Scale to fit within bounds (preserves aspect ratio) local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight) result.scaleX = scale result.scaleY = scale result.dw = imageWidth * scale result.dh = imageHeight * scale -- Apply object-position for letterbox alignment local posX, posY = ImageRenderer._parsePosition(objectPosition) result.dx = (boundsWidth - result.dw) * posX result.dy = (boundsHeight - result.dh) * posY elseif fitMode == "cover" then -- Scale to cover bounds (preserves aspect ratio, may crop) local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight) result.scaleX = scale result.scaleY = scale local scaledWidth = imageWidth * scale local scaledHeight = imageHeight * scale -- Apply object-position for crop alignment local posX, posY = ImageRenderer._parsePosition(objectPosition) -- Calculate which part of the scaled image to show local cropX = (scaledWidth - boundsWidth) * posX local cropY = (scaledHeight - boundsHeight) * posY -- Convert back to source coordinates result.sx = cropX / scale result.sy = cropY / scale result.sw = boundsWidth / scale result.sh = boundsHeight / scale result.dx = 0 result.dy = 0 result.dw = boundsWidth result.dh = boundsHeight elseif fitMode == "none" then -- Use natural size (no scaling) result.scaleX = 1 result.scaleY = 1 result.dw = imageWidth result.dh = imageHeight -- Apply object-position local posX, posY = ImageRenderer._parsePosition(objectPosition) result.dx = (boundsWidth - imageWidth) * posX result.dy = (boundsHeight - imageHeight) * posY elseif fitMode == "scale-down" then -- Use none or contain, whichever is smaller if imageWidth <= boundsWidth and imageHeight <= boundsHeight then -- Image fits naturally, use "none" return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition) else -- Image too large, use "contain" return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition) end else ErrorHandler.warn("ImageRenderer", "VAL_007", string.format("Invalid fit mode: '%s'. Must be one of: fill, contain, cover, scale-down, none", tostring(fitMode)), { fitMode = fitMode, fallback = "fill" }) -- Use 'fill' as fallback return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition) end return result end --- Parse object-position string into normalized coordinates (0-1) --- Supports keywords (center, top, bottom, left, right) and percentages ---@param position string -- Position string like "center center", "top left", "50% 50%" ---@return number, number -- Normalized X and Y positions (0-1) function ImageRenderer._parsePosition(position) if not position or type(position) ~= "string" then return 0.5, 0.5 -- Default to center end -- Split into X and Y components local parts = {} for part in position:gmatch("%S+") do table.insert(parts, part:lower()) end -- If only one value, use it for both axes (with special handling) if #parts == 1 then local val = parts[1] if val == "left" or val == "right" then parts = { val, "center" } elseif val == "top" or val == "bottom" then parts = { "center", val } else parts = { val, val } end elseif #parts == 0 then return 0.5, 0.5 -- Default to center end local function parseValue(val) -- Handle keywords if val == "center" then return 0.5 elseif val == "left" or val == "top" then return 0 elseif val == "right" or val == "bottom" then return 1 end -- Handle percentages local percent = val:match("^([%d%.]+)%%$") if percent then return tonumber(percent) / 100 end -- Handle plain numbers (treat as percentage) local num = tonumber(val) if num then return num / 100 end -- Invalid value, default to center return 0.5 end local x = parseValue(parts[1]) local y = parseValue(parts[2] or parts[1]) -- Clamp to 0-1 range x = math.max(0, math.min(1, x)) y = math.max(0, math.min(1, y)) return x, y end --- Draw an image with specified object-fit mode ---@param image love.Image -- Image to draw ---@param x number -- X position of bounds ---@param y number -- Y position of bounds ---@param width number -- Width of bounds ---@param height number -- Height of bounds ---@param fitMode string? -- Object-fit mode (default: "fill") ---@param objectPosition string? -- Object-position (default: "center center") ---@param opacity number? -- Opacity 0-1 (default: 1) ---@param tintColor Color? -- Color to tint the image (default: white/no tint) function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor) if not image then return -- Nothing to draw end opacity = opacity or 1 fitMode = fitMode or "fill" objectPosition = objectPosition or "center center" local imgWidth, imgHeight = image:getDimensions() local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition) -- Save current color local r, g, b, a = love.graphics.getColor() -- Apply opacity and tint if tintColor then love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) else love.graphics.setColor(1, 1, 1, opacity) end -- Draw image if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then -- Need to use a quad for cropping local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight) love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh) else -- Simple draw with scaling love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY) end -- Restore color love.graphics.setColor(r, g, b, a) end --- Draw an image with tiling/repeat mode ---@param image love.Image -- Image to draw ---@param x number -- X position of bounds ---@param y number -- Y position of bounds ---@param width number -- Width of bounds ---@param height number -- Height of bounds ---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat") ---@param opacity number? -- Opacity 0-1 (default: 1) ---@param tintColor Color? -- Color to tint the image (default: white/no tint) function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor) if not image then return -- Nothing to draw end opacity = opacity or 1 repeatMode = repeatMode or "no-repeat" local imgWidth, imgHeight = image:getDimensions() -- Save current color local r, g, b, a = love.graphics.getColor() -- Apply opacity and tint if tintColor then love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) else love.graphics.setColor(1, 1, 1, opacity) end if repeatMode == "no-repeat" then -- Just draw once, no tiling love.graphics.draw(image, x, y) elseif repeatMode == "repeat" then -- Tile in both directions local tilesX = math.ceil(width / imgWidth) local tilesY = math.ceil(height / imgHeight) for tileY = 0, tilesY - 1 do for tileX = 0, tilesX - 1 do local drawX = x + (tileX * imgWidth) local drawY = y + (tileY * imgHeight) -- Calculate how much of the tile to draw (for partial tiles at edges) local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) if drawWidth < imgWidth or drawHeight < imgHeight then -- Use quad for partial tile local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight) love.graphics.draw(image, quad, drawX, drawY) else -- Draw full tile love.graphics.draw(image, drawX, drawY) end end end elseif repeatMode == "repeat-x" then -- Tile horizontally only local tilesX = math.ceil(width / imgWidth) for tileX = 0, tilesX - 1 do local drawX = x + (tileX * imgWidth) local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) if drawWidth < imgWidth then -- Use quad for partial tile local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight) love.graphics.draw(image, quad, drawX, y) else -- Draw full tile love.graphics.draw(image, drawX, y) end end elseif repeatMode == "repeat-y" then -- Tile vertically only local tilesY = math.ceil(height / imgHeight) for tileY = 0, tilesY - 1 do local drawY = y + (tileY * imgHeight) local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) if drawHeight < imgHeight then -- Use quad for partial tile local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight) love.graphics.draw(image, quad, x, drawY) else -- Draw full tile love.graphics.draw(image, x, drawY) end end elseif repeatMode == "space" then -- Distribute tiles with even spacing local tilesX = math.floor(width / imgWidth) local tilesY = math.floor(height / imgHeight) if tilesX < 1 then tilesX = 1 end if tilesY < 1 then tilesY = 1 end local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0 local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0 for tileY = 0, tilesY - 1 do for tileX = 0, tilesX - 1 do local drawX = x + (tileX * (imgWidth + spaceX)) local drawY = y + (tileY * (imgHeight + spaceY)) love.graphics.draw(image, drawX, drawY) end end elseif repeatMode == "round" then -- Scale tiles to fit bounds exactly local tilesX = math.max(1, math.round(width / imgWidth)) local tilesY = math.max(1, math.round(height / imgHeight)) local scaleX = width / (tilesX * imgWidth) local scaleY = height / (tilesY * imgHeight) for tileY = 0, tilesY - 1 do for tileX = 0, tilesX - 1 do local drawX = x + (tileX * imgWidth * scaleX) local drawY = y + (tileY * imgHeight * scaleY) love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY) end end else ErrorHandler.warn("ImageRenderer", "VAL_007", string.format("Invalid repeat mode: '%s'. Using 'no-repeat'", tostring(repeatMode)), { repeatMode = repeatMode, fallback = "no-repeat" }) love.graphics.draw(image, x, y) end -- Restore color love.graphics.setColor(r, g, b, a) end return ImageRenderer