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}
+
-
+ {#if sizeMode === 'size'} + + {:else} + + {/if} +
+ + +
+
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.
- {#if sizeMode === 'size'} - +
+ + +
+ {#if baseColorMode === 'solid'} + {#if baseColor} +
+ + +
+ {:else} + + {/if} {:else} - + {#if baseMaterialXPath} +
+ + {baseMaterialXPath.split(/[\\/]/).pop()} + + +
+ {:else} + + {/if} {/if} - {#if baseColor} +
+ + {#if baseColorMode === 'solid' && baseColorPickerOpen} +
+ { + baseColor = { hex, label, collection }; + baseColorPickerOpen = false; + }} + onclose={() => { baseColorPickerOpen = false; }} + /> +
+ {/if} + + {#if baseColorMode === 'texture' && baseMaterialXPath} +
+
+ 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. + +
- - + + mm
- {:else} - - {/if} - {#if baseColorPickerOpen} -
- { baseColor = { hex, label, collection }; baseColorPickerOpen = false; }} - onclose={() => { baseColorPickerOpen = false; }} - /> +
+ 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}