import { useEffect, useMemo, useRef, useState } from 'react'; import { BITMAP_HELPERS, bitmapPrefix, emitBitmapCode, processBitmap, uniqueBitmapPrefix, } from '@/lib/formats/bitmap.ts'; import type { BitmapPixels, BitmapSettings } from '@/lib/formats/bitmap.ts'; import { THREADS } from '@/data.ts'; import { Button } from '@/components/ui/button.tsx'; import { Checkbox } from '@/components/ui/checkbox.tsx'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog.tsx'; import { Label } from '@/components/ui/label.tsx'; import { Slider } from '@/components/ui/slider.tsx'; import { Switch } from '@/components/ui/switch.tsx'; import { cn } from '@/utils.ts'; export interface BitmapImportSource extends BitmapPixels { filename: string; } interface Props { source: BitmapImportSource | null; programSource: string; onClose: () => void; onInsert: (code: string, summary: string) => void; } type Section = 'region' | 'resolution' | 'colors' | 'tone' | 'insert'; const IMPORT_TABS: Array<[Section, string]> = [ ['region', '1 Region'], ['resolution', '2 Resolution'], ['colors', '3 Colors'], ['tone', '4 Tone'], ['insert', '5 Insert'], ]; function initialSettings(image: BitmapImportSource): BitmapSettings { const aspect = image.width / image.height; const width = aspect >= 1 ? image.height : image.width; const height = width; const x = Math.round((image.width - width) / 2); const y = Math.round((image.height - height) / 2); return { crop: { x, y, width, height }, columns: 48, rows: 48, fabric: '#f5efe4', threads: ['#2B2B2B'], invert: false, steps: 8, dither: false, mm: 60, }; } function NumberField({ label, value, min, max, onChange, }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void; }) { return ( ); } function RangeField({ label, value, min, max, onChange, }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void; }) { return (
{value.toLocaleString()} px ({min.toLocaleString()}–{max.toLocaleString()})
onChange(Array.isArray(next) ? next[0] : next)} />
); } function CropPreview({ source, crop, }: { source: BitmapImportSource; crop: BitmapSettings['crop']; }) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const longestSide = 560; const scale = longestSide / Math.max(source.width, source.height); canvas.width = Math.max(1, Math.round(source.width * scale)); canvas.height = Math.max(1, Math.round(source.height * scale)); const ctx = canvas.getContext('2d'); if (!ctx) return; const pixels = new ImageData(new Uint8ClampedArray(source.data), source.width, source.height); const imageCanvas = document.createElement('canvas'); imageCanvas.width = source.width; imageCanvas.height = source.height; const imageCtx = imageCanvas.getContext('2d'); if (!imageCtx) return; imageCtx.putImageData(pixels, 0, 0); ctx.drawImage(imageCanvas, 0, 0, canvas.width, canvas.height); const x = crop.x * scale; const y = crop.y * scale; const width = crop.width * scale; const height = crop.height * scale; ctx.fillStyle = 'rgba(8, 10, 12, 0.56)'; ctx.fillRect(0, 0, canvas.width, y); ctx.fillRect(0, y + height, canvas.width, canvas.height - y - height); ctx.fillRect(0, y, x, height); ctx.fillRect(x + width, y, canvas.width - x - width, height); ctx.strokeStyle = '#f6c558'; ctx.lineWidth = 2; ctx.strokeRect(x + 1, y + 1, Math.max(0, width - 2), Math.max(0, height - 2)); }, [crop, source]); return (
Original image · crop highlighted
); } function BitmapPreview({ source, settings, stitched, grid, hoop, }: { source: BitmapImportSource; settings: BitmapSettings; stitched: boolean; grid: boolean; hoop: boolean; }) { const canvasRef = useRef(null); const processed = useMemo(() => processBitmap(source, settings), [source, settings]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const size = 520; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.imageSmoothingEnabled = false; ctx.fillStyle = settings.fabric; ctx.fillRect(0, 0, size, size); if (!stitched) { const scratch = document.createElement('canvas'); scratch.width = source.width; scratch.height = source.height; const imageCtx = scratch.getContext('2d'); if (imageCtx) { imageCtx.putImageData( new ImageData(new Uint8ClampedArray(source.data), source.width, source.height), 0, 0, ); const { x, y, width, height } = settings.crop; ctx.drawImage(scratch, x, y, width, height, 0, 0, size, size); } } else { const cellW = size / settings.columns; const cellH = size / settings.rows; for (let row = 0; row < settings.rows; row++) { for (let col = 0; col < settings.columns; col++) { for (let plate = 0; plate < processed.plates.length; plate++) { const intensity = parseInt(processed.plates[plate].rows[row][col], 16) / 15; if (!intensity) continue; ctx.globalAlpha = intensity; ctx.fillStyle = processed.plates[plate].color; ctx.fillRect(col * cellW, row * cellH, Math.ceil(cellW), Math.ceil(cellH)); } } } ctx.globalAlpha = 1; if (grid && Math.min(cellW, cellH) >= 4) { ctx.strokeStyle = 'rgba(20, 20, 20, 0.18)'; ctx.lineWidth = 1; for (let col = 0; col <= settings.columns; col++) { ctx.beginPath(); ctx.moveTo(col * cellW, 0); ctx.lineTo(col * cellW, size); ctx.stroke(); } for (let row = 0; row <= settings.rows; row++) { ctx.beginPath(); ctx.moveTo(0, row * cellH); ctx.lineTo(size, row * cellH); ctx.stroke(); } } } if (hoop) { const diameter = Math.min(size, (47 / settings.mm) * size); ctx.beginPath(); ctx.arc(size / 2, size / 2, diameter / 2, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255, 255, 255, 0.88)'; ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]); } }, [grid, hoop, processed, settings, source, stitched]); return ( ); } export default function BitmapImportDialog({ source, programSource, onClose, onInsert }: Props) { const initialSource = source ?? { filename: 'bitmap', width: 1, height: 1, data: new Uint8ClampedArray(4), }; const [section, setSection] = useState
('region'); const [settings, setSettings] = useState(() => initialSettings(initialSource)); const [prefix, setPrefix] = useState(() => uniqueBitmapPrefix(initialSource.filename, programSource), ); const [stitched, setStitched] = useState(true); const [grid, setGrid] = useState(true); const [hoop, setHoop] = useState(true); const [includeHelpers, setIncludeHelpers] = useState( () => !/\bdef\s+(?:bmpixel|bmsample)\b/.test(programSource), ); const processed = useMemo( () => (source && settings ? processBitmap(source, settings) : null), [settings, source], ); const helpersPresent = /\bdef\s+(?:bmpixel|bmsample)\b/.test(programSource); const emitted = useMemo( () => source && settings && processed ? emitBitmapCode(processed, settings, { filename: source.filename, prefix: bitmapPrefix(prefix), source: programSource, includeHelpers, }) : '', [includeHelpers, prefix, processed, programSource, settings, source], ); if (!source || !processed) return null; const mmPerCell = settings.mm / Math.max(settings.columns, settings.rows); const update = (next: Partial) => setSettings((current) => ({ ...current!, ...next })); const setCrop = (next: Partial) => update({ crop: { ...settings.crop, ...next } }); const setColumns = (columns: number) => { const safe = Math.min(96, Math.max(8, columns)); update({ columns: safe, rows: Math.min( 96, Math.max(8, Math.round((safe * settings.crop.height) / settings.crop.width)), ), }); }; const setThreadCount = (count: number) => { const threads = Array.from( { length: count }, (_, index) => settings.threads[index] ?? THREADS[index], ); update({ threads }); }; const insert = () => { localStorage.setItem('ns-bitmap-import-pref', JSON.stringify({ ...settings, crop: undefined })); onInsert( emitted, `Inserted bitmap '${source.filename}' — ${settings.columns}×${settings.rows}, ${processed.plates.length} plate${processed.plates.length === 1 ? '' : 's'}, ~${processed.estimatedStitches.toLocaleString()} st (est.)`, ); }; return ( !open && onClose()}> Import bitmap — {source.filename} Turn image pixels into editable NeedleScript data. Tune the sewn result before inserting it.
{IMPORT_TABS.map(([id, label]) => ( ))}
{section === 'region' && (

Pan and size the crop with sliders. Each range updates to keep the selected area inside the source image.

setCrop({ x })} /> setCrop({ y })} /> setCrop({ width })} /> setCrop({ height })} />
)} {section === 'resolution' && (
update({ rows })} />
{[24, 32, 48, 64, 96].map((size) => ( ))}
update({ mm })} />

{mmPerCell.toFixed(2)} mm per cell {mmPerCell < 0.8 && ' — below ~0.8 mm/cell, line pitch limits detail.'}

)} {section === 'colors' && (
{settings.threads.map((thread, index) => ( ))} {settings.threads.length > 1 && (

Plates overlap. Give each color its own hatch angle or spiral phase to avoid density hotspots.

)}
)} {section === 'tone' && (
update({ steps: Array.isArray(value) ? value[0] : value }) } />

Steps control the number of visible tones. Dither trades smooth gradients for a deliberate stitched grain.

)} {section === 'insert' && (
{helpersPresent && (

bmpixel/bmsample already defined — reusing them.

)}
                  {emitted}
                
)}
{settings.columns}×{settings.rows} cells {mmPerCell.toFixed(2)} mm/cell ~{processed.estimatedStitches.toLocaleString()} st ~{((processed.sourceBytes + emitted.length) / 1024).toFixed(1)} KB source
{BITMAP_HELPERS ? 'Data stays in your program; the original image is not stored.' : ''}
); }