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,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.0] - 2026-06-05

### Added
- **Fill enclosed areas** (CLI + web GUI). Blank spaces *inside* a design — letter
interiors, the holes in an outlined mark — that match the background were
detected as background and left as empty gaps. Tick "Fill enclosed areas" in
the GUI (or pass `--fill-holes`) to fold those enclosed regions into the
silhouette so they're detected as their own color and become paintable.
Background that touches the image border (the real outside) is left out.

## [0.4.0] - 2026-06-05

### Added
Expand Down Expand Up @@ -90,7 +100,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.4.0...HEAD
[Unreleased]: https://github.com/kurenn/colorplate/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/kurenn/colorplate/compare/v0.4.0...v0.5.0
[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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ colorplate logo.svg -o out/ --height 180 \
| `--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 |
| `--fill-holes` | fill enclosed blank areas (e.g. letter interiors) into the design | off |
| `--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 @@ -160,6 +161,9 @@ What it does (all real, no mocks):
- **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.
- **Fill enclosed areas** — for logos where blank spaces inside the design (letter
interiors, the holes in an outlined mark) match the background, tick this to fold
them into the silhouette so they become paintable colors instead of empty gaps.
- **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 @@ -241,8 +245,10 @@ analytics. CI runs it on every push and pull request across Python 3.10 & 3.12.
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,
colors as background showing through) needs a fill pass first.
- Source artwork should use **filled** color regions. If your design has blank
spaces enclosed by the outline (letter interiors, an outlined mark) that match
the background, turn on **Fill enclosed areas** (`--fill-holes`) to make them
paintable instead of empty gaps.

## 📫 Contact

Expand Down
4 changes: 4 additions & 0 deletions colorplate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ def build_parser() -> argparse.ArgumentParser:
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)")
p.add_argument("--fill-holes", action="store_true",
help="Fill enclosed blank areas (e.g. letter interiors) so they "
"become paintable regions instead of empty gaps.")

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

Expand Down
4 changes: 4 additions & 0 deletions colorplate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class PlateConfig:
# that are too thin to print at the chosen size.
nozzle_mm: float = 0.4

# Fill enclosed background ('holes' — e.g. white letter interiors) into the
# silhouette so they become paintable regions instead of empty gaps.
fill_holes: bool = False

# 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
4 changes: 2 additions & 2 deletions colorplate/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def run(self, input_path: str, out_dir: str) -> PlateResult:
stem = os.path.splitext(os.path.basename(input_path))[0]

loader = RasterLoader(self.cfg.raster_px)
raster = loader.load(input_path)
raster = loader.load(input_path, fill_holes=self.cfg.fill_holes)

palette = self.cfg.palette
if not palette and input_path.lower().endswith(".svg"):
Expand Down Expand Up @@ -112,7 +112,7 @@ def run_stack(self, input_path: str, out_dir: str, *, base_mm: float,
stem = os.path.splitext(os.path.basename(input_path))[0]

loader = RasterLoader(self.cfg.raster_px)
raster = loader.load(input_path)
raster = loader.load(input_path, fill_holes=self.cfg.fill_holes)
palette = self.cfg.palette
if not palette and input_path.lower().endswith(".svg"):
palette = RasterLoader.palette_from_svg(input_path)
Expand Down
16 changes: 14 additions & 2 deletions colorplate/raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@

import numpy as np
from PIL import Image
from scipy.ndimage import binary_fill_holes

from .config import Color


def fill_enclosed(silhouette: np.ndarray) -> np.ndarray:
"""Fill *enclosed* background ('holes') into the silhouette — e.g. the white
interiors of letters that background detection excluded — so they become
paintable regions. Background that touches the image border (the real
outside) is left out."""
return binary_fill_holes(silhouette)


@dataclass
class Raster:
rgb: np.ndarray # (H, W, 3) int
Expand All @@ -32,12 +41,15 @@ class RasterLoader:
def __init__(self, raster_px: int = 1600):
self.raster_px = raster_px

def load(self, path: str) -> Raster:
def load(self, path: str, *, fill_holes: bool = False) -> Raster:
if path.lower().endswith(self.SVG_EXT):
rgba = self._render_svg(path)
else:
rgba = self._load_raster(path)
return self._to_raster(rgba)
raster = self._to_raster(rgba)
if fill_holes:
raster.silhouette = fill_enclosed(raster.silhouette)
return raster

# -- SVG -------------------------------------------------------------
def _render_svg(self, path: str) -> np.ndarray:
Expand Down
9 changes: 6 additions & 3 deletions colorplate/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async def _lifespan(app: FastAPI):
class RedetectReq(BaseModel):
uploadId: str
maxColors: int
fillHoles: bool = False


class PreviewReq(BaseModel):
Expand Down Expand Up @@ -119,7 +120,8 @@ def index() -> HTMLResponse:


@app.post("/api/detect")
async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4)):
async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4),
fillHoles: bool = Form(False)):
name = file.filename or "logo.svg"
ext = os.path.splitext(name)[1].lower()
if ext not in _ACCEPT:
Expand All @@ -136,7 +138,7 @@ async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4

try:
session, payload = service.load_session(
sid, name, src_path, out_dir, _clamp_colors(maxColors)
sid, name, src_path, out_dir, _clamp_colors(maxColors), fill_holes=fillHoles
)
except Exception as exc:
service._cleanup_dir(out_dir)
Expand All @@ -151,7 +153,8 @@ async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4
@app.post("/api/redetect")
def api_redetect(request: Request, req: RedetectReq):
session = _require(req.uploadId)
payload = service.detect_from_path(session, _clamp_colors(req.maxColors))
payload = service.detect_from_path(session, _clamp_colors(req.maxColors),
fill_holes=req.fillHoles)
analytics.record("redetect", request, max_colors=_clamp_colors(req.maxColors),
regions=len(payload["regions"]))
return payload
Expand Down
23 changes: 16 additions & 7 deletions colorplate/web/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from ..config import PlateConfig
from ..mesh import MeshBuilder
from ..printability import feature_report
from ..raster import RasterLoader
from ..raster import RasterLoader, fill_enclosed
from ..stack import merge_terrace
from ..stack import snap as _snap
from ..stack import swap_bands as _swap_bands
Expand Down Expand Up @@ -112,7 +112,9 @@ class Session:
src_path: str # saved original upload (for full-res regen)
out_dir: str # scratch dir for generated artifacts
rgb: np.ndarray # (H, W, 3) int detection-resolution color
sil: np.ndarray # (H, W) bool silhouette mask
sil: np.ndarray # (H, W) bool effective silhouette (used everywhere)
sil_raw: np.ndarray = field(default=None) # (H, W) bool silhouette before hole-fill
fill_holes: bool = False # treat enclosed blank areas as design
labels: np.ndarray = field(default=None) # (H, W) int region index, -1 = bg
detected: list = field(default_factory=list) # hex per region (dominant-first)
weights: list = field(default_factory=list) # area fraction per region
Expand Down Expand Up @@ -249,8 +251,14 @@ def _regions_payload(session: Session) -> list[dict]:
return out


def detect_from_path(session: Session, max_colors: int) -> dict:
"""(Re)run detection on an already-loaded session at the given color count."""
def detect_from_path(session: Session, max_colors: int,
fill_holes: bool | None = None) -> dict:
"""(Re)run detection on an already-loaded session at the given color count.
When ``fill_holes`` is set, enclosed blank areas (e.g. letter interiors) are
folded into the silhouette so they become paintable regions."""
if fill_holes is not None:
session.fill_holes = fill_holes
session.sil = fill_enclosed(session.sil_raw) if session.fill_holes else session.sil_raw
labels, detected, weights = _quantize(session.rgb, session.sil, max_colors)
session.labels = labels
session.detected = detected
Expand All @@ -263,17 +271,18 @@ def detect_from_path(session: Session, max_colors: int) -> dict:
"filename": session.filename,
"regions": regions,
"preview": preview,
"fillHoles": session.fill_holes,
}


def load_session(sid: str, filename: str, src_path: str, out_dir: str,
max_colors: int) -> tuple[Session, dict]:
max_colors: int, fill_holes: bool = False) -> tuple[Session, dict]:
raster = RasterLoader(DETECT_PX).load(src_path)
session = Session(
id=sid, filename=filename, src_path=src_path, out_dir=out_dir,
rgb=raster.rgb, sil=raster.silhouette,
rgb=raster.rgb, sil=raster.silhouette, sil_raw=raster.silhouette,
)
payload = detect_from_path(session, max_colors)
payload = detect_from_path(session, max_colors, fill_holes=fill_holes)
return session, payload


Expand Down
36 changes: 31 additions & 5 deletions colorplate/web/static/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ async function apiErr(r) {
catch (e) { return new Error(r.statusText || "Request failed"); }
}
const api = {
async detect(file, maxColors) {
async detect(file, maxColors, fillHoles) {
const fd = new FormData();
fd.append("file", file);
fd.append("maxColors", String(maxColors));
fd.append("fillHoles", fillHoles ? "true" : "false");
const r = await fetch("/api/detect", { method: "POST", body: fd });
if (!r.ok) throw await apiErr(r);
return r.json();
},
async redetect(uploadId, maxColors) {
async redetect(uploadId, maxColors, fillHoles) {
const r = await fetch("/api/redetect", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId, maxColors }),
body: JSON.stringify({ uploadId, maxColors, fillHoles }),
});
if (!r.ok) throw await apiErr(r);
return r.json();
Expand Down Expand Up @@ -156,6 +157,7 @@ function App() {
const [view, setView] = useState("2d"); // 2d recolor | 3d geometry
const [tourOpen, setTourOpen] = useState(false);
const [maxColors, setMaxColors] = useState(4);
const [fillHoles, setFillHoles] = useState(false);
const [size, setSize] = useState(180);
const [nozzle, setNozzle] = useState(0.4);
const [printable, setPrintable] = useState(null); // printability report
Expand Down Expand Up @@ -245,7 +247,7 @@ function App() {
const loadFile = async (f) => {
setBusy(true); setError(null);
try {
const resp = await api.detect(f, maxColors);
const resp = await api.detect(f, maxColors, fillHoles);
applyDetection(resp);
setResult(null);
setPhase("loaded");
Expand All @@ -256,6 +258,22 @@ function App() {
}
};

const toggleFill = async () => {
const next = !fillHoles;
setFillHoles(next);
if (!loaded || !uploadId) return;
setBusy(true); setError(null);
try {
const resp = await api.redetect(uploadId, maxColors, next);
applyDetection(resp);
if (phase === "results") { setResult(null); setPhase("loaded"); }
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};

const clearFile = () => {
setUploadId(null); setFile(null); setPreview(null);
setRegions([]); setBacking(null); setResult(null);
Expand All @@ -267,7 +285,7 @@ function App() {
if (!loaded || !uploadId) return;
setBusy(true); setError(null);
try {
const resp = await api.redetect(uploadId, n);
const resp = await api.redetect(uploadId, n, fillHoles);
applyDetection(resp);
if (phase === "results") { setResult(null); setPhase("loaded"); }
} catch (e) {
Expand Down Expand Up @@ -392,6 +410,14 @@ function App() {
<div className="section-label">Max colors</div>
<Segmented value={maxColors} options={[2, 3, 4, 5, 6]} onChange={changeMax} />
<div className="hint">Most printers handle 4 at once.</div>
<label className={"fill-toggle" + (busy ? " is-busy" : "")}>
<input type="checkbox" checked={fillHoles} onChange={toggleFill} disabled={busy} />
<span>Fill enclosed areas
<span className="hint" style={{ marginLeft: 6 }}>
blank spaces inside the design (e.g. letter interiors) become paintable
</span>
</span>
</label>
</div>

{/* color config */}
Expand Down
6 changes: 6 additions & 0 deletions colorplate/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,12 @@
border-radius:5px; padding:2px 6px; flex:none;
}

/* ---------- FILL ENCLOSED TOGGLE ---------- */
.fill-toggle{ display:flex; align-items:flex-start; gap:8px; margin-top:9px; cursor:pointer; font-size:12.5px; color:var(--text-2); font-weight:500; }
.fill-toggle input{ accent-color:var(--accent); margin-top:1px; flex:none; }
.fill-toggle .hint{ display:block; font-weight:400; margin-top:1px; margin-left:0; }
.fill-toggle.is-busy{ opacity:.55; cursor:wait; }

/* ---------- PRINTABILITY ---------- */
.risk{ display:flex; flex-direction:column; gap:6px; margin-top:4px; }
.risk-row{
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "colorplate"
version = "0.4.0"
version = "0.5.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"
Expand Down
Loading
Loading