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) |
diff --git a/app.go b/app.go
index 5af0fc7..33c918c 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)
@@ -703,6 +716,42 @@ 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"`
+}
+
+// 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
+// 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 or Texture Pack",
+ Filters: []wailsRuntime.FileFilter{
+ {DisplayName: "MaterialX (*.mtlx, *.zip)", Pattern: "*.mtlx;*.zip"},
+ },
+ })
+ if err != nil || path == "" {
+ return &MaterialXOpenResult{}, err
+ }
+ return &MaterialXOpenResult{Path: path}, 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 +847,22 @@ 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 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"`
+ // 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/cmd/ditherforge/main.go b/cmd/ditherforge/main.go
index 660c4af..ed1be55 100644
--- a/cmd/ditherforge/main.go
+++ b/cmd/ditherforge/main.go
@@ -27,29 +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)"`
- 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 {
@@ -76,30 +79,33 @@ func main() {
}
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,
- 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,
+ 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,
+ 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/frontend/src/App.svelte b/frontend/src/App.svelte
index 3958128..36af707 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, 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';
@@ -118,6 +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. 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;
@@ -560,7 +570,7 @@
$effect(() => {
// Read all form values to establish tracking.
void [inputFile, sizeMode, sizeValue, scaleValue, printerId, nozzleDiameter,
- layerHeight, baseColor, ...colorSlots,
+ layerHeight, baseColor, baseColorMode, baseMaterialXPath, baseMaterialXTileMM, baseMaterialXTriplanarSharpness, ...colorSlots,
inventoryCollectionColors,
committedBrightness, committedContrast, committedSaturation,
JSON.stringify(warpPins),
@@ -797,6 +807,10 @@
nozzleDiameter: String(nozzleDiameter),
layerHeight: String(layerHeight),
baseColor: baseColor ? { hex: baseColor.hex, label: baseColor.label, collection: baseColor.collection } : null,
+ baseColorMode,
+ baseMaterialXPath,
+ baseMaterialXTileMM,
+ baseMaterialXTriplanarSharpness,
colorSlots: colorSlots.map(s => s ? { hex: s.hex, label: s.label, collection: s.collection } : null),
inventoryCollection,
brightness,
@@ -854,6 +868,28 @@
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.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
+ // 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);
}
@@ -1064,6 +1100,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: baseColorMode === 'texture' ? baseMaterialXPath : '',
+ BaseColorMaterialXTileMM: baseMaterialXTileMM,
+ BaseColorMaterialXTriplanarSharpness: baseMaterialXTriplanarSharpness,
NozzleDiameter: parseFloat(nozzleDiameter) || 0.4,
LayerHeight: parseFloat(layerHeight) || 0.2,
Printer: printerId,
@@ -1307,6 +1346,7 @@
{/snippet}
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 MaterialX (.mtlx / .zip) graph for a procedural or image-backed pattern.
+ Tile size
+
+ 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.
+
+
+ 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}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 7a80a5e..f125ed0 100755
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -28,8 +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 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..1d29973 100755
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -50,10 +50,18 @@ 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']();
}
+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..f2b3b8d 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -123,6 +123,10 @@ export namespace main {
nozzleDiameter: string;
layerHeight: string;
baseColor?: ColorSlotSetting;
+ baseMaterialXPath?: string;
+ baseMaterialXTileMM?: number;
+ baseMaterialXTriplanarSharpness?: number;
+ baseColorMode?: string;
colorSlots: ColorSlotSetting[];
inventoryCollection: string;
brightness: number;
@@ -162,6 +166,10 @@ export namespace main {
this.nozzleDiameter = source["nozzleDiameter"];
this.layerHeight = source["layerHeight"];
this.baseColor = this.convertValues(source["baseColor"], ColorSlotSetting);
+ 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"];
@@ -237,6 +245,18 @@ export namespace main {
return a;
}
}
+ export class MaterialXOpenResult {
+ path: string;
+
+ static createFrom(source: any = {}) {
+ return new MaterialXOpenResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.path = source["path"];
+ }
+ }
export class NozzleOption {
diameter: string;
layerHeights: number[];
@@ -369,6 +389,9 @@ export namespace pipeline {
Scale: number;
Output: string;
BaseColor: string;
+ BaseColorMaterialX: string;
+ BaseColorMaterialXTileMM: number;
+ BaseColorMaterialXTriplanarSharpness: number;
NozzleDiameter: number;
LayerHeight: number;
Printer: string;
@@ -406,6 +429,9 @@ export namespace pipeline {
this.Scale = source["Scale"];
this.Output = source["Output"];
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"];
diff --git a/internal/materialx/eval.go b/internal/materialx/eval.go
new file mode 100644
index 0000000..5488f4b
--- /dev/null
+++ b/internal/materialx/eval.go
@@ -0,0 +1,702 @@
+package materialx
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "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).
+// 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
+// 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, 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) SampleAt(_ SampleContext) [3]float64 { return [3]float64(c) }
+func (c constSampler) UsesUV() bool { return false }
+
+// --- 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(ctx *SampleContext, scratch []Value) Value
+
+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
+}
+
+// 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 {
+ 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 {
+ 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(s *sampleScratch) [3]float64 {
+ scratch := s.scratch[:g.nSlots]
+ for _, st := range g.steps {
+ scratch[st.slot] = st.fn(&s.ctx, 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). 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{},
+ resolver: resolver,
+ images: newImageCache(),
+ }
+ outSlot, err := c.compileNode(out.NodeName)
+ if err != nil {
+ return nil, err
+ }
+ return &compiledGraph{
+ nSlots: c.nSlots,
+ outSlot: outSlot,
+ steps: c.steps,
+ usesUV: c.usesUV,
+ }, nil
+}
+
+type compiler struct {
+ ng *nodeGraph
+ slotOf map[string]int
+ visited map[string]bool // detects cycles
+ steps []compileStep
+ nSlots int
+ resolver ResourceResolver
+ images *imageCache
+ usesUV bool
+}
+
+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(_ *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(_ *SampleContext, _ []Value) Value { return v }, nil
+ case in.Value != nil:
+ v := *in.Value
+ return func(_ *SampleContext, _ []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)
+}
+
+// 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)
+
+// 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,
+ "texcoord": buildTexcoord,
+ "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,
+ "image": buildImage,
+ "extract": buildExtract,
+ }
+}
+
+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.RawString != "" && in.RawString != "object" {
+ return nil, fmt.Errorf("position node: only object-space is supported, got %q", in.RawString)
+ }
+ return func(ctx *SampleContext, _ []Value) Value { return Vec3Value(ctx.Pos) }, nil
+}
+
+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
+ // 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) {
+ 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(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
+}
+
+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(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(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])
+ }
+ 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(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
+}
+
+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(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
+}
+
+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(ctx *SampleContext, scratch []Value) Value {
+ return FloatValue(op(in(ctx, 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(ctx *SampleContext, scratch []Value) Value {
+ return FloatValue(math.Pow(a(ctx, scratch).AsFloat(), b(ctx, 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(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(ctx *SampleContext, scratch []Value) Value {
+ low := floatOrDefault(lowFn, ctx, scratch, 0)
+ high := floatOrDefault(highFn, ctx, scratch, 1)
+ v := Value{Type: out}
+ src := in(ctx, 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(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(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
+ }
+ return v
+ }, nil
+ }
+ 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) {
+ c.usesUV = true
+ 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)
+ // `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
+ 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
+ }
+ 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
+// 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, ctx *SampleContext, scratch []Value) [3]float64 {
+ if fn == nil {
+ return ctx.Pos
+ }
+ return fn(ctx, scratch).AsVec3()
+}
+
+func intOrDefault(fn evalFn, ctx *SampleContext, scratch []Value, def int) int {
+ if fn == nil {
+ return def
+ }
+ return fn(ctx, scratch).AsInt()
+}
+
+func floatOrDefault(fn evalFn, ctx *SampleContext, scratch []Value, def float64) float64 {
+ if fn == nil {
+ return def
+ }
+ return fn(ctx, 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/image.go b/internal/materialx/image.go
new file mode 100644
index 0000000..393aca6
--- /dev/null
+++ b/internal/materialx/image.go
@@ -0,0 +1,220 @@
+package materialx
+
+import (
+ "fmt"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "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(r io.Reader, srgb bool) (*decodedImage, error) {
+ img, _, err := image.Decode(r)
+ 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
+}
+
+// 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
new file mode 100644
index 0000000..9637181
--- /dev/null
+++ b/internal/materialx/materialx_test.go
@@ -0,0 +1,695 @@
+package materialx_test
+
+import (
+ "archive/zip"
+ "bytes"
+ "image"
+ "image/color"
+ "image/png"
+ "math"
+ "os"
+ "path/filepath"
+ "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.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)
+ 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
+}
+
+// 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 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 {
+ 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/noise.go b/internal/materialx/noise.go
new file mode 100644
index 0000000..ee8a4a9
--- /dev/null
+++ b/internal/materialx/noise.go
@@ -0,0 +1,131 @@
+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
+}
+
+// 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)
+ 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
+ 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,
+// 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 float64
+ amp := 1.0
+ freq := 1.0
+ for i := 0; i < octaves; i++ {
+ sum += amp * perlin3D(x*freq, y*freq, z*freq)
+ freq *= lacunarity
+ amp *= diminish
+ }
+ return sum
+}
diff --git a/internal/materialx/parse.go b/internal/materialx/parse.go
new file mode 100644
index 0000000..3d4452a
--- /dev/null
+++ b/internal/materialx/parse.go
@@ -0,0 +1,371 @@
+package materialx
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+// 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 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()
+ 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.
+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 v, ok := attrLookup(se, "colorspace"); ok {
+ in.Colorspace = v
+ }
+ if valueStr, ok := attrLookup(se, "value"); ok {
+ 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
+ }
+ }
+ 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/resolver.go b/internal/materialx/resolver.go
new file mode 100644
index 0000000..fc243f8
--- /dev/null
+++ b/internal/materialx/resolver.go
@@ -0,0 +1,188 @@
+package materialx
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "slices"
+ "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) {
+ 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))
+}
+
+// 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(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) }
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..5ba366e
--- /dev/null
+++ b/internal/materialx/types.go
@@ -0,0 +1,248 @@
+// 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 (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type ValueType int
+
+const (
+ TypeUnknown ValueType = iota
+ TypeFloat
+ TypeInteger
+ TypeVector2
+ TypeVector3
+ TypeVector4
+ TypeColor3
+ TypeColor4
+ TypeString // free-form ASCII (addressmode names, etc.)
+ TypeFilename // path resolved via ResourceResolver
+)
+
+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"
+ case TypeString:
+ return "string"
+ case TypeFilename:
+ return "filename"
+ }
+ 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
+ case "string":
+ return TypeString
+ case "filename":
+ return TypeFilename
+ }
+ 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 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 {
+ 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. 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:
+ 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. 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 {
+ 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
+ RawString string // populated when Type is TypeString or TypeFilename
+ Colorspace string // optional; only meaningful on image filename inputs
+ 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
+}
diff --git a/internal/pipeline/loadoutput_persist.go b/internal/pipeline/loadoutput_persist.go
index b537804..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 {
@@ -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.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
+ // 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..0841d3b
--- /dev/null
+++ b/internal/pipeline/materialx_override.go
@@ -0,0 +1,208 @@
+package pipeline
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/rtwfroody/ditherforge/internal/materialx"
+ "github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/voxel"
+)
+
+// 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(ctx voxel.BaseColorContext) [3]uint8 {
+ pos := [3]float64{
+ float64(ctx.Pos[0]) * m.invTileMM,
+ float64(ctx.Pos[1]) * m.invTileMM,
+ float64(ctx.Pos[2]) * m.invTileMM,
+ }
+ 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.
+//
+// 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]))
+
+ 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 — 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
+ }
+
+ var out [3]float64
+ if wx > 1e-6 {
+ c := m.sampler.SampleAt(materialx.SampleContext{
+ Pos: pos,
+ UV: [2]float64{pos[1] * signX, 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] * signY, 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] * signZ, pos[1]},
+ })
+ out[0] += c[0] * wz
+ out[1] += c[1] * wz
+ out[2] += c[2] * wz
+ }
+ 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]),
+ floatToByte(rgb[1]),
+ floatToByte(rgb[2]),
+ }
+}
+
+// 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 {
+ if f != f {
+ return 0
+ }
+ v := f*255 + 0.5
+ if v <= 0 {
+ return 0
+ }
+ if v >= 255 {
+ return 255
+ }
+ return uint8(v)
+}
+
+// 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.
+//
+// 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 {
+ 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
+ }
+ if tileMM <= 0 {
+ tileMM = 1
+ }
+ return &materialxOverride{
+ sampler: s,
+ invTileMM: 1 / tileMM,
+ useUV: s.UsesUV(),
+ sharpness: triplanarSharpness,
+ }, nil
+}
+
diff --git a/internal/pipeline/materialx_override_test.go b/internal/pipeline/materialx_override_test.go
new file mode 100644
index 0000000..6b1bb13
--- /dev/null
+++ b/internal/pipeline/materialx_override_test.go
@@ -0,0 +1,202 @@
+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)
+ }
+}
+
+// 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.
+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 4dbbd5c..5ebf88c 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -32,6 +32,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 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
@@ -478,34 +498,38 @@ 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 cache.baseColorOverride). 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).
-func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) {
- if lo.appliedBaseColor == opts.BaseColor {
+// 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, tracker progress.Tracker) {
+ if lo.appliedBaseColor == opts.BaseColor &&
+ lo.appliedBaseColorMaterialX == opts.BaseColorMaterialX &&
+ lo.appliedBaseColorMaterialXTileMM == opts.BaseColorMaterialXTileMM &&
+ lo.appliedBaseColorMaterialXTriplanarSharpness == opts.BaseColorMaterialXTriplanarSharpness {
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 +539,27 @@ 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.
+ // 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 && 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 {
applyBaseColorOverride(lo.SampleModel, opts.BaseColor)
@@ -523,6 +567,36 @@ 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
+ lo.appliedBaseColorMaterialXTriplanarSharpness = opts.BaseColorMaterialXTriplanarSharpness
+}
+
+// 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]]
+ 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(voxel.BaseColorContext{
+ Pos: centroid,
+ Normal: voxel.FaceNormal(i, model),
+ })
+ 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..fc3e1e4 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
}
@@ -223,7 +231,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,9 +517,19 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) {
}
}
+ // 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,
+ )
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..678cf12 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,24 @@ 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
+ // 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.
@@ -189,14 +208,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
@@ -211,7 +238,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()
@@ -221,7 +248,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()
@@ -231,7 +258,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))
@@ -239,7 +266,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
@@ -379,12 +406,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 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{,TileMM,TriplanarSharpness}
+ // track the base-color override currently baked into ColorModel /
+ // 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 empty/zero means pristine.
+ appliedBaseColor string
+ appliedBaseColorMaterialX string
+ appliedBaseColorMaterialXTileMM float64
+ appliedBaseColorMaterialXTriplanarSharpness float64
}
type voxelizeOutput struct {
@@ -432,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
@@ -445,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 {
@@ -570,19 +601,30 @@ 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
- LayerHeight float32
- BaseColor string
+ 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 is included so a base-color change invalidates the sticker
+ // 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
+ 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
@@ -689,13 +731,29 @@ 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,
+ NozzleDiameter: opts.NozzleDiameter,
+ LayerHeight: opts.LayerHeight,
+ BaseColor: opts.BaseColor,
+ BaseColorMaterialX: opts.BaseColorMaterialX,
+ BaseColorMaterialXMTime: mtime,
+ BaseColorMaterialXSize: size,
+ BaseColorMaterialXTileMM: opts.BaseColorMaterialXTileMM,
+ BaseColorMaterialXTriplanarSharpness: opts.BaseColorMaterialXTriplanarSharpness,
}
case StageSticker:
- return stickerSettings{Stickers: opts.Stickers, BaseColor: opts.BaseColor, AlphaWrap: opts.AlphaWrap}
+ mtime, size := materialXFileStamp(opts.BaseColorMaterialX)
+ return stickerSettings{
+ 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}
case StageColorWarp:
@@ -739,6 +797,54 @@ 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()
+}
+
+// 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 {
@@ -759,8 +865,18 @@ 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)
+ 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 dc7929d..1492a6b 100644
--- a/internal/pipeline/stepcache_test.go
+++ b/internal/pipeline/stepcache_test.go
@@ -1,9 +1,109 @@
package pipeline
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "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
+// 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
+// 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
@@ -22,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")
}
}
@@ -56,3 +156,116 @@ func TestVoxelizeStageKeyDependsOnBaseColor(t *testing.T) {
t.Fatal("StageVoxelize key did not change when BaseColor changed")
}
}
+
+// TestVoxelizeStageKeyDependsOnMaterialX guards the same invalidation
+// 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: mtlxA,
+ BaseColorMaterialXTileMM: 10,
+ BaseColorMaterialXTriplanarSharpness: 4,
+ }
+ pathChanged := base
+ pathChanged.BaseColorMaterialX = mtlxB
+ tileChanged := base
+ tileChanged.BaseColorMaterialXTileMM = 20
+ sharpChanged := base
+ sharpChanged.BaseColorMaterialXTriplanarSharpness = 8
+
+ 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 MaterialX override.
+// 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.
+func TestStickerStageKeyDependsOnMaterialX(t *testing.T) {
+ c := NewStageCache()
+ mtlxA := writeMtlxTempFile(t, "")
+ mtlxB := writeMtlxTempFile(t, "")
+ base := Options{
+ Input: "model.glb",
+ BaseColorMaterialX: mtlxA,
+ BaseColorMaterialXTileMM: 10,
+ BaseColorMaterialXTriplanarSharpness: 4,
+ Stickers: []Sticker{
+ {ImagePath: "sticker.png", Mode: "unfold", Scale: 1, MaxAngle: 90},
+ },
+ }
+ pathChanged := base
+ 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; " +
+ "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
+// 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()
+ mtlxA := writeMtlxTempFile(t, "")
+ mtlxB := writeMtlxTempFile(t, "")
+ base := Options{
+ Input: "model.glb",
+ BaseColorMaterialX: mtlxA,
+ BaseColorMaterialXTileMM: 10,
+ BaseColorMaterialXTriplanarSharpness: 4,
+ }
+ changed := base
+ 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")
+ }
+ 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/pipeline/unified_cache_test.go b/internal/pipeline/unified_cache_test.go
index e9eabd8..2da142f 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,47 @@ 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")
+ }
+}
+
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
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/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..bce5a95 100644
--- a/internal/voxel/color.go
+++ b/internal/voxel/color.go
@@ -268,6 +268,49 @@ 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.
+// 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(ctx BaseColorContext) [3]uint8
+}
+
+// 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]]
+ 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
// samples the texture color and alpha there. If decals are provided,
// sticker textures are composited over the base color. Returns RGBA.
@@ -275,8 +318,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 +330,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 +362,13 @@ func SampleNearestColorWithSticker(
}
matAlpha, bc, texIdx := faceMaterial(int(bestTri), model)
+ if override != nil && (texIdx < 0 || int(texIdx) >= len(model.Textures)) {
+ 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]
bary := [3]float32{1 - bestS - bestT, bestS, bestT}