Skip to content

About

Flat full-colour prints from per-pixel filament stacks on a 4-independent-toolhead printer: gamut search, spool-loadout ranking, 3MF output

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

53 Commits

Folders and files

Repository files navigation

stackforge

Flat, full-colour prints from a handful of filaments on a 4-independent-toolhead printer (built and tested on a FlashForge Creator 5). Where HueForge encodes an image in a height map with one global filament order, stackforge gives every pixel its own filament stack and prints the plaque at constant thickness. The question it answers: given these spools and this image, which stacks reproduce each pixel, and which four spools should I load?

target, ranked loadouts, simulated result

image ──► gamut search ──► per-pixel stack ──► 3MF (modifier volumes per layer) ──► slicer
            ▲                                          ▲
   filamentdb (colour + td per filament)        --template (your slicer's own 3MF)
            ▲
   stackforge-calibrate (print wedges, fit td)

Quick start

1. Install (once). Needs Python 3.10+. Debian/Ubuntu refuse a system-wide pip install, so use a virtual environment:

git clone <this repo> stackforge && cd stackforge
python3 -m venv venv && . venv/bin/activate      # again in every new terminal
pip install -e ".[gui]"                          # drop [gui] for the command-line tools only
stackforge-filaments list                        # check: prints the filament library

2. Make your slicer project (once, and again if you change the print profile). In Flash Studio: choose the Creator 5 and your print profile (e.g. 0.12 mm), set up all four filament slots, then File > Save Project As… to a .3mf. stackforge copies your printer and print settings from it and builds the plaque on its layer height. This is the --template file, and the Slicer project box at the top of the GUI.

3. Make a plaque. In the GUI (stackforge), or from the repo folder:

stackforge-plaque docs/plaque_target.png --base polymaker-pla-pro-white \
    --filaments polymaker-pla-pro-white,polymaker-pla-pro-blue,polymaker-pla-pro-red,polymaker-pla-pro-yellow \
    --width 60 --template my_project.3mf --preview sim.png -o plaque.3mf

Look at sim.png first: it is what the print should look like. The base thickness is chosen for you (enough layers of the base filament to block 99% of the light; see The base has to actually be opaque).

4. Print it. Open plaque.3mf in Flash Studio (File > Open Project), load the spools in the T1–T4 order stackforge printed, leave the layer height alone, slice and print. In the slice preview, colour should show on every layer near the top, not on alternate ones.

Orca output (the default --flavor) is refused without --template unless you give --layer-height yourself (see Caveats). Tests: pip install -e ".[dev]" && pytest (see Development).

One GUI for everything

pip install -e ".[gui]"   # once; adds PySide6
stackforge [image.png] # or: python -m stackforge.gui.app

First choose your slicer project with Choose… in the bar at the top (step 2 above); the layer height follows it, and nothing can be generated without it. Then on the Plaque tab: open an image, tick the filaments you own (the list is your database), press Generate, then Export 3MF. The export dialog says which spool goes on which toolhead.

stackforge opens a single window over every tool here: Plaque (the plaque designer, plus an All options form), Paint (stackforge-top-paint / stackforge-paint), Filaments (the library editor, plus the stackforge-filaments / polymaker forms), Calibrate, Measure (with a terminal) and Tools.

stackforge

The forms are generated from each CLI's argparse definition, so a new flag appears with no GUI change, and every run is the CLI as a subprocess (python -m stackforge.tools.<tool>), so the GUI and the command line cannot diverge. Each form shows the equivalent shell command. The bar across the top holds the project: database, template, flavor, part type and the layer height / first layer, which are read from the template unless overridden. Settings and per-tool presets live in ~/.config/stackforge/. Ctrl+Enter runs the visible form, Esc cancels it.

Caveats up front

  • Verified: output loads as a project in Flash Studio 1.7.x with four parts on extruders 1-4.
  • Verified (headless slice, 2026-09-30): Flash Studio honours extruder overrides on modifier volumes. tests/test_slice.py re-checks this on every test run where Flash Studio is installed (skip it with STACKFORGE_SKIP_SLICER=1; locations via FLASHSTUDIO_RUN / FLASHSTUDIO_TEMPLATE). If colours are wrong in your slicer, try --part-type part.
  • The optical model is single-pass alpha-over with per-filament td; accuracy depends on calibrated td values (stackforge-calibrate). The shipped filaments.json values are mostly estimates.
  • The base must be opaque (--base-layers); stackforge warns when it is not.
  • Slice at exactly the --layer-height you generated with.

Repository layout

Everything lives in the stackforge package under src/stackforge/ (core/ libraries, tools/ CLIs, gui/); pip install -e . puts one console script per tool on PATH, and python -m stackforge.tools.<tool> also works. The rest of this repo is the toolchain stackforge stands on. Tool changes are assumed cheap, so nothing here tries to minimize them.

tool what it does
stackforge-filaments (core/filamentdb.py) filament colors + optical properties, grown over time
gui/filaments/ the library editor (details, look, match by eye) and the guided calibration page
stackforge-polymaker (tools/polymaker.py) look a Polymaker SKU up in their published hex/TD table
stackforge-calibrate (tools/calibrate.py) step-wedge generator and td/color fitter
stackforge-top-paint (tools/top_paint.py) project an image onto the top-visible surface of any 3MF or GLB
stackforge-paint (tools/paint.py) colour a 3MF or GLB anywhere on its surface: the GLB's own colours, a wrapped image, or a pattern
stackforge-measure (tools/measure.py) measure wedges, plaques and chip transmission with a ColorMunki / ArgyllCMS
stackforge-dither-compare (tools/dither_compare.py) score the plaque's dither modes by blurred dE
stackforge-make-samples (tools/make_samples.py) generate fabric.3mf, badge.3mf and globe.glb sample models
stackforge-plaque (tools/plaque.py) flat full-color plaques from per-pixel filament stacks
stackforge-spectral (tools/spectral.py) Kubelka-Munk K and S per 10 nm band from measured wedge spectra; spectra plots
gui/ the Qt GUI (stackforge): host window (app.py), generated forms (form.py, panel.py), pickers, one module per tab in tabs/, plaque designer in tabs/plaque.py
data/ shipped filaments.json and polymaker_catalog.json; the filament library you edit is a copy of the first, in ~/.config/stackforge/
core/threemf.py, core/colormath.py shared 3MF I/O and color math
gui/theme.py the dark theme and small layout helpers
gui/argform/ form specs generated from each tool's build_parser(); no GUI toolkit needed
core/optics.py what layers of a filament look like over a base (previews, best layer count)
core/spectral.py, data/cie/ CIE 1931 observer + D65/D50/A, the Kubelka-Munk layer model, spectral records

stackforge-paint is built to the first three steps of docs/plans/surfacecolor.md (patterns, wrapped images, shell masking); the interactive painting window is not.

Requires numpy, Pillow, scipy; stackforge also needs PySide6 (the gui extra). stackforge-polymaker is stdlib only.


stackforge-filaments

One plain JSON file so it diffs cleanly in git and you can hand-edit it. Every entry records where its numbers came from, so estimated placeholders never get mistaken for measured values — list dims anything unmeasured. provenance runs measured (a wedge, read with an instrument) → matched (a wedge, compared by eye, good to ~±15%) → vendor (published by the manufacturer) → estimated (a guess). Only the first is trusted without a warning; lists show each entry's source.

Where it lives. $FILAMENT_DB if set, else ./filaments.json if the current folder has one, else your own copy at ~/.config/stackforge/filaments.json ($XDG_CONFIG_HOME is honoured). That copy is made from the one shipped in the package the first time it is needed, so your filaments and calibrations are never written into the package, where a reinstall or git pull would clash with them. Filaments added to the shipped file later do not reach your copy by themselves: stackforge-filaments seed or the Polymaker browser adds them.

stackforge-filaments seed                 # starter Polymaker PLA Pro set
stackforge-filaments list
stackforge-filaments add --name Teal --color "#00757F" --td 0.13
stackforge-filaments show teal --layer-height 0.08
stackforge-filaments set teal --td 0.128 --provenance measured
stackforge-filaments import-sku CA02001    # colour + TD from Polymaker

Transmission distance

td is the thickness in mm at which transmittance falls to 1/e (36.8%): T(t) = exp(-t / td). Optional td_rgb gives per-channel values.

This is not HueForge's TD scale, which is closer to "thickness to opacity". Use import-hueforge (divides by ~4.6) rather than pasting values across.

The seeded defaults are finish-based guesses chosen so pigmented PLA goes opaque in ~0.6 mm (~8 layers at 0.08 mm). That target matters more than it looks: the whole stacking technique lives on partial transmission. Too opaque and the reachable gamut collapses to the filament colors with nothing in between; too transmissive and the base leaks through even at full depth, so saturated darks become unreachable.


Filaments tab (stackforge)

stackforge   # then the Filaments tab

The database is the weakest link in everything else here — stackforge's colour maths is only ever as good as the td and colour it is handed. The CLI can already write those numbers; what it cannot do is show you what they mean. That is what this is for.

the editor

  • Library down the left with swatches, live filter, and est on anything unmeasured. New / Duplicate / Delete, and Starter set… for a fresh brand and series.
  • Details — every field, with the colour settable by hex, by picker, or by clicking a photo of a printed swatch (it averages a 7×7 patch, because print texture makes a single pixel meaningless). Changing the finish offers that finish's starter td, but never over a measured entry. A HueForge TD box converts rather than letting the wrong scale in, and a Polymaker SKU box fills the whole entry from their published table — see stackforge-polymaker.
  • Look — the payoff. N layers of the selected filament composited over white and over black with the same optical model stackforge uses, plus the transmittance table and the depth at which it goes opaque. A td that is wrong by a factor of two is obvious here and invisible in a JSON file.
  • Match by eye — no instrument needed: pick the candidate td whose patch-against-base step looks like your printed one. See matching by eye.
  • Calibrate this filament… (under the list) opens the Calibrate tab on the selected entry. That tab is the one place to calibrate: its Guided page writes the step-wedge 3MF (on your slicer project's layer grid), takes the patches (from the Measure tab, typed, or sampled from a cropped photo), fits, shows the per-step residuals and saves the result to the library. All options has the raw wedge/chips/fit commands, e.g. three filaments on one plate.

In the editor, nothing touches disk until Save; the Editor tab carries a dot while there are unsaved edits, and Save refuses the whole file if any entry has an unparseable colour or a non-positive td.

It refuses the same bad fits the CLI does

Applying a fit sets provenance to measured and stamps the date and layer height, so an entry can only earn that label by being measured. And a fit from a single low-contrast wedge cannot be applied at all — if the wedge spans less than dE 25 end to end, td and colour are trading off against each other and the solver will report a tight residual on a badly wrong answer. The report says so in red and the Apply button stays disabled until you fill in Wedge B over a contrasting base.

Checked against synthetic wedges: a translucent with a true td of 0.90 fits to 0.865 from one white-base wedge (blocked, correctly — the wedge spans dE 5), and to 0.8978 once the black-base wedge is added. A teal with true td 0.30 and colour #227788 comes back as 0.2997 / #237788.


stackforge-polymaker

Polymaker publish a HEX code and a TD for most of their catalogue: wiki.polymaker.com › Hex Codes and Transmission Distances. This scrapes that page once into polymaker_catalog.json (the packaged copy unless there is one in the working directory, or $POLYMAKER_CATALOG) and reads from there afterwards, so lookups are instant, work offline, and diff in git the way the filament database does.

stackforge-polymaker refresh              # re-scrape (needs network; retries, and leaves the cache alone on failure)
stackforge-polymaker lookup CA02001
stackforge-polymaker search silk blue
stackforge-polymaker import CA02001 --write

stackforge-filaments import-sku CA02001  # same thing, from the db tool
CA02001    Panchroma Translucent PLA — Translucent Cyan
  hex #08ABFB
  TD 8.0 (Polymaker scale)  ->  td 1.7391 mm here

SKUs are matched case-insensitively and ignoring dashes and spaces, and a near miss gets a did you mean list. Imports set brand, series, name, color, finish, the new sku field, and a note recording where the numbers came from and when.

Their TD is not this database's td

The wiki defines TD as "the approximate thickness of solid plastic (in millimeters) that light can penetrate before it is effectively blocked" — the HueForge scale. This database stores the 1/e thickness. Reading "effectively blocked" as 1% transmission puts them ln(100) = 4.6 apart, so everything converts on the way in (filamentdb.hueforge_td) and no raw wiki TD is ever written into a td field. Translucent Cyan's TD 8 becomes td 1.739 mm.

What the table does and does not have

Of the 1,298 products scraped in August 2026:

1,141 have a HEX code
384 have a TD — most SKUs give you a colour and nothing else
46 are dual-colour and list two HEX codes

So a lot of imports fill in the colour and leave the td a finish-based guess. Those entries stay provenance: estimated and say why in their notes — calling them vendor would be a lie about the number that matters most. Only a SKU with both becomes vendor. Nothing here ever writes measured; that is what stackforge-calibrate is for.

Dual-colour filaments are refused outright, with their two hex codes in the error, because the optical model assumes one bulk colour per filament. SKUs with no published HEX are refused too.

Importing over an entry that is already measured needs --overwrite-measured: a printed wedge beats a vendor number, and clobbering it by accident would be a real loss.

From the GUI

the Filaments tab has a Polymaker SKU box on the Details tab (type it, press Look up) and Add from Polymaker SKU… for browsing the catalogue by colour with swatches and TDs:

the catalogue browser

Search is ranked — an exact colour-name match wins, then importable entries, then ones with a published TD — because "silk blue" otherwise buries the filament actually called Silk Blue under every Dual Silk whose name mentions blue. Refresh from wiki re-scrapes on a worker thread.

Guessing a td from colour

Most SKUs have no published TD, so guess-td learns one from the 384 that do:

stackforge-polymaker guess-td --all           # dry run over the database
stackforge-polymaker guess-td teal --write

It fits log(TD) against L*a*b* within a finish group, and picks between that regression and the group's flat median by leave-one-out, per group, rather than assuming which is better — because it varies:

group n winner typical error
pigmented 148 colour regression ×1.6
silk 24 colour regression ×1.5
translucent 18 group median ×1.4
glow 8 group median ×1.2

Training pairs are deduplicated on (hex, TD) first — the rebrand left the same spool in the table twice, and counting it twice would flatter the accuracy. Estimates are clamped to the range actually observed for the group, and the entry stays estimated with a note recording the method and its error.

How much this buys you. Planning a plaque with the wrong td and then compositing the stacks it chose with the true optics, over an in-gamut target:

td accuracy resulting print
exact dE 0.3
×1.5 out (colour estimate) dE 5–13
×3.1 out (one flat guess for everything) dE 8–30

So the estimator roughly halves the error, and a wedge is still worth an order of magnitude more than either.

The preview cannot see this error. It composites with the same wrong td it planned with, so it is self-consistent and confident: in those runs the preview claimed dE 1.6–4.0 while the actual print was dE 4.6–13.2. A clean-looking preview says nothing about whether the td is right.

Their table disagrees with itself

Worth knowing before you trust any single number: 21 pairs of entries share a colour to within dE 2 and disagree on TD by more than 1.8×. #A4D0DF is TD 1.8 as Panchroma Matte Pastel Ice and TD 0.1 as PolyTerra Matte Pastel Ice — the same spool under its old and new name, 18× apart. Some of that is real (finish changes transmission), some is measurement noise, and it caps how well any colour-based estimate can ever do.

When it breaks

The wiki is a GitBook page, so this parses role="row" / role="cell" markup — fragile by nature. refresh refuses to overwrite a good cache with a parse that collapses to fewer than 200 rows, and points at the selectors to fix. The cached JSON keeps working in the meantime.


stackforge-calibrate

Prints the step wedges a filament is calibrated from. The GUI's Calibrate tab (Guided) does the same for one filament.

# 1. print this (on your slicer project's layer grid, solid infill, opaque bases)
stackforge-calibrate wedge --filament teal --base white,black --template my_project.3mf -o wedge_teal.3mf

# 2. read every patch with the ColorMunki, then fit the spectra
stackforge-measure measure-wedge --sheet wedge_teal.sheet.json
stackforge-spectral fit --readings wedge_teal.readings.json --filament teal --write

Calibration is spectral only on this branch. The RGB td fit (stackforge-calibrate fit, from typed hex colours or a photo) is gone: the command exits with a pointer to stackforge-spectral fit, and the Guided page hands a readings file to the Spectra tab instead of fitting. stackforge-plaque defaults to --optics spectral, so a filament with no spectral calibration is refused unless you ask for --optics rgb.

Print wedges over bases that contrast with the filament

What separates what a filament absorbs from what it scatters is watching its layers hide a background unlike it. A coloured filament looks like a white base where it is light and like a black one where it is dark, so it needs both. White needs only black, and black only white (add a grey for extra data). The spectral fit refuses --write when some wavelength never changes on any wedge, and says which base to add. A wedge whose base is the filament itself is left out. See stackforge-spectral.

Matching by eye, with no instrument

If you have no spectrophotometer and no white card, you can still do far better than a guess: print patches, then compare them against the model's prediction on screen. the Filaments tab has a Match by eye tab for exactly this.

matching by eye

It states plainly what to print — "print 8 layers × 0.08 mm = 0.64 mm of Red, over White" — because a layer count means nothing without the layer height it was counted at, and the model only cares about the millimetres. Change the layer height and the recommended count changes with it, holding the physical thickness roughly constant (8 × 0.08 mm and 4 × 0.20 mm are the same test). It also names the base thickness needed for the patch to be measuring filament rather than build plate.

It offers nine candidate td values spanning ×0.4 to ×2.5 and draws each one as base beside patch. The judgement asked for is which pair has the same step as your print — a contrast comparison, not an absolute colour one. That matters: nobody can name a colour by eye, but everybody can see which of two pairs differs more, and comparing steps is what makes the screen's white point and the room's lighting largely cancel. Picking a square sets td and marks the entry matched.

The useful thickness is per filament, and it is not obvious. Best layer count works it out by asking where a 1.6× td error moves the patch furthest:

filament best test (at 0.08 mm layers) a 1.6× error shows as pins td to
Black 1 layer — 0.08 mm over white dE 13.9 ±16%
Blue 1 layer — 0.08 mm over white dE 16.6 ±15%
Green, Purple 2 layers — 0.16 mm over white dE 12–13 ±20%
Red, Yellow, Magenta 6–8 layers — 0.48–0.64 mm over white dE 13–20 ±13–18%
Orange 12 layers — 0.96 mm over white dE 15.2 ±16%
White 3 layers — 0.24 mm over black dE 9.0 ±30%
Natural 8 layers — 0.64 mm over black — ±30%
Beige, Grey 1–2 layers over black dE 3–4 ±32–40%, weak

One layer is the sharpest test for an opaque filament and nearly useless for a translucent one — black is already opaque at a single layer, while natural barely absorbs anything until it is millimetres thick. Over white, beige and natural show almost nothing at any thickness; they need a dark base.

Against the ±60% a colour-based estimate gives, ±15% is a real gain — the simulations above put that at roughly dE 3–4 in the finished plaque instead of dE 5–13.

One plate, three filaments

wedge takes a comma-separated list and puts one staircase row per filament over a shared base, each on its own extruder — so a four-head machine calibrates three filaments per print:

stackforge-calibrate wedge --filament black,blue,red --base white \
    --template my_project.3mf -o wedge.3mf --steps 8

The base under the steps is made opaque by default (--base-layers overrides it): the patches only measure the filament if the base underneath them is opaque. The wedge is forced to solid infill, like the plaque. fit takes the same --template for the layer height the wedge was printed at; step n carries n layers of the filament, on top of the base.

A step is too small to hold a hand-held instrument against, so give it a gap: --gap 8 leaves 8 mm between steps and thins the base there to a hinge (--hinge-layers, default 4). Each step keeps the full --base-layers pad, so opacity is unchanged, and you can flex one step flat onto the aperture while the wedge stays one piece. Mind the plate width: 12 steps at 14 mm + 8 mm gaps is 256 mm.


stackforge-top-paint

Renders a top-down z-buffer, samples an image across the footprint, quantizes to your filaments, and emits one modifier volume per filament hugging the visible top surface. Only the top --depth mm change color; original geometry is never altered.

Because the boxes follow the z-buffer rather than sitting at a fixed height, this works on arbitrary geometry — a flat chainmail sheet, a domed badge, and a terrain tile all get the image laid over their real top surface.

stackforge-top-paint fabric.3mf logo.png -o out.3mf \
    --filaments white,black,blue,red --depth 0.6 --preview prev.png
flag meaning
--filaments / --palette from the database, or raw hex
--resolution mm per sample, also box granularity
--depth mm below the surface the color reaches
--dither none / ordered / floyd — worth it here, the palette is flat
--fit, --rotate, --flip, --region image placement
--flavor orca (default) or prusa
--part-type modifier (default) or part
--preview PNG of exactly what will be painted

On chainmail the gaps between tiles mask out automatically, giving a mosaic at tile resolution. 159 mm sheet at 0.4 mm runs in about half a second.


stackforge-plaque

HueForge builds a height map: one global filament order for the whole print, with a pixel's color decided purely by stack height there. That exists because on a single-hotend MMU every swap is a global event.

With independent toolheads, swaps are cheap — so this drops the height map. Every pixel gets its own stack, and the plaque comes out flat. Color is encoded in vertical composition, so there's no surface topography to catch raking light.

stackforge-plaque photo.jpg -o plaque.3mf \
    --filaments white,blue,red,yellow --base white --template my_project.3mf \
    --width 150 --preview sim.png --gamut-preview check.png

Optical model

Layers composite bottom-up in linear light. Adding a layer of filament f over an existing color C: T = exp(-h/td_f), then C' = C·T + color_f·(1-T) — alpha-over with α = 1-T. Single-pass: it ignores light scattering back up through the stack a second time, the same approximation HueForge uses. It holds up well for pigmented PLA.

The gamut is built by breadth-first expansion, deduplicated on a grid over the sRGB encoding (a linear-light grid lumped L* 0–5 into one cell). A layer that moves the colour by less than one cell is still followed, so translucent filaments keep accumulating. The state is just the composited color — what's underneath stops mattering once obscured — so the search stays 3-dimensional however deep it goes.

That is --optics rgb, which has to be asked for. The default, --optics spectral, swaps this for Kubelka-Munk per 10 nm band (K and S per filament, fitted from measured wedge spectra), so a layer filters what is under it instead of fading towards its own colour: orange over blue goes olive, as printed, not purple. Only spectrally calibrated filaments are accepted. See stackforge-spectral and docs/spectral.md.

Short stacks pad at the bottom with base filament, which is optically identical to the base plate. That's what keeps every pixel the same height while allowing different effective depths.

Choosing which filaments to load

List more filaments than you have toolheads and it compares every subset that fits, so you can see which loadout suits the image before committing to a print. Ranking turns on automatically. Without -o it only ranks; with -o it ranks and then builds the plaque from the best combination.

stackforge-plaque photo.jpg --base white \
    --filaments white,black,blue,red,yellow,teal,orange,magenta \
    --slots 4 --top 5 --rank-sheet combos.png

Prints a ranked table and writes a contact sheet — target first, then the best few rendered with their filament swatches and dE. Re-run with the winning --filaments and -o to produce the plaque (or pass -o the first time).

The base counts as one of your toolheads. --slots 4 means four spools total: the base plus three others, never five. A --base outside --filaments is rejected rather than quietly added. The base is a full color as well as the backing — short stacks pad against it at the bottom, so it does double duty without costing an extra slot.

Scoring weights each distinct colour of the image by how many pixels have it, so it is exact and repeatable (a random sample of 4000 pixels flipped the winner between seeds when the top two were 0.04 dE apart). Images with more than --rank-samples distinct colours (default 20000) are binned to fit. With --dither ordered/blue the two-stack mix is scored, since that is what prints; the contact sheet is rendered with the same dither. 35 combinations of 8 filaments take about 50 s. The base is held fixed in every subset, so with N filaments and S slots there are C(N-1, S-1) combinations — 8 filaments and 4 slots gives C(7,3) = 35.

--rank-by p95 optimizes worst-case error instead of average — worth it when a few badly-wrong regions bother you more than a slight overall shift. --no-rank skips it and uses the base plus the first --slots-1 others as listed.

As a sanity check: on docs/plaque_target.png (a full hue sweep) the ranker's top two are white + blue + red + yellow and white + blue + yellow + magenta — subtractive-ish primaries, found without being told about them.

GUI

stackforge [image.jpg]   # the Plaque tab

Every CLI option, plus the things a GUI is genuinely better at: seeing the simulated print beside the target while you turn knobs, and picking a loadout by looking at renders instead of reading a table.

  • Filament library down the left with swatches; unmeasured entries are dimmed and tagged est. Tick more than the toolhead count and the Generate button becomes Rank combinations.
  • Four tabs: target, simulated print, error map, and the combination contact sheet.
  • After a ranking it offers to select the winning filaments and generate straight away.
  • Solves run on a worker thread with a progress bar and a working Cancel, so the window never locks up.
  • Export reports the load order (T1…T4) and the exact layer height to slice at, and asks first if the image, filaments or settings changed since Generate.
  • The base is always T1 in the GUI; use the CLI to put it on another toolhead.
  • Edits made on the Filaments tab are picked up here when you switch back (the list reloads, keeping whatever was already ticked).

Dithering

--dither blue (blue-noise screen), ordered (Bayer) and floyd all try to buy accuracy by mixing two stacks per pixel side by side. stackforge-dither-compare scores them on dE after a Gaussian blur that stands in for the eye, since per-pixel dE punishes any dither unfairly.

target and each dither mode, 2 layers

Measured on a smooth test scene with five filaments: with 1-2 layers blue and ordered cut blurred dE from 27.7 to about 25.7; at 4 layers the gain shrinks to under 1. With deep stacks it depends on the scene: under 0.1 on this one, about 0.7–1.1 on saturated hue sweeps whose targets fall outside the gamut. Blue and ordered score alike on dE; blue trades Bayer's crosshatch for grain, which is the reason to prefer it. floyd beat none at 1-2 layers but trailed blue/ordered, and was worse than none from 4 layers up.

Those are simulated gains; the slicer takes most of them back. A dither is made of one-pixel features, and Flash Studio 1.7.8 gives each modifier region its own walls and does not extrude features one 0.4 mm pixel wide (see Slicing). Dithered plaques are 11–33% one-pixel features. Until that is solved, prefer --dither none.

Notes from testing

  • The gamut saturates. With 4–5 filaments at 0.08 mm it stops growing around 16 layers, once the deepest stack goes opaque. --max-layers 20 adds time and no color.
  • Dither only shallow stacks. With 12+ layers the gamut is already dense (~215k colours for 5 filaments at 16 layers) and blue/ordered gain little; see Dithering, including why the slicer undoes it. It does matter in stackforge-top-paint, where the palette is flat.
  • Residual error is gamut, not solver. Feeding a rendered result back in gives mean dE 0.1. If --gamut-preview shows red regions, those colors are genuinely unreachable with that filament set — add a filament, don't tweak settings.
  • Geometry gets big. A 120 mm plaque at 0.4 mm / 16 layers is ~2M triangles and ~15 MB. Coarsen --resolution or drop --dither if the slicer struggles.

Solid infill is required, not optional

The optical model treats every colour layer as a continuous film. Stock profiles make only the top shell solid — the Creator 5 profile is top_shell_layers 5 / top_shell_thickness 1 mm over sparse_infill_density 15% — so on a 16-layer stack roughly eleven layers would print as sparse grid and the colour maths would not describe the object at all.

stackforge-plaque therefore bakes sparse_infill_density: 100% into the output profile, and pins infill_combination: 0 so neighbouring colour layers are never merged into one extrusion. --no-force-solid opts out if your profile is already fully solid.

stackforge-top-paint does not change infill (it paints someone else's model), but it warns when --depth reaches below the profile's solid top shell, where paint would land on a lattice rather than a surface.

The base has to actually be opaque

The gamut starts from "the base is an opaque backing", and with a realistic td that is not free. White is far more transmissive than it looks: the shipped Polymaker PLA Pro White (td 0.467 mm, estimated) passes 42% through 5 base layers (0.40 mm), and the print picks up whatever is underneath. It needs about 2.2 mm: 27 layers at 0.08 mm, 17 at 0.12 mm. --base-layers therefore defaults to auto, the fewest layers that pass at most 1%; give a number to override it, and stackforge warns when that is not opaque. Black at td 0.022 is opaque in 2 layers.

What td means matters here. stackforge-calibrate fits it to reflected light, which crosses each layer twice, so it is an effective value; stackforge-measure transmission measures single-pass td, which for a clear absorber is about twice as large. stackforge uses the reflectance kind.

stackforge-plaque now warns when the base passes more than 1% and tells you the layer count that would fix it; the GUI's estimate panel shows the same. The old flat td 0.13 put white at 4.6% through 5 layers, which is why this never came up before.

Bottom layers come out base-coloured, and that is fine

Short stacks pad downward with base filament, so the deepest colour layers are legitimately uniform base. Those are now trimmed automatically — padding sitting on a base-filament plate is optically identical to no padding at all, so dropping it is free. A 16-layer stack that only needs 12 prints 12.

A solid block of base colour at the bottom is by design. Alternating or periodic base layers are the layer-grid bug described above.

Slicing

Layer geometry must match the profile that will slice it, or colours drop out. Boxes sit in the middle half of each layer — Slic3r-family slicers sample at each layer's mid-height, so a correctly aligned box is always hit — but that tolerance cannot survive a wrong spacing or a wrong starting offset.

Two things set that grid, and both are read from --template automatically and written back into the output:

  • layer_height — the spacing.
  • initial_layer_print_height — the first layer is usually thicker, and it offsets every layer above it. A 5-layer base is first + 4 × layer, not 5 × layer.

The heights are baked into the output's project_settings.config, and the layer height is also written as a per-object override (Orca drops project values whose preset name matches a system preset; per-object ones survive). That holds when you override with --layer-height / --first-layer-height too, which rewrites the profile to match rather than leaving a silent conflict. The first layer has no per-object form, so it relies on the profile rewrite. A height outside the profile's own min_layer_height/max_layer_height is warned about.

Features one pixel wide do not print at 0.4 mm. Sliced headlessly in Flash Studio 1.7.8 and compared pixel by pixel with the design, every extruded pixel had the designed tool, but 5–9% of colour pixels got no plastic at --resolution 0.4: each modifier region gets its own walls, and a region one nozzle-width across is dropped. At 0.6 mm the same plaque lost 0.1%, so 0.6 is the default and stackforge warns below it.

The prime tower is moved in if it would run off the bed. A single-colour template never grew a tower, so its saved position can be too close to the back edge; Flash Studio then refuses to slice ("G-code in unprintable area").

Getting this wrong has a distinctive symptom: colour appears on some layers and not others in a repeating pattern, because the boxes beat against the real layer grid. Note that a solid block of base-coloured layers at the bottom is not the same thing — short stacks pad downward with base filament by design.


Caveats — verify before a long print

  • Always pass --template for Flash Studio. It fixes two things at once.

    The version gate. Orca-derived slicers parse the Application metadata into a generator version and compare it against the running slicer. Too old, and they show "generated by an old OrcaSlicer version, loading geometry data only" and silently discard model_settings.config -- losing every part and extruder assignment. The string must be at least as new as the installed build, so the writer reads it out of the template rather than hardcoding one. Do not copy a version from FlashForge's own bundled calibration 3MFs: they say BambuStudio-02.00.02.01 (= 2.0.2.1) while Flash Studio 1.7.x is 2.3.2, so a file claiming the bundled version reads as stale and fails.

    The project settings. Opening as a project needs Metadata/project_settings.config, a 600-key printer profile. That cannot be synthesized without inventing a printer, so it is carried over verbatim along with slice_info.config. Anything describing geometry or a plate (cut_information.xml, plate thumbnails) is deliberately left behind: it would still point at the template's objects, and a stale reference breaks the load more reliably than a missing one. filament_colour is patched to the filaments actually used, so the slicer previews the right colours.

  • Give the template as many filament slots as you print with. Set all four up in Flash Studio before exporting it. Dozens of per-filament arrays in the profile are indexed in lockstep (type, temperature, flow, retraction), so the writer will not grow filament_colour by itself, and it refuses to write the file: sliced, the missing extruders collapse onto extruder 1 and those colours print in the base.

  • Confirmed working against Flash Studio 1.7.x with a Creator 5 profile: loads as a project, four parts on extruders 1-4, and (sliced with Flash Studio 1.7.8 from the command line) the extruder overrides on modifier volumes are honoured.

  • --part-type part gives real overlapping solids instead of modifiers.

  • Output 3MFs are regenerated clean: transforms are baked to world space and slicer settings in the input model (stackforge-top-paint / stackforge-paint) are not carried over; --template supplies the profile.

  • Always check --preview before slicing. It costs nothing.

stackforge-paint

Realistic colour from a GLB. Give it a .glb/.gltf and it reads the model's own colours (base-colour texture with its UVs, vertex colours, material colour), quantises them to your filaments with an ordered dither, and writes a printable 3MF:

stackforge-paint model.glb -o out.3mf --filaments white,black,blue,red --template my_profile.3mf
stackforge-paint model.glb -o out.3mf --palette "#e2dedb,#0c0e0c,#003287,#e20010" --scale-to 60

glTF is Y-up metres; it is rotated to Z-up and scaled to millimetres (--scale-to MM sets the largest dimension instead), put on z=0 and centred on the bed from --template. Compressed files (Draco, meshopt, KTX2) are refused with a message; re-export without compression. --dither off gives plain nearest-filament; the run prints a mean dE (simulated, not measured). Dithering is made of one-voxel features, so keep --resolution at 0.6 mm or more (the default 0.8 is fine). The result is only as good as your palette: a texture with green in it needs a green-ish filament. stackforge-make-samples writes globe.glb to try this on. The Paint tab's Realistic colour mode is this path.

Everything below also applies to GLB input; the decorative patterns need no colour data.

Colours an existing 3D model anywhere on its surface without UVs: it voxelises the model on the slicer's layer grid, evaluates a pattern at every voxel within --depth mm of the surface, and emits boxes as per-extruder modifier volumes. The slicer does the intersection, so the mesh is never touched.

stackforge-paint badge.3mf -o out.3mf --filaments white,black \
    --pattern checker3d --scale 6 --preview preview.png
stackforge-paint globe.3mf -o mars.3mf --filaments white,red,orange,black \
    --pattern image-spherical --image mars_equirect.jpg
stackforge-paint vase.3mf -o out.3mf --filaments white,black \
    --pattern expr --expr "sin(z/3 + theta*4) > 0"

Patterns: texture (the GLB's own colours, the default for a GLB), image-spherical, image-cylindrical, image-planar (wrap an --image), and the decorative checker3d, checker-sphere, stripes, gradient, expr. Pass --template so the layer grid comes from your profile (same reason as stackforge: boxes on the wrong grid drop out on alternate layers).

  • Colour is surface colour: each voxel gets its nearest single filament, with no stack solve. --depth 0 colours all the way through.
  • Depth is real distance into the material (a distance transform), not depth below the top, so vertical walls colour correctly.
  • Overlapping shells are fine (winding number, not parity), as in the dome-on-plate fixture. Consistently oriented normals are assumed.
  • --expr is checked before it runs: it is parsed and only arithmetic, comparisons, the names x y z r theta phi pi and the listed numpy functions (sin cos tan arctan2 sqrt abs floor ceil mod where minimum maximum exp log sign round) are accepted; attributes, subscripts, lambdas, comprehensions and strings are refused. Safe on untrusted input.
  • Unverified: that Flash Studio honours the extruder on many small modifier volumes on a curved model when slicing (the same open question as everything else here), and performance on 100 mm models with busy patterns. The default --resolution 0.8 keeps box counts down.
  • No interactive painting window. Dithering exists for texture and image patterns (--dither), but its gains are simulated only: the slicer drops the one-pixel features a dither is made of (see Caveats).

stackforge-measure

Measures printed wedges and plaques with a ColorMunki (or any ArgyllCMS spectro) and feeds stackforge-calibrate. Reflectance wedges and plaque checks use spotread's normal reflective mode; transmission uses a calibrated laptop screen as the backlight and standalone chips from stackforge-calibrate chips. Not yet run on real hardware. A ColorMunki with a stale dial report can use --nospos (patched ArgyllCMS via argyll-nospos, dial check off; stackforge-measure adds its own checks). Setup, the power-cycle fallback, and first-run checklist: docs/measuring.md.

stackforge-measure measure-wedge --steps 12 -o wedge.json
stackforge-calibrate chips --filament teal -o chips.3mf
stackforge-measure transmission --thickness 0.25,0.33,0.41,0.49

stackforge-spectral

Per-wavelength calibration and the Spectra tab. A filament printed as a wedge over a white and a black base, measured with spectra (stackforge-measure measure-wedge --sheet), gets an absorption K and a scattering S for every 10 nm band from 380 to 730 nm. stackforge-plaque --optics spectral then predicts stacks wavelength by wavelength. Model, limits and the calibration walk-through: docs/spectral.md.

stackforge-spectral fit --readings wedge.readings.json --filament orange --preview fit.png
stackforge-spectral fit --readings wedge.readings.json --filament orange --write
stackforge-spectral show --filaments orange,blue --base white --layers 8 --preview spectra.png
stackforge-spectral demo-readings -o demo.readings.json     # SYNTHETIC, to try the fit

The fit reports a held-out dE (each step predicted by a fit that never saw it). It refuses to write when a base looks like the filament at some wavelength (no wedge changes there), under 4 steps, or spectra whose scale disagrees with the reading's own XYZ.

Spectra tab (synthetic demo filaments)

The Spectra tab overlays calibrated filaments. It has a layer slider (with play), ghost curves for every other layer count, reflectance / transmittance / K-S views, and a stack builder ("2 orange over 3 blue over white"). A D65 / D50 / A light switch shows the dE each stack shifts under a lamp. "Load readings…" dots measured wedge steps over the model. "Load demo spectra" explores with watermarked synthetic filaments that are never saved. Not yet verified on a real meter or a print (docs/spectral.md, "Unverified").

stackforge-make-samples

Generates fabric.3mf (40×40 tile chainmail), badge.3mf (dome on a plinth) and globe.glb (a textured globe) for trying things without real files.

Development

  • pip install -e ".[dev]", then pytest runs the tests (GUI ones offscreen; the Flash Studio slice test runs when Flash Studio is installed, STACKFORGE_SKIP_SLICER=1 skips it) and ruff check src tests lints. CI runs both. Tests keep their settings and filament library in a throwaway config folder (tests/conftest.py).
  • CLAUDE.md has the load-bearing invariants (layer grid, version gate) before you change code.
  • Ideas and plans: docs/plans/surfacecolor.md, docs/ideas.md.

License

MIT. See LICENSE.

About

Flat full-colour prints from per-pixel filament stacks on a 4-independent-toolhead printer: gamut search, spool-loadout ranking, 3MF output

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages