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
Your printer's nozzle / line width.
; + const bad = report.colors.filter((c) => c.level !== "ok"); + if (!bad.length) { + return
+ All features print cleanly at {report.sizeMm}mm. +
; + } + const filFor = (i) => (regions[i] && regions[i].filament) || { name: "color " + (i + 1), hex: "#999" }; + return ( +
+ {bad.map((c) => { + const f = filFor(c.index); + const wont = c.level === "wontprint"; + return ( +
+ + {f.name} + + {wont ? (c.vanish ? "would vanish" : "won't print") : "fragile"} + + {c.narrowestMm ? ~{c.narrowestMm}mm : null} +
+ ); + })} +
+ Some features are thinner than the {report.nozzleMm}mm nozzle line. +
+
+ {report.suggestedSizeMm + ? + : null} + {report.overlay + ? + : null} +
+
+ ); +} + function App() { const [theme, setTheme] = useState(() => document.documentElement.getAttribute("data-theme") || "light"); useEffect(() => { @@ -104,6 +157,9 @@ function App() { const [tourOpen, setTourOpen] = useState(false); const [maxColors, setMaxColors] = useState(4); const [size, setSize] = useState(180); + const [nozzle, setNozzle] = useState(0.4); + const [printable, setPrintable] = useState(null); // printability report + const [showRisk, setShowRisk] = useState(true); // 2D at-risk overlay const [front, setFront] = useState(1.0); const [backThick, setBackThick] = useState(2.0); const [regions, setRegions] = useState([]); @@ -140,6 +196,20 @@ function App() { const filamentByHex = (hex) => distinct.find((f) => f.hex === hex) || { name: hex, hex }; + // Printability check — debounced, refetched when the geometry-affecting inputs + // (file, color count, size, nozzle) change. Independent of filament colors. + useEffect(() => { + if (!loaded || !uploadId) { setPrintable(null); return; } + let cancelled = false; + const t = setTimeout(async () => { + try { + const rep = await api.printability(uploadId, size, nozzle); + if (!cancelled) setPrintable(rep); + } catch (e) { /* leave last report on transient error */ } + }, 300); + return () => { cancelled = true; clearTimeout(t); }; + }, [uploadId, maxColors, size, nozzle, loaded]); + // client-side swap schedule (mirrors the server's layer snapping) const schedule = useMemo(() => { const snap = (v, l) => Math.max(l, Math.round(v / l) * l); @@ -337,6 +407,20 @@ function App() {
Longest dimension of the finished plate.
+ {/* nozzle + printability */} +
+
Nozzle & printability + {printable && printable.worst !== "ok" + ? + {printable.colors.filter((c) => c.level !== "ok").length} + : null} +
+ + setShowRisk((v) => !v)} + onApplySize={(z) => setSize(z)} /> +
+ {printer === "mmu" ? ( <> {/* thickness */} @@ -514,7 +598,12 @@ function App() { ) : loaded && preview ? (
- recolored logo preview +
+ recolored logo preview + {showRisk && printable && printable.overlay + ? at-risk areas + : null} +
{distinct.map((f) => (
diff --git a/colorplate/web/static/icons.jsx b/colorplate/web/static/icons.jsx index 05913ae..5a727e2 100644 --- a/colorplate/web/static/icons.jsx +++ b/colorplate/web/static/icons.jsx @@ -25,6 +25,7 @@ const Icons = { refresh:(p) => , mail: (p) => , help: (p) => , + warn: (p) => , github: (p) => ( diff --git a/colorplate/web/static/index.html b/colorplate/web/static/index.html index 9ae7f55..19936a1 100644 --- a/colorplate/web/static/index.html +++ b/colorplate/web/static/index.html @@ -369,6 +369,30 @@ border-radius:5px; padding:2px 6px; flex:none; } + /* ---------- PRINTABILITY ---------- */ + .risk{ display:flex; flex-direction:column; gap:6px; margin-top:4px; } + .risk-row{ + display:flex; align-items:center; gap:9px; padding:7px 10px; + border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg-2); + } + .risk-row .sw{ width:16px; height:16px; border-radius:5px; flex:none; box-shadow: inset 0 0 0 1px rgba(127,127,127,.3); } + .risk-row .snm{ font-size:13px; font-weight:600; } + .risk-tag{ font-family:var(--font-mono); font-size:10px; font-weight:600; padding:2px 7px; border-radius:999px; letter-spacing:.02em; } + .risk-tag.bad{ color:#e0533d; background:color-mix(in oklab,#e0533d 14%, transparent); } + .risk-tag.warn{ color:#c8881a; background:color-mix(in oklab,#d9971f 16%, transparent); } + .risk-row .risk-w{ margin-left:auto; font-size:11px; color:var(--text-3); } + .risk-foot{ display:flex; align-items:center; gap:10px; margin-top:2px; flex-wrap:wrap; } + .risk-apply{ + display:inline-flex; align-items:center; gap:6px; font-family:var(--font-ui); font-size:12px; font-weight:600; + color:var(--accent-text); background:var(--accent); border:0; border-radius:var(--radius-sm); padding:7px 11px; cursor:pointer; + transition: all var(--speed) ease; + } + .risk-apply:hover{ background:var(--accent-strong); } + .risk-toggle{ display:inline-flex; align-items:center; gap:6px; font-size:11.5px; color:var(--text-2); cursor:pointer; margin-left:auto; } + .risk-toggle input{ accent-color:var(--accent); } + .art-wrap{ position:relative; display:inline-flex; } + .risk-overlay{ position:absolute; left:0; top:0; width:100%; height:100%; pointer-events:none; image-rendering:pixelated; } + /* ---------- GUIDED TOUR ---------- */ .tour-block{ position:fixed; inset:0; z-index:1000; cursor:default; } .tour-spot{ diff --git a/pyproject.toml b/pyproject.toml index cc831bd..939da61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "colorplate" -version = "0.3.1" +version = "0.4.0" description = "Turn SVG/PNG artwork into layered, gap-free multicolor STL plates for face-down multi-material 3D printing." readme = "README.md" license = "MIT" diff --git a/tests/test_printability.py b/tests/test_printability.py new file mode 100644 index 0000000..8f80bec --- /dev/null +++ b/tests/test_printability.py @@ -0,0 +1,127 @@ +"""Printability guardrails: thin-feature detection (`feature_report`), the web +service + `/api/printability` endpoint, and the CLI/pipeline report. +""" +from __future__ import annotations + +import json +import os + +import numpy as np +import pytest + +from colorplate import printability as P +from colorplate.config import Color, PlateConfig +from colorplate.pipeline import PlatePipeline +from colorplate.web import server, service + + +# --------------------------------------------------------------------------- +# Unit: feature_report on synthetic masks +# --------------------------------------------------------------------------- +def _block(h=100, w=100): + m = np.zeros((h, w), bool) + m[20:80, 20:80] = True # 60px square + return m + + +def test_thick_block_is_ok(): + rep = P.feature_report([("k", _block())], scale=0.2, nozzle_mm=0.4, size_mm=100) + assert rep["worst"] == "ok" + assert rep["colors"][0]["level"] == "ok" + assert rep["suggestedSizeMm"] is None + + +def test_hairline_strand_wont_print(): + m = np.zeros((100, 100), bool) + m[20:80, 40:80] = True # thick anchor + m[49:50, 20:40] = True # 1px (0.2mm @ scale 0.2) strand → below nozzle + rep = P.feature_report([("k", m)], scale=0.2, nozzle_mm=0.4, size_mm=100) + assert rep["worst"] == "wontprint" + assert rep["colors"][0]["narrowestMm"] < 0.4 + assert rep["suggestedSizeMm"] and rep["suggestedSizeMm"] > 100 + + +def test_midwidth_strand_is_fragile(): + m = np.zeros((100, 100), bool) + m[20:80, 40:80] = True + m[48:51, 20:40] = True # 3px (0.6mm) strand → between 0.4 and 0.8 + rep = P.feature_report([("k", m)], scale=0.2, nozzle_mm=0.4, size_mm=100) + assert rep["colors"][0]["level"] == "fragile" + + +def test_suggested_size_clears_the_feature(): + m = np.zeros((100, 100), bool) + m[20:80, 40:80] = True + m[49:50, 20:40] = True + rep = P.feature_report([("k", m)], scale=0.2, nozzle_mm=0.4, size_mm=100) + s = rep["suggestedSizeMm"] + assert s and s > 100 + # at the suggested size the print scales up (scale grows with size, span fixed), + # so the feature should no longer be a hard "won't print" + rep2 = P.feature_report([("k", m)], scale=0.2 * (s / 100), nozzle_mm=0.4, size_mm=s) + assert rep2["worst"] != "wontprint" + + +def test_empty_masks_are_ok(): + rep = P.feature_report([("k", np.zeros((10, 10), bool))], scale=0.2, nozzle_mm=0.4, size_mm=100) + assert rep["worst"] == "ok" and rep["colors"] == [] + + +# --------------------------------------------------------------------------- +# Web service + endpoint +# --------------------------------------------------------------------------- +def _thin_session(): + """A session whose region 1 is a 2-px strand (thin at small sizes).""" + h = w = 200 + sil = np.zeros((h, w), bool); sil[20:180, 20:180] = True + labels = np.full((h, w), -1, int); labels[sil] = 0 + labels[99:101, 20:180] = 1 # 2px-wide strand = region 1 + return service.Session(id="thin", filename="t.svg", src_path="", out_dir="", + rgb=np.zeros((h, w, 3), int), sil=sil, labels=labels, + detected=["#231F1D", "#ED4324"]) + + +def test_service_printability_small_vs_large(): + session = _thin_session() + small = service.printability(session, size_mm=20, nozzle_mm=0.4) # 2px ≈ 0.25mm + large = service.printability(session, size_mm=400, nozzle_mm=0.4) # 2px ≈ 5mm + assert small["worst"] == "wontprint" + assert small["overlay"] and small["overlay"].startswith("data:image/png") + assert large["worst"] == "ok" and large["overlay"] is None + + +@pytest.fixture() +def client(sample_session): + from fastapi.testclient import TestClient + session, _ = sample_session + server.store.put(session) + return TestClient(server.app) + + +def test_api_printability_ok(client, sample_session): + session, _ = sample_session + r = client.post("/api/printability", json={"uploadId": session.id, "size": 25, "nozzle": 0.6}) + assert r.status_code == 200 + data = r.json() + assert {"colors", "worst", "suggestedSizeMm", "nozzleMm", "overlay"} <= data.keys() + assert all("index" in c for c in data["colors"]) + + +def test_api_printability_unknown_upload_404(client): + r = client.post("/api/printability", json={"uploadId": "nope", "size": 100, "nozzle": 0.4}) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# CLI / pipeline +# --------------------------------------------------------------------------- +_PALETTE = [Color.from_hex("c0", "#231F1D"), Color.from_hex("c1", "#F9CF26"), + Color.from_hex("c2", "#ED4324"), Color.from_hex("c3", "#F4F4F4")] + + +def test_pipeline_report_in_result_and_manifest(sample_svg_path, tmp_path): + cfg = PlateConfig(size_mm=180, raster_px=600, palette=_PALETTE, nozzle_mm=0.4) + res = PlatePipeline(cfg).run(sample_svg_path, str(tmp_path)) + assert res.printability and res.printability["worst"] == "ok" # sample is chunky + man = json.load(open(res.manifest)) + assert "printability" in man and man["printability"]["nozzleMm"] == 0.4