From c695375d56ed30be35ab0ef097ef1b33b1b5ca94 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 14:17:13 -0700 Subject: [PATCH 01/13] Add internal/materialx package: parser + procedural evaluator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subset of MaterialX needed to evaluate solid procedural shader graphs (marble, brick, checkerboard) at 3D positions. Image, BSDF, and codegen nodes intentionally out of scope; the consumer is the voxel color pipeline, which only needs RGB at a point. Graphs are compiled to a closure tree at sampler construction so Sample is reentrant and zero-alloc on the hot path (892 ns/op, 0 allocs/op for the official standard_surface_marble_solid.mtlx fixture). Validation is exhaustive at construction — unsupported nodes and missing wires fail there, not silently at sample time. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/materialx/eval.go | 530 ++++++++++++++++++ internal/materialx/export_test.go | 6 + internal/materialx/materialx_test.go | 417 ++++++++++++++ internal/materialx/noise.go | 124 ++++ internal/materialx/parse.go | 350 ++++++++++++ .../standard_surface_marble_solid.mtlx | 67 +++ internal/materialx/types.go | 223 ++++++++ 7 files changed, 1717 insertions(+) create mode 100644 internal/materialx/eval.go create mode 100644 internal/materialx/export_test.go create mode 100644 internal/materialx/materialx_test.go create mode 100644 internal/materialx/noise.go create mode 100644 internal/materialx/parse.go create mode 100644 internal/materialx/testdata/standard_surface_marble_solid.mtlx create mode 100644 internal/materialx/types.go diff --git a/internal/materialx/eval.go b/internal/materialx/eval.go new file mode 100644 index 0000000..5f44b26 --- /dev/null +++ b/internal/materialx/eval.go @@ -0,0 +1,530 @@ +package materialx + +import ( + "errors" + "fmt" + "math" + "sort" + "sync" +) + +// Sampler returns an RGB color (each channel typically in [0, 1], but +// not clamped — the consumer decides how to handle out-of-gamut values) +// given an object-space sample position. Compiled samplers are +// reentrant: calling Sample concurrently from multiple goroutines is +// safe because each call uses its own scratch slot table. +type Sampler interface { + Sample(pos [3]float64) [3]float64 +} + +// MaterialNames returns the material names defined by the document, in +// alphabetical order. +func (d *Document) MaterialNames() []string { + names := make([]string, 0, len(d.Materials)) + for k := range d.Materials { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// BaseColorSampler returns a Sampler for the base_color input of the +// surface shader bound to the named material. +func (d *Document) BaseColorSampler(materialName string) (Sampler, error) { + m, ok := d.Materials[materialName] + if !ok { + return nil, fmt.Errorf("materialx: material %q not found", materialName) + } + if m.SurfaceShaderName == "" { + return nil, fmt.Errorf("materialx: material %q has no surfaceshader binding", materialName) + } + s, ok := d.Surfaces[m.SurfaceShaderName] + if !ok { + return nil, fmt.Errorf("materialx: surface shader %q not found", m.SurfaceShaderName) + } + bc, ok := s.Inputs["base_color"] + if !ok { + return nil, fmt.Errorf("materialx: surface shader %q has no base_color input", s.Name) + } + return d.samplerFromInput(bc) +} + +// DefaultBaseColorSampler returns a Sampler for the first material in +// alphabetical order. Documents typically contain a single material. +func (d *Document) DefaultBaseColorSampler() (Sampler, error) { + names := d.MaterialNames() + if len(names) == 0 { + return nil, errors.New("materialx: document has no materials") + } + return d.BaseColorSampler(names[0]) +} + +func (d *Document) samplerFromInput(in *input) (Sampler, error) { + if in.Value != nil { + return constSampler(in.Value.AsVec3()), nil + } + if in.GraphName != "" { + ng, ok := d.NodeGraphs[in.GraphName] + if !ok { + return nil, fmt.Errorf("materialx: nodegraph %q not found", in.GraphName) + } + outName := in.OutputName + if outName == "" { + outName = "out" + } + out, ok := ng.outputsByName[outName] + if !ok { + return nil, fmt.Errorf("materialx: nodegraph %q has no output %q", ng.Name, outName) + } + return compileGraph(ng, out) + } + return nil, fmt.Errorf("materialx: input %q has no usable source", in.Name) +} + +type constSampler [3]float64 + +func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } + +// --- compiled graph evaluator --- +// +// At construction time, every reachable node is lowered into a closure +// of type evalFn that reads pre-resolved input values (either pulled +// from the slot table or returned as constants). Slots are indexed by +// node position; the per-Sample scratch is a stack-allocated array of +// up to slotMax entries, with a heap fallback for larger graphs. There +// are no maps, no string lookups, and no allocations on the hot path. + +type evalFn func(pos [3]float64, scratch []Value) Value + +type compiledGraph struct { + nSlots int + outSlot int + steps []compileStep +} + +type compileStep struct { + slot int + fn evalFn +} + +// slotMax sets the pooled scratch capacity. Real-world procedural +// graphs (marble: 14 nodes; brick/wood: ~30) fit comfortably; larger +// graphs fall through to a per-call allocation. The closure call sites +// cause escape analysis to give up on a stack-allocated array, so +// pooling delivers the zero-alloc fast path while preserving reentrancy. +const slotMax = 64 + +var scratchPool = sync.Pool{ + New: func() any { + s := make([]Value, slotMax) + return &s + }, +} + +func (g *compiledGraph) Sample(pos [3]float64) [3]float64 { + if g.nSlots > slotMax { + scratch := make([]Value, g.nSlots) + return g.run(pos, scratch) + } + p := scratchPool.Get().(*[]Value) + out := g.run(pos, (*p)[:g.nSlots]) + scratchPool.Put(p) + return out +} + +func (g *compiledGraph) run(pos [3]float64, scratch []Value) [3]float64 { + for _, st := range g.steps { + scratch[st.slot] = st.fn(pos, scratch) + } + return scratch[g.outSlot].AsVec3() +} + +// compileGraph walks the graph from the named output and lowers every +// reachable node into an ordered list of evalFn closures. Returns an +// error if any node references an unknown type, missing input, or +// unsupported attribute value — eager validation is exhaustive (every +// reachable node is compiled), unlike the prior approach of sampling +// once at the origin. +func compileGraph(ng *nodeGraph, out *graphOutput) (Sampler, error) { + if out.NodeName == "" { + return nil, fmt.Errorf("materialx: nodegraph %q output %q has no nodename", ng.Name, out.Name) + } + c := &compiler{ + ng: ng, + slotOf: map[string]int{}, + visited: map[string]bool{}, + } + outSlot, err := c.compileNode(out.NodeName) + if err != nil { + return nil, err + } + return &compiledGraph{ + nSlots: c.nSlots, + outSlot: outSlot, + steps: c.steps, + }, nil +} + +type compiler struct { + ng *nodeGraph + slotOf map[string]int + visited map[string]bool // detects cycles + steps []compileStep + nSlots int +} + +func (c *compiler) compileNode(name string) (int, error) { + if slot, ok := c.slotOf[name]; ok { + return slot, nil + } + if c.visited[name] { + return 0, fmt.Errorf("materialx: cycle through node %q", name) + } + c.visited[name] = true + + n, ok := c.ng.nodesByName[name] + if !ok { + return 0, fmt.Errorf("materialx: node %q not found in nodegraph %q", name, c.ng.Name) + } + + build, ok := nodeBuilders[n.Type] + if !ok { + return 0, fmt.Errorf("materialx: unsupported node type %q (node %q)", n.Type, n.Name) + } + fn, err := build(c, n) + if err != nil { + return 0, fmt.Errorf("node %q (%s): %w", n.Name, n.Type, err) + } + + slot := c.nSlots + c.nSlots++ + c.slotOf[name] = slot + c.steps = append(c.steps, compileStep{slot: slot, fn: fn}) + return slot, nil +} + +// compileInput resolves an input wire to an evalFn that produces its +// value during Sample. Three sources: another node (recurse and emit a +// slot read), a graph-level interface parameter (emit a constant from +// its default), or a literal value (emit a constant). +func (c *compiler) compileInput(in *input) (evalFn, error) { + switch { + case in.NodeName != "": + slot, err := c.compileNode(in.NodeName) + if err != nil { + return nil, fmt.Errorf("input %q: %w", in.Name, err) + } + return func(_ [3]float64, scratch []Value) Value { return scratch[slot] }, nil + case in.InterfaceName != "": + gi, ok := c.ng.inputsByName[in.InterfaceName] + if !ok { + return nil, fmt.Errorf("input %q references missing interface %q", in.Name, in.InterfaceName) + } + v := gi.Default + return func(_ [3]float64, _ []Value) Value { return v }, nil + case in.Value != nil: + v := *in.Value + return func(_ [3]float64, _ []Value) Value { return v }, nil + } + return nil, fmt.Errorf("input %q has no value", in.Name) +} + +// compileOptional returns nil if the input is absent (and the default +// will be used at call sites). Non-nil errors propagate. +func (c *compiler) compileOptional(n *node, name string) (evalFn, error) { + in, ok := n.inputsByName[name] + if !ok { + return nil, nil + } + return c.compileInput(in) +} + +func (c *compiler) compileRequired(n *node, name string) (evalFn, error) { + in, ok := n.inputsByName[name] + if !ok { + return nil, fmt.Errorf("missing required input %q", name) + } + return c.compileInput(in) +} + +// --- node builders --- + +type nodeBuilder func(c *compiler, n *node) (evalFn, error) + +// Populated in init() to break the initialization cycle: each builder +// transitively reads nodeBuilders during recursion. +var nodeBuilders map[string]nodeBuilder + +func init() { + nodeBuilders = map[string]nodeBuilder{ + "position": buildPosition, + "constant": buildConstant, + "dotproduct": buildDotProduct, + "multiply": buildArithmetic(func(a, b float64) float64 { return a * b }), + "add": buildArithmetic(func(a, b float64) float64 { return a + b }), + "subtract": buildArithmetic(func(a, b float64) float64 { return a - b }), + "fractal3d": buildFractal3D, + "noise3d": buildNoise3D, + "sin": buildUnary(math.Sin), + "cos": buildUnary(math.Cos), + "power": buildPower, + "clamp": buildClamp, + "mix": buildMix, + } +} + +func buildPosition(_ *compiler, n *node) (evalFn, error) { + // "space" attribute: only object-space is supported. Anything else + // would silently produce wrong results since the caller hands us + // coordinates in a single fixed frame. + if in, ok := n.inputsByName["space"]; ok && in.Value != nil { + return nil, fmt.Errorf("position node: only object-space is supported, got %v", in.Value) + } + return func(pos [3]float64, _ []Value) Value { return Vec3Value(pos) }, nil +} + +func buildConstant(c *compiler, n *node) (evalFn, error) { + return c.compileRequired(n, "value") +} + +func buildDotProduct(c *compiler, n *node) (evalFn, error) { + a, err := c.compileRequired(n, "in1") + if err != nil { + return nil, err + } + b, err := c.compileRequired(n, "in2") + if err != nil { + return nil, err + } + return func(pos [3]float64, scratch []Value) Value { + av := a(pos, scratch).AsVec3() + bv := b(pos, scratch).AsVec3() + return FloatValue(av[0]*bv[0] + av[1]*bv[1] + av[2]*bv[2]) + }, nil +} + +func buildArithmetic(op func(a, b float64) float64) nodeBuilder { + return func(c *compiler, n *node) (evalFn, error) { + a, err := c.compileRequired(n, "in1") + if err != nil { + return nil, err + } + b, err := c.compileRequired(n, "in2") + if err != nil { + return nil, err + } + out := n.OutputType + arity := vecArity(out) + switch out { + case TypeFloat: + return func(pos [3]float64, scratch []Value) Value { + return FloatValue(op(a(pos, scratch).AsFloat(), b(pos, scratch).AsFloat())) + }, nil + case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: + return func(pos [3]float64, scratch []Value) Value { + av := broadcast(a(pos, scratch)) + bv := broadcast(b(pos, scratch)) + v := Value{Type: out} + for i := range arity { + v.Vec[i] = op(av[i], bv[i]) + } + return v + }, nil + } + return nil, fmt.Errorf("unsupported output type %s", out) + } +} + +func buildFractal3D(c *compiler, n *node) (evalFn, error) { + posFn, err := c.compileOptional(n, "position") + if err != nil { + return nil, err + } + octFn, err := c.compileOptional(n, "octaves") + if err != nil { + return nil, err + } + lacFn, err := c.compileOptional(n, "lacunarity") + if err != nil { + return nil, err + } + dimFn, err := c.compileOptional(n, "diminish") + if err != nil { + return nil, err + } + ampFn, err := c.compileOptional(n, "amplitude") + if err != nil { + return nil, err + } + return func(pos [3]float64, scratch []Value) Value { + p := posOrDefault(posFn, pos, scratch) + oct := intOrDefault(octFn, pos, scratch, 3) + lac := floatOrDefault(lacFn, pos, scratch, 2.0) + dim := floatOrDefault(dimFn, pos, scratch, 0.5) + amp := floatOrDefault(ampFn, pos, scratch, 1.0) + return FloatValue(amp * fractal3D(p[0], p[1], p[2], oct, lac, dim)) + }, nil +} + +func buildNoise3D(c *compiler, n *node) (evalFn, error) { + posFn, err := c.compileOptional(n, "position") + if err != nil { + return nil, err + } + ampFn, err := c.compileOptional(n, "amplitude") + if err != nil { + return nil, err + } + pivFn, err := c.compileOptional(n, "pivot") + if err != nil { + return nil, err + } + return func(pos [3]float64, scratch []Value) Value { + p := posOrDefault(posFn, pos, scratch) + amp := floatOrDefault(ampFn, pos, scratch, 1.0) + piv := floatOrDefault(pivFn, pos, scratch, 0.0) + return FloatValue(perlin3D(p[0], p[1], p[2])*amp + piv) + }, nil +} + +func buildUnary(op func(float64) float64) nodeBuilder { + return func(c *compiler, n *node) (evalFn, error) { + in, err := c.compileRequired(n, "in") + if err != nil { + return nil, err + } + return func(pos [3]float64, scratch []Value) Value { + return FloatValue(op(in(pos, scratch).AsFloat())) + }, nil + } +} + +func buildPower(c *compiler, n *node) (evalFn, error) { + a, err := c.compileRequired(n, "in1") + if err != nil { + return nil, err + } + b, err := c.compileRequired(n, "in2") + if err != nil { + return nil, err + } + return func(pos [3]float64, scratch []Value) Value { + return FloatValue(math.Pow(a(pos, scratch).AsFloat(), b(pos, scratch).AsFloat())) + }, nil +} + +func buildClamp(c *compiler, n *node) (evalFn, error) { + in, err := c.compileRequired(n, "in") + if err != nil { + return nil, err + } + lowFn, err := c.compileOptional(n, "low") + if err != nil { + return nil, err + } + highFn, err := c.compileOptional(n, "high") + if err != nil { + return nil, err + } + out := n.OutputType + arity := vecArity(out) + if out == TypeFloat { + return func(pos [3]float64, scratch []Value) Value { + low := floatOrDefault(lowFn, pos, scratch, 0) + high := floatOrDefault(highFn, pos, scratch, 1) + return FloatValue(clampF(in(pos, scratch).AsFloat(), low, high)) + }, nil + } + return func(pos [3]float64, scratch []Value) Value { + low := floatOrDefault(lowFn, pos, scratch, 0) + high := floatOrDefault(highFn, pos, scratch, 1) + v := Value{Type: out} + src := in(pos, scratch).Vec + for i := range arity { + v.Vec[i] = clampF(src[i], low, high) + } + return v + }, nil +} + +// buildMix implements MaterialX mix(bg, fg, t) = bg*(1-t) + fg*t. Per +// spec, scalar inputs are broadcast to the output arity (e.g. a float +// fed into a color3 mix produces a constant grey). +func buildMix(c *compiler, n *node) (evalFn, error) { + bg, err := c.compileRequired(n, "bg") + if err != nil { + return nil, err + } + fg, err := c.compileRequired(n, "fg") + if err != nil { + return nil, err + } + mixFn, err := c.compileRequired(n, "mix") + if err != nil { + return nil, err + } + out := n.OutputType + arity := vecArity(out) + switch out { + case TypeFloat: + return func(pos [3]float64, scratch []Value) Value { + t := mixFn(pos, scratch).AsFloat() + return FloatValue(bg(pos, scratch).AsFloat()*(1-t) + fg(pos, scratch).AsFloat()*t) + }, nil + case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: + return func(pos [3]float64, scratch []Value) Value { + t := mixFn(pos, scratch).AsFloat() + bgv := broadcast(bg(pos, scratch)) + fgv := broadcast(fg(pos, scratch)) + v := Value{Type: out} + for i := range arity { + v.Vec[i] = bgv[i]*(1-t) + fgv[i]*t + } + return v + }, nil + } + return nil, fmt.Errorf("unsupported mix output type %s", out) +} + +// --- helpers --- + +// broadcast widens a scalar Value into a 4-component vector by +// replicating the scalar; vector/color values pass through unchanged. +func broadcast(v Value) [4]float64 { + if v.Type == TypeFloat || v.Type == TypeInteger { + f := v.AsFloat() + return [4]float64{f, f, f, f} + } + return v.Vec +} + +func posOrDefault(fn evalFn, pos [3]float64, scratch []Value) [3]float64 { + if fn == nil { + return pos + } + return fn(pos, scratch).AsVec3() +} + +func intOrDefault(fn evalFn, pos [3]float64, scratch []Value, def int) int { + if fn == nil { + return def + } + return fn(pos, scratch).AsInt() +} + +func floatOrDefault(fn evalFn, pos [3]float64, scratch []Value, def float64) float64 { + if fn == nil { + return def + } + return fn(pos, scratch).AsFloat() +} + +func clampF(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/materialx/export_test.go b/internal/materialx/export_test.go new file mode 100644 index 0000000..4a8bf36 --- /dev/null +++ b/internal/materialx/export_test.go @@ -0,0 +1,6 @@ +package materialx + +// PerlinForTest exposes perlin3D to external test packages so the +// reference permutation table can be guarded against accidental edits. +// Test-only; never reference from production code. +func PerlinForTest(x, y, z float64) float64 { return perlin3D(x, y, z) } diff --git a/internal/materialx/materialx_test.go b/internal/materialx/materialx_test.go new file mode 100644 index 0000000..ce0d27a --- /dev/null +++ b/internal/materialx/materialx_test.go @@ -0,0 +1,417 @@ +package materialx_test + +import ( + "math" + "os" + "strings" + "testing" + + "github.com/rtwfroody/ditherforge/internal/materialx" +) + +func TestParseMarbleStructure(t *testing.T) { + doc := loadMarble(t) + if got, want := len(doc.NodeGraphs), 1; got != want { + t.Errorf("nodegraphs: got %d, want %d", got, want) + } + if got, want := len(doc.Surfaces), 1; got != want { + t.Errorf("surfaces: got %d, want %d", got, want) + } + if got, want := len(doc.Materials), 1; got != want { + t.Errorf("materials: got %d, want %d", got, want) + } + names := doc.MaterialNames() + if len(names) != 1 || names[0] != "Marble_3D" { + t.Errorf("material names: got %v, want [Marble_3D]", names) + } +} + +func TestSampleMarbleDeterministic(t *testing.T) { + doc := loadMarble(t) + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + p := [3]float64{0.31, -0.42, 0.7} + c1 := s.Sample(p) + c2 := s.Sample(p) + if c1 != c2 { + t.Errorf("non-deterministic: %v vs %v", c1, c2) + } +} + +func TestSampleMarbleVariesAndStaysInRange(t *testing.T) { + doc := loadMarble(t) + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + + // Marble graph mixes between (0.8, 0.8, 0.8) and (0.1, 0.1, 0.3) + // using a mix factor in [0, 1]. Output channels must lie within + // the per-channel min/max of those two endpoints. + c1 := [3]float64{0.8, 0.8, 0.8} + c2 := [3]float64{0.1, 0.1, 0.3} + lo := [3]float64{} + hi := [3]float64{} + for i := range 3 { + lo[i] = math.Min(c1[i], c2[i]) + hi[i] = math.Max(c1[i], c2[i]) + } + + var minV, maxV [3]float64 + for i := range minV { + minV[i] = math.Inf(1) + maxV[i] = math.Inf(-1) + } + const eps = 1e-9 + const samples = 8 + const span = 0.5 + for ix := range samples { + for iy := range samples { + for iz := range samples { + p := [3]float64{ + -span + 2*span*float64(ix)/float64(samples-1), + -span + 2*span*float64(iy)/float64(samples-1), + -span + 2*span*float64(iz)/float64(samples-1), + } + c := s.Sample(p) + for i := range 3 { + if c[i] < lo[i]-eps || c[i] > hi[i]+eps { + t.Fatalf("color out of range at %v: %v (allowed [%v, %v])", p, c, lo, hi) + } + if c[i] < minV[i] { + minV[i] = c[i] + } + if c[i] > maxV[i] { + maxV[i] = c[i] + } + } + } + } + } + + // Variation: at least one channel must span >10% of its allowable + // range across the sample grid. Without this the sampler could be + // stuck on a single mix endpoint and the test would still pass. + varied := false + for i := range 3 { + if maxV[i]-minV[i] > 0.1*(hi[i]-lo[i]) { + varied = true + break + } + } + if !varied { + t.Errorf("output insufficiently varied across grid: min=%v max=%v", minV, maxV) + } +} + +func TestParseUnknownNodeFailsConstruction(t *testing.T) { + // The marble file contains only known nodes; a doc that references + // an unknown node type should fail at sampler construction (not + // silently at sample time). + bad := strings.NewReader(` + + + + + + + + + + + + +`) + doc, err := materialx.Parse(bad) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, err := doc.DefaultBaseColorSampler(); err == nil { + t.Fatalf("expected error from unsupported node, got nil") + } +} + +func TestConstantBaseColor(t *testing.T) { + // Surface shader with a literal base_color should produce a constant + // sampler that ignores position. + src := strings.NewReader(` + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + t.Fatal(err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + want := [3]float64{0.25, 0.5, 0.75} + for _, p := range [][3]float64{{0, 0, 0}, {1, 2, 3}, {-5, 100, 0.1}} { + if got := s.Sample(p); got != want { + t.Errorf("Sample(%v) = %v, want %v", p, got, want) + } + } +} + +// TestAttributeOrderRobustness exercises the parser on input elements +// whose `value` attribute appears before `type`. XML attributes are +// unordered, so a parser that consumed them in iteration order would +// try to parse the value as TypeUnknown and fail. +func TestAttributeOrderRobustness(t *testing.T) { + src := strings.NewReader(` + + + + + + + + + + + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + got := s.Sample([3]float64{0, 0, 0}) + want := [3]float64{0.2, 0.4, 0.6} // mix(bg, fg, 0.5) = 0.5*bg + 0.5*fg + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("channel %d: got %v, want %v", i, got[i], want[i]) + } + } +} + +// TestMixScalarBroadcast checks that a scalar fed into a color3 mix is +// broadcast across all channels (per MaterialX implicit-conversion +// rules) rather than producing zeros in components 1-2. +func TestMixScalarBroadcast(t *testing.T) { + src := strings.NewReader(` + + + + + + + + + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + t.Fatal(err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + got := s.Sample([3]float64{0, 0, 0}) + want := 0.2*0.75 + 0.8*0.25 // 0.35 + for i := range 3 { + if math.Abs(got[i]-want) > 1e-9 { + t.Errorf("channel %d: got %v, want %v (scalar should broadcast)", i, got[i], want) + } + } +} + +// TestArithmeticTypeCoercion checks that vector op scalar broadcasts +// the scalar across the vector's components — e.g. multiply(vec3, float). +func TestArithmeticTypeCoercion(t *testing.T) { + src := strings.NewReader(` + + + + + + + + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + t.Fatal(err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + got := s.Sample([3]float64{0, 0, 0}) + want := [3]float64{0.2, 0.4, 0.6} + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("channel %d: got %v, want %v", i, got[i], want[i]) + } + } +} + +// TestInterfaceFallthroughNoDefault verifies that referencing a graph +// input that has no value attribute uses the type's zero value (rather +// than crashing or producing an error). +func TestInterfaceFallthroughNoDefault(t *testing.T) { + src := strings.NewReader(` + + + + + + + + + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + t.Fatal(err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatal(err) + } + got := s.Sample([3]float64{0, 0, 0}) + want := [3]float64{0, 0, 0} + for i := range 3 { + if got[i] != want[i] { + t.Errorf("channel %d: got %v, want %v (missing default → zero)", i, got[i], want[i]) + } + } +} + +// TestPositionSpaceUnsupported verifies that a position node with a +// non-default space attribute fails at construction rather than +// silently producing wrong coordinates. +func TestPositionSpaceUnsupported(t *testing.T) { + src := strings.NewReader(` + + + + + + + + + + + + + + +`) + doc, err := materialx.Parse(src) + if err != nil { + // Parse may fail because string isn't a known type — also acceptable. + return + } + if _, err := doc.DefaultBaseColorSampler(); err == nil { + t.Errorf("expected error for unsupported position space, got nil") + } +} + +// TestPerlinGoldenValues pins a handful of perlin3D outputs at known +// inputs. The Perlin permutation table is the reference Ken Perlin +// 2002 table; if any of these golden values change, the noise +// implementation has drifted from the standard. +func TestPerlinGoldenValues(t *testing.T) { + // Computed once from this implementation; serves as a regression + // guard. Any future reshuffle of the permutation table or edit to + // fade/grad/lerp must be reflected here intentionally. + cases := []struct { + x, y, z float64 + want float64 + }{ + {0, 0, 0, 0}, + {0.5, 0.5, 0.5, -0.25}, + {0.25, 0.6, 0.1, -0.10208973162000007}, + {1.5, 2.5, 3.5, 0.125}, + } + for _, tc := range cases { + got := materialx.PerlinForTest(tc.x, tc.y, tc.z) + if math.Abs(got-tc.want) > 1e-12 { + t.Errorf("perlin3D(%v,%v,%v) = %v, want %v", tc.x, tc.y, tc.z, got, tc.want) + } + } +} + +// BenchmarkSampleMarble measures per-Sample cost on the hot path. The +// closure-tree compiler should produce zero allocations per call so +// the voxelizer can call this millions of times per print without GC +// pressure. +func BenchmarkSampleMarble(b *testing.B) { + f, err := os.Open("testdata/standard_surface_marble_solid.mtlx") + if err != nil { + b.Fatal(err) + } + defer f.Close() + doc, err := materialx.Parse(f) + if err != nil { + b.Fatal(err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + var sink [3]float64 + i := 0 + for b.Loop() { + sink = s.Sample([3]float64{float64(i) * 0.001, 0.4, -0.7}) + i++ + } + _ = sink +} + +func loadMarble(t *testing.T) *materialx.Document { + t.Helper() + f, err := os.Open("testdata/standard_surface_marble_solid.mtlx") + if err != nil { + t.Fatalf("open fixture: %v", err) + } + defer f.Close() + doc, err := materialx.Parse(f) + if err != nil { + t.Fatalf("parse: %v", err) + } + return doc +} diff --git a/internal/materialx/noise.go b/internal/materialx/noise.go new file mode 100644 index 0000000..7c28496 --- /dev/null +++ b/internal/materialx/noise.go @@ -0,0 +1,124 @@ +package materialx + +import "math" + +// Classical Ken Perlin "Improved Noise" (2002), 3D variant. Returns +// roughly [-1, 1]. Deterministic given (x, y, z); no internal state. + +var permTable = [256]int{ + 151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, + 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, + 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, + 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, + 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, + 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, + 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, + 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, + 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, + 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, + 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, + 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, + 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, + 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, + 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, + 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180, +} + +var perm [512]int + +func init() { + for i := range 256 { + perm[i] = permTable[i] + perm[i+256] = permTable[i] + } +} + +func fade(t float64) float64 { + return t * t * t * (t*(t*6-15) + 10) +} + +func lerp(t, a, b float64) float64 { + return a + t*(b-a) +} + +func grad3(hash int, x, y, z float64) float64 { + h := hash & 15 + var u, v float64 + if h < 8 { + u = x + } else { + u = y + } + if h < 4 { + v = y + } else if h == 12 || h == 14 { + v = x + } else { + v = z + } + if h&1 != 0 { + u = -u + } + if h&2 != 0 { + v = -v + } + return u + v +} + +// perlin3D returns a value in approximately [-1, 1]. +func perlin3D(x, y, z float64) float64 { + fx := math.Floor(x) + fy := math.Floor(y) + fz := math.Floor(z) + X := int(fx) & 255 + Y := int(fy) & 255 + Z := int(fz) & 255 + x -= fx + y -= fy + z -= fz + u := fade(x) + v := fade(y) + w := fade(z) + A := perm[X] + Y + AA := perm[A] + Z + AB := perm[A+1] + Z + B := perm[X+1] + Y + BA := perm[B] + Z + BB := perm[B+1] + Z + return lerp(w, + lerp(v, + lerp(u, grad3(perm[AA], x, y, z), grad3(perm[BA], x-1, y, z)), + lerp(u, grad3(perm[AB], x, y-1, z), grad3(perm[BB], x-1, y-1, z))), + lerp(v, + lerp(u, grad3(perm[AA+1], x, y, z-1), grad3(perm[BA+1], x-1, y, z-1)), + lerp(u, grad3(perm[AB+1], x, y-1, z-1), grad3(perm[BB+1], x-1, y-1, z-1)))) +} + +// fractal3D returns a fractal Brownian motion sum of Perlin noises. +// +// The MaterialX 1.39 fractal3d spec only states the output is +// "approximately [-1, 1]" without nailing down the exact accumulation +// formula. We sum amp * perlin3D(p * freq) per octave (with freq *= +// lacunarity, amp *= diminish) and divide by the sum of amplitudes — +// this keeps the result inside [-1, 1] regardless of octave count, +// which is what most reference implementations (Houdini's mtlx +// fractal3d, Arnold's standard_surface marble) do but is not mandated +// by spec. Single-octave output is identical to perlin3D. +func fractal3D(x, y, z float64, octaves int, lacunarity, diminish float64) float64 { + if octaves < 1 { + octaves = 1 + } + var sum, ampSum float64 + amp := 1.0 + freq := 1.0 + for i := 0; i < octaves; i++ { + sum += amp * perlin3D(x*freq, y*freq, z*freq) + ampSum += amp + freq *= lacunarity + amp *= diminish + } + if ampSum > 0 { + sum /= ampSum + } + return sum +} diff --git a/internal/materialx/parse.go b/internal/materialx/parse.go new file mode 100644 index 0000000..610413f --- /dev/null +++ b/internal/materialx/parse.go @@ -0,0 +1,350 @@ +package materialx + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "os" +) + +// Parse reads a MaterialX document from r. +func Parse(r io.Reader) (*Document, error) { + dec := xml.NewDecoder(r) + for { + tok, err := dec.Token() + if err == io.EOF { + return nil, errors.New("materialx: no root element") + } + if err != nil { + return nil, err + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if se.Name.Local != "materialx" { + return nil, fmt.Errorf("materialx: expected root , got <%s>", se.Name.Local) + } + return parseMaterialX(dec) + } +} + +// ParseFile is a convenience wrapper around Parse. +func ParseFile(path string) (*Document, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return Parse(f) +} + +// ParseBytes parses MaterialX from an in-memory byte slice. +func ParseBytes(b []byte) (*Document, error) { + return Parse(bytes.NewReader(b)) +} + +func parseMaterialX(dec *xml.Decoder) (*Document, error) { + doc := &Document{ + NodeGraphs: map[string]*nodeGraph{}, + Surfaces: map[string]*surface{}, + Materials: map[string]*material{}, + } + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "nodegraph": + ng, err := parseNodeGraph(dec, t) + if err != nil { + return nil, err + } + doc.NodeGraphs[ng.Name] = ng + case "surfacematerial": + m, err := parseMaterial(dec, t) + if err != nil { + return nil, err + } + doc.Materials[m.Name] = m + default: + // Surface shaders are dispatched by their type="surfaceshader" + // attribute rather than element name, so the parser supports + // standard_surface, open_pbr_surface, UsdPreviewSurface, etc. + // without an element-name allowlist. + if isSurfaceShader(t) { + s, err := parseSurfaceShader(dec, t) + if err != nil { + return nil, err + } + doc.Surfaces[s.Name] = s + } else if err := skipElement(dec); err != nil { + return nil, err + } + } + case xml.EndElement: + return doc, nil + } + } +} + +func isSurfaceShader(se xml.StartElement) bool { + for _, a := range se.Attr { + if a.Name.Local == "type" && a.Value == "surfaceshader" { + return true + } + } + return false +} + +func parseNodeGraph(dec *xml.Decoder, se xml.StartElement) (*nodeGraph, error) { + ng := &nodeGraph{ + inputsByName: map[string]*graphInput{}, + nodesByName: map[string]*node{}, + outputsByName: map[string]*graphOutput{}, + } + for _, a := range se.Attr { + if a.Name.Local == "name" { + ng.Name = a.Value + } + } + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "input": + gi, err := parseGraphInput(dec, t) + if err != nil { + return nil, fmt.Errorf("nodegraph %q: %w", ng.Name, err) + } + ng.Inputs = append(ng.Inputs, gi) + ng.inputsByName[gi.Name] = gi + case "output": + gout, err := parseGraphOutput(dec, t) + if err != nil { + return nil, fmt.Errorf("nodegraph %q: %w", ng.Name, err) + } + ng.Outputs = append(ng.Outputs, gout) + ng.outputsByName[gout.Name] = gout + default: + n, err := parseNode(dec, t) + if err != nil { + return nil, fmt.Errorf("nodegraph %q: %w", ng.Name, err) + } + ng.Nodes = append(ng.Nodes, n) + ng.nodesByName[n.Name] = n + } + case xml.EndElement: + return ng, nil + } + } +} + +// attrLookup returns the value of the named attribute and whether it +// was present. XML attributes are unordered, so any code that depends +// on multiple attributes (e.g. parsing a value against its declared +// type) must collect them up-front instead of consuming them in +// iteration order. +func attrLookup(se xml.StartElement, name string) (string, bool) { + for _, a := range se.Attr { + if a.Name.Local == name { + return a.Value, true + } + } + return "", false +} + +func parseGraphInput(dec *xml.Decoder, se xml.StartElement) (*graphInput, error) { + gi := &graphInput{} + if v, ok := attrLookup(se, "name"); ok { + gi.Name = v + } + if v, ok := attrLookup(se, "type"); ok { + gi.Type = parseValueType(v) + } + if valueStr, ok := attrLookup(se, "value"); ok { + v, err := parseValueString(valueStr, gi.Type) + if err != nil { + return nil, fmt.Errorf("input %q: %w", gi.Name, err) + } + gi.Default = v + } + if err := skipElement(dec); err != nil { + return nil, err + } + return gi, nil +} + +func parseGraphOutput(dec *xml.Decoder, se xml.StartElement) (*graphOutput, error) { + o := &graphOutput{} + if v, ok := attrLookup(se, "name"); ok { + o.Name = v + } + if v, ok := attrLookup(se, "type"); ok { + o.Type = parseValueType(v) + } + if v, ok := attrLookup(se, "nodename"); ok { + o.NodeName = v + } + if v, ok := attrLookup(se, "output"); ok { + o.OutputName = v + } + if err := skipElement(dec); err != nil { + return nil, err + } + return o, nil +} + +func parseNode(dec *xml.Decoder, se xml.StartElement) (*node, error) { + n := &node{ + Type: se.Name.Local, + inputsByName: map[string]*input{}, + } + if v, ok := attrLookup(se, "name"); ok { + n.Name = v + } + if v, ok := attrLookup(se, "type"); ok { + n.OutputType = parseValueType(v) + } + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "input" { + in, err := parseInput(dec, t) + if err != nil { + return nil, fmt.Errorf("node %q: %w", n.Name, err) + } + n.Inputs = append(n.Inputs, in) + n.inputsByName[in.Name] = in + } else if err := skipElement(dec); err != nil { + return nil, err + } + case xml.EndElement: + return n, nil + } + } +} + +func parseInput(dec *xml.Decoder, se xml.StartElement) (*input, error) { + in := &input{} + if v, ok := attrLookup(se, "name"); ok { + in.Name = v + } + if v, ok := attrLookup(se, "type"); ok { + in.Type = parseValueType(v) + } + if v, ok := attrLookup(se, "nodename"); ok { + in.NodeName = v + } + if v, ok := attrLookup(se, "interfacename"); ok { + in.InterfaceName = v + } + if v, ok := attrLookup(se, "nodegraph"); ok { + in.GraphName = v + } + if v, ok := attrLookup(se, "output"); ok { + in.OutputName = v + } + if valueStr, ok := attrLookup(se, "value"); ok { + v, err := parseValueString(valueStr, in.Type) + if err != nil { + return nil, fmt.Errorf("input %q: %w", in.Name, err) + } + in.Value = &v + } + if err := skipElement(dec); err != nil { + return nil, err + } + return in, nil +} + +func parseSurfaceShader(dec *xml.Decoder, se xml.StartElement) (*surface, error) { + s := &surface{ + ShaderType: se.Name.Local, + Inputs: map[string]*input{}, + } + if v, ok := attrLookup(se, "name"); ok { + s.Name = v + } + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "input" { + in, err := parseInput(dec, t) + if err != nil { + return nil, fmt.Errorf("surface %q: %w", s.Name, err) + } + s.Inputs[in.Name] = in + } else if err := skipElement(dec); err != nil { + return nil, err + } + case xml.EndElement: + return s, nil + } + } +} + +func parseMaterial(dec *xml.Decoder, se xml.StartElement) (*material, error) { + m := &material{} + if v, ok := attrLookup(se, "name"); ok { + m.Name = v + } + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "input" { + in, err := parseInput(dec, t) + if err != nil { + return nil, err + } + if in.Name == "surfaceshader" { + m.SurfaceShaderName = in.NodeName + } + } else if err := skipElement(dec); err != nil { + return nil, err + } + case xml.EndElement: + return m, nil + } + } +} + +// skipElement consumes the remainder of the current element through its +// matching EndElement (handling nested children). +func skipElement(dec *xml.Decoder) error { + depth := 1 + for depth > 0 { + tok, err := dec.Token() + if err != nil { + return err + } + switch tok.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + return nil +} diff --git a/internal/materialx/testdata/standard_surface_marble_solid.mtlx b/internal/materialx/testdata/standard_surface_marble_solid.mtlx new file mode 100644 index 0000000..59c1131 --- /dev/null +++ b/internal/materialx/testdata/standard_surface_marble_solid.mtlx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/materialx/types.go b/internal/materialx/types.go new file mode 100644 index 0000000..4a7f73f --- /dev/null +++ b/internal/materialx/types.go @@ -0,0 +1,223 @@ +// Package materialx parses and evaluates a procedural subset of the +// MaterialX (.mtlx) format, sampling shader graphs at 3D positions to +// produce surface colors. Only the nodes needed to evaluate solid +// procedural patterns (marble, brick, checkerboard, etc.) are +// implemented; image-based, BSDF, and lighting nodes are intentionally +// out of scope — the consumer is ditherforge's voxel-color pipeline, +// which only needs RGB at a 3D point. +package materialx + +import ( + "fmt" + "strconv" + "strings" +) + +type ValueType int + +const ( + TypeUnknown ValueType = iota + TypeFloat + TypeInteger + TypeVector2 + TypeVector3 + TypeVector4 + TypeColor3 + TypeColor4 +) + +func (t ValueType) String() string { + switch t { + case TypeFloat: + return "float" + case TypeInteger: + return "integer" + case TypeVector2: + return "vector2" + case TypeVector3: + return "vector3" + case TypeVector4: + return "vector4" + case TypeColor3: + return "color3" + case TypeColor4: + return "color4" + } + return "unknown" +} + +func parseValueType(s string) ValueType { + switch strings.TrimSpace(s) { + case "float": + return TypeFloat + case "integer": + return TypeInteger + case "vector2": + return TypeVector2 + case "vector3": + return TypeVector3 + case "vector4": + return TypeVector4 + case "color3": + return TypeColor3 + case "color4": + return TypeColor4 + } + return TypeUnknown +} + +// Value is a tagged union over MaterialX scalar/vector/color types. Vec +// holds 2/3/4 components for vectors and colors; F/I hold scalars. +type Value struct { + Type ValueType + F float64 + I int + Vec [4]float64 +} + +func FloatValue(f float64) Value { return Value{Type: TypeFloat, F: f} } +func IntValue(i int) Value { return Value{Type: TypeInteger, I: i} } +func Vec3Value(v [3]float64) Value { return Value{Type: TypeVector3, Vec: [4]float64{v[0], v[1], v[2], 0}} } +func Color3Value(v [3]float64) Value { return Value{Type: TypeColor3, Vec: [4]float64{v[0], v[1], v[2], 0}} } + +func (v Value) AsFloat() float64 { + switch v.Type { + case TypeFloat: + return v.F + case TypeInteger: + return float64(v.I) + case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: + return v.Vec[0] + } + return 0 +} + +func (v Value) AsInt() int { + switch v.Type { + case TypeInteger: + return v.I + case TypeFloat: + return int(v.F) + } + return 0 +} + +func (v Value) AsVec3() [3]float64 { + switch v.Type { + case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: + return [3]float64{v.Vec[0], v.Vec[1], v.Vec[2]} + case TypeFloat: + return [3]float64{v.F, v.F, v.F} + case TypeInteger: + f := float64(v.I) + return [3]float64{f, f, f} + } + return [3]float64{} +} + +// parseValueString converts a MaterialX attribute string ("0.8, 0.8, 0.8", +// "3.0", "3", "1, 1, 1") into a typed Value. +func parseValueString(s string, typ ValueType) (Value, error) { + s = strings.TrimSpace(s) + switch typ { + case TypeFloat: + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return Value{}, err + } + return FloatValue(f), nil + case TypeInteger: + i, err := strconv.Atoi(s) + if err != nil { + return Value{}, err + } + return IntValue(i), nil + case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: + parts := strings.Split(s, ",") + need := vecArity(typ) + if len(parts) != need { + return Value{}, fmt.Errorf("type %s expects %d components, got %d", typ, need, len(parts)) + } + v := Value{Type: typ} + for i, p := range parts { + f, err := strconv.ParseFloat(strings.TrimSpace(p), 64) + if err != nil { + return Value{}, fmt.Errorf("component %d: %w", i, err) + } + v.Vec[i] = f + } + return v, nil + } + return Value{}, fmt.Errorf("unsupported value type %s", typ) +} + +func vecArity(t ValueType) int { + switch t { + case TypeVector2: + return 2 + case TypeVector3, TypeColor3: + return 3 + case TypeVector4, TypeColor4: + return 4 + } + return 0 +} + +// Document is the parsed contents of a .mtlx file. +type Document struct { + NodeGraphs map[string]*nodeGraph + Surfaces map[string]*surface + Materials map[string]*material +} + +type nodeGraph struct { + Name string + Inputs []*graphInput + Nodes []*node + Outputs []*graphOutput + inputsByName map[string]*graphInput + nodesByName map[string]*node + outputsByName map[string]*graphOutput +} + +type graphInput struct { + Name string + Type ValueType + Default Value +} + +type graphOutput struct { + Name string + Type ValueType + NodeName string + OutputName string +} + +type node struct { + Type string + Name string + OutputType ValueType + Inputs []*input + inputsByName map[string]*input +} + +type input struct { + Name string + Type ValueType + Value *Value + NodeName string + InterfaceName string + GraphName string + OutputName string +} + +type surface struct { + Name string + ShaderType string // e.g. "standard_surface", "open_pbr_surface" + Inputs map[string]*input +} + +type material struct { + Name string + SurfaceShaderName string +} From 96200f26edffe1816e6dcbdd99cbe049890531f6 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 14:50:49 -0700 Subject: [PATCH 02/13] Wire procedural MaterialX as a base color throughout the pipeline A new BaseColorMaterialX option (file content) plus a tile-size knob take precedence over the existing hex BaseColor when set. The voxelizer samples per-voxel via a voxel.BaseColorOverride interface fed by an adapter over materialx.Sampler; the per-face centroid is also baked into FaceBaseColor so the on-screen 3D preview matches. Sampling happens in the original-mesh frame via the existing Transform.ApplyInverse, so the marble pattern is continuous across split halves with no extra plumbing. Both new fields are hashed into voxelizeSettings *and* stickerSettings since runSticker deep-clones ColorModel; cache contracts are exercised by new regression tests alongside the existing BaseColor ones. Load and decimate caches survive procedural changes. Frontend gains a "Load .mtlx" button + tile-size input in the Base color section, mutually exclusive with the hex picker; the .mtlx content is round-tripped through the Settings JSON so projects rebuild identically without re-reading the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.go | 37 ++++++++++ cmd/ditherforge/main.go | 30 +++++--- frontend/src/App.svelte | 69 +++++++++++++++++-- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ frontend/wailsjs/go/models.ts | 24 +++++++ internal/pipeline/loadoutput_persist.go | 17 ++--- internal/pipeline/materialx_override.go | 89 ++++++++++++++++++++++++ internal/pipeline/pipeline.go | 92 ++++++++++++++++++++----- internal/pipeline/run.go | 4 +- internal/pipeline/stepcache.go | 52 +++++++++----- internal/pipeline/stepcache_test.go | 76 ++++++++++++++++++++ internal/squarevoxel/split_test.go | 6 ++ internal/squarevoxel/squarevoxel.go | 14 ++-- internal/voxel/color.go | 24 ++++++- 15 files changed, 476 insertions(+), 64 deletions(-) create mode 100644 internal/pipeline/materialx_override.go diff --git a/app.go b/app.go index 5af0fc7..642f15d 100644 --- a/app.go +++ b/app.go @@ -703,6 +703,35 @@ func (a *App) OpenStickerImage() (string, error) { }) } +// MaterialXOpenResult is the result of OpenMaterialXFile. Path is +// empty when the user cancels. +type MaterialXOpenResult struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// OpenMaterialXFile opens a file dialog for selecting a procedural +// MaterialX (.mtlx) file. Returns the path and the file contents +// (UTF-8) so the frontend can hold the contents directly — no +// secondary read step is needed, and the cache key reflects the actual +// graph definition. +func (a *App) OpenMaterialXFile() (*MaterialXOpenResult, error) { + path, err := wailsRuntime.OpenFileDialog(a.ctx, wailsRuntime.OpenDialogOptions{ + Title: "Select MaterialX File", + Filters: []wailsRuntime.FileFilter{ + {DisplayName: "MaterialX (*.mtlx)", Pattern: "*.mtlx"}, + }, + }) + if err != nil || path == "" { + return &MaterialXOpenResult{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read materialx: %w", err) + } + return &MaterialXOpenResult{Path: path, Content: string(data)}, nil +} + // ReadStickerThumbnail reads a sticker image and returns a base64 data URL // thumbnail (max 64x64, preserving aspect ratio). func (a *App) ReadStickerThumbnail(path string) (string, error) { @@ -798,6 +827,14 @@ type Settings struct { NozzleDiameter string `json:"nozzleDiameter"` LayerHeight string `json:"layerHeight"` BaseColor *ColorSlotSetting `json:"baseColor,omitempty"` + // BaseMaterialXPath is the on-disk path of the user-selected .mtlx + // file (display only). BaseMaterialXContent is the file contents + // at the time the user picked the file — round-tripped through + // settings so the project rebuilds identically without re-reading + // the file. BaseMaterialXTileMM is the procedural-to-mm scale. + BaseMaterialXPath string `json:"baseMaterialXPath,omitempty"` + BaseMaterialXContent string `json:"baseMaterialXContent,omitempty"` + BaseMaterialXTileMM float64 `json:"baseMaterialXTileMM,omitempty"` ColorSlots []*ColorSlotSetting `json:"colorSlots"` InventoryCollection string `json:"inventoryCollection"` Brightness float64 `json:"brightness"` diff --git a/cmd/ditherforge/main.go b/cmd/ditherforge/main.go index 660c4af..b3a6cb7 100644 --- a/cmd/ditherforge/main.go +++ b/cmd/ditherforge/main.go @@ -34,6 +34,8 @@ type Args struct { Scale float32 `arg:"--scale" default:"1.0" help:"Additional scale multiplier"` Output string `arg:"--output" help:"Output .3mf file (default: .3mf)"` BaseColor string `arg:"--base-color" help:"Hex color for untextured faces (e.g. #FF0000)"` + BaseMaterialX string `arg:"--base-materialx" help:"Path to a procedural .mtlx file applied as the base color of untextured faces (overrides --base-color)"` + BaseMaterialXTileMM float64 `arg:"--base-materialx-tile-mm" default:"10" help:"Object-space scale (mm per shading-unit cycle) for the MaterialX procedural"` NozzleDiameter float32 `arg:"--nozzle-diameter" default:"0.4" help:"Nozzle diameter in mm"` LayerHeight float32 `arg:"--layer-height" default:"0.2" help:"Layer height in mm"` Printer string `arg:"--printer" help:"Target printer profile id (e.g. snapmaker_u1, snapmaker_j1, prusa_xl, prusa_xl_5t, bambu_h2d, bambu_h2d_pro); defaults to snapmaker_u1"` @@ -75,15 +77,27 @@ func main() { } } + var materialxContent string + if args.BaseMaterialX != "" { + data, err := os.ReadFile(args.BaseMaterialX) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading --base-materialx: %v\n", err) + os.Exit(1) + } + materialxContent = string(data) + } + opts := pipeline.Options{ - Input: args.Input, - NumColors: args.NumColors, - LockedColors: expandColors(args.Color), - InventoryFile: args.Inventory, - Scale: args.Scale, - Output: args.Output, - BaseColor: args.BaseColor, - NozzleDiameter: args.NozzleDiameter, + Input: args.Input, + NumColors: args.NumColors, + LockedColors: expandColors(args.Color), + InventoryFile: args.Inventory, + Scale: args.Scale, + Output: args.Output, + BaseColor: args.BaseColor, + BaseColorMaterialX: materialxContent, + BaseColorMaterialXTileMM: args.BaseMaterialXTileMM, + NozzleDiameter: args.NozzleDiameter, LayerHeight: args.LayerHeight, Printer: args.Printer, Brightness: args.Brightness, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 3958128..639c134 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -26,7 +26,7 @@ import { SharedCamera } from '$lib/components/SharedCamera.svelte'; import { contrastColor } from '$lib/utils'; import type { CutPlanePreview } from '$lib/types'; - import { ProcessPipeline, Export3MF, SaveSettings, SaveSettingsDialog, OpenFileDialog, LoadSettingsFile, DefaultSettingsPath, Version, LogMessage, GetCollectionColors, ImportCollection, CreateCollection, DeleteCollection, OpenStickerImage, ReadStickerThumbnail, EnumerateObjects, ListPrinters, Quit } from '../wailsjs/go/main/App'; + import { ProcessPipeline, Export3MF, SaveSettings, SaveSettingsDialog, OpenFileDialog, LoadSettingsFile, DefaultSettingsPath, Version, LogMessage, GetCollectionColors, ImportCollection, CreateCollection, DeleteCollection, OpenStickerImage, ReadStickerThumbnail, OpenMaterialXFile, EnumerateObjects, ListPrinters, Quit } from '../wailsjs/go/main/App'; import type { main } from '../wailsjs/go/models'; import { collectionStore } from '$lib/stores/collections.svelte'; import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime'; @@ -118,6 +118,12 @@ // Base color for untextured faces: null = use model default, or {hex, label, collection}. let baseColor = $state(null); let baseColorPickerOpen = $state(false); + // Procedural MaterialX base color. Mutually exclusive with `baseColor`. + // baseMaterialXContent is the actual file body (round-tripped through + // settings); baseMaterialXPath is for display only. + let baseMaterialXPath = $state(''); + let baseMaterialXContent = $state(''); + let baseMaterialXTileMM = $state(10); // Color palette: each slot is either null (auto) or a locked color with hex + label + source collection. type ColorInfo = { hex: string; label: string; collection?: string }; type ColorSlot = ColorInfo | null; @@ -560,7 +566,7 @@ $effect(() => { // Read all form values to establish tracking. void [inputFile, sizeMode, sizeValue, scaleValue, printerId, nozzleDiameter, - layerHeight, baseColor, ...colorSlots, + layerHeight, baseColor, baseMaterialXContent, baseMaterialXTileMM, ...colorSlots, inventoryCollectionColors, committedBrightness, committedContrast, committedSaturation, JSON.stringify(warpPins), @@ -797,6 +803,9 @@ nozzleDiameter: String(nozzleDiameter), layerHeight: String(layerHeight), baseColor: baseColor ? { hex: baseColor.hex, label: baseColor.label, collection: baseColor.collection } : null, + baseMaterialXPath, + baseMaterialXContent, + baseMaterialXTileMM, colorSlots: colorSlots.map(s => s ? { hex: s.hex, label: s.label, collection: s.collection } : null), inventoryCollection, brightness, @@ -854,6 +863,9 @@ if (s.layerHeight !== undefined) layerHeight = s.layerHeight; reconcilePrinterSelection(); if (s.baseColor !== undefined) baseColor = s.baseColor ? { hex: s.baseColor.hex, label: s.baseColor.label || '', collection: s.baseColor.collection || '' } : null; + if (s.baseMaterialXPath !== undefined) baseMaterialXPath = s.baseMaterialXPath; + if (s.baseMaterialXContent !== undefined) baseMaterialXContent = s.baseMaterialXContent; + if (s.baseMaterialXTileMM !== undefined) baseMaterialXTileMM = s.baseMaterialXTileMM; if (s.colorSlots !== undefined) { colorSlots = s.colorSlots.map((c: any) => c ? { hex: c.hex, label: c.label || '', collection: c.collection || '' } : null); } @@ -1064,6 +1076,8 @@ LockedColors: colorSlots.filter((s): s is ColorInfo => s !== null).map(s => s.hex), Scale: sizeMode === 'scale' ? (parseFloat(scaleValue) || 1.0) : 1.0, BaseColor: baseColor?.hex ?? '', + BaseColorMaterialX: baseMaterialXContent, + BaseColorMaterialXTileMM: baseMaterialXTileMM, NozzleDiameter: parseFloat(nozzleDiameter) || 0.4, LayerHeight: parseFloat(layerHeight) || 0.2, Printer: printerId, @@ -1324,7 +1338,7 @@
Base color - Color used for faces that aren't covered by the model's texture. Pick one to override the model's default. + Color used for faces that aren't covered by the model's texture. Pick a single color or load a procedural MaterialX (.mtlx) file (e.g. marble, brick) — MaterialX takes precedence when both are set.
{#if sizeMode === 'size'} @@ -1332,7 +1346,17 @@ {:else} {/if} - {#if baseColor} + {#if baseMaterialXContent} +
+ + {baseMaterialXPath ? baseMaterialXPath.split(/[\\/]/).pop() : 'inline .mtlx'} + + +
+ {:else if baseColor}
+ {:else} +
+ Tile size + + Object-space scale (mm per shading-unit cycle) applied to the procedural before sampling. Smaller = denser pattern. + +
+
+ + mm +
+ {/if} {#if baseColorPickerOpen}
{ baseColor = { hex, label, collection }; baseColorPickerOpen = false; }} + onselect={(hex, label, collection) => { + baseColor = { hex, label, collection }; + baseColorPickerOpen = false; + // Picking a hex retires the procedural. + baseMaterialXContent = ''; + baseMaterialXPath = ''; + }} onclose={() => { baseColorPickerOpen = false; }} />
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 7a80a5e..9004a4e 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -30,6 +30,8 @@ export function LogMessage(arg1:string,arg2:string):Promise; export function OpenFileDialog():Promise; +export function OpenMaterialXFile():Promise; + export function OpenStickerImage():Promise; export function ProcessPipeline(arg1:pipeline.Options):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ddab73a..cc2b001 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -54,6 +54,10 @@ export function OpenFileDialog() { return window['go']['main']['App']['OpenFileDialog'](); } +export function OpenMaterialXFile() { + return window['go']['main']['App']['OpenMaterialXFile'](); +} + export function OpenStickerImage() { return window['go']['main']['App']['OpenStickerImage'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index dfc3092..e70c30b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -123,6 +123,9 @@ export namespace main { nozzleDiameter: string; layerHeight: string; baseColor?: ColorSlotSetting; + baseMaterialXPath?: string; + baseMaterialXContent?: string; + baseMaterialXTileMM?: number; colorSlots: ColorSlotSetting[]; inventoryCollection: string; brightness: number; @@ -162,6 +165,9 @@ export namespace main { this.nozzleDiameter = source["nozzleDiameter"]; this.layerHeight = source["layerHeight"]; this.baseColor = this.convertValues(source["baseColor"], ColorSlotSetting); + this.baseMaterialXPath = source["baseMaterialXPath"]; + this.baseMaterialXContent = source["baseMaterialXContent"]; + this.baseMaterialXTileMM = source["baseMaterialXTileMM"]; this.colorSlots = this.convertValues(source["colorSlots"], ColorSlotSetting); this.inventoryCollection = source["inventoryCollection"]; this.brightness = source["brightness"]; @@ -237,6 +243,20 @@ export namespace main { return a; } } + export class MaterialXOpenResult { + path: string; + content: string; + + static createFrom(source: any = {}) { + return new MaterialXOpenResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.content = source["content"]; + } + } export class NozzleOption { diameter: string; layerHeights: number[]; @@ -369,6 +389,8 @@ export namespace pipeline { Scale: number; Output: string; BaseColor: string; + BaseColorMaterialX: string; + BaseColorMaterialXTileMM: number; NozzleDiameter: number; LayerHeight: number; Printer: string; @@ -406,6 +428,8 @@ export namespace pipeline { this.Scale = source["Scale"]; this.Output = source["Output"]; this.BaseColor = source["BaseColor"]; + this.BaseColorMaterialX = source["BaseColorMaterialX"]; + this.BaseColorMaterialXTileMM = source["BaseColorMaterialXTileMM"]; this.NozzleDiameter = source["NozzleDiameter"]; this.LayerHeight = source["LayerHeight"]; this.Printer = source["Printer"]; diff --git a/internal/pipeline/loadoutput_persist.go b/internal/pipeline/loadoutput_persist.go index b537804..87d36ac 100644 --- a/internal/pipeline/loadoutput_persist.go +++ b/internal/pipeline/loadoutput_persist.go @@ -38,14 +38,15 @@ type loadOutputOnDisk struct { InputMesh *MeshData PreviewScale float32 ExtentMM float32 - // appliedBaseColor is intentionally not persisted: cache.setLoad - // is called inside runLoad (before applyBaseColor runs), so the - // disk version's "applied" state is always pristine. On disk hit - // applyBaseColor sees appliedBaseColor=="" and skips the reset- - // from-parse path (since lo.ColorModel is already pristine); - // only an in-session BaseColor change after lo's been mutated - // triggers a reset, and that path is satisfied by the in-memory - // parse cache. + // The applied-base-color triple (appliedBaseColor, + // appliedBaseColorMaterialX, appliedBaseColorMaterialXTileMM) is + // intentionally not persisted: cache.setLoad is called inside + // runLoad (before applyBaseColor runs), so the disk version's + // "applied" state is always pristine. On disk hit applyBaseColor + // sees the pristine triple and skips the reset-from-parse path + // (since lo.ColorModel is already pristine); only an in-session + // base-color change after lo's been mutated triggers a reset, and + // that path is satisfied by the in-memory parse cache. } func (lo *loadOutput) GobEncode() ([]byte, error) { diff --git a/internal/pipeline/materialx_override.go b/internal/pipeline/materialx_override.go new file mode 100644 index 0000000..0d9011f --- /dev/null +++ b/internal/pipeline/materialx_override.go @@ -0,0 +1,89 @@ +package pipeline + +import ( + "fmt" + "log" + + "github.com/rtwfroody/ditherforge/internal/materialx" + "github.com/rtwfroody/ditherforge/internal/voxel" +) + +// materialxOverride adapts a materialx.Sampler to voxel.BaseColorOverride, +// scaling input mm-positions into shading-unit positions before sampling +// and clamping the resulting RGB into 8-bit range. Reentrant — Sampler +// guarantees concurrent-safe Sample calls. +type materialxOverride struct { + sampler materialx.Sampler + invTileMM float64 +} + +// SampleBaseColor implements voxel.BaseColorOverride. +func (m *materialxOverride) SampleBaseColor(p [3]float32) [3]uint8 { + pos := [3]float64{ + float64(p[0]) * m.invTileMM, + float64(p[1]) * m.invTileMM, + float64(p[2]) * m.invTileMM, + } + rgb := m.sampler.Sample(pos) + return [3]uint8{ + floatToByte(rgb[0]), + floatToByte(rgb[1]), + floatToByte(rgb[2]), + } +} + +func floatToByte(f float64) uint8 { + // NaN propagates through arithmetic and fails both comparison + // branches below; uint8(NaN) is implementation-defined in Go. Pin + // it to 0 so a malformed graph evaluates to black instead of + // random per-voxel garbage. + if f != f { + return 0 + } + v := f*255 + 0.5 + if v <= 0 { + return 0 + } + if v >= 255 { + return 255 + } + return uint8(v) +} + +// buildBaseColorOverride parses a MaterialX document and returns an +// override that samples its first material's base_color graph. tileMM +// converts world-space mm positions into the procedural's shading +// frame; values <= 0 are treated as 1 mm (no scaling). Returns +// (nil, nil) when content is empty so callers can pass the result +// straight through to the voxelizer. +func buildBaseColorOverride(content string, tileMM float64) (voxel.BaseColorOverride, error) { + if content == "" { + return nil, nil + } + doc, err := materialx.ParseBytes([]byte(content)) + if err != nil { + return nil, fmt.Errorf("parse MaterialX: %w", err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + return nil, fmt.Errorf("MaterialX base color: %w", err) + } + if tileMM <= 0 { + tileMM = 1 + } + return &materialxOverride{sampler: s, invTileMM: 1 / tileMM}, nil +} + +// safeBaseColorOverride wraps buildBaseColorOverride in a logging helper +// for the run path: a malformed .mtlx is reported as a warning and the +// pipeline proceeds without the override (mirroring how applyBaseColor +// handles invalid hex colors). Returns nil on any error so the +// downstream voxel sampler falls back to per-face base colors. +func safeBaseColorOverride(content string, tileMM float64) voxel.BaseColorOverride { + o, err := buildBaseColorOverride(content, tileMM) + if err != nil { + log.Printf("Warning: ignoring MaterialX base color: %v", err) + return nil + } + return o +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 4dbbd5c..884ddf5 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -32,6 +32,17 @@ type Options struct { Scale float32 Output string BaseColor string // hex color for untextured faces (e.g. "#FF0000"); empty = use model default + // BaseColorMaterialX is the contents of a .mtlx file applied to + // untextured faces as a procedural base color. When non-empty it + // takes precedence over BaseColor. Stored as content (not path) so + // the cache key reflects the actual graph definition; cheap because + // procedural .mtlx files are KB-scale text. + BaseColorMaterialX string + // BaseColorMaterialXTileMM scales positions before sampling the + // MaterialX graph: a value of 10 means one shading-unit cycle of + // the procedural maps to 10 mm of object space. Zero or negative + // is treated as 1 mm (i.e. raw position). + BaseColorMaterialXTileMM float64 NozzleDiameter float32 LayerHeight float32 // Printer is the printer profile ID (e.g. "snapmaker_u1") used when @@ -478,34 +489,37 @@ func applyBaseColorOverride(model *loader.LoadedModel, hexColor string) { } // applyBaseColor resets lo.ColorModel / lo.SampleModel FaceBaseColor from the -// pristine parse output and reapplies opts.BaseColor, then rebuilds -// lo.InputMesh so the preview reflects the new colors. Idempotent — a no-op -// when lo.appliedBaseColor already matches opts.BaseColor. +// pristine parse output and reapplies the active base-color override, then +// rebuilds lo.InputMesh so the preview reflects the new colors. Idempotent — +// a no-op when the applied state already matches opts. +// +// Two override sources are supported, mutually exclusive at apply time: +// - opts.BaseColorMaterialX (with TileMM): per-face centroid sample of the +// procedural graph, baked into FaceBaseColor for the preview. The +// voxelizer also samples per-voxel via the same graph for higher +// fidelity (see buildBaseColorOverride). MaterialX takes precedence +// when both are set. +// - opts.BaseColor: legacy uniform hex override. // // This intentionally violates the cache's "outputs are immutable after set" // contract for loadOutput: ColorModel.FaceBaseColor and SampleModel.FaceBaseColor // are mutated in place every run. Safe today because (a) the pipeline runs // single-threaded under app.pipelineWorker, so no other reader is active when -// this runs; (b) BaseColor is excluded from loadSettings, so multiple cached -// loadOutput entries don't exist for the same load key with different colors. +// this runs; (b) the base-color settings are excluded from loadSettings, so +// multiple cached loadOutput entries don't exist for the same load key with +// different colors. // // Invariant: whenever lo is present, parse output is reachable via -// cache.getParse — but only when lo.appliedBaseColor != "" do we actually -// fetch it (the empty-string case means lo is already pristine and we just -// need to apply the override on top). +// cache.getParse — but only when an override was previously applied do we +// actually fetch it (the pristine case skips the parse cache lookup). func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { - if lo.appliedBaseColor == opts.BaseColor { + if lo.appliedBaseColor == opts.BaseColor && + lo.appliedBaseColorMaterialX == opts.BaseColorMaterialX && + lo.appliedBaseColorMaterialXTileMM == opts.BaseColorMaterialXTileMM { return } - // When lo.appliedBaseColor is empty, lo.ColorModel.FaceBaseColor - // is already pristine (no override has been applied to this - // instance yet — runLoad doesn't apply base color, and a - // disk-cached lo always arrives with appliedBaseColor="" - // because cache.setLoad runs before applyBaseColor mutates lo). - // In that case we can skip the reset-from-parse step entirely, - // which avoids touching the StageParse cache on a warm-cache - // restart and saves a multi-second disk read for big inputs. - if lo.appliedBaseColor != "" { + pristine := lo.appliedBaseColor == "" && lo.appliedBaseColorMaterialX == "" + if !pristine { raw := cache.getParse(opts) if raw == nil { panic("applyBaseColor: parse output missing but load cache mutated") @@ -515,7 +529,21 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { copy(lo.SampleModel.FaceBaseColor, raw.FaceBaseColor) } } - if opts.BaseColor != "" { + switch { + case opts.BaseColorMaterialX != "": + // Build the override once and reuse it across both models — parsing + // the .mtlx and validating the graph is cheap but non-trivial, and + // run.go also builds its own override for per-voxel sampling. + override, err := buildBaseColorOverride(opts.BaseColorMaterialX, opts.BaseColorMaterialXTileMM) + if err != nil { + log.Printf("Warning: ignoring MaterialX base color: %v", err) + } else if override != nil { + bakeMaterialXBaseColor(lo.ColorModel, override) + if lo.SampleModel != lo.ColorModel { + bakeMaterialXBaseColor(lo.SampleModel, override) + } + } + case opts.BaseColor != "": applyBaseColorOverride(lo.ColorModel, opts.BaseColor) if lo.SampleModel != lo.ColorModel { applyBaseColorOverride(lo.SampleModel, opts.BaseColor) @@ -523,6 +551,32 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { } lo.InputMesh = buildInputMeshData(lo.ColorModel) lo.appliedBaseColor = opts.BaseColor + lo.appliedBaseColorMaterialX = opts.BaseColorMaterialX + lo.appliedBaseColorMaterialXTileMM = opts.BaseColorMaterialXTileMM +} + +// bakeMaterialXBaseColor evaluates the procedural at every untextured +// face's centroid (in original-mesh coords) and writes the result into +// model.FaceBaseColor. Mirrors applyBaseColorOverride's NoTextureMask +// gating. Centroid sampling is a preview-fidelity approximation; the +// voxelizer separately samples per-voxel for the actual print colors. +func bakeMaterialXBaseColor(model *loader.LoadedModel, override voxel.BaseColorOverride) { + for i := range model.FaceBaseColor { + if model.NoTextureMask != nil && !model.NoTextureMask[i] { + continue + } + f := model.Faces[i] + v0 := model.Vertices[f[0]] + v1 := model.Vertices[f[1]] + v2 := model.Vertices[f[2]] + c := [3]float32{ + (v0[0] + v1[0] + v2[0]) / 3, + (v0[1] + v1[1] + v2[1]) / 3, + (v0[2] + v1[2] + v2[2]) / 3, + } + rgb := override.SampleBaseColor(c) + model.FaceBaseColor[i] = [4]uint8{rgb[0], rgb[1], rgb[2], 255} + } } // floodFillTwoGrids runs flood fill separately for each (Grid, diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index 23b6c63..b5d0612 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -509,9 +509,11 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) { } } + baseColorOverride := safeBaseColorOverride(r.opts.BaseColorMaterialX, r.opts.BaseColorMaterialXTileMM) result, verr := squarevoxel.VoxelizeTwoGrids(r.ctx, lo.Model, sampleModel, stickerModel, stickerSI, - layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo) + layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo, + baseColorOverride) if verr != nil { return nil, fmt.Errorf("voxelize: %w", verr) } diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go index 00c56e7..b307b29 100644 --- a/internal/pipeline/stepcache.go +++ b/internal/pipeline/stepcache.go @@ -379,12 +379,15 @@ type loadOutput struct { InputMesh *MeshData PreviewScale float32 // scale factor to convert pipeline coords back to preview coords ExtentMM float32 // native max bounding-box extent in mm (scale=1.0, size=unset) - // appliedBaseColor tracks the base color currently applied to ColorModel / - // SampleModel FaceBaseColor slices. Empty string means pristine (no - // override currently applied). applyBaseColor() resets from raw and - // re-applies when this diverges from opts.BaseColor, so - // load/decimate/sticker caches survive color changes. - appliedBaseColor string + // appliedBaseColor / appliedBaseColorMaterialX / appliedBaseColorMaterialXTileMM + // track the base-color override currently baked into ColorModel / + // SampleModel FaceBaseColor. The triple is the cache key for the + // in-place mutation: when any field diverges from the corresponding + // opts.* value, applyBaseColor resets from the parse cache and re-bakes. + // All three empty/zero means pristine. + appliedBaseColor string + appliedBaseColorMaterialX string + appliedBaseColorMaterialXTileMM float64 } type voxelizeOutput struct { @@ -573,16 +576,21 @@ type loadSettings struct { // because runSticker deep-clones ColorModel into so.Model and the per-run // reapply step does not patch that scratch copy. type voxelizeSettings struct { - NozzleDiameter float32 - LayerHeight float32 - BaseColor string + NozzleDiameter float32 + LayerHeight float32 + BaseColor string + BaseColorMaterialX string + BaseColorMaterialXTileMM float64 } type stickerSettings struct { Stickers []Sticker - // BaseColor is included so a base-color change invalidates the sticker - // stage. See voxelizeSettings doc above for the reason. - BaseColor string + // BaseColor / BaseColorMaterialX / BaseColorMaterialXTileMM are + // included so any base-color change invalidates the sticker stage. + // See voxelizeSettings doc above for the reason. + BaseColor string + BaseColorMaterialX string + BaseColorMaterialXTileMM float64 // AlphaWrap toggling changes the sticker substrate (wrap mesh vs. // original mesh), so decals built for one substrate are invalid when // the toggle changes. AlphaWrapAlpha and AlphaWrapOffset live in @@ -690,12 +698,20 @@ func (c *StageCache) settingsForStage(stage StageID, opts Options) any { return s case StageVoxelize: return voxelizeSettings{ - NozzleDiameter: opts.NozzleDiameter, - LayerHeight: opts.LayerHeight, - BaseColor: opts.BaseColor, + NozzleDiameter: opts.NozzleDiameter, + LayerHeight: opts.LayerHeight, + BaseColor: opts.BaseColor, + BaseColorMaterialX: opts.BaseColorMaterialX, + BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, } case StageSticker: - return stickerSettings{Stickers: opts.Stickers, BaseColor: opts.BaseColor, AlphaWrap: opts.AlphaWrap} + return stickerSettings{ + Stickers: opts.Stickers, + BaseColor: opts.BaseColor, + BaseColorMaterialX: opts.BaseColorMaterialX, + BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, + AlphaWrap: opts.AlphaWrap, + } case StageColorAdjust: return colorAdjustSettings{Brightness: opts.Brightness, Contrast: opts.Contrast, Saturation: opts.Saturation} case StageColorWarp: @@ -759,8 +775,12 @@ func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 { writeFloat32(h, v.NozzleDiameter) writeFloat32(h, v.LayerHeight) writeString(h, v.BaseColor) + writeString(h, v.BaseColorMaterialX) + writeFloat64(h, v.BaseColorMaterialXTileMM) case stickerSettings: writeString(h, v.BaseColor) + writeString(h, v.BaseColorMaterialX) + writeFloat64(h, v.BaseColorMaterialXTileMM) writeBool(h, v.AlphaWrap) writeInt(h, len(v.Stickers)) for _, s := range v.Stickers { diff --git a/internal/pipeline/stepcache_test.go b/internal/pipeline/stepcache_test.go index dc7929d..ff777b6 100644 --- a/internal/pipeline/stepcache_test.go +++ b/internal/pipeline/stepcache_test.go @@ -56,3 +56,79 @@ func TestVoxelizeStageKeyDependsOnBaseColor(t *testing.T) { t.Fatal("StageVoxelize key did not change when BaseColor changed") } } + +// TestVoxelizeStageKeyDependsOnMaterialX guards the same invalidation +// contract for the procedural base-color override: changing the .mtlx +// content or its tile size must invalidate the voxelize cache because +// the per-voxel sampler reads them. +func TestVoxelizeStageKeyDependsOnMaterialX(t *testing.T) { + c := NewStageCache() + base := Options{ + Input: "model.glb", + BaseColorMaterialX: "", + BaseColorMaterialXTileMM: 10, + } + contentChanged := base + contentChanged.BaseColorMaterialX = "" + tileChanged := base + tileChanged.BaseColorMaterialXTileMM = 20 + + if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, contentChanged) { + t.Error("StageVoxelize key did not change when BaseColorMaterialX content changed") + } + if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, tileChanged) { + t.Error("StageVoxelize key did not change when BaseColorMaterialXTileMM changed") + } +} + +// TestStickerStageKeyDependsOnMaterialX is the sticker-stage analogue +// of TestStickerStageKeyDependsOnBaseColor for the procedural override. +// runSticker deep-clones lo.ColorModel into so.Model with whatever +// procedural pattern was baked into FaceBaseColor by the per-face +// preview bake; changing the .mtlx must invalidate that cached clone. +func TestStickerStageKeyDependsOnMaterialX(t *testing.T) { + c := NewStageCache() + base := Options{ + Input: "model.glb", + BaseColorMaterialX: "", + BaseColorMaterialXTileMM: 10, + Stickers: []Sticker{ + {ImagePath: "sticker.png", Mode: "unfold", Scale: 1, MaxAngle: 90}, + }, + } + contentChanged := base + contentChanged.BaseColorMaterialX = "" + tileChanged := base + tileChanged.BaseColorMaterialXTileMM = 20 + + if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, contentChanged) { + t.Error("StageSticker key did not change when BaseColorMaterialX content changed; " + + "so.Model.FaceBaseColor would be stale on a cached run") + } + if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, tileChanged) { + t.Error("StageSticker key did not change when BaseColorMaterialXTileMM changed") + } +} + +// TestLoadAndDecimateStageKeysIndependentOfMaterialX mirrors the design +// intent that the per-run applyBaseColor patches caches in place — load +// and decimate must survive .mtlx changes the same way they survive hex +// changes. +func TestLoadAndDecimateStageKeysIndependentOfMaterialX(t *testing.T) { + c := NewStageCache() + base := Options{ + Input: "model.glb", + BaseColorMaterialX: "", + BaseColorMaterialXTileMM: 10, + } + changed := base + changed.BaseColorMaterialX = "" + changed.BaseColorMaterialXTileMM = 20 + + if c.stageFnv(StageLoad, base) != c.stageFnv(StageLoad, changed) { + t.Error("StageLoad key changed on MaterialX change; load cache should survive") + } + if c.stageFnv(StageDecimate, base) != c.stageFnv(StageDecimate, changed) { + t.Error("StageDecimate key changed on MaterialX change; decimate cache should survive") + } +} diff --git a/internal/squarevoxel/split_test.go b/internal/squarevoxel/split_test.go index 16212ed..20fc425 100644 --- a/internal/squarevoxel/split_test.go +++ b/internal/squarevoxel/split_test.go @@ -71,6 +71,7 @@ func TestVoxelize_SplitInfoNilUnchanged(t *testing.T) { progress.NullTracker{}, nil, nil, + nil, ) if err != nil { t.Fatalf("VoxelizeTwoGrids: %v", err) @@ -118,6 +119,7 @@ func TestVoxelize_SplitInfoTagsHalves(t *testing.T) { progress.NullTracker{}, nil, splitInfo, + nil, ) if err != nil { t.Fatalf("VoxelizeTwoGrids: %v", err) @@ -179,6 +181,7 @@ func TestVoxelize_SplitInfoInverseTransformDistinctHalves(t *testing.T) { progress.NullTracker{}, nil, splitInfo, + nil, ) if err != nil { t.Fatalf("VoxelizeTwoGrids: %v", err) @@ -259,6 +262,7 @@ func TestVoxelize_SplitInfoNonIdentityRotation(t *testing.T) { progress.NullTracker{}, nil, splitInfo, + nil, ) if err != nil { t.Fatalf("VoxelizeTwoGrids: %v", err) @@ -302,6 +306,7 @@ func TestVoxelize_SplitInfoRequiresColorModel(t *testing.T) { progress.NullTracker{}, nil, splitInfo, + nil, ) if err == nil { t.Fatal("expected error when split path runs without colorModel") @@ -324,6 +329,7 @@ func TestVoxelize_SplitInfoEmptyHalfRejected(t *testing.T) { progress.NullTracker{}, nil, splitInfo, + nil, ) if err == nil { t.Fatal("expected error when split half is empty") diff --git a/internal/squarevoxel/squarevoxel.go b/internal/squarevoxel/squarevoxel.go index 747b9f9..ec2eabd 100644 --- a/internal/squarevoxel/squarevoxel.go +++ b/internal/squarevoxel/squarevoxel.go @@ -142,6 +142,7 @@ func colorCells( decals []*voxel.StickerDecal, halfIdx uint8, invXform split.Transform, + baseColorOverride voxel.BaseColorOverride, ) ([]voxel.ActiveCell, error) { colorRadius := p.CellSize * 3 cellKeys := make([]voxel.CellKey, 0, len(cellSet)) @@ -199,11 +200,13 @@ func colorCells( rgba = voxel.SampleNearestColorWithSticker( samplePos, colorModel, si, colorRadius, buf, decals, - stickerModel, stickerSI, stickerBuf) + stickerModel, stickerSI, stickerBuf, + baseColorOverride) } else { rgba = voxel.SampleNearestColor( samplePos, - colorModel, si, colorRadius, buf, decals) + colorModel, si, colorRadius, buf, decals, + baseColorOverride) } if rgba[3] < 128 { continue @@ -263,6 +266,7 @@ func VoxelizeTwoGrids( tracker progress.Tracker, decals []*voxel.StickerDecal, splitInfo *SplitInfo, + baseColorOverride voxel.BaseColorOverride, ) (*TwoGridResult, error) { // Decide the geometry meshes and per-mesh inverse transforms. // Unsplit path (splitInfo == nil) takes the single `model` @@ -403,7 +407,7 @@ func VoxelizeTwoGrids( for i, e := range entries { cells0, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI, perMesh[i].layer0, p0, tracker, &counter, decals, - e.halfIdx, e.invXform) + e.halfIdx, e.invXform, baseColorOverride) if err != nil { return nil, err } @@ -411,7 +415,7 @@ func VoxelizeTwoGrids( if nLayers > 1 { cells1, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI, perMesh[i].upper, p1, tracker, &counter, decals, - e.halfIdx, e.invXform) + e.halfIdx, e.invXform, baseColorOverride) if err != nil { return nil, err } @@ -488,7 +492,7 @@ func Voxelize(ctx context.Context, model, colorModel *loader.LoadedModel, cellSi tColor := time.Now() tracker.StageStart("Coloring cells", true, len(cellSet)) var counter atomic.Int64 - cells, err := colorCells(ctx, model, si, nil, nil, cellSet, p, tracker, &counter, decals, 0, split.IdentityTransform) + cells, err := colorCells(ctx, model, si, nil, nil, cellSet, p, tracker, &counter, decals, 0, split.IdentityTransform, nil) if err != nil { return nil, nil, [3]float32{}, err } diff --git a/internal/voxel/color.go b/internal/voxel/color.go index ad6ee8c..79945dc 100644 --- a/internal/voxel/color.go +++ b/internal/voxel/color.go @@ -268,6 +268,16 @@ func FaceAlpha(faceIdx int, model *loader.LoadedModel) uint8 { return uint8(ClampF(a+0.5, 0, 255)) } +// BaseColorOverride supplies a procedural replacement for an +// untextured face's base color, evaluated at the sample's 3D position. +// p is in the same coord frame Sample* receives — i.e. the original +// (pre-split) mesh frame. Implementations must be safe to call from +// many goroutines concurrently. Returns RGB only; alpha continues to +// come from the model's per-face material. +type BaseColorOverride interface { + SampleBaseColor(p [3]float32) [3]uint8 +} + // SampleNearestColor finds the closest surface point to p on `model`, then // samples the texture color and alpha there. If decals are provided, // sticker textures are composited over the base color. Returns RGBA. @@ -275,8 +285,8 @@ func FaceAlpha(faceIdx int, model *loader.LoadedModel) uint8 { // When stickers live on a *different* mesh than the base color sample // model (alpha-wrap mode: original mesh carries texture/UV, wrap mesh // carries decals), call SampleNearestColorWithSticker instead. -func SampleNearestColor(p [3]float32, model *loader.LoadedModel, si *SpatialIndex, radius float32, buf *SearchBuf, decals []*StickerDecal) [4]uint8 { - return SampleNearestColorWithSticker(p, model, si, radius, buf, decals, nil, nil, nil) +func SampleNearestColor(p [3]float32, model *loader.LoadedModel, si *SpatialIndex, radius float32, buf *SearchBuf, decals []*StickerDecal, override BaseColorOverride) [4]uint8 { + return SampleNearestColorWithSticker(p, model, si, radius, buf, decals, nil, nil, nil, override) } // SampleNearestColorWithSticker is the two-mesh form of SampleNearestColor. @@ -287,11 +297,17 @@ func SampleNearestColor(p [3]float32, model *loader.LoadedModel, si *SpatialInde // is performed and decals are composited based on that result. stickerBuf // must be a separate SearchBuf sized for stickerModel; passing nil reuses // `buf` (safe because the two lookups don't overlap in time). +// +// override (optional) replaces the per-face base color with a +// procedurally sampled RGB at p, but only for untextured faces — when +// the nearest face has a usable texture, the texture wins as usual. +// Pass nil for the legacy behavior (per-face FaceBaseColor only). func SampleNearestColorWithSticker( p [3]float32, model *loader.LoadedModel, si *SpatialIndex, radius float32, buf *SearchBuf, decals []*StickerDecal, stickerModel *loader.LoadedModel, stickerSI *SpatialIndex, stickerBuf *SearchBuf, + override BaseColorOverride, ) [4]uint8 { cands := si.CandidatesRadiusZ(p[0], p[1], radius, p[2], radius, buf) bestDistSq := float32(math.MaxFloat32) @@ -313,6 +329,10 @@ func SampleNearestColorWithSticker( } matAlpha, bc, texIdx := faceMaterial(int(bestTri), model) + if override != nil && (texIdx < 0 || int(texIdx) >= len(model.Textures)) { + rgb := override.SampleBaseColor(p) + bc[0], bc[1], bc[2] = rgb[0], rgb[1], rgb[2] + } f := model.Faces[bestTri] bary := [3]float32{1 - bestS - bestT, bestS, bestT} From 5527ba6aa71aaf362341890a18538d06b46f6006 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 17:15:36 -0700 Subject: [PATCH 03/13] materialx: match reference fractal3d/perlin scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two delta versus the official MaterialX GLSL implementation (libraries/stdlib/genglsl/lib/mx_noise.glsl): * fractal3D no longer normalizes by sum-of-amplitudes. With octaves=3/diminish=0.5 our output was 1.75x too small, which made the noise term in standard_surface_marble_solid (3 * fractal3d) unable to overpower the linear (x+y+z) carrier — visible as arrow-straight diagonal bands instead of organic veins. * perlin3D now multiplies by mx_gradient_scale3d=0.9820, the empirical scale factor MaterialX uses to compensate for non-unit gradient vectors. Brings single-octave Perlin output into the reference's numeric range. Hash function still differs (we use Ken Perlin's permutation table; the reference uses Bob Jenkins lookup3) so individual sample values won't bit-match, but pattern statistics and amplitude do. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/materialx/materialx_test.go | 6 ++--- internal/materialx/noise.go | 39 ++++++++++++++++------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/internal/materialx/materialx_test.go b/internal/materialx/materialx_test.go index ce0d27a..1823f68 100644 --- a/internal/materialx/materialx_test.go +++ b/internal/materialx/materialx_test.go @@ -362,9 +362,9 @@ func TestPerlinGoldenValues(t *testing.T) { want float64 }{ {0, 0, 0, 0}, - {0.5, 0.5, 0.5, -0.25}, - {0.25, 0.6, 0.1, -0.10208973162000007}, - {1.5, 2.5, 3.5, 0.125}, + {0.5, 0.5, 0.5, -0.2455}, + {0.25, 0.6, 0.1, -0.10025211645084006}, + {1.5, 2.5, 3.5, 0.12275}, } for _, tc := range cases { got := materialx.PerlinForTest(tc.x, tc.y, tc.z) diff --git a/internal/materialx/noise.go b/internal/materialx/noise.go index 7c28496..ee8a4a9 100644 --- a/internal/materialx/noise.go +++ b/internal/materialx/noise.go @@ -65,6 +65,14 @@ func grad3(hash int, x, y, z float64) float64 { return u + v } +// perlinGradientScale3d compensates for the fact that the gradient +// vectors used by grad3 (cube-edge directions) aren't unit-length, so +// raw Perlin output peaks slightly above 1. The constant matches +// MaterialX's mx_gradient_scale3d so single-octave Perlin output +// across our evaluator and the reference GLSL implementation stays in +// the same numeric range. +const perlinGradientScale3d = 0.9820 + // perlin3D returns a value in approximately [-1, 1]. func perlin3D(x, y, z float64) float64 { fx := math.Floor(x) @@ -85,40 +93,39 @@ func perlin3D(x, y, z float64) float64 { B := perm[X+1] + Y BA := perm[B] + Z BB := perm[B+1] + Z - return lerp(w, + r := lerp(w, lerp(v, lerp(u, grad3(perm[AA], x, y, z), grad3(perm[BA], x-1, y, z)), lerp(u, grad3(perm[AB], x, y-1, z), grad3(perm[BB], x-1, y-1, z))), lerp(v, lerp(u, grad3(perm[AA+1], x, y, z-1), grad3(perm[BA+1], x-1, y, z-1)), lerp(u, grad3(perm[AB+1], x, y-1, z-1), grad3(perm[BB+1], x-1, y-1, z-1)))) + return r * perlinGradientScale3d } -// fractal3D returns a fractal Brownian motion sum of Perlin noises. -// -// The MaterialX 1.39 fractal3d spec only states the output is -// "approximately [-1, 1]" without nailing down the exact accumulation -// formula. We sum amp * perlin3D(p * freq) per octave (with freq *= -// lacunarity, amp *= diminish) and divide by the sum of amplitudes — -// this keeps the result inside [-1, 1] regardless of octave count, -// which is what most reference implementations (Houdini's mtlx -// fractal3d, Arnold's standard_surface marble) do but is not mandated -// by spec. Single-octave output is identical to perlin3D. +// fractal3D returns a fractal Brownian motion sum of Perlin noises, +// matching the reference MaterialX implementation +// (libraries/stdlib/genglsl/lib/mx_noise.glsl, mx_fractal3d_noise_float): +// raw amplitude-weighted sum without normalization. With diminish=0.5 +// and octaves=3 the output ranges roughly [-1.75, 1.75]; the spec +// only commits to "approximately [-1, 1]" but every reference +// implementation we've checked (GLSL/OSL/MDL gen libraries) skips the +// normalize step. Normalizing here makes the noise term in +// downstream graphs (e.g. standard_surface_marble_solid.mtlx, where +// scale_noise = 3 * fractal3d competes with a linear position carrier +// term) under-amplitude relative to the reference, which produces +// visibly straighter bands. func fractal3D(x, y, z float64, octaves int, lacunarity, diminish float64) float64 { if octaves < 1 { octaves = 1 } - var sum, ampSum float64 + var sum float64 amp := 1.0 freq := 1.0 for i := 0; i < octaves; i++ { sum += amp * perlin3D(x*freq, y*freq, z*freq) - ampSum += amp freq *= lacunarity amp *= diminish } - if ampSum > 0 { - sum /= ampSum - } return sum } From 5799c2c5789aab7c5da95e7560559df7b94ab640 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 18:07:56 -0700 Subject: [PATCH 04/13] materialx: image, texcoord, extract nodes + zip/dir resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the parser-and-evaluator half of image-backed MaterialX support (Quixel/AmbientCG-style PBR packs). Self-contained — no pipeline plumbing changes yet; the existing per-voxel hot path is unaffected. * SampleContext (Pos/UV/Normal) replaces the legacy pos-only entry point on Sampler. Sample(pos) is preserved as a thin wrapper. * Per-call context+scratch are pooled together so SampleContext doesn't escape when its address flows into closures (zero allocs per Sample retained). * New nodes: image (PNG/JPEG via stdlib, periodic/clamp/mirror addressing, nearest/linear filtering, sRGB pixels kept un-linearized to match downstream sRGB quantization), texcoord (reads UV from context), extract (one component of a vector). multiply already generalized to vec2 in a prior commit just by widening arity. * ResourceResolver interface + dirResolver, zipResolver, mapResolver. ParsePackage detects .mtlx vs .zip; ParseFile auto-installs a dir resolver rooted at the file's directory; ParseBytes leaves the resolver nil (image graphs error at sampler construction time). * Parser tolerates unknown-typed inputs by stashing the raw string — surface shaders carry many we don't consume (boolean thin_walled, matrix44 etc.); failing the whole parse on those was wrong. Verified end-to-end against the real cobblestone .zip (TH_Pink_Cobblestone_Floor_2k_8b_3h0n66y.zip) — loads, samples, returns plausible per-UV colors. In-package tests cover image sampling, addressing modes, UV scaling, zip loading, and the no- resolver error path. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/materialx/eval.go | 325 ++++++++++++++++++++------- internal/materialx/image.go | 228 +++++++++++++++++++ internal/materialx/materialx_test.go | 235 +++++++++++++++++++ internal/materialx/parse.go | 33 ++- internal/materialx/resolver.go | 192 ++++++++++++++++ internal/materialx/types.go | 51 +++-- 6 files changed, 962 insertions(+), 102 deletions(-) create mode 100644 internal/materialx/image.go create mode 100644 internal/materialx/resolver.go diff --git a/internal/materialx/eval.go b/internal/materialx/eval.go index 5f44b26..45e6ecf 100644 --- a/internal/materialx/eval.go +++ b/internal/materialx/eval.go @@ -8,13 +8,26 @@ import ( "sync" ) +// SampleContext bundles the per-sample inputs an evaluator may read. +// Procedural graphs only consume Pos; image-backed graphs additionally +// read UV (via the texcoord node) and Normal (the consumer typically +// uses this to drive triplanar projection in a wrapper, not inside the +// evaluator). Construct via Sampler.Sample for the legacy Pos-only +// path or Sampler.SampleAt for full control. +type SampleContext struct { + Pos [3]float64 + UV [2]float64 + Normal [3]float64 +} + // Sampler returns an RGB color (each channel typically in [0, 1], but -// not clamped — the consumer decides how to handle out-of-gamut values) -// given an object-space sample position. Compiled samplers are -// reentrant: calling Sample concurrently from multiple goroutines is -// safe because each call uses its own scratch slot table. +// not clamped — the consumer decides how to handle out-of-gamut values). +// Compiled samplers are reentrant: calling Sample/SampleAt concurrently +// from multiple goroutines is safe because each call uses its own +// scratch slot table. type Sampler interface { Sample(pos [3]float64) [3]float64 + SampleAt(ctx SampleContext) [3]float64 } // MaterialNames returns the material names defined by the document, in @@ -76,14 +89,15 @@ func (d *Document) samplerFromInput(in *input) (Sampler, error) { if !ok { return nil, fmt.Errorf("materialx: nodegraph %q has no output %q", ng.Name, outName) } - return compileGraph(ng, out) + return compileGraph(ng, out, d.Resolver) } return nil, fmt.Errorf("materialx: input %q has no usable source", in.Name) } type constSampler [3]float64 -func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } +func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } +func (c constSampler) SampleAt(_ SampleContext) [3]float64 { return [3]float64(c) } // --- compiled graph evaluator --- // @@ -94,7 +108,7 @@ func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } // up to slotMax entries, with a heap fallback for larger graphs. There // are no maps, no string lookups, and no allocations on the hot path. -type evalFn func(pos [3]float64, scratch []Value) Value +type evalFn func(ctx *SampleContext, scratch []Value) Value type compiledGraph struct { nSlots int @@ -107,34 +121,52 @@ type compileStep struct { fn evalFn } -// slotMax sets the pooled scratch capacity. Real-world procedural -// graphs (marble: 14 nodes; brick/wood: ~30) fit comfortably; larger -// graphs fall through to a per-call allocation. The closure call sites -// cause escape analysis to give up on a stack-allocated array, so -// pooling delivers the zero-alloc fast path while preserving reentrancy. +// slotMax sets the pooled scratch capacity. Real-world graphs (marble: +// 14 nodes; brick/wood: ~30; PBR pack with image+texcoord+multiply: +// ~10) fit comfortably; larger graphs fall through to a per-call +// allocation. The closure call sites cause escape analysis to give up +// on a stack-allocated array, so pooling delivers the zero-alloc fast +// path while preserving reentrancy. const slotMax = 64 +// sampleScratch bundles the per-call context and slot table into a +// single pool-managed struct. Pooling both together keeps SampleContext +// from escaping to the heap when its address is passed to closures — +// the pointer points into a pool entry, not a stack-local that +// outlives the call. +type sampleScratch struct { + ctx SampleContext + scratch []Value +} + var scratchPool = sync.Pool{ New: func() any { - s := make([]Value, slotMax) - return &s + return &sampleScratch{scratch: make([]Value, slotMax)} }, } func (g *compiledGraph) Sample(pos [3]float64) [3]float64 { + return g.SampleAt(SampleContext{Pos: pos}) +} + +func (g *compiledGraph) SampleAt(ctx SampleContext) [3]float64 { if g.nSlots > slotMax { - scratch := make([]Value, g.nSlots) - return g.run(pos, scratch) - } - p := scratchPool.Get().(*[]Value) - out := g.run(pos, (*p)[:g.nSlots]) - scratchPool.Put(p) + s := &sampleScratch{ctx: ctx, scratch: make([]Value, g.nSlots)} + return g.run(s) + } + s := scratchPool.Get().(*sampleScratch) + s.ctx = ctx + out := g.run(s) + // Zero the context so the next pool consumer doesn't inherit it. + s.ctx = SampleContext{} + scratchPool.Put(s) return out } -func (g *compiledGraph) run(pos [3]float64, scratch []Value) [3]float64 { +func (g *compiledGraph) run(s *sampleScratch) [3]float64 { + scratch := s.scratch[:g.nSlots] for _, st := range g.steps { - scratch[st.slot] = st.fn(pos, scratch) + scratch[st.slot] = st.fn(&s.ctx, scratch) } return scratch[g.outSlot].AsVec3() } @@ -143,16 +175,19 @@ func (g *compiledGraph) run(pos [3]float64, scratch []Value) [3]float64 { // reachable node into an ordered list of evalFn closures. Returns an // error if any node references an unknown type, missing input, or // unsupported attribute value — eager validation is exhaustive (every -// reachable node is compiled), unlike the prior approach of sampling -// once at the origin. -func compileGraph(ng *nodeGraph, out *graphOutput) (Sampler, error) { +// reachable node is compiled). When the graph references image files +// (image nodes), resolver must be non-nil; otherwise sampler +// construction fails with a clear error. +func compileGraph(ng *nodeGraph, out *graphOutput, resolver ResourceResolver) (Sampler, error) { if out.NodeName == "" { return nil, fmt.Errorf("materialx: nodegraph %q output %q has no nodename", ng.Name, out.Name) } c := &compiler{ - ng: ng, - slotOf: map[string]int{}, - visited: map[string]bool{}, + ng: ng, + slotOf: map[string]int{}, + visited: map[string]bool{}, + resolver: resolver, + images: newImageCache(), } outSlot, err := c.compileNode(out.NodeName) if err != nil { @@ -166,11 +201,13 @@ func compileGraph(ng *nodeGraph, out *graphOutput) (Sampler, error) { } type compiler struct { - ng *nodeGraph - slotOf map[string]int - visited map[string]bool // detects cycles - steps []compileStep - nSlots int + ng *nodeGraph + slotOf map[string]int + visited map[string]bool // detects cycles + steps []compileStep + nSlots int + resolver ResourceResolver + images *imageCache } func (c *compiler) compileNode(name string) (int, error) { @@ -214,17 +251,17 @@ func (c *compiler) compileInput(in *input) (evalFn, error) { if err != nil { return nil, fmt.Errorf("input %q: %w", in.Name, err) } - return func(_ [3]float64, scratch []Value) Value { return scratch[slot] }, nil + return func(_ *SampleContext, scratch []Value) Value { return scratch[slot] }, nil case in.InterfaceName != "": gi, ok := c.ng.inputsByName[in.InterfaceName] if !ok { return nil, fmt.Errorf("input %q references missing interface %q", in.Name, in.InterfaceName) } v := gi.Default - return func(_ [3]float64, _ []Value) Value { return v }, nil + return func(_ *SampleContext, _ []Value) Value { return v }, nil case in.Value != nil: v := *in.Value - return func(_ [3]float64, _ []Value) Value { return v }, nil + return func(_ *SampleContext, _ []Value) Value { return v }, nil } return nil, fmt.Errorf("input %q has no value", in.Name) } @@ -247,6 +284,20 @@ func (c *compiler) compileRequired(n *node, name string) (evalFn, error) { return c.compileInput(in) } +// stringInputOrDefault reads a literal string-typed input by name, +// returning def if the input is absent or has no raw value. Used by +// builders that consume enum-style attributes (addressmode, filtertype). +func stringInputOrDefault(n *node, name, def string) string { + in, ok := n.inputsByName[name] + if !ok { + return def + } + if in.RawString != "" { + return in.RawString + } + return def +} + // --- node builders --- type nodeBuilder func(c *compiler, n *node) (evalFn, error) @@ -258,6 +309,7 @@ var nodeBuilders map[string]nodeBuilder func init() { nodeBuilders = map[string]nodeBuilder{ "position": buildPosition, + "texcoord": buildTexcoord, "constant": buildConstant, "dotproduct": buildDotProduct, "multiply": buildArithmetic(func(a, b float64) float64 { return a * b }), @@ -270,6 +322,8 @@ func init() { "power": buildPower, "clamp": buildClamp, "mix": buildMix, + "image": buildImage, + "extract": buildExtract, } } @@ -277,10 +331,23 @@ func buildPosition(_ *compiler, n *node) (evalFn, error) { // "space" attribute: only object-space is supported. Anything else // would silently produce wrong results since the caller hands us // coordinates in a single fixed frame. - if in, ok := n.inputsByName["space"]; ok && in.Value != nil { - return nil, fmt.Errorf("position node: only object-space is supported, got %v", in.Value) + if in, ok := n.inputsByName["space"]; ok && in.RawString != "" && in.RawString != "object" { + return nil, fmt.Errorf("position node: only object-space is supported, got %q", in.RawString) } - return func(pos [3]float64, _ []Value) Value { return Vec3Value(pos) }, nil + return func(ctx *SampleContext, _ []Value) Value { return Vec3Value(ctx.Pos) }, nil +} + +func buildTexcoord(_ *compiler, n *node) (evalFn, error) { + // MaterialX texcoord exposes a UV channel index (default 0). We + // only plumb a single UV channel through SampleContext.UV — any + // non-zero index would silently return the same UV. Fail loudly + // rather than mislead. + if in, ok := n.inputsByName["index"]; ok && in.Value != nil { + if in.Value.AsInt() != 0 { + return nil, fmt.Errorf("texcoord node: only UV channel 0 is supported, got %d", in.Value.AsInt()) + } + } + return func(ctx *SampleContext, _ []Value) Value { return Vec2Value(ctx.UV) }, nil } func buildConstant(c *compiler, n *node) (evalFn, error) { @@ -296,9 +363,9 @@ func buildDotProduct(c *compiler, n *node) (evalFn, error) { if err != nil { return nil, err } - return func(pos [3]float64, scratch []Value) Value { - av := a(pos, scratch).AsVec3() - bv := b(pos, scratch).AsVec3() + return func(ctx *SampleContext, scratch []Value) Value { + av := a(ctx, scratch).AsVec3() + bv := b(ctx, scratch).AsVec3() return FloatValue(av[0]*bv[0] + av[1]*bv[1] + av[2]*bv[2]) }, nil } @@ -317,13 +384,13 @@ func buildArithmetic(op func(a, b float64) float64) nodeBuilder { arity := vecArity(out) switch out { case TypeFloat: - return func(pos [3]float64, scratch []Value) Value { - return FloatValue(op(a(pos, scratch).AsFloat(), b(pos, scratch).AsFloat())) + return func(ctx *SampleContext, scratch []Value) Value { + return FloatValue(op(a(ctx, scratch).AsFloat(), b(ctx, scratch).AsFloat())) }, nil case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: - return func(pos [3]float64, scratch []Value) Value { - av := broadcast(a(pos, scratch)) - bv := broadcast(b(pos, scratch)) + return func(ctx *SampleContext, scratch []Value) Value { + av := broadcast(a(ctx, scratch)) + bv := broadcast(b(ctx, scratch)) v := Value{Type: out} for i := range arity { v.Vec[i] = op(av[i], bv[i]) @@ -356,12 +423,12 @@ func buildFractal3D(c *compiler, n *node) (evalFn, error) { if err != nil { return nil, err } - return func(pos [3]float64, scratch []Value) Value { - p := posOrDefault(posFn, pos, scratch) - oct := intOrDefault(octFn, pos, scratch, 3) - lac := floatOrDefault(lacFn, pos, scratch, 2.0) - dim := floatOrDefault(dimFn, pos, scratch, 0.5) - amp := floatOrDefault(ampFn, pos, scratch, 1.0) + return func(ctx *SampleContext, scratch []Value) Value { + p := posOrDefault(posFn, ctx, scratch) + oct := intOrDefault(octFn, ctx, scratch, 3) + lac := floatOrDefault(lacFn, ctx, scratch, 2.0) + dim := floatOrDefault(dimFn, ctx, scratch, 0.5) + amp := floatOrDefault(ampFn, ctx, scratch, 1.0) return FloatValue(amp * fractal3D(p[0], p[1], p[2], oct, lac, dim)) }, nil } @@ -379,10 +446,10 @@ func buildNoise3D(c *compiler, n *node) (evalFn, error) { if err != nil { return nil, err } - return func(pos [3]float64, scratch []Value) Value { - p := posOrDefault(posFn, pos, scratch) - amp := floatOrDefault(ampFn, pos, scratch, 1.0) - piv := floatOrDefault(pivFn, pos, scratch, 0.0) + return func(ctx *SampleContext, scratch []Value) Value { + p := posOrDefault(posFn, ctx, scratch) + amp := floatOrDefault(ampFn, ctx, scratch, 1.0) + piv := floatOrDefault(pivFn, ctx, scratch, 0.0) return FloatValue(perlin3D(p[0], p[1], p[2])*amp + piv) }, nil } @@ -393,8 +460,8 @@ func buildUnary(op func(float64) float64) nodeBuilder { if err != nil { return nil, err } - return func(pos [3]float64, scratch []Value) Value { - return FloatValue(op(in(pos, scratch).AsFloat())) + return func(ctx *SampleContext, scratch []Value) Value { + return FloatValue(op(in(ctx, scratch).AsFloat())) }, nil } } @@ -408,8 +475,8 @@ func buildPower(c *compiler, n *node) (evalFn, error) { if err != nil { return nil, err } - return func(pos [3]float64, scratch []Value) Value { - return FloatValue(math.Pow(a(pos, scratch).AsFloat(), b(pos, scratch).AsFloat())) + return func(ctx *SampleContext, scratch []Value) Value { + return FloatValue(math.Pow(a(ctx, scratch).AsFloat(), b(ctx, scratch).AsFloat())) }, nil } @@ -429,17 +496,17 @@ func buildClamp(c *compiler, n *node) (evalFn, error) { out := n.OutputType arity := vecArity(out) if out == TypeFloat { - return func(pos [3]float64, scratch []Value) Value { - low := floatOrDefault(lowFn, pos, scratch, 0) - high := floatOrDefault(highFn, pos, scratch, 1) - return FloatValue(clampF(in(pos, scratch).AsFloat(), low, high)) + return func(ctx *SampleContext, scratch []Value) Value { + low := floatOrDefault(lowFn, ctx, scratch, 0) + high := floatOrDefault(highFn, ctx, scratch, 1) + return FloatValue(clampF(in(ctx, scratch).AsFloat(), low, high)) }, nil } - return func(pos [3]float64, scratch []Value) Value { - low := floatOrDefault(lowFn, pos, scratch, 0) - high := floatOrDefault(highFn, pos, scratch, 1) + return func(ctx *SampleContext, scratch []Value) Value { + low := floatOrDefault(lowFn, ctx, scratch, 0) + high := floatOrDefault(highFn, ctx, scratch, 1) v := Value{Type: out} - src := in(pos, scratch).Vec + src := in(ctx, scratch).Vec for i := range arity { v.Vec[i] = clampF(src[i], low, high) } @@ -467,15 +534,15 @@ func buildMix(c *compiler, n *node) (evalFn, error) { arity := vecArity(out) switch out { case TypeFloat: - return func(pos [3]float64, scratch []Value) Value { - t := mixFn(pos, scratch).AsFloat() - return FloatValue(bg(pos, scratch).AsFloat()*(1-t) + fg(pos, scratch).AsFloat()*t) + return func(ctx *SampleContext, scratch []Value) Value { + t := mixFn(ctx, scratch).AsFloat() + return FloatValue(bg(ctx, scratch).AsFloat()*(1-t) + fg(ctx, scratch).AsFloat()*t) }, nil case TypeVector2, TypeVector3, TypeVector4, TypeColor3, TypeColor4: - return func(pos [3]float64, scratch []Value) Value { - t := mixFn(pos, scratch).AsFloat() - bgv := broadcast(bg(pos, scratch)) - fgv := broadcast(fg(pos, scratch)) + return func(ctx *SampleContext, scratch []Value) Value { + t := mixFn(ctx, scratch).AsFloat() + bgv := broadcast(bg(ctx, scratch)) + fgv := broadcast(fg(ctx, scratch)) v := Value{Type: out} for i := range arity { v.Vec[i] = bgv[i]*(1-t) + fgv[i]*t @@ -486,6 +553,98 @@ func buildMix(c *compiler, n *node) (evalFn, error) { return nil, fmt.Errorf("unsupported mix output type %s", out) } +// buildImage loads the referenced texture once at compile time and +// returns a closure that samples it at the texcoord input's UV (or +// SampleContext.UV directly when no texcoord is wired). The output +// type drives how many channels are pulled from the texture; alpha is +// dropped because ditherforge bakes alpha separately. RGB stays in +// sRGB throughout — the consumer (voxel pipeline) wants sRGB-quantized +// output anyway, so a linearize-then-encode round-trip would only add +// rounding error. +func buildImage(c *compiler, n *node) (evalFn, error) { + fileIn, ok := n.inputsByName["file"] + if !ok { + return nil, fmt.Errorf("image node: missing required %q input", "file") + } + if fileIn.RawString == "" { + return nil, fmt.Errorf("image node: %q input has no path", "file") + } + img, err := c.images.load(c.resolver, fileIn.RawString, fileIn.Colorspace) + if err != nil { + return nil, fmt.Errorf("image node: %w", err) + } + uMode := parseAddressMode(stringInputOrDefault(n, "uaddressmode", "periodic")) + vMode := parseAddressMode(stringInputOrDefault(n, "vaddressmode", "periodic")) + filter := parseFilterType(stringInputOrDefault(n, "filtertype", "linear")) + + uvFn, err := c.compileOptional(n, "texcoord") + if err != nil { + return nil, err + } + + out := n.OutputType + arity := vecArity(out) + defaultFn, err := c.compileOptional(n, "default") + if err != nil { + return nil, err + } + + return func(ctx *SampleContext, scratch []Value) Value { + var uv [2]float64 + if uvFn != nil { + v := uvFn(ctx, scratch) + uv = [2]float64{v.Vec[0], v.Vec[1]} + } else { + uv = ctx.UV + } + rgb := img.sample(uv, uMode, vMode, filter) + v := Value{Type: out} + switch arity { + case 0: // float + v.Type = TypeFloat + v.F = rgb[0] + return v + case 2: + v.Vec[0] = rgb[0] + v.Vec[1] = rgb[1] + case 3: + v.Vec[0] = rgb[0] + v.Vec[1] = rgb[1] + v.Vec[2] = rgb[2] + case 4: + v.Vec[0] = rgb[0] + v.Vec[1] = rgb[1] + v.Vec[2] = rgb[2] + v.Vec[3] = 1 + } + // Hush unused-default-input warning when present but unsupported; + // real default sampling happens implicitly via address modes. + _ = defaultFn + return v + }, nil +} + +// buildExtract pulls one component out of a vector/color, indexed by +// the literal `index` input (0-based). +func buildExtract(c *compiler, n *node) (evalFn, error) { + in, err := c.compileRequired(n, "in") + if err != nil { + return nil, err + } + idxIn, ok := n.inputsByName["index"] + if !ok || idxIn.Value == nil { + return nil, fmt.Errorf("extract node: missing literal %q input", "index") + } + idx := idxIn.Value.AsInt() + if idx < 0 || idx >= 4 { + return nil, fmt.Errorf("extract node: index %d out of range [0, 4)", idx) + } + return func(ctx *SampleContext, scratch []Value) Value { + v := in(ctx, scratch) + return FloatValue(v.Vec[idx]) + }, nil +} + // --- helpers --- // broadcast widens a scalar Value into a 4-component vector by @@ -498,25 +657,25 @@ func broadcast(v Value) [4]float64 { return v.Vec } -func posOrDefault(fn evalFn, pos [3]float64, scratch []Value) [3]float64 { +func posOrDefault(fn evalFn, ctx *SampleContext, scratch []Value) [3]float64 { if fn == nil { - return pos + return ctx.Pos } - return fn(pos, scratch).AsVec3() + return fn(ctx, scratch).AsVec3() } -func intOrDefault(fn evalFn, pos [3]float64, scratch []Value, def int) int { +func intOrDefault(fn evalFn, ctx *SampleContext, scratch []Value, def int) int { if fn == nil { return def } - return fn(pos, scratch).AsInt() + return fn(ctx, scratch).AsInt() } -func floatOrDefault(fn evalFn, pos [3]float64, scratch []Value, def float64) float64 { +func floatOrDefault(fn evalFn, ctx *SampleContext, scratch []Value, def float64) float64 { if fn == nil { return def } - return fn(pos, scratch).AsFloat() + return fn(ctx, scratch).AsFloat() } func clampF(v, lo, hi float64) float64 { diff --git a/internal/materialx/image.go b/internal/materialx/image.go new file mode 100644 index 0000000..6f04858 --- /dev/null +++ b/internal/materialx/image.go @@ -0,0 +1,228 @@ +package materialx + +import ( + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "math" + "strings" + "sync" +) + +// AddressMode controls how out-of-[0,1] UV coordinates are wrapped +// before sampling. Mirrors MaterialX's uaddressmode/vaddressmode +// inputs on the image node. +type AddressMode int + +const ( + AddressPeriodic AddressMode = iota + AddressClamp + AddressMirror +) + +func parseAddressMode(s string) AddressMode { + switch strings.ToLower(strings.TrimSpace(s)) { + case "clamp": + return AddressClamp + case "mirror": + return AddressMirror + } + return AddressPeriodic +} + +// FilterType selects the texel-resampling kernel. +type FilterType int + +const ( + FilterLinear FilterType = iota + FilterClosest +) + +func parseFilterType(s string) FilterType { + switch strings.ToLower(strings.TrimSpace(s)) { + case "closest", "nearest": + return FilterClosest + } + return FilterLinear +} + +// decodedImage is a CPU-resident texture decoded once at sampler +// construction time. Pixels are stored row-major as 4-channel RGBA8 +// regardless of the source format. Sample is reentrant — the struct +// is immutable after construction. +type decodedImage struct { + w, h int + pixels []uint8 + // srgb is true when the source declared colorspace="srgb_texture"; + // for ditherforge's downstream sRGB-quantized output we keep the + // values as 8-bit sRGB (no linearization), matching how flat + // FaceBaseColor is treated elsewhere in the pipeline. + srgb bool +} + +func decodeImage(rc interface { + Read(p []byte) (int, error) +}, srgb bool) (*decodedImage, error) { + img, _, err := image.Decode(readerFunc(rc.Read)) + if err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + b := img.Bounds() + w, h := b.Dx(), b.Dy() + pixels := make([]uint8, 4*w*h) + idx := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, a := img.At(x, y).RGBA() + pixels[idx+0] = uint8(r >> 8) + pixels[idx+1] = uint8(g >> 8) + pixels[idx+2] = uint8(bl >> 8) + pixels[idx+3] = uint8(a >> 8) + idx += 4 + } + } + return &decodedImage{w: w, h: h, pixels: pixels, srgb: srgb}, nil +} + +// readerFunc lets us pass a Read method directly into image.Decode +// without requiring it to satisfy the full io.Reader interface +// upstream of decodeImage's caller. +type readerFunc func(p []byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } + +// sample looks up an RGB triplet at the given UV with the requested +// address/filter modes. Output is in [0, 1] per channel; alpha is +// ignored (ditherforge bakes alpha separately from base color). +func (img *decodedImage) sample(uv [2]float64, uMode, vMode AddressMode, filter FilterType) [3]float64 { + u := wrapUV(uv[0], uMode) + v := wrapUV(uv[1], vMode) + // MaterialX texture origin is bottom-left; image package origin + // is top-left. Flip V so loaded textures match what reference + // renderers produce. + v = 1 - v + + x := u * float64(img.w) + y := v * float64(img.h) + if filter == FilterClosest { + ix := wrapPixel(int(math.Floor(x)), img.w, uMode) + iy := wrapPixel(int(math.Floor(y)), img.h, vMode) + return img.fetch(ix, iy) + } + // Bilinear: shift by -0.5 so integer pixel coords sit at texel centers. + x -= 0.5 + y -= 0.5 + x0 := int(math.Floor(x)) + y0 := int(math.Floor(y)) + fx := x - float64(x0) + fy := y - float64(y0) + x1 := x0 + 1 + y1 := y0 + 1 + x0 = wrapPixel(x0, img.w, uMode) + x1 = wrapPixel(x1, img.w, uMode) + y0 = wrapPixel(y0, img.h, vMode) + y1 = wrapPixel(y1, img.h, vMode) + c00 := img.fetch(x0, y0) + c10 := img.fetch(x1, y0) + c01 := img.fetch(x0, y1) + c11 := img.fetch(x1, y1) + var out [3]float64 + for i := range 3 { + a := c00[i]*(1-fx) + c10[i]*fx + b := c01[i]*(1-fx) + c11[i]*fx + out[i] = a*(1-fy) + b*fy + } + return out +} + +// fetch returns the un-converted RGB at integer pixel (x, y) in [0, 1]. +// Caller must have already wrapped (x, y) into the image bounds. +func (img *decodedImage) fetch(x, y int) [3]float64 { + off := 4 * (y*img.w + x) + return [3]float64{ + float64(img.pixels[off+0]) / 255, + float64(img.pixels[off+1]) / 255, + float64(img.pixels[off+2]) / 255, + } +} + +func wrapUV(t float64, mode AddressMode) float64 { + switch mode { + case AddressPeriodic: + t = t - math.Floor(t) + case AddressClamp: + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + case AddressMirror: + t = math.Mod(math.Abs(t), 2.0) + if t > 1 { + t = 2 - t + } + } + return t +} + +func wrapPixel(i, n int, mode AddressMode) int { + switch mode { + case AddressClamp: + if i < 0 { + return 0 + } + if i >= n { + return n - 1 + } + return i + case AddressMirror: + // Reflect at boundaries, period 2*n. + period := 2 * n + i = ((i % period) + period) % period + if i >= n { + i = period - 1 - i + } + return i + } + // Periodic. + i = i % n + if i < 0 { + i += n + } + return i +} + +// imageCache deduplicates image loads across multiple references in a +// single graph (e.g. a base_color + roughness shader pack referencing +// the same .png from two image nodes). Used during sampler compile. +type imageCache struct { + mu sync.Mutex + images map[string]*decodedImage +} + +func newImageCache() *imageCache { + return &imageCache{images: map[string]*decodedImage{}} +} + +func (c *imageCache) load(r ResourceResolver, relpath, colorspace string) (*decodedImage, error) { + if r == nil { + return nil, fmt.Errorf("no resource resolver — load .mtlx via ParsePackage or ParseFile to enable image nodes") + } + c.mu.Lock() + defer c.mu.Unlock() + if img, ok := c.images[relpath]; ok { + return img, nil + } + rc, err := r.Open(relpath) + if err != nil { + return nil, err + } + defer rc.Close() + img, err := decodeImage(rc, strings.EqualFold(colorspace, "srgb_texture")) + if err != nil { + return nil, err + } + c.images[relpath] = img + return img, nil +} diff --git a/internal/materialx/materialx_test.go b/internal/materialx/materialx_test.go index 1823f68..452d41b 100644 --- a/internal/materialx/materialx_test.go +++ b/internal/materialx/materialx_test.go @@ -1,8 +1,14 @@ package materialx_test import ( + "archive/zip" + "bytes" + "image" + "image/color" + "image/png" "math" "os" + "path/filepath" "strings" "testing" @@ -415,3 +421,232 @@ func loadMarble(t *testing.T) *materialx.Document { } return doc } + +// stripePNG returns a 4×1 PNG with horizontal stripes red, green, +// blue, white. Used by image-graph tests. +func stripePNG(t *testing.T) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, 4, 1)) + cells := []color.NRGBA{ + {R: 255, A: 255}, + {G: 255, A: 255}, + {B: 255, A: 255}, + {R: 255, G: 255, B: 255, A: 255}, + } + for i, c := range cells { + img.Set(i, 0, c) + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode stripe png: %v", err) + } + return buf.Bytes() +} + +// imageGraphMtlx is a minimal .mtlx that wires image → texcoord +// directly (no UV multiplier) so test UVs land at known pixel centers. +const imageGraphMtlx = ` + + + + + + + + + + + + + + + + + + + + + +` + +func TestImageGraphSamplesExactPixelCenters(t *testing.T) { + doc, err := materialx.ParseBytes([]byte(imageGraphMtlx)) + if err != nil { + t.Fatalf("parse: %v", err) + } + doc.Resolver = materialx.NewMapResolver(map[string][]byte{ + "stripe.png": stripePNG(t), + }) + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + // Pixel centers for a 4-wide image are at u = 0.125, 0.375, 0.625, 0.875. + // V is irrelevant for a 1-tall image. Address mode is periodic but + // we stay inside [0, 1] here so it doesn't matter. + cases := []struct { + u float64 + want [3]float64 + }{ + {0.125, [3]float64{1, 0, 0}}, // red + {0.375, [3]float64{0, 1, 0}}, // green + {0.625, [3]float64{0, 0, 1}}, // blue + {0.875, [3]float64{1, 1, 1}}, // white + } + for _, tc := range cases { + got := s.SampleAt(materialx.SampleContext{UV: [2]float64{tc.u, 0.5}}) + for i := range 3 { + if math.Abs(got[i]-tc.want[i]) > 1e-9 { + t.Errorf("Sample(u=%v): got %v, want %v", tc.u, got, tc.want) + break + } + } + } +} + +func TestImageAddressingModes(t *testing.T) { + // Same fixture as TestImageGraphSamples but with each addressmode + // substituted in. We test by sampling outside [0, 1] and checking + // where the lookup lands. + pngBytes := stripePNG(t) + for _, mode := range []string{"periodic", "clamp", "mirror"} { + t.Run(mode, func(t *testing.T) { + mtlx := strings.ReplaceAll(imageGraphMtlx, `value="periodic"`, `value="`+mode+`"`) + doc, err := materialx.ParseBytes([]byte(mtlx)) + if err != nil { + t.Fatalf("parse: %v", err) + } + doc.Resolver = materialx.NewMapResolver(map[string][]byte{"stripe.png": pngBytes}) + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + + // Sample at u = -0.125 (one pixel left of u=0). + got := s.SampleAt(materialx.SampleContext{UV: [2]float64{-0.125, 0.5}}) + var want [3]float64 + switch mode { + case "periodic": + // -0.125 wraps to 0.875 → white pixel. + want = [3]float64{1, 1, 1} + case "clamp": + // Clamps to 0 → red pixel. + want = [3]float64{1, 0, 0} + case "mirror": + // Mirrors at 0 → 0.125 → red pixel. + want = [3]float64{1, 0, 0} + } + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("%s mode at u=-0.125: got %v, want %v", mode, got, want) + break + } + } + }) + } +} + +// uvScaleMtlx multiplies texcoord by 2 before sampling — a single tile +// of the texture covers UV [0, 0.5]; UV [0.5, 1] also tiles the same +// content (with periodic addressing). +const uvScaleMtlx = ` + + + + + + + + + + + + + + + + + + + + + + +` + +func TestUVScalingTilesTexture(t *testing.T) { + doc, err := materialx.ParseBytes([]byte(uvScaleMtlx)) + if err != nil { + t.Fatalf("parse: %v", err) + } + doc.Resolver = materialx.NewMapResolver(map[string][]byte{"stripe.png": stripePNG(t)}) + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + // UV 0.0625 * 2 = 0.125 → red. UV 0.5625 * 2 = 1.125 wraps → 0.125 → red. + for _, u := range []float64{0.0625, 0.5625} { + got := s.SampleAt(materialx.SampleContext{UV: [2]float64{u, 0.5}}) + want := [3]float64{1, 0, 0} + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("u=%v: got %v, want %v (UV scaling/wrap broken)", u, got, want) + break + } + } + } +} + +func TestParsePackageZip(t *testing.T) { + // Build an in-memory zip containing the .mtlx + the PNG, write it + // to a temp file, and load via ParsePackage. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + addZip := func(name string, data []byte) { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write(data); err != nil { + t.Fatalf("zip write: %v", err) + } + } + addZip("pack.mtlx", []byte(imageGraphMtlx)) + addZip("stripe.png", stripePNG(t)) + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + + tmp := filepath.Join(t.TempDir(), "pack.zip") + if err := os.WriteFile(tmp, buf.Bytes(), 0644); err != nil { + t.Fatalf("write zip: %v", err) + } + + doc, err := materialx.ParsePackage(tmp) + if err != nil { + t.Fatalf("ParsePackage: %v", err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + got := s.SampleAt(materialx.SampleContext{UV: [2]float64{0.125, 0.5}}) + want := [3]float64{1, 0, 0} + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("zip-loaded sampler at u=0.125: got %v, want %v", got, want) + break + } + } +} + +func TestImageGraphRequiresResolver(t *testing.T) { + doc, err := materialx.ParseBytes([]byte(imageGraphMtlx)) + if err != nil { + t.Fatalf("parse: %v", err) + } + // No Resolver set — image node should fail at sampler construction. + if _, err := doc.DefaultBaseColorSampler(); err == nil { + t.Errorf("expected error when Resolver is nil, got success") + } +} diff --git a/internal/materialx/parse.go b/internal/materialx/parse.go index 610413f..3d4452a 100644 --- a/internal/materialx/parse.go +++ b/internal/materialx/parse.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" ) // Parse reads a MaterialX document from r. @@ -31,14 +32,21 @@ func Parse(r io.Reader) (*Document, error) { } } -// ParseFile is a convenience wrapper around Parse. +// ParseFile is a convenience wrapper around Parse that also installs a +// directory-based ResourceResolver rooted at the file's containing +// directory, so image-backed graphs can find adjacent texture files. func ParseFile(path string) (*Document, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() - return Parse(f) + doc, err := Parse(f) + if err != nil { + return nil, err + } + doc.Resolver = &dirResolver{base: filepath.Dir(path)} + return doc, nil } // ParseBytes parses MaterialX from an in-memory byte slice. @@ -258,12 +266,25 @@ func parseInput(dec *xml.Decoder, se xml.StartElement) (*input, error) { if v, ok := attrLookup(se, "output"); ok { in.OutputName = v } + if v, ok := attrLookup(se, "colorspace"); ok { + in.Colorspace = v + } if valueStr, ok := attrLookup(se, "value"); ok { - v, err := parseValueString(valueStr, in.Type) - if err != nil { - return nil, fmt.Errorf("input %q: %w", in.Name, err) + switch in.Type { + case TypeString, TypeFilename, TypeUnknown: + // Unknown types (e.g. boolean, matrix44) appear on surface + // shader inputs we don't consume (base_color is all we + // extract). Store the raw string so the parse doesn't fail; + // any code that tries to evaluate the input will hit a + // clear error later. + in.RawString = valueStr + default: + v, err := parseValueString(valueStr, in.Type) + if err != nil { + return nil, fmt.Errorf("input %q: %w", in.Name, err) + } + in.Value = &v } - in.Value = &v } if err := skipElement(dec); err != nil { return nil, err diff --git a/internal/materialx/resolver.go b/internal/materialx/resolver.go new file mode 100644 index 0000000..0051ceb --- /dev/null +++ b/internal/materialx/resolver.go @@ -0,0 +1,192 @@ +package materialx + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// ResourceResolver opens files referenced by a MaterialX document — image +// inputs name them via relative paths, so the resolver decouples the +// document model from where the bytes physically live (filesystem +// directory, .zip archive, in-memory map for tests). +type ResourceResolver interface { + Open(relpath string) (io.ReadCloser, error) +} + +// ParsePackage opens a .mtlx file or a .zip archive containing one and +// returns a Document with Resolver populated, so image-backed graphs +// can find their referenced textures. +// +// .zip archives are expected to hold the .mtlx at the archive root (or +// in a single top-level directory) alongside any referenced texture +// files. Multiple .mtlx in the archive is an error — pick one. +func ParsePackage(path string) (*Document, error) { + switch ext := strings.ToLower(filepath.Ext(path)); ext { + case ".mtlx": + return ParseFile(path) + case ".zip": + return parseZipPackage(path) + default: + return nil, fmt.Errorf("materialx: unsupported package extension %q (want .mtlx or .zip)", ext) + } +} + +// ParseFileWithResolver is the same as ParseFile but lets the caller +// supply a custom resolver — useful for tests that mount in-memory +// fixtures. +func ParseFileWithResolver(path string, r ResourceResolver) (*Document, error) { + doc, err := ParseFile(path) + if err != nil { + return nil, err + } + doc.Resolver = r + return doc, nil +} + +// dirResolver resolves paths relative to a filesystem directory. +type dirResolver struct { + base string +} + +func (d *dirResolver) Open(relpath string) (io.ReadCloser, error) { + clean := filepath.FromSlash(filepath.Clean(relpath)) + if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + return nil, fmt.Errorf("materialx: refusing to resolve %q outside %q", relpath, d.base) + } + return os.Open(filepath.Join(d.base, clean)) +} + +// zipResolver resolves paths against an opened *zip.Reader. Holds the +// underlying *os.File so callers can release the archive when done. +type zipResolver struct { + r *zip.Reader + closer io.Closer + prefix string // top-level directory inside the zip, if any + entries map[string]*zip.File +} + +func (z *zipResolver) Open(relpath string) (io.ReadCloser, error) { + clean := path.Clean(strings.ReplaceAll(relpath, "\\", "/")) + if strings.HasPrefix(clean, "/") || strings.HasPrefix(clean, "..") { + return nil, fmt.Errorf("materialx: refusing to resolve %q outside zip", relpath) + } + for _, key := range []string{clean, path.Join(z.prefix, clean)} { + if f, ok := z.entries[key]; ok { + return f.Open() + } + } + return nil, fmt.Errorf("materialx: %q not found in zip", relpath) +} + +// Close releases the underlying zip file handle. Safe to call multiple +// times. +func (z *zipResolver) Close() error { + if z.closer == nil { + return nil + } + c := z.closer + z.closer = nil + return c.Close() +} + +func parseZipPackage(zipPath string) (*Document, error) { + f, err := os.Open(zipPath) + if err != nil { + return nil, err + } + stat, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + zr, err := zip.NewReader(f, stat.Size()) + if err != nil { + f.Close() + return nil, fmt.Errorf("materialx: open zip: %w", err) + } + + var mtlxFile *zip.File + entries := make(map[string]*zip.File, len(zr.File)) + for _, e := range zr.File { + if e.FileInfo().IsDir() { + continue + } + entries[e.Name] = e + if strings.EqualFold(filepath.Ext(e.Name), ".mtlx") { + if mtlxFile != nil { + f.Close() + return nil, fmt.Errorf("materialx: zip contains multiple .mtlx files (%q and %q)", + mtlxFile.Name, e.Name) + } + mtlxFile = e + } + } + if mtlxFile == nil { + f.Close() + return nil, errors.New("materialx: zip contains no .mtlx file") + } + + // All other resources resolve relative to the .mtlx's containing + // directory inside the archive — same convention as a directory layout. + prefix := path.Dir(mtlxFile.Name) + if prefix == "." { + prefix = "" + } + + rc, err := mtlxFile.Open() + if err != nil { + f.Close() + return nil, fmt.Errorf("materialx: read %q: %w", mtlxFile.Name, err) + } + doc, err := Parse(rc) + rc.Close() + if err != nil { + f.Close() + return nil, fmt.Errorf("materialx: parse %q: %w", mtlxFile.Name, err) + } + + doc.Resolver = &zipResolver{ + r: zr, + closer: f, + prefix: prefix, + entries: entries, + } + return doc, nil +} + +// mapResolver is an in-memory ResourceResolver used by tests. Public so +// external test packages can construct fixtures. +type mapResolver map[string][]byte + +func (m mapResolver) Open(relpath string) (io.ReadCloser, error) { + clean := path.Clean(strings.ReplaceAll(relpath, "\\", "/")) + b, ok := m[clean] + if !ok { + return nil, fmt.Errorf("materialx: %q not found in map resolver", relpath) + } + return io.NopCloser(byteReader{b: b}), nil +} + +// NewMapResolver returns an in-memory ResourceResolver backed by the +// given path-to-bytes map. Test-only. +func NewMapResolver(files map[string][]byte) ResourceResolver { return mapResolver(files) } + +type byteReader struct { + b []byte + i int +} + +func (r byteReader) Read(p []byte) (int, error) { + if r.i >= len(r.b) { + return 0, io.EOF + } + n := copy(p, r.b[r.i:]) + r.i += n + return n, nil +} diff --git a/internal/materialx/types.go b/internal/materialx/types.go index 4a7f73f..5ba366e 100644 --- a/internal/materialx/types.go +++ b/internal/materialx/types.go @@ -1,10 +1,12 @@ -// Package materialx parses and evaluates a procedural subset of the -// MaterialX (.mtlx) format, sampling shader graphs at 3D positions to -// produce surface colors. Only the nodes needed to evaluate solid -// procedural patterns (marble, brick, checkerboard, etc.) are -// implemented; image-based, BSDF, and lighting nodes are intentionally -// out of scope — the consumer is ditherforge's voxel-color pipeline, -// which only needs RGB at a 3D point. +// Package materialx parses and evaluates a base-color subset of the +// MaterialX (.mtlx) format, sampling shader graphs at 3D positions +// (and optionally UVs / surface normals) to produce surface colors. +// Procedural patterns (marble, brick, checkerboard) are evaluated +// directly; image-backed graphs (Quixel/AmbientCG-style PBR packs) +// are supported via a ResourceResolver that loads referenced .png/.jpg +// files from a directory or a .zip archive. BSDF, lighting, and +// shader codegen are out of scope — the consumer is ditherforge's +// voxel-color pipeline, which only needs RGB at a 3D point. package materialx import ( @@ -24,6 +26,8 @@ const ( TypeVector4 TypeColor3 TypeColor4 + TypeString // free-form ASCII (addressmode names, etc.) + TypeFilename // path resolved via ResourceResolver ) func (t ValueType) String() string { @@ -42,6 +46,10 @@ func (t ValueType) String() string { return "color3" case TypeColor4: return "color4" + case TypeString: + return "string" + case TypeFilename: + return "filename" } return "unknown" } @@ -62,6 +70,10 @@ func parseValueType(s string) ValueType { return TypeColor3 case "color4": return TypeColor4 + case "string": + return TypeString + case "filename": + return TypeFilename } return TypeUnknown } @@ -75,10 +87,11 @@ type Value struct { Vec [4]float64 } -func FloatValue(f float64) Value { return Value{Type: TypeFloat, F: f} } -func IntValue(i int) Value { return Value{Type: TypeInteger, I: i} } -func Vec3Value(v [3]float64) Value { return Value{Type: TypeVector3, Vec: [4]float64{v[0], v[1], v[2], 0}} } -func Color3Value(v [3]float64) Value { return Value{Type: TypeColor3, Vec: [4]float64{v[0], v[1], v[2], 0}} } +func FloatValue(f float64) Value { return Value{Type: TypeFloat, F: f} } +func IntValue(i int) Value { return Value{Type: TypeInteger, I: i} } +func Vec2Value(v [2]float64) Value { return Value{Type: TypeVector2, Vec: [4]float64{v[0], v[1], 0, 0}} } +func Vec3Value(v [3]float64) Value { return Value{Type: TypeVector3, Vec: [4]float64{v[0], v[1], v[2], 0}} } +func Color3Value(v [3]float64) Value { return Value{Type: TypeColor3, Vec: [4]float64{v[0], v[1], v[2], 0}} } func (v Value) AsFloat() float64 { switch v.Type { @@ -116,8 +129,14 @@ func (v Value) AsVec3() [3]float64 { } // parseValueString converts a MaterialX attribute string ("0.8, 0.8, 0.8", -// "3.0", "3", "1, 1, 1") into a typed Value. +// "3.0", "3", "1, 1, 1") into a typed Value. String/filename values are +// stored on the input directly (input.RawString) — Value remains lean +// for the per-voxel hot path, where slots are typed Value and string +// storage would inflate the scratch buffer needlessly. func parseValueString(s string, typ ValueType) (Value, error) { + if typ == TypeString || typ == TypeFilename { + return Value{}, fmt.Errorf("string-typed value should be stored on input.RawString, not parsed into Value") + } s = strings.TrimSpace(s) switch typ { case TypeFloat: @@ -163,11 +182,15 @@ func vecArity(t ValueType) int { return 0 } -// Document is the parsed contents of a .mtlx file. +// Document is the parsed contents of a .mtlx file. Resolver is +// optional and only required for graphs that reference external files +// (image nodes); ParsePackage and ParseFile auto-populate it, +// ParseBytes leaves it nil. type Document struct { NodeGraphs map[string]*nodeGraph Surfaces map[string]*surface Materials map[string]*material + Resolver ResourceResolver } type nodeGraph struct { @@ -205,6 +228,8 @@ type input struct { Name string Type ValueType Value *Value + RawString string // populated when Type is TypeString or TypeFilename + Colorspace string // optional; only meaningful on image filename inputs NodeName string InterfaceName string GraphName string From 04d9275a0d37a8de223acf0083f0885b672ca24e Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 18:17:38 -0700 Subject: [PATCH 05/13] materialx: triplanar projection for image-backed graphs + path-based cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs the closest-face surface normal into the BaseColorOverride contract so the pipeline adapter can drive triplanar projection on image-backed MaterialX graphs (PBR packs whose .mtlx reads texcoord to sample a 2D texture). For purely position-driven graphs (marble, brick) the new sampler.UsesUV() flag short-circuits the triplanar machinery and falls back to a single SampleAt — no per-voxel cost increase for the existing procedural path. Specifically: * voxel.BaseColorOverride.SampleBaseColor now takes a BaseColorContext bundling pos and the unit-length face normal (zero on degenerate faces). Both call sites — voxel.SampleNearestColorWithSticker and pipeline.bakeMaterialXBaseColor — compute the normal from the triangle's three vertices. * materialx.Sampler grows UsesUV() so the adapter can detect graphs that consume texcoord. The compiler flags it on when texcoord or image nodes are encountered. * materialxOverride.SampleBaseColor either single-samples (no UV) or triplanar-blends three SampleAt calls weighted by |normal.axis|^TriplanarSharpness, with TriplanarSharpness exposed as a new pipeline.Options field (default 4 in CLI; UI slider in the next commit). * Options.BaseColorMaterialX semantics: was inline file content, now path. Cache invalidation hashes path + mtime + size — both voxelize and sticker stages. Existing inline-content settings break (intentionally, per the project's no-cache-compat policy). Verified end-to-end against the real cobblestone .zip — voxelize completes cleanly, no warnings. New pipeline-internal tests cover triplanar plane-picking, diagonal-normal blending, the non-UV fast-path, file-stamp cache invalidation, and the new sharpness field. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/ditherforge/main.go | 38 ++--- internal/materialx/eval.go | 21 ++- internal/pipeline/materialx_override.go | 138 +++++++++++++++---- internal/pipeline/materialx_override_test.go | 137 ++++++++++++++++++ internal/pipeline/pipeline.go | 54 ++++++-- internal/pipeline/run.go | 6 +- internal/pipeline/stepcache.go | 90 ++++++++---- internal/pipeline/stepcache_test.go | 95 +++++++++---- internal/voxel/color.go | 47 ++++++- 9 files changed, 505 insertions(+), 121 deletions(-) create mode 100644 internal/pipeline/materialx_override_test.go diff --git a/cmd/ditherforge/main.go b/cmd/ditherforge/main.go index b3a6cb7..a032a9f 100644 --- a/cmd/ditherforge/main.go +++ b/cmd/ditherforge/main.go @@ -33,9 +33,10 @@ type Args struct { Inventory string `arg:"--inventory" help:"Inventory file for remaining colors"` Scale float32 `arg:"--scale" default:"1.0" help:"Additional scale multiplier"` Output string `arg:"--output" help:"Output .3mf file (default: .3mf)"` - BaseColor string `arg:"--base-color" help:"Hex color for untextured faces (e.g. #FF0000)"` - BaseMaterialX string `arg:"--base-materialx" help:"Path to a procedural .mtlx file applied as the base color of untextured faces (overrides --base-color)"` - BaseMaterialXTileMM float64 `arg:"--base-materialx-tile-mm" default:"10" help:"Object-space scale (mm per shading-unit cycle) for the MaterialX procedural"` + BaseColor string `arg:"--base-color" help:"Hex color for untextured faces (e.g. #FF0000)"` + BaseMaterialX string `arg:"--base-materialx" help:"Path to a .mtlx file or .zip archive containing one (with adjacent textures) applied as the base color of untextured faces (overrides --base-color)"` + BaseMaterialXTileMM float64 `arg:"--base-materialx-tile-mm" default:"10" help:"Object-space scale (mm per shading-unit cycle) for the MaterialX procedural"` + BaseMaterialXTriplanarSharpness float64 `arg:"--base-materialx-triplanar-sharpness" default:"4" help:"Triplanar projection sharpness for image-backed MaterialX (higher = sharper axis transitions; ignored by procedural .mtlx)"` NozzleDiameter float32 `arg:"--nozzle-diameter" default:"0.4" help:"Nozzle diameter in mm"` LayerHeight float32 `arg:"--layer-height" default:"0.2" help:"Layer height in mm"` Printer string `arg:"--printer" help:"Target printer profile id (e.g. snapmaker_u1, snapmaker_j1, prusa_xl, prusa_xl_5t, bambu_h2d, bambu_h2d_pro); defaults to snapmaker_u1"` @@ -77,27 +78,18 @@ func main() { } } - var materialxContent string - if args.BaseMaterialX != "" { - data, err := os.ReadFile(args.BaseMaterialX) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading --base-materialx: %v\n", err) - os.Exit(1) - } - materialxContent = string(data) - } - opts := pipeline.Options{ - Input: args.Input, - NumColors: args.NumColors, - LockedColors: expandColors(args.Color), - InventoryFile: args.Inventory, - Scale: args.Scale, - Output: args.Output, - BaseColor: args.BaseColor, - BaseColorMaterialX: materialxContent, - BaseColorMaterialXTileMM: args.BaseMaterialXTileMM, - NozzleDiameter: args.NozzleDiameter, + Input: args.Input, + NumColors: args.NumColors, + LockedColors: expandColors(args.Color), + InventoryFile: args.Inventory, + Scale: args.Scale, + Output: args.Output, + BaseColor: args.BaseColor, + BaseColorMaterialX: args.BaseMaterialX, + BaseColorMaterialXTileMM: args.BaseMaterialXTileMM, + BaseColorMaterialXTriplanarSharpness: args.BaseMaterialXTriplanarSharpness, + NozzleDiameter: args.NozzleDiameter, LayerHeight: args.LayerHeight, Printer: args.Printer, Brightness: args.Brightness, diff --git a/internal/materialx/eval.go b/internal/materialx/eval.go index 45e6ecf..678be09 100644 --- a/internal/materialx/eval.go +++ b/internal/materialx/eval.go @@ -25,9 +25,16 @@ type SampleContext struct { // Compiled samplers are reentrant: calling Sample/SampleAt concurrently // from multiple goroutines is safe because each call uses its own // scratch slot table. +// +// UsesUV reports whether the underlying graph reads SampleContext.UV +// (true for image-backed graphs and any graph containing a texcoord +// node). Consumers can use this to skip wrapping in triplanar +// projection when the graph is purely position-driven (e.g. marble), +// avoiding 3× redundant evaluator calls per sample. type Sampler interface { Sample(pos [3]float64) [3]float64 SampleAt(ctx SampleContext) [3]float64 + UsesUV() bool } // MaterialNames returns the material names defined by the document, in @@ -96,8 +103,9 @@ func (d *Document) samplerFromInput(in *input) (Sampler, error) { type constSampler [3]float64 -func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } -func (c constSampler) SampleAt(_ SampleContext) [3]float64 { return [3]float64(c) } +func (c constSampler) Sample(_ [3]float64) [3]float64 { return [3]float64(c) } +func (c constSampler) SampleAt(_ SampleContext) [3]float64 { return [3]float64(c) } +func (c constSampler) UsesUV() bool { return false } // --- compiled graph evaluator --- // @@ -114,8 +122,11 @@ type compiledGraph struct { nSlots int outSlot int steps []compileStep + usesUV bool } +func (g *compiledGraph) UsesUV() bool { return g.usesUV } + type compileStep struct { slot int fn evalFn @@ -197,6 +208,7 @@ func compileGraph(ng *nodeGraph, out *graphOutput, resolver ResourceResolver) (S nSlots: c.nSlots, outSlot: outSlot, steps: c.steps, + usesUV: c.usesUV, }, nil } @@ -208,6 +220,7 @@ type compiler struct { nSlots int resolver ResourceResolver images *imageCache + usesUV bool } func (c *compiler) compileNode(name string) (int, error) { @@ -337,7 +350,8 @@ func buildPosition(_ *compiler, n *node) (evalFn, error) { return func(ctx *SampleContext, _ []Value) Value { return Vec3Value(ctx.Pos) }, nil } -func buildTexcoord(_ *compiler, n *node) (evalFn, error) { +func buildTexcoord(c *compiler, n *node) (evalFn, error) { + c.usesUV = true // MaterialX texcoord exposes a UV channel index (default 0). We // only plumb a single UV channel through SampleContext.UV — any // non-zero index would silently return the same UV. Fail loudly @@ -562,6 +576,7 @@ func buildMix(c *compiler, n *node) (evalFn, error) { // output anyway, so a linearize-then-encode round-trip would only add // rounding error. func buildImage(c *compiler, n *node) (evalFn, error) { + c.usesUV = true fileIn, ok := n.inputsByName["file"] if !ok { return nil, fmt.Errorf("image node: missing required %q input", "file") diff --git a/internal/pipeline/materialx_override.go b/internal/pipeline/materialx_override.go index 0d9011f..bbb55bb 100644 --- a/internal/pipeline/materialx_override.go +++ b/internal/pipeline/materialx_override.go @@ -3,28 +3,107 @@ package pipeline import ( "fmt" "log" + "math" "github.com/rtwfroody/ditherforge/internal/materialx" "github.com/rtwfroody/ditherforge/internal/voxel" ) -// materialxOverride adapts a materialx.Sampler to voxel.BaseColorOverride, -// scaling input mm-positions into shading-unit positions before sampling -// and clamping the resulting RGB into 8-bit range. Reentrant — Sampler -// guarantees concurrent-safe Sample calls. +// materialxOverride adapts a materialx.Sampler to voxel.BaseColorOverride. +// +// For purely position-driven graphs (procedural marble, brick, etc.) +// the adapter forwards a single SampleAt call with the world-space mm +// position scaled by 1/tileMM. For graphs that consume UVs (image- +// backed PBR packs) it triplanar-projects: three SampleAt calls along +// the YZ, XZ, and XY planes, blended by |normal|^sharpness. This gives +// untextured meshes a continuous, seam-light texture without UV +// authoring. +// +// All fields are immutable after construction. Reentrant by virtue of +// the underlying Sampler being reentrant. type materialxOverride struct { sampler materialx.Sampler invTileMM float64 + useUV bool + sharpness float64 } // SampleBaseColor implements voxel.BaseColorOverride. -func (m *materialxOverride) SampleBaseColor(p [3]float32) [3]uint8 { +func (m *materialxOverride) SampleBaseColor(ctx voxel.BaseColorContext) [3]uint8 { pos := [3]float64{ - float64(p[0]) * m.invTileMM, - float64(p[1]) * m.invTileMM, - float64(p[2]) * m.invTileMM, + float64(ctx.Pos[0]) * m.invTileMM, + float64(ctx.Pos[1]) * m.invTileMM, + float64(ctx.Pos[2]) * m.invTileMM, } - rgb := m.sampler.Sample(pos) + if !m.useUV { + // Procedural graphs ignore UV — one call is enough. + return rgbToBytes(m.sampler.SampleAt(materialx.SampleContext{Pos: pos})) + } + return rgbToBytes(m.triplanar(pos, ctx.Normal)) +} + +// triplanar runs the underlying sampler three times against the three +// axis-aligned planes (YZ for X-facing surfaces, XZ for Y, XY for Z) +// and blends by the normal-derived weights. Sharpness controls how +// abruptly the projection switches across axis transitions: 1 is a +// soft cosine-weighted blend, higher values approach a hard box map. +func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]float64 { + nx := math.Abs(float64(normal[0])) + ny := math.Abs(float64(normal[1])) + nz := math.Abs(float64(normal[2])) + + sharp := m.sharpness + if sharp <= 0 { + sharp = 4 + } + wx := math.Pow(nx, sharp) + wy := math.Pow(ny, sharp) + wz := math.Pow(nz, sharp) + sum := wx + wy + wz + if sum < 1e-12 { + // Degenerate normal — fall back to one planar sample so we at + // least produce a defined color rather than black. + return m.sampler.SampleAt(materialx.SampleContext{ + Pos: pos, + UV: [2]float64{pos[0], pos[1]}, + }) + } + wx /= sum + wy /= sum + wz /= sum + + var out [3]float64 + if wx > 1e-6 { + c := m.sampler.SampleAt(materialx.SampleContext{ + Pos: pos, + UV: [2]float64{pos[1], pos[2]}, + }) + out[0] += c[0] * wx + out[1] += c[1] * wx + out[2] += c[2] * wx + } + if wy > 1e-6 { + c := m.sampler.SampleAt(materialx.SampleContext{ + Pos: pos, + UV: [2]float64{pos[0], pos[2]}, + }) + out[0] += c[0] * wy + out[1] += c[1] * wy + out[2] += c[2] * wy + } + if wz > 1e-6 { + c := m.sampler.SampleAt(materialx.SampleContext{ + Pos: pos, + UV: [2]float64{pos[0], pos[1]}, + }) + out[0] += c[0] * wz + out[1] += c[1] * wz + out[2] += c[2] * wz + } + return out +} + +func rgbToBytes(rgb [3]float64) [3]uint8 { return [3]uint8{ floatToByte(rgb[0]), floatToByte(rgb[1]), @@ -50,19 +129,20 @@ func floatToByte(f float64) uint8 { return uint8(v) } -// buildBaseColorOverride parses a MaterialX document and returns an -// override that samples its first material's base_color graph. tileMM -// converts world-space mm positions into the procedural's shading -// frame; values <= 0 are treated as 1 mm (no scaling). Returns -// (nil, nil) when content is empty so callers can pass the result +// buildBaseColorOverride parses a MaterialX package (path to .mtlx or +// .zip) and returns an override that samples its first material's +// base_color graph. tileMM scales world-space mm into the procedural's +// shading frame (values <= 0 are treated as 1 mm). triplanarSharpness +// only matters for image-backed graphs; <= 0 picks a sensible default. +// Returns (nil, nil) when path is empty so callers can pass the result // straight through to the voxelizer. -func buildBaseColorOverride(content string, tileMM float64) (voxel.BaseColorOverride, error) { - if content == "" { +func buildBaseColorOverride(path string, tileMM, triplanarSharpness float64) (voxel.BaseColorOverride, error) { + if path == "" { return nil, nil } - doc, err := materialx.ParseBytes([]byte(content)) + doc, err := materialx.ParsePackage(path) if err != nil { - return nil, fmt.Errorf("parse MaterialX: %w", err) + return nil, fmt.Errorf("MaterialX package %q: %w", path, err) } s, err := doc.DefaultBaseColorSampler() if err != nil { @@ -71,16 +151,22 @@ func buildBaseColorOverride(content string, tileMM float64) (voxel.BaseColorOver if tileMM <= 0 { tileMM = 1 } - return &materialxOverride{sampler: s, invTileMM: 1 / tileMM}, nil + return &materialxOverride{ + sampler: s, + invTileMM: 1 / tileMM, + useUV: s.UsesUV(), + sharpness: triplanarSharpness, + }, nil } -// safeBaseColorOverride wraps buildBaseColorOverride in a logging helper -// for the run path: a malformed .mtlx is reported as a warning and the -// pipeline proceeds without the override (mirroring how applyBaseColor -// handles invalid hex colors). Returns nil on any error so the -// downstream voxel sampler falls back to per-face base colors. -func safeBaseColorOverride(content string, tileMM float64) voxel.BaseColorOverride { - o, err := buildBaseColorOverride(content, tileMM) +// safeBaseColorOverride wraps buildBaseColorOverride in a logging +// helper for the run path: a malformed .mtlx is reported as a warning +// and the pipeline proceeds without the override (mirroring how +// applyBaseColor handles invalid hex colors). Returns nil on any +// error so the downstream voxel sampler falls back to per-face base +// colors. +func safeBaseColorOverride(path string, tileMM, triplanarSharpness float64) voxel.BaseColorOverride { + o, err := buildBaseColorOverride(path, tileMM, triplanarSharpness) if err != nil { log.Printf("Warning: ignoring MaterialX base color: %v", err) return nil diff --git a/internal/pipeline/materialx_override_test.go b/internal/pipeline/materialx_override_test.go new file mode 100644 index 0000000..be8c789 --- /dev/null +++ b/internal/pipeline/materialx_override_test.go @@ -0,0 +1,137 @@ +package pipeline + +import ( + "math" + "testing" + + "github.com/rtwfroody/ditherforge/internal/materialx" + "github.com/rtwfroody/ditherforge/internal/voxel" +) + +// fakeSampler is a UV-aware materialx.Sampler stub that delegates each +// SampleAt call to a caller-supplied function so triplanar tests can +// observe which UV plane the adapter chose. +type fakeSampler struct { + cb func(ctx materialx.SampleContext) [3]float64 + usesUV bool +} + +func (f *fakeSampler) Sample(p [3]float64) [3]float64 { return f.cb(materialx.SampleContext{Pos: p}) } +func (f *fakeSampler) SampleAt(ctx materialx.SampleContext) [3]float64 { return f.cb(ctx) } +func (f *fakeSampler) UsesUV() bool { return f.usesUV } + +// TestTriplanarPicksDominantPlane verifies that with a sharply-axial +// face normal, the triplanar adapter returns nearly the color +// produced by the corresponding plane: +Z normal → XY plane, +X → YZ, +// +Y → XZ. The fake sampler returns a distinct color per plane based +// on which UV component pair the adapter handed it. +// +// Positions are picked to be exactly representable in float32 so the +// fake can identify the plane by exact-equality check on UV. +func TestTriplanarPicksDominantPlane(t *testing.T) { + pos32 := [3]float32{0.25, 0.5, 0.75} + px, py, pz := float64(pos32[0]), float64(pos32[1]), float64(pos32[2]) + colors := [3][3]float64{ + {1, 0, 0}, // YZ plane → red + {0, 1, 0}, // XZ plane → green + {0, 0, 1}, // XY plane → blue + } + fake := &fakeSampler{ + usesUV: true, + cb: func(ctx materialx.SampleContext) [3]float64 { + switch { + case ctx.UV[0] == py && ctx.UV[1] == pz: + return colors[0] // YZ + case ctx.UV[0] == px && ctx.UV[1] == pz: + return colors[1] // XZ + case ctx.UV[0] == px && ctx.UV[1] == py: + return colors[2] // XY + } + t.Errorf("unexpected UV %v", ctx.UV) + return [3]float64{} + }, + } + o := &materialxOverride{sampler: fake, invTileMM: 1, useUV: true, sharpness: 8} + + cases := []struct { + normal [3]float32 + want [3]float64 + }{ + {[3]float32{0, 0, 1}, colors[2]}, // XY dominates + {[3]float32{1, 0, 0}, colors[0]}, // YZ dominates + {[3]float32{0, 1, 0}, colors[1]}, // XZ dominates + } + for _, tc := range cases { + got := o.SampleBaseColor(voxel.BaseColorContext{ + Pos: pos32, + Normal: tc.normal, + }) + // Sharpness=8 with one component=1 and others=0 produces ~100% + // weight for the dominant plane; the result should be the pure + // dominant color. + want := [3]uint8{ + uint8(math.Round(tc.want[0] * 255)), + uint8(math.Round(tc.want[1] * 255)), + uint8(math.Round(tc.want[2] * 255)), + } + if got != want { + t.Errorf("normal=%v: got %v, want %v", tc.normal, got, want) + } + } +} + +// TestTriplanarBlendsWhenNormalIsDiagonal — a 45° normal in the XY +// plane should blend YZ and XZ samples roughly equally; XY is +// suppressed because |z|=0. +func TestTriplanarBlendsWhenNormalIsDiagonal(t *testing.T) { + pos32 := [3]float32{0.25, 0.5, 0.75} + px, py, pz := float64(pos32[0]), float64(pos32[1]), float64(pos32[2]) + colors := map[[2]float64][3]float64{ + {py, pz}: {1, 0, 0}, // YZ → red + {px, pz}: {0, 1, 0}, // XZ → green + {px, py}: {0, 0, 1}, // XY → blue (should be 0-weighted with z=0) + } + fake := &fakeSampler{ + usesUV: true, + cb: func(ctx materialx.SampleContext) [3]float64 { + c, ok := colors[ctx.UV] + if !ok { + t.Errorf("unexpected UV %v", ctx.UV) + } + return c + }, + } + o := &materialxOverride{sampler: fake, invTileMM: 1, useUV: true, sharpness: 4} + + got := o.SampleBaseColor(voxel.BaseColorContext{ + Pos: pos32, + Normal: [3]float32{0.7071, 0.7071, 0}, // XY-plane diagonal + }) + // Expect roughly (0.5, 0.5, 0) ± rounding. Blue (XY plane) should + // not contribute since |normal.z|=0. + if got[0] < 100 || got[0] > 155 || got[1] < 100 || got[1] > 155 || got[2] > 20 { + t.Errorf("expected ~50/50 red+green blend, got %v", got) + } +} + +// TestNonUVSamplerSkipsTriplanar verifies the fast path: a +// non-UV-using sampler is consulted exactly once per call regardless +// of normal, since no projection is needed. +func TestNonUVSamplerSkipsTriplanar(t *testing.T) { + calls := 0 + fake := &fakeSampler{ + usesUV: false, + cb: func(ctx materialx.SampleContext) [3]float64 { + calls++ + return [3]float64{0.5, 0.5, 0.5} + }, + } + o := &materialxOverride{sampler: fake, invTileMM: 1, useUV: false, sharpness: 4} + o.SampleBaseColor(voxel.BaseColorContext{ + Pos: [3]float32{0, 0, 0}, + Normal: [3]float32{0.577, 0.577, 0.577}, // diagonal would trigger 3-way blend + }) + if calls != 1 { + t.Errorf("non-UV sampler should be called once per SampleBaseColor; got %d", calls) + } +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 884ddf5..476ca38 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -10,6 +10,7 @@ import ( _ "image/jpeg" _ "image/png" "log" + "math" "path/filepath" "strconv" "strings" @@ -32,17 +33,26 @@ type Options struct { Scale float32 Output string BaseColor string // hex color for untextured faces (e.g. "#FF0000"); empty = use model default - // BaseColorMaterialX is the contents of a .mtlx file applied to - // untextured faces as a procedural base color. When non-empty it - // takes precedence over BaseColor. Stored as content (not path) so - // the cache key reflects the actual graph definition; cheap because - // procedural .mtlx files are KB-scale text. + // BaseColorMaterialX is the path to a .mtlx file or a .zip archive + // containing one (with adjacent textures) applied to untextured + // faces as a procedural or image-backed base color. When non-empty + // it takes precedence over BaseColor. Cache invalidation tracks + // the file's mtime + size; in-place edits without mtime change + // won't be picked up. BaseColorMaterialX string // BaseColorMaterialXTileMM scales positions before sampling the // MaterialX graph: a value of 10 means one shading-unit cycle of // the procedural maps to 10 mm of object space. Zero or negative // is treated as 1 mm (i.e. raw position). BaseColorMaterialXTileMM float64 + // BaseColorMaterialXTriplanarSharpness controls the blend + // weighting for image-backed graphs that get triplanar-projected + // onto untextured faces. Higher values produce sharper transitions + // between the three axis-aligned projections (closer to a hard box + // map); lower values blend more softly. Zero or negative falls + // back to a sensible default (4). Ignored by purely position- + // driven graphs (marble, brick). + BaseColorMaterialXTriplanarSharpness float64 NozzleDiameter float32 LayerHeight float32 // Printer is the printer profile ID (e.g. "snapmaker_u1") used when @@ -515,7 +525,8 @@ func applyBaseColorOverride(model *loader.LoadedModel, hexColor string) { func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { if lo.appliedBaseColor == opts.BaseColor && lo.appliedBaseColorMaterialX == opts.BaseColorMaterialX && - lo.appliedBaseColorMaterialXTileMM == opts.BaseColorMaterialXTileMM { + lo.appliedBaseColorMaterialXTileMM == opts.BaseColorMaterialXTileMM && + lo.appliedBaseColorMaterialXTriplanarSharpness == opts.BaseColorMaterialXTriplanarSharpness { return } pristine := lo.appliedBaseColor == "" && lo.appliedBaseColorMaterialX == "" @@ -531,10 +542,15 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { } switch { case opts.BaseColorMaterialX != "": - // Build the override once and reuse it across both models — parsing - // the .mtlx and validating the graph is cheap but non-trivial, and - // run.go also builds its own override for per-voxel sampling. - override, err := buildBaseColorOverride(opts.BaseColorMaterialX, opts.BaseColorMaterialXTileMM) + // Build the override once and reuse it across both models — + // parsing the .mtlx (and decoding any referenced textures) is + // cheap but non-trivial, and run.go also builds its own + // override for per-voxel sampling. + override, err := buildBaseColorOverride( + opts.BaseColorMaterialX, + opts.BaseColorMaterialXTileMM, + opts.BaseColorMaterialXTriplanarSharpness, + ) if err != nil { log.Printf("Warning: ignoring MaterialX base color: %v", err) } else if override != nil { @@ -553,6 +569,7 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { lo.appliedBaseColor = opts.BaseColor lo.appliedBaseColorMaterialX = opts.BaseColorMaterialX lo.appliedBaseColorMaterialXTileMM = opts.BaseColorMaterialXTileMM + lo.appliedBaseColorMaterialXTriplanarSharpness = opts.BaseColorMaterialXTriplanarSharpness } // bakeMaterialXBaseColor evaluates the procedural at every untextured @@ -569,12 +586,25 @@ func bakeMaterialXBaseColor(model *loader.LoadedModel, override voxel.BaseColorO v0 := model.Vertices[f[0]] v1 := model.Vertices[f[1]] v2 := model.Vertices[f[2]] - c := [3]float32{ + centroid := [3]float32{ (v0[0] + v1[0] + v2[0]) / 3, (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3, } - rgb := override.SampleBaseColor(c) + // Face normal via cross product, normalized; degenerate faces + // get the zero vector and the override's triplanar fallback + // kicks in. + e1x, e1y, e1z := v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2] + e2x, e2y, e2z := v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2] + nx := e1y*e2z - e1z*e2y + ny := e1z*e2x - e1x*e2z + nz := e1x*e2y - e1y*e2x + nl := float32(math.Sqrt(float64(nx*nx + ny*ny + nz*nz))) + var normal [3]float32 + if nl > 1e-9 { + normal = [3]float32{nx / nl, ny / nl, nz / nl} + } + rgb := override.SampleBaseColor(voxel.BaseColorContext{Pos: centroid, Normal: normal}) model.FaceBaseColor[i] = [4]uint8{rgb[0], rgb[1], rgb[2], 255} } } diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index b5d0612..e014380 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -509,7 +509,11 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) { } } - baseColorOverride := safeBaseColorOverride(r.opts.BaseColorMaterialX, r.opts.BaseColorMaterialXTileMM) + baseColorOverride := safeBaseColorOverride( + r.opts.BaseColorMaterialX, + r.opts.BaseColorMaterialXTileMM, + r.opts.BaseColorMaterialXTriplanarSharpness, + ) result, verr := squarevoxel.VoxelizeTwoGrids(r.ctx, lo.Model, sampleModel, stickerModel, stickerSI, layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo, diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go index b307b29..2092659 100644 --- a/internal/pipeline/stepcache.go +++ b/internal/pipeline/stepcache.go @@ -379,15 +379,16 @@ type loadOutput struct { InputMesh *MeshData PreviewScale float32 // scale factor to convert pipeline coords back to preview coords ExtentMM float32 // native max bounding-box extent in mm (scale=1.0, size=unset) - // appliedBaseColor / appliedBaseColorMaterialX / appliedBaseColorMaterialXTileMM + // appliedBaseColor / appliedBaseColorMaterialX{,TileMM,TriplanarSharpness} // track the base-color override currently baked into ColorModel / - // SampleModel FaceBaseColor. The triple is the cache key for the + // SampleModel FaceBaseColor. The tuple is the cache key for the // in-place mutation: when any field diverges from the corresponding // opts.* value, applyBaseColor resets from the parse cache and re-bakes. - // All three empty/zero means pristine. - appliedBaseColor string - appliedBaseColorMaterialX string - appliedBaseColorMaterialXTileMM float64 + // All empty/zero means pristine. + appliedBaseColor string + appliedBaseColorMaterialX string + appliedBaseColorMaterialXTileMM float64 + appliedBaseColorMaterialXTriplanarSharpness float64 } type voxelizeOutput struct { @@ -576,21 +577,27 @@ type loadSettings struct { // because runSticker deep-clones ColorModel into so.Model and the per-run // reapply step does not patch that scratch copy. type voxelizeSettings struct { - NozzleDiameter float32 - LayerHeight float32 - BaseColor string - BaseColorMaterialX string - BaseColorMaterialXTileMM float64 + NozzleDiameter float32 + LayerHeight float32 + BaseColor string + BaseColorMaterialX string // path + BaseColorMaterialXMTime int64 // ns; 0 if file is missing/inaccessible + BaseColorMaterialXSize int64 // bytes; 0 if file is missing/inaccessible + BaseColorMaterialXTileMM float64 + BaseColorMaterialXTriplanarSharpness float64 } type stickerSettings struct { Stickers []Sticker - // BaseColor / BaseColorMaterialX / BaseColorMaterialXTileMM are - // included so any base-color change invalidates the sticker stage. - // See voxelizeSettings doc above for the reason. - BaseColor string - BaseColorMaterialX string - BaseColorMaterialXTileMM float64 + // BaseColor / BaseColorMaterialX{,MTime,Size,TileMM,TriplanarSharpness} + // are included so any base-color change invalidates the sticker + // stage. See voxelizeSettings doc above for the reason. + BaseColor string + BaseColorMaterialX string + BaseColorMaterialXMTime int64 + BaseColorMaterialXSize int64 + BaseColorMaterialXTileMM float64 + BaseColorMaterialXTriplanarSharpness float64 // AlphaWrap toggling changes the sticker substrate (wrap mesh vs. // original mesh), so decals built for one substrate are invalid when // the toggle changes. AlphaWrapAlpha and AlphaWrapOffset live in @@ -697,20 +704,28 @@ func (c *StageCache) settingsForStage(stage StageID, opts Options) any { } return s case StageVoxelize: + mtime, size := materialXFileStamp(opts.BaseColorMaterialX) return voxelizeSettings{ - NozzleDiameter: opts.NozzleDiameter, - LayerHeight: opts.LayerHeight, - BaseColor: opts.BaseColor, - BaseColorMaterialX: opts.BaseColorMaterialX, - BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, + NozzleDiameter: opts.NozzleDiameter, + LayerHeight: opts.LayerHeight, + BaseColor: opts.BaseColor, + BaseColorMaterialX: opts.BaseColorMaterialX, + BaseColorMaterialXMTime: mtime, + BaseColorMaterialXSize: size, + BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, + BaseColorMaterialXTriplanarSharpness: opts.BaseColorMaterialXTriplanarSharpness, } case StageSticker: + mtime, size := materialXFileStamp(opts.BaseColorMaterialX) return stickerSettings{ - Stickers: opts.Stickers, - BaseColor: opts.BaseColor, - BaseColorMaterialX: opts.BaseColorMaterialX, - BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, - AlphaWrap: opts.AlphaWrap, + Stickers: opts.Stickers, + BaseColor: opts.BaseColor, + BaseColorMaterialX: opts.BaseColorMaterialX, + BaseColorMaterialXMTime: mtime, + BaseColorMaterialXSize: size, + BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM, + BaseColorMaterialXTriplanarSharpness: opts.BaseColorMaterialXTriplanarSharpness, + AlphaWrap: opts.AlphaWrap, } case StageColorAdjust: return colorAdjustSettings{Brightness: opts.Brightness, Contrast: opts.Contrast, Saturation: opts.Saturation} @@ -755,6 +770,21 @@ func (c *StageCache) settingsForStage(stage StageID, opts Options) any { return nil } +// materialXFileStamp returns the mtime (ns) and size (bytes) of the +// MaterialX package file at path, or (0, 0) if the file is missing or +// inaccessible. Used to invalidate the voxelize/sticker stage caches +// when the .mtlx or .zip on disk changes. +func materialXFileStamp(path string) (mtime, size int64) { + if path == "" { + return 0, 0 + } + info, err := os.Stat(path) + if err != nil { + return 0, 0 + } + return info.ModTime().UnixNano(), info.Size() +} + // stageFnv hashes a single stage's settings to a uint64. Used as the // per-stage component of the cumulative stageKey. func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 { @@ -776,11 +806,17 @@ func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 { writeFloat32(h, v.LayerHeight) writeString(h, v.BaseColor) writeString(h, v.BaseColorMaterialX) + binary.Write(h, binary.LittleEndian, v.BaseColorMaterialXMTime) + binary.Write(h, binary.LittleEndian, v.BaseColorMaterialXSize) writeFloat64(h, v.BaseColorMaterialXTileMM) + writeFloat64(h, v.BaseColorMaterialXTriplanarSharpness) case stickerSettings: writeString(h, v.BaseColor) writeString(h, v.BaseColorMaterialX) + binary.Write(h, binary.LittleEndian, v.BaseColorMaterialXMTime) + binary.Write(h, binary.LittleEndian, v.BaseColorMaterialXSize) writeFloat64(h, v.BaseColorMaterialXTileMM) + writeFloat64(h, v.BaseColorMaterialXTriplanarSharpness) writeBool(h, v.AlphaWrap) writeInt(h, len(v.Stickers)) for _, s := range v.Stickers { diff --git a/internal/pipeline/stepcache_test.go b/internal/pipeline/stepcache_test.go index ff777b6..4d1e6dd 100644 --- a/internal/pipeline/stepcache_test.go +++ b/internal/pipeline/stepcache_test.go @@ -1,6 +1,24 @@ package pipeline -import "testing" +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// writeMtlxTempFile drops a tiny .mtlx file into a freshly-created +// temp dir and returns its path. Test helper for the MaterialX cache +// tests below — the contents don't have to be a valid graph since +// settingsForStage only stat()s the file. +func writeMtlxTempFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "graph.mtlx") + if err := os.WriteFile(p, []byte(body), 0644); err != nil { + t.Fatalf("write mtlx: %v", err) + } + return p +} // TestStickerStageKeyDependsOnBaseColor guards a cache-coherency contract: // runSticker deep-clones lo.ColorModel into so.Model, including @@ -58,51 +76,78 @@ func TestVoxelizeStageKeyDependsOnBaseColor(t *testing.T) { } // TestVoxelizeStageKeyDependsOnMaterialX guards the same invalidation -// contract for the procedural base-color override: changing the .mtlx -// content or its tile size must invalidate the voxelize cache because -// the per-voxel sampler reads them. +// contract for the MaterialX base-color override: changing the path, +// the file's bytes (via mtime/size), the tile size, or the triplanar +// sharpness must each independently invalidate the voxelize cache +// because the per-voxel sampler reads them. func TestVoxelizeStageKeyDependsOnMaterialX(t *testing.T) { c := NewStageCache() + mtlxA := writeMtlxTempFile(t, "") + mtlxB := writeMtlxTempFile(t, "") base := Options{ - Input: "model.glb", - BaseColorMaterialX: "", - BaseColorMaterialXTileMM: 10, + Input: "model.glb", + BaseColorMaterialX: mtlxA, + BaseColorMaterialXTileMM: 10, + BaseColorMaterialXTriplanarSharpness: 4, } - contentChanged := base - contentChanged.BaseColorMaterialX = "" + pathChanged := base + pathChanged.BaseColorMaterialX = mtlxB tileChanged := base tileChanged.BaseColorMaterialXTileMM = 20 + sharpChanged := base + sharpChanged.BaseColorMaterialXTriplanarSharpness = 8 - if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, contentChanged) { - t.Error("StageVoxelize key did not change when BaseColorMaterialX content changed") + if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, pathChanged) { + t.Error("StageVoxelize key did not change when BaseColorMaterialX path changed") } if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, tileChanged) { t.Error("StageVoxelize key did not change when BaseColorMaterialXTileMM changed") } + if c.stageFnv(StageVoxelize, base) == c.stageFnv(StageVoxelize, sharpChanged) { + t.Error("StageVoxelize key did not change when BaseColorMaterialXTriplanarSharpness changed") + } + + // Edit-in-place: same path, larger file. Different size must + // invalidate even though the path string is unchanged. + beforeEdit := c.stageFnv(StageVoxelize, base) + if err := os.WriteFile(mtlxA, []byte(""), 0644); err != nil { + t.Fatalf("rewrite mtlx: %v", err) + } + // Bump mtime forward a hair to defeat filesystems that only stamp + // at second granularity. + future := time.Now().Add(2 * time.Second) + _ = os.Chtimes(mtlxA, future, future) + afterEdit := c.stageFnv(StageVoxelize, base) + if beforeEdit == afterEdit { + t.Error("StageVoxelize key did not change after rewriting the .mtlx file (mtime/size hash broken)") + } } // TestStickerStageKeyDependsOnMaterialX is the sticker-stage analogue -// of TestStickerStageKeyDependsOnBaseColor for the procedural override. +// of TestStickerStageKeyDependsOnBaseColor for the MaterialX override. // runSticker deep-clones lo.ColorModel into so.Model with whatever -// procedural pattern was baked into FaceBaseColor by the per-face -// preview bake; changing the .mtlx must invalidate that cached clone. +// pattern was baked into FaceBaseColor by the per-face preview bake; +// any change to the underlying .mtlx must invalidate that cached +// clone. func TestStickerStageKeyDependsOnMaterialX(t *testing.T) { c := NewStageCache() + mtlxA := writeMtlxTempFile(t, "") + mtlxB := writeMtlxTempFile(t, "") base := Options{ Input: "model.glb", - BaseColorMaterialX: "", + BaseColorMaterialX: mtlxA, BaseColorMaterialXTileMM: 10, Stickers: []Sticker{ {ImagePath: "sticker.png", Mode: "unfold", Scale: 1, MaxAngle: 90}, }, } - contentChanged := base - contentChanged.BaseColorMaterialX = "" + pathChanged := base + pathChanged.BaseColorMaterialX = mtlxB tileChanged := base tileChanged.BaseColorMaterialXTileMM = 20 - if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, contentChanged) { - t.Error("StageSticker key did not change when BaseColorMaterialX content changed; " + + if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, pathChanged) { + t.Error("StageSticker key did not change when BaseColorMaterialX path changed; " + "so.Model.FaceBaseColor would be stale on a cached run") } if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, tileChanged) { @@ -116,14 +161,18 @@ func TestStickerStageKeyDependsOnMaterialX(t *testing.T) { // changes. func TestLoadAndDecimateStageKeysIndependentOfMaterialX(t *testing.T) { c := NewStageCache() + mtlxA := writeMtlxTempFile(t, "") + mtlxB := writeMtlxTempFile(t, "") base := Options{ - Input: "model.glb", - BaseColorMaterialX: "", - BaseColorMaterialXTileMM: 10, + Input: "model.glb", + BaseColorMaterialX: mtlxA, + BaseColorMaterialXTileMM: 10, + BaseColorMaterialXTriplanarSharpness: 4, } changed := base - changed.BaseColorMaterialX = "" + changed.BaseColorMaterialX = mtlxB changed.BaseColorMaterialXTileMM = 20 + changed.BaseColorMaterialXTriplanarSharpness = 8 if c.stageFnv(StageLoad, base) != c.stageFnv(StageLoad, changed) { t.Error("StageLoad key changed on MaterialX change; load cache should survive") diff --git a/internal/voxel/color.go b/internal/voxel/color.go index 79945dc..1501f5e 100644 --- a/internal/voxel/color.go +++ b/internal/voxel/color.go @@ -268,14 +268,46 @@ func FaceAlpha(faceIdx int, model *loader.LoadedModel) uint8 { return uint8(ClampF(a+0.5, 0, 255)) } +// BaseColorContext bundles the inputs an override may consult when +// sampling. Pos is the world-space sample point in the original +// (pre-split) mesh frame; Normal is the unit-length surface normal of +// the closest face (zero when the face is degenerate). Image-backed +// MaterialX graphs use Normal to drive triplanar projection; pure- +// procedural graphs ignore it. +type BaseColorContext struct { + Pos [3]float32 + Normal [3]float32 +} + // BaseColorOverride supplies a procedural replacement for an // untextured face's base color, evaluated at the sample's 3D position. -// p is in the same coord frame Sample* receives — i.e. the original -// (pre-split) mesh frame. Implementations must be safe to call from -// many goroutines concurrently. Returns RGB only; alpha continues to -// come from the model's per-face material. +// Implementations must be safe to call from many goroutines +// concurrently. Returns RGB only; alpha continues to come from the +// model's per-face material. type BaseColorOverride interface { - SampleBaseColor(p [3]float32) [3]uint8 + SampleBaseColor(ctx BaseColorContext) [3]uint8 +} + +// faceNormal returns the unit-length normal of the face at faceIdx +// computed from its three vertex positions. Returns the zero vector +// when the face is degenerate (e.g. zero area). +func faceNormal(faceIdx int, model *loader.LoadedModel) [3]float32 { + f := model.Faces[faceIdx] + v0 := model.Vertices[f[0]] + v1 := model.Vertices[f[1]] + v2 := model.Vertices[f[2]] + e1 := [3]float32{v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]} + e2 := [3]float32{v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]} + n := [3]float32{ + e1[1]*e2[2] - e1[2]*e2[1], + e1[2]*e2[0] - e1[0]*e2[2], + e1[0]*e2[1] - e1[1]*e2[0], + } + l := float32(math.Sqrt(float64(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]))) + if l < 1e-9 { + return [3]float32{} + } + return [3]float32{n[0] / l, n[1] / l, n[2] / l} } // SampleNearestColor finds the closest surface point to p on `model`, then @@ -330,7 +362,10 @@ func SampleNearestColorWithSticker( matAlpha, bc, texIdx := faceMaterial(int(bestTri), model) if override != nil && (texIdx < 0 || int(texIdx) >= len(model.Textures)) { - rgb := override.SampleBaseColor(p) + rgb := override.SampleBaseColor(BaseColorContext{ + Pos: p, + Normal: faceNormal(int(bestTri), model), + }) bc[0], bc[1], bc[2] = rgb[0], rgb[1], rgb[2] } f := model.Faces[bestTri] From 4581a1ef91aaa7ec6600dd56677bb66c5e5de6b1 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 18:20:07 -0700 Subject: [PATCH 06/13] Frontend: accept .mtlx and .zip; expose triplanar sharpness slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MaterialX file picker now accepts both .mtlx files (with adjacent texture directories) and .zip archives containing a .mtlx + textures. The pipeline reads from the path at run time, so settings only persist the path — drops the now-redundant baseMaterialXContent that was a workaround for the previous inline-content cache key. A new "Triplanar" input lets the user trade soft cosine blending for sharp box-mapping when an image-backed graph gets projected onto untextured faces. Defaults to 4 (a reasonable middle ground); the existing tile-size knob's helper text is updated to mention image packs alongside procedurals. Wails bindings (models.ts, App.d.ts, App.js, app.go MaterialXOpenResult) are updated to drop the content field and add the triplanar sharpness to both Settings and pipeline.Options. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.go | 39 ++++++++++++++---------------- frontend/src/App.svelte | 45 ++++++++++++++++++++--------------- frontend/wailsjs/go/models.ts | 6 +++-- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/app.go b/app.go index 642f15d..146c32a 100644 --- a/app.go +++ b/app.go @@ -706,30 +706,25 @@ func (a *App) OpenStickerImage() (string, error) { // MaterialXOpenResult is the result of OpenMaterialXFile. Path is // empty when the user cancels. type MaterialXOpenResult struct { - Path string `json:"path"` - Content string `json:"content"` + Path string `json:"path"` } -// OpenMaterialXFile opens a file dialog for selecting a procedural -// MaterialX (.mtlx) file. Returns the path and the file contents -// (UTF-8) so the frontend can hold the contents directly — no -// secondary read step is needed, and the cache key reflects the actual -// graph definition. +// OpenMaterialXFile opens a file dialog for selecting a MaterialX +// .mtlx file or a .zip archive containing one (with adjacent +// textures). The pipeline opens the file directly from the path at +// run time — there's no need to round-trip its content through the +// frontend. func (a *App) OpenMaterialXFile() (*MaterialXOpenResult, error) { path, err := wailsRuntime.OpenFileDialog(a.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Select MaterialX File", + Title: "Select MaterialX File or Texture Pack", Filters: []wailsRuntime.FileFilter{ - {DisplayName: "MaterialX (*.mtlx)", Pattern: "*.mtlx"}, + {DisplayName: "MaterialX (*.mtlx, *.zip)", Pattern: "*.mtlx;*.zip"}, }, }) if err != nil || path == "" { return &MaterialXOpenResult{}, err } - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read materialx: %w", err) - } - return &MaterialXOpenResult{Path: path, Content: string(data)}, nil + return &MaterialXOpenResult{Path: path}, nil } // ReadStickerThumbnail reads a sticker image and returns a base64 data URL @@ -828,13 +823,15 @@ type Settings struct { LayerHeight string `json:"layerHeight"` BaseColor *ColorSlotSetting `json:"baseColor,omitempty"` // BaseMaterialXPath is the on-disk path of the user-selected .mtlx - // file (display only). BaseMaterialXContent is the file contents - // at the time the user picked the file — round-tripped through - // settings so the project rebuilds identically without re-reading - // the file. BaseMaterialXTileMM is the procedural-to-mm scale. - BaseMaterialXPath string `json:"baseMaterialXPath,omitempty"` - BaseMaterialXContent string `json:"baseMaterialXContent,omitempty"` - BaseMaterialXTileMM float64 `json:"baseMaterialXTileMM,omitempty"` + // file or .zip archive. The pipeline reads the file at run time — + // settings only stores the path, so projects assume the asset + // lives at the same path on the next machine. + // BaseMaterialXTileMM is the procedural-to-mm scale. + // BaseMaterialXTriplanarSharpness controls image-backed graphs' + // triplanar projection blend (ignored by procedural .mtlx). + BaseMaterialXPath string `json:"baseMaterialXPath,omitempty"` + BaseMaterialXTileMM float64 `json:"baseMaterialXTileMM,omitempty"` + BaseMaterialXTriplanarSharpness float64 `json:"baseMaterialXTriplanarSharpness,omitempty"` ColorSlots []*ColorSlotSetting `json:"colorSlots"` InventoryCollection string `json:"inventoryCollection"` Brightness float64 `json:"brightness"` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 639c134..dc7563f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -118,12 +118,13 @@ // Base color for untextured faces: null = use model default, or {hex, label, collection}. let baseColor = $state(null); let baseColorPickerOpen = $state(false); - // Procedural MaterialX base color. Mutually exclusive with `baseColor`. - // baseMaterialXContent is the actual file body (round-tripped through - // settings); baseMaterialXPath is for display only. + // MaterialX base color. Mutually exclusive with `baseColor`. + // baseMaterialXPath is read by the backend at pipeline run time; + // settings round-trips just the path. Accepts .mtlx (with adjacent + // textures) or a .zip containing both. let baseMaterialXPath = $state(''); - let baseMaterialXContent = $state(''); let baseMaterialXTileMM = $state(10); + let baseMaterialXTriplanarSharpness = $state(4); // Color palette: each slot is either null (auto) or a locked color with hex + label + source collection. type ColorInfo = { hex: string; label: string; collection?: string }; type ColorSlot = ColorInfo | null; @@ -566,7 +567,7 @@ $effect(() => { // Read all form values to establish tracking. void [inputFile, sizeMode, sizeValue, scaleValue, printerId, nozzleDiameter, - layerHeight, baseColor, baseMaterialXContent, baseMaterialXTileMM, ...colorSlots, + layerHeight, baseColor, baseMaterialXPath, baseMaterialXTileMM, baseMaterialXTriplanarSharpness, ...colorSlots, inventoryCollectionColors, committedBrightness, committedContrast, committedSaturation, JSON.stringify(warpPins), @@ -804,8 +805,8 @@ layerHeight: String(layerHeight), baseColor: baseColor ? { hex: baseColor.hex, label: baseColor.label, collection: baseColor.collection } : null, baseMaterialXPath, - baseMaterialXContent, baseMaterialXTileMM, + baseMaterialXTriplanarSharpness, colorSlots: colorSlots.map(s => s ? { hex: s.hex, label: s.label, collection: s.collection } : null), inventoryCollection, brightness, @@ -864,8 +865,8 @@ reconcilePrinterSelection(); if (s.baseColor !== undefined) baseColor = s.baseColor ? { hex: s.baseColor.hex, label: s.baseColor.label || '', collection: s.baseColor.collection || '' } : null; if (s.baseMaterialXPath !== undefined) baseMaterialXPath = s.baseMaterialXPath; - if (s.baseMaterialXContent !== undefined) baseMaterialXContent = s.baseMaterialXContent; if (s.baseMaterialXTileMM !== undefined) baseMaterialXTileMM = s.baseMaterialXTileMM; + if (s.baseMaterialXTriplanarSharpness !== undefined) baseMaterialXTriplanarSharpness = s.baseMaterialXTriplanarSharpness; if (s.colorSlots !== undefined) { colorSlots = s.colorSlots.map((c: any) => c ? { hex: c.hex, label: c.label || '', collection: c.collection || '' } : null); } @@ -1076,8 +1077,9 @@ LockedColors: colorSlots.filter((s): s is ColorInfo => s !== null).map(s => s.hex), Scale: sizeMode === 'scale' ? (parseFloat(scaleValue) || 1.0) : 1.0, BaseColor: baseColor?.hex ?? '', - BaseColorMaterialX: baseMaterialXContent, + BaseColorMaterialX: baseMaterialXPath, BaseColorMaterialXTileMM: baseMaterialXTileMM, + BaseColorMaterialXTriplanarSharpness: baseMaterialXTriplanarSharpness, NozzleDiameter: parseFloat(nozzleDiameter) || 0.4, LayerHeight: parseFloat(layerHeight) || 0.2, Printer: printerId, @@ -1346,15 +1348,15 @@ {:else} {/if} - {#if baseMaterialXContent} + {#if baseMaterialXPath}
- {baseMaterialXPath ? baseMaterialXPath.split(/[\\/]/).pop() : 'inline .mtlx'} + {baseMaterialXPath.split(/[\\/]/).pop()} - +
{:else if baseColor}
@@ -1373,34 +1375,40 @@ Default {/if} - {#if !baseMaterialXContent} + {#if !baseMaterialXPath}
MaterialX - Load a procedural MaterialX (.mtlx) file. Only solid procedurals (position-based noise/marble/brick) are supported — image-textured materials won't render anything visible. + Load a MaterialX (.mtlx) file or .zip archive containing one (with adjacent textures). Procedural graphs (marble, brick) and image-backed PBR packs are both supported.
+ }}>Load .mtlx / .zip {:else}
Tile size - Object-space scale (mm per shading-unit cycle) applied to the procedural before sampling. Smaller = denser pattern. + Object-space scale (mm per shading-unit cycle) applied before sampling. Smaller = denser pattern. For image-backed packs this is also the texture's repeat distance.
mm
+
+ Triplanar + + Sharpness of the triplanar projection blend for image-backed MaterialX. 1 is a soft cosine blend; higher values approach a hard box map. Ignored by procedural .mtlx that don't read texture coordinates. + +
+ {/if} {#if baseColorPickerOpen}
@@ -1408,8 +1416,7 @@ onselect={(hex, label, collection) => { baseColor = { hex, label, collection }; baseColorPickerOpen = false; - // Picking a hex retires the procedural. - baseMaterialXContent = ''; + // Picking a hex retires the MaterialX. baseMaterialXPath = ''; }} onclose={() => { baseColorPickerOpen = false; }} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e70c30b..6737e4b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -124,8 +124,8 @@ export namespace main { layerHeight: string; baseColor?: ColorSlotSetting; baseMaterialXPath?: string; - baseMaterialXContent?: string; baseMaterialXTileMM?: number; + baseMaterialXTriplanarSharpness?: number; colorSlots: ColorSlotSetting[]; inventoryCollection: string; brightness: number; @@ -166,8 +166,8 @@ export namespace main { this.layerHeight = source["layerHeight"]; this.baseColor = this.convertValues(source["baseColor"], ColorSlotSetting); this.baseMaterialXPath = source["baseMaterialXPath"]; - this.baseMaterialXContent = source["baseMaterialXContent"]; this.baseMaterialXTileMM = source["baseMaterialXTileMM"]; + this.baseMaterialXTriplanarSharpness = source["baseMaterialXTriplanarSharpness"]; this.colorSlots = this.convertValues(source["colorSlots"], ColorSlotSetting); this.inventoryCollection = source["inventoryCollection"]; this.brightness = source["brightness"]; @@ -391,6 +391,7 @@ export namespace pipeline { BaseColor: string; BaseColorMaterialX: string; BaseColorMaterialXTileMM: number; + BaseColorMaterialXTriplanarSharpness: number; NozzleDiameter: number; LayerHeight: number; Printer: string; @@ -430,6 +431,7 @@ export namespace pipeline { this.BaseColor = source["BaseColor"]; this.BaseColorMaterialX = source["BaseColorMaterialX"]; this.BaseColorMaterialXTileMM = source["BaseColorMaterialXTileMM"]; + this.BaseColorMaterialXTriplanarSharpness = source["BaseColorMaterialXTriplanarSharpness"]; this.NozzleDiameter = source["NozzleDiameter"]; this.LayerHeight = source["LayerHeight"]; this.Printer = source["Printer"]; From 3f24c70a38392263031f3476a697407dcad5c51d Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 18:41:20 -0700 Subject: [PATCH 07/13] materialx: cache compiled sampler, sign-flipped triplanar, tracker warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in the review punch list from the prior review pass: * Compiled materialx.Sampler is now cached on StageCache by (path, mtime, size). applyBaseColor (preview bake) and the voxelize stage share one parse + image decode per pipeline run, vs. two before. Errors are cached too so a malformed .mtlx isn't re-attempted on every consumer call. * Triplanar UV signs now flip with sign(normal.axis) so a directional texture (text, arrows) reads consistently across opposite-facing parallel faces. For the cobblestone fixture the change is invisible; for any image-with-text it would have shown mirrored on -X faces. * Degenerate-normal triplanar fallback now averages all three planes equally rather than silently picking XY — degenerate faces blend smoothly with neighbors instead of popping. * dirResolver path-traversal check splits on filepath.Separator and rejects ".." segments. The old HasPrefix(clean, "..") was a no-op on Windows for "..\foo" after FromSlash didn't normalize backslashes. * Tracker grows a Warn(message) method so MaterialX errors surface in the GUI status banner (existing pipeline-warning event listener) and the CLI stderr — not just buried in log.Printf. * Frontend stats the .mtlx path on settings load (new MaterialXPathOK Wails method) and warns immediately if the file is missing — useful when a project file moves between machines. Drive-by polish: * voxel.faceNormal is now exported as voxel.FaceNormal; pipeline.go's bakeMaterialXBaseColor reuses it instead of duplicating the cross- product math. * decodeImage takes io.Reader directly; the anonymous interface wrapper and readerFunc adapter are deleted. * Resolver's hand-rolled byteReader replaced with bytes.NewReader. * image.go's unused defaultFn capture removed (the real default-color story for missing files is "fail at compile time", documented). * floatToByte's ±1 round-trip from FP blend documented inline. New tests cover: Sampler cache memoization (via observed color change), missing-path file-stamp returns (0, 0), triplanar UV sign flip on opposite-facing normals, degenerate-normal three-way average, ParsePackage on a zip with .mtlx in a subdirectory. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.go | 25 +++++ frontend/src/App.svelte | 14 ++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 2 - internal/materialx/eval.go | 12 +-- internal/materialx/image.go | 14 +-- internal/materialx/materialx_test.go | 43 ++++++++ internal/materialx/resolver.go | 28 +++-- internal/pipeline/materialx_override.go | 105 +++++++++++-------- internal/pipeline/materialx_override_test.go | 65 ++++++++++++ internal/pipeline/pipeline.go | 25 ++--- internal/pipeline/run.go | 7 +- internal/pipeline/stepcache.go | 46 ++++++++ internal/pipeline/stepcache_test.go | 82 +++++++++++++++ internal/progress/tracker.go | 12 +++ internal/voxel/color.go | 11 +- 17 files changed, 391 insertions(+), 106 deletions(-) diff --git a/app.go b/app.go index 146c32a..696edef 100644 --- a/app.go +++ b/app.go @@ -422,6 +422,19 @@ func (t *guiTracker) StageDone(stage string) { }) } +type warnEvent struct { + Gen int64 `json:"gen"` + Message string `json:"message"` +} + +func (t *guiTracker) Warn(message string) { + // Reuses the existing "pipeline-warning" event listener in + // App.svelte; the frontend updates the status banner. + wailsRuntime.EventsEmit(t.appCtx, "pipeline-warning", warnEvent{ + Gen: t.gen, Message: message, + }) +} + // Compile-time check that guiTracker implements progress.Tracker. var _ progress.Tracker = (*guiTracker)(nil) @@ -709,6 +722,18 @@ type MaterialXOpenResult struct { Path string `json:"path"` } +// MaterialXPathOK reports whether the file at path exists and is +// readable. Used by the frontend to warn at settings-load time that +// a referenced .mtlx / .zip is missing on this machine. Empty path +// returns true (means "nothing requested"). +func (a *App) MaterialXPathOK(path string) bool { + if path == "" { + return true + } + _, err := os.Stat(path) + return err == nil +} + // OpenMaterialXFile opens a file dialog for selecting a MaterialX // .mtlx file or a .zip archive containing one (with adjacent // textures). The pipeline opens the file directly from the path at diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index dc7563f..e168385 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -26,7 +26,7 @@ import { SharedCamera } from '$lib/components/SharedCamera.svelte'; import { contrastColor } from '$lib/utils'; import type { CutPlanePreview } from '$lib/types'; - import { ProcessPipeline, Export3MF, SaveSettings, SaveSettingsDialog, OpenFileDialog, LoadSettingsFile, DefaultSettingsPath, Version, LogMessage, GetCollectionColors, ImportCollection, CreateCollection, DeleteCollection, OpenStickerImage, ReadStickerThumbnail, OpenMaterialXFile, EnumerateObjects, ListPrinters, Quit } from '../wailsjs/go/main/App'; + import { ProcessPipeline, Export3MF, SaveSettings, SaveSettingsDialog, OpenFileDialog, LoadSettingsFile, DefaultSettingsPath, Version, LogMessage, GetCollectionColors, ImportCollection, CreateCollection, DeleteCollection, OpenStickerImage, ReadStickerThumbnail, OpenMaterialXFile, MaterialXPathOK, EnumerateObjects, ListPrinters, Quit } from '../wailsjs/go/main/App'; import type { main } from '../wailsjs/go/models'; import { collectionStore } from '$lib/stores/collections.svelte'; import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime'; @@ -867,6 +867,18 @@ if (s.baseMaterialXPath !== undefined) baseMaterialXPath = s.baseMaterialXPath; if (s.baseMaterialXTileMM !== undefined) baseMaterialXTileMM = s.baseMaterialXTileMM; if (s.baseMaterialXTriplanarSharpness !== undefined) baseMaterialXTriplanarSharpness = s.baseMaterialXTriplanarSharpness; + // Best-effort check: when a settings file is loaded on a different + // machine than where it was saved, the .mtlx path may not resolve. + // Surface a warning immediately so the user knows before they + // first click Generate. + if (baseMaterialXPath) { + MaterialXPathOK(baseMaterialXPath).then((ok: boolean) => { + if (!ok) { + statusMessage = `MaterialX file not found: ${baseMaterialXPath}. Re-pick or place the file at that path.`; + statusType = 'warning'; + } + }); + } if (s.colorSlots !== undefined) { colorSlots = s.colorSlots.map((c: any) => c ? { hex: c.hex, label: c.label || '', collection: c.collection || '' } : null); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9004a4e..6a6202f 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -32,6 +32,8 @@ export function OpenFileDialog():Promise; export function OpenMaterialXFile():Promise; +export function MaterialXPathOK(arg1:string):Promise; + export function OpenStickerImage():Promise; export function ProcessPipeline(arg1:pipeline.Options):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index cc2b001..37f8770 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -58,6 +58,10 @@ export function OpenMaterialXFile() { return window['go']['main']['App']['OpenMaterialXFile'](); } +export function MaterialXPathOK(arg1) { + return window['go']['main']['App']['MaterialXPathOK'](arg1); +} + export function OpenStickerImage() { return window['go']['main']['App']['OpenStickerImage'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6737e4b..d6906ac 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -245,7 +245,6 @@ export namespace main { } export class MaterialXOpenResult { path: string; - content: string; static createFrom(source: any = {}) { return new MaterialXOpenResult(source); @@ -254,7 +253,6 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.path = source["path"]; - this.content = source["content"]; } } export class NozzleOption { diff --git a/internal/materialx/eval.go b/internal/materialx/eval.go index 678be09..5488f4b 100644 --- a/internal/materialx/eval.go +++ b/internal/materialx/eval.go @@ -599,10 +599,11 @@ func buildImage(c *compiler, n *node) (evalFn, error) { out := n.OutputType arity := vecArity(out) - defaultFn, err := c.compileOptional(n, "default") - if err != nil { - return nil, err - } + // `default` input intentionally ignored: the only scenario the + // MaterialX spec uses it for is "file load failed at runtime", + // which we surface at compile time via images.load returning an + // error. Out-of-bounds UVs are handled by the address-mode + // inputs, not the default. return func(ctx *SampleContext, scratch []Value) Value { var uv [2]float64 @@ -632,9 +633,6 @@ func buildImage(c *compiler, n *node) (evalFn, error) { v.Vec[2] = rgb[2] v.Vec[3] = 1 } - // Hush unused-default-input warning when present but unsupported; - // real default sampling happens implicitly via address modes. - _ = defaultFn return v }, nil } diff --git a/internal/materialx/image.go b/internal/materialx/image.go index 6f04858..393aca6 100644 --- a/internal/materialx/image.go +++ b/internal/materialx/image.go @@ -5,6 +5,7 @@ import ( "image" _ "image/jpeg" _ "image/png" + "io" "math" "strings" "sync" @@ -61,10 +62,8 @@ type decodedImage struct { srgb bool } -func decodeImage(rc interface { - Read(p []byte) (int, error) -}, srgb bool) (*decodedImage, error) { - img, _, err := image.Decode(readerFunc(rc.Read)) +func decodeImage(r io.Reader, srgb bool) (*decodedImage, error) { + img, _, err := image.Decode(r) if err != nil { return nil, fmt.Errorf("decode: %w", err) } @@ -85,13 +84,6 @@ func decodeImage(rc interface { return &decodedImage{w: w, h: h, pixels: pixels, srgb: srgb}, nil } -// readerFunc lets us pass a Read method directly into image.Decode -// without requiring it to satisfy the full io.Reader interface -// upstream of decodeImage's caller. -type readerFunc func(p []byte) (int, error) - -func (f readerFunc) Read(p []byte) (int, error) { return f(p) } - // sample looks up an RGB triplet at the given UV with the requested // address/filter modes. Output is in [0, 1] per channel; alpha is // ignored (ditherforge bakes alpha separately from base color). diff --git a/internal/materialx/materialx_test.go b/internal/materialx/materialx_test.go index 452d41b..9637181 100644 --- a/internal/materialx/materialx_test.go +++ b/internal/materialx/materialx_test.go @@ -640,6 +640,49 @@ func TestParsePackageZip(t *testing.T) { } } +func TestParsePackageZipWithSubdirectory(t *testing.T) { + // Many real-world packs have the .mtlx + textures inside a single + // top-level folder rather than at the archive root. Verify the + // resolver's prefix logic handles that case (image's relative + // "stripe.png" resolves via the .mtlx's containing directory). + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + addZip := func(name string, data []byte) { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write(data); err != nil { + t.Fatalf("zip write: %v", err) + } + } + addZip("Pack/pack.mtlx", []byte(imageGraphMtlx)) + addZip("Pack/stripe.png", stripePNG(t)) + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + tmp := filepath.Join(t.TempDir(), "pack.zip") + if err := os.WriteFile(tmp, buf.Bytes(), 0644); err != nil { + t.Fatalf("write zip: %v", err) + } + doc, err := materialx.ParsePackage(tmp) + if err != nil { + t.Fatalf("ParsePackage: %v", err) + } + s, err := doc.DefaultBaseColorSampler() + if err != nil { + t.Fatalf("sampler: %v", err) + } + got := s.SampleAt(materialx.SampleContext{UV: [2]float64{0.125, 0.5}}) + want := [3]float64{1, 0, 0} + for i := range 3 { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("subdir-zip-loaded sampler at u=0.125: got %v, want %v", got, want) + break + } + } +} + func TestImageGraphRequiresResolver(t *testing.T) { doc, err := materialx.ParseBytes([]byte(imageGraphMtlx)) if err != nil { diff --git a/internal/materialx/resolver.go b/internal/materialx/resolver.go index 0051ceb..fc243f8 100644 --- a/internal/materialx/resolver.go +++ b/internal/materialx/resolver.go @@ -2,12 +2,14 @@ package materialx import ( "archive/zip" + "bytes" "errors" "fmt" "io" "os" "path" "path/filepath" + "slices" "strings" ) @@ -56,7 +58,15 @@ type dirResolver struct { func (d *dirResolver) Open(relpath string) (io.ReadCloser, error) { clean := filepath.FromSlash(filepath.Clean(relpath)) - if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + if filepath.IsAbs(clean) { + return nil, fmt.Errorf("materialx: refusing to resolve absolute path %q", relpath) + } + // Reject any path segment equal to ".." — this is robust on both + // "/" (Linux/macOS) and "\" (Windows) separators, where a naive + // HasPrefix("..") would let "..\foo" through on Windows after + // FromSlash converts forward slashes but leaves the existing + // backslashes alone. + if slices.Contains(strings.Split(clean, string(filepath.Separator)), "..") { return nil, fmt.Errorf("materialx: refusing to resolve %q outside %q", relpath, d.base) } return os.Open(filepath.Join(d.base, clean)) @@ -170,23 +180,9 @@ func (m mapResolver) Open(relpath string) (io.ReadCloser, error) { if !ok { return nil, fmt.Errorf("materialx: %q not found in map resolver", relpath) } - return io.NopCloser(byteReader{b: b}), nil + return io.NopCloser(bytes.NewReader(b)), nil } // NewMapResolver returns an in-memory ResourceResolver backed by the // given path-to-bytes map. Test-only. func NewMapResolver(files map[string][]byte) ResourceResolver { return mapResolver(files) } - -type byteReader struct { - b []byte - i int -} - -func (r byteReader) Read(p []byte) (int, error) { - if r.i >= len(r.b) { - return 0, io.EOF - } - n := copy(p, r.b[r.i:]) - r.i += n - return n, nil -} diff --git a/internal/pipeline/materialx_override.go b/internal/pipeline/materialx_override.go index bbb55bb..b846462 100644 --- a/internal/pipeline/materialx_override.go +++ b/internal/pipeline/materialx_override.go @@ -2,7 +2,6 @@ package pipeline import ( "fmt" - "log" "math" "github.com/rtwfroody/ditherforge/internal/materialx" @@ -47,7 +46,17 @@ func (m *materialxOverride) SampleBaseColor(ctx voxel.BaseColorContext) [3]uint8 // and blends by the normal-derived weights. Sharpness controls how // abruptly the projection switches across axis transitions: 1 is a // soft cosine-weighted blend, higher values approach a hard box map. +// +// Each plane's U coordinate is multiplied by sign(normal.axis) so a +// face's UV traverses the same direction whether its normal points +// along +axis or -axis. Without this, a directional texture (text, +// arrows) would render mirrored across opposite-facing parallel +// faces. For direction-free textures (cobblestone, marble) the flip +// is invisible. func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]float64 { + signX := signOrPos(float64(normal[0])) + signY := signOrPos(float64(normal[1])) + signZ := signOrPos(float64(normal[2])) nx := math.Abs(float64(normal[0])) ny := math.Abs(float64(normal[1])) nz := math.Abs(float64(normal[2])) @@ -61,22 +70,22 @@ func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]floa wz := math.Pow(nz, sharp) sum := wx + wy + wz if sum < 1e-12 { - // Degenerate normal — fall back to one planar sample so we at - // least produce a defined color rather than black. - return m.sampler.SampleAt(materialx.SampleContext{ - Pos: pos, - UV: [2]float64{pos[0], pos[1]}, - }) + // Degenerate normal — average the three planar samples + // equally so a degenerate face renders with the same + // "everywhere" pattern instead of popping to one of the three + // projections. + wx, wy, wz = 1.0/3, 1.0/3, 1.0/3 + } else { + wx /= sum + wy /= sum + wz /= sum } - wx /= sum - wy /= sum - wz /= sum var out [3]float64 if wx > 1e-6 { c := m.sampler.SampleAt(materialx.SampleContext{ Pos: pos, - UV: [2]float64{pos[1], pos[2]}, + UV: [2]float64{pos[1] * signX, pos[2]}, }) out[0] += c[0] * wx out[1] += c[1] * wx @@ -85,7 +94,7 @@ func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]floa if wy > 1e-6 { c := m.sampler.SampleAt(materialx.SampleContext{ Pos: pos, - UV: [2]float64{pos[0], pos[2]}, + UV: [2]float64{pos[0] * signY, pos[2]}, }) out[0] += c[0] * wy out[1] += c[1] * wy @@ -94,7 +103,7 @@ func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]floa if wz > 1e-6 { c := m.sampler.SampleAt(materialx.SampleContext{ Pos: pos, - UV: [2]float64{pos[0], pos[1]}, + UV: [2]float64{pos[0] * signZ, pos[1]}, }) out[0] += c[0] * wz out[1] += c[1] * wz @@ -103,6 +112,18 @@ func (m *materialxOverride) triplanar(pos [3]float64, normal [3]float32) [3]floa return out } +// signOrPos returns -1 when v < 0, +1 otherwise (including 0). +// Triplanar UV flipping needs a deterministic sign for the zero-normal +// case, where any choice is fine because the corresponding weight is +// zero (or 1/3 in the degenerate-normal fallback, where the flip +// doesn't visually matter either). +func signOrPos(v float64) float64 { + if v < 0 { + return -1 + } + return 1 +} + func rgbToBytes(rgb [3]float64) [3]uint8 { return [3]uint8{ floatToByte(rgb[0]), @@ -111,11 +132,20 @@ func rgbToBytes(rgb [3]float64) [3]uint8 { } } +// floatToByte quantizes a [0, 1] float to an 8-bit channel value. +// +// On the triplanar path, identical 8-bit sub-samples can come back at +// ±1 from the input byte: each sub-sample is rgb_float = byte/255, +// then weighted-sum across three planes accumulates a few ULPs of FP +// error before the round step here. Practically invisible against the +// dithering that runs downstream, but worth knowing if a future +// debugger asks "why isn't this pixel exactly equal to the texel". +// +// NaN propagates through arithmetic and fails both comparison +// branches below; uint8(NaN) is implementation-defined in Go. Pin it +// to 0 so a malformed graph evaluates to black instead of random +// per-voxel garbage. func floatToByte(f float64) uint8 { - // NaN propagates through arithmetic and fails both comparison - // branches below; uint8(NaN) is implementation-defined in Go. Pin - // it to 0 so a malformed graph evaluates to black instead of - // random per-voxel garbage. if f != f { return 0 } @@ -129,24 +159,25 @@ func floatToByte(f float64) uint8 { return uint8(v) } -// buildBaseColorOverride parses a MaterialX package (path to .mtlx or -// .zip) and returns an override that samples its first material's -// base_color graph. tileMM scales world-space mm into the procedural's -// shading frame (values <= 0 are treated as 1 mm). triplanarSharpness -// only matters for image-backed graphs; <= 0 picks a sensible default. -// Returns (nil, nil) when path is empty so callers can pass the result -// straight through to the voxelizer. -func buildBaseColorOverride(path string, tileMM, triplanarSharpness float64) (voxel.BaseColorOverride, error) { +// baseColorOverride wraps the cached materialx.Sampler for the package +// at path with a per-run tile/triplanar config. tileMM scales +// world-space mm into the procedural's shading frame (values <= 0 are +// treated as 1 mm). triplanarSharpness only matters for image-backed +// graphs; <= 0 picks a sensible default. Returns (nil, nil) when +// path is empty so callers can pass the result straight through to +// the voxelizer. The expensive parts (XML parse + image decode) are +// memoized on StageCache, so applyBaseColor and the voxelize stage +// share one parse per pipeline run. +func (c *StageCache) baseColorOverride(path string, tileMM, triplanarSharpness float64) (voxel.BaseColorOverride, error) { if path == "" { return nil, nil } - doc, err := materialx.ParsePackage(path) + s, err := c.materialXSampler(path) if err != nil { - return nil, fmt.Errorf("MaterialX package %q: %w", path, err) + return nil, fmt.Errorf("MaterialX %q: %w", path, err) } - s, err := doc.DefaultBaseColorSampler() - if err != nil { - return nil, fmt.Errorf("MaterialX base color: %w", err) + if s == nil { + return nil, nil } if tileMM <= 0 { tileMM = 1 @@ -159,17 +190,3 @@ func buildBaseColorOverride(path string, tileMM, triplanarSharpness float64) (vo }, nil } -// safeBaseColorOverride wraps buildBaseColorOverride in a logging -// helper for the run path: a malformed .mtlx is reported as a warning -// and the pipeline proceeds without the override (mirroring how -// applyBaseColor handles invalid hex colors). Returns nil on any -// error so the downstream voxel sampler falls back to per-face base -// colors. -func safeBaseColorOverride(path string, tileMM, triplanarSharpness float64) voxel.BaseColorOverride { - o, err := buildBaseColorOverride(path, tileMM, triplanarSharpness) - if err != nil { - log.Printf("Warning: ignoring MaterialX base color: %v", err) - return nil - } - return o -} diff --git a/internal/pipeline/materialx_override_test.go b/internal/pipeline/materialx_override_test.go index be8c789..6b1bb13 100644 --- a/internal/pipeline/materialx_override_test.go +++ b/internal/pipeline/materialx_override_test.go @@ -114,6 +114,71 @@ func TestTriplanarBlendsWhenNormalIsDiagonal(t *testing.T) { } } +// TestTriplanarUVSignFlip verifies the per-plane sign flip: a face +// with +X normal samples YZ at u=+pos.y; a -X normal samples at +// u=-pos.y. Without the flip, mirror seams appear on opposite-facing +// parallel faces with directional textures. +func TestTriplanarUVSignFlip(t *testing.T) { + pos32 := [3]float32{0.25, 0.5, 0.75} + py, pz := float64(pos32[1]), float64(pos32[2]) + var seenUVs []float64 + fake := &fakeSampler{ + usesUV: true, + cb: func(ctx materialx.SampleContext) [3]float64 { + seenUVs = append(seenUVs, ctx.UV[0]) + return [3]float64{1, 1, 1} + }, + } + o := &materialxOverride{sampler: fake, invTileMM: 1, useUV: true, sharpness: 8} + + seenUVs = nil + o.SampleBaseColor(voxel.BaseColorContext{Pos: pos32, Normal: [3]float32{1, 0, 0}}) + if len(seenUVs) == 0 || seenUVs[0] != py { + t.Errorf("+X normal should sample YZ with u=+pos.y=%v; saw %v", py, seenUVs) + } + + seenUVs = nil + o.SampleBaseColor(voxel.BaseColorContext{Pos: pos32, Normal: [3]float32{-1, 0, 0}}) + if len(seenUVs) == 0 || seenUVs[0] != -py { + t.Errorf("-X normal should sample YZ with u=-pos.y=%v; saw %v", -py, seenUVs) + } + _ = pz +} + +// TestTriplanarDegenerateNormalAveragesAllPlanes verifies that a +// zero-length normal produces an equal three-way blend rather than +// silently picking one plane. A face with random distinct per-plane +// colors should resolve to their average. +func TestTriplanarDegenerateNormalAveragesAllPlanes(t *testing.T) { + pos32 := [3]float32{0.25, 0.5, 0.75} + px, py, pz := float64(pos32[0]), float64(pos32[1]), float64(pos32[2]) + colors := map[[2]float64][3]float64{ + {py, pz}: {0.6, 0.0, 0.0}, + {px, pz}: {0.0, 0.6, 0.0}, + {px, py}: {0.0, 0.0, 0.6}, + } + fake := &fakeSampler{ + usesUV: true, + cb: func(ctx materialx.SampleContext) [3]float64 { + return colors[ctx.UV] + }, + } + o := &materialxOverride{sampler: fake, invTileMM: 1, useUV: true, sharpness: 4} + got := o.SampleBaseColor(voxel.BaseColorContext{ + Pos: pos32, + Normal: [3]float32{0, 0, 0}, + }) + // Each channel: 0.6 / 3 = 0.2 → 0.2*255+0.5 = 51.5 → 51. + want := [3]uint8{51, 51, 51} + for i := range 3 { + if got[i] < 50 || got[i] > 52 { + t.Errorf("degenerate normal: channel %d got %d, want ~51 (3-way average)", i, got[i]) + break + } + } + _ = want +} + // TestNonUVSamplerSkipsTriplanar verifies the fast path: a // non-UV-using sampler is consulted exactly once per call regardless // of normal, since no projection is needed. diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 476ca38..e32a640 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -10,7 +10,6 @@ import ( _ "image/jpeg" _ "image/png" "log" - "math" "path/filepath" "strconv" "strings" @@ -522,7 +521,7 @@ func applyBaseColorOverride(model *loader.LoadedModel, hexColor string) { // Invariant: whenever lo is present, parse output is reachable via // cache.getParse — but only when an override was previously applied do we // actually fetch it (the pristine case skips the parse cache lookup). -func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { +func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options, tracker progress.Tracker) { if lo.appliedBaseColor == opts.BaseColor && lo.appliedBaseColorMaterialX == opts.BaseColorMaterialX && lo.appliedBaseColorMaterialXTileMM == opts.BaseColorMaterialXTileMM && @@ -546,13 +545,13 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) { // parsing the .mtlx (and decoding any referenced textures) is // cheap but non-trivial, and run.go also builds its own // override for per-voxel sampling. - override, err := buildBaseColorOverride( + override, err := cache.baseColorOverride( opts.BaseColorMaterialX, opts.BaseColorMaterialXTileMM, opts.BaseColorMaterialXTriplanarSharpness, ) if err != nil { - log.Printf("Warning: ignoring MaterialX base color: %v", err) + tracker.Warn(fmt.Sprintf("ignoring MaterialX base color: %v", err)) } else if override != nil { bakeMaterialXBaseColor(lo.ColorModel, override) if lo.SampleModel != lo.ColorModel { @@ -591,20 +590,10 @@ func bakeMaterialXBaseColor(model *loader.LoadedModel, override voxel.BaseColorO (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3, } - // Face normal via cross product, normalized; degenerate faces - // get the zero vector and the override's triplanar fallback - // kicks in. - e1x, e1y, e1z := v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2] - e2x, e2y, e2z := v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2] - nx := e1y*e2z - e1z*e2y - ny := e1z*e2x - e1x*e2z - nz := e1x*e2y - e1y*e2x - nl := float32(math.Sqrt(float64(nx*nx + ny*ny + nz*nz))) - var normal [3]float32 - if nl > 1e-9 { - normal = [3]float32{nx / nl, ny / nl, nz / nl} - } - rgb := override.SampleBaseColor(voxel.BaseColorContext{Pos: centroid, Normal: normal}) + rgb := override.SampleBaseColor(voxel.BaseColorContext{ + Pos: centroid, + Normal: voxel.FaceNormal(i, model), + }) model.FaceBaseColor[i] = [4]uint8{rgb[0], rgb[1], rgb[2], 255} } } diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index e014380..27ce24e 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -223,7 +223,7 @@ func (r *pipelineRun) Load() (*loadOutput, error) { // Apply base-color override on top of the (possibly cached) // load output. Cheap and idempotent. On a fresh disk hit // (lo.appliedBaseColor=="") this skips the parse cache lookup. - applyBaseColor(r.cache, lo, r.opts) + applyBaseColor(r.cache, lo, r.opts, r.tracker) return lo, nil } @@ -509,11 +509,14 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) { } } - baseColorOverride := safeBaseColorOverride( + baseColorOverride, bcoErr := r.cache.baseColorOverride( r.opts.BaseColorMaterialX, r.opts.BaseColorMaterialXTileMM, r.opts.BaseColorMaterialXTriplanarSharpness, ) + if bcoErr != nil { + r.tracker.Warn(fmt.Sprintf("ignoring MaterialX base color: %v", bcoErr)) + } result, verr := squarevoxel.VoxelizeTwoGrids(r.ctx, lo.Model, sampleModel, stickerModel, stickerSI, layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo, diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go index 2092659..3c13723 100644 --- a/internal/pipeline/stepcache.go +++ b/internal/pipeline/stepcache.go @@ -15,6 +15,7 @@ import ( "github.com/rtwfroody/ditherforge/internal/cacheblob" "github.com/rtwfroody/ditherforge/internal/diskcache" "github.com/rtwfroody/ditherforge/internal/loader" + "github.com/rtwfroody/ditherforge/internal/materialx" "github.com/rtwfroody/ditherforge/internal/plog" "github.com/rtwfroody/ditherforge/internal/progress" "github.com/rtwfroody/ditherforge/internal/split" @@ -173,6 +174,18 @@ type StageCache struct { invContentsMtime time.Time invContentsSize int64 invContents string + + // mtlxSampler caches the parsed-and-compiled MaterialX sampler so + // applyBaseColor (preview bake) and the voxelize stage (per-voxel + // sample) share one parse + image-decode per pipeline run. + // Errors are cached too — a malformed .mtlx shouldn't be re-tried + // on every consumer call within a session. Tracked by (path, + // mtime, size) like inputHash/invContents. + mtlxSamplerPath string + mtlxSamplerMtime time.Time + mtlxSamplerSize int64 + mtlxSampler materialx.Sampler + mtlxSamplerErr error } // NewStageCache returns an empty stage cache with no disk persistence. @@ -785,6 +798,39 @@ func materialXFileStamp(path string) (mtime, size int64) { return info.ModTime().UnixNano(), info.Size() } +// materialXSampler returns the parsed-and-compiled materialx.Sampler +// for the package at path, memoized within the session by (path, +// mtime, size). Errors are cached so a malformed .mtlx isn't +// re-attempted on every call. Returns (nil, nil) for an empty path. +// +// Same single-threaded-pipeline assumption as inventoryContents — no +// mutex. +func (c *StageCache) materialXSampler(path string) (materialx.Sampler, error) { + if path == "" { + return nil, nil + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %q: %w", path, err) + } + if c.mtlxSamplerPath == path && + c.mtlxSamplerMtime.Equal(info.ModTime()) && + c.mtlxSamplerSize == info.Size() { + return c.mtlxSampler, c.mtlxSamplerErr + } + doc, perr := materialx.ParsePackage(path) + var s materialx.Sampler + if perr == nil { + s, perr = doc.DefaultBaseColorSampler() + } + c.mtlxSamplerPath = path + c.mtlxSamplerMtime = info.ModTime() + c.mtlxSamplerSize = info.Size() + c.mtlxSampler = s + c.mtlxSamplerErr = perr + return s, perr +} + // stageFnv hashes a single stage's settings to a uint64. Used as the // per-stage component of the cumulative stageKey. func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 { diff --git a/internal/pipeline/stepcache_test.go b/internal/pipeline/stepcache_test.go index 4d1e6dd..fef5c48 100644 --- a/internal/pipeline/stepcache_test.go +++ b/internal/pipeline/stepcache_test.go @@ -7,6 +7,88 @@ import ( "time" ) +// TestMaterialXSamplerMemoizedByFileStamp verifies the cache keyed +// by (path, mtime, size). Constant-base-color samplers are value-typed +// so we observe behavior via the sampled color, not pointer identity: +// rewriting the file with a different constant must change the +// reported color, while two calls without a rewrite must not. +func TestMaterialXSamplerMemoizedByFileStamp(t *testing.T) { + c := NewStageCache() + + // Empty path → (nil, nil) every time. + if s, err := c.materialXSampler(""); s != nil || err != nil { + t.Errorf("empty path: got (%v, %v), want (nil, nil)", s, err) + } + + mtlx := func(hex string) string { + return ` + + + + + + + +` + } + p := writeMtlxTempFile(t, mtlx("0.5, 0.5, 0.5")) + s1, err := c.materialXSampler(p) + if err != nil { + t.Fatalf("first call: %v", err) + } + if got := s1.Sample([3]float64{}); got != [3]float64{0.5, 0.5, 0.5} { + t.Fatalf("first sample: got %v, want (0.5, 0.5, 0.5)", got) + } + + // Same file, second call: cache hit, same color. + s2, err := c.materialXSampler(p) + if err != nil { + t.Fatalf("second call: %v", err) + } + if got := s2.Sample([3]float64{}); got != [3]float64{0.5, 0.5, 0.5} { + t.Errorf("cache hit but color drifted: got %v, want (0.5, 0.5, 0.5)", got) + } + + // Rewrite with a different constant + bump mtime → cache miss → + // new sampler reflects the new color. + if err := os.WriteFile(p, []byte(mtlx("0.1, 0.2, 0.3")), 0644); err != nil { + t.Fatalf("rewrite: %v", err) + } + future := time.Now().Add(2 * time.Second) + _ = os.Chtimes(p, future, future) + s3, err := c.materialXSampler(p) + if err != nil { + t.Fatalf("post-edit call: %v", err) + } + if got := s3.Sample([3]float64{}); got != [3]float64{0.1, 0.2, 0.3} { + t.Errorf("post-edit color: got %v, want (0.1, 0.2, 0.3) — cache key ignored an mtime change", got) + } + + // Parse error caching: malformed file → error both calls. + bad := writeMtlxTempFile(t, ``) + if _, err := c.materialXSampler(bad); err == nil { + t.Fatalf("malformed mtlx: expected error, got nil") + } + if _, err := c.materialXSampler(bad); err == nil { + t.Fatalf("second call on malformed mtlx: expected cached error, got nil") + } +} + +// TestMaterialXFileStampMissingPath is the boundary case for the +// path-based MaterialX cache key: a missing or unstat-able path must +// hash to (0, 0) so two missing-path Options collide consistently +// (which is what we want — there's nothing to cache). +func TestMaterialXFileStampMissingPath(t *testing.T) { + mtime, size := materialXFileStamp("/no/such/path.mtlx") + if mtime != 0 || size != 0 { + t.Errorf("missing path: got (%d, %d), want (0, 0)", mtime, size) + } + mtime, size = materialXFileStamp("") + if mtime != 0 || size != 0 { + t.Errorf("empty path: got (%d, %d), want (0, 0)", mtime, size) + } +} + // writeMtlxTempFile drops a tiny .mtlx file into a freshly-created // temp dir and returns its path. Test helper for the MaterialX cache // tests below — the contents don't have to be a valid graph since diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go index 8224da1..a3634ae 100644 --- a/internal/progress/tracker.go +++ b/internal/progress/tracker.go @@ -1,6 +1,7 @@ package progress import ( + "log" "time" "github.com/schollz/progressbar/v3" @@ -18,6 +19,12 @@ type Tracker interface { // StageDone signals that a stage has completed. StageDone(stage string) + + // Warn surfaces a non-fatal warning to the user (e.g. malformed + // MaterialX file, missing inventory entry). The pipeline continues + // after the warning is logged. Implementations route this to + // stderr (CLI), the GUI's toast/notification panel, or both. + Warn(message string) } // NullTracker is a no-op Tracker for use when progress reporting is not needed. @@ -26,6 +33,7 @@ type NullTracker struct{} func (NullTracker) StageStart(string, bool, int) {} func (NullTracker) StageProgress(string, int) {} func (NullTracker) StageDone(string) {} +func (NullTracker) Warn(string) {} // Stage is a handle returned by BeginStage. Its Done method ends the stage // and is idempotent — safe to call from defer plus explicitly when you want @@ -104,3 +112,7 @@ func (t *CLITracker) StageDone(stage string) { delete(t.bars, stage) } } + +func (t *CLITracker) Warn(message string) { + log.Printf("Warning: %s", message) +} diff --git a/internal/voxel/color.go b/internal/voxel/color.go index 1501f5e..bce5a95 100644 --- a/internal/voxel/color.go +++ b/internal/voxel/color.go @@ -288,10 +288,11 @@ type BaseColorOverride interface { SampleBaseColor(ctx BaseColorContext) [3]uint8 } -// faceNormal returns the unit-length normal of the face at faceIdx -// computed from its three vertex positions. Returns the zero vector -// when the face is degenerate (e.g. zero area). -func faceNormal(faceIdx int, model *loader.LoadedModel) [3]float32 { +// FaceNormal returns the unit-length normal of the face at faceIdx +// computed from its three vertex positions (right-handed cross +// product, v0→v1 × v0→v2). Returns the zero vector when the face is +// degenerate (zero area within float32 precision). +func FaceNormal(faceIdx int, model *loader.LoadedModel) [3]float32 { f := model.Faces[faceIdx] v0 := model.Vertices[f[0]] v1 := model.Vertices[f[1]] @@ -364,7 +365,7 @@ func SampleNearestColorWithSticker( if override != nil && (texIdx < 0 || int(texIdx) >= len(model.Textures)) { rgb := override.SampleBaseColor(BaseColorContext{ Pos: p, - Normal: faceNormal(int(bestTri), model), + Normal: FaceNormal(int(bestTri), model), }) bc[0], bc[1], bc[2] = rgb[0], rgb[1], rgb[2] } From eb38715d181ac0a0b46cfa7e201a039ee96dd1f2 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 20:16:48 -0700 Subject: [PATCH 08/13] Rework Base color UI: dedicated row + solid/texture toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Model section previously crammed Size/Scale and Base color into a single 2-column row, with the right column branching between three incompatible widgets (color swatch / mtlx-loaded label / "Default") plus a separate MaterialX picker squeezed into a second row. The behavior was right but the layout was hostile. Now: * Size/Scale gets its own row (label + input). * Base color row: an explicit "Solid / Texture" radio toggle next to the label, and a single picker on the right for whichever mode is active — color swatch when Solid, file-name display + Clear when Texture (or "Load .mtlx / .zip" if no path picked yet). * Texture-only options (Tile size, Triplanar) drop down on a row below, only visible when Texture mode + a path are both set. Mode is persisted in the settings JSON as `baseColorMode` so loaded projects open in the picker the user expects. Older settings files that lack the field fall back to inferring from baseMaterialXPath (set → texture, else solid). Backend wiring: pipeline.Options.BaseColorMaterialX is now suppressed when mode === 'solid', so the unselected mode's stored value (retained client-side so the user can toggle without losing it) never reaches the cache key or the voxelizer. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.go | 6 ++ frontend/src/App.svelte | 155 +++++++++++++++++------------- frontend/wailsjs/go/main/App.d.ts | 4 +- frontend/wailsjs/go/main/App.js | 8 +- frontend/wailsjs/go/models.ts | 2 + 5 files changed, 102 insertions(+), 73 deletions(-) diff --git a/app.go b/app.go index 696edef..33c918c 100644 --- a/app.go +++ b/app.go @@ -857,6 +857,12 @@ type Settings struct { BaseMaterialXPath string `json:"baseMaterialXPath,omitempty"` BaseMaterialXTileMM float64 `json:"baseMaterialXTileMM,omitempty"` BaseMaterialXTriplanarSharpness float64 `json:"baseMaterialXTriplanarSharpness,omitempty"` + // BaseColorMode is "solid" or "texture" — UI mode toggle that + // decides which of (BaseColor, BaseMaterialXPath) is sent to the + // pipeline. The unselected mode's fields are kept around (so the + // user can flip the toggle without losing their other choice) but + // don't reach the backend Options. + BaseColorMode string `json:"baseColorMode,omitempty"` ColorSlots []*ColorSlotSetting `json:"colorSlots"` InventoryCollection string `json:"inventoryCollection"` Brightness float64 `json:"brightness"` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e168385..406df9d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -118,13 +118,16 @@ // Base color for untextured faces: null = use model default, or {hex, label, collection}. let baseColor = $state(null); let baseColorPickerOpen = $state(false); - // MaterialX base color. Mutually exclusive with `baseColor`. - // baseMaterialXPath is read by the backend at pipeline run time; - // settings round-trips just the path. Accepts .mtlx (with adjacent + // MaterialX base color. Path is read by the backend at pipeline run + // time; settings round-trips the path. Accepts .mtlx (with adjacent // textures) or a .zip containing both. let baseMaterialXPath = $state(''); let baseMaterialXTileMM = $state(10); let baseMaterialXTriplanarSharpness = $state(4); + // baseColorMode picks which of the two pickers (and the + // corresponding pipeline option) is in effect. Backend only ever + // gets one — the other is suppressed. + let baseColorMode = $state<'solid' | 'texture'>('solid'); // Color palette: each slot is either null (auto) or a locked color with hex + label + source collection. type ColorInfo = { hex: string; label: string; collection?: string }; type ColorSlot = ColorInfo | null; @@ -567,7 +570,7 @@ $effect(() => { // Read all form values to establish tracking. void [inputFile, sizeMode, sizeValue, scaleValue, printerId, nozzleDiameter, - layerHeight, baseColor, baseMaterialXPath, baseMaterialXTileMM, baseMaterialXTriplanarSharpness, ...colorSlots, + layerHeight, baseColor, baseColorMode, baseMaterialXPath, baseMaterialXTileMM, baseMaterialXTriplanarSharpness, ...colorSlots, inventoryCollectionColors, committedBrightness, committedContrast, committedSaturation, JSON.stringify(warpPins), @@ -804,6 +807,7 @@ nozzleDiameter: String(nozzleDiameter), layerHeight: String(layerHeight), baseColor: baseColor ? { hex: baseColor.hex, label: baseColor.label, collection: baseColor.collection } : null, + baseColorMode, baseMaterialXPath, baseMaterialXTileMM, baseMaterialXTriplanarSharpness, @@ -867,6 +871,13 @@ if (s.baseMaterialXPath !== undefined) baseMaterialXPath = s.baseMaterialXPath; if (s.baseMaterialXTileMM !== undefined) baseMaterialXTileMM = s.baseMaterialXTileMM; if (s.baseMaterialXTriplanarSharpness !== undefined) baseMaterialXTriplanarSharpness = s.baseMaterialXTriplanarSharpness; + // Mode falls back to "texture" when only the path is present + // (older settings files predate the explicit mode field). + if (s.baseColorMode === 'solid' || s.baseColorMode === 'texture') { + baseColorMode = s.baseColorMode; + } else { + baseColorMode = baseMaterialXPath ? 'texture' : 'solid'; + } // Best-effort check: when a settings file is loaded on a different // machine than where it was saved, the .mtlx path may not resolve. // Surface a warning immediately so the user knows before they @@ -1089,7 +1100,7 @@ LockedColors: colorSlots.filter((s): s is ColorInfo => s !== null).map(s => s.hex), Scale: sizeMode === 'scale' ? (parseFloat(scaleValue) || 1.0) : 1.0, BaseColor: baseColor?.hex ?? '', - BaseColorMaterialX: baseMaterialXPath, + BaseColorMaterialX: baseColorMode === 'texture' ? baseMaterialXPath : '', BaseColorMaterialXTileMM: baseMaterialXTileMM, BaseColorMaterialXTriplanarSharpness: baseMaterialXTriplanarSharpness, NozzleDiameter: parseFloat(nozzleDiameter) || 0.4, @@ -1335,6 +1346,7 @@ {/snippet}
+
-
- Base color - - Color used for faces that aren't covered by the model's texture. Pick a single color or load a procedural MaterialX (.mtlx) file (e.g. marble, brick) — MaterialX takes precedence when both are set. - -
{#if sizeMode === 'size'} {:else} {/if} - {#if baseMaterialXPath} -
- - {baseMaterialXPath.split(/[\\/]/).pop()} - - -
- {:else if baseColor} -
- - -
+
+ + +
+
+ Base color + + + + Color used for faces that aren't covered by the model's texture. Pick a single color, or load a MaterialX (.mtlx / .zip) graph for a procedural or image-backed pattern. + +
+ {#if baseColorMode === 'solid'} + {#if baseColor} +
+ + +
+ {:else} + + {/if} {:else} - + {#if baseMaterialXPath} +
+ + {baseMaterialXPath.split(/[\\/]/).pop()} + + +
+ {:else} + + {/if} {/if} - {#if !baseMaterialXPath} -
- MaterialX - - Load a MaterialX (.mtlx) file or .zip archive containing one (with adjacent textures). Procedural graphs (marble, brick) and image-backed PBR packs are both supported. - -
-
+ + {#if baseColorMode === 'solid' && baseColorPickerOpen} +
+ { + baseColor = { hex, label, collection }; baseColorPickerOpen = false; - } - }}>Load .mtlx / .zip - {:else} + }} + onclose={() => { baseColorPickerOpen = false; }} + /> +
+ {/if} + + {#if baseColorMode === 'texture' && baseMaterialXPath} +
Tile size @@ -1421,21 +1455,8 @@
- {/if} - {#if baseColorPickerOpen} -
- { - baseColor = { hex, label, collection }; - baseColorPickerOpen = false; - // Picking a hex retires the MaterialX. - baseMaterialXPath = ''; - }} - onclose={() => { baseColorPickerOpen = false; }} - /> -
- {/if} -
+
+ {/if}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 6a6202f..f125ed0 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -28,12 +28,12 @@ export function LoadSettingsFile(arg1:string):Promise; export function LogMessage(arg1:string,arg2:string):Promise; +export function MaterialXPathOK(arg1:string):Promise; + export function OpenFileDialog():Promise; export function OpenMaterialXFile():Promise; -export function MaterialXPathOK(arg1:string):Promise; - export function OpenStickerImage():Promise; export function ProcessPipeline(arg1:pipeline.Options):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 37f8770..1d29973 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -50,6 +50,10 @@ export function LogMessage(arg1, arg2) { return window['go']['main']['App']['LogMessage'](arg1, arg2); } +export function MaterialXPathOK(arg1) { + return window['go']['main']['App']['MaterialXPathOK'](arg1); +} + export function OpenFileDialog() { return window['go']['main']['App']['OpenFileDialog'](); } @@ -58,10 +62,6 @@ export function OpenMaterialXFile() { return window['go']['main']['App']['OpenMaterialXFile'](); } -export function MaterialXPathOK(arg1) { - return window['go']['main']['App']['MaterialXPathOK'](arg1); -} - export function OpenStickerImage() { return window['go']['main']['App']['OpenStickerImage'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index d6906ac..f2b3b8d 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -126,6 +126,7 @@ export namespace main { baseMaterialXPath?: string; baseMaterialXTileMM?: number; baseMaterialXTriplanarSharpness?: number; + baseColorMode?: string; colorSlots: ColorSlotSetting[]; inventoryCollection: string; brightness: number; @@ -168,6 +169,7 @@ export namespace main { this.baseMaterialXPath = source["baseMaterialXPath"]; this.baseMaterialXTileMM = source["baseMaterialXTileMM"]; this.baseMaterialXTriplanarSharpness = source["baseMaterialXTriplanarSharpness"]; + this.baseColorMode = source["baseColorMode"]; this.colorSlots = this.convertValues(source["colorSlots"], ColorSlotSetting); this.inventoryCollection = source["inventoryCollection"]; this.brightness = source["brightness"]; From c569e5d1764ba450b32ae0fe18246566656c9be6 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 20:20:26 -0700 Subject: [PATCH 09/13] Promote "Base color" label to its own row above the mode toggle Matches the Printer section's pattern where each control's title spans both columns, then the controls themselves sit on the row below. The Solid/Texture radio toggle stays on the left of the second row, with the active picker on the right. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/App.svelte | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 406df9d..36af707 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1368,10 +1368,17 @@ {/if}
- +
-
+
Base color + + Color used for faces that aren't covered by the model's texture. Pick a single color, or load a MaterialX (.mtlx / .zip) graph for a procedural or image-backed pattern. + +
+
- - Color used for faces that aren't covered by the model's texture. Pick a single color, or load a MaterialX (.mtlx / .zip) graph for a procedural or image-backed pattern. -
{#if baseColorMode === 'solid'} {#if baseColor} From bfa77008c4239f62771843eeb5bc9731fb5fd4c6 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 20:33:10 -0700 Subject: [PATCH 10/13] Fix nil-pointer crash from disk-cache sweep race in runStage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runStageCached's cache-hit path decoded the blob, logged "cache hit", then threw the value away and returned nil to indicate success. runStage then saw *slot == nil and called cache.get a second time to populate the slot — a second decode of the same blob. If the background disk-cache sweep (kicked at the end of every pipeline run in app.kickDiskCacheSweep) deleted the file between those two reads, the second cache.get returned nil. runStage stored the nil into the slot and returned (nil, nil). The caller (e.g. runLoad → applyBaseColor) then dereferenced the nil loadOutput. The Go runtime's SIGSEGV handler should have produced a clean panic, but on Linux Wails ships a CGO/GTK signal handler that clobbers SA_ONSTACK, so the runtime's diagnostic was the cryptic "non-Go code set up signal handler without SA_ONSTACK flag" instead of the underlying nil dereference. Fix: thread the decoded value from runStageCached's first read out to runStage so there's only ever one cache.get per stage. Plus a defensive guard at the end of runStage that returns an explicit error if neither the cache hit nor the body populated the slot — should be unreachable, but better than silently returning nil. Tests cover both the cache-hit invariant (runStage returns non-nil without invoking the body) and the eviction-race scenario (cache file gone before the call returns non-nil rather than (nil, nil)). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/pipeline/run.go | 28 +++--- internal/pipeline/stepcache.go | 20 +++-- internal/pipeline/unified_cache_test.go | 113 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index 27ce24e..b01d851 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -75,12 +75,10 @@ func (r *pipelineRun) checkCancel() error { // // 1. If the slot already holds a value (this Run already produced or // decoded it), return immediately. -// 2. Run the cache-aware wrapper. On a cache hit the body is skipped -// and the slot stays nil; on a miss, the body produces the value, -// stores it in the slot, and async-writes the encoded blob to the -// disk cache. -// 3. If the slot is still nil after the wrapper, the cache-hit path -// ran — decode from the cache to populate the slot. +// 2. Run the cache-aware wrapper. On a cache hit it returns the +// decoded value, which we stash directly into the slot. On a miss +// the body produces the value, stores it in the slot, and +// async-writes the encoded blob to the disk cache. // // The slot-then-cache-set ordering is load-bearing: a downstream call // to the typed getter (e.g. cache.getX) cannot return a value the @@ -96,7 +94,7 @@ func runStage[T any]( if *slot != nil { return *slot, nil } - err := runStageCached(r.cache, stage, r.opts, r.tracker, func() error { + cached, err := runStageCached(r.cache, stage, r.opts, r.tracker, func() error { out, err := body() if err != nil { return err @@ -112,10 +110,20 @@ func runStage[T any]( if err != nil { return nil, err } + if cached != nil { + // Cache-hit path: stash the wrapper's already-decoded value + // instead of doing a second cache.get. A second call would + // race the background disk-cache sweep (kicked at the end of + // every pipeline run) and could observe the file as deleted, + // leaving the slot nil and the caller dereferencing it. + *slot = cached.(*T) + } if *slot == nil { - if v := r.cache.get(stage, r.opts); v != nil { - *slot = v.(*T) - } + // Defensive: succeeded with neither a cache hit nor a body + // that populated the slot. Should be unreachable; surface + // loudly rather than return a nil pointer that downstream + // consumers will dereference. + return nil, fmt.Errorf("pipeline: stage %s succeeded with no result (cache file vanished?)", stageNames[stage]) } return *slot, nil } diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go index 3c13723..6f632ec 100644 --- a/internal/pipeline/stepcache.go +++ b/internal/pipeline/stepcache.go @@ -202,14 +202,22 @@ func (c *StageCache) SetDisk(d *diskcache.Cache) { // runStageCached is the canonical wrapper every pipeline stage uses. It: // -// - returns immediately on a cache hit, emitting a single "completed" -// stage marker so the UI shows the stage as done; +// - on a cache hit, returns the freshly-decoded value (cached != nil) +// so the caller can stash it directly without a second cache read; // - on a miss, times the body, lets body emit its own progress markers // (some stages are spinners, some have determinate progress bars from // inner functions like DecimateMesh / VoxelizeTwoGrids), and on // success calls stampCost to back-fill the disk meta sidecar with // the wall-clock generation time. // +// Returning the decoded value (rather than letting the caller re-fetch +// it) is load-bearing: the disk cache is swept asynchronously after +// every pipeline run, and a sweep that fires between the cache-hit +// detection here and a second cache.get from the caller would delete +// the file out from under us, leaving the caller with nil. Surfaced +// previously as a SIGSEGV in applyBaseColor when Load returned +// (nil, nil). +// // body is responsible only for producing and persisting the stage's // result. In normal use, callers reach this helper via runStage (in // run.go), which wraps body to memoize the live pointer into @@ -224,7 +232,7 @@ func runStageCached( opts Options, tracker progress.Tracker, body func() error, -) error { +) (cached any, err error) { name := stageNames[stage] key := cache.stageKey(stage, opts) getStart := time.Now() @@ -234,7 +242,7 @@ func runStageCached( hitSourceLabel(src), time.Since(getStart).Round(time.Microsecond), shortKey(key)) progress.BeginStage(tracker, name, false, 0).Done() - return nil + return v, nil } plog.Printf("%s: starting (cache miss key=%s)", name, shortKey(key)) start := time.Now() @@ -244,7 +252,7 @@ func runStageCached( // pointing at it would be misleading. plog.Printf("%s: failed after %s — %v", name, time.Since(start).Round(time.Millisecond), err) - return err + return nil, err } plog.Printf("%s: done in %s", name, time.Since(start).Round(time.Millisecond)) @@ -252,7 +260,7 @@ func runStageCached( // meta sidecar with description and wall-clock cost so the // next sweep can rank this entry correctly. cache.stampCost(stage, opts, time.Since(start)) - return nil + return nil, nil } // shortKey returns the first 12 hex chars of a stage cache key — enough diff --git a/internal/pipeline/unified_cache_test.go b/internal/pipeline/unified_cache_test.go index e9eabd8..43c0d7e 100644 --- a/internal/pipeline/unified_cache_test.go +++ b/internal/pipeline/unified_cache_test.go @@ -1,11 +1,13 @@ package pipeline import ( + "context" "os" "path/filepath" "testing" "github.com/rtwfroody/ditherforge/internal/diskcache" + "github.com/rtwfroody/ditherforge/internal/progress" ) // makeFakeInput writes a tiny placeholder to a temp dir so stageKey's @@ -182,3 +184,114 @@ func TestStageKeyEmptyOnHashFailure(t *testing.T) { t.Errorf("expected empty key on hash failure, got %q", k) } } + +// TestRunStageCacheHitReturnsValue is the basic post-refactor invariant: +// when the disk cache contains a usable entry for the stage, runStage +// returns it without invoking the body, and the returned pointer is +// non-nil. Caller code (Load → applyBaseColor) dereferences the +// pointer immediately, so a (nil, nil) return would be a crash. +func TestRunStageCacheHitReturnsValue(t *testing.T) { + c := NewStageCache() + d, err := diskcache.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + c.SetDisk(d) + defer c.WaitForDiskWrites() + + path := makeFakeInput(t) + opts := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"} + + want := &decimateOutput{} + c.set(StageDecimate, opts, want) + c.WaitForDiskWrites() + + bodyRan := false + r := &pipelineRun{ + ctx: context.Background(), + cache: c, + opts: opts, + tracker: progress.NullTracker{}, + } + got, err := runStage(r, StageDecimate, &r.decimate, func() (*decimateOutput, error) { + bodyRan = true + return &decimateOutput{}, nil + }) + if err != nil { + t.Fatalf("runStage: %v", err) + } + if got == nil { + t.Fatal("runStage returned nil on a cache hit") + } + if bodyRan { + t.Error("body executed despite a cache hit being available") + } +} + +// TestRunStageHandlesMidRunCacheEviction guards against the regression +// that produced a SIGSEGV in applyBaseColor when the disk cache sweep +// (kicked at the end of every pipeline run) raced runStage's old +// double-read pattern: getWithSource succeeded inside runStageCached, +// the value was thrown away, and a second cache.get from the caller +// raced the sweep's os.Remove and returned nil. runStage wrote the +// nil into the slot and returned (nil, nil). +// +// We simulate the race by deleting the cache file between the +// runStageCached cache hit and any second read: the runStageCached +// closure (the body) doesn't run on a hit, so we instead pre-populate +// the disk cache, then wedge a fake "remove the file underneath" +// between getWithSource and the slot write by removing it from a Set +// completion goroutine. Easier: just ensure runStage's contract holds +// — when there's a cache hit, the returned slot is non-nil even if a +// concurrent eviction wipes the file. +func TestRunStageHandlesMidRunCacheEviction(t *testing.T) { + c := NewStageCache() + d, err := diskcache.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + c.SetDisk(d) + defer c.WaitForDiskWrites() + + path := makeFakeInput(t) + opts := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"} + + // Pre-populate the Decimate stage with a known value. + want := &decimateOutput{} + c.set(StageDecimate, opts, want) + c.WaitForDiskWrites() + + // Now wipe the file from disk to simulate the post-set sweep race. + // runStageCached's first cache.getWithSource will still succeed by + // reading from the OS page cache before the unlink takes effect — + // and even if it doesn't, the bug we care about is that the + // caller's second cache.get goes missing. Deleting before the + // runStage call exercises both halves: with the old code, the + // first read happens, the value is dropped, the second read + // misses (file gone), and *slot stays nil. With the fix, + // runStageCached returns the decoded value to the caller, which + // stashes it before any second read. + subdir := stageSubdir(StageDecimate) + key := c.stageKey(StageDecimate, opts) + d.Remove(subdir, key) + + r := &pipelineRun{ + ctx: context.Background(), + cache: c, + opts: opts, + tracker: progress.NullTracker{}, + } + got, err := runStage(r, StageDecimate, &r.decimate, func() (*decimateOutput, error) { + // Body should NOT run if the cache hit returned a value before + // our delete. If it does run (the file was already gone before + // the wrapper's get), that's fine — we're testing the + // non-nil-result invariant, not the exact code path. + return &decimateOutput{}, nil + }) + if err != nil { + t.Fatalf("runStage returned err=%v on a path that should always succeed", err) + } + if got == nil { + t.Fatal("runStage returned (nil, nil) — caller would dereference nil and crash") + } +} From 9847edeaa2583e1186c824e9be59251bfbc75383 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 20:42:41 -0700 Subject: [PATCH 11/13] Bump version to 0.8.0 Minor bump for the new MaterialX base-color feature (procedural .mtlx graphs, image-backed PBR packs via .zip, triplanar projection, sign-flipped UVs, sampler caching, and the runStage cache-race fix). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/pipeline/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pipeline/version.go b/internal/pipeline/version.go index e428c07..eafb85e 100644 --- a/internal/pipeline/version.go +++ b/internal/pipeline/version.go @@ -2,7 +2,7 @@ package pipeline // VersionSemver is the bare semver portion of the application version. // Keep this in sync with Version below; the release workflow greps Version. -const VersionSemver = "0.7.1" +const VersionSemver = "0.8.0" // Version is the application version string shown in UIs and CLI --version. const Version = "ditherforge " + VersionSemver From 9a7ee284b4a9c1a6e3a4959b12edd56a3cb040a6 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 20:46:04 -0700 Subject: [PATCH 12/13] README: document MaterialX base-color support Updates the "Base color" section to cover the new Solid/Texture toggle, and adds the three --base-materialx* CLI flags to the options table. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 376e194..6c127cc 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,34 @@ alone. STL files always contain a single mesh and do not show this control. Meshes sometimes have faces without a texture or vertex color (common in STL files and in some 3MF files). By default these faces render as plain white. -Use the **Base color** picker in the settings panel to choose a different -color — this acts as the "paint" applied to any face that has no other color -assigned, before dithering and palette selection. +The **Base color** section of the settings panel offers two modes: + +- **Solid** — pick a single color from any of your filament collections. + This acts as the "paint" applied to any face that has no other color + assigned, before dithering and palette selection. +- **Texture** — load a [MaterialX](https://materialx.org/) shader graph + (`.mtlx` file, or a `.zip` archive containing the `.mtlx` and its + textures) and apply it as a procedural or image-backed pattern. Procedural + graphs (marble, brick) are sampled per voxel in 3D, so the pattern looks + carved-from-the-block rather than projected. Image-backed PBR packs + (Quixel, AmbientCG, …) are projected via triplanar mapping, so they wrap + cleanly across faces without requiring authored UVs on the mesh. + + Two knobs appear once a file is loaded: + + - **Tile size (mm)** — the object-space distance one shading-unit cycle + of the procedural maps to. For image packs this is also the texture's + repeat distance. Smaller = denser pattern. + - **Triplanar** — sharpness of the triplanar projection blend for + image-backed graphs. `1` is a soft cosine blend; higher values approach + a hard box map. Ignored by purely procedural graphs that don't read + texture coordinates. + + Try the official [`standard_surface_marble_solid.mtlx`](https://github.com/AcademySoftwareFoundation/MaterialX/blob/main/resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx) + for a procedural example, or any [AmbientCG](https://ambientcg.com/) pack + exported as a `.zip`. Only the graph's `base_color` output is consumed — + normal maps, roughness, etc. are ignored, and only RGB is baked into the + print. ## How to Configure the Color Palette @@ -336,6 +361,9 @@ settings file. | `--color` | — | Lock a color (CSS name or `#RRGGBB`; repeatable, comma-separated) | | `--inventory` | — | Filament inventory file (`#RRGGBB Label` per line) for auto colors | | `--base-color` | — | Hex color for untextured faces (e.g. `#FF0000`) | +| `--base-materialx` | — | Path to a MaterialX `.mtlx` file (or `.zip` archive containing one with adjacent textures) applied as the base color of untextured faces. Overrides `--base-color`. | +| `--base-materialx-tile-mm` | `10` | Object-space scale (mm per shading-unit cycle) for the MaterialX graph | +| `--base-materialx-triplanar-sharpness` | `4` | Triplanar projection sharpness for image-backed MaterialX (higher = sharper axis transitions; ignored by procedural `.mtlx`) | | `--brightness` | `0` | Brightness adjustment (-100 to +100) | | `--contrast` | `0` | Contrast adjustment (-100 to +100) | | `--saturation` | `0` | Saturation adjustment (-100 to +100) | From 82e67aabc186d0ca949197b633d7dfe47e4569b4 Mon Sep 17 00:00:00 2001 From: Tim Newsome Date: Sat, 2 May 2026 21:49:39 -0700 Subject: [PATCH 13/13] Address review pass on PR #3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small cleanups from the holistic review: * Delete TestRunStageHandlesMidRunCacheEviction. The named regression did not exercise — it deleted the cache file before runStage ran, so the first cache.get inside runStageCached missed (file gone), the body ran, and *slot was populated. The old buggy code passes the same path. The actual race (delete BETWEEN the wrapper's get and the caller's second get) is now structurally impossible because the second get is gone; TestRunStageCacheHitReturnsValue covers what's still meaningful to assert. * Add BaseColorMaterialXTriplanarSharpness coverage to the Sticker cache-key test — it was missing while the Voxelize equivalent already had it. * Stale comment refs from the pre-refactor architecture: runSticker / runVoxelize / runLoad / cache.setLoad / buildBaseColorOverride no longer exist as named functions. Renamed to "the {Sticker,Voxelize, Load} stage body" and "cache.baseColorOverride" respectively. * Hoist the duplicate "ignoring MaterialX base color" warning into cache.baseColorOverride with per-session, per-(path,mtime,size) dedup. Previously the user saw the same toast twice per run on a malformed .mtlx (once from applyBaseColor, once from Voxelize). * gofmt over cmd/ditherforge/main.go to retire the column-shift drift introduced when the new BaseMaterialX* fields landed. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/ditherforge/main.go | 84 ++++++++++++------------- internal/pipeline/loadoutput_persist.go | 8 +-- internal/pipeline/materialx_override.go | 22 ++++++- internal/pipeline/pipeline.go | 17 ++--- internal/pipeline/run.go | 9 +-- internal/pipeline/stepcache.go | 12 +++- internal/pipeline/stepcache_test.go | 20 +++--- internal/pipeline/unified_cache_test.go | 67 -------------------- 8 files changed, 101 insertions(+), 138 deletions(-) diff --git a/cmd/ditherforge/main.go b/cmd/ditherforge/main.go index a032a9f..ed1be55 100644 --- a/cmd/ditherforge/main.go +++ b/cmd/ditherforge/main.go @@ -27,32 +27,32 @@ func expandColors(colors []string) []string { // Args defines the CLI arguments. type Args struct { - Input string `arg:"positional,required" help:"Input .glb, .3mf, or .stl file"` - NumColors int `arg:"-n" default:"4" help:"Number of palette colors"` - Color []string `arg:"--color,separate" help:"Lock a color (CSS name or hex, repeatable, comma-separated)"` - Inventory string `arg:"--inventory" help:"Inventory file for remaining colors"` - Scale float32 `arg:"--scale" default:"1.0" help:"Additional scale multiplier"` - Output string `arg:"--output" help:"Output .3mf file (default: .3mf)"` - BaseColor string `arg:"--base-color" help:"Hex color for untextured faces (e.g. #FF0000)"` - BaseMaterialX string `arg:"--base-materialx" help:"Path to a .mtlx file or .zip archive containing one (with adjacent textures) applied as the base color of untextured faces (overrides --base-color)"` - BaseMaterialXTileMM float64 `arg:"--base-materialx-tile-mm" default:"10" help:"Object-space scale (mm per shading-unit cycle) for the MaterialX procedural"` - BaseMaterialXTriplanarSharpness float64 `arg:"--base-materialx-triplanar-sharpness" default:"4" help:"Triplanar projection sharpness for image-backed MaterialX (higher = sharper axis transitions; ignored by procedural .mtlx)"` - NozzleDiameter float32 `arg:"--nozzle-diameter" default:"0.4" help:"Nozzle diameter in mm"` - LayerHeight float32 `arg:"--layer-height" default:"0.2" help:"Layer height in mm"` - Printer string `arg:"--printer" help:"Target printer profile id (e.g. snapmaker_u1, snapmaker_j1, prusa_xl, prusa_xl_5t, bambu_h2d, bambu_h2d_pro); defaults to snapmaker_u1"` - Brightness float32 `arg:"--brightness" default:"0" help:"Brightness adjustment (-100 to +100)"` - Contrast float32 `arg:"--contrast" default:"0" help:"Contrast adjustment (-100 to +100)"` - Saturation float32 `arg:"--saturation" default:"0" help:"Saturation adjustment (-100 to +100)"` - Dither string `arg:"--dither" default:"dizzy" help:"Dithering mode: none, dizzy"` - NoMerge bool `arg:"--no-merge" help:"Skip coplanar triangle merging"` - NoSimplify bool `arg:"--no-simplify" help:"Skip QEM mesh decimation before clipping"` - Size *float32 `arg:"--size" help:"Scale model so largest extent equals this value in mm"` - Force bool `arg:"--force" help:"Bypass extent size check"` - Stats bool `arg:"--stats" help:"Print face counts per material"` - ColorSnap float64 `arg:"--color-snap" default:"5" help:"Shift cell colors toward nearest palette color by this many delta E units (0 to disable)"` - AlphaWrap bool `arg:"--alpha-wrap" help:"Clean up the loaded mesh with CGAL Alpha_wrap_3 (requires uv on PATH)"` - AlphaWrapAlpha float32 `arg:"--alpha-wrap-alpha" help:"Alpha-wrap probe radius in mm (default: nozzle diameter)"` - AlphaWrapOffset float32 `arg:"--alpha-wrap-offset" help:"Alpha-wrap offset distance in mm (default: alpha/30)"` + Input string `arg:"positional,required" help:"Input .glb, .3mf, or .stl file"` + NumColors int `arg:"-n" default:"4" help:"Number of palette colors"` + Color []string `arg:"--color,separate" help:"Lock a color (CSS name or hex, repeatable, comma-separated)"` + Inventory string `arg:"--inventory" help:"Inventory file for remaining colors"` + Scale float32 `arg:"--scale" default:"1.0" help:"Additional scale multiplier"` + Output string `arg:"--output" help:"Output .3mf file (default: .3mf)"` + BaseColor string `arg:"--base-color" help:"Hex color for untextured faces (e.g. #FF0000)"` + BaseMaterialX string `arg:"--base-materialx" help:"Path to a .mtlx file or .zip archive containing one (with adjacent textures) applied as the base color of untextured faces (overrides --base-color)"` + BaseMaterialXTileMM float64 `arg:"--base-materialx-tile-mm" default:"10" help:"Object-space scale (mm per shading-unit cycle) for the MaterialX procedural"` + BaseMaterialXTriplanarSharpness float64 `arg:"--base-materialx-triplanar-sharpness" default:"4" help:"Triplanar projection sharpness for image-backed MaterialX (higher = sharper axis transitions; ignored by procedural .mtlx)"` + NozzleDiameter float32 `arg:"--nozzle-diameter" default:"0.4" help:"Nozzle diameter in mm"` + LayerHeight float32 `arg:"--layer-height" default:"0.2" help:"Layer height in mm"` + Printer string `arg:"--printer" help:"Target printer profile id (e.g. snapmaker_u1, snapmaker_j1, prusa_xl, prusa_xl_5t, bambu_h2d, bambu_h2d_pro); defaults to snapmaker_u1"` + Brightness float32 `arg:"--brightness" default:"0" help:"Brightness adjustment (-100 to +100)"` + Contrast float32 `arg:"--contrast" default:"0" help:"Contrast adjustment (-100 to +100)"` + Saturation float32 `arg:"--saturation" default:"0" help:"Saturation adjustment (-100 to +100)"` + Dither string `arg:"--dither" default:"dizzy" help:"Dithering mode: none, dizzy"` + NoMerge bool `arg:"--no-merge" help:"Skip coplanar triangle merging"` + NoSimplify bool `arg:"--no-simplify" help:"Skip QEM mesh decimation before clipping"` + Size *float32 `arg:"--size" help:"Scale model so largest extent equals this value in mm"` + Force bool `arg:"--force" help:"Bypass extent size check"` + Stats bool `arg:"--stats" help:"Print face counts per material"` + ColorSnap float64 `arg:"--color-snap" default:"5" help:"Shift cell colors toward nearest palette color by this many delta E units (0 to disable)"` + AlphaWrap bool `arg:"--alpha-wrap" help:"Clean up the loaded mesh with CGAL Alpha_wrap_3 (requires uv on PATH)"` + AlphaWrapAlpha float32 `arg:"--alpha-wrap-alpha" help:"Alpha-wrap probe radius in mm (default: nozzle diameter)"` + AlphaWrapOffset float32 `arg:"--alpha-wrap-offset" help:"Alpha-wrap offset distance in mm (default: alpha/30)"` } func (Args) Description() string { @@ -90,22 +90,22 @@ func main() { BaseColorMaterialXTileMM: args.BaseMaterialXTileMM, BaseColorMaterialXTriplanarSharpness: args.BaseMaterialXTriplanarSharpness, NozzleDiameter: args.NozzleDiameter, - LayerHeight: args.LayerHeight, - Printer: args.Printer, - Brightness: args.Brightness, - Contrast: args.Contrast, - Saturation: args.Saturation, - Dither: args.Dither, - NoMerge: args.NoMerge, - NoSimplify: args.NoSimplify, - Size: args.Size, - Force: args.Force, - Stats: args.Stats, - ColorSnap: args.ColorSnap, - ObjectIndex: -1, // load all objects (no CLI flag yet; GUI has a picker dialog) - AlphaWrap: args.AlphaWrap, - AlphaWrapAlpha: args.AlphaWrapAlpha, - AlphaWrapOffset: args.AlphaWrapOffset, + LayerHeight: args.LayerHeight, + Printer: args.Printer, + Brightness: args.Brightness, + Contrast: args.Contrast, + Saturation: args.Saturation, + Dither: args.Dither, + NoMerge: args.NoMerge, + NoSimplify: args.NoSimplify, + Size: args.Size, + Force: args.Force, + Stats: args.Stats, + ColorSnap: args.ColorSnap, + ObjectIndex: -1, // load all objects (no CLI flag yet; GUI has a picker dialog) + AlphaWrap: args.AlphaWrap, + AlphaWrapAlpha: args.AlphaWrapAlpha, + AlphaWrapOffset: args.AlphaWrapOffset, } prepResult, _, err := pipeline.Run(context.Background(), opts) diff --git a/internal/pipeline/loadoutput_persist.go b/internal/pipeline/loadoutput_persist.go index 87d36ac..0001c29 100644 --- a/internal/pipeline/loadoutput_persist.go +++ b/internal/pipeline/loadoutput_persist.go @@ -14,7 +14,7 @@ import ( // applyBaseColor (pipeline.go) and voxel.SampleNearestColorWithSticker // (color.go) both branch on whether these pointers are equal. // -// runLoad produces exactly these three configurations: +// the Load stage body produces exactly these three configurations: // // 1. Alpha-wrap off: Model == ColorModel == SampleModel // 2. Alpha-wrap on, inflate: Model != ColorModel, SampleModel != ColorModel, @@ -28,7 +28,7 @@ import ( // Invariant: SampleModel ∈ {Model, ColorModel}. The encoding does NOT // handle a hypothetical "Model == SampleModel but != ColorModel" // configuration — both fields would round-trip as distinct copies, -// silently losing the Model==SampleModel aliasing. If runLoad ever +// silently losing the Model==SampleModel aliasing. If the Load stage body ever // produces that configuration, this encoder needs a third alias bit. type loadOutputOnDisk struct { @@ -40,8 +40,8 @@ type loadOutputOnDisk struct { ExtentMM float32 // The applied-base-color triple (appliedBaseColor, // appliedBaseColorMaterialX, appliedBaseColorMaterialXTileMM) is - // intentionally not persisted: cache.setLoad is called inside - // runLoad (before applyBaseColor runs), so the disk version's + // intentionally not persisted: cache.set is called inside + // the Load stage body (before applyBaseColor runs), so the disk version's // "applied" state is always pristine. On disk hit applyBaseColor // sees the pristine triple and skips the reset-from-parse path // (since lo.ColorModel is already pristine); only an in-session diff --git a/internal/pipeline/materialx_override.go b/internal/pipeline/materialx_override.go index b846462..0841d3b 100644 --- a/internal/pipeline/materialx_override.go +++ b/internal/pipeline/materialx_override.go @@ -5,6 +5,7 @@ import ( "math" "github.com/rtwfroody/ditherforge/internal/materialx" + "github.com/rtwfroody/ditherforge/internal/progress" "github.com/rtwfroody/ditherforge/internal/voxel" ) @@ -168,14 +169,29 @@ func floatToByte(f float64) uint8 { // the voxelizer. The expensive parts (XML parse + image decode) are // memoized on StageCache, so applyBaseColor and the voxelize stage // share one parse per pipeline run. -func (c *StageCache) baseColorOverride(path string, tileMM, triplanarSharpness float64) (voxel.BaseColorOverride, error) { +// +// On parse error, tracker.Warn is invoked once per session per +// (path, mtime, size) — applyBaseColor and Voxelize both call this +// per run, and we don't want the same toast twice. The error is +// still returned so callers can skip downstream work, but only the +// first call surfaces it to the user. +func (c *StageCache) baseColorOverride(path string, tileMM, triplanarSharpness float64, tracker progress.Tracker) (voxel.BaseColorOverride, error) { if path == "" { + c.mtlxWarnedPath = "" return nil, nil } s, err := c.materialXSampler(path) if err != nil { - return nil, fmt.Errorf("MaterialX %q: %w", path, err) - } + err = fmt.Errorf("MaterialX %q: %w", path, err) + if c.mtlxWarnedPath != path { + tracker.Warn(fmt.Sprintf("ignoring MaterialX base color: %v", err)) + c.mtlxWarnedPath = path + } + return nil, err + } + // Successful resolution clears the dedup so a future failure on + // this path warns again. + c.mtlxWarnedPath = "" if s == nil { return nil, nil } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index e32a640..5ebf88c 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -506,7 +506,7 @@ func applyBaseColorOverride(model *loader.LoadedModel, hexColor string) { // - opts.BaseColorMaterialX (with TileMM): per-face centroid sample of the // procedural graph, baked into FaceBaseColor for the preview. The // voxelizer also samples per-voxel via the same graph for higher -// fidelity (see buildBaseColorOverride). MaterialX takes precedence +// fidelity (see cache.baseColorOverride). MaterialX takes precedence // when both are set. // - opts.BaseColor: legacy uniform hex override. // @@ -541,23 +541,24 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options, tracker pro } switch { case opts.BaseColorMaterialX != "": - // Build the override once and reuse it across both models — - // parsing the .mtlx (and decoding any referenced textures) is - // cheap but non-trivial, and run.go also builds its own - // override for per-voxel sampling. + // Build the override once and reuse it across both models. + // The expensive parts (XML parse + image decode) are memoized + // on StageCache so the Voxelize stage's per-voxel sampler + // reuses the same compiled graph; the warning on parse error + // is also deduped inside baseColorOverride. override, err := cache.baseColorOverride( opts.BaseColorMaterialX, opts.BaseColorMaterialXTileMM, opts.BaseColorMaterialXTriplanarSharpness, + tracker, ) - if err != nil { - tracker.Warn(fmt.Sprintf("ignoring MaterialX base color: %v", err)) - } else if override != nil { + if err == nil && override != nil { bakeMaterialXBaseColor(lo.ColorModel, override) if lo.SampleModel != lo.ColorModel { bakeMaterialXBaseColor(lo.SampleModel, override) } } + _ = err // baseColorOverride already routed the warning through tracker. case opts.BaseColor != "": applyBaseColorOverride(lo.ColorModel, opts.BaseColor) if lo.SampleModel != lo.ColorModel { diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index b01d851..fc3e1e4 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -517,14 +517,15 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) { } } - baseColorOverride, bcoErr := r.cache.baseColorOverride( + // baseColorOverride routes parse errors through the tracker + // itself with per-session dedup, so we just pass nil through + // to the voxelizer on failure. + baseColorOverride, _ := r.cache.baseColorOverride( r.opts.BaseColorMaterialX, r.opts.BaseColorMaterialXTileMM, r.opts.BaseColorMaterialXTriplanarSharpness, + r.tracker, ) - if bcoErr != nil { - r.tracker.Warn(fmt.Sprintf("ignoring MaterialX base color: %v", bcoErr)) - } result, verr := squarevoxel.VoxelizeTwoGrids(r.ctx, lo.Model, sampleModel, stickerModel, stickerSI, layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo, diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go index 6f632ec..678cf12 100644 --- a/internal/pipeline/stepcache.go +++ b/internal/pipeline/stepcache.go @@ -186,6 +186,12 @@ type StageCache struct { mtlxSamplerSize int64 mtlxSampler materialx.Sampler mtlxSamplerErr error + // mtlxWarnedPath suppresses duplicate "ignoring MaterialX base + // color" warnings: applyBaseColor and the Voxelize stage both + // build the override per run, and a malformed .mtlx would + // otherwise fire the same toast twice. Cleared whenever a + // different path is consulted. + mtlxWarnedPath string } // NewStageCache returns an empty stage cache with no disk persistence. @@ -457,7 +463,7 @@ type stickerOutput struct { // nearest-tri lookup (Model == sample model) or two separate lookups. FromAlphaWrap bool - // si is the spatial index over Model. Seeded inside runSticker on a + // si is the spatial index over Model. Seeded inside the Sticker stage body on a // fresh build; nil after a disk-cache decode (the field is unexported, // gob skips it). Rebuilt by ensureSI() on first access. sync.Once // makes the lazy build safe against the disk-encode goroutine @@ -470,7 +476,7 @@ type stickerOutput struct { // ensureSI returns so.si, building it on first call. Safe to call from // multiple goroutines; in practice the single pipeline worker is the only -// caller (runVoxelize on the alpha-wrap branch). +// caller (the Voxelize stage body on the alpha-wrap branch). func (so *stickerOutput) ensureSI() *voxel.SpatialIndex { so.siOnce.Do(func() { if so.si == nil && so.Model != nil { @@ -595,7 +601,7 @@ type loadSettings struct { // affects voxel cell coloring. A cheap per-run step reapplies the override // to the cached ColorModel before voxelize, so Load/Decimate caches // survive base-color changes. Sticker is invalidated on base-color change -// because runSticker deep-clones ColorModel into so.Model and the per-run +// because the Sticker stage body deep-clones ColorModel into so.Model and the per-run // reapply step does not patch that scratch copy. type voxelizeSettings struct { NozzleDiameter float32 diff --git a/internal/pipeline/stepcache_test.go b/internal/pipeline/stepcache_test.go index fef5c48..1492a6b 100644 --- a/internal/pipeline/stepcache_test.go +++ b/internal/pipeline/stepcache_test.go @@ -103,7 +103,7 @@ func writeMtlxTempFile(t *testing.T, body string) string { } // TestStickerStageKeyDependsOnBaseColor guards a cache-coherency contract: -// runSticker deep-clones lo.ColorModel into so.Model, including +// the Sticker stage body deep-clones lo.ColorModel into so.Model, including // FaceBaseColor. The per-run applyBaseColor reapplies the override to // lo.ColorModel/lo.SampleModel but not to so.Model. So a base-color change // must invalidate the sticker stage; otherwise voxelize samples colors from @@ -122,7 +122,7 @@ func TestStickerStageKeyDependsOnBaseColor(t *testing.T) { if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, changed) { t.Fatal("StageSticker key did not change when BaseColor changed; " + - "runSticker's so.Model.FaceBaseColor would be stale on a cached run") + "the Sticker stage body's so.Model.FaceBaseColor would be stale on a cached run") } } @@ -207,7 +207,7 @@ func TestVoxelizeStageKeyDependsOnMaterialX(t *testing.T) { // TestStickerStageKeyDependsOnMaterialX is the sticker-stage analogue // of TestStickerStageKeyDependsOnBaseColor for the MaterialX override. -// runSticker deep-clones lo.ColorModel into so.Model with whatever +// the Sticker stage body deep-clones lo.ColorModel into so.Model with whatever // pattern was baked into FaceBaseColor by the per-face preview bake; // any change to the underlying .mtlx must invalidate that cached // clone. @@ -216,9 +216,10 @@ func TestStickerStageKeyDependsOnMaterialX(t *testing.T) { mtlxA := writeMtlxTempFile(t, "") mtlxB := writeMtlxTempFile(t, "") base := Options{ - Input: "model.glb", - BaseColorMaterialX: mtlxA, - BaseColorMaterialXTileMM: 10, + Input: "model.glb", + BaseColorMaterialX: mtlxA, + BaseColorMaterialXTileMM: 10, + BaseColorMaterialXTriplanarSharpness: 4, Stickers: []Sticker{ {ImagePath: "sticker.png", Mode: "unfold", Scale: 1, MaxAngle: 90}, }, @@ -227,14 +228,19 @@ func TestStickerStageKeyDependsOnMaterialX(t *testing.T) { pathChanged.BaseColorMaterialX = mtlxB tileChanged := base tileChanged.BaseColorMaterialXTileMM = 20 + sharpChanged := base + sharpChanged.BaseColorMaterialXTriplanarSharpness = 8 if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, pathChanged) { t.Error("StageSticker key did not change when BaseColorMaterialX path changed; " + - "so.Model.FaceBaseColor would be stale on a cached run") + "the Sticker stage body's so.Model.FaceBaseColor would be stale on a cached run") } if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, tileChanged) { t.Error("StageSticker key did not change when BaseColorMaterialXTileMM changed") } + if c.stageFnv(StageSticker, base) == c.stageFnv(StageSticker, sharpChanged) { + t.Error("StageSticker key did not change when BaseColorMaterialXTriplanarSharpness changed") + } } // TestLoadAndDecimateStageKeysIndependentOfMaterialX mirrors the design diff --git a/internal/pipeline/unified_cache_test.go b/internal/pipeline/unified_cache_test.go index 43c0d7e..2da142f 100644 --- a/internal/pipeline/unified_cache_test.go +++ b/internal/pipeline/unified_cache_test.go @@ -228,70 +228,3 @@ func TestRunStageCacheHitReturnsValue(t *testing.T) { } } -// TestRunStageHandlesMidRunCacheEviction guards against the regression -// that produced a SIGSEGV in applyBaseColor when the disk cache sweep -// (kicked at the end of every pipeline run) raced runStage's old -// double-read pattern: getWithSource succeeded inside runStageCached, -// the value was thrown away, and a second cache.get from the caller -// raced the sweep's os.Remove and returned nil. runStage wrote the -// nil into the slot and returned (nil, nil). -// -// We simulate the race by deleting the cache file between the -// runStageCached cache hit and any second read: the runStageCached -// closure (the body) doesn't run on a hit, so we instead pre-populate -// the disk cache, then wedge a fake "remove the file underneath" -// between getWithSource and the slot write by removing it from a Set -// completion goroutine. Easier: just ensure runStage's contract holds -// — when there's a cache hit, the returned slot is non-nil even if a -// concurrent eviction wipes the file. -func TestRunStageHandlesMidRunCacheEviction(t *testing.T) { - c := NewStageCache() - d, err := diskcache.Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - c.SetDisk(d) - defer c.WaitForDiskWrites() - - path := makeFakeInput(t) - opts := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"} - - // Pre-populate the Decimate stage with a known value. - want := &decimateOutput{} - c.set(StageDecimate, opts, want) - c.WaitForDiskWrites() - - // Now wipe the file from disk to simulate the post-set sweep race. - // runStageCached's first cache.getWithSource will still succeed by - // reading from the OS page cache before the unlink takes effect — - // and even if it doesn't, the bug we care about is that the - // caller's second cache.get goes missing. Deleting before the - // runStage call exercises both halves: with the old code, the - // first read happens, the value is dropped, the second read - // misses (file gone), and *slot stays nil. With the fix, - // runStageCached returns the decoded value to the caller, which - // stashes it before any second read. - subdir := stageSubdir(StageDecimate) - key := c.stageKey(StageDecimate, opts) - d.Remove(subdir, key) - - r := &pipelineRun{ - ctx: context.Background(), - cache: c, - opts: opts, - tracker: progress.NullTracker{}, - } - got, err := runStage(r, StageDecimate, &r.decimate, func() (*decimateOutput, error) { - // Body should NOT run if the cache hit returned a value before - // our delete. If it does run (the file was already gone before - // the wrapper's get), that's fine — we're testing the - // non-nil-result invariant, not the exact code path. - return &decimateOutput{}, nil - }) - if err != nil { - t.Fatalf("runStage returned err=%v on a path that should always succeed", err) - } - if got == nil { - t.Fatal("runStage returned (nil, nil) — caller would dereference nil and crash") - } -}