diff options
| -rw-r--r-- | generat3d/formula.py | 116 | ||||
| -rw-r--r-- | tests/test_formula.py | 96 |
2 files changed, 212 insertions, 0 deletions
diff --git a/generat3d/formula.py b/generat3d/formula.py new file mode 100644 index 0000000..4a6a0d9 --- /dev/null +++ b/generat3d/formula.py @@ -0,0 +1,116 @@ +# generat3D - evaluate Daz DSON scenes and write .dbz files +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import math +import operator + +from . import Error + +STAGES = ("sum", "mult") +BINARY = {"add": operator.add, "sub": operator.sub, + "mult": operator.mul, "div": operator.truediv} +SPLINES = () # filled in by Task 4 + + +class Prop: + __slots__ = ("value", "lo", "hi", "clamped") + + def __init__(self, value, lo, hi, clamped): + self.value, self.lo, self.hi, self.clamped = value, lo, hi, clamped + + +class Graph: + """Properties keyed by string, plus the formulas that drive them.""" + + def __init__(self): + self.props = {} + self.formulas = {} # output key -> [(stage, ops, source)] + + def add_prop(self, key, value=0.0, lo=-math.inf, hi=math.inf, clamped=False): + self.props[key] = Prop(value, lo, hi, clamped) + + def set_value(self, key, value): + self.props[key].value = value + + def add_formula(self, output, ops, stage="sum", source="?"): + if stage not in STAGES: + raise Error(f"{source}: unknown formula stage {stage!r} for {output}") + self.formulas.setdefault(output, []).append((stage, ops, source)) + + def evaluate(self): + """Final value of every property.""" + done = {} + for key in self.props: + self._value(key, done, []) + return done + + def _value(self, key, done, path): + if key in done: + return done[key] + if key in path: + raise Error("formula cycle: " + " -> ".join(path[path.index(key):] + [key])) + prop = self.props.get(key) + if prop is None: + return None + path.append(key) + total, factor = prop.value, 1.0 + for stage, ops, source in self.formulas.get(key, ()): + r = self._run(key, ops, done, path, source) + if r is None: + continue + if stage == "sum": + total += r + else: + factor *= r + path.pop() + v = total * factor + if prop.clamped: + v = min(max(v, prop.lo), prop.hi) + done[key] = v + return v + + def _run(self, key, ops, done, path, source): + stack = [] + try: + for op in ops: + name = op["op"] + if name == "push": + if "key" in op: + v = self._value(op["key"], done, path) + if v is None: + return None + stack.append(v) + else: + stack.append(op["val"]) + elif name in BINARY: + b, a = stack.pop(), stack.pop() + stack.append(BINARY[name](a, b)) + elif name == "neg": + stack.append(-stack.pop()) + elif name in SPLINES: + stack.append(_spline_op(name, stack)) + else: + raise Error(f"{source}: unknown formula operation {name!r} for {key}") + except (IndexError, KeyError, TypeError, ZeroDivisionError, ValueError): + raise Error(f"{source}: malformed formula for {key}") from None + if len(stack) != 1: + raise Error(f"{source}: malformed formula for {key}") + return stack[0] + + +def _spline_op(name, stack): + raise NotImplementedError # Task 4 diff --git a/tests/test_formula.py b/tests/test_formula.py new file mode 100644 index 0000000..8074e5a --- /dev/null +++ b/tests/test_formula.py @@ -0,0 +1,96 @@ +# generat3D - evaluate Daz DSON scenes and write .dbz files +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import unittest + +from generat3d import Error +from generat3d.formula import Graph + + +def push(key): + return {"op": "push", "key": key} + + +def val(v): + return {"op": "push", "val": v} + + +class GraphTest(unittest.TestCase): + def setUp(self): + self.g = Graph() + self.g.add_prop("a", 0.5) + self.g.add_prop("b") + self.g.add_prop("c", 0.0, 0.0, 1.0, clamped=True) + + def test_base_values(self): + self.assertEqual(self.g.evaluate(), {"a": 0.5, "b": 0.0, "c": 0.0}) + + def test_sum_stage(self): + self.g.add_formula("b", [push("a"), val(2), {"op": "mult"}]) + self.g.add_formula("b", [push("a"), val(1), {"op": "add"}]) + self.assertEqual(self.g.evaluate()["b"], 1.0 + 1.5) + + def test_mult_stage(self): + self.g.set_value("b", 3.0) + self.g.add_formula("b", [push("a")], stage="mult") + self.assertEqual(self.g.evaluate()["b"], 1.5) + + def test_chain_and_clamp(self): + self.g.add_formula("b", [push("a"), val(4), {"op": "mult"}]) + self.g.add_formula("c", [push("b")]) + out = self.g.evaluate() + self.assertEqual(out["b"], 2.0) + self.assertEqual(out["c"], 1.0) + + def test_sub_div_neg(self): + self.g.add_formula("b", [val(1), push("a"), {"op": "sub"}, val(4), {"op": "div"}, + {"op": "neg"}]) + self.assertEqual(self.g.evaluate()["b"], -0.125) + + def test_unknown_input_drops_formula(self): + self.g.add_formula("b", [push("missing")]) + self.g.add_formula("b", [val(7)]) + self.assertEqual(self.g.evaluate()["b"], 7) + + def test_output_without_prop_is_ignored(self): + self.g.add_formula("nowhere", [val(1)]) + self.assertNotIn("nowhere", self.g.evaluate()) + + def test_cycle_is_fatal(self): + self.g.add_formula("a", [push("b")]) + self.g.add_formula("b", [push("a")]) + with self.assertRaisesRegex(Error, "formula cycle: a -> b -> a"): + self.g.evaluate() + + def test_unknown_op_is_fatal(self): + self.g.add_formula("b", [val(1), {"op": "pow"}], source="/data/x.dsf#m") + with self.assertRaisesRegex(Error, r"/data/x.dsf#m: unknown formula operation 'pow'"): + self.g.evaluate() + + def test_unknown_stage_is_fatal(self): + with self.assertRaisesRegex(Error, "unknown formula stage 'avg'"): + self.g.add_formula("b", [val(1)], stage="avg") + + def test_stack_errors_are_fatal(self): + self.g.add_formula("b", [{"op": "add"}], source="f") + with self.assertRaisesRegex(Error, "f: malformed formula for b"): + self.g.evaluate() + + def test_leftover_stack_is_fatal(self): + self.g.add_formula("b", [val(1), val(2)], source="f") + with self.assertRaisesRegex(Error, "f: malformed formula for b"): + self.g.evaluate() |
