aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--generat3d/formula.py116
-rw-r--r--tests/test_formula.py96
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()