From 9fc4aaf3b812b664b0bd9d5a552a6da6b33fdf13 Mon Sep 17 00:00:00 2001 From: Lars Mueller Date: Wed, 24 May 2023 19:39:20 +0200 Subject: [PATCH] Add experimental texture modifier DSL --- doc/minetest/texmod.md | 39 ++++ minetest.lua | 1 + minetest/texmod.lua | 28 +++ minetest/texmod/calc_dims.lua | 54 ++++++ minetest/texmod/dsl.lua | 259 +++++++++++++++++++++++++++ minetest/texmod/read.lua | 327 ++++++++++++++++++++++++++++++++++ minetest/texmod/write.lua | 173 ++++++++++++++++++ 7 files changed, 881 insertions(+) create mode 100644 doc/minetest/texmod.md create mode 100644 minetest/texmod.lua create mode 100644 minetest/texmod/calc_dims.lua create mode 100644 minetest/texmod/dsl.lua create mode 100644 minetest/texmod/read.lua create mode 100644 minetest/texmod/write.lua diff --git a/doc/minetest/texmod.md b/doc/minetest/texmod.md new file mode 100644 index 0000000..b50235b --- /dev/null +++ b/doc/minetest/texmod.md @@ -0,0 +1,39 @@ +# Texture Modifiers + +## Specification + +Refer to the following "specifications", in this order of precedence: + +1. [Minetest Docs](https://github.com/minetest/minetest_docs/blob/master/doc/texture_modifiers.adoc) +2. [Minetest Lua API](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt), section "texture modifiers" +3. [Minetest Sources](https://github.com/minetest/minetest/blob/master/src/client/tile.cpp) + +## Implementation + +### Constructors ("DSL") + +Constructors are kept close to the original forms and perform basic validation. Additionally, texture modifiers can directly be created using `texmod{type = "...", ...}`, bypassing the checks. + +### Writing + +The naive way to implement string building would be to have a +`tostring` function recursively `tostring`ing the sub-modifiers of the current modifier; +each writer would only need a stream (often passed in the form of a `write` function). + +The problem with this is that applying escaping quickly makes this run in quadratic time. + +A more efficient approach passes the escaping along with the `write` function. Thus a "writer" object `w` encapsulating this state is passed around. + +The writer won't necessarily produce the *shortest* or most readable texture modifier possible; for example, colors will be converted to hexadecimal representation, and texture modifiers with optional parameters may have the default values be written. +You should not rely on the writer to produce any particular of the various valid outputs. + +### Reading + +**The reader does not attempt to precisely match the behavior of Minetest's shotgun "parser".** It *may* be more strict in some instances, rejecting insane constructs Minetest's parser allows. +It *may* however sometimes also be more lenient (though I haven't encountered an instance of this yet), accepting sane constructs which Minetest's parser rejects due to shortcomings in its implementation. + +The parser is written *to spec*, in the given order of precedence. +If a documented construct is not working, that's a bug. If a construct which is incorrect according to the docs is accepted, that's a bug too. +Compatibility with Minetest's parser for all reasonable inputs is greatly valued. If an invalid input is notably used in the wild (or it is reasonable that it may occur in the wild) and supported by Minetest, this parser ought to support it too. + +Recursive descent parsing is complicated by the two forms of escaping texture modifiers support: Reading each character needs to handle escaping. The current depth and whether the parser is inside an inventorycube need to be saved in state variables. These could be passed on the stack, but it's more comfortable (and possibly more efficient) to just share them across all functions and restore them after leaving an inventorycube / moving to a lower level. diff --git a/minetest.lua b/minetest.lua index 0919732..629e442 100644 --- a/minetest.lua +++ b/minetest.lua @@ -9,6 +9,7 @@ for _, value in pairs{ "colorspec", "media", "obj", + "texmod", } do components[value] = value end diff --git a/minetest/texmod.lua b/minetest/texmod.lua new file mode 100644 index 0000000..2409708 --- /dev/null +++ b/minetest/texmod.lua @@ -0,0 +1,28 @@ +-- Texture Modifier representation for building, parsing and stringifying texture modifiers according to +-- https://github.com/minetest/minetest_docs/blob/master/doc/texture_modifiers.adoc + +local function component(component_name, ...) + return assert(loadfile(modlib.mod.get_resource(modlib.modname, "minetest", "texmod", component_name .. ".lua")))(...) +end + +local texmod, metatable = component"dsl" +texmod.write = component"write" +texmod.read = component("read", texmod) +texmod.calc_dims = component"calc_dims" + +function metatable:__tostring() + local rope = {} + self:write(function(str) rope[#rope+1] = str end) + return table.concat(rope) +end + +function texmod.read_string(str) + local i = 0 + return texmod.read(function() + i = i + 1 + if i > #str then return end + return str:sub(i, i) + end) +end + +return texmod diff --git a/minetest/texmod/calc_dims.lua b/minetest/texmod/calc_dims.lua new file mode 100644 index 0000000..0c12166 --- /dev/null +++ b/minetest/texmod/calc_dims.lua @@ -0,0 +1,54 @@ +-- TODO ensure completeness, use table of dim calculation functions +local base_dims = modlib.table.set({ + "opacity", + "invert", + "brighten", + "noalpha", + "makealpha", + "lowpart", + "mask", + "colorize", +}) +local floor, max, clamp = math.floor, math.max, modlib.math.clamp +local function next_pow_of_2(x) + -- I don't want to use a naive 2^ceil(log(x)/log(2)) due to possible float precision issues. + local m, e = math.frexp(x) -- x = _*2^e, _ in [0.5, 1) + if m == 0.5 then e = e - 1 end -- x = 2^(e-1) + return math.ldexp(1, e) -- 2^e, premature optimization here we go +end +return function(self, get_file_dims) + local function calc_dims(self) + local type = self.type + if type == "filename" then + return get_file_dims(self.filename) + end if base_dims[type] then + return calc_dims(self.base) + end if type == "resize" or type == "combine" then + return self.w, self.h + end if type == "overlay" then + local base_w, base_h = calc_dims(self.base) + local overlay_w, overlay_h = calc_dims(self.over) + return max(base_w, overlay_w), max(base_h, overlay_h) + end if type == "transform" then + if self.rotation_deg % 180 ~= 0 then + local base_w, base_h = calc_dims(self.base) + return base_h, base_w + end + return calc_dims(self.base) + end if type == "inventorycube" then + local top_w, top_h = calc_dims(self.top) + local left_w, left_h = calc_dims(self.left) + local right_w, right_h = calc_dims(self.right) + local d = clamp(next_pow_of_2(max(top_w, top_h, left_w, left_h, right_w, right_h)), 2, 64) + return d, d + end if type == "verticalframe" or type == "crack" or type == "cracko" then + local base_w, base_h = calc_dims(self.base) + return base_w, floor(base_h / self.framecount) + end if type == "sheet" then + local base_w, base_h = calc_dims(self.base) + return floor(base_w / self.w), floor(base_h / self.h) + end + error("unsupported texture modifier: " .. type) + end + return calc_dims(self) +end \ No newline at end of file diff --git a/minetest/texmod/dsl.lua b/minetest/texmod/dsl.lua new file mode 100644 index 0000000..791252c --- /dev/null +++ b/minetest/texmod/dsl.lua @@ -0,0 +1,259 @@ +local texmod = {} +local metatable = {__index = texmod} + +local function new(self) + return setmetatable(self, metatable) +end + +-- `texmod{...}` may be used to create texture modifiers, bypassing the checks +setmetatable(texmod, {__call = new}) + +-- Constructors / "generators" + +function texmod.file(filename) + return new{ + type = "filename", + filename = filename + } +end + +function texmod.png(data) + assert(type(data) == "string") + return new{ + type = "png", + data = data + } +end + +function texmod.combine(w, h, blits) + assert(w % 1 == 0 and w > 0) + assert(h % 1 == 0 and h > 0) + for _, blit in ipairs(blits) do + assert(blit.x % 1 == 0) + assert(blit.y % 1 == 0) + assert(blit.texture) + end + return new{ + type = "combine", + w = w, + h = h, + blits = blits + } +end + +function texmod.inventorycube(top, left, right) + return new{ + type = "inventorycube", + top = top, + left = left, + right = right + } +end + +-- Methods / "modifiers" + +function texmod:overlay(overlay) + return new{ + type = "overlay", + base = self, + over = overlay + } +end + +function texmod:brighten() + return new{ + type = "brighten", + base = self, + } +end + +function texmod:noalpha() + return new{ + type = "noalpha", + base = self + } +end + +function texmod:resize(w, h) + assert(w % 1 == 0 and w > 0) + assert(h % 1 == 0 and h > 0) + return new{ + type = "resize", + base = self, + w = w, + h = h, + } +end + +local function assert_uint8(num) + assert(num % 1 == 0 and num >= 0 and num <= 0xFF) +end + +function texmod:makealpha(r, g, b) + assert_uint8(r); assert_uint8(g); assert_uint8(b) + return new{ + type = "makealpha", + base = self, + r = r, g = g, b = b + } +end + +function texmod:opacity(ratio) + assert_uint8(ratio) + return new{ + type = "opacity", + base = self, + ratio = ratio + } +end + +local function tobool(val) + return not not val +end + +function texmod:invert(channels --[[set with keys "r", "g", "b", "a"]]) + return new{ + type = "invert", + base = self, + r = tobool(channels.r), + g = tobool(channels.g), + b = tobool(channels.b), + a = tobool(channels.a) + } +end + +function texmod:flip(flip_axis --[["x" or "y"]]) + assert(flip_axis == "x" or flip_axis == "y") + return new{ + type = "transform", + base = self, + flip_axis = flip_axis + } +end + +function texmod:rotate(deg) + deg = deg % 360 + assert(deg % 90 == 0, "only multiples of 90° supported") + return new{ + type = "transform", + base = self, + rotation_deg = deg + } +end + +-- First flip, then rotate counterclockwise +function texmod:transform(flip_axis, rotation_deg) + assert(flip_axis == nil or flip_axis == "x" or flip_axis == "y") + rotation_deg = (rotation_deg or 0) % 360 + assert(rotation_deg % 90 == 0, "only multiples of 90° supported") + return new{ + type = "transform", + base = self, + rotation_deg = rotation_deg ~= 0 and rotation_deg or nil, + flip_axis = flip_axis + } +end + +function texmod:verticalframe(framecount, frame) + assert(framecount >= 1) + assert(frame >= 0) + return new{ + type = "verticalframe", + base = self, + framecount = framecount, + frame = frame + } +end + +local function crack(self, name, ...) + local tilecount, framecount, frame + if select("#", ...) == 2 then + tilecount, framecount, frame = 1, ... + else + assert(select("#", ...) == 3, "invalid number of arguments") + tilecount, framecount, frame = ... + end + assert(tilecount >= 1) + assert(framecount >= 1) + assert(frame >= 0) + return new{ + type = name, + base = self, + tilecount = tilecount, + framecount = framecount, + frame = frame + } +end + +function texmod:crack(...) + return crack(self, "crack", ...) +end + +function texmod:cracko(...) + return crack(self, "cracko", ...) +end +texmod.crack_with_opacity = texmod.cracko + +function texmod:sheet(w, h, x, y) + assert(w % 1 == 0 and w >= 1) + assert(h % 1 == 0 and h >= 1) + assert(x % 1 == 0 and x < w) + assert(y % 1 == 0 and y < w) + return new{ + type = "sheet", + base = self, + w = w, + h = h, + x = x, + y = y + } +end + +local colorspec = modlib.minetest.colorspec + +function texmod:multiply(color) + return new{ + type = "multiply", + base = self, + color = colorspec.from_any(color) -- copies a given colorspec + } +end + +function texmod:colorize(color, ratio) + color = colorspec.from_any(color) -- copies a given colorspec + if ratio == "alpha" then + assert(color.alpha or 0xFF == 0xFF) + else + ratio = ratio or color.alpha + assert_uint8(ratio) + if color.alpha == ratio then + ratio = nil + end + end + return new{ + type = "colorize", + base = self, + color = color, + ratio = ratio + } +end + +function texmod:mask(mask) + return new{ + type = "mask", + base = self, + mask = mask + } +end + +function texmod:lowpart(percent, overlay) + assert(percent % 1 == 0 and percent >= 0 and percent <= 100) + return new{ + type = "lowpart", + base = self, + percent = percent, + over = overlay + } +end + +return texmod, metatable \ No newline at end of file diff --git a/minetest/texmod/read.lua b/minetest/texmod/read.lua new file mode 100644 index 0000000..e36372c --- /dev/null +++ b/minetest/texmod/read.lua @@ -0,0 +1,327 @@ +local texmod = ... +local colorspec = modlib.minetest.colorspec + +local transforms = { + [0] = {}, + {rotation_deg = 90}, + {rotation_deg = 180}, + {rotation_deg = 270}, + {flip_axis = "x"}, + {flip_axis = "x", rotation_deg = 90}, + {flip_axis = "y"}, + {flip_axis = "y", rotation_deg = 90}, +} + +return function(read_char) + -- TODO this currently uses closures rather than passing around a "reader" object, + -- which is inconsistent with the writer and harder to port to more static languages + local level = 0 + local invcube = false + local eof = false + + local escapes, character + + local function peek() + if eof then return end + local expected_escapes = 0 + if level > 0 then + -- Premature optimization my beloved (this is `2^(level-1)`) + expected_escapes = math.ldexp(0.5, level) + end + if character:match"[&^:]" then + if escapes == expected_escapes then return character end + elseif escapes <= expected_escapes then + return character + elseif escapes >= 2*expected_escapes then + return "\\" + end + end + + local function popchar() + escapes = 0 + while true do + character = read_char() + if character ~= "\\" then break end + escapes = escapes + 1 + end + if character == nil then + assert(escapes == 0, "end of texmod expected") + eof = true + end + end + + popchar() + + local function pop() + local expected_escapes = 0 + if level > 0 then + -- Premature optimization my beloved (this is `2^(level-1)`) + expected_escapes = math.ldexp(0.5, level) + end + if escapes > 0 and escapes >= 2*expected_escapes then + escapes = escapes - 2*expected_escapes + return + end + return popchar() + end + + local function match(char) + if peek() == char then + pop() + return true + end + end + + local function expect(char) + if not match(char) then + error(("%q expected"):format(char)) + end + end + + local function hat() + return match(invcube and "&" or "^") + end + + local function match_charset(set) + local char = peek() + if char and char:match(set) then + pop() + return char + end + end + + local function match_str(set) + local c = match_charset(set) + if not c then error ("character in " .. set .. " expected") end + local t = {c} + while true do + c = match_charset(set) + if not c then break end + table.insert(t, c) + end + return table.concat(t) + end + + local function int() + local sign = 1 + if match"-" then sign = -1 end + return sign * tonumber(match_str"%d") + end + + local texp + local function subtexp() + level = level + 1 + local res = texp() + level = level - 1 + return res + end + + local read_base = { + png = function() + expect":" + local base64 = match_str"[a-zA-Z0-9+/=]" + return assert(minetest.decode_base64(base64), "invalid base64") + end, + inventorycube = function() + local function read_side() + assert(not invcube, "can't nest inventorycube") + invcube = true + assert(match"{", "'{' expected") + local res = texp() + invcube = false + return res + end + local top = read_side() + local left = read_side() + local right = read_side() + return top, left, right + end, + combine = function() + expect":" + local w = int() + expect"x" + local h = int() + local blits = {} + while match":" do + if eof then break end -- we can just end with `:`, right? + local x = int() + expect"," + local y = int() + expect"=" + level = level + 1 + local t = texp() + level = level - 1 + table.insert(blits, {x = x, y = y, texture = t}) + end + return w, h, blits + end, + } + + local function fname() + -- This is overly permissive, as is Minetest; + -- we just allow arbitrary characters up until a character which may terminate the name. + -- Inside an inventorycube, `&` also terminates names. + return match_str(invcube and "[^:^&){]" or "[^:^){]") + end + + local function basexp() + if match"(" then + local res = texp() + expect")" + return res + end + if match"[" then + local name = match_str"[a-z]" + local reader = read_base[name] + if not reader then + error("invalid texture modifier: " .. name) + end + return texmod[name](reader()) + end + return texmod.file(fname()) + end + + local function pcolorspec() + -- Leave exact validation up to colorspec, only do a rough greedy charset matching + return assert(colorspec.from_string(match_str"[#%xa-z]")) + end + + local function crack() + expect":" + local framecount = int() + expect":" + local frame = int() + if match":" then + return framecount, frame, int() + end + return framecount, frame + end + + local param_readers = { + brighten = function()end, + noalpha = function()end, + resize = function() + expect":" + local w = int() + expect"x" + local h = int() + return w, h + end, + makealpha = function() + expect":" + local r = int() + expect"," + local g = int() + expect"," + local b = int() + return r, g, b + end, + opacity = function() + expect":" + local ratio = int() + return ratio + end, + invert = function() + expect":" + local channels = {} + while true do + local c = match_charset"[rgba]" + if not c then break end + channels[c] = true + end + return channels + end, + transform = function() + if match"I" then + return + end + local flip_axis + if match"F" then + flip_axis = assert(match_charset"[XY]", "axis expected"):lower() + end + local rot_deg + if match"R" then + rot_deg = int() + end + if flip_axis or rot_deg then + return flip_axis, rot_deg + end + local transform = assert(transforms[int()], "out of range") + return transform.flip_axis, transform.rotation_deg + end, + verticalframe = function() + expect":" + local framecount = int() + expect":" + local frame = int() + return framecount, frame + end, + crack = crack, + cracko = crack, + sheet = function() + expect":" + local w = int() + expect"x" + local h = int() + expect":" + local x = int() + expect"," + local y = int() + return w, h, x, y + end, + multiply = function() + expect":" + return pcolorspec() + end, + colorize = function() + expect":" + local color = pcolorspec() + if not match":" then + return color + end + if not match"a" then + return color, int() + end + for c in ("lpha"):gmatch"." do + expect(c) + end + return color, "alpha" + end, + mask = function() + expect":" + return subtexp() + end, + lowpart = function() + expect":" + local percent = int() + assert(percent) + expect":" + return percent, subtexp() + end, + } + + function texp() + local base = basexp() + while hat() do + if match"[" then + local name = match_str"[a-z]" + local param_reader = param_readers[name] + local gen_reader = read_base[name] + if not (param_reader or gen_reader) then + error("invalid texture modifier: " .. name) + end + if param_reader then + base = base[name](base, param_reader()) + elseif gen_reader then + base = base:overlay(texmod[name](gen_reader())) + end + else + base = base:overlay(basexp()) + end + end + return base + end + local res = texp() + assert(eof, "eof expected") + return res +end \ No newline at end of file diff --git a/minetest/texmod/write.lua b/minetest/texmod/write.lua new file mode 100644 index 0000000..220a37f --- /dev/null +++ b/minetest/texmod/write.lua @@ -0,0 +1,173 @@ +local pw = {} -- parameter writers: `[type] = func(self, write)` + +function pw:png(w) + w.colon(); w.str(minetest.encode_base64(self.data)) +end + +function pw:combine(w) + w.colon(); w.int(self.w); w.str"x"; w.str(self.h) + for _, blit in ipairs(self.blits) do + w.colon() + w.int(blit.x); w.str","; w.int(blit.y); w.str"=" + w.esctex(blit.texture) + end +end + +-- Consider [inventorycube{a{b{c inside [combine: no need to escape &'s (but :'s need to be escaped) +-- Consider [combine inside inventorycube: need to escape &'s + +function pw:inventorycube(w) + assert(not w.inventorycube, "[inventorycube may not be nested") + local function write_side(side) + w.str"{" + w.inventorycube = true + w.tex(self[side]) + w.inventorycube = false + end + write_side"top" + write_side"left" + write_side"right" +end + +-- No parameters to write +pw.brighten = modlib.func.no_op +pw.noalpha = modlib.func.no_op + +function pw:resize(w) + w.colon(); w.int(self.w); w.str"x"; w.int(self.h) +end + +function pw:makealpha(w) + w.colon(); w.int(self.r); w.str","; w.int(self.g); w.str","; w.int(self.b) +end + +function pw:opacity(w) + w.colon(); w.int(self.ratio) +end + +function pw:invert(w) + w.colon() + if self.r then w.str"r" end + if self.g then w.str"g" end + if self.b then w.str"b" end + if self.a then w.str"a" end +end + +function pw:transform(w) + local rot_deg, flip_axis = self.rotation_deg or 0, self.flip_axis + if rot_deg == 0 and flip_axis == nil then + w.str"I" + elseif rot_deg == 0 then + w.str(assert(({x = "FX", y = "FY"})[flip_axis])) + elseif flip_axis == nil then + w.str"R"; w.int(self.rotation_deg) + elseif rot_deg == 90 then + w.str(assert(({x = "FX", y = "FY"})[flip_axis])) + w.str"R90" + elseif rot_deg == 180 then + -- Rotating by 180° is equivalent to flipping both axes; + -- if one axis was already flipped, that is undone - + -- thus it is equivalent to flipping the other axis + w.str(assert(({x = "FY", y = "FX"})[flip_axis])) + elseif rot_deg == 270 then + -- Rotating by 270° is equivalent to first rotating by 180°, then rotating by 90°; + -- first flipping an axis and then rotating by 180° + -- is equivalent to flipping the other axis as shown above + w.str(assert(({x = "FY", y = "FX"})[flip_axis])) + w.str"R90" + end +end + +function pw:verticalframe(w) + w.colon(); w.int(self.framecount); w.colon(); w.int(self.frame) +end + +function pw:crack(w) + w.colon(); w.int(self.tilecount); w.colon(); w.int(self.framecount); w.colon(); w.int(self.frame) +end + +pw.cracko = pw.crack + +function pw:sheet(w) + w.colon(); w.int(self.w); w.str"x"; w.int(self.h); w.colon(); w.int(self.x); w.str","; w.int(self.y) +end + +function pw:multiply(w) + w.colon() + w.str(self.color:to_string()) +end + +function pw:colorize(w) + w.colon() + w.str(self.color:to_string()) + if self.ratio then + w.colon() + if self.ratio == "alpha" then + w.str"alpha" + else + w.int(self.ratio) + end + end +end + +function pw:mask(w) + w.colon(); w.esctex(self.mask) +end + +function pw:lowpart(w) + w.colon(); w.int(self.percent); w.colon(); w.esctex(self.over) +end + +return function(self, write_str) + -- We could use a metatable here, but it wouldn't really be worth it; + -- it would save us instantiating a handful of closures at the cost of constant `__index` events + -- and having to constantly pass `self`, increasing code complexity + local w = {} + w.inventorycube = false + w.level = 0 + w.str = write_str + function w.esc() + if w.level == 0 then return end + w.str(("\\"):rep(math.ldexp(0.5, w.level))) + end + function w.hat() + -- Note: We technically do not need to escape `&` inside an [inventorycube which is nested inside [combine, + -- but we do it anyways for good practice and since we have to escape `&` inside [combine inside [inventorycube. + w.esc() + w.str(w.inventorycube and "&" or "^") + end + function w.colon() + w.esc(); w.str":" + end + function w.int(int) + w.str(("%d"):format(int)) + end + function w.tex(tex) + if tex.type == "filename" then + assert(not tex.filename:find"[:^\\&{[]", "invalid character in filename") + w.str(tex.filename) + return + end + if tex.base then + w.tex(tex.base) + w.hat() + end + if tex.type == "overlay" then + if tex.over.type ~= "filename" then -- TODO also exclude [png, [combine and [inventorycube (generators) + w.str"("; w.tex(tex.over); w.str")" + else + w.tex(tex.over) + end + else + w.str"[" + w.str(tex.type) + pw[tex.type](tex, w) + end + end + function w.esctex(tex) + w.level = w.level + 1 + w.tex(tex) + w.level = w.level - 1 + end + w.tex(self) +end \ No newline at end of file