Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) |
Expand Down
65 changes: 65 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"`
Expand Down
100 changes: 53 additions & 47 deletions cmd/ditherforge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <input>.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: <input>.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 {
Expand All @@ -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)
Expand Down
Loading
Loading