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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-06-05

### Added
- **Printability guardrails** (CLI + web GUI). Detects color regions with features
thinner than the nozzle line width at the chosen print size — strands the slicer
would skip or print fragile — and flags colors so thin they'd vanish entirely.
The GUI shows a per-color warnings panel, highlights the at-risk areas on the 2D
preview, and offers a one-click "bump size to ~X mm" fix; the CLI prints a
warning per color. Configurable nozzle width (`--nozzle`, default 0.4 mm); the
report is written into the manifest. Warns only — nothing is blocked.
Backed by a shared `colorplate/printability.py` and `POST /api/printability`.
- A short **guided tour** of the web GUI for first-time visitors — a spotlight
walkthrough of the drop → printer → colors → size → preview → generate flow.
Auto-shows once per browser and can be replayed from the "?" button. No
Expand Down Expand Up @@ -80,7 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Graphical README with brand assets (hero banner, logo, pipeline graphic,
GUI screenshots) and an MIT license.

[Unreleased]: https://github.com/kurenn/colorplate/compare/v0.3.1...HEAD
[Unreleased]: https://github.com/kurenn/colorplate/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/kurenn/colorplate/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/kurenn/colorplate/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/kurenn/colorplate/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/kurenn/colorplate/compare/v0.1.0...v0.2.0
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ colorplate logo.svg -o out/ --height 180 \
| `--backing-color` | color name for the single-color back (omit = no backing) | none |
| `--palette` | `name=#hex,...`; omit to auto-detect | auto |
| `--colors` | target colors when quantizing a raster | 4 |
| `--nozzle` | nozzle line width (mm); warns about thinner features | 0.4 |
| `--single-extruder` | stack colors by height into one terraced STL + swap schedule | off |
| `--base` / `--step` | base-plate / per-color band height for single-extruder (mm) | 0.8 / 0.6 |
| `--layer-height` | layer height that filament swaps snap to (mm) | 0.2 |
Expand Down Expand Up @@ -156,6 +157,9 @@ What it does (all real, no mocks):
assigned filaments; it's built from the exact masks used for meshing, so what
you see is what the STLs contain. Flip to the **3D** view to rotate the actual
layered plates (front shells + backing) — the same geometry that gets exported.
- **Printability** — flags colors with features thinner than your nozzle line
width at the chosen size (set the nozzle, default 0.4 mm), highlights the at-risk
areas right on the 2D preview, and offers a one-click "bump size" to fix them.
- **Generate** — one watertight STL per distinct assigned filament (regions sharing
a filament are merged), plus an optional single-color backing plate, a flat-color
preview PNG, and a manifest — bundled into a downloadable `.zip`.
Expand Down Expand Up @@ -232,7 +236,9 @@ analytics. CI runs it on every push and pull request across Python 3.10 & 3.12.
## 📝 Notes

- Thin features (e.g. web strands) must be wider than your nozzle line width at
the chosen `--height`; scale up if a preview shows hairline regions.
the chosen `--height`. ColorPlate checks this for you — the GUI flags at-risk
colors and highlights them on the preview (with a one-click "bump size"), and
the CLI prints a warning per color (tune the nozzle with `--nozzle`).
- The front shell must be opaque enough that the backing color doesn't ghost
through; ~1.0 mm (5 layers @ 0.2 mm) is usually fine, bump `--front` if not.
- Source artwork must use **filled** color regions. Pure line-art (strokes only,
Expand Down
22 changes: 22 additions & 0 deletions colorplate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def build_parser() -> argparse.ArgumentParser:
help="Target color count when auto-quantizing a raster (default: 4)")
p.add_argument("--raster-px", type=int, default=1600,
help="Rasterization resolution on the long edge (default: 1600)")
p.add_argument("--nozzle", type=float, default=0.4,
help="Nozzle line width in mm; warns about thinner features (default: 0.4)")

se = p.add_argument_group("single-extruder (filament swaps, no MMU)")
se.add_argument("--single-extruder", action="store_true",
Expand All @@ -68,6 +70,7 @@ def main(argv: list[str] | None = None) -> int:
backing_color=args.backing_color,
raster_px=args.raster_px,
auto_colors=args.colors,
nozzle_mm=args.nozzle,
palette=_parse_palette(args.palette) if args.palette else [],
)

Expand All @@ -85,6 +88,7 @@ def main(argv: list[str] | None = None) -> int:
for b in res.bands:
verb = "start" if b["action"] == "start" else "swap "
print(f" layer {b['layer']:<4d} z {b['z0']:.2f}mm {verb} {b['hex']}")
_print_printability(res.printability)
return 0

result = PlatePipeline(cfg).run(args.input, args.out)
Expand All @@ -98,8 +102,26 @@ def main(argv: list[str] | None = None) -> int:
print(f"WARNING: {result.gap_px} uncovered pixels (gaps in tiling)", file=sys.stderr)
else:
print("Coverage: 100% (no gaps)")
_print_printability(result.printability)
return 0


def _print_printability(rep: dict | None) -> None:
"""Print thin-feature warnings to stderr (like the coverage-gap warning)."""
if not rep or rep.get("worst") == "ok":
return
for c in rep["colors"]:
if c["level"] == "ok":
continue
what = ("would vanish" if c.get("vanish") else "won't print reliably") \
if c["level"] == "wontprint" else "is fragile"
w = f" (~{c['narrowestMm']}mm)" if c.get("narrowestMm") else ""
print(f"WARNING: color '{c['key']}' {what}{w} at {rep['nozzleMm']}mm nozzle",
file=sys.stderr)
if rep.get("suggestedSizeMm"):
print(f" → scale up to ~{rep['suggestedSizeMm']}mm to clear thin features",
file=sys.stderr)


if __name__ == "__main__":
raise SystemExit(main())
4 changes: 4 additions & 0 deletions colorplate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class PlateConfig:
# Drop traced islands smaller than this area (mm^2) to remove speckle.
min_area_mm2: float = 0.3

# Nozzle line width (mm) used by the printability check to flag features
# that are too thin to print at the chosen size.
nozzle_mm: float = 0.4

# Explicit palette. If empty, the pipeline auto-discovers colors
# (from SVG fills/strokes, or by quantization for rasters).
palette: list[Color] = field(default_factory=list)
Expand Down
23 changes: 19 additions & 4 deletions colorplate/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .classify import Classifier
from .config import PlateConfig
from .mesh import MeshBuilder
from .printability import feature_report
from .raster import RasterLoader
from .stack import merge_terrace, snap, swap_bands

Expand All @@ -27,6 +28,7 @@ class PlateResult:
preview: str
manifest: str
gap_px: int
printability: dict = None # per-color thin-feature report


@dataclass
Expand All @@ -38,12 +40,20 @@ class StackResult:
preview: str
bands: list[dict]
total_mm: float
printability: dict = None


def _rgb_to_hex(rgb) -> str:
return "#%02X%02X%02X" % (int(rgb[0]), int(rgb[1]), int(rgb[2]))


def _printability(regions, scale: float, cfg: PlateConfig) -> dict:
rep = feature_report(list(regions.masks.items()), scale, cfg.nozzle_mm,
min_area_mm2=cfg.min_area_mm2, size_mm=cfg.size_mm)
# name the offenders by color (CLI/manifest are keyed by palette name)
return rep


class PlatePipeline:
def __init__(self, config: PlateConfig):
self.cfg = config
Expand Down Expand Up @@ -84,12 +94,13 @@ def run(self, input_path: str, out_dir: str) -> PlateResult:
backing.export(path)
files["backing"] = path

printability = _printability(regions, scale, self.cfg)
preview = self._write_preview(regions, os.path.join(out_dir, f"{stem}_preview.png"))
manifest = self._write_manifest(
regions, files, os.path.join(out_dir, f"{stem}_manifest.json")
regions, files, os.path.join(out_dir, f"{stem}_manifest.json"), printability
)
return PlateResult(files=files, preview=preview, manifest=manifest,
gap_px=regions.coverage_gap())
gap_px=regions.coverage_gap(), printability=printability)

def run_stack(self, input_path: str, out_dir: str, *, base_mm: float,
step_mm: float, layer_mm: float) -> StackResult:
Expand Down Expand Up @@ -156,6 +167,7 @@ def run_stack(self, input_path: str, out_dir: str, *, base_mm: float,
with open(swaps_path, "w") as fh:
fh.write("\n".join(lines) + "\n")

printability = _printability(regions, self._scale(raster), self.cfg)
man_path = os.path.join(out_dir, f"{stem}_manifest.json")
with open(man_path, "w") as fh:
json.dump({
Expand All @@ -166,12 +178,14 @@ def run_stack(self, input_path: str, out_dir: str, *, base_mm: float,
"bands": [{"name": name_by_hex[b["hex"]], "hex": b["hex"],
"action": b["action"], "z_mm": b["z0"], "layer": b["layer"]}
for b in bands],
"printability": printability,
"note": "Single extruder: print the STL and insert an M600 at each swap layer.",
}, fh, indent=2)

preview = self._write_preview(regions, os.path.join(out_dir, f"{stem}_preview.png"))
return StackResult(stl=stl_path, swaps=swaps_path, manifest=man_path,
preview=preview, bands=bands, total_mm=round(total, 2))
preview=preview, bands=bands, total_mm=round(total, 2),
printability=printability)

def _scale(self, raster) -> float:
ys, xs = np.where(raster.silhouette)
Expand All @@ -187,7 +201,7 @@ def _write_preview(self, regions, path: str) -> str:
Image.fromarray(out).resize((512, 512)).save(path)
return path

def _write_manifest(self, regions, files, path: str) -> str:
def _write_manifest(self, regions, files, path: str, printability=None) -> str:
lut = {c.name: c.rgb for c in regions.palette}
data = {
"front_mm": self.cfg.front_mm,
Expand All @@ -199,6 +213,7 @@ def _write_manifest(self, regions, files, path: str) -> str:
for n in files if n != "backing"
],
"backing": os.path.basename(files["backing"]) if "backing" in files else None,
"printability": printability,
"note": "Load all STLs together; they share an origin. Print face-down.",
}
with open(path, "w") as f:
Expand Down
131 changes: 131 additions & 0 deletions colorplate/printability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Printability guardrails (shared by the CLI and the web service).

Detects color regions with features narrower than the nozzle line width at the
chosen print size — strands that the slicer would skip or print fragile — and
flags colors that are so thin they'd vanish entirely (the mesh builder drops
islands below ``min_area_mm2``). Reports per color, with the width of the
thinnest real feature and the size you'd need to scale to so it clears the
nozzle. Warns only; nothing is blocked or modified.

Dependency-light: numpy + scipy.ndimage + cv2 (all already required).
"""
from __future__ import annotations

import math

import cv2
import numpy as np
from scipy import ndimage

OK, FRAGILE, WONTPRINT = "ok", "fragile", "wontprint"
_RANK = {OK: 0, FRAGILE: 1, WONTPRINT: 2}


def _thin_mask(mask: np.ndarray, width_px: float) -> np.ndarray:
"""Pixels of ``mask`` belonging to features narrower than ``width_px`` —
i.e. removed by a morphological opening with a disk of that diameter. Opening
keeps the thick core and strips only genuinely thin parts, so region
boundaries of thick blobs are NOT flagged."""
if width_px < 1.5: # threshold finer than the pixel grid → nothing to flag
return np.zeros_like(mask, dtype=bool)
r = max(1, int(round(width_px / 2.0)))
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
opened = cv2.morphologyEx(mask.astype(np.uint8), cv2.MORPH_OPEN, k)
return mask & ~opened.astype(bool)


def _keep_significant(lost: np.ndarray, min_px: float) -> np.ndarray:
"""Drop connected components smaller than ``min_px`` so 1-px noise isn't
flagged (mirrors MeshBuilder's min-area speckle filter)."""
if not lost.any():
return lost
labeled, n = ndimage.label(lost)
if n == 0:
return lost
sizes = ndimage.sum(np.ones_like(labeled), labeled, index=range(1, n + 1))
keep = {i + 1 for i, s in enumerate(sizes) if s >= min_px}
return np.isin(labeled, list(keep)) if keep else np.zeros_like(lost)


def _narrowest_mm(mask: np.ndarray, lost: np.ndarray, scale: float) -> float | None:
"""Width (mm) of the thinnest real thin-feature: per connected component of
``lost``, the strand width ≈ ``2*EDT_max - 1`` px (the -1 corrects the
half-pixel discretization bias of the distance transform on thin strands)."""
if not lost.any():
return None
edt = ndimage.distance_transform_edt(mask)
labeled, n = ndimage.label(lost)
if n == 0:
return None
widths = [max(0.5, 2.0 * float(edt[labeled == i].max()) - 1.0) * scale
for i in range(1, n + 1)]
return round(min(widths), 3) if widths else None


def feature_report(masks, scale: float, nozzle_mm: float, *,
min_area_mm2: float = 0.3, fragile_mult: float = 2.0,
size_mm: float | None = None,
return_masks: bool = False) -> dict:
"""Assess printability of each color region.

``masks`` : iterable of ``(key, bool_mask)``.
``scale`` : mm per pixel (``size_mm / span``).
Returns ``{colors: [{key, level, narrowestMm, atRiskPct}], worst,
suggestedSizeMm, nozzleMm}``. With ``return_masks`` also yields per-color
``hardMask`` / ``fragileMask`` (numpy bool) for rendering an overlay.
"""
nozzle_mm = max(0.05, float(nozzle_mm))
px_area = scale * scale
min_px = max(1.0, min_area_mm2 / px_area) if px_area > 0 else 1.0
nozzle_px = nozzle_mm / scale if scale > 0 else 1.0

colors, worst_rank, narrowest_offender = [], 0, None
for key, mask in masks:
mask = np.asarray(mask, dtype=bool)
area_px = int(mask.sum())
if area_px == 0:
continue

hard = _keep_significant(_thin_mask(mask, nozzle_px), min_px)
frag = _keep_significant(_thin_mask(mask, nozzle_px * fragile_mult), min_px)
frag_only = frag & ~hard

hard_px = int(hard.sum())
printable_px = area_px - hard_px
vanish = printable_px * px_area < min_area_mm2

# a level needs a *meaningful* at-risk area (≥ min_area_mm2), so the
# rounded corners an opening shaves off a thick block don't trip it
hard_area = hard_px * px_area
frag_area = int(frag_only.sum()) * px_area
if (hard_area >= min_area_mm2) or vanish:
level = WONTPRINT
elif frag_area >= min_area_mm2:
level = FRAGILE
else:
level = OK

at_risk_pct = round(100.0 * (hard_px + int(frag_only.sum())) / area_px, 1)
narrow = _narrowest_mm(mask, hard if hard.any() else frag_only, scale)
entry = {"key": key, "level": level, "narrowestMm": narrow,
"atRiskPct": at_risk_pct, "vanish": bool(vanish)}
if return_masks:
entry["hardMask"], entry["fragileMask"] = hard, frag_only
colors.append(entry)

worst_rank = max(worst_rank, _RANK[level])
if level != OK and narrow:
narrowest_offender = narrow if narrowest_offender is None else min(narrowest_offender, narrow)

worst = next(k for k, v in _RANK.items() if v == worst_rank)

# Suggest the size that brings the thinnest at-risk feature up to a *safe*
# width (2× nozzle), so one click lands it cleanly printable. Capped at 4×.
suggested = None
if size_mm and narrowest_offender and narrowest_offender > 0:
factor = min(4.0, (fragile_mult * nozzle_mm) / narrowest_offender)
if factor > 1.01:
suggested = int(math.ceil(size_mm * factor / 5.0) * 5) # round up to 5mm

return {"colors": colors, "worst": worst,
"suggestedSizeMm": suggested, "nozzleMm": round(nozzle_mm, 2)}
17 changes: 17 additions & 0 deletions colorplate/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ class Stack3DReq(BaseModel):
layer: float


class PrintabilityReq(BaseModel):
uploadId: str
size: float
nozzle: float


class GenerateReq(BaseModel):
uploadId: str
assignments: list[Filament] # one per region, in order
Expand Down Expand Up @@ -182,6 +188,17 @@ def api_stack3d(req: Stack3DReq):
raise HTTPException(500, f"Single-extruder preview failed: {exc}")


@app.post("/api/printability")
def api_printability(req: PrintabilityReq):
session = _require(req.uploadId)
try:
return service.printability(
session, size_mm=max(1.0, req.size), nozzle_mm=max(0.05, req.nozzle),
)
except Exception as exc: # pragma: no cover - defensive
raise HTTPException(500, f"Printability check failed: {exc}")


@app.post("/api/generate")
def api_generate(request: Request, req: GenerateReq):
session = _require(req.uploadId)
Expand Down
Loading
Loading