diff --git a/.github/workflows/enforce-main-source-branch.yml b/.github/workflows/enforce-main-source-branch.yml
new file mode 100644
index 0000000..e0d976c
--- /dev/null
+++ b/.github/workflows/enforce-main-source-branch.yml
@@ -0,0 +1,25 @@
+name: Enforce main source branch
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - edited
+ - ready_for_review
+
+permissions: {}
+
+jobs:
+ require_develop_into_main:
+ name: Require develop into main
+ runs-on: ubuntu-latest
+ steps:
+ - name: Only allow develop into main
+ if: ${{ github.head_ref != 'develop' || github.event.pull_request.head.repo.full_name != github.repository }}
+ run: |
+ echo "Pull requests into main must come from the develop branch in this repository."
+ exit 1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index edffcde..9c1cda3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
All notable changes to Kromacut are documented in this file.
+## v3.1.0 - unreleased
+
+### Added
+
+- **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. The selected mode persists across page refreshes.
+- **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping.
+- **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup.
+- **Next-best-color suggestion** — "Suggest next filament" button in the Auto-paint panel recommends the single filament addition that would most reduce the average color error (ΔE) across the image. The result card shows the suggested hex color, a recommended starting TD, an estimated ΔE improvement, the proportion of image pixels that benefit, and an isolation score. Clicking "Add to filaments" inserts the suggestion directly into the filament list with a `Kromacut-Suggestion-NN` name. This is an inventory-planning heuristic — re-run auto-paint after adding the suggestion to see the actual result.
+
+### Changed
+
+- **Header settings dialog** - Replaced the standalone theme toggle with a centered settings dialog that contains compact System, Dark, and Light theme options plus the current app version.
+- **SEO-friendly docs URLs** - Documentation now uses real `/docs/...` URLs with per-page metadata, generated static HTML pages, a sitemap, and robots.txt output.
+
+### Fixed
+
## v3.0.0 - 2026-06-01
### Added
diff --git a/README.md b/README.md
index b0a74b8..053e906 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# Kromacut
-[](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.youtube.com/@vycdev) [](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut) [](https://github.com/vycdev/Kromacut/releases) [](https://github.com/vycdev/Kromacut/releases/latest)
+[](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.youtube.com/@vycdev) [](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut) [](https://github.com/vycdev/Kromacut/releases) [](https://github.com/vycdev/Kromacut/releases/latest)
Open-source HueForge-style tool for converting images into stacked, color-layered 3D prints.
@@ -176,6 +176,7 @@ Region weighting is most useful when filament budget is limited and you want the
| **Enhanced color matching** | Optimizes filament ordering for best color reproduction rather than simple luminance sorting. Uses advanced algorithms (exhaustive, simulated annealing, genetic) automatically selected based on filament count. Scoring considers weighted DeltaE accuracy, height spread, layer count, and transition waste. |
| **Allow repeated filament swaps** | (Requires Enhanced color matching) Allows a filament to appear more than once in the stack. This creates intermediate blended colors — for example, a thin white layer over red produces pink. The algorithm greedily inserts up to 4 extra swaps, each at the position that best improves the score. |
| **Height dithering** | (Requires Enhanced color matching) Applies block-aware Floyd-Steinberg error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. |
+| **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. |
| **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. |
| **Optimizer algorithm** | Choose which optimization algorithm to use: Auto (recommended), Exhaustive, Simulated Annealing, or Genetic. Auto selects the best algorithm based on search space size. |
| **Optimizer seed** | Set a random seed for reproducible optimizer results. Leave blank for random behavior. Useful for testing and comparing configurations. |
@@ -203,6 +204,24 @@ When auto-paint is active and filaments are defined, the UI displays a **Transit
- A **compressed** badge on zones that have been reduced below their ideal thickness due to a Max Height constraint.
- Total model height and total number of physical layers.
+### Next-best-color suggestion
+
+After running auto-paint, a **Suggest next filament** button appears at the bottom of the panel. Click it to run a blend-aware analysis that finds the single filament addition that would most reduce the average color error (ΔE) across the image.
+
+The result card shows:
+
+| Field | Description |
+|---|---|
+| **Hex** | Suggested filament color. |
+| **Est. ΔE** | Estimated reduction in blend-aware average ΔE if this filament is added. A rough relative estimate — not a calibration confidence rating. |
+| **TD** | Recommended starting Transmission Distance, borrowed from the nearest existing filament by color distance. Adjust after printing a test patch. |
+| **Captures** | Percentage of image pixels whose blend-aware color error would improve with this filament. |
+| **Isolation** | How far this color sits from existing filaments in perceptual color space (0–1). Higher means it fills a more distinct gap; lower means it overlaps territory already covered by blending. |
+
+Click **Add to filaments** to insert the suggestion directly into the filament list (named `Kromacut-Suggestion-01`, `02`, etc.). You can then re-run auto-paint with the expanded set and repeat as many times as needed.
+
+**How it works (heuristic):** The algorithm estimates how well the current filament set covers the image's color range by checking each image color against each filament and Beer-Lambert blend curve. It identifies underserved colors (those with the largest estimated error) and generates candidates from those colors plus extrapolated positions — colors that, when optically blended with an existing filament, would be closer to the underserved target. The winner is the candidate whose addition most reduces weighted-average estimated error across all image pixels. This is an inventory-planning heuristic: the improvement numbers are relative estimates, not predictions of a specific auto-paint result.
+
### Quick start
1. Load an image into Kromacut.
diff --git a/content/autopaint-3d-mode.png b/content/autopaint-3d-mode.png
new file mode 100644
index 0000000..3c9c16c
Binary files /dev/null and b/content/autopaint-3d-mode.png differ
diff --git a/index.html b/index.html
index a3de29a..10f08e3 100644
--- a/index.html
+++ b/index.html
@@ -7,6 +7,7 @@
+
diff --git a/package-lock.json b/package-lock.json
index e60579f..e26282c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kromacut",
- "version": "3.0.0",
+ "version": "3.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kromacut",
- "version": "3.0.0",
+ "version": "3.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/package.json b/package.json
index 06dac15..9939a14 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
{
"name": "kromacut",
"private": true,
- "version": "3.0.0",
+ "version": "3.1.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "tsc -b && vite build && node scripts/generate-docs-seo-pages.mjs",
"test": "node --no-warnings --experimental-strip-types tests/run-tests.ts",
"test:e2e": "playwright test --grep @smoke",
"test:e2e:matrix": "playwright test --grep @matrix",
diff --git a/public/version.json b/public/version.json
index e51b2fb..3802684 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,5 +1,5 @@
{
- "version": "3.0.0",
+ "version": "3.1.0",
"download_url": "https://github.com/vycdev/Kromacut/releases/latest",
- "release_notes": "Major release with AGPL licensing, in-app docs, image resize, smooth meshing improvements, and export fixes"
+ "release_notes": "Kromacut 3.1.0 adds orthographic preview mode, experimental Flat Paint, desktop update settings, and next-best-color filament suggestions."
}
diff --git a/scripts/generate-docs-seo-pages.mjs b/scripts/generate-docs-seo-pages.mjs
new file mode 100644
index 0000000..324095e
--- /dev/null
+++ b/scripts/generate-docs-seo-pages.mjs
@@ -0,0 +1,395 @@
+import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..');
+const docsDir = path.join(rootDir, 'src', 'docs');
+const distDir = path.join(rootDir, 'dist');
+const distIndexPath = path.join(distDir, 'index.html');
+const siteUrl = 'https://kromacut.com';
+const socialImageUrl = `${siteUrl}/android-chrome-512x512.png`;
+
+function escapeHtml(value) {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function slugify(value) {
+ return value
+ .toLowerCase()
+ .trim()
+ .replace(/['"]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+function createSlugger() {
+ const seen = new Map();
+ return (value) => {
+ const base = slugify(value) || 'section';
+ const count = seen.get(base) ?? 0;
+ seen.set(base, count + 1);
+ return count === 0 ? base : `${base}-${count + 1}`;
+ };
+}
+
+function parseFrontmatter(raw) {
+ const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ if (!normalized.startsWith('---\n')) {
+ return { attributes: {}, body: normalized.trim() };
+ }
+
+ const end = normalized.indexOf('\n---', 4);
+ if (end === -1) {
+ return { attributes: {}, body: normalized.trim() };
+ }
+
+ const attributes = {};
+ normalized
+ .slice(4, end)
+ .split('\n')
+ .forEach((line) => {
+ const separator = line.indexOf(':');
+ if (separator === -1) return;
+ const key = line.slice(0, separator).trim();
+ const value = line
+ .slice(separator + 1)
+ .trim()
+ .replace(/^["']|["']$/g, '');
+ if (key) attributes[key] = value;
+ });
+
+ return {
+ attributes,
+ body: normalized.slice(end + 4).trim(),
+ };
+}
+
+function parseDocs() {
+ return readdirSync(docsDir)
+ .filter((file) => file.endsWith('.md'))
+ .map((file) => {
+ const raw = readFileSync(path.join(docsDir, file), 'utf8');
+ const { attributes, body } = parseFrontmatter(raw);
+ const title = attributes.title ?? body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? 'Untitled';
+ const slug = attributes.slug ?? slugify(title);
+ const order = Number.parseInt(attributes.order ?? '999', 10);
+ return {
+ file,
+ body,
+ title,
+ slug,
+ description: attributes.description ?? `${title} documentation for Kromacut.`,
+ order: Number.isFinite(order) ? order : 999,
+ };
+ })
+ .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
+}
+
+function findBuiltAsset(prefix) {
+ const assetsDir = path.join(distDir, 'assets');
+ if (!existsSync(assetsDir)) return undefined;
+ const match = readdirSync(assetsDir).find((file) => file.startsWith(prefix));
+ return match ? `/assets/${match}` : undefined;
+}
+
+function resolveDocImage(src) {
+ const clean = src
+ .trim()
+ .replace(/^\.?\//, '')
+ .split(/\s+/)[0]
+ .replace(/^["']|["']$/g, '');
+
+ if (clean.includes('td-test.png')) {
+ return findBuiltAsset('tdTest-') ?? clean;
+ }
+ if (clean.includes('kromacut-logo.png')) {
+ return findBuiltAsset('logo-') ?? clean;
+ }
+ return clean;
+}
+
+function resolveDocHref(href, currentDocSlug, docsBySlug) {
+ const trimmed = href.trim();
+ if (!trimmed) return '#';
+ if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
+ if (trimmed.startsWith('#')) return trimmed;
+
+ const [docPart, ...headingParts] = trimmed.split('#');
+ const docSlug = docPart
+ .replace(/^\.?\//, '')
+ .replace(/\.md$/i, '')
+ .replace(/^docs\//, '');
+ const heading = headingParts.join('#');
+
+ if (!docSlug) {
+ return heading ? `#${encodeURIComponent(heading)}` : `/docs/${currentDocSlug}`;
+ }
+ if (!docsBySlug.has(docSlug)) return '#';
+
+ return `/docs/${encodeURIComponent(docSlug)}${heading ? `#${encodeURIComponent(heading)}` : ''}`;
+}
+
+function renderInline(markdown, currentDocSlug, docsBySlug) {
+ let html = escapeHtml(markdown);
+
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, rawSrc) => {
+ const [srcPart, titlePart] = rawSrc.trim().split(/\s+["']/);
+ const title = titlePart ? titlePart.replace(/["']$/, '') : '';
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
+ return ``;
+ });
+
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => {
+ const resolved = resolveDocHref(href, currentDocSlug, docsBySlug);
+ const externalAttrs = /^(https?:|mailto:)/i.test(resolved)
+ ? ' target="_blank" rel="noopener noreferrer"'
+ : '';
+ return `${renderInline(label, currentDocSlug, docsBySlug)}`;
+ });
+
+ html = html.replace(/`([^`]+)`/g, '
$1');
+ html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
+ html = html.replace(/__([^_]+)__/g, '$1');
+ html = html.replace(/\*([^*]+)\*/g, '$1');
+ html = html.replace(/_([^_]+)_/g, '$1');
+
+ return html;
+}
+
+function isBlockStart(line) {
+ const trimmed = line.trim();
+ return (
+ !trimmed ||
+ /^#{1,6}\s+/.test(trimmed) ||
+ /^([-*+]|\d+[.)])\s+/.test(trimmed) ||
+ /^>\s?/.test(trimmed) ||
+ /^```/.test(trimmed) ||
+ /^-{3,}$|^\*{3,}$|^_{3,}$/.test(trimmed) ||
+ (trimmed.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line))
+ );
+}
+
+function splitTableRow(line) {
+ return line
+ .trim()
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map((cell) => cell.trim());
+}
+
+function renderMarkdown(doc, docsBySlug) {
+ const lines = doc.body.split('\n');
+ const slugger = createSlugger();
+ const html = [];
+ let index = 0;
+
+ while (index < lines.length) {
+ const line = lines[index];
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ index++;
+ continue;
+ }
+
+ if (trimmed.startsWith('```')) {
+ const codeLines = [];
+ index++;
+ while (index < lines.length && !lines[index].trim().startsWith('```')) {
+ codeLines.push(lines[index]);
+ index++;
+ }
+ if (index < lines.length) index++;
+ html.push(`
${escapeHtml(codeLines.join('\n'))}`);
+ continue;
+ }
+
+ const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*$/);
+ if (heading) {
+ const depth = heading[1].length;
+ const text = heading[2].trim();
+ html.push(
+ `${renderMarkdown({ ...doc, body: parts.join('\n') }, docsBySlug)}`); + continue; + } + + if ( + line.includes('|') && + lines[index + 1] && + /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1]) + ) { + const headers = splitTableRow(line); + const rows = []; + index += 2; + while (index < lines.length && lines[index].includes('|') && lines[index].trim()) { + rows.push(splitTableRow(lines[index])); + index++; + } + html.push( + `
| ${renderInline(cell, doc.slug, docsBySlug)} | `) + .join('')}
|---|
| ${renderInline(cell, doc.slug, docsBySlug)} | `) + .join('')}
${renderInline(paragraphLines.join(' '), doc.slug, docsBySlug)}
`); + } + + return html.join('\n'); +} + +function replaceOrInsertHeadTag(html, selector, replacement) { + if (selector.test(html)) return html.replace(selector, replacement); + return html.replace('', ` ${replacement}\n `); +} + +function updateMeta(html, attribute, key, content) { + const escaped = escapeHtml(content); + const pattern = new RegExp(`]*${attribute}="${key}"[^>]*>`, 's'); + return replaceOrInsertHeadTag(html, pattern, ``); +} + +function updateDocHead(template, doc) { + const title = `${doc.title} | Kromacut Docs`; + const url = `${siteUrl}/docs/${doc.slug}`; + let html = template.replace(/+ Current filament set already covers all image colors well. +
+ )} + {nextBestError && ( ++ {nextBestError} +
+ )} +- Smooth connected color boundary edges with fast welded topology + Smooth connected color boundary edges with fast welded topology. + Turning this on disables Flat Paint.