diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac9492..1b0bd4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 744d554..1cb8df1 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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`. @@ -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, diff --git a/colorplate/cli.py b/colorplate/cli.py index e445804..50fa8fb 100644 --- a/colorplate/cli.py +++ b/colorplate/cli.py @@ -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", @@ -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 [], ) @@ -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) @@ -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()) diff --git a/colorplate/config.py b/colorplate/config.py index 255aa7f..fb37a2f 100644 --- a/colorplate/config.py +++ b/colorplate/config.py @@ -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) diff --git a/colorplate/pipeline.py b/colorplate/pipeline.py index 4a1f12d..7d1d03a 100644 --- a/colorplate/pipeline.py +++ b/colorplate/pipeline.py @@ -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 @@ -27,6 +28,7 @@ class PlateResult: preview: str manifest: str gap_px: int + printability: dict = None # per-color thin-feature report @dataclass @@ -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 @@ -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: @@ -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({ @@ -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) @@ -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, @@ -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: diff --git a/colorplate/printability.py b/colorplate/printability.py new file mode 100644 index 0000000..cbaf0d9 --- /dev/null +++ b/colorplate/printability.py @@ -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)} diff --git a/colorplate/web/server.py b/colorplate/web/server.py index 06f5d4b..7f0a74a 100644 --- a/colorplate/web/server.py +++ b/colorplate/web/server.py @@ -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 @@ -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) diff --git a/colorplate/web/service.py b/colorplate/web/service.py index cbca990..ca3f35b 100644 --- a/colorplate/web/service.py +++ b/colorplate/web/service.py @@ -34,6 +34,7 @@ from ..config import PlateConfig from ..mesh import MeshBuilder +from ..printability import feature_report from ..raster import RasterLoader from ..stack import merge_terrace from ..stack import snap as _snap @@ -313,6 +314,59 @@ def render_preview(session: Session, assignments_hex: list[str], max_px: int = 5 return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") +# ---------------------------------------------------------------------------- +# Printability guardrails +# ---------------------------------------------------------------------------- +def printability(session: Session, *, size_mm: float, nozzle_mm: float, + overlay_px: int = 560) -> dict: + """Per-color printability report for the current size + nozzle, plus an + overlay PNG (cropped/sized like render_preview, so it aligns with the 2D + preview) highlighting at-risk areas — magenta = won't print, amber = + fragile. Colors are keyed by region ``index`` so the client can label them + with the currently-assigned filament.""" + sil = session.sil + labels = session.labels + ys, xs = np.where(sil) + if ys.size == 0 or labels is None or not session.detected: + return {"colors": [], "worst": "ok", "suggestedSizeMm": None, + "nozzleMm": round(nozzle_mm, 2), "sizeMm": size_mm, "overlay": None} + + span = max(int(xs.max() - xs.min()), int(ys.max() - ys.min())) or 1 + scale = size_mm / span + masks = [("T%d" % (i + 1), labels == i) for i in range(len(session.detected))] + rep = feature_report(masks, scale, nozzle_mm, + min_area_mm2=PlateConfig().min_area_mm2, + size_mm=size_mm, return_masks=True) + + h, w = sil.shape + rgba = np.zeros((h, w, 4), "uint8") + any_risk = False + for c in rep["colors"]: + frag = c.pop("fragileMask"); hard = c.pop("hardMask") + c["index"] = int(c["key"][1:]) - 1 + if frag.any(): + rgba[frag] = (245, 158, 11, 205); any_risk = True # amber — fragile + if hard.any(): + rgba[hard] = (236, 64, 122, 225); any_risk = True # magenta — won't print + + overlay = None + if any_risk: + img = Image.fromarray(rgba, "RGBA").crop( + (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)) + le = max(img.size) + if le > overlay_px: + s = overlay_px / le + img = img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), + Image.NEAREST) + buf = io.BytesIO() + img.save(buf, "PNG") + overlay = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") + + return {"colors": rep["colors"], "worst": rep["worst"], + "suggestedSizeMm": rep["suggestedSizeMm"], "nozzleMm": rep["nozzleMm"], + "sizeMm": size_mm, "overlay": overlay} + + # ---------------------------------------------------------------------------- # Live 3D preview geometry # ---------------------------------------------------------------------------- diff --git a/colorplate/web/static/app.jsx b/colorplate/web/static/app.jsx index 6b5cecf..316c850 100644 --- a/colorplate/web/static/app.jsx +++ b/colorplate/web/static/app.jsx @@ -49,6 +49,14 @@ const api = { if (!r.ok) throw await apiErr(r); return r.json(); }, + async printability(uploadId, size, nozzle) { + const r = await fetch("/api/printability", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uploadId, size, nozzle }), + }); + if (!r.ok) throw await apiErr(r); + return r.json(); + }, }; // ---- onboarding tour ------------------------------------------------------- @@ -70,6 +78,51 @@ const TOUR_STEPS = [ body: "Export watertight STLs (plus a backing plate, or a filament-swap schedule for single extruder) bundled in a zip — ready to slice. That's it!" }, ]; +// ---- printability warnings panel ------------------------------------------ +function PrintabilityPanel({ report, regions, showRisk, onToggleRisk, onApplySize }) { + if (!report) return