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 -[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total)](https://github.com/vycdev/Kromacut/releases/latest) +[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](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 `${escapeHtml(alt)}`; + }); + + 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( + `${renderInline(text, doc.slug, docsBySlug)}` + ); + index++; + continue; + } + + if (/^-{3,}$|^\*{3,}$|^_{3,}$/.test(trimmed)) { + html.push('
'); + index++; + continue; + } + + if (trimmed.startsWith('>')) { + const parts = []; + while (index < lines.length && lines[index].trim().startsWith('>')) { + parts.push(lines[index].replace(/^\s*>\s?/, '')); + index++; + } + 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( + `${headers + .map((cell) => ``) + .join('')}${rows + .map( + (row) => + `${row + .map((cell) => ``) + .join('')}` + ) + .join('')}
${renderInline(cell, doc.slug, docsBySlug)}
${renderInline(cell, doc.slug, docsBySlug)}
` + ); + continue; + } + + const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/); + if (listMatch) { + const ordered = /^\d/.test(listMatch[2]); + const tag = ordered ? 'ol' : 'ul'; + const items = []; + while (index < lines.length) { + const itemMatch = lines[index].match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/); + if (!itemMatch || /^\d/.test(itemMatch[2]) !== ordered) break; + items.push(`
  • ${renderInline(itemMatch[3], doc.slug, docsBySlug)}
  • `); + index++; + } + html.push(`<${tag}>${items.join('')}`); + continue; + } + + const paragraphLines = [trimmed]; + index++; + while (index < lines.length && !isBlockStart(lines[index])) { + paragraphLines.push(lines[index].trim()); + index++; + } + html.push(`

    ${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(/.*?<\/title>/, `<title>${escapeHtml(title)}`); + + html = updateMeta(html, 'name', 'description', doc.description); + html = html.replace( + //, + `` + ); + html = updateMeta(html, 'property', 'og:title', title); + html = updateMeta(html, 'property', 'og:description', doc.description); + html = updateMeta(html, 'property', 'og:type', 'article'); + html = updateMeta(html, 'property', 'og:url', url); + html = updateMeta(html, 'property', 'og:image', socialImageUrl); + html = updateMeta(html, 'property', 'og:image:secure_url', socialImageUrl); + html = updateMeta(html, 'name', 'twitter:title', title); + html = updateMeta(html, 'name', 'twitter:description', doc.description); + html = updateMeta(html, 'name', 'twitter:image', socialImageUrl); + + return html; +} + +function renderStaticRoot(doc, docs, docsBySlug) { + const nav = docs + .map((entry) => `
  • ${escapeHtml(entry.title)}
  • `) + .join(''); + const article = renderMarkdown(doc, docsBySlug); + + return `
    +
    + +
    + ${article} +
    +
    +
    `; +} + +function generateDocPage(template, doc, docs, docsBySlug) { + return updateDocHead(template, doc).replace( + /
    <\/div>/, + renderStaticRoot(doc, docs, docsBySlug) + ); +} + +function writeDocPage(template, doc, docs, docsBySlug, slug = doc.slug) { + const outputDir = path.join(distDir, 'docs', slug); + const docPageHtml = generateDocPage(template, doc, docs, docsBySlug); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'index.html'), docPageHtml); + + if (slug) { + writeFileSync(path.join(distDir, 'docs', `${slug}.html`), docPageHtml); + } +} + +function writeSitemap(docs) { + const urls = ['/', ...docs.map((doc) => `/docs/${doc.slug}`)]; + const body = urls + .map((url) => ` ${siteUrl}${url === '/' ? '/' : url}`) + .join('\n'); + writeFileSync( + path.join(distDir, 'sitemap.xml'), + `\n\n${body}\n\n` + ); +} + +function writeRobots() { + writeFileSync( + path.join(distDir, 'robots.txt'), + `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n` + ); +} + +if (!existsSync(distIndexPath)) { + throw new Error('dist/index.html was not found. Run this script after vite build.'); +} + +const docs = parseDocs(); +const docsBySlug = new Map(docs.map((doc) => [doc.slug, doc])); +const template = readFileSync(distIndexPath, 'utf8'); +const overviewDoc = docsBySlug.get('overview') ?? docs[0]; + +docs.forEach((doc) => writeDocPage(template, doc, docs, docsBySlug)); +if (overviewDoc) writeDocPage(template, overviewDoc, docs, docsBySlug, ''); +writeSitemap(docs); +writeRobots(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6dc91e..99efa6e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1907,7 +1907,7 @@ dependencies = [ [[package]] name = "kromacut" -version = "3.0.0" +version = "3.1.0" dependencies = [ "log", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 83e6f1a..3be2eaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kromacut" -version = "3.0.0" +version = "3.1.0" description = "Kromacut - Multi-color lithophane 3D print generator" authors = ["vycdev"] license = "AGPL-3.0-only" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 39c2ef7..3405864 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Kromacut", - "version": "3.0.0", + "version": "3.1.0", "identifier": "com.kromacut.lithophane", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 865a675..aab12b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,9 @@ import { UpdateChecker } from './components/UpdateChecker'; import ProgressOverlay from './components/ProgressOverlay'; import DocsPage from './components/docs/DocsPage'; import { defaultDocSlug } from './docs'; -import { buildDocsHash, parseDocsHash } from './lib/docs/navigation'; +import { loadCameraMode, saveCameraMode } from './lib/cameraPrefs'; +import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; +import { applyHomeSeo } from './lib/seo'; import { AlertDialog, AlertDialogContent, @@ -78,6 +80,7 @@ type AutoPaintPersisted = Pick< | 'allowRepeatedSwaps' | 'heightDithering' | 'ditherLineWidth' + | 'flatPaint' >; const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { @@ -104,6 +107,7 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, + flatPaint: parsed.flatPaint ?? false, }; } catch { return null; @@ -197,7 +201,8 @@ function App(): React.ReactElement | null { const [adjustmentsEpoch, setAdjustmentsEpoch] = useState(0); // UI mode toggles (2D / 3D) - UI only for now const [mode, setMode] = useState<'2d' | '3d'>('2d'); - const [docsOpen, setDocsOpen] = useState(() => parseDocsHash(window.location.hash) !== null); + const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); + const [isOrtho, setIsOrtho] = useState(loadCameraMode); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -211,11 +216,15 @@ function App(): React.ReactElement | null { threeDState, setThreeDState, threeDBuildSignal, + builtThreeDState, + builtFlatPaint, buildWarning, handleThreeDStateChange, confirmBuild, cancelBuild, } = useBuildWarning({ imageSrc }); + const builtModelState = builtThreeDState ?? threeDState; + const builtModelAutoPaint = builtModelState.paintMode === 'autopaint'; // Hydrate threeDState once with persisted autopaint data const [autopaintHydrated] = useState(() => { @@ -236,6 +245,7 @@ function App(): React.ReactElement | null { allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, + flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, })); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -254,6 +264,7 @@ function App(): React.ReactElement | null { allowRepeatedSwaps: threeDState.allowRepeatedSwaps, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, + flatPaint: threeDState.flatPaint, }); }, [ threeDState.filaments, @@ -265,6 +276,7 @@ function App(): React.ReactElement | null { threeDState.allowRepeatedSwaps, threeDState.heightDithering, threeDState.ditherLineWidth, + threeDState.flatPaint, ]); // No auto-build on tab switch — the user must click "Build 3D Model" / "Apply Changes". @@ -279,22 +291,28 @@ function App(): React.ReactElement | null { }, [imageSrc]); useEffect(() => { - const syncDocsHash = () => { - const target = parseDocsHash(window.location.hash); + const syncDocsLocation = () => { + const target = parseDocsLocation(window.location); setDocsOpen(target !== null); }; - window.addEventListener('hashchange', syncDocsHash); - return () => window.removeEventListener('hashchange', syncDocsHash); + window.addEventListener('hashchange', syncDocsLocation); + window.addEventListener('popstate', syncDocsLocation); + return () => { + window.removeEventListener('hashchange', syncDocsLocation); + window.removeEventListener('popstate', syncDocsLocation); + }; }, []); + useEffect(() => { + if (!docsOpen) { + applyHomeSeo(); + } + }, [docsOpen]); + const backToApp = () => { setDocsOpen(false); - if (parseDocsHash(window.location.hash)) { - window.history.pushState( - null, - '', - `${window.location.pathname}${window.location.search}` - ); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); } }; @@ -305,8 +323,8 @@ function App(): React.ReactElement | null { } setDocsOpen(true); - if (!parseDocsHash(window.location.hash)) { - window.history.pushState(null, '', buildDocsHash(defaultDocSlug)); + if (!parseDocsLocation(window.location)) { + window.history.pushState(null, '', buildDocsPath(defaultDocSlug)); } }; @@ -403,11 +421,11 @@ function App(): React.ReactElement | null { exportObjectToStlBlob, exportObjectTo3MFBlob: (obj, onProgress, onZipProgress) => exportObjectTo3MFBlob(obj, { - layerHeight: threeDState.layerHeight, - firstLayerHeight: threeDState.slicerFirstLayerHeight, + layerHeight: builtModelState.layerHeight, + firstLayerHeight: builtModelState.slicerFirstLayerHeight, layerFilamentColors: - threeDState.paintMode === 'autopaint' - ? threeDState.autoPaintFilamentSwatches?.map((s) => s.hex) + builtModelAutoPaint + ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) : undefined, onProgress, onZipProgress, @@ -428,12 +446,8 @@ function App(): React.ReactElement | null { invalidate(); setImage(tdTestImg, true); setMode('2d'); - if (parseDocsHash(window.location.hash)) { - window.history.pushState( - null, - '', - `${window.location.pathname}${window.location.search}` - ); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); } setDocsOpen(false); }} @@ -600,6 +614,8 @@ function App(): React.ReactElement | null { setThreeDState((prev) => ({ ...prev, ...partial })) @@ -646,31 +662,33 @@ function App(): React.ReactElement | null { {exportingSTL && ( + setIsOrtho((v) => { + const next = !v; + saveCameraMode(next); + return next; + }) + } />
    diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index c01e1d6..5f93e40 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -32,6 +32,7 @@ import type { CalibrationResult } from '../lib/calibration'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; +import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker'; interface AutoPaintSliceData { virtualSwatches: Swatch[]; @@ -44,6 +45,7 @@ interface AutoPaintTabProps { // Filament state filaments: Filament[]; addFilament: () => void; + addFilamentWithProps: (props: { color: string; td: number; name: string }) => void; removeFilament: (id: string) => void; updateFilament: (id: string, updates: Partial>) => void; @@ -81,6 +83,7 @@ interface AutoPaintTabProps { // Image colors filteredCount: number; + imageSwatches: Array<{ hex: string; count?: number }>; // Enhanced matching options enhancedColorMatch: boolean; @@ -92,6 +95,10 @@ interface AutoPaintTabProps { ditherLineWidth: number; setDitherLineWidth: (v: number) => void; + // Flat Paint (flat face-down print) + flatPaint: boolean; + setFlatPaint: (v: boolean) => void; + // Optimizer options optimizerAlgorithm: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; setOptimizerAlgorithm: (v: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto') => void; @@ -104,6 +111,7 @@ interface AutoPaintTabProps { export default function AutoPaintTab({ filaments, addFilament, + addFilamentWithProps, removeFilament, updateFilament, profiles, @@ -134,6 +142,7 @@ export default function AutoPaintTab({ error, calibrationLayerHeight, filteredCount, + imageSwatches, enhancedColorMatch, setEnhancedColorMatch, allowRepeatedSwaps, @@ -142,6 +151,8 @@ export default function AutoPaintTab({ setHeightDithering, ditherLineWidth, setDitherLineWidth, + flatPaint, + setFlatPaint, optimizerAlgorithm, setOptimizerAlgorithm, optimizerSeed, @@ -149,6 +160,18 @@ export default function AutoPaintTab({ regionWeightingMode, setRegionWeightingMode, }: AutoPaintTabProps) { + const { + result: nextBestResult, + isComputing: isNextBestComputing, + error: nextBestError, + requestSuggestion: requestNextBestSuggestion, + reset: resetNextBestSuggestion, + } = useNextBestColorWorker(); + const suggestionCountRef = React.useRef(0); + + React.useEffect(() => { + resetNextBestSuggestion(); + }, [filaments, imageSwatches, resetNextBestSuggestion]); const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState( ditherLineWidth.toString() ); @@ -596,6 +619,27 @@ export default function AutoPaintTab({ )} + {/* Flat Paint */} + {filaments.length > 0 && ( +
    +
    +
    + + +
    +
    + )} + {/* Optimizer Settings */} {filaments.length > 0 && (
    )} + + {/* Next-best-color suggestion */} + {autoPaintResult && imageSwatches.length > 0 && ( +
    + + {nextBestResult?.candidate && ( +
    +
    + + + {nextBestResult.candidate.hex.toUpperCase()} + + + Est. ΔE{' '} + + +{nextBestResult.candidate.improvementPct.toFixed(1)}% + + +
    +
    + + TD:{' '} + + {nextBestResult.candidate.td.toFixed(2)} + + + + Captures:{' '} + + {( + (nextBestResult.candidate.pixelsCaptured / + nextBestResult.totalPixels) * + 100 + ).toFixed(1)} + % + + + + Isolation:{' '} + + {nextBestResult.candidate.isolationScore.toFixed(2)} + + +
    + +
    + )} + {nextBestResult && !nextBestResult.candidate && ( +

    + Current filament set already covers all image colors well. +

    + )} + {nextBestError && ( +

    + {nextBestError} +

    + )} +
    + )}
    diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3b7e7e1..6cdcd5c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,45 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, BookOpen, Image, Github, Heart, Moon, Sun, MessageCircle } from 'lucide-react'; +import { + AlertCircle, + ArrowLeft, + BookOpen, + CheckCircle2, + Download, + Image, + Github, + Heart, + Loader2, + Moon, + Sun, + MessageCircle, + RefreshCw, + Settings, + X, + Monitor, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Switch } from '@/components/ui/switch'; +import { + checkForDesktopUpdates, + isDesktopUpdateSupported, + openDesktopReleasesPage, + type VersionInfo, +} from '@/lib/desktopUpdates'; +import { + applyResolvedTheme, + applyThemeMode, + getStoredThemeMode, + saveThemeMode, + subscribeToSystemTheme, + THEME_STORAGE_KEY, + type ThemeMode, +} from '@/lib/theme'; +import { + getUpdateCheckOnStartup, + saveUpdateCheckOnStartup, + subscribeToUpdateCheckOnStartup, +} from '@/lib/updatePreferences'; import logo from '../assets/logo.png'; interface Props { @@ -10,21 +49,103 @@ interface Props { onToggleDocs: () => void; } +const appVersion = __APP_VERSION__; +type UpdateCheckStatus = 'idle' | 'checking' | 'available' | 'current' | 'error'; + export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onToggleDocs }) => { - const [isDark, setIsDark] = React.useState(() => { - return document.documentElement.classList.contains('dark'); - }); - - const toggleTheme = () => { - const root = document.documentElement; - if (isDark) { - root.classList.remove('dark'); - localStorage.setItem('theme', 'light'); - setIsDark(false); - } else { - root.classList.add('dark'); - localStorage.setItem('theme', 'dark'); - setIsDark(true); + const [themeMode, setThemeMode] = React.useState(() => getStoredThemeMode()); + const [settingsOpen, setSettingsOpen] = React.useState(false); + const [checkOnStartup, setCheckOnStartup] = React.useState(() => getUpdateCheckOnStartup()); + const [updateStatus, setUpdateStatus] = React.useState('idle'); + const [availableUpdate, setAvailableUpdate] = React.useState(null); + const [updateError, setUpdateError] = React.useState(''); + const settingsTitleId = React.useId(); + const updateStartupSwitchId = React.useId(); + const isDesktopApp = isDesktopUpdateSupported(); + + React.useEffect(() => { + if (!settingsOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSettingsOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [settingsOpen]); + + React.useEffect(() => { + applyThemeMode(themeMode); + + if (themeMode !== 'system') { + return; + } + + return subscribeToSystemTheme((resolvedTheme) => { + applyResolvedTheme(resolvedTheme); + }); + }, [themeMode]); + + React.useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === THEME_STORAGE_KEY) { + setThemeMode(getStoredThemeMode()); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + + React.useEffect(() => { + if (!isDesktopUpdateSupported()) return; + + return subscribeToUpdateCheckOnStartup(setCheckOnStartup); + }, []); + + React.useEffect(() => { + if (settingsOpen) return; + + setUpdateStatus('idle'); + setAvailableUpdate(null); + setUpdateError(''); + }, [settingsOpen]); + + const setTheme = (nextThemeMode: ThemeMode) => { + saveThemeMode(nextThemeMode); + setThemeMode(nextThemeMode); + }; + + const setStartupUpdateChecks = (enabled: boolean) => { + saveUpdateCheckOnStartup(enabled); + setCheckOnStartup(enabled); + }; + + const handleCheckForUpdates = async () => { + setUpdateStatus('checking'); + setAvailableUpdate(null); + setUpdateError(''); + + try { + const updateInfo = await checkForDesktopUpdates(); + setAvailableUpdate(updateInfo); + setUpdateStatus(updateInfo ? 'available' : 'current'); + } catch (error) { + console.error('Failed to check for updates:', error); + setUpdateError('Could not check for updates. Try again later.'); + setUpdateStatus('error'); + } + }; + + const handleDownloadUpdate = async () => { + try { + await openDesktopReleasesPage(); + } catch (error) { + console.error('Failed to open releases page:', error); + setUpdateError('Could not open the download page.'); + setUpdateStatus('error'); } }; @@ -126,13 +247,188 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT
    + {settingsOpen && ( +
    setSettingsOpen(false)} + > +
    event.stopPropagation()} + > +
    +

    + Settings +

    + +
    + +
    +
    Theme
    +
    + + + +
    +
    + + {isDesktopApp && ( +
    +
    +
    + Updates +
    + +
    + +
    +
    + + +
    +
    + +
    + {updateStatus === 'available' && availableUpdate && ( +
    +
    + +
    +
    + Version {availableUpdate.version} is available +
    + {availableUpdate.release_notes && ( +
    + {availableUpdate.release_notes} +
    + )} +
    + +
    +
    + )} + + {updateStatus === 'current' && ( +
    + + Kromacut is up to date. +
    + )} + + {updateStatus === 'error' && ( +
    + + {updateError} +
    + )} +
    +
    + )} + +
    + Kromacut + v{appVersion} +
    +
    +
    + )} ); }; diff --git a/src/components/PreviewActions.tsx b/src/components/PreviewActions.tsx index 86c020a..fbe504b 100644 --- a/src/components/PreviewActions.tsx +++ b/src/components/PreviewActions.tsx @@ -14,6 +14,8 @@ import { Trash2, FileBox, FileType, + Box, + Camera, } from 'lucide-react'; export interface PreviewActionsProps { @@ -36,6 +38,10 @@ export interface PreviewActionsProps { onExportImage: () => Promise; onExportStl: () => Promise; onExport3MF: () => Promise; + /** The currently built model is a Flat Paint slab — STL export is useless for it */ + flatPaintModel?: boolean; + isOrtho?: boolean; + onToggleCamera?: () => void; } export const PreviewActions: React.FC = ({ @@ -58,9 +64,23 @@ export const PreviewActions: React.FC = ({ onExportImage, onExportStl, onExport3MF, + flatPaintModel = false, + isOrtho = false, + onToggleCamera, }) => { return (
    + {mode === '3d' && onToggleCamera && ( + + )} - + {/* Flat Paint slabs carry their colors as per-filament 3MF + objects; a single-geometry STL of the slab is useless */} + {!flatPaintModel && ( + + )}
    - {/* Start Color */} -
    -
    Start with Color
    - {tooManyColors ? ( -
    - — + {/* Flat Paint: no manual swap sequence — slicer assigns filaments */} + {flatPaint ? ( +
    +
    + Flat Paint multi-material print
    - ) : swapPlan.length && swapPlan[0].type === 'start' ? ( - (() => { - const sw = swapPlan[0].swatch; - return ( -
    - - - {sw.hex} - +
      +
    • + Export as 3MF — the model + contains one object per filament. Assign each object to its filament + in the slicer (AMS/toolchanger required). +
    • +
    • + Use clear filament for the + transparent carrier object — it prints first and becomes the smooth + viewing face. +
    • +
    • + Print as-is — the artwork is already mirrored for face-down + printing. Do not mirror in the slicer. +
    • +
    • After printing, flip the piece over to view the image.
    • +
    +
    + ) : ( + <> + {/* Start Color */} +
    +
    + Start with Color +
    + {tooManyColors ? ( +
    + — +
    + ) : swapPlan.length && swapPlan[0].type === 'start' ? ( + (() => { + const sw = swapPlan[0].swatch; + return ( +
    + + + {sw.hex} + +
    + ); + })() + ) : ( +
    + —
    - ); - })() - ) : ( -
    - — + )}
    - )} -
    - {/* Color Swap Plan */} -
    -
    Color Swap Plan
    - {tooManyColors ? ( -
    - Swap instructions are disabled for very large palettes ({colorCount}{' '} - colors). Reduce the image to 64 colors or fewer in 2D mode first. -
    - ) : swapPlan.length <= 1 ? ( -
    - Only one color configured — no swaps needed. -
    - ) : ( -
      - {swapPlan.map((entry, idx) => { - if (entry.type === 'start') return null; - return ( -
    1. - - {idx}. - -
      -
      - Swap to - - - {entry.swatch.hex} - -
      -
      - at layer{' '} - - {entry.layer} - {' '} - (~ - - {entry.height.toFixed(3)} mm + {/* Color Swap Plan */} +
      +
      + Color Swap Plan +
      + {tooManyColors ? ( +
      + Swap instructions are disabled for very large palettes ( + {colorCount} colors). Reduce the image to 64 colors or fewer in + 2D mode first. +
      + ) : swapPlan.length <= 1 ? ( +
      + Only one color configured — no swaps needed. +
      + ) : ( +
        + {swapPlan.map((entry, idx) => { + if (entry.type === 'start') return null; + return ( +
      1. + + {idx}. - ) -
      -
      -
    2. - ); - })} -
    - )} -
    +
    +
    + Swap to + + + {entry.swatch.hex} + +
    +
    + at layer{' '} + + {entry.layer} + {' '} + (~ + + {entry.height.toFixed(3)} mm + + ) +
    +
    + + ); + })} + + )} +
    + + )}
    ℹ️{' '} @@ -166,4 +202,4 @@ export default function PrintInstructions({
    ); -} \ No newline at end of file +} diff --git a/src/components/PrintSettingsCard.tsx b/src/components/PrintSettingsCard.tsx index 1e0f3f1..713e0fd 100644 --- a/src/components/PrintSettingsCard.tsx +++ b/src/components/PrintSettingsCard.tsx @@ -248,7 +248,8 @@ export default function PrintSettingsCard({
    Smooth Meshing

    - Smooth connected color boundary edges with fast welded topology + Smooth connected color boundary edges with fast welded topology. + Turning this on disables Flat Paint.

    void; /** * Called whenever non-build settings change so the parent can keep @@ -41,9 +45,17 @@ interface ThreeDControlsProps { persisted?: ThreeDControlsStateShape | null; } -export default function ThreeDControls({ swatches, imageDimensions, onChange, onSettingsChange, persisted }: ThreeDControlsProps) { +export default function ThreeDControls({ + swatches, + imageDimensions, + builtState = null, + builtFlatPaint = false, + onChange, + onSettingsChange, + persisted, +}: ThreeDControlsProps) { // --- Filaments --- - const { filaments, setFilaments, addFilament, removeFilament, updateFilament } = useFilaments({ + const { filaments, setFilaments, addFilament, addFilamentWithProps, removeFilament, updateFilament } = useFilaments({ initial: persisted?.filaments?.length ? persisted.filaments : undefined, }); @@ -64,9 +76,16 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const initialPaintMode = persisted?.paintMode ?? 'manual'; + const initialFlatPaint = persisted?.flatPaint ?? false; + // --- Print Settings --- const [initialPrintSettings] = useState(() => { const stored = loadPrintSettingsFromStorage(); + const storedSmoothMeshing = + stored?.smoothMeshing ?? + persisted?.smoothMeshing ?? + DEFAULT_PRINT_SETTINGS.smoothMeshing; return { layerHeight: stored?.layerHeight ?? persisted?.layerHeight ?? DEFAULT_PRINT_SETTINGS.layerHeight, @@ -76,8 +95,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on DEFAULT_PRINT_SETTINGS.slicerFirstLayerHeight, pixelSize: stored?.pixelSize ?? persisted?.pixelSize ?? DEFAULT_PRINT_SETTINGS.pixelSize, - smoothMeshing: - stored?.smoothMeshing ?? persisted?.smoothMeshing ?? DEFAULT_PRINT_SETTINGS.smoothMeshing, + smoothMeshing: storedSmoothMeshing, }; }); @@ -90,14 +108,13 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on const [calibrationLayerHeight, setCalibrationLayerHeight] = useState( persisted?.calibrationLayerHeight ?? initialPrintSettings.layerHeight ); - const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>( - persisted?.paintMode ?? 'manual' - ); + const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); const [allowRepeatedSwaps, setAllowRepeatedSwaps] = useState(persisted?.allowRepeatedSwaps ?? false); const [heightDithering, setHeightDithering] = useState(persisted?.heightDithering ?? false); const [ditherLineWidth, setDitherLineWidth] = useState(persisted?.ditherLineWidth ?? 0.42); + const [flatPaint, setFlatPaint] = useState(initialFlatPaint); // --- Optimizer Options --- const [optimizerAlgorithm, setOptimizerAlgorithm] = useState<'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'>( @@ -124,6 +141,20 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on } }, []); + const flatPaintActive = paintMode === 'autopaint' && flatPaint; + const effectiveSmoothMeshing = flatPaintActive ? false : smoothMeshing; + + const handleSmoothMeshingChange = useCallback((enabled: boolean) => { + setSmoothMeshing(enabled); + if (enabled) { + setFlatPaint(false); + } + }, []); + + const handleFlatPaintChange = useCallback((enabled: boolean) => { + setFlatPaint(enabled); + }, []); + // Sync non-build settings to parent so persisted stays current across mode switches useEffect(() => { onSettingsChange?.({ @@ -133,13 +164,14 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -210,10 +242,19 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on paintMode === 'autopaint' && autoPaintSliceData ? autoPaintSliceData.colorSliceHeights : colorSliceHeights; - const depth = estimateOrder.reduce((total, swatchIndex, position) => { - const height = estimateHeights[swatchIndex] ?? 0; - return total + (position === 0 ? Math.max(height, slicerFirstLayerHeight) : height); - }, 0); + const depth = + flatPaintActive && paintMode === 'autopaint' + ? Math.max(slicerFirstLayerHeight, layerHeight) + + estimateOrder.filter((swatchIndex) => (estimateHeights[swatchIndex] ?? 0) > 0) + .length * + layerHeight + : estimateOrder.reduce((total, swatchIndex, position) => { + const height = estimateHeights[swatchIndex] ?? 0; + return ( + total + + (position === 0 ? Math.max(height, slicerFirstLayerHeight) : height) + ); + }, 0); return { width: widthPx * pixelSize, @@ -225,27 +266,39 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on colorOrder, colorSliceHeights, imageDimensions, + flatPaintActive, + layerHeight, paintMode, pixelSize, slicerFirstLayerHeight, ]); + const instructionPaintMode = builtState?.paintMode ?? paintMode; + const instructionAutoPaintResult = builtState?.autoPaintResult ?? autoPaintResult; + const instructionColorOrder = builtState?.colorOrder ?? colorOrder; + const instructionColorSliceHeights = builtState?.colorSliceHeights ?? colorSliceHeights; + const instructionFiltered = builtState?.filteredSwatches ?? filtered; + const instructionLayerHeight = builtState?.layerHeight ?? layerHeight; + const instructionSlicerFirstLayerHeight = + builtState?.slicerFirstLayerHeight ?? slicerFirstLayerHeight; + const instructionFlatPaint = builtState ? builtFlatPaint : flatPaintActive; const instructionColorCount = - paintMode === 'autopaint' - ? autoPaintResult?.layers.length ?? 0 - : displayOrder.length; + instructionPaintMode === 'autopaint' + ? instructionAutoPaintResult?.layers.length ?? 0 + : instructionColorOrder.length; const isInstructionOverLimit = instructionColorCount > 64; // --- Swap Plan --- const { swapPlan, copied, copyToClipboard } = useSwapPlan({ - colorOrder, - colorSliceHeights, - filtered, - layerHeight, - slicerFirstLayerHeight, - paintMode, - autoPaintResult, + colorOrder: instructionColorOrder, + colorSliceHeights: instructionColorSliceHeights, + filtered: instructionFiltered, + layerHeight: instructionLayerHeight, + slicerFirstLayerHeight: instructionSlicerFirstLayerHeight, + paintMode: instructionPaintMode, + autoPaintResult: instructionAutoPaintResult, disabled: isInstructionOverLimit, + flatPaint: instructionFlatPaint, }); // --- Apply handler --- @@ -266,6 +319,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -285,6 +339,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on pixelSize, filaments, paintMode, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -306,6 +361,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -345,11 +401,11 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on slicerFirstLayerHeight={slicerFirstLayerHeight} pixelSize={pixelSize} modelSizeEstimate={modelSizeEstimate} - smoothMeshing={smoothMeshing} + smoothMeshing={effectiveSmoothMeshing} onLayerHeightChange={setLayerHeight} onSlicerFirstLayerHeightChange={setSlicerFirstLayerHeight} onPixelSizeChange={setPixelSize} - onSmoothMeshingChange={setSmoothMeshing} + onSmoothMeshingChange={handleSmoothMeshingChange} onReset={handleResetPrintSettings} allDefault={ layerHeight === DEFAULT_PRINT_SETTINGS.layerHeight && @@ -376,6 +432,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on
    ); diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index df542aa..20fca07 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -6,9 +6,13 @@ import useThreeScene from '../hooks/useThreeScene'; import { generateGreedyMesh, generateSmoothMesh, + type MeshData, type MeshMetrics, type MeshProgress, } from '../lib/meshing'; +import { LAYER_ACTIVATION_EPSILON } from '../lib/layerActivation'; +import { normalizeHexColor as normalizeHexColorValue } from '../lib/colorUtils'; +import { buildFlatPaintLayout, heightMapToFlatPaintLayerCounts } from '../lib/flatPaint'; import { clampProgress, layeredBuildScanProgress, @@ -39,6 +43,8 @@ interface ThreeDViewProps { heightDithering?: boolean; // Floyd-Steinberg error diffusion on height map ditherLineWidth?: number; // Minimum dot size in mm for dithering smoothMeshing?: boolean; // Smooth connected boundaries using welded grid topology + isOrtho?: boolean; + flatPaint?: boolean; // Build a flat face-down slab (Flat Paint style, auto-paint only) } // Convert hex color to RGB tuple @@ -131,10 +137,7 @@ function sliderSpanPercentCss(startPercent: number, endPercent: number) { } function normalizeHexColor(hex: string | undefined) { - const fallback = '#3b82f6'; - if (!hex) return fallback; - const value = hex.startsWith('#') ? hex : `#${hex}`; - return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; + return normalizeHexColorValue(hex, '#3b82f6'); } function layerNumberForTransition( @@ -199,6 +202,33 @@ function createFlatShadedGeometry( return geom; } +function remapMeshZRange(mesh: MeshData, baseZ: number, topZ: number, heightScale: number): MeshData { + const positions = new Float32Array(mesh.positions.length); + let minZ = Infinity; + let maxZ = -Infinity; + + for (let i = 2; i < mesh.positions.length; i += 3) { + minZ = Math.min(minZ, mesh.positions[i]); + maxZ = Math.max(maxZ, mesh.positions[i]); + } + + const sourceSpan = maxZ - minZ || 1; + const targetBase = baseZ * heightScale; + const targetSpan = (topZ - baseZ) * heightScale; + + for (let i = 0; i < mesh.positions.length; i += 3) { + positions[i] = mesh.positions[i]; + positions[i + 1] = mesh.positions[i + 1]; + positions[i + 2] = targetBase + ((mesh.positions[i + 2] - minZ) / sourceSpan) * targetSpan; + } + + return { + positions, + indices: mesh.indices, + metrics: mesh.metrics, + }; +} + interface E2EBuildMetrics { status: 'building' | 'complete'; startedAt?: number; @@ -226,6 +256,7 @@ interface E2EBuildMetrics { autoPaintEnabled: boolean; enhancedColorMatch: boolean; heightDithering: boolean; + flatPaint?: boolean; }; } @@ -261,6 +292,12 @@ function updateE2EBuild(metrics: E2EBuildMetrics) { } } +function clearLastMeshRef() { + if (typeof window === 'undefined') return; + (window as unknown as { __KROMACUT_LAST_MESH?: THREE.Object3D }).__KROMACUT_LAST_MESH = + undefined; +} + function collectMeshStats(root: THREE.Object3D) { let meshCount = 0; let visibleMeshCount = 0; @@ -304,6 +341,8 @@ export default function ThreeDView({ heightDithering = false, ditherLineWidth = 0.42, smoothMeshing = false, + isOrtho = false, + flatPaint = false, }: ThreeDViewProps) { const mountRef = useRef(null); const [isBuilding, setIsBuilding] = useState(false); @@ -323,10 +362,13 @@ export default function ThreeDView({ xPercent: number; } | null>(null); const previewTrackRef = useRef(null); - const { cameraRef, controlsRef, modelGroupRef, materialRef, requestRender } = useThreeScene( + const { cameraRef, controlsRef, modelGroupRef, materialRef, requestRender, switchCamera } = useThreeScene( mountRef, setIsBuilding ); + useEffect(() => { + switchCamera(isOrtho); + }, [isOrtho, switchCamera]); const progressRef = useRef(0); const progressLastUpdateRef = useRef(0); @@ -399,6 +441,9 @@ export default function ThreeDView({ const layerPreviewSegments = useMemo(() => { if (maxModelHeight <= 0 || colorOrder.length === 0) return []; + // Flat Paint: printed layers contain several filaments side by side, so a + // single global swap sequence does not exist — show a plain track. + if (flatPaint) return []; const segments: LayerPreviewSegment[] = []; let running = 0; @@ -448,6 +493,7 @@ export default function ThreeDView({ filamentSwatches, swatches, layerHeight, + flatPaint, ]); const updateHoveredSegment = ( @@ -478,6 +524,7 @@ export default function ThreeDView({ const debounceTimerRef = useRef(null); const lastParamsKeyRef = useRef(null); const lastRebuildRef = useRef(rebuildSignal); + const lastImageSrcRef = useRef(imageSrc); useEffect(() => { return () => { @@ -492,6 +539,9 @@ export default function ThreeDView({ const modelGroup = modelGroupRef.current; if (!modelGroup) return; + const imageChanged = imageSrc !== lastImageSrcRef.current; + lastImageSrcRef.current = imageSrc; + if (!imageSrc) { buildTokenRef.current++; if (debounceTimerRef.current !== null) { @@ -499,14 +549,33 @@ export default function ThreeDView({ debounceTimerRef.current = null; } modelGroup.clear(); + clearLastMeshRef(); setIsBuilding(false); setModelDimensions(null); setMaxModelHeight(0); + setPreviewMinHeight(0); setPreviewHeight(null); requestRender(); return; } + if (imageChanged) { + buildTokenRef.current++; + lastParamsKeyRef.current = null; + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + modelGroup.clear(); + clearLastMeshRef(); + setIsBuilding(false); + setModelDimensions(null); + setMaxModelHeight(0); + setPreviewMinHeight(0); + setPreviewHeight(null); + requestRender(); + } + const rebuildRequested = rebuildSignal !== lastRebuildRef.current; if (!rebuildRequested) return; @@ -521,10 +590,13 @@ export default function ThreeDView({ debounceTimerRef.current = null; } modelGroup.clear(); + clearLastMeshRef(); setIsBuilding(false); return; } + const buildSmoothMeshing = smoothMeshing && !flatPaint; + // Stable key of inputs to avoid duplicate builds when references unchanged const paramsKey = JSON.stringify({ imageSrc, @@ -534,6 +606,8 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches: swatches.map((s) => s.hex), + // Filament colors shape Flat Paint geometry (zone merging + export groups) + filamentSwatches: filamentSwatches?.map((s) => s.hex), pixelSize, heightScale, stepped, @@ -545,6 +619,7 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, + flatPaint, }); if (paramsKey === lastParamsKeyRef.current) return; // nothing changed logically lastParamsKeyRef.current = paramsKey; @@ -552,7 +627,7 @@ export default function ThreeDView({ // Debounce rapid changes (e.g., dragging slider) if (debounceTimerRef.current !== null) window.clearTimeout(debounceTimerRef.current); const token = ++buildTokenRef.current; - setActiveBuildSmoothMeshing(smoothMeshing); + setActiveBuildSmoothMeshing(buildSmoothMeshing); debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; const buildStartedAt = performance.now(); @@ -567,10 +642,11 @@ export default function ThreeDView({ pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing, + smoothMeshing: buildSmoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, + flatPaint, }, }); @@ -615,6 +691,7 @@ export default function ThreeDView({ // Clear current model modelGroup.clear(); + clearLastMeshRef(); const YIELD_MS = 12; let lastYield = performance.now(); @@ -1161,58 +1238,157 @@ export default function ThreeDView({ } } - // Build each layer once; smooth meshing does not run overhang repair passes. - const layerBuildOrder = Array.from( - { length: colorOrder.length }, - (_, layerIndex) => layerIndex - ); - const builtLayerMeshes: Array = new Array( - colorOrder.length - ); - - for ( - let buildLayerIndex = 0; - buildLayerIndex < layerBuildOrder.length; - buildLayerIndex++ - ) { - const i = layerBuildOrder[buildLayerIndex]; - if (token !== buildTokenRef.current) return; + if (flatPaint) { + // === FLAT_PAINT: uniform face-down slab === + // Reverse each pixel column so the visible blend layer + // touches the plate (mirrored in X so the artwork reads + // correctly once the finished print is flipped over), + // backfill behind the columns with the foundation + // filament, and add a transparent carrier first layer. + const orientedCounts = new Uint16Array(boxW * boxH); + { + const rawCounts = heightMapToFlatPaintLayerCounts( + pixelHeightMap, + cumulativeHeights, + layerHeight + ); + for (let y = 0; y < boxH; y++) { + const srcRow = y * boxW; + const dstRow = (boxH - 1 - y) * boxW; + for (let x = 0; x < boxW; x++) { + orientedCounts[dstRow + (boxW - 1 - x)] = + rawCounts[srcRow + x]; + } + } + } - const swatchIdx = colorOrder[i]; - if (!swatches[swatchIdx]) continue; - const colorHex = swatches[swatchIdx].hex; - const thickness = - i === 0 - ? Math.max( - colorSliceHeights[swatchIdx] || 0, - slicerFirstLayerHeight - ) - : colorSliceHeights[swatchIdx] || 0; - if (thickness <= 0.0001) continue; + const layout = buildFlatPaintLayout({ + layerCounts: orientedCounts, + width: boxW, + height: boxH, + layerCount: colorOrder.length, + layerHeight, + carrierThickness: Math.max(slicerFirstLayerHeight, layerHeight), + layerVirtualHexes: colorOrder.map( + (swatchIdx) => swatches[swatchIdx]?.hex ?? '#888888' + ), + layerFilamentHexes: colorOrder.map( + (swatchIdx) => + (filamentSwatches?.[swatchIdx] ?? swatches[swatchIdx])?.hex ?? + '#888888' + ), + }); - const topZ = i === 0 ? cumulativeHeights[0] : cumulativeHeights[i]; - const baseZ = i === 0 ? 0 : cumulativeHeights[i - 1]; + const partCount = Math.max(1, layout.parts.length); + const scanSpanEnd = 1 / (colorOrder.length + 1); + const pushPartDetail = ( + partIndex: number, + label: string, + progress: number + ) => { + const stepProgress = clampProgress(progress); + pushBuildOverlayStep({ + stepLabel: `Flat Paint part ${partIndex + 1} of ${partCount}: ${label}`, + stepIndex: Math.min(partCount + 1, partIndex + 2), + stepCount: partCount + 1, + stepProgress, + }); + pushProgress( + progressInSpan( + scanSpanEnd, + 1 - scanSpanEnd, + (partIndex + stepProgress) / partCount + ) + ); + }; - // Identify active pixels for this layer using precomputed height map - const activePixels = new Uint8Array(boxW * boxH); - let activeCount = 0; + const flatMeshCache = new WeakMap>(); + const partIdxForProgress = (part: (typeof layout.parts)[number]) => + layout.parts.indexOf(part); + const getFlatMaskMesh = (part: (typeof layout.parts)[number]) => { + const cached = flatMeshCache.get(part.mask); + if (cached) return cached; + + const promise = generateGreedyMesh( + part.mask, + boxW, + boxH, + 1, + 0, + pixelSize, + 1, + { + yieldIntervalMs: 8, + onProgress: (progress: MeshProgress) => { + pushPartDetail( + partIdxForProgress(part), + progress.label, + progressInSpan(0, 0.9, progress.progress) + ); + }, + } + ); + flatMeshCache.set(part.mask, promise); + return promise; + }; - for (let y = 0; y < boxH; y++) { - for (let x = 0; x < boxW; x++) { - const mapIdx = y * boxW + x; - const pixelHeight = pixelHeightMap[mapIdx]; + for (let partIdx = 0; partIdx < layout.parts.length; partIdx++) { + const part = layout.parts[partIdx]; + if (token !== buildTokenRef.current) return; + if (part.activeCount === 0) continue; + + // Flat Paint always uses the greedy mesher: smoothed + // boundaries would open gaps between side-by-side + // color regions inside the slab. + const generatedMesh = remapMeshZRange( + await getFlatMaskMesh(part), + part.baseZ, + part.topZ, + heightScale + ); + meshBuildMetrics.push({ + layerIndex: partIdx, + swatchIndex: part.classIndex, + activePixelCount: part.activeCount, + vertexCount: generatedMesh.positions.length / 3, + triangleCount: generatedMesh.indices.length / 3, + metrics: generatedMesh.metrics, + }); - if (pixelHeight > 0 && pixelHeight >= topZ - 0.001) { - activePixels[(boxH - 1 - y) * boxW + x] = 1; - activeCount++; + const geom = createFlatShadedGeometry( + generatedMesh.positions, + generatedMesh.indices, + { + activePixels: part.mask, + width: boxW, + height: boxH, + pixelSize, + topZ: part.topZ * heightScale, + compactHeightfield: true, } - } - - pushLayerDetail( - buildLayerIndex, - 'Selecting active pixels', - progressInSpan(0, 0.35, (y + 1) / boxH) ); + const isCarrier = part.kind === 'carrier'; + const mat = new THREE.MeshStandardMaterial({ + color: part.previewHex, + side: THREE.FrontSide, + metalness: 0, + roughness: isCarrier ? 0.3 : 0.7, + flatShading: true, + transparent: isCarrier, + opacity: isCarrier ? 0.3 : 1, + }); + + const mesh = new THREE.Mesh(geom, mat); + // Store slab Z range for the preview slider + mesh.userData.baseZ = part.baseZ; + mesh.userData.topZ = part.topZ; + // Export metadata: one 3MF object per physical filament + mesh.userData.kromacutExportGroup = part.exportGroup; + mesh.userData.kromacutFilamentHex = part.filamentHex; + mesh.userData.kromacutMaterialKey = part.exportGroup; + mesh.userData.kromacutPartName = part.partName; + modelGroup.add(mesh); + pushPartDetail(partIdx, 'Part mesh complete', 1); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1220,63 +1396,127 @@ export default function ThreeDView({ lastYield = performance.now(); } } + } else { + // Build each layer once; smooth meshing does not run overhang repair passes. + const layerBuildOrder = Array.from( + { length: colorOrder.length }, + (_, layerIndex) => layerIndex + ); + const builtLayerMeshes: Array = new Array( + colorOrder.length + ); - if (activeCount === 0) continue; + for ( + let buildLayerIndex = 0; + buildLayerIndex < layerBuildOrder.length; + buildLayerIndex++ + ) { + const i = layerBuildOrder[buildLayerIndex]; + if (token !== buildTokenRef.current) return; - // Generate mesh for this layer - const generatedMesh = await ( - smoothMeshing ? generateSmoothMesh : generateGreedyMesh - )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { - yieldIntervalMs: 8, - onProgress: meshProgressReporter(buildLayerIndex), - }); - meshBuildMetrics.push({ - layerIndex: i, - swatchIndex: swatchIdx, - activePixelCount: activeCount, - vertexCount: generatedMesh.positions.length / 3, - triangleCount: generatedMesh.indices.length / 3, - metrics: generatedMesh.metrics, - }); + const swatchIdx = colorOrder[i]; + if (!swatches[swatchIdx]) continue; + const colorHex = swatches[swatchIdx].hex; + const thickness = + i === 0 + ? Math.max( + colorSliceHeights[swatchIdx] || 0, + slicerFirstLayerHeight + ) + : colorSliceHeights[swatchIdx] || 0; + if (thickness <= 0.0001) continue; + + const topZ = i === 0 ? cumulativeHeights[0] : cumulativeHeights[i]; + const baseZ = i === 0 ? 0 : cumulativeHeights[i - 1]; + + // Identify active pixels for this layer using precomputed height map + const activePixels = new Uint8Array(boxW * boxH); + let activeCount = 0; - const geom = createFlatShadedGeometry( - generatedMesh.positions, - generatedMesh.indices, - { - activePixels, - width: boxW, - height: boxH, - pixelSize, - topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !smoothMeshing, + for (let y = 0; y < boxH; y++) { + for (let x = 0; x < boxW; x++) { + const mapIdx = y * boxW + x; + const pixelHeight = pixelHeightMap[mapIdx]; + + if ( + pixelHeight > 0 && + pixelHeight >= topZ - LAYER_ACTIVATION_EPSILON + ) { + activePixels[(boxH - 1 - y) * boxW + x] = 1; + activeCount++; + } + } + + pushLayerDetail( + buildLayerIndex, + 'Selecting active pixels', + progressInSpan(0, 0.35, (y + 1) / boxH) + ); + + if (performance.now() - lastYield > YIELD_MS) { + await new Promise((r) => requestAnimationFrame(r)); + if (token !== buildTokenRef.current) return; + lastYield = performance.now(); + } } - ); - pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); - const mat = new THREE.MeshStandardMaterial({ - color: colorHex, - side: THREE.FrontSide, - metalness: 0, - roughness: 0.7, - flatShading: true, - }); - const mesh = new THREE.Mesh(geom, mat); - // Store layer Z range for preview slider - mesh.userData.baseZ = baseZ; - mesh.userData.topZ = topZ; - builtLayerMeshes[i] = mesh; - pushLayerDetail(buildLayerIndex, 'Layer mesh complete', 1); + if (activeCount === 0) continue; - if (performance.now() - lastYield > YIELD_MS) { - await new Promise((r) => requestAnimationFrame(r)); - if (token !== buildTokenRef.current) return; - lastYield = performance.now(); + // Generate mesh for this layer + const generatedMesh = await ( + buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh + )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { + yieldIntervalMs: 8, + onProgress: meshProgressReporter(buildLayerIndex), + }); + meshBuildMetrics.push({ + layerIndex: i, + swatchIndex: swatchIdx, + activePixelCount: activeCount, + vertexCount: generatedMesh.positions.length / 3, + triangleCount: generatedMesh.indices.length / 3, + metrics: generatedMesh.metrics, + }); + + const geom = createFlatShadedGeometry( + generatedMesh.positions, + generatedMesh.indices, + { + activePixels, + width: boxW, + height: boxH, + pixelSize, + topZ: (baseZ + thickness) * heightScale, + compactHeightfield: !buildSmoothMeshing, + } + ); + pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); + const mat = new THREE.MeshStandardMaterial({ + color: colorHex, + side: THREE.FrontSide, + metalness: 0, + roughness: 0.7, + flatShading: true, + }); + + const mesh = new THREE.Mesh(geom, mat); + // Store layer Z range for preview slider + mesh.userData.baseZ = baseZ; + mesh.userData.topZ = topZ; + builtLayerMeshes[i] = mesh; + pushLayerDetail(buildLayerIndex, 'Layer mesh complete', 1); + + if (performance.now() - lastYield > YIELD_MS) { + await new Promise((r) => requestAnimationFrame(r)); + if (token !== buildTokenRef.current) return; + lastYield = performance.now(); + } } - } - for (const mesh of builtLayerMeshes) { - if (mesh) { - modelGroup.add(mesh); + for (const mesh of builtLayerMeshes) { + if (mesh) { + modelGroup.add(mesh); + } } } } else { @@ -1397,7 +1637,7 @@ export default function ThreeDView({ // Generate Optimized Greedy Mesh const generatedMesh = await ( - smoothMeshing ? generateSmoothMesh : generateGreedyMesh + buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { yieldIntervalMs: 8, onProgress: meshProgressReporter(buildLayerIndex), @@ -1420,7 +1660,7 @@ export default function ThreeDView({ height: boxH, pixelSize, topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !smoothMeshing, + compactHeightfield: !buildSmoothMeshing, } ); pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); @@ -1497,10 +1737,11 @@ export default function ThreeDView({ pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing, + smoothMeshing: buildSmoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, + flatPaint, }, }); @@ -1511,17 +1752,34 @@ export default function ThreeDView({ if (camera && controls) { const sphere = new THREE.Sphere(); box.getBoundingSphere(sphere); - // ... same framing logic ... - const fov = (camera.fov * Math.PI) / 180; - const distance = sphere.radius / Math.sin(fov / 2); const dir = new THREE.Vector3(0.9, 0.8, 1).normalize(); - const camPos = sphere.center - .clone() - .add(dir.multiplyScalar(distance * 1.35)); - camera.position.copy(camPos); - controls.target.copy(sphere.center); - camera.near = Math.max(0.01, sphere.radius * 0.01); - camera.far = sphere.radius * 20; + if (camera instanceof THREE.PerspectiveCamera) { + const fov = (camera.fov * Math.PI) / 180; + const distance = sphere.radius / Math.sin(fov / 2); + const camPos = sphere.center + .clone() + .add(dir.multiplyScalar(distance * 1.35)); + camera.position.copy(camPos); + controls.target.copy(sphere.center); + camera.near = Math.max(0.01, sphere.radius * 0.01); + camera.far = sphere.radius * 20; + } else if (camera instanceof THREE.OrthographicCamera) { + const distance = sphere.radius * 2.5; + const camPos = sphere.center + .clone() + .add(dir.multiplyScalar(distance)); + camera.position.copy(camPos); + controls.target.copy(sphere.center); + camera.near = 0.01; + camera.far = sphere.radius * 20; + // Expand frustum to fit the sphere + const viewH = sphere.radius * 2.5; + const aspect = (camera.right - camera.left) / (camera.top - camera.bottom); + camera.top = viewH / 2; + camera.bottom = -viewH / 2; + camera.left = -(viewH * aspect) / 2; + camera.right = (viewH * aspect) / 2; + } camera.updateProjectionMatrix(); controls.update(); } @@ -1601,6 +1859,7 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches, + filamentSwatches, pixelSize, heightScale, stepped, @@ -1613,6 +1872,7 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, + flatPaint, cameraRef, controlsRef, materialRef, diff --git a/src/components/UpdateChecker.tsx b/src/components/UpdateChecker.tsx index e040643..ba5a65a 100644 --- a/src/components/UpdateChecker.tsx +++ b/src/components/UpdateChecker.tsx @@ -9,38 +9,55 @@ import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Download, X } from 'lucide-react'; -import { invoke, isTauri } from '@tauri-apps/api/core'; - -interface VersionInfo { - version: string; - download_url?: string; - release_notes?: string; -} +import { + checkForDesktopUpdates, + isDesktopUpdateSupported, + openDesktopReleasesPage, + type VersionInfo, +} from '@/lib/desktopUpdates'; +import { + getUpdateCheckOnStartup, + subscribeToUpdateCheckOnStartup, +} from '@/lib/updatePreferences'; export function UpdateChecker() { const [updateAvailable, setUpdateAvailable] = useState(null); const [dismissed, setDismissed] = useState(false); const [checking, setChecking] = useState(false); + const [checkOnStartup, setCheckOnStartup] = useState(() => getUpdateCheckOnStartup()); useEffect(() => { - // Only check for updates in Tauri environment - if (!isTauri()) return; + if (!isDesktopUpdateSupported()) return; + + return subscribeToUpdateCheckOnStartup(setCheckOnStartup); + }, []); + + useEffect(() => { + if (!checkOnStartup) { + setUpdateAvailable(null); + setChecking(false); + } + }, [checkOnStartup]); + + useEffect(() => { + if (!isDesktopUpdateSupported() || !checkOnStartup) return; + + let active = true; const checkForUpdates = async () => { setChecking(true); try { - const currentVersion = await invoke('get_app_version'); - const updateInfo = await invoke('check_for_updates', { - currentVersion, - }); + const updateInfo = await checkForDesktopUpdates(); - if (updateInfo) { + if (active) { setUpdateAvailable(updateInfo); } } catch (error) { console.error('Failed to check for updates:', error); } finally { - setChecking(false); + if (active) { + setChecking(false); + } } }; @@ -50,16 +67,19 @@ export function UpdateChecker() { // Check periodically (every 4 hours) const interval = setInterval(checkForUpdates, 4 * 60 * 60 * 1000); - return () => clearInterval(interval); - }, []); + return () => { + active = false; + clearInterval(interval); + }; + }, [checkOnStartup]); - if (!isTauri() || !updateAvailable || dismissed || checking) { + if (!isDesktopUpdateSupported() || !checkOnStartup || !updateAvailable || dismissed || checking) { return null; } const handleDownload = async () => { try { - await invoke('open_releases_page'); + await openDesktopReleasesPage(); } catch (error) { console.error('Failed to open releases page:', error); } diff --git a/src/components/docs/DocsPage.tsx b/src/components/docs/DocsPage.tsx index 20b4acd..7d57ef6 100644 --- a/src/components/docs/DocsPage.tsx +++ b/src/components/docs/DocsPage.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { docs, defaultDocSlug } from '@/docs'; import type { DocLinkTarget, DocRecord, TocEntry } from '@/types/docs'; -import { buildDocsHash, parseDocsHash } from '@/lib/docs/navigation'; +import { applyDocSeo } from '@/lib/seo'; +import { buildDocsPath, parseDocsLocation } from '@/lib/docs/navigation'; import MarkdownRenderer from './MarkdownRenderer'; function getInitialTarget(): DocLinkTarget { if (typeof window === 'undefined') return { docSlug: defaultDocSlug }; - return parseDocsHash(window.location.hash) ?? { docSlug: defaultDocSlug }; + return parseDocsLocation(window.location) ?? { docSlug: defaultDocSlug }; } function findDoc(slug: string): DocRecord { @@ -62,22 +63,30 @@ export default function DocsPage() { setActiveDocSlug(nextDoc.meta.slug); setPendingHeading(target.headingSlug); setActiveHeading(target.headingSlug); - window.history.pushState(null, '', buildDocsHash(nextDoc.meta.slug, target.headingSlug)); + window.history.pushState(null, '', buildDocsPath(nextDoc.meta.slug, target.headingSlug)); }, []); useEffect(() => { - const onHashChange = () => { - const target = parseDocsHash(window.location.hash); + const onLocationChange = () => { + const target = parseDocsLocation(window.location); if (!target) return; const nextDoc = findDoc(target.docSlug); setActiveDocSlug(nextDoc.meta.slug); setPendingHeading(target.headingSlug); setActiveHeading(target.headingSlug); }; - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); + window.addEventListener('hashchange', onLocationChange); + window.addEventListener('popstate', onLocationChange); + return () => { + window.removeEventListener('hashchange', onLocationChange); + window.removeEventListener('popstate', onLocationChange); + }; }, []); + useEffect(() => { + applyDocSeo(activeDoc); + }, [activeDoc]); + useEffect(() => { const scrollElement = scrollRef.current; if (!scrollElement) return; @@ -162,7 +171,7 @@ export default function DocsPage() { return (
  • { event.preventDefault(); navigate({ docSlug: doc.meta.slug }); @@ -227,7 +236,7 @@ export default function DocsPage() { return ( { event.preventDefault(); navigate({ diff --git a/src/components/docs/MarkdownRenderer.tsx b/src/components/docs/MarkdownRenderer.tsx index 6d50109..094b936 100644 --- a/src/components/docs/MarkdownRenderer.tsx +++ b/src/components/docs/MarkdownRenderer.tsx @@ -7,7 +7,7 @@ import type { MarkdownRendererProps, } from '@/types/docs'; import { resolveDocAsset } from '@/docs/assets'; -import { buildDocsHash, isSafeExternalHref, resolveDocHref } from '@/lib/docs/navigation'; +import { buildDocsPath, isSafeExternalHref, resolveDocHref } from '@/lib/docs/navigation'; function linkClassName() { return 'font-semibold text-primary underline underline-offset-4 hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm'; @@ -67,7 +67,7 @@ function renderInlineNodes( return ( { event.preventDefault(); props.onNavigate(target); @@ -132,7 +132,7 @@ function renderHeading( <> {renderInlineNodes(block.children, props, `heading-${block.id}`)} { event.preventDefault(); props.onNavigate(target); diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index ff80fbc..9cf25eb 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -80,6 +80,20 @@ Optional controls appear with enhanced matching: - **Line width** should roughly match the printer line or nozzle width used for dither dots. - **Optimizer Settings** let you choose **Algorithm**, **Region priority**, and an optional **Seed**. +## Flat Paint + +**Flat Paint (flat face-down print)** builds a uniform-thickness slab instead of a stepped relief. Every printed layer has the full model footprint: + +- The artwork is placed face down against the build plate, under a **transparent carrier layer** that prints first and becomes the smooth viewing face. Use clear filament for the carrier object. +- Each pixel column's layer order is reversed so the print looks identical to the normal model when viewed from the face side, and the space behind the image is filled with the foundation filament. +- The model is already mirrored for face-down printing — do not mirror it again in the slicer. After printing, flip the piece over to view the image. + +Because a single printed layer contains several filaments side by side, Flat Paint requires a multi-material printer (AMS or toolchanger). Export as **3MF**: the model contains one object per filament, plus the carrier object, ready for per-object filament assignment in the slicer. + +Flat Paint works in both standard and enhanced color matching modes. Expect heavier geometry and slower slicing than a normal build — flat models are best for bookmarks, coasters, and other pieces that benefit from a smooth, glass-flat face. + +Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns the other off because Flat Paint already uses a full-footprint slab layout instead of smoothed boundary contours. + ## Optimizer Settings | Setting | Meaning | @@ -100,10 +114,20 @@ After Auto-paint computes a result, Kromacut can show: Low confidence usually means you should calibrate filaments, add a missing filament color, or loosen a restrictive max height. +## Preview Controls + +The toolbar in the top-right corner of the 3D preview contains controls for the active view: + +- **Camera toggle** — switches between perspective and orthographic projection. Perspective gives a natural depth effect; orthographic removes foreshortening and is useful for checking layer alignment. The button icon reflects the current mode, and the camera position is preserved when toggling. +- **Undo / Redo** — steps through changes to the 3D settings. +- **Download** — exports the current model as a .stl or a .3mf. + ## Layer Preview After building, the bottom **Layer Preview** bar lets you show only a height range of the model. Drag the lower and upper handles to inspect how the print builds. Hover over color segments to see the start layer or swap layer. The preview range is only for inspection; exports still include the complete model. +In Flat Paint mode the bar shows a plain track because printed layers contain several filaments at once — there is no single swap sequence. Orbit underneath the model to inspect the artwork face. + Next: [Generating and exporting output](generating-exporting-output). diff --git a/src/docs/generating-exporting-output.md b/src/docs/generating-exporting-output.md index b9ea045..5025ca0 100644 --- a/src/docs/generating-exporting-output.md +++ b/src/docs/generating-exporting-output.md @@ -38,6 +38,8 @@ Open the 3D download menu and choose: 3MF export preserves physical filament colors in Auto-paint where possible. Still review slicer assignments before printing. +For **Flat Paint** models the download menu offers only 3MF: the model contains one object per physical filament plus a transparent carrier object, and an uncolored single-geometry STL of the flat slab would be useless. Flat Paint turns off **Smooth Meshing** because the flat slab layout does not use smoothed boundary contours. + ## Print Instructions The **Print Instructions** panel gives you: @@ -49,6 +51,8 @@ The **Print Instructions** panel gives you: Use the copied plan beside your slicer preview. The layer numbers depend on **Layer Height** and **First Layer Height**, so keep those values consistent. +In Flat Paint mode there is no manual swap plan. The panel instead summarizes the multi-material workflow: assign each 3MF object to its filament, use clear filament for the carrier, and print without mirroring. + ## Recommended Slicer Setup Kromacut recommends: diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 0981f0d..f6a235a 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -18,9 +18,11 @@ This page collects controls that affect the whole app or are easy to miss. | Discord | Opens the community link. | | GitHub | Opens the project page. | | Support me | Opens the support link. | -| Theme button | Switches between dark and light mode. | +| Settings | Opens the settings dialog, including theme and desktop update controls. | -The theme choice is saved for later sessions. +The theme selector offers **System**, **Dark**, and **Light**. **System** follows the operating system or browser color-scheme preference and updates when that preference changes. The theme choice is saved for later sessions. + +The settings dialog also shows the current Kromacut version. ## Workspace Modes @@ -28,6 +30,8 @@ Use the **2D** and **3D** buttons to switch between image preparation and model The vertical splitter between the controls panel and preview can be dragged. Make the left panel wider when working with detailed settings, or make the preview wider when inspecting the image or model. +Documentation pages use shareable `/docs/...` links. Opening one of those links takes you directly to the matching guide. + ## Saved Print Settings Kromacut remembers print settings such as **Pixel Size (XY)**, **Layer Height**, **First Layer Height**, and **Smooth Meshing** in the browser. @@ -43,6 +47,7 @@ Auto-paint settings are preserved across sessions, including: - Enhanced color matching. - Repeated swaps. - Height dithering and line width. +- Flat Paint. - Optimizer algorithm and seed. - Region priority. @@ -64,4 +69,6 @@ Use profile import and export to move calibrated filaments between browsers or s In the desktop app, Kromacut can show an update notice when a newer version is available. The notice lets you open the download page or dismiss the reminder. +Open **Settings** to check for updates manually. The desktop settings also include **Check on startup**, which controls whether Kromacut checks for updates when the app opens. This is enabled by default, and manual checks still work when it is off. + Next: [Troubleshooting](troubleshooting). diff --git a/src/hooks/useBuildWarning.ts b/src/hooks/useBuildWarning.ts index a071443..c67dddb 100644 --- a/src/hooks/useBuildWarning.ts +++ b/src/hooks/useBuildWarning.ts @@ -3,6 +3,7 @@ import type { ThreeDControlsStateShape } from '../types'; const LAYER_WARNING_THRESHOLD = 64; const PIXEL_WARNING_THRESHOLD = 2500000; +const FLAT_PAINT_LAYER_WARNING_THRESHOLD = 32; export interface BuildWarning { warnings: string[]; @@ -13,23 +14,38 @@ export interface UseBuildWarningOptions { imageSrc?: string | null; } +const INITIAL_THREE_D_STATE: ThreeDControlsStateShape = { + layerHeight: 0.12, + slicerFirstLayerHeight: 0.2, + colorSliceHeights: [], + colorOrder: [], + filteredSwatches: [], + pixelSize: 0.1, + filaments: [], + paintMode: 'manual', +}; + +function clearLastBuiltMeshRef() { + if (typeof window === 'undefined') return; + (window as unknown as { __KROMACUT_LAST_MESH?: unknown }).__KROMACUT_LAST_MESH = undefined; +} + export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { const [imageDimensions, setImageDimensions] = useState<{ w: number; h: number } | null>(null); const [buildWarning, setBuildWarning] = useState(null); - const [threeDState, setThreeDState] = useState({ - layerHeight: 0.12, - slicerFirstLayerHeight: 0.2, - colorSliceHeights: [], - colorOrder: [], - filteredSwatches: [], - pixelSize: 0.1, - filaments: [], - paintMode: 'manual', - }); + const [threeDState, setThreeDState] = + useState(INITIAL_THREE_D_STATE); const [threeDBuildSignal, setThreeDBuildSignal] = useState(0); + const [builtThreeDState, setBuiltThreeDState] = useState( + null + ); + const builtFlatPaint = + builtThreeDState?.paintMode === 'autopaint' && !!builtThreeDState.flatPaint; // Track image dimensions for build warning checks useEffect(() => { + setBuiltThreeDState(null); + clearLastBuiltMeshRef(); if (!imageSrc) { setImageDimensions(null); return; @@ -43,6 +59,17 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { // Apply state without warning (used after user confirms, or when no warning needed) const applyThreeDState = useCallback((s: ThreeDControlsStateShape) => { setThreeDState(s); + setBuiltThreeDState({ + ...s, + colorSliceHeights: [...s.colorSliceHeights], + colorOrder: [...s.colorOrder], + filteredSwatches: [...s.filteredSwatches], + filaments: [...s.filaments], + autoPaintSwatches: s.autoPaintSwatches ? [...s.autoPaintSwatches] : undefined, + autoPaintFilamentSwatches: s.autoPaintFilamentSwatches + ? [...s.autoPaintFilamentSwatches] + : undefined, + }); setThreeDBuildSignal((n) => n + 1); }, []); @@ -67,6 +94,18 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { } } + if (s.paintMode === 'autopaint' && s.flatPaint && layerCount > FLAT_PAINT_LAYER_WARNING_THRESHOLD) { + warnings.push( + `Flat Paint fills every one of the ${layerCount} layers at full size, producing much heavier geometry and slower slicing. Consider raising the layer height or lowering Max Height.` + ); + } + + if (s.paintMode === 'autopaint' && s.flatPaint && s.heightDithering) { + warnings.push( + 'Flat Paint with height dithering can fragment color regions into many small parts, making builds, exports, and slicer processing much slower.' + ); + } + if (warnings.length > 0) { setBuildWarning({ warnings, pendingState: s }); } else { @@ -91,6 +130,8 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { threeDState, setThreeDState, threeDBuildSignal, + builtThreeDState, + builtFlatPaint, buildWarning, handleThreeDStateChange, confirmBuild, diff --git a/src/hooks/useFilaments.ts b/src/hooks/useFilaments.ts index a32c17d..e6f3810 100644 --- a/src/hooks/useFilaments.ts +++ b/src/hooks/useFilaments.ts @@ -21,6 +21,13 @@ export function useFilaments(options: UseFilamentsOptions = {}) { ]); }, []); + const addFilamentWithProps = useCallback((props: Omit) => { + setFilaments((prev) => [ + ...prev, + { id: Math.random().toString(36).substring(2, 9), ...props }, + ]); + }, []); + const removeFilament = useCallback((id: string) => { setFilaments((prev) => prev.filter((f) => f.id !== id)); }, []); @@ -33,6 +40,7 @@ export function useFilaments(options: UseFilamentsOptions = {}) { filaments, setFilaments, addFilament, + addFilamentWithProps, removeFilament, updateFilament, }; diff --git a/src/hooks/useNextBestColorWorker.ts b/src/hooks/useNextBestColorWorker.ts new file mode 100644 index 0000000..484935d --- /dev/null +++ b/src/hooks/useNextBestColorWorker.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { NextBestColorResult } from '../lib/nextBestColor'; +import type { Filament } from '../types'; +import type { + NextBestColorWorkerRequest, + NextBestColorWorkerResponse, +} from '../workers/nextBestColor.worker'; + +export interface UseNextBestColorWorkerResult { + result: NextBestColorResult | null; + isComputing: boolean; + error?: string; + requestSuggestion: ( + filaments: Filament[], + imageSwatches: Array<{ hex: string; count?: number }> + ) => void; + reset: () => void; +} + +let nextRequestId = 1; + +export function useNextBestColorWorker(): UseNextBestColorWorkerResult { + const [result, setResult] = useState(null); + const [isComputing, setIsComputing] = useState(false); + const [error, setError] = useState(undefined); + + const workerRef = useRef(null); + const activeRequestIdRef = useRef(0); + + const cancelWorker = useCallback(() => { + workerRef.current?.terminate(); + workerRef.current = null; + }, []); + + const finishRequest = useCallback( + (id: number, nextError?: string, nextResult?: NextBestColorResult) => { + if (id !== activeRequestIdRef.current) return; + + activeRequestIdRef.current = 0; + cancelWorker(); + setIsComputing(false); + setError(nextError); + setResult(nextError ? null : (nextResult ?? null)); + }, + [cancelWorker] + ); + + const reset = useCallback(() => { + activeRequestIdRef.current = 0; + cancelWorker(); + setResult(null); + setIsComputing(false); + setError(undefined); + }, [cancelWorker]); + + const requestSuggestion = useCallback( + (filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>) => { + if (filaments.length === 0 || imageSwatches.length === 0) { + reset(); + return; + } + + cancelWorker(); + const id = nextRequestId++; + activeRequestIdRef.current = id; + setResult(null); + setIsComputing(true); + setError(undefined); + + try { + const worker = new Worker( + new URL('../workers/nextBestColor.worker.ts', import.meta.url), + { type: 'module' } + ); + workerRef.current = worker; + + worker.onmessage = (e: MessageEvent) => { + const response = e.data; + finishRequest(response.id, response.error, response.result); + }; + + worker.onerror = (err) => { + finishRequest(id, err.message || 'Next-best-color worker failed'); + }; + + worker.onmessageerror = () => { + finishRequest(id, 'Next-best-color worker returned an unreadable result'); + }; + + const request: NextBestColorWorkerRequest = { id, filaments, imageSwatches }; + worker.postMessage(request); + } catch (postError) { + finishRequest( + id, + postError instanceof Error ? postError.message : String(postError) + ); + } + }, + [cancelWorker, finishRequest, reset] + ); + + useEffect(() => { + return () => { + activeRequestIdRef.current = 0; + cancelWorker(); + }; + }, [cancelWorker]); + + return { result, isComputing, error, requestSuggestion, reset }; +} diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts index f493c2b..2546f72 100644 --- a/src/hooks/useSwapPlan.ts +++ b/src/hooks/useSwapPlan.ts @@ -15,6 +15,8 @@ export interface UseSwapPlanOptions { paintMode: 'manual' | 'autopaint'; autoPaintResult?: AutoPaintResult; disabled?: boolean; + /** Flat Paint prints have no manual swap sequence (multi-material per layer) */ + flatPaint?: boolean; } export function useSwapPlan({ @@ -26,9 +28,10 @@ export function useSwapPlan({ paintMode, autoPaintResult, disabled = false, + flatPaint = false, }: UseSwapPlanOptions) { const swapPlan = useMemo(() => { - if (disabled) { + if (disabled || flatPaint) { return [] as SwapEntry[]; } @@ -110,11 +113,20 @@ export function useSwapPlan({ paintMode, autoPaintResult, disabled, + flatPaint, ]); // Build a plain-text representation of the instructions for copying const buildInstructionsText = () => { const lines: string[] = []; + const appendFooter = () => { + lines.push(''); + lines.push('Notes: Heights are approximate. Confirm in slicer before printing.'); + lines.push(''); + lines.push('---------------------'); + lines.push('Made with Kromacut by vycdev!'); + }; + lines.push('3D Print Instructions'); lines.push('---------------------'); lines.push(`Layer height: ${layerHeight.toFixed(3)} mm`); @@ -122,6 +134,23 @@ export function useSwapPlan({ lines.push('Recommended: Layer loops: 1; Infill: 100%'); lines.push(''); + if (flatPaint) { + lines.push('Flat Paint mode (flat face-down print):'); + lines.push('- Export as 3MF: the model contains one object per filament.'); + lines.push( + '- Assign each object to its filament in the slicer (multi-material printer required).' + ); + lines.push( + '- Use clear/transparent filament for the carrier object — it is the first printed layer and becomes the smooth viewing face.' + ); + lines.push( + '- Print as-is: the artwork is already mirrored for face-down printing. Do not mirror in the slicer.' + ); + lines.push('- After printing, flip the piece over: the image side is the bottom.'); + appendFooter(); + return lines.join('\n'); + } + if (swapPlan.length) { const first = swapPlan[0]; if (first.type === 'start') lines.push(`Start with color: ${first.swatch.hex}`); @@ -146,11 +175,7 @@ export function useSwapPlan({ idx++; } } - lines.push(''); - lines.push('Notes: Heights are approximate. Confirm in slicer before printing.'); - lines.push(''); - lines.push('---------------------'); - lines.push('Made with Kromacut by vycdev!'); + appendFooter(); return lines.join('\n'); }; @@ -186,4 +211,4 @@ export function useSwapPlan({ copied, copyToClipboard, }; -} \ No newline at end of file +} diff --git a/src/hooks/useThreeScene.ts b/src/hooks/useThreeScene.ts index 097430d..c1afe52 100644 --- a/src/hooks/useThreeScene.ts +++ b/src/hooks/useThreeScene.ts @@ -9,12 +9,14 @@ export function useThreeScene( const rafRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); - const cameraRef = useRef(null); + const cameraRef = useRef(null); + const perspCameraRef = useRef(null); const controlsRef = useRef(null); const modelGroupRef = useRef(null); const materialRef = useRef(null); const requestRenderRef = useRef<(() => void) | null>(null); + const switchCameraRef = useRef<((isOrtho: boolean) => void) | null>(null); useEffect(() => { const el = mountRef.current; @@ -36,6 +38,7 @@ export function useThreeScene( const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 1000); camera.position.set(0, 0.9, 1.8); + perspCameraRef.current = camera; cameraRef.current = camera; const controls = new OrbitControls(camera, renderer.domElement); @@ -93,13 +96,57 @@ export function useThreeScene( }; requestRenderRef.current = requestRender; + const switchCamera = (isOrtho: boolean) => { + const cam = cameraRef.current; + const persp = perspCameraRef.current; + const ctrl = controlsRef.current; + if (!cam || !persp || !ctrl || !el) return; + const aspect = el.clientWidth / el.clientHeight; + + if (isOrtho && cam instanceof THREE.PerspectiveCamera) { + const target = ctrl.target.clone(); + const dist = cam.position.distanceTo(target); + const fovRad = (cam.fov * Math.PI) / 180; + const viewH = 2 * dist * Math.tan(fovRad / 2); + const viewW = viewH * aspect; + const ortho = new THREE.OrthographicCamera( + -viewW / 2, viewW / 2, viewH / 2, -viewH / 2, cam.near, cam.far + ); + ortho.position.copy(cam.position); + ortho.quaternion.copy(cam.quaternion); + ortho.updateProjectionMatrix(); + cameraRef.current = ortho; + ctrl.object = ortho as unknown as THREE.Camera; + } else if (!isOrtho && cam instanceof THREE.OrthographicCamera) { + persp.position.copy(cam.position); + persp.quaternion.copy(cam.quaternion); + persp.near = cam.near; + persp.far = cam.far; + persp.aspect = aspect; + persp.updateProjectionMatrix(); + cameraRef.current = persp; + ctrl.object = persp as unknown as THREE.Camera; + } + ctrl.update(); + requestRender(); + }; + switchCameraRef.current = switchCamera; + const resize = () => { if (!el || !cameraRef.current || !rendererRef.current) return; const w = el.clientWidth; const h = el.clientHeight; rendererRef.current.setSize(w, h); - cameraRef.current!.aspect = w / h; - cameraRef.current!.updateProjectionMatrix(); + const cam = cameraRef.current; + if (cam instanceof THREE.PerspectiveCamera) { + cam.aspect = w / h; + } else if (cam instanceof THREE.OrthographicCamera) { + const viewH = cam.top - cam.bottom; + const viewW = viewH * (w / h); + cam.left = -viewW / 2; + cam.right = viewW / 2; + } + cam.updateProjectionMatrix(); requestRender(); }; const ro = new ResizeObserver(resize); @@ -124,7 +171,7 @@ export function useThreeScene( const animate = () => { if (controlsRef.current) controlsRef.current.update(); - renderer.render(scene, camera); + if (cameraRef.current) renderer.render(scene, cameraRef.current); rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); @@ -142,9 +189,11 @@ export function useThreeScene( rendererRef.current = null; sceneRef.current = null; cameraRef.current = null; + perspCameraRef.current = null; controlsRef.current = null; modelGroupRef.current = null; materialRef.current = null; + switchCameraRef.current = null; setIsBuilding(false); }; }, [mountRef, setIsBuilding]); @@ -157,6 +206,7 @@ export function useThreeScene( modelGroupRef, materialRef, requestRender: () => requestRenderRef.current?.(), + switchCamera: (isOrtho: boolean) => switchCameraRef.current?.(isOrtho), } as const; } diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 8af0365..56f98d8 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -26,6 +26,8 @@ import { import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting'; import { computeProfileConfidence } from './calibration'; +export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; + /** RGB color representation (0-255 range) */ export interface RGB { r: number; @@ -1735,4 +1737,4 @@ export function debugAutoPaint( ); }); console.groupEnd(); -} \ No newline at end of file +} diff --git a/src/lib/cameraPrefs.ts b/src/lib/cameraPrefs.ts new file mode 100644 index 0000000..995ec2e --- /dev/null +++ b/src/lib/cameraPrefs.ts @@ -0,0 +1,17 @@ +const KEY = 'kromacut:3d-camera-mode'; + +export function loadCameraMode(): boolean { + try { + return localStorage.getItem(KEY) === 'orthographic'; + } catch { + return false; + } +} + +export function saveCameraMode(isOrtho: boolean): void { + try { + localStorage.setItem(KEY, isOrtho ? 'orthographic' : 'perspective'); + } catch { + // ignore + } +} diff --git a/src/lib/colorUtils.ts b/src/lib/colorUtils.ts index f5f5cc6..74b4c29 100644 --- a/src/lib/colorUtils.ts +++ b/src/lib/colorUtils.ts @@ -2,6 +2,17 @@ * Shared color utility functions. */ +/** + * Normalize a hex color to canonical `#RRGGBB` uppercase form. + * Accepts values with or without the leading '#'; anything that is not a + * 6-digit hex color returns the fallback unchanged. + */ +export function normalizeHexColor(hex: string | undefined, fallback: string): string { + if (!hex) return fallback; + const value = hex.startsWith('#') ? hex : `#${hex}`; + return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; +} + /** * Compute perceived luminance (0–1) from a hex color string. * Uses the standard sRGB luminance coefficients. diff --git a/src/lib/desktopUpdates.ts b/src/lib/desktopUpdates.ts new file mode 100644 index 0000000..27b4f90 --- /dev/null +++ b/src/lib/desktopUpdates.ts @@ -0,0 +1,26 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; + +export interface VersionInfo { + version: string; + download_url?: string; + release_notes?: string; +} + +export function isDesktopUpdateSupported(): boolean { + return isTauri(); +} + +export async function checkForDesktopUpdates(): Promise { + if (!isDesktopUpdateSupported()) { + return null; + } + + const currentVersion = await invoke('get_app_version'); + return invoke('check_for_updates', { + currentVersion, + }); +} + +export async function openDesktopReleasesPage(): Promise { + await invoke('open_releases_page'); +} diff --git a/src/lib/docs/navigation.ts b/src/lib/docs/navigation.ts index 8830fab..e88323a 100644 --- a/src/lib/docs/navigation.ts +++ b/src/lib/docs/navigation.ts @@ -1,5 +1,7 @@ import type { DocLinkTarget, DocRecord } from '@/types/docs'; +const DOCS_PATH_PREFIX = '/docs'; + function cleanDocSlug(value: string): string { return value .trim() @@ -22,24 +24,27 @@ function safeDecodeURIComponent(value: string): string | null { } } -export function buildDocsHash(docSlug: string, headingSlug?: string): string { - const encodedDoc = encodeURIComponent(docSlug); +export function buildDocsPath(docSlug: string, headingSlug?: string): string { + const encodedDoc = encodeURIComponent(cleanDocSlug(docSlug)); const encodedHeading = headingSlug ? `#${encodeURIComponent(headingSlug)}` : ''; - return `#docs/${encodedDoc}${encodedHeading}`; + return `${DOCS_PATH_PREFIX}/${encodedDoc}${encodedHeading}`; } -export function parseDocsHash(hash: string): DocLinkTarget | null { - const raw = hash.replace(/^#/, ''); - if (!raw.startsWith('docs/')) return null; +export function parseDocsPath(pathname: string, hash = ''): DocLinkTarget | null { + const normalizedPath = pathname.replace(/\/+$/, '') || '/'; + if (normalizedPath !== DOCS_PATH_PREFIX && !normalizedPath.startsWith(`${DOCS_PATH_PREFIX}/`)) { + return null; + } - const { docPart, headingPart } = splitDocAndHeading(raw.slice('docs/'.length)); - const decodedDocPart = safeDecodeURIComponent(docPart); - if (decodedDocPart === null) return null; + const rawDocSlug = normalizedPath.slice(DOCS_PATH_PREFIX.length).replace(/^\/+/, ''); + const decodedDocSlug = rawDocSlug ? safeDecodeURIComponent(rawDocSlug) : 'overview'; + if (decodedDocSlug === null) return null; - const decodedHeading = headingPart ? safeDecodeURIComponent(headingPart) : undefined; + const rawHeading = hash.replace(/^#/, ''); + const decodedHeading = rawHeading ? safeDecodeURIComponent(rawHeading) : undefined; if (decodedHeading === null) return null; - const docSlug = cleanDocSlug(decodedDocPart); + const docSlug = cleanDocSlug(decodedDocSlug); if (!docSlug) return null; return { @@ -48,6 +53,14 @@ export function parseDocsHash(hash: string): DocLinkTarget | null { }; } +export function parseDocsLocation(location: Pick): DocLinkTarget | null { + return parseDocsPath(location.pathname, location.hash); +} + +export function isDocsPath(pathname: string): boolean { + return parseDocsPath(pathname) !== null; +} + export function resolveDocHref( href: string, currentDocSlug: string, diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts index f456281..a14185f 100644 --- a/src/lib/export3mf.ts +++ b/src/lib/export3mf.ts @@ -2,6 +2,7 @@ import JSZip from 'jszip'; import * as THREE from 'three'; import { MINIMAL_PROJECT_SETTINGS, KROMACUT_CONFIG } from './slicerDefaults'; import { clampProgress, exportMeshProgress, exportZipProgress, progressInSpan } from './progress'; +import { normalizeHexColor } from './colorUtils'; export interface Export3MFOptions { layerHeight?: number; @@ -22,6 +23,18 @@ type ExportGeometrySource = { itemSize?: number; }; +/** + * Meshes tagged with the same `userData.kromacutExportGroup` key are merged + * into a single 3MF object (used by Flat Paint to export one object per + * physical filament). Untagged meshes keep the one-object-per-mesh behavior. + */ +interface ExportMeshGroup { + meshes: THREE.Mesh[]; + overrideHex?: string; + materialKey?: string; + partName?: string; +} + function getKromacutExportGeometry(geometry: THREE.BufferGeometry): ExportGeometrySource | null { const source = geometry.userData?.kromacutExportGeometry as ExportGeometrySource | undefined; if (!source?.positions || !source.indices) return null; @@ -32,6 +45,11 @@ function getKromacutExportGeometry(geometry: THREE.BufferGeometry): ExportGeomet }; } +function readMeshUserDataString(mesh: THREE.Mesh, key: string): string | undefined { + const value = (mesh.userData as Record | undefined)?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, @@ -79,36 +97,78 @@ export async function exportObjectTo3MFBlob( if (meshes.length === 0) throw new Error('No meshes to export'); + // Group meshes into exported objects (see ExportMeshGroup). + const groups: ExportMeshGroup[] = []; + const groupByKey = new Map(); + + meshes.forEach((mesh, meshIndex) => { + const groupKey = readMeshUserDataString(mesh, 'kromacutExportGroup'); + const meshHex = readMeshUserDataString(mesh, 'kromacutFilamentHex'); + const materialKey = readMeshUserDataString(mesh, 'kromacutMaterialKey'); + const meshName = readMeshUserDataString(mesh, 'kromacutPartName'); + + if (groupKey) { + if (!meshHex) { + throw new Error(`Export group "${groupKey}" is missing kromacutFilamentHex`); + } + let group = groupByKey.get(groupKey); + if (!group) { + group = { + meshes: [], + overrideHex: meshHex, + materialKey: materialKey ?? groupKey, + partName: meshName, + }; + groupByKey.set(groupKey, group); + groups.push(group); + } + group.meshes.push(mesh); + if (meshHex !== group.overrideHex) { + throw new Error(`Export group "${groupKey}" contains multiple filament colors`); + } + group.materialKey ??= materialKey ?? groupKey; + group.partName ??= meshName; + } else { + // Untagged meshes keep positional filament color mapping by mesh index. + groups.push({ + meshes: [mesh], + overrideHex: meshHex ?? options?.layerFilamentColors?.[meshIndex], + partName: meshName, + }); + } + }); + // Collect materials (colors) // We map hex string -> index in basematerials const colorMap = new Map(); const colors: string[] = []; const normalizeHex = (hex?: string): string | null => { - if (!hex) return null; - const cleaned = hex.replace('#', '').toUpperCase(); - return cleaned.length === 6 ? cleaned : null; + const normalized = normalizeHexColor(hex, ''); + return normalized ? normalized.slice(1) : null; }; const getMaterialIndex = ( material: THREE.Material | THREE.Material[], - overrideHex?: string + overrideHex?: string, + materialKey?: string ): number => { const mat = Array.isArray(material) ? material[0] : material; let hex = normalizeHex(overrideHex) || 'FFFFFF'; if (!overrideHex && 'color' in mat && (mat as THREE.MeshStandardMaterial).color) { hex = (mat as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); } - if (!colorMap.has(hex)) { - colorMap.set(hex, colors.length); + const mapKey = materialKey ? `${materialKey}:${hex}` : hex; + if (!colorMap.has(mapKey)) { + colorMap.set(mapKey, colors.length); colors.push(hex); } - return colorMap.get(hex)!; + return colorMap.get(mapKey)!; }; // Pre-calculate all materials so we can write the header correctly - for (let i = 0; i < meshes.length; i++) { - getMaterialIndex(meshes[i].material, options?.layerFilamentColors?.[i]); + for (const group of groups) { + getMaterialIndex(group.meshes[0].material, group.overrideHex, group.materialKey); } // Prepare Project Settings (Minimal) @@ -217,38 +277,36 @@ export async function exportObjectTo3MFBlob( const reportProgress = (value: number) => { onProgress?.(clampProgress(value)); }; - const totalMeshes = meshes.length; + const totalGroups = groups.length; // Mesh generation is the first 80%; zip generation owns the final 20%. - const reportMeshProgress = (meshIdx: number, meshFrac: number) => { + const reportMeshProgress = (groupIdx: number, meshFrac: number) => { if (!onProgress) return; - reportProgress(exportMeshProgress(meshIdx, totalMeshes, meshFrac)); + reportProgress(exportMeshProgress(groupIdx, totalGroups, meshFrac)); }; - for (let i = 0; i < meshes.length; i++) { - const mesh = meshes[i]; - const overrideHex = options?.layerFilamentColors?.[i]; - const matIdx = getMaterialIndex(mesh.material, overrideHex); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const overrideHex = group.overrideHex; + const matIdx = getMaterialIndex(group.meshes[0].material, overrideHex, group.materialKey); const objectId = nextId++; componentIds.push(objectId); let hex = normalizeHex(overrideHex) || 'FFFFFF'; - if ( - !overrideHex && - 'color' in mesh.material && - (mesh.material as THREE.MeshStandardMaterial).color - ) { - hex = (mesh.material as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); + const firstMaterial = group.meshes[0].material; + const firstMat = Array.isArray(firstMaterial) ? firstMaterial[0] : firstMaterial; + if (!overrideHex && 'color' in firstMat && (firstMat as THREE.MeshStandardMaterial).color) { + hex = (firstMat as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); } + const objectName = group.partName ?? `Layer ${i + 1} (#${hex})`; // Use 1-based index for color/extruder componentMeta.push({ id: objectId, - name: `Layer ${i + 1} (#${hex})`, + name: objectName, colorIdx: matIdx + 1, }); - const layerName = `Layer ${i + 1} (#${hex})`; - const writeMeshObject = async ( - mesh: THREE.Mesh, + const writeMeshGroupObject = async ( + groupMeshes: THREE.Mesh[], meshObjectId: number, meshName: string, progressStart: number, @@ -258,10 +316,6 @@ export async function exportObjectTo3MFBlob( `); write(` `); - const geom = mesh.geometry; - const pos = geom.getAttribute('position'); - const index = geom.getIndex(); - const source = getKromacutExportGeometry(geom); const phaseProgress = (value: number) => progressInSpan(progressStart, progressSpan, value); const COLLECT_START = phaseProgress(0); @@ -269,216 +323,9 @@ export async function exportObjectTo3MFBlob( const VERTEX_WRITE_END = phaseProgress(0.68); const TRIANGLE_WRITE_END = phaseProgress(1); - if (source?.indices) { - const positions = source.positions; - const indices = source.indices; - const itemSize = source.itemSize ?? 3; - const matrix = mesh.matrixWorld; - const matrixElements = matrix.elements; - const sourceVertexCount = Math.floor(positions.length / itemSize); - const sourceTriangleCount = Math.floor(indices.length / 3); - const sourceToExportVertex = new Int32Array(sourceVertexCount); - sourceToExportVertex.fill(-1); - const exportVertexMap = new Map(); - const exportVertexCoords: number[] = []; - const triangleChunks: TriangleIndexChunk[] = []; - const TRIANGLE_CHUNK_INDICES = 300000; - let currentTriangleChunk = new Uint32Array(TRIANGLE_CHUNK_INDICES); - let currentTriangleChunkLength = 0; - let exportTriangleCount = 0; - - const getSourceExportVertex = (sourceIndex: number) => { - if ( - !Number.isInteger(sourceIndex) || - sourceIndex < 0 || - sourceIndex >= sourceVertexCount - ) { - return -1; - } - - const cached = sourceToExportVertex[sourceIndex]; - if (cached !== -1) { - return cached >= 0 ? cached : -1; - } - - const sourceOffset = sourceIndex * itemSize; - const x = positions[sourceOffset]; - const y = positions[sourceOffset + 1]; - const z = positions[sourceOffset + 2]; - - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { - sourceToExportVertex[sourceIndex] = -2; - return -1; - } - - const transformedX = - matrixElements[0] * x + - matrixElements[4] * y + - matrixElements[8] * z + - matrixElements[12]; - const transformedY = - matrixElements[1] * x + - matrixElements[5] * y + - matrixElements[9] * z + - matrixElements[13]; - const transformedZ = - matrixElements[2] * x + - matrixElements[6] * y + - matrixElements[10] * z + - matrixElements[14]; - - const coordX = toCoordUnits(transformedX); - const coordY = toCoordUnits(transformedY); - const coordZ = toCoordUnits(transformedZ); - const key = `${coordX},${coordY},${coordZ}`; - let exportIndex = exportVertexMap.get(key); - - if (exportIndex === undefined) { - exportIndex = exportVertexCoords.length / 3; - exportVertexMap.set(key, exportIndex); - exportVertexCoords.push(coordX, coordY, coordZ); - } - - sourceToExportVertex[sourceIndex] = exportIndex; - return exportIndex; - }; - - const flushTriangleChunk = () => { - if (currentTriangleChunkLength === 0) return; - triangleChunks.push({ - data: currentTriangleChunk, - length: currentTriangleChunkLength, - }); - currentTriangleChunk = new Uint32Array(TRIANGLE_CHUNK_INDICES); - currentTriangleChunkLength = 0; - }; - - const pushExportTriangle = (v1: number, v2: number, v3: number) => { - if (currentTriangleChunkLength + 3 > currentTriangleChunk.length) { - flushTriangleChunk(); - } - - currentTriangleChunk[currentTriangleChunkLength++] = v1; - currentTriangleChunk[currentTriangleChunkLength++] = v2; - currentTriangleChunk[currentTriangleChunkLength++] = v3; - exportTriangleCount++; - }; - - const addExportTriangle = (sourceA: number, sourceB: number, sourceC: number) => { - const v1 = getSourceExportVertex(sourceA); - const v2 = getSourceExportVertex(sourceB); - const v3 = getSourceExportVertex(sourceC); - - if (v1 < 0 || v2 < 0 || v3 < 0 || v1 === v2 || v2 === v3 || v1 === v3) { - return; - } - - const p1 = v1 * 3; - const p2 = v2 * 3; - const p3 = v3 * 3; - const abx = exportVertexCoords[p2] - exportVertexCoords[p1]; - const aby = exportVertexCoords[p2 + 1] - exportVertexCoords[p1 + 1]; - const abz = exportVertexCoords[p2 + 2] - exportVertexCoords[p1 + 2]; - const acx = exportVertexCoords[p3] - exportVertexCoords[p1]; - const acy = exportVertexCoords[p3 + 1] - exportVertexCoords[p1 + 1]; - const acz = exportVertexCoords[p3 + 2] - exportVertexCoords[p1 + 2]; - const crossX = aby * acz - abz * acy; - const crossY = abz * acx - abx * acz; - const crossZ = abx * acy - aby * acx; - - if (crossX === 0 && crossY === 0 && crossZ === 0) { - return; - } - - pushExportTriangle(v1, v2, v3); - }; - - for (let j = 0; j < sourceTriangleCount; j++) { - addExportTriangle(indices[j * 3], indices[j * 3 + 1], indices[j * 3 + 2]); - - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - sourceTriangleCount > 0 ? (j + 1) / sourceTriangleCount : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - flushTriangleChunk(); - reportMeshProgress(i, COLLECT_END); - - write(` -`); - - const exportVertexCount = exportVertexCoords.length / 3; - for (let j = 0; j < exportVertexCoords.length; j += 3) { - const vertexIndex = j / 3; - write(` -`); - - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_END, - VERTEX_WRITE_END - COLLECT_END, - exportVertexCount > 0 ? (vertexIndex + 1) / exportVertexCount : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - reportMeshProgress(i, VERTEX_WRITE_END); - write(` -`); - write(` -`); - - let trianglesWritten = 0; - for (const chunk of triangleChunks) { - for (let j = 0; j < chunk.length; j += 3) { - write(` -`); - trianglesWritten++; - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - VERTEX_WRITE_END, - TRIANGLE_WRITE_END - VERTEX_WRITE_END, - exportTriangleCount > 0 - ? trianglesWritten / exportTriangleCount - : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - } - reportMeshProgress(i, TRIANGLE_WRITE_END); - - write(` -`); - write(` -`); - write(` -`); - exportVertexMap.clear(); - exportVertexCoords.length = 0; - return; - } - - const exportVertexMap = new Map(); + // Shared output buffers for the whole object. Vertex welding is + // reset per member mesh so each member stays an independent + // closed shell inside the exported object. const exportVertexCoords: number[] = []; const triangleChunks: TriangleIndexChunk[] = []; const TRIANGLE_CHUNK_INDICES = 300000; @@ -486,24 +333,6 @@ export async function exportObjectTo3MFBlob( let currentTriangleChunkLength = 0; let exportTriangleCount = 0; - const getExportVertex = (vertexIndex: number) => { - v.fromBufferAttribute(pos, vertexIndex).applyMatrix4(mesh.matrixWorld); - const x = toCoordUnits(v.x); - const y = toCoordUnits(v.y); - const z = toCoordUnits(v.z); - const key = `${x},${y},${z}`; - const existing = exportVertexMap.get(key); - - if (existing !== undefined) { - return existing; - } - - const exportIndex = exportVertexCoords.length / 3; - exportVertexMap.set(key, exportIndex); - exportVertexCoords.push(x, y, z); - return exportIndex; - }; - const flushTriangleChunk = () => { if (currentTriangleChunkLength === 0) return; triangleChunks.push({ @@ -525,12 +354,8 @@ export async function exportObjectTo3MFBlob( exportTriangleCount++; }; - const addExportTriangle = (a: number, b: number, c: number) => { - const v1 = getExportVertex(a); - const v2 = getExportVertex(b); - const v3 = getExportVertex(c); - - if (v1 === v2 || v2 === v3 || v1 === v3) { + const addExportTriangleByIndex = (v1: number, v2: number, v3: number) => { + if (v1 < 0 || v2 < 0 || v3 < 0 || v1 === v2 || v2 === v3 || v1 === v3) { return; } @@ -554,43 +379,163 @@ export async function exportObjectTo3MFBlob( pushExportTriangle(v1, v2, v3); }; - if (index) { - const elementCount = index.count; - for (let j = 0; j < elementCount; j += 3) { - addExportTriangle(index.getX(j), index.getX(j + 1), index.getX(j + 2)); - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - (j + 3) / elementCount - ) + const memberCount = groupMeshes.length; + const collectSpan = COLLECT_END - COLLECT_START; + + for (let memberIdx = 0; memberIdx < memberCount; memberIdx++) { + const mesh = groupMeshes[memberIdx]; + const geom = mesh.geometry; + const pos = geom.getAttribute('position'); + const index = geom.getIndex(); + const source = getKromacutExportGeometry(geom); + const memberCollectStart = + COLLECT_START + (collectSpan * memberIdx) / memberCount; + const memberCollectSpan = collectSpan / memberCount; + const reportCollect = (fraction: number) => { + reportMeshProgress( + i, + progressInSpan(memberCollectStart, memberCollectSpan, fraction) + ); + }; + + // Per-member vertex welding map (see note above). + const exportVertexMap = new Map(); + + const addCoordVertex = (coordX: number, coordY: number, coordZ: number) => { + const key = `${coordX},${coordY},${coordZ}`; + let exportIndex = exportVertexMap.get(key); + + if (exportIndex === undefined) { + exportIndex = exportVertexCoords.length / 3; + exportVertexMap.set(key, exportIndex); + exportVertexCoords.push(coordX, coordY, coordZ); + } + + return exportIndex; + }; + + if (source?.indices) { + const positions = source.positions; + const indices = source.indices; + const itemSize = source.itemSize ?? 3; + const matrixElements = mesh.matrixWorld.elements; + const sourceVertexCount = Math.floor(positions.length / itemSize); + const sourceTriangleCount = Math.floor(indices.length / 3); + const sourceToExportVertex = new Int32Array(sourceVertexCount); + sourceToExportVertex.fill(-1); + + const getSourceExportVertex = (sourceIndex: number) => { + if ( + !Number.isInteger(sourceIndex) || + sourceIndex < 0 || + sourceIndex >= sourceVertexCount + ) { + return -1; + } + + const cached = sourceToExportVertex[sourceIndex]; + if (cached !== -1) { + return cached >= 0 ? cached : -1; + } + + const sourceOffset = sourceIndex * itemSize; + const x = positions[sourceOffset]; + const y = positions[sourceOffset + 1]; + const z = positions[sourceOffset + 2]; + + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + sourceToExportVertex[sourceIndex] = -2; + return -1; + } + + const transformedX = + matrixElements[0] * x + + matrixElements[4] * y + + matrixElements[8] * z + + matrixElements[12]; + const transformedY = + matrixElements[1] * x + + matrixElements[5] * y + + matrixElements[9] * z + + matrixElements[13]; + const transformedZ = + matrixElements[2] * x + + matrixElements[6] * y + + matrixElements[10] * z + + matrixElements[14]; + + const exportIndex = addCoordVertex( + toCoordUnits(transformedX), + toCoordUnits(transformedY), + toCoordUnits(transformedZ) ); - await new Promise((resolve) => setTimeout(resolve, 0)); + + sourceToExportVertex[sourceIndex] = exportIndex; + return exportIndex; + }; + + for (let j = 0; j < sourceTriangleCount; j++) { + addExportTriangleByIndex( + getSourceExportVertex(indices[j * 3]), + getSourceExportVertex(indices[j * 3 + 1]), + getSourceExportVertex(indices[j * 3 + 2]) + ); + + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect( + sourceTriangleCount > 0 ? (j + 1) / sourceTriangleCount : 1 + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + } } - } - } else { - const elementCount = pos.count; - for (let j = 0; j < elementCount; j += 3) { - addExportTriangle(j, j + 1, j + 2); - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - (j + 3) / elementCount - ) + } else { + const getExportVertex = (vertexIndex: number) => { + v.fromBufferAttribute(pos, vertexIndex).applyMatrix4(mesh.matrixWorld); + return addCoordVertex(toCoordUnits(v.x), toCoordUnits(v.y), toCoordUnits(v.z)); + }; + + const addAttributeTriangle = (a: number, b: number, c: number) => { + addExportTriangleByIndex( + getExportVertex(a), + getExportVertex(b), + getExportVertex(c) ); - await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + if (index) { + const elementCount = index.count; + for (let j = 0; j < elementCount; j += 3) { + addAttributeTriangle( + index.getX(j), + index.getX(j + 1), + index.getX(j + 2) + ); + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect((j + 3) / elementCount); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + } else { + const elementCount = pos.count; + for (let j = 0; j < elementCount; j += 3) { + addAttributeTriangle(j, j + 1, j + 2); + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect((j + 3) / elementCount); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } } } + + exportVertexMap.clear(); } + flushTriangleChunk(); reportMeshProgress(i, COLLECT_END); @@ -652,13 +597,12 @@ export async function exportObjectTo3MFBlob( `); write(` `); - exportVertexMap.clear(); exportVertexCoords.length = 0; triangleChunks.length = 0; currentTriangleChunk = new Uint32Array(0); }; - await writeMeshObject(mesh, objectId, layerName, 0, 1); + await writeMeshGroupObject(group.meshes, objectId, objectName, 0, 1); } // Assembly Object diff --git a/src/lib/flatPaint.ts b/src/lib/flatPaint.ts new file mode 100644 index 0000000..1786c6f --- /dev/null +++ b/src/lib/flatPaint.ts @@ -0,0 +1,344 @@ +/** + * Flat Paint layout planning for Auto-paint face-down flat prints. + * + * A normal auto-paint model varies each pixel column's height: the stack is + * built dark-to-light from the plate and the image is viewed from the stepped + * top surface. Flat Paint instead produces a uniform-thickness slab that is + * printed FACE DOWN: + * + * 1. Each pixel column's layer sequence is REVERSED so the final visible + * blend layer touches the build plate. Optically this is identical to the + * normal print viewed from above, because the filament order along the + * viewing axis is unchanged. + * 2. A transparent carrier layer (printed first, in clear filament) absorbs + * the slicer's thick first layer so every image layer keeps its exact + * simulated thickness, and protects the image face. + * 3. The space behind each column (above its reversed stack) is backfilled + * with the foundation filament so every printed layer has the exact same + * footprint — the defining Flat Paint property. + * + * Because a single printed layer now contains several filaments side by side, + * Flat Paint prints require a multi-material setup (AMS/toolchanger). Parts are + * therefore tagged with a per-filament export group so the 3MF exporter can + * emit one object per physical filament. + * + * Coordinate conventions: the caller provides a per-pixel layer-count grid + * already oriented for the 3D scene (Y flipped) and mirrored in X — mirroring + * is required so the artwork reads correctly after the finished print is + * flipped over. + */ + +import { normalizeHexColor } from './colorUtils.ts'; +import { LAYER_ACTIVATION_EPSILON } from './layerActivation.ts'; + +/** A solid axis-aligned slab of a single color in the flat stack */ +export interface FlatPaintPart { + kind: 'carrier' | 'face' | 'zone' | 'backing'; + /** + * Pixel layer-count class this part belongs to (pixels whose columns + * contain exactly `classIndex` layers). 0 for the carrier, which spans + * every opaque pixel. + */ + classIndex: number; + /** Mask of active pixels (shared between parts of the same class) */ + mask: Uint8Array; + activeCount: number; + /** Z range of the slab in mm, measured from the build plate */ + baseZ: number; + topZ: number; + /** Color used for the preview mesh material */ + previewHex: string; + /** Physical filament color used for export color mapping */ + filamentHex: string; + /** 3MF object grouping key — one exported object per physical filament */ + exportGroup: string; + /** Human-readable part name for slicer metadata */ + partName: string; +} + +export interface FlatPaintLayout { + parts: FlatPaintPart[]; + /** + * Uniform slab height: carrier + tallest present column class × + * layerHeight. Trailing stack layers no pixel reaches are trimmed. + */ + totalHeight: number; + carrierThickness: number; + /** Number of distinct pixel layer-count classes found in the image */ + classCount: number; +} + +export interface FlatPaintLayoutOptions { + /** + * Per-pixel layer counts (0 = transparent pixel, otherwise 1..layerCount), + * already oriented for the scene (Y flipped) and mirrored in X for + * face-down printing. + */ + layerCounts: Uint16Array | Uint8Array; + width: number; + height: number; + /** Total number of layers in the auto-paint stack */ + layerCount: number; + /** Uniform image layer thickness in mm */ + layerHeight: number; + /** Thickness of the transparent carrier layer in mm */ + carrierThickness: number; + /** Per-layer blended preview colors (virtual swatches), bottom-up order */ + layerVirtualHexes: string[]; + /** Per-layer physical filament colors, bottom-up order */ + layerFilamentHexes: string[]; +} + +export const FLAT_PAINT_CARRIER_GROUP = 'flat-paint:carrier'; +export const FLAT_PAINT_CARRIER_HEX = '#D8FFF8'; + +/** + * Convert a per-pixel target height map (mm) into per-pixel layer counts. + * + * A pixel's column contains layer `i` when its height reaches that layer's + * cumulative top — the same `height >= top - epsilon` rule the normal + * auto-paint mask build uses, so flat and normal geometry stay consistent. + * + * @param pixelHeightMap - Per-pixel target heights in mm (0 = transparent) + * @param cumulativeHeights - Cumulative layer top heights, bottom-up + * @param epsilon - Height comparison tolerance (default matches mask build) + */ +export function heightMapToLayerCounts( + pixelHeightMap: Float32Array, + cumulativeHeights: number[], + epsilon: number = LAYER_ACTIVATION_EPSILON +): Uint16Array { + const counts = new Uint16Array(pixelHeightMap.length); + const layerCount = cumulativeHeights.length; + if (layerCount === 0) return counts; + + for (let i = 0; i < pixelHeightMap.length; i++) { + const h = pixelHeightMap[i]; + if (h <= 0) continue; + + // Binary search: number of cumulative tops <= h + epsilon + let lo = 0; + let hi = layerCount; + const target = h + epsilon; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (cumulativeHeights[mid] <= target) lo = mid + 1; + else hi = mid; + } + counts[i] = Math.max(1, Math.min(layerCount, lo)); + } + + return counts; +} + +/** + * Convert normal auto-paint target heights into Flat Paint image-layer counts. + * + * Auto-paint heights are generated for a normal print where the first colored + * layer may be the slicer's thicker first layer. Flat Paint moves that thick + * first layer to the transparent carrier, so image colors are counted on + * regular image-layer steps behind the carrier. + */ +export function heightMapToFlatPaintLayerCounts( + pixelHeightMap: Float32Array, + cumulativeHeights: number[], + imageLayerHeight: number, + epsilon: number = LAYER_ACTIVATION_EPSILON +): Uint16Array { + if (cumulativeHeights.length === 0) return new Uint16Array(pixelHeightMap.length); + if (imageLayerHeight <= 0) { + return heightMapToLayerCounts(pixelHeightMap, cumulativeHeights, epsilon); + } + + const normalFirstTop = cumulativeHeights[0] ?? imageLayerHeight; + const firstLayerOffset = Math.max(0, normalFirstTop - imageLayerHeight); + const flatCumulativeHeights = cumulativeHeights.map((_, index) => + Number(((index + 1) * imageLayerHeight).toFixed(8)) + ); + const adjustedHeightMap = new Float32Array(pixelHeightMap.length); + + for (let i = 0; i < pixelHeightMap.length; i++) { + const h = pixelHeightMap[i]; + adjustedHeightMap[i] = h > 0 ? Math.max(0, h - firstLayerOffset) : 0; + } + + return heightMapToLayerCounts(adjustedHeightMap, flatCumulativeHeights, epsilon); +} + +/** + * Plan the solid parts of a uniform face-down slab. + * + * Pixels are grouped into classes by their layer count `k`. Each class + * produces (bottom-up, in printed orientation): + * + * - a FACE slab at the plate: the column's top layer (index k-1), colored + * with its blended virtual swatch — this is the visible artwork surface; + * - ZONE slabs for the remaining reversed layers (k-2 down to 0), with + * consecutive layers of the same physical filament merged into one box; + * - a BACKING slab from the end of the column to the slab top, in the + * foundation filament (layer 0), when the column is shorter than the stack. + * + * Every opaque pixel additionally receives the transparent CARRIER slab at + * [0, carrierThickness]; all image slabs are shifted up by the carrier. + */ +export function buildFlatPaintLayout(options: FlatPaintLayoutOptions): FlatPaintLayout { + const { + layerCounts, + width, + height, + layerCount, + layerHeight, + carrierThickness, + layerVirtualHexes, + layerFilamentHexes, + } = options; + + const parts: FlatPaintPart[] = []; + const pixelCount = width * height; + + if (layerCount <= 0 || layerHeight <= 0 || pixelCount === 0) { + return { + parts, + totalHeight: Math.max(0, carrierThickness), + carrierThickness, + classCount: 0, + }; + } + + // --- Gather per-class masks (and the opaque mask for the carrier) --- + const classActiveCounts = new Uint32Array(layerCount + 1); + let opaqueCount = 0; + + for (let i = 0; i < pixelCount; i++) { + const k = layerCounts[i]; + if (k <= 0) continue; + const clamped = Math.min(layerCount, k); + classActiveCounts[clamped]++; + opaqueCount++; + } + + if (opaqueCount === 0) { + return { parts, totalHeight: carrierThickness, carrierThickness, classCount: 0 }; + } + + // The auto-paint stack can end with layers no pixel actually reaches + // (normal mode just skips their empty masks). Padding backing up to those + // phantom layers would only waste height and filament, so size the slab + // to the tallest column class that is actually present. + let effectiveLayerCount = 0; + for (let k = layerCount; k >= 1; k--) { + if (classActiveCounts[k] > 0) { + effectiveLayerCount = k; + break; + } + } + + const totalHeight = carrierThickness + effectiveLayerCount * layerHeight; + + const opaqueMask = new Uint8Array(pixelCount); + const classMasks = new Map(); + for (let k = 1; k <= layerCount; k++) { + if (classActiveCounts[k] > 0) classMasks.set(k, new Uint8Array(pixelCount)); + } + + for (let i = 0; i < pixelCount; i++) { + const k = layerCounts[i]; + if (k <= 0) continue; + opaqueMask[i] = 1; + classMasks.get(Math.min(layerCount, k))![i] = 1; + } + + const levelBase = (level: number) => carrierThickness + level * layerHeight; + const filamentHex = (layer: number) => + normalizeHexColor( + layerFilamentHexes[layer], + normalizeHexColor(layerVirtualHexes[layer], '#888888') + ); + const virtualHex = (layer: number) => + normalizeHexColor(layerVirtualHexes[layer], filamentHex(layer)); + const filamentGroup = (hex: string) => `flat-paint:filament:${hex}`; + const filamentPartName = (hex: string) => `Flat Paint filament (${hex})`; + + // --- Carrier slab: full opaque footprint at the plate --- + parts.push({ + kind: 'carrier', + classIndex: 0, + mask: opaqueMask, + activeCount: opaqueCount, + baseZ: 0, + topZ: carrierThickness, + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + exportGroup: FLAT_PAINT_CARRIER_GROUP, + partName: 'Flat Paint transparent carrier (use clear filament)', + }); + + // --- Per-class slabs --- + const foundationHex = filamentHex(0); + + for (const [k, mask] of classMasks) { + const activeCount = classActiveCounts[k]; + + // Face slab: printed level 0 = the column's top (visible) layer k-1. + // Preview uses the blended virtual color so the face shows the artwork. + parts.push({ + kind: 'face', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(0), + topZ: levelBase(1), + previewHex: virtualHex(k - 1), + filamentHex: filamentHex(k - 1), + exportGroup: filamentGroup(filamentHex(k - 1)), + partName: filamentPartName(filamentHex(k - 1)), + }); + + // Zone slabs: printed level j holds original layer k-1-j. Merge runs + // of consecutive levels that use the same physical filament. + let runStart = 1; + while (runStart < k) { + const runHex = filamentHex(k - 1 - runStart); + let runEnd = runStart; + while (runEnd + 1 < k && filamentHex(k - 1 - (runEnd + 1)) === runHex) { + runEnd++; + } + + parts.push({ + kind: 'zone', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(runStart), + topZ: levelBase(runEnd + 1), + previewHex: runHex, + filamentHex: runHex, + exportGroup: filamentGroup(runHex), + partName: filamentPartName(runHex), + }); + + runStart = runEnd + 1; + } + + // Backing slab: fill behind the column up to the uniform slab top. + if (k < effectiveLayerCount) { + parts.push({ + kind: 'backing', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(k), + topZ: levelBase(effectiveLayerCount), + previewHex: foundationHex, + filamentHex: foundationHex, + exportGroup: filamentGroup(foundationHex), + partName: filamentPartName(foundationHex), + }); + } + } + + // Stable build order: bottom-up by baseZ, then by class for determinism. + parts.sort((a, b) => a.baseZ - b.baseZ || a.classIndex - b.classIndex); + + return { parts, totalHeight, carrierThickness, classCount: classMasks.size }; +} diff --git a/src/lib/layerActivation.ts b/src/lib/layerActivation.ts new file mode 100644 index 0000000..ef91979 --- /dev/null +++ b/src/lib/layerActivation.ts @@ -0,0 +1,5 @@ +/** + * Height tolerance used when deciding whether a pixel's target height reaches + * a layer's cumulative top. + */ +export const LAYER_ACTIVATION_EPSILON = 0.001; diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts new file mode 100644 index 0000000..c9485cb --- /dev/null +++ b/src/lib/nextBestColor.ts @@ -0,0 +1,421 @@ +import type { Filament } from '../types/index.ts'; + +// --------------------------------------------------------------------------- +// Minimal color math — inlined to avoid pulling in autoPaint's optimizer dep. +// --------------------------------------------------------------------------- + +interface RGB { r: number; g: number; b: number } +interface Lab { L: number; a: number; b: number } + +function hexToRgb(hex: string): RGB { + const h = hex.replace(/^#/, ''); + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +} + +// IEC 61966-2-1 sRGB linearisation thresholds and coefficients. +const SRGB_LINEARISE_THRESHOLD = 0.04045; +const SRGB_LINEARISE_SCALE = 12.92; +const SRGB_LINEARISE_OFFSET = 0.055; +const SRGB_LINEARISE_DENOM = 1.055; +const SRGB_LINEARISE_GAMMA = 2.4; + +// IEC 61966-2-1 sRGB → CIE XYZ (D65) matrix row coefficients. +const M_RX = 0.4124564, M_GX = 0.3575761, M_BX = 0.1804375; +const M_RY = 0.2126729, M_GY = 0.7151522, M_BY = 0.0721750; +const M_RZ = 0.0193339, M_GZ = 0.1191920, M_BZ = 0.9503041; + +// CIE XYZ (D65) → sRGB inverse matrix row coefficients. +const M_INV_XR = 3.2404542, M_INV_YR = -1.5371385, M_INV_ZR = -0.4985314; +const M_INV_XG = -0.9692660, M_INV_YG = 1.8760108, M_INV_ZG = 0.0415560; +const M_INV_XB = 0.0556434, M_INV_YB = -0.2040259, M_INV_ZB = 1.0572252; + +// CIE standard illuminant D65 tristimulus values (normalises XYZ to [0,1]). +const D65_X = 0.95047; +const D65_Y = 1.00000; +const D65_Z = 1.08883; + +// CIE L*a*b* cube-root approximation thresholds and coefficients (CIE 1976). +const LAB_EPSILON = 0.008856; // (6/29)³ +const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment +const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment +const LAB_INV_CBRT = 6 / 29; // cbrt(LAB_EPSILON) — Lab→XYZ cube-root threshold + +// Threshold for linear RGB values in the sRGB de-linearisation step. +// Derived from SRGB_LINEARISE_THRESHOLD / SRGB_LINEARISE_SCALE ≈ 0.0031308. +const SRGB_LINEAR_THRESHOLD = SRGB_LINEARISE_THRESHOLD / SRGB_LINEARISE_SCALE; + +function rgbToLab(rgb: RGB): Lab { + const linearise = (v: number) => { + const s = v / 255; + return s <= SRGB_LINEARISE_THRESHOLD + ? s / SRGB_LINEARISE_SCALE + : Math.pow((s + SRGB_LINEARISE_OFFSET) / SRGB_LINEARISE_DENOM, SRGB_LINEARISE_GAMMA); + }; + const r = linearise(rgb.r), g = linearise(rgb.g), b = linearise(rgb.b); + + const fx = (r * M_RX + g * M_GX + b * M_BX) / D65_X; + const fy = (r * M_RY + g * M_GY + b * M_BY) / D65_Y; + const fz = (r * M_RZ + g * M_GZ + b * M_BZ) / D65_Z; + + const f = (t: number) => t > LAB_EPSILON ? Math.cbrt(t) : LAB_KAPPA * t + LAB_DELTA_16; + return { L: 116 * f(fy) - 16, a: 500 * (f(fx) - f(fy)), b: 200 * (f(fy) - f(fz)) }; +} + +function labToHex(lab: Lab): string { + // Lab → XYZ (D65) + const fy = (lab.L + 16) / 116; + const fx = lab.a / 500 + fy; + const fz = fy - lab.b / 200; + const invF = (f: number) => f > LAB_INV_CBRT ? f * f * f : (f - LAB_DELTA_16) / LAB_KAPPA; + const X = D65_X * invF(fx); + const Y = D65_Y * invF(fy); + const Z = D65_Z * invF(fz); + + // XYZ → linear sRGB (clamp to [0,1] to handle out-of-gamut Lab values) + const rl = Math.max(0, Math.min(1, M_INV_XR * X + M_INV_YR * Y + M_INV_ZR * Z)); + const gl = Math.max(0, Math.min(1, M_INV_XG * X + M_INV_YG * Y + M_INV_ZG * Z)); + const bl = Math.max(0, Math.min(1, M_INV_XB * X + M_INV_YB * Y + M_INV_ZB * Z)); + + // Linear → sRGB + const delinearise = (c: number) => + c <= SRGB_LINEAR_THRESHOLD + ? c * SRGB_LINEARISE_SCALE + : SRGB_LINEARISE_DENOM * Math.pow(c, 1 / SRGB_LINEARISE_GAMMA) - SRGB_LINEARISE_OFFSET; + + const r = Math.round(delinearise(rl) * 255); + const g = Math.round(delinearise(gl) * 255); + const b = Math.round(delinearise(bl) * 255); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +function deltaELab(a: Lab, b: Lab): number { + return Math.sqrt((a.L - b.L) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2); +} + +/** Convert a hex color string to its CIE L*a*b* representation. */ +export function hexToLab(hex: string): Lab { + return rgbToLab(hexToRgb(hex)); +} + +export { labToHex }; + +// Beer-Lambert layer blend: matches autoPaint's blendColors (operates in sRGB [0-255]). +function blendRgb(bg: RGB, fg: RGB, td: number, thickness: number): RGB { + if (td <= 0 || thickness <= 0) return bg; + const t = Math.pow(0.1, thickness / td); + return { r: fg.r + (bg.r - fg.r) * t, g: fg.g + (bg.g - fg.g) * t, b: fg.b + (bg.b - fg.b) * t }; +} + +const BLEND_CURVE_STEPS = 16; + +/** + * Pre-compute the Lab values along a Beer-Lambert blend curve (bg→fg over 3×fgTd). + * Call once per filament pair, then use minCurveDE for each swatch — avoids repeating + * the expensive rgbToLab conversion M times for the same blend curve. + */ +function buildBlendCurve(bgRgb: RGB, fgRgb: RGB, fgTd: number): Lab[] { + const maxT = 3 * Math.max(fgTd, 0.01); + const labs: Lab[] = [rgbToLab(bgRgb)]; + for (let i = 1; i <= BLEND_CURVE_STEPS; i++) { + labs.push(rgbToLab(blendRgb(bgRgb, fgRgb, fgTd, (i / BLEND_CURVE_STEPS) * maxT))); + } + return labs; +} + +function minCurveDE(sLab: Lab, curveLabs: Lab[]): number { + let best = Infinity; + for (const cLab of curveLabs) { + best = Math.min(best, deltaELab(sLab, cLab)); + } + return best; +} + +/** + * Solves blend(C, anchor, t) = target for C in Lab space. + * Returns the color that, when blended with `anchor` at ratio `t`, produces `target`. + */ +function extrapolateLab(target: Lab, anchor: Lab, t: number): Lab { + return { + L: (target.L - (1 - t) * anchor.L) / t, + a: (target.a - (1 - t) * anchor.a) / t, + b: (target.b - (1 - t) * anchor.b) / t, + }; +} + +// Blend ratios used when extrapolating candidate colors from underserved swatches. +// t=0.7 → candidate is close to the swatch (gentle extrapolation) +// t=0.5 → candidate is the reflection of the filament through the swatch +// t=0.3 → more aggressive; may push out of gamut but gets clamped to valid hex +const EXTRAP_BLEND_RATIOS = [0.3, 0.5, 0.7]; + +export interface ColorCandidate { + hex: string; + /** Recommended starting TD, derived from the nearest existing filament by ΔE. */ + td: number; + /** + * % reduction in blend-aware weighted-average ΔE vs current filament set. + * The baseline estimates existing filament↔filament blend potential, so this + * reflects the candidate's estimated inventory-planning benefit. + */ + improvementPct: number; + /** Number of image pixels whose blend-aware error improves with this candidate. */ + pixelsCaptured: number; + /** 0–1: nearest-filament ΔE normalised to [0,1] across viable candidates. Informational only — ranking uses blend-aware gain directly. */ + isolationScore: number; +} + +export interface NextBestColorResult { + candidate: ColorCandidate | null; + /** Blend-aware weighted-average ΔE across all image pixels before adding anything. */ + baselineAvgDeltaE: number; + totalPixels: number; +} + +/** + * Given the current filament set and image swatches, returns the single color + * whose addition as a new filament would most reduce the blend-aware weighted- + * average ΔE between the rendered print and the target image. + * + * Candidate generation (two sources): + * 1. Swatch colors in the p75 most underserved (by blend-aware reachable error). + * 2. Extrapolated heuristic colors: for each underserved swatch S and filament F, + * solve blend(C, F, t) = S in Lab space for C at t ∈ {0.3, 0.5, 0.7}. + * These candidates may improve coverage when paired with an existing filament, + * and are then scored against the same estimated blend curves. + * + * Scoring: for each candidate C, gain = Σ_i max(0, currentReachable_i − + * newReachable_i) × count_i, where newReachable_i is the minimum ΔE achievable + * from swatch i via any existing blend line or any new C↔filament segment. + */ +export function nextBestColor( + filaments: Filament[], + imageSwatches: Array<{ hex: string; count?: number }> +): NextBestColorResult { + const empty: NextBestColorResult = { candidate: null, baselineAvgDeltaE: 0, totalPixels: 0 }; + + if (filaments.length === 0 || imageSwatches.length === 0) return empty; + + // Pre-compute color values for filaments and swatches. + const filamentRgbs: RGB[] = filaments.map((f) => hexToRgb(f.color)); + const filamentLabs: Lab[] = filamentRgbs.map((rgb) => rgbToLab(rgb)); + const filamentTds: number[] = filaments.map((f) => f.td); + const swatchLabs: Lab[] = imageSwatches.map((s) => rgbToLab(hexToRgb(s.hex))); + const counts: number[] = imageSwatches.map((s) => s.count ?? 1); + + // ------------------------------------------------------------------------- + // Baseline: blend-aware reachable error for every swatch. + // Uses sampled Beer-Lambert-style blend curves as an inventory-planning + // heuristic. This estimates potential coverage, but does not predict the + // exact auto-paint stack for the current print settings. + // + // Blend curves are pre-computed once per filament pair so the expensive + // rgbToLab conversion isn't repeated for every swatch. + // ------------------------------------------------------------------------- + const pairCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + for (let fj = fi + 1; fj < filamentRgbs.length; fj++) { + pairCurves.push(buildBlendCurve(filamentRgbs[fi], filamentRgbs[fj], filamentTds[fj])); + pairCurves.push(buildBlendCurve(filamentRgbs[fj], filamentRgbs[fi], filamentTds[fi])); + } + } + + const currentReachable: number[] = swatchLabs.map((sLab) => { + let best = Infinity; + for (const fLab of filamentLabs) { + best = Math.min(best, deltaELab(sLab, fLab)); + } + for (const curve of pairCurves) { + best = Math.min(best, minCurveDE(sLab, curve)); + } + return best; + }); + + const totalPixels = counts.reduce((s, c) => s + c, 0); + + // Residual error below 1 ΔE is below the just-noticeable difference and is + // treated as fully covered. This absorbs Lab↔hex quantisation noise so that + // blend-line midpoints (which land within ~0.5 ΔE of their segment) don't + // show up as a meaningful gap. + const COVERAGE_FLOOR = 1.0; + const effectiveReachable = currentReachable.map(e => e >= COVERAGE_FLOOR ? e : 0); + + const baselineTotal = effectiveReachable.reduce((s, e, i) => s + e * counts[i], 0); + const baselineAvgDeltaE = totalPixels > 0 ? baselineTotal / totalPixels : 0; + + if (baselineAvgDeltaE === 0) return { candidate: null, baselineAvgDeltaE: 0, totalPixels }; + + // ------------------------------------------------------------------------- + // Build candidate pool. + // For each p75-underserved swatch (by weighted contribution = error × count), + // include the swatch color itself plus extrapolated Lab positions derived from + // each filament at each blend ratio. Filtering on weighted contribution means + // a high-frequency moderate-error swatch isn't excluded just because rarer + // swatches have larger raw errors. + // ------------------------------------------------------------------------- + const COVERAGE_THRESHOLD = 3.0; // ΔE — skip near-duplicates of existing filaments + + // Weighted contribution: each swatch's share of the total baseline error. + const weightedContrib = effectiveReachable.map((e, i) => e * counts[i]); + const sortedContrib = [...weightedContrib].sort((a, b) => a - b); + const p75ContribThreshold = sortedContrib[Math.floor(sortedContrib.length * 0.75)]; + + // These thresholds are still on raw error, used only for scoring weights (not filtering). + const sortedReachable = [...effectiveReachable].sort((a, b) => a - b); + const p90Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.90)]; + const maxReachable = sortedReachable[sortedReachable.length - 1]; + + interface LabCandidate { lab: Lab; hex: string } + const seen = new Set(); + const pool: LabCandidate[] = []; + + const addCandidate = (hex: string) => { + if (seen.has(hex)) return; + // Round-trip through hex so the Lab used for scoring and filtering always + // matches the actual representable color. labToHex clamps out-of-gamut + // extrapolations, so using the raw extrapolated Lab would score a phantom + // color and return an inconsistent hex (the original bug). + const lab = rgbToLab(hexToRgb(hex)); + for (const fLab of filamentLabs) { + if (deltaELab(lab, fLab) < COVERAGE_THRESHOLD) return; + } + seen.add(hex); + pool.push({ lab, hex }); + }; + + for (let c = 0; c < swatchLabs.length; c++) { + if (weightedContrib[c] < p75ContribThreshold) continue; + + // The swatch color itself. + addCandidate(imageSwatches[c].hex); + + // Extrapolated: color that, blended with each filament at ratio t, hits this swatch. + for (const fLab of filamentLabs) { + for (const t of EXTRAP_BLEND_RATIOS) { + addCandidate(labToHex(extrapolateLab(swatchLabs[c], fLab, t))); + } + } + } + + if (pool.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; + + // ------------------------------------------------------------------------- + // Score every candidate. + // ------------------------------------------------------------------------- + interface CandidateScore { lab: Lab; hex: string; weightedGain: number; rawGain: number; nearestFilamentDE: number; estimatedTd: number } + const scores: CandidateScore[] = []; + + for (const { lab, hex } of pool) { + let nearestFilamentDE = Infinity; + let estimatedTd = filaments[0].td; + for (let fi = 0; fi < filamentLabs.length; fi++) { + const de = deltaELab(lab, filamentLabs[fi]); + if (de < nearestFilamentDE) { nearestFilamentDE = de; estimatedTd = filamentTds[fi]; } + } + const candidateRgb = hexToRgb(hex); + + // Pre-compute candidate↔filament blend curves once, then check all swatches against them. + const candCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + candCurves.push(buildBlendCurve(filamentRgbs[fi], candidateRgb, estimatedTd)); + candCurves.push(buildBlendCurve(candidateRgb, filamentRgbs[fi], filamentTds[fi])); + } + + let weightedGain = 0; + let rawGain = 0; + for (let i = 0; i < swatchLabs.length; i++) { + let newReachable = currentReachable[i]; + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], lab)); + for (const curve of candCurves) { + newReachable = Math.min(newReachable, minCurveDE(swatchLabs[i], curve)); + } + const improvement = effectiveReachable[i] - newReachable; + if (improvement > 0) { + const w = effectiveReachable[i] >= maxReachable ? 3.0 + : effectiveReachable[i] >= p90Threshold ? 2.0 + : 1.0; + weightedGain += improvement * counts[i] * w; + rawGain += improvement * counts[i]; + } + } + + scores.push({ lab, hex, weightedGain, rawGain, nearestFilamentDE, estimatedTd }); + } + + if (scores.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; + + // Isolation score is informational — blend-aware gain already rewards isolated + // candidates (longer C↔F segments cover more Lab space). + const maxIsolation = Math.max(...scores.map((s) => s.nearestFilamentDE)); + + // Rank by weighted gain; report improvement from unweighted gain so improvementPct ∈ [0,100]. + const winner = scores.reduce((best, s) => s.weightedGain > best.weightedGain ? s : best, scores[0]); + + // ------------------------------------------------------------------------- + // Build the result for the winning candidate. + // ------------------------------------------------------------------------- + + // Pixel capture: swatches whose blend-aware reachable error improves with the winner. + const winnerRgb = hexToRgb(winner.hex); + const winnerCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + winnerCurves.push(buildBlendCurve(filamentRgbs[fi], winnerRgb, winner.estimatedTd)); + winnerCurves.push(buildBlendCurve(winnerRgb, filamentRgbs[fi], filamentTds[fi])); + } + let pixelsCaptured = 0; + for (let i = 0; i < swatchLabs.length; i++) { + if (effectiveReachable[i] === 0) continue; + let newReachable = effectiveReachable[i]; + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], winner.lab)); + for (const curve of winnerCurves) { + newReachable = Math.min(newReachable, minCurveDE(swatchLabs[i], curve)); + } + if (newReachable < effectiveReachable[i]) pixelsCaptured += counts[i]; + } + + // TD: borrow from nearest existing filament by ΔE. + let nearestFilamentIdx = 0; + let nearestDE = Infinity; + for (let fi = 0; fi < filamentLabs.length; fi++) { + const de = deltaELab(winner.lab, filamentLabs[fi]); + if (de < nearestDE) { nearestDE = de; nearestFilamentIdx = fi; } + } + const recommendedTd = filaments[nearestFilamentIdx].td; + + const isolationScore = winner.nearestFilamentDE / maxIsolation; + const improvementPct = (winner.rawGain / baselineTotal) * 100; + + console.group( + `[NextBestColor] ${filaments.length} filament${filaments.length !== 1 ? 's' : ''} → ` + + `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px | ` + + `${pool.length} candidates (${scores.length} scored)` + ); + console.log(` Baseline avg ΔE: ${baselineAvgDeltaE.toFixed(2)} (blend-aware)`); + console.log(` Suggestion: ${winner.hex.toUpperCase()} TD ${recommendedTd.toFixed(2)}`); + console.log( + ` Accuracy gain: +${improvementPct.toFixed(1)}% ` + + `(${pixelsCaptured.toLocaleString()} px / ` + + `${((pixelsCaptured / totalPixels) * 100).toFixed(1)}% of image improve)` + ); + console.log( + ` Isolation: ${isolationScore.toFixed(3)} ` + + `(nearest filament ΔE ${winner.nearestFilamentDE.toFixed(1)} / ` + + `max ${maxIsolation.toFixed(1)})` + ); + console.groupEnd(); + + return { + candidate: { + hex: winner.hex, + td: recommendedTd, + improvementPct, + pixelsCaptured, + isolationScore, + }, + baselineAvgDeltaE, + totalPixels, + }; +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..ddef712 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,99 @@ +import type { DocRecord } from '@/types/docs'; + +export const SITE_URL = 'https://kromacut.com'; +export const SITE_NAME = 'Kromacut'; +export const SOCIAL_IMAGE_URL = `${SITE_URL}/android-chrome-512x512.png`; + +const HOME_TITLE = 'Kromacut - Free Image-to-3D Color Layer Print Generator'; +const HOME_DESCRIPTION = + 'Turn 2D images into color-layered 3D prints for free with Kromacut. Reduce palettes, plan filament swaps, preview layers, and export STL or 3MF models.'; + +function absoluteUrl(pathname: string): string { + return new URL(pathname, SITE_URL).toString(); +} + +export function docPath(docSlug: string): string { + return `/docs/${encodeURIComponent(docSlug)}`; +} + +export function docUrl(docSlug: string): string { + return absoluteUrl(docPath(docSlug)); +} + +function findOrCreateMeta(attribute: 'name' | 'property', key: string): HTMLMetaElement { + let meta = document.querySelector(`meta[${attribute}="${key}"]`); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute(attribute, key); + document.head.appendChild(meta); + } + return meta; +} + +function setMeta(attribute: 'name' | 'property', key: string, content: string) { + findOrCreateMeta(attribute, key).content = content; +} + +function findOrCreateCanonical(): HTMLLinkElement { + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'canonical'; + document.head.appendChild(link); + } + return link; +} + +function applySeo({ + title, + description, + url, + type = 'website', +}: { + title: string; + description: string; + url: string; + type?: string; +}) { + document.title = title; + setMeta('name', 'description', description); + findOrCreateCanonical().href = url; + + setMeta('property', 'og:title', title); + setMeta('property', 'og:description', description); + setMeta('property', 'og:type', type); + setMeta('property', 'og:url', url); + setMeta('property', 'og:site_name', SITE_NAME); + setMeta('property', 'og:image', SOCIAL_IMAGE_URL); + setMeta('property', 'og:image:secure_url', SOCIAL_IMAGE_URL); + + setMeta('name', 'twitter:card', 'summary'); + setMeta('name', 'twitter:title', title); + setMeta('name', 'twitter:description', description); + setMeta('name', 'twitter:image', SOCIAL_IMAGE_URL); +} + +export function applyHomeSeo() { + applySeo({ + title: HOME_TITLE, + description: HOME_DESCRIPTION, + url: absoluteUrl('/'), + }); +} + +export function docSeoTitle(doc: DocRecord): string { + return `${doc.meta.title} | Kromacut Docs`; +} + +export function docSeoDescription(doc: DocRecord): string { + return doc.meta.description ?? `${doc.meta.title} documentation for Kromacut.`; +} + +export function applyDocSeo(doc: DocRecord) { + applySeo({ + title: docSeoTitle(doc), + description: docSeoDescription(doc), + url: docUrl(doc.meta.slug), + type: 'article', + }); +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..18d2e54 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,85 @@ +export type ThemeMode = 'system' | 'dark' | 'light'; +export type ResolvedTheme = 'dark' | 'light'; + +export const THEME_STORAGE_KEY = 'theme'; + +const DEFAULT_THEME_MODE: ThemeMode = 'dark'; +const SYSTEM_THEME_QUERY = '(prefers-color-scheme: dark)'; + +const getSystemThemeQuery = (): MediaQueryList | null => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return null; + } + + return window.matchMedia(SYSTEM_THEME_QUERY); +}; + +export const isThemeMode = (value: string | null): value is ThemeMode => { + return value === 'system' || value === 'dark' || value === 'light'; +}; + +export const getStoredThemeMode = (): ThemeMode => { + if (typeof localStorage === 'undefined') { + return DEFAULT_THEME_MODE; + } + + try { + const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); + return isThemeMode(storedTheme) ? storedTheme : DEFAULT_THEME_MODE; + } catch { + return DEFAULT_THEME_MODE; + } +}; + +export const saveThemeMode = (themeMode: ThemeMode) => { + try { + localStorage.setItem(THEME_STORAGE_KEY, themeMode); + } catch { + // Theme changes should still apply for the current session if storage is blocked. + } +}; + +export const getSystemTheme = (): ResolvedTheme => { + return getSystemThemeQuery()?.matches ? 'dark' : 'light'; +}; + +export const resolveThemeMode = (themeMode: ThemeMode): ResolvedTheme => { + return themeMode === 'system' ? getSystemTheme() : themeMode; +}; + +export const applyResolvedTheme = (resolvedTheme: ResolvedTheme) => { + const isDark = resolvedTheme === 'dark'; + + document.documentElement.classList.toggle('dark', isDark); + document.documentElement.style.colorScheme = resolvedTheme; + + const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + if (themeColorMeta) { + themeColorMeta.content = isDark ? '#0a0a0a' : '#ffffff'; + } +}; + +export const applyThemeMode = (themeMode: ThemeMode): ResolvedTheme => { + const resolvedTheme = resolveThemeMode(themeMode); + applyResolvedTheme(resolvedTheme); + return resolvedTheme; +}; + +export const subscribeToSystemTheme = (onChange: (resolvedTheme: ResolvedTheme) => void) => { + const mediaQuery = getSystemThemeQuery(); + if (!mediaQuery) { + return () => {}; + } + + const handleChange = (event: MediaQueryListEvent) => { + onChange(event.matches ? 'dark' : 'light'); + }; + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + + mediaQuery.addListener(handleChange); + return () => mediaQuery.removeListener(handleChange); +}; diff --git a/src/lib/updatePreferences.ts b/src/lib/updatePreferences.ts new file mode 100644 index 0000000..49094fc --- /dev/null +++ b/src/lib/updatePreferences.ts @@ -0,0 +1,57 @@ +export const UPDATE_CHECK_ON_STARTUP_STORAGE_KEY = 'kromacut:update-check-on-startup'; +export const UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT = 'kromacut:update-check-on-startup-changed'; + +const DEFAULT_CHECK_ON_STARTUP = true; + +export function getUpdateCheckOnStartup(): boolean { + if (typeof window === 'undefined') { + return DEFAULT_CHECK_ON_STARTUP; + } + + try { + return window.localStorage.getItem(UPDATE_CHECK_ON_STARTUP_STORAGE_KEY) !== 'false'; + } catch { + return DEFAULT_CHECK_ON_STARTUP; + } +} + +export function saveUpdateCheckOnStartup(enabled: boolean) { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(UPDATE_CHECK_ON_STARTUP_STORAGE_KEY, String(enabled)); + } catch { + // The in-session preference should still update if storage is blocked. + } + + window.dispatchEvent( + new CustomEvent(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, { detail: enabled }) + ); +} + +export function subscribeToUpdateCheckOnStartup(onChange: (enabled: boolean) => void) { + if (typeof window === 'undefined') { + return () => {}; + } + + const handlePreferenceChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + onChange(typeof detail === 'boolean' ? detail : getUpdateCheckOnStartup()); + }; + + const handleStorageChange = (event: StorageEvent) => { + if (event.key === UPDATE_CHECK_ON_STARTUP_STORAGE_KEY) { + onChange(getUpdateCheckOnStartup()); + } + }; + + window.addEventListener(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, handlePreferenceChange); + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, handlePreferenceChange); + window.removeEventListener('storage', handleStorageChange); + }; +} diff --git a/src/main.tsx b/src/main.tsx index b9f5601..1bbaa06 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,15 +2,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { applyThemeMode, getStoredThemeMode } from './lib/theme'; +import { applyHomeSeo } from './lib/seo'; -// Apply saved theme preference, default to dark -const savedTheme = localStorage.getItem('theme'); -if (savedTheme !== 'light') { - document.documentElement.classList.add('dark'); -} - -// Keep browser tabs compact while the static HTML title remains descriptive for crawlers and previews. -document.title = 'Kromacut'; +// Apply the saved theme preference before React paints. +applyThemeMode(getStoredThemeMode()); +applyHomeSeo(); createRoot(document.getElementById('root')!).render( diff --git a/src/types/index.ts b/src/types/index.ts index e7f246c..1955e62 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,8 @@ export interface ThreeDControlsStateShape { allowRepeatedSwaps?: boolean; heightDithering?: boolean; ditherLineWidth?: number; + /** Flat Paint: build a flat, face-down slab (auto-paint only) */ + flatPaint?: boolean; // Optimizer options optimizerAlgorithm?: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; optimizerSeed?: number; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..dbb4c62 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __APP_VERSION__: string; diff --git a/src/workers/nextBestColor.worker.ts b/src/workers/nextBestColor.worker.ts new file mode 100644 index 0000000..2721024 --- /dev/null +++ b/src/workers/nextBestColor.worker.ts @@ -0,0 +1,38 @@ +/** + * Web Worker for next-best-color suggestions. + * + * The scoring loop can get expensive with large image palettes and filament + * inventories, so keep it off the UI thread. + */ + +import { nextBestColor } from '../lib/nextBestColor'; +import type { NextBestColorResult } from '../lib/nextBestColor'; +import type { Filament } from '../types'; + +export interface NextBestColorWorkerRequest { + id: number; + filaments: Filament[]; + imageSwatches: Array<{ hex: string; count?: number }>; +} + +export interface NextBestColorWorkerResponse { + id: number; + result?: NextBestColorResult; + error?: string; +} + +self.onmessage = (e: MessageEvent) => { + const req = e.data; + + try { + const result = nextBestColor(req.filaments, req.imageSwatches); + const response: NextBestColorWorkerResponse = { id: req.id, result }; + self.postMessage(response); + } catch (err) { + const response: NextBestColorWorkerResponse = { + id: req.id, + error: err instanceof Error ? err.message : String(err), + }; + self.postMessage(response); + } +}; diff --git a/tests/cameraPrefs.test.ts b/tests/cameraPrefs.test.ts new file mode 100644 index 0000000..b241636 --- /dev/null +++ b/tests/cameraPrefs.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Minimal localStorage mock for Node.js +const store: Record = {}; +const mockLocalStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, +}; +Object.defineProperty(globalThis, 'localStorage', { value: mockLocalStorage }); + +const { loadCameraMode, saveCameraMode } = await import('../src/lib/cameraPrefs.ts'); + +test('loadCameraMode returns false when no preference is stored', () => { + mockLocalStorage.clear(); + assert.equal(loadCameraMode(), false); +}); + +test('loadCameraMode returns true after saving orthographic', () => { + saveCameraMode(true); + assert.equal(loadCameraMode(), true); +}); + +test('loadCameraMode returns false after saving perspective', () => { + saveCameraMode(false); + assert.equal(loadCameraMode(), false); +}); + +test('saveCameraMode writes orthographic string for true', () => { + saveCameraMode(true); + assert.equal(mockLocalStorage.getItem('kromacut:3d-camera-mode'), 'orthographic'); +}); + +test('saveCameraMode writes perspective string for false', () => { + saveCameraMode(false); + assert.equal(mockLocalStorage.getItem('kromacut:3d-camera-mode'), 'perspective'); +}); diff --git a/tests/flatPaint.test.ts b/tests/flatPaint.test.ts new file mode 100644 index 0000000..85e8a6c --- /dev/null +++ b/tests/flatPaint.test.ts @@ -0,0 +1,577 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolve } from 'node:path'; +import * as THREE from 'three'; +import JSZip from 'jszip'; +import { createServer } from 'vite'; +import { + buildFlatPaintLayout, + heightMapToFlatPaintLayerCounts, + heightMapToLayerCounts, + FLAT_PAINT_CARRIER_GROUP, + FLAT_PAINT_CARRIER_HEX, + type FlatPaintLayout, + type FlatPaintPart, +} from '../src/lib/flatPaint.ts'; +import { generateGreedyMesh, type MeshData } from '../src/lib/meshing.ts'; +import { exportObjectToStlBlob } from '../src/lib/exportStl.ts'; +import { inspectMeshIntegrity, type MeshIntegrityReport } from './meshDiagnostics.ts'; + +type Export3mfModule = typeof import('../src/lib/export3mf.ts'); + +let export3mfModule: Promise | null = null; + +async function loadExport3mfModule(): Promise { + export3mfModule ??= loadViteModule('/src/lib/export3mf.ts'); + + return export3mfModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { + noDiscovery: true, + }, + resolve: { + alias: { + '@': resolve(process.cwd(), 'src'), + }, + }, + root: process.cwd(), + server: { + hmr: false, + middlewareMode: true, + }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +const noYieldOptions = { + yieldIntervalMs: Infinity, + onYield: async () => undefined, +}; + +class NodeFileReader { + error: Error | null = null; + onerror: ((event: { target: NodeFileReader }) => void) | null = null; + onload: ((event: { target: NodeFileReader }) => void) | null = null; + result: ArrayBuffer | null = null; + + readAsArrayBuffer(blob: Blob) { + void blob + .arrayBuffer() + .then((buffer) => { + this.result = buffer; + this.onload?.({ target: this }); + }) + .catch((error: unknown) => { + this.error = error instanceof Error ? error : new Error(String(error)); + this.onerror?.({ target: this }); + }); + } +} + +function installFileReaderPolyfill() { + if (typeof globalThis.FileReader === 'undefined') { + globalThis.FileReader = NodeFileReader as unknown as typeof FileReader; + } +} + +function reportForMessage(report: MeshIntegrityReport) { + return JSON.stringify( + { + vertexCount: report.vertexCount, + triangleCount: report.triangleCount, + boundaryEdgeCount: report.boundaryEdgeCount, + nonManifoldEdgeCount: report.nonManifoldEdgeCount, + inconsistentWindingEdgeCount: report.inconsistentWindingEdgeCount, + signedVolume: report.signedVolume, + bounds: report.bounds, + }, + null, + 2 + ); +} + +function assertHealthyMesh(label: string, mesh: MeshData) { + const report = inspectMeshIntegrity(mesh); + + assert.ok(report.vertexCount > 0, `${label} should contain vertices`); + assert.ok(report.triangleCount > 0, `${label} should contain triangles`); + assert.equal(report.isValid, true, `${label} integrity failed:\n${reportForMessage(report)}`); + + return report; +} + +/** + * Shared fixture: 3×2 oriented layer-count grid with one transparent pixel. + * + * counts = [1, 2, 3, + * 3, 0, 2] + * + * Stack: 3 layers at 0.1mm under a 0.2mm carrier. Layers 0 and 1 use the + * same physical filament (#000000) so reversed columns must merge them. + */ +const FIXTURE = { + width: 3, + height: 2, + layerCount: 3, + layerHeight: 0.1, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1, 2, 3, 3, 0, 2]), + layerVirtualHexes: ['#101010', '#808080', '#F0F0F0'], + layerFilamentHexes: ['#000000', '#000000', '#FFFFFF'], +}; + +function buildFixtureLayout(): FlatPaintLayout { + return buildFlatPaintLayout({ ...FIXTURE }); +} + +function partsCoveringPixel(layout: FlatPaintLayout, pixelIndex: number) { + return layout.parts + .filter((part) => part.mask[pixelIndex] === 1) + .sort((a, b) => a.baseZ - b.baseZ); +} + +test('heightMapToLayerCounts matches the layer mask activation rule', () => { + const cumulativeHeights = [0.2, 0.3, 0.4]; + const heightMap = Float32Array.from([0.2, 0.3, 0.4, 0.4, 0, 0.3]); + + const counts = heightMapToLayerCounts(heightMap, cumulativeHeights); + + assert.deepEqual(Array.from(counts), [1, 2, 3, 3, 0, 2]); +}); + +test('heightMapToLayerCounts tolerates float noise near layer boundaries', () => { + const cumulativeHeights = [0.2, 0.3, 0.4]; + const heightMap = Float32Array.from([0.3999, 0.2995, 0.35, 0.05]); + + const counts = heightMapToLayerCounts(heightMap, cumulativeHeights); + + // 0.3999 + eps reaches 0.4; 0.2995 + eps reaches 0.3; 0.35 stays at 2 + // layers; tiny positive heights still get the mandatory first layer. + assert.deepEqual(Array.from(counts), [3, 2, 2, 1]); +}); + +test('heightMapToFlatPaintLayerCounts lets the carrier absorb the thick first layer', () => { + const normalCumulativeHeights = [0.2, 0.32, 0.44]; + const heightMap = Float32Array.from([0.2, 0.31, 0.32, 0.44, 0]); + + const counts = heightMapToFlatPaintLayerCounts(heightMap, normalCumulativeHeights, 0.12); + + // The first colored Flat Paint layer is a regular 0.12mm image slab behind + // the 0.20mm carrier, so thresholds shift down by 0.08mm. + assert.deepEqual(Array.from(counts), [1, 1, 2, 3, 0]); +}); + +test('Flat Paint layout tiles every opaque pixel column without gaps or overlaps', () => { + const layout = buildFixtureLayout(); + + assert.equal(layout.totalHeight, FIXTURE.carrierThickness + 3 * FIXTURE.layerHeight); + assert.equal(layout.classCount, 3); + + for (let pixel = 0; pixel < FIXTURE.width * FIXTURE.height; pixel++) { + const covering = partsCoveringPixel(layout, pixel); + + if (FIXTURE.layerCounts[pixel] === 0) { + assert.equal(covering.length, 0, `transparent pixel ${pixel} should have no parts`); + continue; + } + + assert.ok(covering.length > 0, `pixel ${pixel} should be covered`); + assert.equal(covering[0].baseZ, 0, `pixel ${pixel} column should start at the plate`); + + let z = 0; + for (const part of covering) { + assert.ok( + Math.abs(part.baseZ - z) < 1e-9, + `pixel ${pixel} has a gap/overlap at ${z} (part ${part.kind} starts at ${part.baseZ})` + ); + z = part.topZ; + } + assert.ok( + Math.abs(z - layout.totalHeight) < 1e-9, + `pixel ${pixel} column should reach the slab top (got ${z})` + ); + } +}); + +test('Flat Paint layout reverses columns: visible blend at the plate, foundation behind', () => { + const layout = buildFixtureLayout(); + + const expectColumn = ( + pixel: number, + expected: Array & { + baseZ: number; + topZ: number; + }> + ) => { + const covering = partsCoveringPixel(layout, pixel).map((part) => ({ + kind: part.kind, + previewHex: part.previewHex, + filamentHex: part.filamentHex, + baseZ: Number(part.baseZ.toFixed(6)), + topZ: Number(part.topZ.toFixed(6)), + })); + assert.deepEqual(covering, expected, `pixel ${pixel} column mismatch`); + }; + + // Class 1 (single dark layer): face shows the layer-0 blend, backing fills. + expectColumn(0, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#101010', filamentHex: '#000000', baseZ: 0.2, topZ: 0.3 }, + { kind: 'backing', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.5 }, + ]); + + // Class 2: face = layer-1 blend, then reversed layer 0, then backing. + expectColumn(1, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#808080', filamentHex: '#000000', baseZ: 0.2, topZ: 0.3 }, + { kind: 'zone', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.4 }, + { kind: 'backing', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.4, topZ: 0.5 }, + ]); + + // Class 3 (full column): face = layer-2 blend; reversed layers 1 and 0 + // share a filament so they must merge into ONE zone box; no backing. + expectColumn(2, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#F0F0F0', filamentHex: '#FFFFFF', baseZ: 0.2, topZ: 0.3 }, + { kind: 'zone', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.5 }, + ]); +}); + +test('Flat Paint layout trims trailing stack layers no pixel reaches', () => { + // The auto-paint stack can overshoot: here it declares 4 layers but the + // tallest column only uses 2. The slab must stop at carrier + 2 layers + // instead of padding backing up to the phantom layers. + const layout = buildFlatPaintLayout({ + width: 2, + height: 1, + layerCount: 4, + layerHeight: 0.1, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1, 2]), + layerVirtualHexes: ['#101010', '#808080', '#C0C0C0', '#F0F0F0'], + layerFilamentHexes: ['#000000', '#FFFFFF', '#FFFFFF', '#FFFFFF'], + }); + + assert.equal(layout.totalHeight, 0.2 + 2 * 0.1); + + const backings = layout.parts.filter((part) => part.kind === 'backing'); + assert.equal(backings.length, 1, 'only the short column should get backing'); + assert.equal(backings[0].classIndex, 1); + assert.equal(Number(backings[0].topZ.toFixed(6)), 0.4); + + for (const part of layout.parts) { + assert.ok( + part.topZ <= layout.totalHeight + 1e-9, + `${part.kind} part should not exceed the trimmed slab top` + ); + } +}); + +test('Flat Paint carrier consumes the first layer while image slabs stay regular height', () => { + const layout = buildFlatPaintLayout({ + width: 1, + height: 1, + layerCount: 2, + layerHeight: 0.12, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1]), + layerVirtualHexes: ['#222222', '#EEEEEE'], + layerFilamentHexes: ['#000000', '#FFFFFF'], + }); + + const carrier = layout.parts.find((part) => part.kind === 'carrier'); + const face = layout.parts.find((part) => part.kind === 'face'); + + assert.equal(Number(carrier?.baseZ.toFixed(6)), 0); + assert.equal(Number(carrier?.topZ.toFixed(6)), 0.2); + assert.equal(Number(face?.baseZ.toFixed(6)), 0.2); + assert.equal(Number(face?.topZ.toFixed(6)), 0.32); + assert.equal(Number(layout.totalHeight.toFixed(6)), 0.32); +}); + +test('Flat Paint layout groups parts by physical filament for export', () => { + const layout = buildFixtureLayout(); + + const groups = new Set(layout.parts.map((part) => part.exportGroup)); + assert.deepEqual( + Array.from(groups).sort(), + [ + FLAT_PAINT_CARRIER_GROUP, + 'flat-paint:filament:#000000', + 'flat-paint:filament:#FFFFFF', + ].sort() + ); + + for (const part of layout.parts) { + if (part.kind === 'carrier') continue; + assert.equal( + part.exportGroup, + `flat-paint:filament:${part.filamentHex}`, + 'non-carrier parts should group by their physical filament' + ); + } +}); + +test('Flat Paint part masks produce manifold greedy meshes', async () => { + const layout = buildFixtureLayout(); + + for (const [index, part] of layout.parts.entries()) { + const mesh = await generateGreedyMesh( + part.mask, + FIXTURE.width, + FIXTURE.height, + part.topZ - part.baseZ, + part.baseZ, + 0.1, + 1, + noYieldOptions + ); + assertHealthyMesh(`Flat Paint part ${index} (${part.kind})`, mesh); + } +}); + +async function buildFixturePartMeshes() { + const layout = buildFixtureLayout(); + const pixelSize = 0.1; + const root = new THREE.Group(); + + for (const part of layout.parts) { + const meshData = await generateGreedyMesh( + part.mask, + FIXTURE.width, + FIXTURE.height, + part.topZ - part.baseZ, + part.baseZ, + pixelSize, + 1, + noYieldOptions + ); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3)); + geometry.setIndex(meshData.indices); + geometry.userData.kromacutExportGeometry = { + positions: meshData.positions, + indices: meshData.indices, + activePixels: part.mask, + width: FIXTURE.width, + height: FIXTURE.height, + pixelSize, + topZ: part.topZ, + compactHeightfield: true, + }; + + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ color: Number.parseInt(part.previewHex.slice(1), 16) }) + ); + mesh.userData.kromacutExportGroup = part.exportGroup; + mesh.userData.kromacutFilamentHex = part.filamentHex; + mesh.userData.kromacutMaterialKey = part.exportGroup; + mesh.userData.kromacutPartName = part.partName; + root.add(mesh); + } + + return { layout, root }; +} + +test('Flat Paint parts compact into a manifold uniform-height STL slab', async () => { + const { layout, root } = await buildFixturePartMeshes(); + + const blob = await exportObjectToStlBlob(root); + const buffer = await blob.arrayBuffer(); + const view = new DataView(buffer); + const triangleCount = view.getUint32(80, true); + + const positions = new Float32Array(triangleCount * 9); + const indices: number[] = new Array(triangleCount * 3); + let offset = 84; + let positionOffset = 0; + for (let triangle = 0; triangle < triangleCount; triangle++) { + offset += 12; + for (let vertex = 0; vertex < 3; vertex++) { + const vertexIndex = triangle * 3 + vertex; + positions[positionOffset++] = view.getFloat32(offset, true); + positions[positionOffset++] = view.getFloat32(offset + 4, true); + positions[positionOffset++] = view.getFloat32(offset + 8, true); + indices[vertexIndex] = vertexIndex; + offset += 12; + } + offset += 2; + } + assert.equal(offset, buffer.byteLength, 'STL parser should consume the whole binary file'); + + const report = assertHealthyMesh('Flat Paint compact STL slab', { positions, indices }); + + assert.ok(report.bounds, 'compact STL slab should have bounds'); + assert.ok( + Math.abs(report.bounds!.maxZ - layout.totalHeight) < 1e-5, + `slab should be uniformly ${layout.totalHeight}mm tall (got ${report.bounds!.maxZ})` + ); + assert.equal(report.bounds!.minZ, 0, 'slab should start at the plate'); +}); + +function getAttribute(source: string, name: string) { + const match = new RegExp(`${name}="([^"]*)"`).exec(source); + return match?.[1] ?? ''; +} + +interface ParsedExportObject { + id: string; + name: string; + materialIndex: number; + triangleCount: number; + badEdgeCount: number; +} + +function parse3mfMeshObjects(modelXml: string): ParsedExportObject[] { + const objects: ParsedExportObject[] = []; + const objectPattern = /]*)>([\s\S]*?)<\/object>/g; + + for (const match of modelXml.matchAll(objectPattern)) { + const body = match[2]; + if (!body.includes('')) continue; + + const edges = new Map(); + const addEdge = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}`; + edges.set(key, (edges.get(key) ?? 0) + 1); + }; + + let triangleCount = 0; + const trianglePattern = //g; + for (const triangleMatch of body.matchAll(trianglePattern)) { + const a = Number(triangleMatch[1]); + const b = Number(triangleMatch[2]); + const c = Number(triangleMatch[3]); + addEdge(a, b); + addEdge(b, c); + addEdge(c, a); + triangleCount++; + } + + let badEdgeCount = 0; + for (const count of edges.values()) { + if (count !== 2) badEdgeCount++; + } + + objects.push({ + id: getAttribute(match[1], 'id'), + name: getAttribute(match[1], 'name'), + materialIndex: Number(getAttribute(match[1], 'pindex')), + triangleCount, + badEdgeCount, + }); + } + + return objects; +} + +test('3MF export merges Flat Paint parts into one object per filament', async () => { + installFileReaderPolyfill(); + const { exportObjectTo3MFBlob } = await loadExport3mfModule(); + const { layout, root } = await buildFixturePartMeshes(); + + const blob = await exportObjectTo3MFBlob(root); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const modelFile = zip.file('3D/3dmodel.model'); + const settingsFile = zip.file('Metadata/model_settings.config'); + const projectFile = zip.file('Metadata/project_settings.config'); + assert.ok(modelFile && settingsFile && projectFile, '3MF archive should contain model files'); + + const modelXml = await modelFile.async('string'); + const modelSettingsXml = await settingsFile.async('string'); + const projectSettings = JSON.parse(await projectFile.async('string')) as { + filament_colour: string[]; + }; + + const objects = parse3mfMeshObjects(modelXml); + const distinctGroups = new Set(layout.parts.map((part) => part.exportGroup)); + + assert.equal( + objects.length, + distinctGroups.size, + '3MF should contain exactly one object per Flat Paint filament group' + ); + + // Group order follows part build order: carrier first, then filaments. + assert.deepEqual( + objects.map((object) => object.name), + [ + 'Flat Paint transparent carrier (use clear filament)', + 'Flat Paint filament (#000000)', + 'Flat Paint filament (#FFFFFF)', + ] + ); + + // Base materials hold physical filament colors. The clear carrier has its + // own material slot so slicers do not merge it with a real white filament. + const baseMaterials = Array.from( + modelXml.matchAll(/ m[1] + ); + assert.deepEqual(baseMaterials, ['D8FFF8', '000000', 'FFFFFF']); + assert.deepEqual( + objects.map((object) => object.materialIndex), + [0, 1, 2], + 'objects should reference their filament material' + ); + assert.deepEqual(projectSettings.filament_colour, ['#D8FFF8', '#000000', '#FFFFFF']); + + // Slicer metadata: one part entry per object with matching extruders. + const partExtruders = Array.from( + modelSettingsXml.matchAll( + /]*id="(\d+)"[^>]*>[\s\S]*? [m[1], Number(m[2])] as const + ); + assert.deepEqual( + partExtruders.map(([, extruder]) => extruder), + [1, 2, 3], + 'extruders should map to filament materials (1-based)' + ); + assert.deepEqual( + partExtruders.map(([id]) => id), + objects.map((object) => object.id), + 'slicer metadata should describe every exported object' + ); + + // Every merged object keeps its member shells closed (all edges used twice). + for (const object of objects) { + assert.ok(object.triangleCount > 0, `${object.name} should contain triangles`); + assert.equal( + object.badEdgeCount, + 0, + `${object.name} should consist of closed shells (bad edges found)` + ); + } +}); diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts new file mode 100644 index 0000000..580d26d --- /dev/null +++ b/tests/nextBestColor.test.ts @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { nextBestColor} from '../src/lib/nextBestColor.ts'; +import type { Filament } from '../src/types/index.ts'; + + +function filament(id: string, color: string, td: number): Filament { + return { id, color, td }; +} + +const BLACK = filament('black', '#000000', 1.0); +const WHITE = filament('white', '#ffffff', 2.0); +const RED = filament('red', '#ff0000', 1.5); +const BLUE = filament('blue', '#0000ff', 2.5); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test('returns null candidate for empty filaments', () => { + const r = nextBestColor([], [{ hex: '#808080', count: 100 }]); + assert.equal(r.candidate, null); + assert.equal(r.baselineAvgDeltaE, 0); +}); + +test('returns null candidate for empty swatches', () => { + const r = nextBestColor([BLACK, WHITE], []); + assert.equal(r.candidate, null); +}); + +test('returns null candidate when all swatches are already covered', () => { + // Swatch exactly matches an existing filament — ΔE < 3, so it is skipped. + const r = nextBestColor([BLACK], [{ hex: '#000000', count: 100 }]); + assert.equal(r.candidate, null); +}); + + +test('returns null candidate when image palette exactly matches the filament set', () => { + // Every image swatch is one of the existing filaments — nothing to add. + const r = nextBestColor( + [BLACK, WHITE, RED], + [ + { hex: '#000000', count: 300 }, + { hex: '#ffffff', count: 300 }, + { hex: '#ff0000', count: 300 }, + ] + ); + assert.equal(r.candidate, null); +}); + +// --------------------------------------------------------------------------- +// Basic ranking +// --------------------------------------------------------------------------- + +test('identifies the most impactful missing color', () => { + // Black filament only. Image has black (covered) and white (uncovered). + // White is the only viable candidate. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 100 }, + { hex: '#ffffff', count: 100 }, + ] + ); + assert.ok(r.candidate !== null, 'expected a candidate'); + assert.equal(r.candidate.hex, '#ffffff'); +}); + +test('blend-aware: far candidate wins when its segment covers the common color territory', () => { + // With only BLACK and an achromatic image, near-white creates a segment + // (#eeeeee↔BLACK) that spans the full L-axis, passing through #888888's Lab + // position — so adding it captures those pixels via blending. Blend-aware + // scoring correctly prefers the longer segment even at count=1. + const r = nextBestColor( + [BLACK], + [ + { hex: '#888888', count: 1000 }, // common mid-grey + { hex: '#eeeeee', count: 1 }, // rare near-white + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#888888'); +}); + +test('pixel count weighting: common color beats rare one when blend segments diverge', () => { + // BLACK + WHITE already cover the L-axis via blending. + // Two chromatic candidates in opposite hue directions: their C↔filament + // segments do not pass near each other, so pixel count dominates. + // Four covered greys anchor p75 below both chromatic candidates. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, // grey — on L-axis blend, covered + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#ff6666', count: 1000 }, // desaturated red — common + { hex: '#00ffff', count: 1 }, // cyan — opposite hue, rare + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#ff6666'); +}); + +// --------------------------------------------------------------------------- +// Underserved-color weighting (p90 = 2×, p100 = 3×) +// --------------------------------------------------------------------------- + +test('p100 weighting tips ranking: rare maximally-underserved color beats common moderate one', () => { + // BLACK + WHITE cover the L-axis. Two chromatic candidates in opposite hue + // directions so their C↔filament blend segments don't reach each other: + // #ff8888 (desaturated red) count=5 — moderate distance (~49 ΔE), high raw gain + // #0000ff (blue) count=1 — maximum distance (~134 ΔE), p100 → 3× weight + // + // Without weighting blue's raw gain (134) < red's (247), red would win. + // With 3× p100 weight blue's weighted gain (402) > red's (255), blue wins. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, // grey — covered by L-axis blend + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#ff8888', count: 5 }, // desaturated red — moderate distance + { hex: '#0000ff', count: 1 }, // blue — maximum distance (p100) + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#0000ff'); +}); + +test('improvementPct stays ≤ 100 when p100 weighting selects the winner', () => { + // weightedGain drives ranking but improvementPct is computed from rawGain + // so it stays a meaningful percentage of the unweighted baseline error. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, + { hex: '#808080', count: 1 }, + { hex: '#a0a0a0', count: 1 }, + { hex: '#c0c0c0', count: 1 }, + { hex: '#ff8888', count: 5 }, + { hex: '#0000ff', count: 1 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.improvementPct <= 100, + `expected ≤ 100, got ${r.candidate.improvementPct}`); +}); + +// --------------------------------------------------------------------------- +// Improvement percentage +// --------------------------------------------------------------------------- + +test('improvementPct is > 0 and ≤ 100', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 50 }, + { hex: '#ffffff', count: 50 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.improvementPct > 0, `expected > 0, got ${r.candidate.improvementPct}`); + assert.ok(r.candidate.improvementPct <= 100, `expected ≤ 100, got ${r.candidate.improvementPct}`); +}); + +// --------------------------------------------------------------------------- +// Isolation score +// --------------------------------------------------------------------------- + +test('isolationScore is in [0, 1]', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 50 }, + { hex: '#ffffff', count: 50 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.isolationScore >= 0 && r.candidate.isolationScore <= 1, + `expected 0–1, got ${r.candidate.isolationScore}`); +}); + +test('single viable candidate always gets isolationScore 1.0', () => { + // Only one candidate passes the coverage threshold, so it is the most isolated by definition. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 10 }, // covered — skipped + { hex: '#ffffff', count: 90 }, // sole viable candidate + ] + ); + assert.ok(r.candidate !== null); + assert.ok( + Math.abs(r.candidate.isolationScore - 1.0) < 1e-9, + `expected 1.0, got ${r.candidate.isolationScore}` + ); +}); + +test('blend-aware: both candidates produce a candidate with valid isolation score', () => { + // BLACK filament only. + // #444444 (dark grey) — close to BLACK, very common → its C↔BLACK segment covers mid-tones + // #ffffff (white) — far from BLACK, rare → its C↔BLACK segment covers more Lab space + // The blend-aware metric naturally weighs segment length; winner depends on pixel counts. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 1 }, // covered + { hex: '#444444', count: 500 }, // common, moderate isolation + { hex: '#ffffff', count: 1 }, // rare, maximum isolation + ] + ); + assert.ok(r.candidate !== null); + assert.ok(typeof r.candidate.isolationScore === 'number'); + assert.ok(r.candidate.isolationScore > 0); +}); + +test('adding exact best candidate as a second filament gives near-zero further improvement', () => { + const swatches = [ + { hex: '#000000', count: 50 }, + { hex: '#aaaaaa', count: 50 }, + ]; + const first = nextBestColor([BLACK], swatches); + assert.ok(first.candidate !== null); + // Now add the winner as a filament and re-run. + const newFilament = filament('new', first.candidate.hex, first.candidate.td); + const second = nextBestColor([BLACK, newFilament], swatches); + // Candidate should be null or have negligible improvement. + if (second.candidate !== null) { + assert.ok( + second.candidate.improvementPct < first.candidate.improvementPct, + 'second best should improve less than the first' + ); + } +}); + +// --------------------------------------------------------------------------- +// pixelsCaptured +// --------------------------------------------------------------------------- + +test('pixelsCaptured is > 0 for a valid candidate', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 100 }, + { hex: '#cccccc', count: 80 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.pixelsCaptured > 0); +}); + +test('pixelsCaptured does not exceed totalPixels', () => { + const swatches = [ + { hex: '#333333', count: 40 }, + { hex: '#999999', count: 60 }, + ]; + const r = nextBestColor([BLACK], swatches); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.pixelsCaptured <= r.totalPixels); +}); + +// --------------------------------------------------------------------------- +// TD recommendation +// --------------------------------------------------------------------------- + +test('recommended TD comes from the nearest existing filament', () => { + // RED (td=1.5) and BLUE (td=2.5). Orange #ff4400 is not on the RED↔BLUE blend + // line (which passes through purple/magenta) and is much closer to RED than BLUE + // in Lab space, so the suggested candidate should inherit RED's td. + const r = nextBestColor( + [RED, BLUE], + [ + { hex: '#ff0000', count: 10 }, // covered — on RED + { hex: '#0000ff', count: 10 }, // covered — on BLUE + { hex: '#ff4400', count: 100 }, // uncovered orange, nearest filament is RED + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.td, RED.td); +}); + +test('light candidate gets WHITE td when WHITE is nearest', () => { + // RED (td=1.5) and BLUE (td=2.5). Sky blue #00aaff is not on the RED↔BLUE blend + // line and is closer to BLUE than RED in Lab space. + const r = nextBestColor( + [RED, BLUE], + [ + { hex: '#ff0000', count: 10 }, // covered — on RED + { hex: '#0000ff', count: 10 }, // covered — on BLUE + { hex: '#00aaff', count: 200 }, // uncovered sky-blue, nearest filament is BLUE + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.td, BLUE.td); +}); + +// --------------------------------------------------------------------------- +// totalPixels and baselineAvgDeltaE +// --------------------------------------------------------------------------- + +test('totalPixels equals sum of swatch counts', () => { + const swatches = [ + { hex: '#ff0000', count: 30 }, + { hex: '#00ff00', count: 70 }, + ]; + const r = nextBestColor([RED], swatches); + assert.equal(r.totalPixels, 100); +}); + +test('baselineAvgDeltaE is 0 when all swatches exactly match filaments', () => { + // Single swatch that exactly matches the filament — ΔE ≈ 0. + const r = nextBestColor([RED], [{ hex: '#ff0000', count: 50 }]); + assert.ok(r.baselineAvgDeltaE < 1, `expected near 0, got ${r.baselineAvgDeltaE}`); +}); diff --git a/tsconfig.json b/tsconfig.json index bc70e14..f93ea25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ { "path": "./tsconfig.test.json" } ], "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/vite.config.ts b/vite.config.ts index ff59791..af23ae0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,19 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) as { version: string }; // https://vite.dev/config/ export default defineConfig({ base: '/', plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, resolve: { alias: { '@': path.resolve(__dirname, './src'),