diff --git a/docs/plans/2026-03-04-refactoring-design.md b/docs/plans/2026-03-04-refactoring-design.md new file mode 100644 index 0000000..6be7027 --- /dev/null +++ b/docs/plans/2026-03-04-refactoring-design.md @@ -0,0 +1,119 @@ +# PerfectPixel Tools — Refactoring Design + +**Date:** 2026-03-04 +**Approach:** Strategy Pattern Refactoring (Plan A) +**Scope:** Full — Python backend + frontend + server + code rot cleanup + +--- + +## Problem Statement + +The codebase suffers from three categories of issues: + +1. **Code rot** — 14 scattered `print()` statements, unused debug functions, stale comments +2. **Redundancy** — 350+ lines of duplicated algorithm logic across two Python backends, copy-pasted CSS/JS between HTML files +3. **Complexity** — `editor.html` is a 5000-line monolith, global state scattered across dozens of variables, `get_perfect_pixel()` violates SRP + +--- + +## Part 1: Python Backend Refactoring + +### Target Structure + +``` +src/perfect_pixel/ +├── __init__.py # Public API entry (get_perfect_pixel) +├── core.py # Pure algorithms: FFT, peak detection, grid refinement, sampling +├── ops.py # ImageOps Protocol definition +├── backend_cv2.py # OpenCV implementation of ImageOps +├── backend_numpy.py # NumPy-only implementation of ImageOps +├── perfect_pixel.py # Backward-compat thin wrapper → imports from core +└── perfect_pixel_noCV2.py # Backward-compat thin wrapper → imports from core +``` + +### ImageOps Protocol + +```python +class ImageOps(Protocol): + def sobel(self, gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]: ... + def morphology_open(self, binary: np.ndarray, ksize: int) -> np.ndarray: ... + def connected_components(self, binary: np.ndarray) -> tuple[int, np.ndarray, np.ndarray]: ... + def kmeans_2(self, pixels: np.ndarray) -> np.ndarray: ... +``` + +### Migration Rules + +- Functions identical in both backends → move directly to `core.py` +- Functions differing only in image ops calls → move to `core.py`, accept `ops: ImageOps` parameter +- `print()` → `logging.getLogger(__name__)` +- `grid_layout()` debug function → remove +- Old files become thin wrappers for backward compatibility + +--- + +## Part 2: Frontend editor.html Modularization + +### Target Structure + +``` +editor/ +├── editor.html # HTML skeleton (~200 lines), + + + + + + + + + +``` + +**Step 7: Verify in browser** + +Run: `python3 web_app.py` and open `http://localhost:5010/editor` + +All tools should work identically to the monolithic version. + +**Step 8: Commit** + +```bash +git add editor/ editor.html +git commit -m "refactor: modularize editor.html into separate JS/CSS files" +``` + +--- + +## Task 6: Update web_ui.html to Use Shared Assets + +**Files:** +- Modify: `web_ui.html` + +**Step 1: Replace inline CSS variables and button styles with link to shared.css** + +In `web_ui.html`, replace the `:root { ... }` block (lines 10-23) and `.btn` styles (lines 95-106) with: + +```html + +``` + +Keep all other web_ui-specific styles inline. + +**Step 2: Replace inline color utility functions with script src** + +Replace `rgbToHex` and `hexToRgb` function definitions (lines 278-286) with: + +```html + +``` + +**Step 3: Remove stale comment** + +Delete line 399: `// ` + +**Step 4: Update Flask routes to serve editor/ directory** + +In `web_app.py`, add a route to serve static files from the `editor/` directory: + +```python +@app.route("/editor/") +def editor_static(filename): + return send_file(os.path.join(os.path.dirname(__file__), "editor", filename)) +``` + +And update the editor route to serve the new editor.html location (if moved) or keep serving from root. + +**Step 5: Verify web_ui.html still works** + +Run: `python3 web_app.py` and open `http://localhost:5010` + +**Step 6: Commit** + +```bash +git add web_ui.html web_app.py +git commit -m "refactor: web_ui.html uses shared CSS/JS, remove stale comments" +``` + +--- + +## Task 7: Clean Up web_app.py + +**Files:** +- Modify: `web_app.py` + +**Step 1: Extract shared utility functions** + +Add helper functions at the top of web_app.py (after imports): + +```python +def _decode_b64_image(b64_str): + """Decode base64 string to RGB numpy array.""" + data = base64.b64decode(b64_str) + arr = np.frombuffer(data, dtype=np.uint8) + bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if bgr is None: + raise ValueError("Cannot decode image data") + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + + +def _make_error(msg, code=400): + """Uniform error response.""" + return jsonify({"error": msg}), code +``` + +**Step 2: Refactor routes to use shared helpers** + +Replace duplicated base64 decode logic in `api_apply_palette()` (line 462) and other routes with calls to `_decode_b64_image()`. + +Replace scattered `return jsonify({"error": ...}), NNN` with `_make_error()`. + +**Step 3: Standardize HTTP status codes** + +- Validation errors (missing params, empty palette) → 400 +- Processing failures (grid detection failed) → 422 +- Internal errors (quantization failed) → 500 + +**Step 4: Verify all API routes work** + +Run: `python3 web_app.py` and test with browser. + +**Step 5: Commit** + +```bash +git add web_app.py +git commit -m "refactor: extract shared helpers, standardize error responses in web_app.py" +``` + +--- + +## Task 8: Fix ComfyUI Integration + +**Files:** +- Modify: `integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py` + +**Step 1: Fix bare except clause** + +Change line 60 from `except Exception:` to `except ImportError:`. + +**Step 2: Verify syntax** + +Run: `python3 -c "import ast; ast.parse(open('integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py').read()); print('OK')"` + +**Step 3: Commit** + +```bash +git add integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py +git commit -m "fix: narrow bare except to ImportError in ComfyUI backend loader" +``` + +--- + +## Task 9: Final Verification and Cleanup + +**Step 1: Run Python import check** + +```bash +cd /Users/cheongzhiyan/Developer/perfectPixel-Tools +python3 -c " +import sys; sys.path.insert(0, 'src') +from perfect_pixel import get_perfect_pixel +from perfect_pixel.core import get_perfect_pixel as core_gpp +from perfect_pixel.backend_cv2 import CV2Ops +from perfect_pixel.backend_numpy import NumpyOps +print('All imports OK') +" +``` + +**Step 2: Verify web app starts** + +```bash +python3 web_app.py & +sleep 2 +curl -s http://localhost:5010/ | head -5 +curl -s http://localhost:5010/editor | head -5 +kill %1 +``` + +**Step 3: Verify file count reduction** + +Run: `wc -l editor.html editor/editor.css editor/shared.css editor/js/*.js` + +Each JS module should be under 500 lines. editor.html should be ~200 lines (HTML skeleton only). + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "refactor: complete codebase refactoring — eliminate redundancy and modularize" +``` + +--- + +## Summary + +| Task | Description | Estimated Steps | +|------|-------------|----------------| +| 1 | ImageOps protocol + backend adapters | 5 | +| 2 | core.py shared algorithms | 3 | +| 3 | Rewire legacy files as wrappers | 5 | +| 4 | Extract shared CSS + color utils | 4 | +| 5 | Modularize editor.html (largest task) | 8 | +| 6 | Update web_ui.html to use shared assets | 6 | +| 7 | Clean up web_app.py | 5 | +| 8 | Fix ComfyUI integration | 3 | +| 9 | Final verification | 4 | diff --git a/editor.html b/editor.html index b3a0186..66de21b 100644 --- a/editor.html +++ b/editor.html @@ -4,619 +4,8 @@ PerfectPixel Editor - + + @@ -679,25 +68,25 @@ - - + + 4x - - - + + + @@ -706,32 +95,30 @@
- +
- +
- 色卡限制 - + 色卡限制 +
- - - + + + + + + + + + + + + +
diff --git a/editor/editor.css b/editor/editor.css new file mode 100644 index 0000000..4e77ecb --- /dev/null +++ b/editor/editor.css @@ -0,0 +1,585 @@ +/* ── Reset ────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; +} + +/* ── Top Bar ──────────────────────────────────────────────────────────── */ +#top-bar { + flex-shrink: 0; + height: 44px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +/* ── Main Layout (CSS Grid) ───────────────────────────────────────────── */ +#layout { + flex: 1; + display: grid; + grid-template-columns: 280px 1fr 48px; + min-height: 0; + overflow: hidden; +} + +/* ── Left Panel ───────────────────────────────────────────────────────── */ +#left-panel { + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--surface); +} + +#left-scroll { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} +#left-scroll > * { flex-shrink: 0; } + +/* Custom scrollbar to match dark theme */ +#left-scroll::-webkit-scrollbar { width: 6px; } +#left-scroll::-webkit-scrollbar-track { background: transparent; } +#left-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +#left-scroll::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +#pixel-inspector { + flex-shrink: 0; + border-top: 1px solid var(--border); + padding: 10px 12px; + background: var(--surface); + font-size: 12px; +} + +/* ── Panel card (used for collapsible sections) ───────────────────────── */ +.panel-card { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface2); +} + +/* ── Canvas Area ──────────────────────────────────────────────────────── */ +#canvas-area { + display: flex; + flex-direction: row; + overflow: hidden; + background: var(--bg); + position: relative; /* positioning context for #selection-canvas overlay */ +} + +/* Zoom scroll content fills remaining space, scrolls independently */ +#zoom-scroll-content { + flex: 1; + overflow: auto; +} + +/* Scroll content element — sized by centerCanvas to create the scrollable area */ +#zoom-scroll-inner { + position: relative; +} + +/* Custom scrollbar for zoom-scroll-content */ +#zoom-scroll-content::-webkit-scrollbar { width: 8px; height: 8px; } +#zoom-scroll-content::-webkit-scrollbar-track { background: var(--surface); } +#zoom-scroll-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +#zoom-scroll-content::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } +#zoom-scroll-content::-webkit-scrollbar-corner { background: var(--surface); } + +/* ── Zoom container (CSS transform target; contains 3-canvas stack) ────── */ +#zoom-container { + position: absolute; + transform-origin: top left; + /* Checkerboard background — scales with CSS transform (16px canvas-pixels per cell at zoom=1) */ + background-color: var(--surface2); + background-image: + linear-gradient(45deg, var(--surface) 25%, transparent 25%), + linear-gradient(-45deg, var(--surface) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--surface) 75%), + linear-gradient(-45deg, transparent 75%, var(--surface) 75%); + background-size: 32px 32px; + background-position: 0 0, 0 16px, 16px -16px, -16px 0px; +} + +/* Eyedropper mode: force crosshair everywhere (overrides button cursors etc.) */ +body.eyedropper-active, body.eyedropper-active * { cursor: crosshair !important; } + +/* ── Canvas elements: pixel-perfect rendering ─────────────────────────── */ +canvas { + image-rendering: pixelated; + image-rendering: crisp-edges; /* Firefox compat */ + display: block; +} + +/* Two canvas layers stacked absolutely inside zoom-container */ +#pixel-canvas, +#cursor-canvas { + position: absolute; + top: 0; + left: 0; +} +#pixel-canvas { z-index: 1; } +#cursor-canvas { z-index: 3; cursor: crosshair; } + +/* Selection canvas lives OUTSIDE zoom-container — covers canvas-area viewport. + Positioned in screen space so lineWidth is immune to CSS zoom scaling. */ +#selection-canvas { + position: absolute; + top: 0; left: 0; + pointer-events: none; + z-index: 10; + image-rendering: pixelated; +} + +/* ── Right Panel (tool strip) ─────────────────────────────────────────── */ +#right-panel { + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + gap: 4px; + background: var(--surface); +} + +.tool-btn { + width: 36px; + height: 36px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface2); + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; +} +.tool-btn:disabled { opacity: .3; cursor: not-allowed; } + +.tool-sep { + width: 28px; + height: 1px; + background: var(--border); + margin: 2px 0; +} +.tool-btn-active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* ── Right-panel instant tooltip ─────────────────────────────────────────── */ +#tool-tip { + position: fixed; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 7px; + padding: 7px 11px; + pointer-events: none; + z-index: 9000; + display: none; + box-shadow: 0 4px 14px rgba(0,0,0,.45); + max-width: 220px; +} +.tool-tip-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.tool-tip-en { color: var(--text-muted); font-weight: 400; } +.tool-tip-key { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + font-size: 10px; + color: var(--text-muted); + font-family: monospace; +} +.tool-tip-desc { + font-size: 11px; + color: var(--text-muted); + margin-top: 3px; + line-height: 1.4; +} + +/* ── Top-bar tool settings inputs ────────────────────────────────────────── */ +#top-bar input[type=number], #top-bar select { + padding: 3px 6px; border-radius: 5px; + border: 1px solid var(--border); background: var(--surface2); + color: var(--text); font-size: 12px; +} +#top-bar input[type=number]:focus, #top-bar select:focus { + outline: none; border-color: var(--accent); +} + +/* ── Palette section (ported from web_ui.html) ───────────────────────────── */ +.palette-section { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.palette-header { + padding: 9px 12px; background: var(--surface2); + display: flex; align-items: center; justify-content: space-between; + cursor: pointer; user-select: none; +} +.palette-header .title { font-size: 12px; font-weight: 700; } +.palette-header .chevron { font-size: 10px; color: var(--text-muted); transition: transform .2s; } +.palette-header.open .chevron { transform: rotate(180deg); } +.palette-body { + display: flex; + flex-direction: column; +} +.palette-body.hidden { display: none; } +.pal-scroll-area { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} +.pal-sticky-bottom { + padding: 10px 12px; + border-top: 1px solid var(--border); + background: var(--surface); + display: flex; + flex-direction: column; + gap: 8px; +} +.add-swatch { + width: 28px; height: 28px; border-radius: 4px; cursor: pointer; + border: 2px dashed #666; background: #555; + display: flex; align-items: center; justify-content: center; + color: #ccc; font-size: 18px; font-weight: 300; line-height: 1; + flex-shrink: 0; transition: border-color .1s, background .1s; + user-select: none; +} +.add-swatch:hover { border-color: var(--accent); background: #666; color: #fff; } +.combobox-row { + display: flex; + border: 1px solid var(--border); + border-radius: var(--radius, 6px); + overflow: hidden; + height: 30px; +} +.combobox-row input[type=text] { + flex: 1; + padding: 0 8px; + border: none; + border-radius: 0; + background: var(--surface2); + color: var(--text); + font-size: 13px; + min-width: 0; + outline: none; +} +.combobox-row input[type=text]:focus { + background: var(--surface3, var(--surface2)); +} +.combobox-drop-btn { + width: 28px; + flex-shrink: 0; + border: none; + border-left: 1px solid var(--border); + border-radius: 0; + background: var(--surface2); + color: var(--text); + font-size: 10px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.combobox-drop-btn:hover { background: var(--surface3, #555); } +.combobox-row .btn { + border: none; + border-left: 1px solid var(--border); + border-radius: 0; + width: 28px; + flex-shrink: 0; + padding: 0; + display: flex; align-items: center; justify-content: center; +} +.swatch-info-icon { + font-size: 11px; + color: var(--text-muted); + cursor: default; + opacity: 0.6; + user-select: none; +} +.swatch-info-icon:hover { opacity: 1; } +/* Instant tooltip for swatch info icon */ +.swatch-info-icon[data-tooltip] { + position: relative; +} +.swatch-info-icon[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + top: 120%; + transform: translateX(-50%); + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + white-space: nowrap; + font-size: 11px; + z-index: 200; + box-shadow: 0 4px 10px rgba(0,0,0,.4); +} +.pal-del-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: none; + color: var(--error, #e55); + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 0 4px; + line-height: 1; +} +.custom-option:hover .pal-del-btn { display: inline; } +.custom-option { position: relative; } +.export-dropdown { position: relative; } +.export-menu { + position: absolute; right: 0; top: calc(100% + 4px); + background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; z-index: 200; min-width: 80px; + box-shadow: 0 4px 16px rgba(0,0,0,.4); +} +.export-menu-item { + display: block; width: 100%; padding: 7px 12px; background: transparent; + border: none; color: var(--text); font-size: 12px; cursor: pointer; + text-align: left; border-bottom: 1px solid var(--border); +} +.export-menu-item:last-child { border-bottom: none; } +.export-menu-item:hover { background: rgba(124,106,247,.1); } +.mapping-row { + display: flex; align-items: center; gap: 8px; +} + +/* Toggle switch */ +.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; } +.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-track { + position: absolute; inset: 0; background: var(--surface2); border: 1px solid var(--border); + border-radius: 10px; cursor: pointer; transition: background .2s; +} +.toggle-track::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; border-radius: 50%; + background: var(--text-muted); transition: transform .2s, background .2s; +} +.toggle-switch input:checked + .toggle-track { background: var(--accent); border-color: var(--accent); } +.toggle-switch input:checked + .toggle-track::after { transform: translateX(16px); background: #fff; } + +/* Tabs */ +.tabs { display: flex; gap: 4px; } +.tab-btn { + flex: 1; padding: 6px; border-radius: 6px; border: 1px solid var(--border); + background: transparent; color: var(--text-muted); font-size: 12px; font-weight: 600; + cursor: pointer; transition: all .15s; +} +.tab-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.tab-pane { display: none; } +.tab-pane.active { display: block; } + +/* Palette file upload zone */ +.pal-upload-zone { + border: 2px dashed var(--border); border-radius: 7px; + padding: 14px 10px; text-align: center; cursor: pointer; + position: relative; transition: border-color .2s, background .2s; + font-size: 12px; color: var(--text-muted); +} +.pal-upload-zone:hover, .pal-upload-zone.dragover { border-color: var(--accent); background: rgba(124,106,247,.06); } +.pal-upload-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } + +/* Swatches — outer wrapper prevents glow clipping */ +.swatches-outer { max-height: 140px; overflow-y: auto; } +.swatches-label { display: flex; align-items: center; justify-content: space-between; } +.swatches-count { font-size: 11px; color: var(--text-muted); } +.swatches-grid { + display: flex; flex-wrap: wrap; gap: 4px; + padding: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; + margin-top: 6px; + overflow: visible; +} +.swatch { + width: 28px; height: 28px; border-radius: 4px; cursor: pointer; + border: 2px solid transparent; position: relative; flex-shrink: 0; + transition: transform .1s, border-color .1s; +} +.swatch:hover { transform: scale(1.15); border-color: rgba(255,255,255,.5); z-index: 2; } +.swatch .del-btn { + display: none; position: absolute; top: -5px; right: -5px; + width: 14px; height: 14px; border-radius: 50%; background: var(--error); color: #fff; + font-size: 9px; font-weight: 900; align-items: center; justify-content: center; + cursor: pointer; border: none; line-height: 1; +} +.swatch:hover .del-btn { display: flex; } +.swatches-empty { color: var(--text-muted); font-size: 12px; padding: 8px; text-align: center; } + +/* Color editor popup */ +.color-popup { + position: fixed; z-index: 999; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px; display: flex; flex-direction: column; gap: 9px; + box-shadow: 0 8px 32px rgba(0,0,0,.5); min-width: 200px; +} +.color-popup .popup-title { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.color-popup input[type=color] { width: 100%; height: 36px; border: none; border-radius: 6px; cursor: pointer; padding: 2px; background: var(--surface2); } +.color-popup .rgb-row { display: flex; gap: 5px; align-items: center; } +.color-popup .rgb-row label { font-size: 11px; color: var(--text-muted); width: 12px; flex-shrink: 0; } +.color-popup .rgb-row input { padding: 4px 6px; font-size: 12px; } +.color-popup .hex-row { display: flex; gap: 5px; align-items: center; } +.color-popup .hex-row label { font-size: 11px; color: var(--text-muted); } +.color-popup .hex-row input { font-family: monospace; font-size: 12px; padding: 4px 6px; } +.color-popup .popup-actions { display: flex; gap: 6px; } + +/* Save/load */ +.save-row { display: flex; gap: 6px; } +.save-row input { flex: 1; } + +/* Custom dropdown for saved palettes */ +.custom-select { position: relative; } +.custom-select-trigger { + width: 100%; background: var(--surface2); border: 1px solid var(--border); + border-radius: 6px; color: var(--text); padding: 6px 9px; font-size: 13px; + cursor: pointer; display: flex; align-items: center; justify-content: space-between; + user-select: none; transition: border-color .2s; +} +.custom-select-trigger:hover { border-color: var(--accent); } +.custom-select.open .custom-select-trigger { border-color: var(--accent); } +.custom-select-trigger .caret { font-size: 9px; color: var(--text-muted); transition: transform .2s; } +.custom-select.open .caret { transform: rotate(180deg); } +.custom-select-options { + display: none; position: absolute; z-index: 200; top: calc(100% + 4px); left: 0; right: 0; + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + max-height: 280px; overflow-y: auto; box-shadow: 0 8px 24px rgba(0,0,0,.5); +} +.custom-select.open .custom-select-options { display: block; } +.custom-option { + padding: 8px 12px; cursor: pointer; transition: background .1s; border-bottom: 1px solid var(--border); +} +.custom-option:last-child { border-bottom: none; } +.custom-option:hover { background: rgba(124,106,247,.1); } +.custom-option.selected { background: rgba(124,106,247,.15); } +.custom-option-empty { padding: 10px 12px; color: var(--text-muted); font-size: 12px; text-align: center; } +.option-name { font-size: 12px; font-weight: 600; margin-bottom: 4px; } +.option-swatches { display: flex; flex-wrap: wrap; gap: 2px; } +.option-swatches span { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } + +/* Export */ +.export-row { display: flex; gap: 5px; } +.export-row .btn { flex: 1; font-size: 11px; padding: 6px 4px; } + +/* section-label (sub-heading within palette body) */ +.section-label { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; } + +/* control-group / slider-row for nColors / minRegionPct */ +.control-group { display: flex; flex-direction: column; gap: 4px; } +.control-group label { font-size: 12px; color: var(--text-muted); } +.slider-row { display: flex; gap: 6px; align-items: center; } +.slider-row input[type=range] { + flex: 1; + accent-color: var(--accent); + background-color: var(--surface3); +} +.n-colors-input { + width: 42px; + padding: 2px 4px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text); + font-size: 11px; + text-align: center; + -moz-appearance: textfield; +} +.n-colors-input::-webkit-outer-spin-button, +.n-colors-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Generic text input styling within palette body */ +.palette-body input[type=text] { + padding: 6px 9px; border-radius: 6px; border: 1px solid var(--border); + background: var(--surface2); color: var(--text); font-size: 13px; width: 100%; +} +.palette-body input[type=text]:focus { outline: none; border-color: var(--accent); } + +/* Overrides for combobox row: inner controls follow outer box, no inner corners */ +.palette-body .combobox-row input[type=text] { + border: none; + border-radius: 0; + background: var(--surface2); + padding: 0 8px; +} +.palette-body .combobox-row .btn-ghost { + border: none; + background: transparent; +} +/* Keep border on palette dropdown button */ +#palDropBtn { + border: 1px solid var(--border); + border-radius: 0; + background: var(--surface2); + height: 30px; + margin-top: -1px; + margin-bottom: -1px; +} + +/* ── Color Picker ────────────────────────────────────────────────────────── */ +.pc-hex-row { display:flex; align-items:stretch; margin-bottom:10px; } +.pc-hash { display:flex; align-items:center; padding:0 9px; background:var(--accent); color:#fff; font-size:14px; font-weight:700; border-radius:5px 0 0 5px; flex-shrink:0; cursor:pointer; } +.pc-hex-input { flex:1; padding:5px 8px; border:1px solid var(--accent); border-left:none; background:var(--surface2); color:var(--text); font-size:13px; font-family:monospace; font-weight:600; outline:none; min-width:0; } +.pc-hex-input:focus { border-color:var(--accent-hover); } +.pc-channel-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; } +.pc-channel-label { font-size:10px; font-family:monospace; color:var(--text-muted); min-width:10px; } +.pc-channel-bar-wrap { flex:1; height:4px; background:var(--border); border-radius:2px; overflow:hidden; } +.pc-channel-bar { height:4px; border-radius:2px; transition:width .08s; } +.pc-channel-num { width:36px; padding:2px 4px; border:1px solid var(--border); border-radius:4px; background:var(--surface2); color:var(--text); font-size:11px; font-family:monospace; text-align:center; outline:none; } +.pc-channel-num:focus { border-color:var(--accent); } +.pc-channel-num::-webkit-outer-spin-button, +.pc-channel-num::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.pc-channel-num[type="number"] { + -moz-appearance: textfield; +} + +/* ── Shortcut modal table ─────────────────────────────────────────────── */ +.sc-section-title { + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: .6px; color: var(--text-muted); margin: 12px 0 6px; +} +.sc-section-title:first-child { margin-top: 0; } +.sc-row { + display: flex; align-items: center; justify-content: space-between; + padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,.04); + font-size: 12px; +} +.sc-row:last-child { border-bottom: none; } +.sc-desc { color: var(--text-muted); } +.sc-keys { display: flex; gap: 4px; } +.sc-key { + background: var(--surface2); border: 1px solid var(--border); + border-radius: 4px; padding: 1px 6px; font-size: 11px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; + color: var(--text); white-space: nowrap; +} diff --git a/editor/js/canvas-render.js b/editor/js/canvas-render.js new file mode 100644 index 0000000..0743f9f --- /dev/null +++ b/editor/js/canvas-render.js @@ -0,0 +1,406 @@ +// ── Canvas Initialization & Rendering ───────────────────────────────────── + +/** + * Create and initialize the 3-canvas stack inside #zoom-container. + */ +function initCanvases(width, height) { + EditorState.width = width; + EditorState.height = height; + + pixelCanvas.width = width; + pixelCanvas.height = height; + pixelCanvas.style.width = width + 'px'; + pixelCanvas.style.height = height + 'px'; + pixelCtx = pixelCanvas.getContext('2d', { willReadFrequently: true, alpha: true }); + + var dpr = window.devicePixelRatio || 1; + + var canvasAreaEl = document.getElementById('canvas-area'); + var caW = canvasAreaEl.clientWidth; + var caH = canvasAreaEl.clientHeight; + selCanvas.width = caW * dpr; + selCanvas.height = caH * dpr; + selCanvas.style.width = caW + 'px'; + selCanvas.style.height = caH + 'px'; + selCtx = selCanvas.getContext('2d'); + selCtx.scale(dpr, dpr); + + cursorCanvas.width = width * dpr; + cursorCanvas.height = height * dpr; + cursorCanvas.style.width = width + 'px'; + cursorCanvas.style.height = height + 'px'; + cursorCtx = cursorCanvas.getContext('2d'); + cursorCtx.scale(dpr, dpr); + + var zc = document.getElementById('zoom-container'); + zc.style.width = width + 'px'; + zc.style.height = height + 'px'; + centerCanvas(); +} + +/** + * Write EditorState.pixels to pixel-canvas via putImageData. + */ +function flushPixels() { + pixelCtx.putImageData( + new ImageData(EditorState.pixels, EditorState.width, EditorState.height), + 0, 0 + ); +} + +/** + * Convert browser pointer coordinates to canvas pixel coordinates. + */ +function viewportToCanvas(clientX, clientY) { + var rect = cursorCanvas.getBoundingClientRect(); + var scaleX = EditorState.width / rect.width; + var scaleY = EditorState.height / rect.height; + return [ + Math.max(0, Math.min(EditorState.width - 1, Math.floor((clientX - rect.left) * scaleX))), + Math.max(0, Math.min(EditorState.height - 1, Math.floor((clientY - rect.top) * scaleY))), + ]; +} + +/** + * Read a pixel from EditorState.pixels (never from canvas element). + */ +function getPixel(x, y) { + var i = (y * EditorState.width + x) * 4; + return [ + EditorState.pixels[i], + EditorState.pixels[i + 1], + EditorState.pixels[i + 2], + EditorState.pixels[i + 3], + ]; +} + +/** + * Write a pixel to EditorState.pixels. Does NOT call flushPixels(). + */ +function setPixel(x, y, rgba) { + var i = (y * EditorState.width + x) * 4; + EditorState.pixels[i] = rgba[0]; + EditorState.pixels[i + 1] = rgba[1]; + EditorState.pixels[i + 2] = rgba[2]; + EditorState.pixels[i + 3] = rgba[3]; +} + +// ── Zoom/Pan ───────────────────────────────────────────────────────────── + +var PAD = 2000; + +function clampScroll(area) { + var zoom = EditorState.zoom; + var W = EditorState.width * zoom; + var H = EditorState.height * zoom; + var minVisible = 100; + var minX = PAD - area.clientWidth + Math.min(W, minVisible); + var maxX = PAD + W - Math.min(W, minVisible); + var minY = PAD - area.clientHeight + Math.min(H, minVisible); + var maxY = PAD + H - Math.min(H, minVisible); + area.scrollLeft = Math.max(minX, Math.min(maxX, area.scrollLeft)); + area.scrollTop = Math.max(minY, Math.min(maxY, area.scrollTop)); +} + +function centerCanvas() { + var area = document.getElementById('zoom-scroll-content'); + var sc = document.getElementById('zoom-scroll-inner'); + var zoom = EditorState.zoom; + var W = EditorState.width * zoom; + var H = EditorState.height * zoom; + sc.style.width = (2 * PAD + W) + 'px'; + sc.style.height = (2 * PAD + H) + 'px'; + var zc = document.getElementById('zoom-container'); + zc.style.left = PAD + 'px'; + zc.style.top = PAD + 'px'; + document.getElementById('zoom-container').style.transform = 'scale(' + zoom + ')'; + var display = Number.isInteger(zoom) ? zoom + 'x' : zoom.toFixed(1) + 'x'; + document.getElementById('zoom-display').textContent = display; + area.scrollLeft = PAD + W / 2 - area.clientWidth / 2; + area.scrollTop = PAD + H / 2 - area.clientHeight / 2; + clampScroll(area); +} + +var SNAP_LEVELS = [0.25, 0.5, 1, 2, 4, 8, 16, 32, 64]; +function snapZoomIn(current) { + return SNAP_LEVELS.find(function(l) { return l > current + 0.01; }) || SNAP_LEVELS[SNAP_LEVELS.length - 1]; +} +function snapZoomOut(current) { + var rev = SNAP_LEVELS.slice().reverse(); + return rev.find(function(l) { return l < current - 0.01; }) || SNAP_LEVELS[0]; +} + +function applyZoom(newZoom, pivotClientX, pivotClientY) { + var oldZoom = EditorState.zoom; + newZoom = Math.max(0.25, Math.min(64, newZoom)); + var area = document.getElementById('zoom-scroll-content'); + var sc = document.getElementById('zoom-scroll-inner'); + var newW = EditorState.width * newZoom; + var newH = EditorState.height * newZoom; + sc.style.width = (2 * PAD + newW) + 'px'; + sc.style.height = (2 * PAD + newH) + 'px'; + var areaRect = area.getBoundingClientRect(); + var pxInArea = pivotClientX - areaRect.left; + var pyInArea = pivotClientY - areaRect.top; + var px = area.scrollLeft + pxInArea; + var py = area.scrollTop + pyInArea; + area.scrollLeft = (px - PAD) * (newZoom / oldZoom) + PAD - pxInArea; + area.scrollTop = (py - PAD) * (newZoom / oldZoom) + PAD - pyInArea; + EditorState.zoom = newZoom; + document.getElementById('zoom-container').style.transform = 'scale(' + newZoom + ')'; + clampScroll(area); + var display = Number.isInteger(newZoom) ? newZoom + 'x' : newZoom.toFixed(1) + 'x'; + document.getElementById('zoom-display').textContent = display; + _onZoomChangedListeners.forEach(function(fn) { fn(); }); +} + +// ── Brush helpers ───────────────────────────────────────────────────────── + +function getBrushStamp(size, shape) { + if (size === 1) return [[0, 0]]; + var offsets = []; + if (shape === 'round') { + var r = (size - 1) / 2; + for (var dy = -Math.ceil(r); dy <= Math.ceil(r); dy++) + for (var dx = -Math.ceil(r); dx <= Math.ceil(r); dx++) + if (Math.sqrt(dx * dx + dy * dy) <= r) offsets.push([dx, dy]); + } else { + var half = Math.floor(size / 2); + for (var dy2 = -half; dy2 <= half; dy2++) + for (var dx2 = -half; dx2 <= half; dx2++) + offsets.push([dx2, dy2]); + } + return offsets; +} + +function applyStamp(cx, cy, stamp, color) { + for (var k = 0; k < stamp.length; k++) { + var dx = stamp[k][0], dy = stamp[k][1]; + var x = cx + dx, y = cy + dy; + if (x >= 0 && x < EditorState.width && y >= 0 && y < EditorState.height) { + if (isSelectedPixel(x, y)) setPixel(x, y, color); + } + } + flushPixels(); +} + +function bresenhamLine(x0, y0, x1, y1) { + var pts = []; + var dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + var sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + var err = dx - dy; + while (true) { + pts.push([x0, y0]); + if (x0 === x1 && y0 === y1) break; + var e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + return pts; +} + +function drawCursorPreview(cx, cy, color) { + cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height); + if (!EditorState.pixels) return; + var stamp = getBrushStamp(EditorState.toolOptions.brushSize, EditorState.toolOptions.brushShape); + var r = color[0], g = color[1], b = color[2], a = color[3]; + for (var k = 0; k < stamp.length; k++) { + cursorCtx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (a / 255) + ')'; + cursorCtx.fillRect(cx + stamp[k][0], cy + stamp[k][1], 1, 1); + } +} + +function clearCursorPreview() { + if (cursorCtx) cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height); +} + +var _ppHistory = []; + +function resetPixelPerfect() { _ppHistory = []; } + +function shouldSkipPixelPerfect(cx, cy) { + if (_ppHistory.length < 2) { _ppHistory.push([cx, cy]); return false; } + var p2 = _ppHistory[0], p1 = _ppHistory[1]; + var sharesAxisWithP2 = (p1[0] === p2[0] || p1[1] === p2[1]); + var sharesAxisWithCur = (p1[0] === cx || p1[1] === cy); + var curDiffersFromP2 = (p2[0] !== cx && p2[1] !== cy); + var skip = sharesAxisWithP2 && sharesAxisWithCur && curDiffersFromP2; + if (!skip) { _ppHistory = [p1, [cx, cy]]; } + return skip; +} + +// ── Flood fill ──────────────────────────────────────────────────────────── + +function floodFill(startX, startY, fillColor, tolerance, contiguous) { + var target = getPixel(startX, startY); + if (target[3] === 0 && fillColor[3] === 0) return; + + function matches(px, py) { + var c = getPixel(px, py); + if (target[3] === 0 && c[3] === 0) return true; + if (target[3] === 0 || c[3] === 0) return false; + return Math.abs(c[0] - target[0]) <= tolerance && + Math.abs(c[1] - target[1]) <= tolerance && + Math.abs(c[2] - target[2]) <= tolerance && + Math.abs(c[3] - target[3]) <= tolerance; + } + + if (contiguous) { + var visited = new Uint8Array(EditorState.width * EditorState.height); + var startIdx = startX + startY * EditorState.width; + visited[startIdx] = 1; + var stack = [startIdx]; + while (stack.length) { + var idx = stack.pop(); + var px = idx % EditorState.width; + var py = (idx / EditorState.width) | 0; + if (isSelectedPixel(px, py)) setPixel(px, py, fillColor); + var neighbors = [[px-1,py],[px+1,py],[px,py-1],[px,py+1]]; + for (var k = 0; k < neighbors.length; k++) { + var nx = neighbors[k][0], ny = neighbors[k][1]; + if (nx < 0 || nx >= EditorState.width || ny < 0 || ny >= EditorState.height) continue; + var ni = nx + ny * EditorState.width; + if (!visited[ni] && matches(nx, ny)) { + visited[ni] = 1; + stack.push(ni); + } + } + } + } else { + for (var y = 0; y < EditorState.height; y++) + for (var x = 0; x < EditorState.width; x++) + if (matches(x, y) && isSelectedPixel(x, y)) setPixel(x, y, fillColor); + } + flushPixels(); +} + +function wandSelect(startX, startY, tolerance, contiguous) { + var W = EditorState.width, H = EditorState.height; + var mask = new Uint8Array(W * H); + var target = getPixel(startX, startY); + + function matches(px, py) { + var c = getPixel(px, py); + if (target[3] === 0 && c[3] === 0) return true; + if (target[3] === 0 || c[3] === 0) return false; + return Math.abs(c[0] - target[0]) <= tolerance && + Math.abs(c[1] - target[1]) <= tolerance && + Math.abs(c[2] - target[2]) <= tolerance && + Math.abs(c[3] - target[3]) <= tolerance; + } + + if (contiguous) { + var startIdx = startX + startY * W; + mask[startIdx] = 1; + var stack = [startIdx]; + while (stack.length) { + var idx = stack.pop(); + var px = idx % W, py = (idx / W) | 0; + var neighbors = [[px-1,py],[px+1,py],[px,py-1],[px,py+1]]; + for (var k = 0; k < neighbors.length; k++) { + var nx = neighbors[k][0], ny = neighbors[k][1]; + if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue; + var ni = nx + ny * W; + if (!mask[ni] && matches(nx, ny)) { + mask[ni] = 1; + stack.push(ni); + } + } + } + } else { + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) + if (matches(x, y)) mask[x + y * W] = 1; + } + + var bbox = computeBoundingBox(mask, W, H); + if (!bbox) return null; + return { mask: mask, bbox: bbox }; +} + +// ── Image loading ───────────────────────────────────────────────────────── + +async function loadImageFromB64(b64OrDataUrl) { + var src = b64OrDataUrl.startsWith('data:') + ? b64OrDataUrl + : 'data:image/png;base64,' + b64OrDataUrl; + await new Promise(function(resolve, reject) { + var img = new Image(); + img.onload = function() { + var width = img.naturalWidth, height = img.naturalHeight; + initCanvases(width, height); + pixelCtx.drawImage(img, 0, 0); + var imageData = pixelCtx.getImageData(0, 0, width, height); + EditorState.pixels = imageData.data.slice(); + clearSelection(); + flushPixels(); + resolve(); + }; + img.onerror = function() { reject(new Error('Image decode failed')); }; + img.src = src; + }); +} + +function showDropZone() { + var canvasArea = document.getElementById('canvas-area'); + var zone = document.createElement('div'); + zone.id = 'drop-zone'; + zone.style.cssText = 'position:absolute;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;cursor:pointer;background:var(--surface);'; + zone.innerHTML = '
\uD83D\uDDBC\uFE0F
\u70B9\u51FB\u6216\u62D6\u62FD\u56FE\u7247\u5230\u6B64\u5904
\u652F\u6301 PNG / JPG / WEBP \u7B49\u683C\u5F0F
'; + canvasArea.appendChild(zone); + + var fileInput = document.createElement('input'); + fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.style.display = 'none'; + canvasArea.appendChild(fileInput); + + zone.addEventListener('click', function() { fileInput.click(); }); + fileInput.addEventListener('change', function() { + if (fileInput.files[0]) handleFileUpload(fileInput.files[0]); + }); + zone.addEventListener('dragover', function(e) { e.preventDefault(); zone.style.background = 'rgba(124,106,247,.08)'; }); + zone.addEventListener('dragleave', function() { zone.style.background = 'var(--surface)'; }); + zone.addEventListener('drop', function(e) { + e.preventDefault(); + var f = e.dataTransfer.files[0]; + if (f && f.type.startsWith('image/')) handleFileUpload(f); + }); +} + +async function handleFileUpload(file) { + EditorState.filename = file.name; + var reader = new FileReader(); + reader.onload = async function(e) { + var dropZone = document.getElementById('drop-zone'); + if (dropZone) dropZone.remove(); + await loadImageFromB64(e.target.result); + pushHistory(); + bindPostLoadEvents(); + }; + reader.readAsDataURL(file); +} + +var _postLoadEventsBound = false; +function bindPostLoadEvents() { + if (_postLoadEventsBound) return; + _postLoadEventsBound = true; + cursorCanvas.addEventListener('pointermove', function(e) { + if (!EditorState.pixels) return; + var coords = viewportToCanvas(e.clientX, e.clientY); + var cx = coords[0], cy = coords[1]; + var c = getPixel(cx, cy); + document.getElementById('insp-x').textContent = cx; + document.getElementById('insp-y').textContent = cy; + document.getElementById('insp-r').textContent = c[0]; + document.getElementById('insp-g').textContent = c[1]; + document.getElementById('insp-b').textContent = c[2]; + document.getElementById('insp-a').textContent = c[3]; + document.getElementById('insp-swatch').style.background = + 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (c[3] / 255) + ')'; + }); + cursorCanvas.addEventListener('pointerleave', function() { + ['insp-x','insp-y','insp-r','insp-g','insp-b','insp-a'].forEach(function(id) { + document.getElementById(id).textContent = '\u2014'; + }); + document.getElementById('insp-swatch').style.background = 'transparent'; + }); +} diff --git a/editor/js/canvas-size.js b/editor/js/canvas-size.js new file mode 100644 index 0000000..d4c1b15 --- /dev/null +++ b/editor/js/canvas-size.js @@ -0,0 +1,226 @@ +// ── Canvas Size Tool ──────────────────────────────────────────────────────── + +// Baseline dimensions when canvas-size panel was last opened (for Restore) +var _cfgBaseW = 0, _cfgBaseH = 0; + +function _getCanvasSizeParams() { + var L = parseInt(document.getElementById('cfg-left').value) || 0; + var R = parseInt(document.getElementById('cfg-right').value) || EditorState.width; + var T = parseInt(document.getElementById('cfg-top').value) || 0; + var B = parseInt(document.getElementById('cfg-bottom').value) || EditorState.height; + var newW = Math.max(1, R - L); + var newH = Math.max(1, B - T); + var offsetL = -L; + var offsetT = -T; + return { newW: newW, newH: newH, offsetL: offsetL, offsetT: offsetT, L: L, R: R, T: T, B: B }; +} + +function drawCanvasSizeGuides() { + if (EditorState.activeTool !== 'canvas-size') return; + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + + if (!EditorState.pixels) return; + + var params = _getCanvasSizeParams(); + var L = params.L, R = params.R, T = params.T, B = params.B; + + var pixRect = pixelCanvas.getBoundingClientRect(); + var caRect = document.getElementById('canvas-area').getBoundingClientRect(); + var originX = pixRect.left - caRect.left; + var originY = pixRect.top - caRect.top; + var ps = pixRect.width / EditorState.width; + + var left = originX + L * ps; + var right = originX + R * ps; + var top = originY + T * ps; + var bottom = originY + B * ps; + + var oldRight = originX + EditorState.width * ps; + var oldBottom = originY + EditorState.height * ps; + var caW = selCanvas.width / dpr; + var caH = selCanvas.height / dpr; + + // Expansion area blue overlay + selCtx.save(); + selCtx.fillStyle = 'rgba(100,140,220,0.15)'; + if (L < 0) selCtx.fillRect(left, top, originX - left, bottom - top); + if (R > EditorState.width) selCtx.fillRect(oldRight, top, right - oldRight, bottom - top); + if (T < 0) selCtx.fillRect(left, top, right - left, originY - top); + if (B > EditorState.height) selCtx.fillRect(left, oldBottom, right - left, bottom - oldBottom); + selCtx.restore(); + + // Purple solid reference lines + selCtx.save(); + selCtx.strokeStyle = '#7c6af7'; + selCtx.lineWidth = 1; + selCtx.setLineDash([]); + selCtx.beginPath(); selCtx.moveTo(left, 0); selCtx.lineTo(left, caH); selCtx.stroke(); + selCtx.beginPath(); selCtx.moveTo(right, 0); selCtx.lineTo(right, caH); selCtx.stroke(); + selCtx.beginPath(); selCtx.moveTo(0, top); selCtx.lineTo(caW, top); selCtx.stroke(); + selCtx.beginPath(); selCtx.moveTo(0, bottom); selCtx.lineTo(caW, bottom); selCtx.stroke(); + selCtx.restore(); +} + +function applyCanvasSize() { + if (!EditorState.pixels) return; + var p = _getCanvasSizeParams(); + var oldPixels = EditorState.pixels; + var oldW = EditorState.width; + var oldH = EditorState.height; + + var newPixels = new Uint8ClampedArray(p.newW * p.newH * 4); + + var srcX0 = Math.max(0, -p.offsetL); + var srcY0 = Math.max(0, -p.offsetT); + var dstX0 = Math.max(0, p.offsetL); + var dstY0 = Math.max(0, p.offsetT); + var copyW = Math.min(oldW - srcX0, p.newW - dstX0); + var copyH = Math.min(oldH - srcY0, p.newH - dstY0); + + if (copyW > 0 && copyH > 0) { + for (var row = 0; row < copyH; row++) { + var srcOff = ((srcY0 + row) * oldW + srcX0) * 4; + var dstOff = ((dstY0 + row) * p.newW + dstX0) * 4; + newPixels.set(oldPixels.subarray(srcOff, srcOff + copyW * 4), dstOff); + } + } + + EditorState.pixels = newPixels; + clearSelection(); + initCanvases(p.newW, p.newH); + flushPixels(); + pushHistory(); + setActiveTool('pencil'); + _closeCanvasSizePanel(); +} + +function _closeCanvasSizePanel() { + var body = document.getElementById('canvasSizeBody'); + var header = document.getElementById('canvasSizeHeader'); + if (body) body.classList.add('hidden'); + if (header) header.classList.remove('open'); +} + +function cancelCanvasSize() { + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + setActiveTool('pencil'); + _closeCanvasSizePanel(); +} + +function toggleCanvasSizePanel(forceOpen) { + var body = document.getElementById('canvasSizeBody'); + var header = document.getElementById('canvasSizeHeader'); + var isOpen = !body.classList.contains('hidden'); + var shouldOpen = forceOpen !== undefined ? forceOpen : !isOpen; + if (shouldOpen === isOpen) return; + if (shouldOpen) { + body.classList.remove('hidden'); + header.classList.add('open'); + if (EditorState.pixels) { + _cfgBaseW = EditorState.width; + _cfgBaseH = EditorState.height; + setActiveTool('canvas-size'); + } + } else { + _closeCanvasSizePanel(); + if (EditorState.activeTool === 'canvas-size') { + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + setActiveTool('pencil'); + } + } +} + +function makeScrubber(input) { + var scrubStart = null, hasMoved = false; + input.addEventListener('pointerdown', function(e) { + scrubStart = { x: e.clientX, val: parseFloat(input.value) || 0 }; + hasMoved = false; + input.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + input.addEventListener('pointermove', function(e) { + if (!scrubStart) return; + var delta = Math.round((e.clientX - scrubStart.x) / 2); + if (Math.abs(delta) >= 1) { + hasMoved = true; + input.value = scrubStart.val + delta; + input.dispatchEvent(new Event('input')); + } + }); + input.addEventListener('pointerup', function() { + if (!scrubStart) return; + var moved = hasMoved; + scrubStart = null; + if (!moved) { + input.style.cursor = 'text'; + input.focus(); + input.select(); + } + }); + input.addEventListener('pointercancel', function() { scrubStart = null; }); + input.addEventListener('blur', function() { input.style.cursor = 'ew-resize'; }); +} + +function _syncFromLR() { + var L = parseInt(document.getElementById('cfg-left').value) || 0; + var R = parseInt(document.getElementById('cfg-right').value) || 0; + document.getElementById('cfg-width').value = Math.max(1, R - L); + drawCanvasSizeGuides(); +} +function _syncFromTB() { + var T = parseInt(document.getElementById('cfg-top').value) || 0; + var B = parseInt(document.getElementById('cfg-bottom').value) || 0; + document.getElementById('cfg-height').value = Math.max(1, B - T); + drawCanvasSizeGuides(); +} +function _syncFromW() { + var L = parseInt(document.getElementById('cfg-left').value) || 0; + var W = parseInt(document.getElementById('cfg-width').value) || 1; + document.getElementById('cfg-right').value = L + Math.max(1, W); + drawCanvasSizeGuides(); +} +function _syncFromH() { + var T = parseInt(document.getElementById('cfg-top').value) || 0; + var H = parseInt(document.getElementById('cfg-height').value) || 1; + document.getElementById('cfg-bottom').value = T + Math.max(1, H); + drawCanvasSizeGuides(); +} + +function initCanvasSizeBindings() { + // Attach scrubbers + ['cfg-width','cfg-height','cfg-left','cfg-right','cfg-top','cfg-bottom'].forEach(function(id) { + makeScrubber(document.getElementById(id)); + }); + + // Auto-sync + document.getElementById('cfg-left').addEventListener('input', _syncFromLR); + document.getElementById('cfg-right').addEventListener('input', _syncFromLR); + document.getElementById('cfg-top').addEventListener('input', _syncFromTB); + document.getElementById('cfg-bottom').addEventListener('input', _syncFromTB); + document.getElementById('cfg-width').addEventListener('input', _syncFromW); + document.getElementById('cfg-height').addEventListener('input', _syncFromH); + + document.getElementById('canvasSizeHeader').addEventListener('click', function() { toggleCanvasSizePanel(); }); + document.getElementById('btn-cfg-apply').addEventListener('click', function() { + applyCanvasSize(); + _cfgBaseW = EditorState.width; + _cfgBaseH = EditorState.height; + }); + document.getElementById('btn-cfg-restore').addEventListener('click', function() { + document.getElementById('cfg-left').value = 0; + document.getElementById('cfg-right').value = _cfgBaseW; + document.getElementById('cfg-top').value = 0; + document.getElementById('cfg-bottom').value = _cfgBaseH; + document.getElementById('cfg-width').value = _cfgBaseW; + document.getElementById('cfg-height').value = _cfgBaseH; + drawCanvasSizeGuides(); + }); + + // Reference lines follow canvas scroll + document.getElementById('zoom-scroll-content').addEventListener('scroll', function() { + if (EditorState.activeTool === 'canvas-size') drawCanvasSizeGuides(); + }, { passive: true }); +} diff --git a/editor/js/color-utils.js b/editor/js/color-utils.js new file mode 100644 index 0000000..076e7b2 --- /dev/null +++ b/editor/js/color-utils.js @@ -0,0 +1,34 @@ +// ── Color Utilities (shared between editor.html and web_ui.html) ────────── + +function hslToRgb(h, s, l) { + s /= 100; l /= 100; + const k = n => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; +} + +function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return [0, 0, Math.round(l * 100)]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]; +} + +function rgbToHex(r, g, b) { + return [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); +} + +function hexToRgb(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + const n = parseInt(hex, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} diff --git a/editor/js/history.js b/editor/js/history.js new file mode 100644 index 0000000..470ef88 --- /dev/null +++ b/editor/js/history.js @@ -0,0 +1,67 @@ +// ── History (undo/redo) ─────────────────────────────────────────────────── + +var btnUndo = null; +var btnRedo = null; + +function initHistoryButtons() { + btnUndo = document.getElementById('btn-undo'); + btnRedo = document.getElementById('btn-redo'); + btnUndo.addEventListener('click', undo); + btnRedo.addEventListener('click', redo); + updateHistoryButtons(); +} + +function updateHistoryButtons() { + if (!btnUndo || !btnRedo) return; + btnUndo.disabled = EditorState.historyIndex <= 0; + btnRedo.disabled = EditorState.historyIndex >= EditorState.history.length - 1; +} + +/** + * Save a snapshot of EditorState.pixels to the history stack. + * Must be called in pointerdown only — never in pointermove. + * One user action = one history entry. + * Each entry stores {pixels, width, height} so canvas-resize ops are fully undoable. + */ +function pushHistory() { + // Truncate any redo branch + EditorState.history.splice(EditorState.historyIndex + 1); + // Push snapshot including canvas dimensions (canvas-size changes must be undoable) + EditorState.history.push({ + pixels: EditorState.pixels.slice(), + width: EditorState.width, + height: EditorState.height, + }); + // Overflow: keep stack at MAX_HISTORY + if (EditorState.history.length > EditorState.MAX_HISTORY) { + EditorState.history.shift(); + } else { + EditorState.historyIndex++; + } + updateHistoryButtons(); +} + +function _restoreHistoryEntry(entry) { + var pixels = entry.pixels, width = entry.width, height = entry.height; + // If canvas size changed, rebuild canvas elements before writing pixels + if (width !== EditorState.width || height !== EditorState.height) { + initCanvases(width, height); + clearSelection(); + } + EditorState.pixels = pixels.slice(); + flushPixels(); +} + +function undo() { + if (EditorState.historyIndex <= 0) return; + EditorState.historyIndex--; + _restoreHistoryEntry(EditorState.history[EditorState.historyIndex]); + updateHistoryButtons(); +} + +function redo() { + if (EditorState.historyIndex >= EditorState.history.length - 1) return; + EditorState.historyIndex++; + _restoreHistoryEntry(EditorState.history[EditorState.historyIndex]); + updateHistoryButtons(); +} diff --git a/editor/js/main.js b/editor/js/main.js new file mode 100644 index 0000000..7b59e58 --- /dev/null +++ b/editor/js/main.js @@ -0,0 +1,712 @@ +// ── DOMContentLoaded: initialization, keyboard shortcuts, UI bindings ──────── + +document.addEventListener('DOMContentLoaded', function() { + // Acquire canvas element references + pixelCanvas = document.getElementById('pixel-canvas'); + selCanvas = document.getElementById('selection-canvas'); + cursorCanvas = document.getElementById('cursor-canvas'); + + // ── sessionStorage handoff from web_ui.html ──────────────────────────── + var _storedImage = sessionStorage.getItem('editorImage'); + var _storedFilename = sessionStorage.getItem('editorFilename'); + if (_storedImage) { + sessionStorage.removeItem('editorImage'); + sessionStorage.removeItem('editorFilename'); + EditorState.filename = _storedFilename || ''; + loadImageFromB64(_storedImage).then(function() { + pushHistory(); + bindPostLoadEvents(); + }); + } else { + showDropZone(); + } + + // Initialize history button references and event listeners + initHistoryButtons(); + + // Initialize tool implementations and pointer event dispatch + initTools(); + + // ── Color Picker Panel init ──────────────────────────────────────────── + pickerCanvas = document.getElementById('picker-canvas'); + pickerCtx = pickerCanvas.getContext('2d'); + var hsl = rgbToHsl(EditorState.foregroundColor[0], EditorState.foregroundColor[1], EditorState.foregroundColor[2]); + currentHue = hsl[0]; currentSat = hsl[1]; currentLit = hsl[2]; + _slCursorX = currentSat / 100; + _slCursorY = 1 - currentLit / 100; + redrawPicker(); + + function handlePickerDrag(px, py) { + if (_pickerDragZone === 'ring') { + var dx = px - PICKER_CX, dy = py - PICKER_CY; + currentHue = ((Math.atan2(dy, dx) * 180 / Math.PI) + 90 + 360) % 360; + EditorState.foregroundColor = [hslToRgb(currentHue, currentSat, currentLit)[0], hslToRgb(currentHue, currentSat, currentLit)[1], hslToRgb(currentHue, currentSat, currentLit)[2], 255]; + syncColorUI(); + } else if (_pickerDragZone === 'square') { + _slCursorX = Math.max(0, Math.min(1, (px - SQ_X) / SQ_W)); + _slCursorY = Math.max(0, Math.min(1, (py - SQ_Y) / SQ_H)); + currentSat = _slCursorX * 100; + currentLit = (1 - _slCursorY) * 100; + var rgb = hslToRgb(currentHue, currentSat, currentLit); + EditorState.foregroundColor = [rgb[0], rgb[1], rgb[2], 255]; + _syncFromDrag = true; + syncColorUI(); + _syncFromDrag = false; + } + } + + pickerCanvas.addEventListener('pointerdown', function(e) { + e.preventDefault(); + pickerCanvas.setPointerCapture(e.pointerId); + var rect = pickerCanvas.getBoundingClientRect(); + var px = e.clientX - rect.left, py = e.clientY - rect.top; + var dx = px - PICKER_CX, dy = py - PICKER_CY; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist >= RING_INNER && dist <= RING_OUTER) { + _pickerDragZone = 'ring'; + } else if (px >= SQ_X && px <= SQ_X + SQ_W && py >= SQ_Y && py <= SQ_Y + SQ_H) { + _pickerDragZone = 'square'; + } else { + _pickerDragZone = null; + } + handlePickerDrag(px, py); + }); + pickerCanvas.addEventListener('pointermove', function(e) { + if (!_pickerDragZone) return; + var rect = pickerCanvas.getBoundingClientRect(); + handlePickerDrag(e.clientX - rect.left, e.clientY - rect.top); + }); + pickerCanvas.addEventListener('pointerup', function() { _pickerDragZone = null; }); + pickerCanvas.addEventListener('pointercancel', function() { _pickerDragZone = null; }); + + // ── Multi-panel color picker bindings ────────────────────────────────── + + function applyHexStr(rawHex) { + var v = rawHex.trim().replace(/^#/, ''); + if (!/^[0-9a-fA-F]{6}$/.test(v)) { syncColorUI(); return; } + EditorState.foregroundColor = [ + parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16), 255, + ]; + syncColorUI(); + } + + function bindHexInput(id) { + var el = document.getElementById(id); + if (!el) return; + el.addEventListener('keydown', function(e) { if (e.key === 'Enter') applyHexStr(el.value); }); + el.addEventListener('blur', function() { applyHexStr(el.value); }); + } + + function bindRgbGroup(rId, gId, bId) { + [rId, gId, bId].forEach(function(id) { + var el = document.getElementById(id); + if (!el) return; + el.addEventListener('input', function() { + var rr = Math.max(0, Math.min(255, parseInt(document.getElementById(rId).value) || 0)); + var gg = Math.max(0, Math.min(255, parseInt(document.getElementById(gId).value) || 0)); + var bb = Math.max(0, Math.min(255, parseInt(document.getElementById(bId).value) || 0)); + EditorState.foregroundColor = [rr, gg, bb, 255]; + syncColorUI(); + }); + }); + } + + function bindCopyBtn(btnId) { + var btn = document.getElementById(btnId); + if (!btn) return; + btn.addEventListener('click', function() { + var fc = EditorState.foregroundColor; + var hex = '#' + [fc[0], fc[1], fc[2]].map(function(v) { return v.toString(16).padStart(2, '0'); }).join(''); + navigator.clipboard.writeText(hex).catch(function() { + var ta = document.createElement('textarea'); + ta.value = hex; + document.body.appendChild(ta); ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + }); + }); + } + + bindHexInput('pc-hex'); bindRgbGroup('pc-r', 'pc-g', 'pc-b'); bindCopyBtn('pc-copy'); + + // Style C RGB channel drag + function attachPcChannelDrag(id) { + var el = document.getElementById(id); + if (!el) return; + var startX = 0; + var startValue = 0; + var SPEED = 0.5; + + el.addEventListener('pointerdown', function(e) { + if (e.button !== 0) return; + e.preventDefault(); + el.setPointerCapture(e.pointerId); + startX = e.clientX; + startValue = parseInt(el.value, 10) || 0; + }); + + el.addEventListener('pointermove', function(e) { + if (!el.hasPointerCapture(e.pointerId)) return; + var dx = e.clientX - startX; + var next = Math.round(startValue + dx * SPEED); + next = Math.max(0, Math.min(255, next)); + if (parseInt(el.value, 10) === next) return; + el.value = next; + var rr = Math.max(0, Math.min(255, parseInt(document.getElementById('pc-r').value, 10) || 0)); + var gg = Math.max(0, Math.min(255, parseInt(document.getElementById('pc-g').value, 10) || 0)); + var bb = Math.max(0, Math.min(255, parseInt(document.getElementById('pc-b').value, 10) || 0)); + EditorState.foregroundColor = [rr, gg, bb, 255]; + syncColorUI(); + }); + + function endDrag(e) { + if (el.hasPointerCapture(e.pointerId)) { + el.releasePointerCapture(e.pointerId); + } + } + + el.addEventListener('pointerup', endDrag); + el.addEventListener('pointercancel', endDrag); + } + + ['pc-r', 'pc-g', 'pc-b'].forEach(attachPcChannelDrag); + + syncColorUI(); + + var pcHashEl = document.querySelector('.pc-hash'); + if (pcHashEl) { + pcHashEl.addEventListener('click', function() { + setActiveTool('eyedropper'); + }); + } + + // ── Tool settings UI bindings ────────────────────────────────────────── + + document.getElementById('opt-brush-size').addEventListener('input', function(e) { + EditorState.toolOptions.brushSize = Math.max(1, Math.min(32, parseInt(e.target.value) || 1)); + }); + document.getElementById('opt-brush-shape').addEventListener('change', function(e) { + EditorState.toolOptions.brushShape = e.target.value; + }); + document.getElementById('opt-pixel-perfect').addEventListener('change', function(e) { + EditorState.toolOptions.pixelPerfect = e.target.checked; + }); + document.getElementById('opt-eraser-size').addEventListener('input', function(e) { + EditorState.toolOptions.brushSize = Math.max(1, Math.min(32, parseInt(e.target.value) || 1)); + }); + document.getElementById('opt-eraser-shape').addEventListener('change', function(e) { + EditorState.toolOptions.brushShape = e.target.value; + }); + document.getElementById('opt-eraser-pixel-perfect').addEventListener('change', function(e) { + EditorState.toolOptions.eraserPixelPerfect = e.target.checked; + }); + document.getElementById('opt-bucket-tolerance').addEventListener('input', function(e) { + EditorState.toolOptions.bucketTolerance = Math.max(0, Math.min(255, parseInt(e.target.value) || 0)); + }); + document.getElementById('opt-contiguous').addEventListener('change', function(e) { + EditorState.toolOptions.contiguous = e.target.checked; + }); + + var optWandTol = document.getElementById('opt-wand-tolerance'); + var optWandCont = document.getElementById('opt-wand-contiguous'); + if (optWandTol) optWandTol.addEventListener('input', function() { + EditorState.toolOptions.wandTolerance = parseInt(optWandTol.value) || 0; + }); + if (optWandCont) optWandCont.addEventListener('change', function() { + EditorState.toolOptions.wandContiguous = optWandCont.checked; + }); + + var btnDeselect = document.getElementById('btn-deselect'); + var btnInverse = document.getElementById('btn-inverse'); + if (btnDeselect) btnDeselect.addEventListener('click', function() { clearSelection(); }); + if (btnInverse) btnInverse.addEventListener('click', function() { invertSelection(); }); + + // ── Canvas Size bindings ─────────────────────────────────────────────── + initCanvasSizeBindings(); + + // ── Download modal bindings ──────────────────────────────────────────── + document.getElementById('btn-download-open').addEventListener('click', openDownloadModal); + document.getElementById('dl-btn-cancel').addEventListener('click', closeDownloadModal); + document.getElementById('btn-go-home').addEventListener('click', goHome); + document.getElementById('dl-btn-download').addEventListener('click', function() { + var scale = Math.max(1, Math.min(100, parseInt(document.getElementById('dl-scale-num').value) || 1)); + triggerDownload(scale); + }); + + var dlSlider = document.getElementById('dl-scale-slider'); + var dlNum = document.getElementById('dl-scale-num'); + dlSlider.addEventListener('input', function() { dlNum.value = dlSlider.value; }); + dlNum.addEventListener('input', function() { + var v = Math.max(1, Math.min(100, parseInt(dlNum.value) || 1)); + dlSlider.value = v; + }); + + document.getElementById('download-modal').addEventListener('click', function(e) { + if (e.target === document.getElementById('download-modal')) closeDownloadModal(); + }); + + // ── Zoom controls ───────────────────────────────────────────────────── + var canvasArea = document.getElementById('canvas-area'); + var zoomScrollEl = document.getElementById('zoom-scroll-content'); + + // Eyedropper: EyeDropper API on pointer leave + canvasArea.addEventListener('pointerleave', function() { + if (EditorState.activeTool === 'eyedropper' && window.EyeDropper && !_eyedropperAborter) { + _eyedropperAborter = new AbortController(); + new window.EyeDropper().open({ signal: _eyedropperAborter.signal }) + .then(function(result) { + _eyedropperAborter = null; + var h = result.sRGBHex.replace('#', ''); + EditorState.foregroundColor = [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + 255, + ]; + syncColorUI(); + }) + .catch(function() { _eyedropperAborter = null; }); + } + }); + + canvasArea.addEventListener('wheel', function(e) { + e.preventDefault(); + + if (e.ctrlKey) { + var factor = e.deltaY < 0 ? 1.05 : 1 / 1.05; + applyZoom(EditorState.zoom * factor, e.clientX, e.clientY); + return; + } + + var pixelDeltaX = e.deltaMode === 0 ? e.deltaX : e.deltaX * 20; + var pixelDeltaY = e.deltaMode === 0 ? e.deltaY : e.deltaY * 20; + zoomScrollEl.scrollLeft += pixelDeltaX; + zoomScrollEl.scrollTop += pixelDeltaY; + clampScroll(zoomScrollEl); + }, { passive: false }); + + zoomScrollEl.addEventListener('scroll', function() { + if (EditorState.transformState) _drawTransformUI(); + }, { passive: true }); + + document.getElementById('btn-zoom-in').addEventListener('click', function() { + var rect = zoomScrollEl.getBoundingClientRect(); + applyZoom(snapZoomIn(EditorState.zoom), rect.left + rect.width / 2, rect.top + rect.height / 2); + }); + document.getElementById('btn-zoom-out').addEventListener('click', function() { + var rect = zoomScrollEl.getBoundingClientRect(); + applyZoom(snapZoomOut(EditorState.zoom), rect.left + rect.width / 2, rect.top + rect.height / 2); + }); + + // ── Keyboard shortcuts ───────────────────────────────────────────────── + document.addEventListener('keydown', function(e) { + if (e.target.matches('input, textarea')) return; + if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { + e.preventDefault(); + var rect = zoomScrollEl.getBoundingClientRect(); + applyZoom(snapZoomIn(EditorState.zoom), rect.left + rect.width / 2, rect.top + rect.height / 2); + } + if ((e.ctrlKey || e.metaKey) && e.key === '-') { + e.preventDefault(); + var rect2 = zoomScrollEl.getBoundingClientRect(); + applyZoom(snapZoomOut(EditorState.zoom), rect2.left + rect2.width / 2, rect2.top + rect2.height / 2); + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + undo(); + } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') { + e.preventDefault(); + redo(); + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'd') { + e.preventDefault(); + if (EditorState.activeTool !== 'move') clearSelection(); + } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'i') { + e.preventDefault(); + if (EditorState.activeTool !== 'move') invertSelection(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && !e.altKey && EditorState.selectionMask) { + e.preventDefault(); + deleteSelection(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && e.altKey && EditorState.selectionMask) { + e.preventDefault(); + fillSelection(); + } + if (!e.ctrlKey && !e.metaKey) { + if (e.key === 'b' || e.key === 'B') setActiveTool('pencil'); + if (e.key === 'e' || e.key === 'E') setActiveTool('eraser'); + if (e.key === 'g' || e.key === 'G') setActiveTool('bucket'); + if (e.key === 'i' || e.key === 'I') setActiveTool('eyedropper'); + if (e.key === 'm' || e.key === 'M') setActiveTool('marquee'); + if (e.key === 'w' || e.key === 'W') setActiveTool('wand'); + if (e.key === 'v' || e.key === 'V') { + if (EditorState.selectionMask || EditorState.transformState) setActiveTool('move'); + } + if (e.key === 's' || e.key === 'S') { + if (EditorState.pixels) toggleCanvasSizePanel(true); + } + } + if (e.key === 'Enter' && EditorState.transformState) { + e.preventDefault(); + applyTransform(); + } + if (e.key === 'Escape' && EditorState.activeTool === 'canvas-size') { + cancelCanvasSize(); return; + } + if (e.key === 'Escape' && EditorState.activeTool === 'eyedropper') { + setActiveTool(EditorState._prevTool || 'pencil'); + } + if (e.key === 'Escape' && EditorState.transformState && EditorState.activeTool !== 'eyedropper') { + cancelTransform(); + } + if (e.key === 'Escape' && EditorState.selectionMask && !EditorState.transformState && EditorState.activeTool !== 'eyedropper') { + var modalOpen = document.getElementById('shortcut-modal'); + if (!modalOpen || modalOpen.style.display !== 'flex') { e.preventDefault(); clearSelection(); } + } + if (e.key === 'Escape' && !EditorState.selectionMask && !EditorState.transformState && EditorState.activeTool !== 'eyedropper') { + var modalOpen2 = document.getElementById('shortcut-modal'); + if (!modalOpen2 || modalOpen2.style.display !== 'flex') { e.preventDefault(); setActiveTool(null); } + } + }); + + // ── Keyboard shortcut help modal ────────────────────────────────────── + (function() { + var isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + var mod = isMac ? '\u2318' : 'Ctrl'; + var alt = isMac ? '\u2325' : 'Alt'; + var shift = isMac ? '\u21e7' : 'Shift'; + + var SECTIONS = [ + { + title: '\u5de5\u5177', + rows: [ + { desc: '\u9009\u6846\uff08\u77e9\u5f62\uff09', keys: ['M'], icon: '/icons/Marquee_icon.png' }, + { desc: '\u9b54\u68d2', keys: ['W'], icon: '/icons/Wand_icon.png' }, + { desc: '\u79fb\u52a8', keys: ['V'], icon: '/icons/Move_icon.png' }, + { desc: '\u94c5\u7b14', keys: ['B'], icon: '/icons/Pencil_icon.png' }, + { desc: '\u6cb9\u6f06\u6876', keys: ['G'], icon: '/icons/Bucket_icon.png' }, + { desc: '\u6a61\u76ae\u64e6', keys: ['E'], icon: '/icons/Eraser_icon.png' }, + { desc: '\u5438\u7ba1\uff08\u53d6\u8272\uff09', keys: ['I'], icon: '/icons/Eyedropper_icon.png' }, + { desc: '\u53d6\u6d88', keys: ['Esc'] }, + ], + }, + { + title: '\u5386\u53f2', + rows: [ + { desc: '\u64a4\u9500', keys: [mod, 'Z'] }, + { desc: '\u91cd\u505a', keys: [mod, shift, 'Z'] }, + ], + }, + { + title: '\u89c6\u56fe', + rows: [ + { desc: '\u653e\u5927', keys: [mod, '+'] }, + { desc: '\u7f29\u5c0f', keys: [mod, '\u2212'] }, + ], + }, + { + title: '\u9009\u533a', + rows: [ + { desc: '\u53d6\u6d88\u9009\u533a', keys: [mod, 'D'] }, + { desc: '\u53cd\u9009', keys: [mod, shift, 'I'] }, + { desc: '\u5220\u9664\u9009\u533a\u5185\u50cf\u7d20', keys: ['Delete', '/', 'Backspace'] }, + { desc: '\u7528\u524d\u666f\u8272\u586b\u5145\u9009\u533a', keys: [alt, 'Delete', '/', alt, 'Backspace'] }, + ], + }, + ]; + + function buildShortcutList() { + var container = document.getElementById('shortcut-list'); + if (!container) return; + container.innerHTML = ''; + SECTIONS.forEach(function(section) { + var title = document.createElement('div'); + title.className = 'sc-section-title'; + title.textContent = section.title; + container.appendChild(title); + section.rows.forEach(function(row) { + var rowEl = document.createElement('div'); + rowEl.className = 'sc-row'; + var descEl = document.createElement('span'); + descEl.className = 'sc-desc'; + if (row.icon) { + var iconEl = document.createElement('img'); + iconEl.src = row.icon; + iconEl.width = 16; iconEl.height = 16; + iconEl.style.cssText = 'vertical-align:middle;margin-right:6px;opacity:0.75'; + descEl.appendChild(iconEl); + } + descEl.appendChild(document.createTextNode(row.desc)); + var keysEl = document.createElement('span'); + keysEl.className = 'sc-keys'; + row.keys.forEach(function(k) { + if (k === '/') { + var sep = document.createElement('span'); + sep.textContent = '/'; + sep.style.cssText = 'color:var(--text-muted);align-self:center;padding:0 2px'; + keysEl.appendChild(sep); + } else { + var keyEl = document.createElement('kbd'); + keyEl.className = 'sc-key'; + keyEl.textContent = k; + keysEl.appendChild(keyEl); + } + }); + rowEl.appendChild(descEl); + rowEl.appendChild(keysEl); + container.appendChild(rowEl); + }); + }); + } + + function showHelpModal() { + buildShortcutList(); + var modal = document.getElementById('shortcut-modal'); + if (modal) { modal.style.display = 'flex'; } + } + function closeHelpModal() { + var modal = document.getElementById('shortcut-modal'); + if (modal) { modal.style.display = 'none'; } + } + + var helpBtn = document.getElementById('help-btn'); + if (helpBtn) helpBtn.addEventListener('click', showHelpModal); + + var helpCloseBtn = document.getElementById('help-close-btn'); + if (helpCloseBtn) helpCloseBtn.addEventListener('click', closeHelpModal); + + var modal = document.getElementById('shortcut-modal'); + if (modal) { + modal.addEventListener('click', function(e) { + if (e.target === modal) closeHelpModal(); + }); + } + + document.addEventListener('keydown', function(e) { + if (e.target.matches('input, textarea')) return; + if (e.key === '?' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + var isOpen = document.getElementById('shortcut-modal'); + if (isOpen && isOpen.style.display === 'flex') closeHelpModal(); else showHelpModal(); + } + if (e.key === 'Escape') { + closeHelpModal(); + } + }); + })(); + + // ── Right-panel instant tooltip ──────────────────────────────────────── + (function() { + var tip = document.getElementById('tool-tip'); + var tipName = document.getElementById('tool-tip-name'); + var tipDesc = document.getElementById('tool-tip-desc'); + if (!tip) return; + + document.querySelectorAll('.tool-btn[data-tip-zh]').forEach(function(btn) { + btn.addEventListener('mouseenter', function() { + var zh = btn.dataset.tipZh || ''; + var en = btn.dataset.tipEn || ''; + var key = btn.dataset.tipKey || ''; + var desc = btn.dataset.tipDesc || ''; + + tipName.innerHTML = + zh + ' ' + en + '' + + (key ? ' ' + key + '' : ''); + tipDesc.textContent = desc; + tipDesc.style.display = desc ? '' : 'none'; + + tip.style.display = 'block'; + var rect = btn.getBoundingClientRect(); + var tipH = tip.offsetHeight; + var tipW = tip.offsetWidth; + tip.style.top = Math.round(rect.top + rect.height / 2 - tipH / 2) + 'px'; + tip.style.left = Math.round(rect.left - tipW - 8) + 'px'; + }); + btn.addEventListener('mouseleave', function() { tip.style.display = 'none'; }); + }); + })(); + + // ── Palette Panel: DOM event bindings ────────────────────────────────── + + var paletteHeader = document.getElementById('paletteHeader'); + var paletteBody = document.getElementById('paletteBody'); + if (paletteHeader && paletteBody) { + paletteHeader.addEventListener('click', function() { + var isOpen = !paletteBody.classList.contains('hidden'); + paletteBody.classList.toggle('hidden', isOpen); + paletteHeader.classList.toggle('open', !isOpen); + }); + } + + var nColorsSlider = document.getElementById('nColors'); + var nColorsInput = document.getElementById('nColorsInput'); + if (nColorsSlider && nColorsInput) { + nColorsSlider.addEventListener('input', function() { nColorsInput.value = nColorsSlider.value; }); + nColorsInput.addEventListener('input', function() { + var v = parseInt(nColorsInput.value); + if (!isNaN(v) && v >= 1) nColorsSlider.value = Math.min(64, Math.max(2, v)); + }); + } + + var minRegPctSlider = document.getElementById('minRegionPct'); + var minRegPctInput = document.getElementById('minRegionPctInput'); + if (minRegPctSlider && minRegPctInput) { + minRegPctSlider.addEventListener('input', function() { minRegPctInput.value = parseFloat(minRegPctSlider.value).toFixed(2); }); + minRegPctInput.addEventListener('input', function() { + var v = parseFloat(minRegPctInput.value); + if (!isNaN(v) && v >= 0.01) minRegPctSlider.value = Math.min(5, Math.max(0.01, v)); + }); + } + + var genAlgorithmEl = document.getElementById('genAlgorithm'); + var boostParamsEl = document.getElementById('boostParams'); + if (genAlgorithmEl && boostParamsEl) { + genAlgorithmEl.addEventListener('change', function() { + boostParamsEl.style.display = genAlgorithmEl.value === 'boost' ? 'block' : 'none'; + }); + } + + var generateBtn = document.getElementById('generateBtn'); + if (generateBtn) { + var ALGO_NAMES = { fastoctree: '\u516b\u53c9\u6811', mediancut: '\u4e2d\u503c\u5207\u5272', boost: '\u8986\u76d6\u589e\u5f3a' }; + generateBtn.addEventListener('click', function() { + var algo = genAlgorithmEl ? genAlgorithmEl.value : 'fastoctree'; + palShowStatus('\u6b63\u5728\u751f\u6210\u8272\u5361\uff08' + (ALGO_NAMES[algo] || algo) + '\uff09...'); + var n = nColorsInput ? (parseInt(nColorsInput.value) || 16) : 16; + var fd = new FormData(); + fd.append('algorithm', algo); + fd.append('n_colors', n); + if (algo === 'boost' && minRegPctInput) { + fd.append('min_region_pct', minRegPctInput.value || '1.0'); + } + if (!EditorState.pixels) { + palShowStatus('\u8bf7\u5148\u52a0\u8f7d\u56fe\u50cf', 'warning'); return; + } + var offCanvas = document.createElement('canvas'); + offCanvas.width = EditorState.width; offCanvas.height = EditorState.height; + var offCtx = offCanvas.getContext('2d'); + var src = EditorState.pixels; + var filtered = new Uint8ClampedArray(src.length); + for (var i = 0; i < src.length; i += 4) { + var a = src[i + 3]; + if (a >= 128) { + filtered[i] = src[i]; + filtered[i+1] = src[i+1]; + filtered[i+2] = src[i+2]; + filtered[i+3] = 255; + } else { + filtered[i] = filtered[i+1] = filtered[i+2] = filtered[i+3] = 0; + } + } + offCtx.putImageData(new ImageData(filtered, EditorState.width, EditorState.height), 0, 0); + var b64 = offCanvas.toDataURL('image/png').split(',')[1]; + fd.append('image', b64); + fetch('/api/generate-palette', { method: 'POST', body: fd }) + .then(function(res) { + return res.json().then(function(data) { + if (!res.ok) { palShowStatus('\u751f\u6210\u5931\u8d25\uff1a' + (data.error || ''), 'error'); return; } + currentPaletteMeta = { name: '', source: 'generate', algorithm: algo, count: data.count }; + setCurrentPalette(data.palette); + palShowStatus('\u8272\u5361\u5df2\u751f\u6210\uff1a' + data.count + ' \u8272\uff08' + (ALGO_NAMES[algo] || algo) + '\uff09', 'success'); + var paletteNameEl = document.getElementById('paletteName'); + if (paletteNameEl) paletteNameEl.value = '\u672a\u547d\u540d\u8272\u5361'; + }); + }) + .catch(function(err) { + palShowStatus('\u751f\u6210\u8272\u5361\u5931\u8d25\uff1a' + err.message, 'error'); + }); + }); + } + + var palFileInputEl = document.getElementById('palFileInput'); + if (palFileInputEl) { + palFileInputEl.addEventListener('change', function() { + var file = palFileInputEl.files[0]; + if (file) uploadPaletteFile(file); + }); + } + + var popupColorPickerEl = document.getElementById('popupColorPicker'); + var popupREl = document.getElementById('popupR'); + var popupGEl = document.getElementById('popupG'); + var popupBEl = document.getElementById('popupB'); + var popupHexEl = document.getElementById('popupHex'); + var popupDoneEl = document.getElementById('popupDone'); + var popupDeleteEl = document.getElementById('popupDelete'); + if (popupColorPickerEl) popupColorPickerEl.addEventListener('input', _popupSyncFromPicker); + if (popupREl) popupREl.addEventListener('input', _popupSyncFromRgb); + if (popupGEl) popupGEl.addEventListener('input', _popupSyncFromRgb); + if (popupBEl) popupBEl.addEventListener('input', _popupSyncFromRgb); + if (popupHexEl) popupHexEl.addEventListener('input', _popupSyncFromHex); + if (popupDoneEl) popupDoneEl.addEventListener('click', closeColorEditor); + if (popupDeleteEl) popupDeleteEl.addEventListener('click', function() { + if (editingSwatchIdx !== null) deleteSwatch(editingSwatchIdx); + }); + + document.addEventListener('click', function(e) { + var popup = document.getElementById('colorPopup'); + if (popup && !popup.contains(e.target) && !e.target.classList.contains('swatch')) { + closeColorEditor(); + } + }); + + var palDropBtnEl = document.getElementById('palDropBtn'); + var savedPaletteOptionsEl = document.getElementById('savedPaletteOptions'); + if (palDropBtnEl && savedPaletteOptionsEl) { + palDropBtnEl.addEventListener('click', function(e) { + e.stopPropagation(); + refreshSavedDropdown(); + var isOpen = savedPaletteOptionsEl.style.display !== 'none'; + savedPaletteOptionsEl.style.display = isOpen ? 'none' : 'block'; + }); + document.addEventListener('click', function(e) { + if (!e.target.closest('#savedPaletteOptions') && e.target !== palDropBtnEl) + savedPaletteOptionsEl.style.display = 'none'; + }); + } + + var savePaletteBtnEl = document.getElementById('savePaletteBtn'); + if (savePaletteBtnEl) { + savePaletteBtnEl.addEventListener('click', function() { + var nameEl = document.getElementById('paletteName'); + var name = nameEl ? nameEl.value.trim() : ''; + if (!name) { palShowStatus('\u8bf7\u8f93\u5165\u8272\u5361\u540d\u79f0', 'warning'); return; } + if (currentPalette.length === 0) { palShowStatus('\u5f53\u524d\u8272\u5361\u4e3a\u7a7a', 'warning'); return; } + var saved = getSavedPalettes(); + saved[name] = currentPalette; + setSavedPalettes(saved); + selectedPaletteKey = name; + var trigText = document.getElementById('savedPaletteTriggerText'); + if (trigText) trigText.textContent = name + ' (' + currentPalette.length + ' \u8272)'; + refreshSavedDropdown(); + palShowStatus('\u8272\u5361\u300c' + name + '\u300d\u5df2\u4fdd\u5b58', 'success'); + }); + } + + var exportDropBtnEl = document.getElementById('exportDropBtn'); + var exportMenuEl = document.getElementById('exportMenu'); + if (exportDropBtnEl && exportMenuEl) { + exportDropBtnEl.addEventListener('click', function(e) { + e.stopPropagation(); + exportMenuEl.style.display = exportMenuEl.style.display === 'none' ? 'block' : 'none'; + }); + document.addEventListener('click', function() { exportMenuEl.style.display = 'none'; }); + } + + var exportActEl = document.getElementById('exportAct'); + var exportGplEl = document.getElementById('exportGpl'); + var exportPalEl = document.getElementById('exportPal'); + if (exportActEl) exportActEl.addEventListener('click', function() { exportPalette('act'); }); + if (exportGplEl) exportGplEl.addEventListener('click', function() { exportPalette('gpl'); }); + if (exportPalEl) exportPalEl.addEventListener('click', function() { exportPalette('pal'); }); + + var applyPaletteBtnEl = document.getElementById('applyPaletteBtn'); + if (applyPaletteBtnEl) { + applyPaletteBtnEl.addEventListener('click', applyPalette); + } + + refreshSavedDropdown(); +}); diff --git a/editor/js/palette.js b/editor/js/palette.js new file mode 100644 index 0000000..abfd130 --- /dev/null +++ b/editor/js/palette.js @@ -0,0 +1,615 @@ +// ── Color Picker State ──────────────────────────────────────────────────── +var pickerCanvas = null, pickerCtx = null; +var currentHue = 0, currentSat = 0, currentLit = 0; +var PICKER_CX = 80, PICKER_CY = 80; +var RING_OUTER = 75, RING_INNER = 55; +var SQ_X = 41, SQ_Y = 41, SQ_W = 78, SQ_H = 78; +var _pickerDragZone = null; +var _slCursorX = 0, _slCursorY = 0; +var _syncFromDrag = false; + +function drawHueRing(ctx) { + var grad = ctx.createConicGradient(-Math.PI / 2, PICKER_CX, PICKER_CY); + var stops = [ + [0, '#ff0000'], [1/6, '#ffff00'], [2/6, '#00ff00'], + [3/6, '#00ffff'], [4/6, '#0000ff'], [5/6, '#ff00ff'], [1, '#ff0000'] + ]; + stops.forEach(function(s) { grad.addColorStop(s[0], s[1]); }); + ctx.beginPath(); + ctx.arc(PICKER_CX, PICKER_CY, RING_OUTER, 0, 2 * Math.PI); + ctx.arc(PICKER_CX, PICKER_CY, RING_INNER, 0, 2 * Math.PI, true); + ctx.fillStyle = grad; + ctx.fill('evenodd'); +} + +function drawSLSquare(ctx) { + var satGrad = ctx.createLinearGradient(SQ_X, SQ_Y, SQ_X + SQ_W, SQ_Y); + satGrad.addColorStop(0, 'hsl(' + currentHue + ', 0%, 50%)'); + satGrad.addColorStop(1, 'hsl(' + currentHue + ', 100%, 50%)'); + ctx.fillStyle = satGrad; + ctx.fillRect(SQ_X, SQ_Y, SQ_W, SQ_H); + var litGrad = ctx.createLinearGradient(SQ_X, SQ_Y, SQ_X, SQ_Y + SQ_H); + litGrad.addColorStop(0, 'rgba(255,255,255,1)'); + litGrad.addColorStop(0.5, 'rgba(255,255,255,0)'); + litGrad.addColorStop(0.5, 'rgba(0,0,0,0)'); + litGrad.addColorStop(1, 'rgba(0,0,0,1)'); + ctx.fillStyle = litGrad; + ctx.fillRect(SQ_X, SQ_Y, SQ_W, SQ_H); +} + +function drawPickerIndicators(ctx) { + var ringR = (RING_OUTER + RING_INNER) / 2; + var hueRad = (currentHue - 90) * Math.PI / 180; + var rx = PICKER_CX + ringR * Math.cos(hueRad); + var ry = PICKER_CY + ringR * Math.sin(hueRad); + ctx.beginPath(); + ctx.arc(rx, ry, 5, 0, 2 * Math.PI); + ctx.fillStyle = '#fff'; + ctx.fill(); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 1; + ctx.stroke(); + + var sx = SQ_X + _slCursorX * SQ_W; + var sy = SQ_Y + _slCursorY * SQ_H; + ctx.beginPath(); + ctx.arc(sx, sy, 5, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.stroke(); +} + +function redrawPicker() { + if (!pickerCtx) return; + pickerCtx.clearRect(0, 0, 160, 160); + drawHueRing(pickerCtx); + drawSLSquare(pickerCtx); + drawPickerIndicators(pickerCtx); +} + +// ── syncColorUI — updates all panel styles ─────────────────────────────── +var _syncLock = false; +function syncColorUI() { + if (_syncLock) return; + _syncLock = true; + var fc = EditorState.foregroundColor; + var r = fc[0], g = fc[1], b = fc[2]; + var hex = '#' + [r, g, b].map(function(v) { return v.toString(16).padStart(2, '0'); }).join(''); + var hexNH = hex.slice(1); + var rgb = 'rgb(' + r + ',' + g + ',' + b + ')'; + + var pcH = document.getElementById('pc-hex'); + if (pcH) { + pcH.value = hexNH; + pcH.style.borderColor = rgb; + pcH.style.background = 'rgba(' + r + ',' + g + ',' + b + ',0.15)'; + } + var pcR = document.getElementById('pc-r'); if (pcR) pcR.value = r; + var pcG = document.getElementById('pc-g'); if (pcG) pcG.value = g; + var pcB = document.getElementById('pc-b'); if (pcB) pcB.value = b; + var brR = document.getElementById('pc-bar-r'); if (brR) brR.style.width = (r / 255 * 100).toFixed(1) + '%'; + var brG = document.getElementById('pc-bar-g'); if (brG) brG.style.width = (g / 255 * 100).toFixed(1) + '%'; + var brB = document.getElementById('pc-bar-b'); if (brB) brB.style.width = (b / 255 * 100).toFixed(1) + '%'; + var pcHash = document.querySelector('.pc-hash'); + if (pcHash) pcHash.style.background = rgb; + var pcCopy = document.getElementById('pc-copy'); + if (pcCopy) pcCopy.style.borderColor = rgb; + + var hsl = rgbToHsl(r, g, b); + currentLit = hsl[2]; + if (!_syncFromDrag) { + if (hsl[2] > 0 && hsl[2] < 100) { + currentHue = hsl[0]; + currentSat = hsl[1]; + _slCursorX = currentSat / 100; + } + _slCursorY = 1 - currentLit / 100; + } + redrawPicker(); + _syncLock = false; + highlightMatchingSwatches(); +} + +// ── PAL-02: Highlight matching swatches ─────────────────────────────────── +function highlightMatchingSwatches() { + if (!EditorState.pixels) return; + var fc = EditorState.foregroundColor; + var fr = fc[0], fg = fc[1], fb = fc[2]; + document.querySelectorAll('#swatchesGrid .swatch').forEach(function(sw, idx) { + if (idx >= currentPalette.length) return; + var c = currentPalette[idx]; + if (c[0] === fr && c[1] === fg && c[2] === fb) { + sw.style.boxShadow = '0 0 0 2px #fff, 0 0 8px 4px rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; + sw.style.zIndex = '3'; + } else { + sw.style.boxShadow = ''; + sw.style.zIndex = ''; + } + }); +} + +// ── getEditorImageB64: export current pixels as base64 PNG ─────────────── +function getEditorImageB64() { + if (!EditorState.pixels || !EditorState.width || !EditorState.height) return null; + var off = document.createElement('canvas'); + off.width = EditorState.width; + off.height = EditorState.height; + var octx = off.getContext('2d'); + octx.putImageData( + new ImageData(EditorState.pixels.slice(), EditorState.width, EditorState.height), + 0, 0 + ); + return off.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''); +} + +// ── Palette Panel: showStatus helper ──────────────────────────────────── +function palShowStatus(msg, type) { + console.info('[palette]', type || 'info', msg); +} + +// ── Palette Panel: core functions ─────────────────────────────────────── + +function setCurrentPalette(palette) { + currentPalette = palette.map(function(c) { + return [ + Math.max(0, Math.min(255, Math.round(c[0]))), + Math.max(0, Math.min(255, Math.round(c[1]))), + Math.max(0, Math.min(255, Math.round(c[2]))), + ]; + }); + EditorState.palette = currentPalette.slice(); + renderSwatches(); + var applyBtn = document.getElementById('applyPaletteBtn'); + if (applyBtn) { + applyBtn.disabled = !EditorState.pixels || currentPalette.length === 0; + } +} + +function renderSwatches() { + var grid = document.getElementById('swatchesGrid'); + var countEl = document.getElementById('swatchCount'); + if (!grid) return; + if (countEl) countEl.textContent = currentPalette.length + ' \u8272'; + if (currentPalette.length === 0) { + grid.innerHTML = '\u8272\u5361\u4E3A\u7A7A'; + if (typeof highlightMatchingSwatches === 'function') highlightMatchingSwatches(); + return; + } + grid.innerHTML = ''; + currentPalette.forEach(function(color, idx) { + var r = color[0], g = color[1], b = color[2]; + var hex = rgbToHex(r, g, b); + var sw = document.createElement('div'); + sw.className = 'swatch'; + sw.style.background = '#' + hex; + sw.title = '#' + hex; + var del = document.createElement('button'); + del.className = 'del-btn'; del.textContent = '\u00d7'; + del.onclick = function(e) { e.stopPropagation(); deleteSwatch(idx); }; + sw.appendChild(del); + sw.addEventListener('click', function() { + var c = currentPalette[idx]; + EditorState.foregroundColor = [c[0], c[1], c[2], 255]; + syncColorUI(); + }); + sw.addEventListener('dblclick', function(e) { e.stopPropagation(); openColorEditor(idx, sw); }); + grid.appendChild(sw); + }); + var addSwEl = document.createElement('div'); + addSwEl.className = 'add-swatch'; + addSwEl.title = '\u6DFB\u52A0\u5F53\u524D\u524D\u666F\u8272\u5230\u8272\u5361'; + addSwEl.textContent = '+'; + addSwEl.addEventListener('click', function() { + var fc = EditorState.foregroundColor; + currentPalette.push([fc[0], fc[1], fc[2]]); + EditorState.palette = currentPalette.slice(); + renderSwatches(); + }); + grid.appendChild(addSwEl); + if (typeof highlightMatchingSwatches === 'function') highlightMatchingSwatches(); +} + +function deleteSwatch(idx) { + currentPalette.splice(idx, 1); + EditorState.palette = currentPalette.slice(); + closeColorEditor(); + renderSwatches(); +} + +// ── Color editor popup ──────────────────────────────────────────────────── +var _popupSyncLock = false; + +function openColorEditor(idx, anchorEl) { + editingSwatchIdx = idx; + var c = currentPalette[idx]; + var r = c[0], g = c[1], b = c[2]; + var hex = rgbToHex(r, g, b); + var popupColorPicker = document.getElementById('popupColorPicker'); + var popupR = document.getElementById('popupR'); + var popupG = document.getElementById('popupG'); + var popupB = document.getElementById('popupB'); + var popupHex = document.getElementById('popupHex'); + if (popupColorPicker) popupColorPicker.value = '#' + hex; + if (popupR) popupR.value = r; + if (popupG) popupG.value = g; + if (popupB) popupB.value = b; + if (popupHex) popupHex.value = hex; + var popup = document.getElementById('colorPopup'); + if (!popup) return; + var rect = anchorEl.getBoundingClientRect(); + var top = rect.bottom + 6, left = rect.left; + if (left + 210 > window.innerWidth) left = window.innerWidth - 215; + if (top + 220 > window.innerHeight) top = rect.top - 226; + popup.style.top = top + 'px'; popup.style.left = left + 'px'; + popup.style.display = 'flex'; +} + +function closeColorEditor() { + var popup = document.getElementById('colorPopup'); + if (popup) popup.style.display = 'none'; + editingSwatchIdx = null; +} + +function _popupSyncFromRgb() { + if (_popupSyncLock) return; _popupSyncLock = true; + var r = Math.max(0, Math.min(255, parseInt(document.getElementById('popupR').value) || 0)); + var g = Math.max(0, Math.min(255, parseInt(document.getElementById('popupG').value) || 0)); + var b = Math.max(0, Math.min(255, parseInt(document.getElementById('popupB').value) || 0)); + var hex = rgbToHex(r, g, b); + var pp = document.getElementById('popupColorPicker'); + var ph = document.getElementById('popupHex'); + if (pp) pp.value = '#' + hex; + if (ph) ph.value = hex; + if (editingSwatchIdx !== null) { currentPalette[editingSwatchIdx] = [r, g, b]; EditorState.palette = currentPalette.slice(); renderSwatches(); } + _popupSyncLock = false; +} + +function _popupSyncFromHex() { + if (_popupSyncLock) return; + var ph = document.getElementById('popupHex'); + if (!ph) return; + var hex = ph.value.replace('#', ''); + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return; + _popupSyncLock = true; + var rgb = hexToRgb(hex); + var pp = document.getElementById('popupColorPicker'); + var pR = document.getElementById('popupR'); + var pG = document.getElementById('popupG'); + var pB = document.getElementById('popupB'); + if (pR) pR.value = rgb[0]; if (pG) pG.value = rgb[1]; if (pB) pB.value = rgb[2]; + if (pp) pp.value = '#' + hex.toUpperCase(); + if (editingSwatchIdx !== null) { currentPalette[editingSwatchIdx] = [rgb[0], rgb[1], rgb[2]]; EditorState.palette = currentPalette.slice(); renderSwatches(); } + _popupSyncLock = false; +} + +function _popupSyncFromPicker() { + if (_popupSyncLock) return; + var pp = document.getElementById('popupColorPicker'); + if (!pp) return; + var hex = pp.value.replace('#', ''); + _popupSyncLock = true; + var rgb = hexToRgb(hex); + var pR = document.getElementById('popupR'); + var pG = document.getElementById('popupG'); + var pB = document.getElementById('popupB'); + var ph = document.getElementById('popupHex'); + if (pR) pR.value = rgb[0]; if (pG) pG.value = rgb[1]; if (pB) pB.value = rgb[2]; + if (ph) ph.value = hex.toUpperCase(); + if (editingSwatchIdx !== null) { currentPalette[editingSwatchIdx] = [rgb[0], rgb[1], rgb[2]]; EditorState.palette = currentPalette.slice(); renderSwatches(); } + _popupSyncLock = false; +} + +// ── Save / load palettes (localStorage) ────────────────────────────────── +var PAL_LS_KEY = 'pp_saved_palettes'; +function getSavedPalettes() { try { return JSON.parse(localStorage.getItem(PAL_LS_KEY) || '{}'); } catch(e) { return {}; } } +function setSavedPalettes(obj) { localStorage.setItem(PAL_LS_KEY, JSON.stringify(obj)); } + +function refreshSavedDropdown() { + var saved = getSavedPalettes(); + var keys = Object.keys(saved); + var opts = document.getElementById('savedPaletteOptions'); + if (!opts) return; + opts.innerHTML = ''; + + if (keys.length === 0) { + opts.innerHTML = '
\u6682\u65E0\u5DF2\u4FDD\u5B58\u7684\u8272\u5361
'; + var uploadOptEmptyEl = document.createElement('div'); + uploadOptEmptyEl.className = 'custom-option'; + uploadOptEmptyEl.style.borderTop = '1px solid var(--border)'; + uploadOptEmptyEl.innerHTML = '\uD83D\uDCC1 \u4ECE\u672C\u5730\u4E0A\u4F20'; + uploadOptEmptyEl.addEventListener('click', function() { + opts.style.display = 'none'; + var palFileInputEl = document.getElementById('palFileInput'); + if (palFileInputEl) palFileInputEl.click(); + }); + opts.appendChild(uploadOptEmptyEl); + return; + } + + keys.forEach(function(name) { + var palette = saved[name]; + var div = document.createElement('div'); + div.className = 'custom-option' + (name === selectedPaletteKey ? ' selected' : ''); + div.dataset.key = name; + + var nameEl = document.createElement('div'); + nameEl.className = 'option-name'; + nameEl.textContent = name + ' (' + palette.length + ' \u8272)'; + + var swatchesEl = document.createElement('div'); + swatchesEl.className = 'option-swatches'; + palette.slice(0, 20).forEach(function(c) { + var s = document.createElement('span'); + s.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; + swatchesEl.appendChild(s); + }); + + div.appendChild(nameEl); + div.appendChild(swatchesEl); + div.addEventListener('click', function() { + selectedPaletteKey = name; + var paletteNameInputEl = document.getElementById('paletteName'); + if (paletteNameInputEl) paletteNameInputEl.value = name; + setCurrentPalette(palette); + var savedPaletteOptionsEl = document.getElementById('savedPaletteOptions'); + if (savedPaletteOptionsEl) savedPaletteOptionsEl.style.display = 'none'; + palShowStatus('\u5DF2\u52A0\u8F7D\u8272\u5361\u300C' + name + '\u300D\uFF1A' + palette.length + ' \u8272', 'success'); + }); + var delBtn = document.createElement('button'); + delBtn.className = 'pal-del-btn'; + delBtn.textContent = '\u2715'; + delBtn.title = '\u5220\u9664\u6B64\u8272\u5361'; + delBtn.addEventListener('click', function(e) { + e.stopPropagation(); + var palettes = getSavedPalettes(); + delete palettes[name]; + setSavedPalettes(palettes); + if (selectedPaletteKey === name) selectedPaletteKey = null; + refreshSavedDropdown(); + }); + div.appendChild(delBtn); + opts.appendChild(div); + }); + + var uploadOptEl = document.createElement('div'); + uploadOptEl.className = 'custom-option'; + uploadOptEl.style.borderTop = '1px solid var(--border)'; + uploadOptEl.innerHTML = '\uD83D\uDCC1 \u4ECE\u672C\u5730\u4E0A\u4F20'; + uploadOptEl.addEventListener('click', function() { + var savedPaletteOptionsEl = document.getElementById('savedPaletteOptions'); + if (savedPaletteOptionsEl) savedPaletteOptionsEl.style.display = 'none'; + var palFileInputEl = document.getElementById('palFileInput'); + if (palFileInputEl) palFileInputEl.click(); + }); + opts.appendChild(uploadOptEl); +} + +// ── Palette file upload ──────────────────────────────────────────────────── +async function uploadPaletteFile(file) { + palShowStatus('\u89E3\u6790\u8272\u5361\u6587\u4EF6...'); + var fd = new FormData(); + fd.append('file', file); + try { + var res = await fetch('/api/parse-palette', { method: 'POST', body: fd }); + var data = await res.json(); + if (!res.ok) { palShowStatus('\u89E3\u6790\u5931\u8D25\uFF1A' + (data.error || ''), 'error'); return; } + currentPaletteMeta = { name: data.name, source: 'upload', algorithm: '', count: data.palette.length }; + setCurrentPalette(data.palette); + var palFilenameEl = document.getElementById('palFilename'); + if (palFilenameEl) palFilenameEl.textContent = file.name; + var paletteNameEl = document.getElementById('paletteName'); + if (data.name && paletteNameEl) paletteNameEl.value = data.name; + palShowStatus('\u5DF2\u52A0\u8F7D\u8272\u5361\u300C' + data.name + '\u300D\uFF1A' + data.palette.length + ' \u8272', 'success'); + } catch (err) { + palShowStatus('\u4E0A\u4F20\u5931\u8D25\uFF1A' + err.message, 'error'); + } +} + +// ── Palette export ───────────────────────────────────────────────────────── +async function exportPalette(fmt) { + if (currentPalette.length === 0) { palShowStatus('\u8272\u5361\u4E3A\u7A7A\uFF0C\u65E0\u6CD5\u5BFC\u51FA', 'warning'); return; } + var fd = new FormData(); + fd.append('palette', JSON.stringify(currentPalette)); + fd.append('format', fmt); + var nameEl = document.getElementById('paletteName'); + fd.append('name', (nameEl ? nameEl.value.trim() : '') || 'Custom Palette'); + try { + var res = await fetch('/api/export-palette', { method: 'POST', body: fd }); + if (!res.ok) { palShowStatus('\u5BFC\u51FA\u5931\u8D25', 'error'); return; } + var blob = await res.blob(); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + var nm = nameEl ? nameEl.value.trim() : 'palette'; + a.href = url; a.download = (nm || 'palette') + '.' + fmt; + a.click(); URL.revokeObjectURL(url); + palShowStatus('\u5DF2\u5BFC\u51FA .' + fmt + ' \u6587\u4EF6', 'success'); + } catch (err) { palShowStatus('\u5BFC\u51FA\u5931\u8D25\uFF1A' + err.message, 'error'); } +} + +// ── PAL-04: nearest palette color matching ──────────────────────────────── +function nearestPaletteColor(r, g, b, palette) { + var bestIdx = 0, bestDist = Infinity; + for (var i = 0; i < palette.length; i++) { + var dr = r - palette[i][0], dg = g - palette[i][1], db = b - palette[i][2]; + var dist = dr*dr + dg*dg + db*db; + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + return palette[bestIdx]; +} + +// ── PAL-LAB: sRGB -> CIE LAB ───────────────────────────────────────────── +function rgbToLab(r, g, b) { + var R = r / 255, G = g / 255, B = b / 255; + R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92; + G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92; + B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92; + var X = (R * 0.4124564 + G * 0.3575761 + B * 0.1804375) / 0.95047; + var Y = (R * 0.2126729 + G * 0.7151522 + B * 0.0721750) / 1.00000; + var Z = (R * 0.0193339 + G * 0.1191920 + B * 0.9503041) / 1.08883; + var f = function(t) { return t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16 / 116); }; + var L = 116 * f(Y) - 16; + var A = 500 * (f(X) - f(Y)); + var Bv = 200 * (f(Y) - f(Z)); + return [L, A, Bv]; +} + +function nearestPaletteColorLab(r, g, b, palette) { + var lab1 = rgbToLab(r, g, b); + var bestIdx = 0, bestDist = Infinity; + for (var i = 0; i < palette.length; i++) { + var lab2 = rgbToLab(palette[i][0], palette[i][1], palette[i][2]); + var dL = lab1[0] - lab2[0], dA = lab1[1] - lab2[1], dB = lab1[2] - lab2[2]; + var dist = dL*dL + dA*dA + dB*dB; + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + return palette[bestIdx]; +} + +// ── PAL-04: apply palette — three modes ──────────────────────────────── +async function applyPalette() { + if (!EditorState.pixels || currentPalette.length === 0) return; + var modeEl = document.getElementById('mappingModeSelect'); + var mode = modeEl ? modeEl.value : 'vector'; + var px = EditorState.pixels; + var pal = currentPalette; + + if (mode === 'vector') { + for (var i = 0; i < px.length; i += 4) { + if (px[i + 3] <= 127) { + px[i] = 0; px[i+1] = 0; px[i+2] = 0; px[i+3] = 0; + } else { + var nc = nearestPaletteColor(px[i], px[i+1], px[i+2], pal); + px[i] = nc[0]; px[i+1] = nc[1]; px[i+2] = nc[2]; px[i+3] = 255; + } + } + flushPixels(); + pushHistory(); + palShowStatus('\u8272\u5361\u5DF2\u5E94\u7528\uFF08\u5411\u91CF\u5339\u914D\uFF09', 'success'); + + } else if (mode === 'perceptual') { + for (var j = 0; j < px.length; j += 4) { + if (px[j + 3] <= 127) { + px[j] = 0; px[j+1] = 0; px[j+2] = 0; px[j+3] = 0; + } else { + var nc2 = nearestPaletteColorLab(px[j], px[j+1], px[j+2], pal); + px[j] = nc2[0]; px[j+1] = nc2[1]; px[j+2] = nc2[2]; px[j+3] = 255; + } + } + flushPixels(); + pushHistory(); + palShowStatus('\u8272\u5361\u5DF2\u5E94\u7528\uFF08\u611F\u77E5\u5339\u914D\uFF09', 'success'); + + } else { + palShowStatus('\u6B63\u5728\u5904\u7406\uFF08\u8272\u5361\u66FF\u6362\uFF09\u2026', 'info'); + try { + var offscreen = document.createElement('canvas'); + offscreen.width = EditorState.width; + offscreen.height = EditorState.height; + var offCtx = offscreen.getContext('2d', { alpha: true }); + offCtx.putImageData(new ImageData(px.slice(), EditorState.width, EditorState.height), 0, 0); + var imageB64 = offscreen.toDataURL('image/png').split(',')[1]; + + var origAlpha = new Uint8Array(EditorState.width * EditorState.height); + for (var k = 0; k < px.length; k += 4) { + origAlpha[k >> 2] = px[k + 3]; + } + + var fd = new FormData(); + fd.append('image', imageB64); + fd.append('palette', JSON.stringify(pal)); + fd.append('mode', 'swap'); + var resp = await fetch('/api/apply-palette', { method: 'POST', body: fd }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + if (data.error) throw new Error(data.error); + + var img = new Image(); + await new Promise(function(resolve, reject) { + img.onload = resolve; img.onerror = reject; + img.src = 'data:image/png;base64,' + data.output; + }); + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = EditorState.width; + resultCanvas.height = EditorState.height; + var rCtx = resultCanvas.getContext('2d', { willReadFrequently: true }); + rCtx.drawImage(img, 0, 0); + var resultData = rCtx.getImageData(0, 0, EditorState.width, EditorState.height).data; + + for (var m = 0; m < px.length; m += 4) { + var a = origAlpha[m >> 2]; + if (a <= 127) { + px[m] = 0; px[m+1] = 0; px[m+2] = 0; px[m+3] = 0; + } else { + px[m] = resultData[m]; px[m+1] = resultData[m+1]; px[m+2] = resultData[m+2]; px[m+3] = 255; + } + } + flushPixels(); + pushHistory(); + palShowStatus('\u8272\u5361\u5DF2\u5E94\u7528\uFF08\u8272\u5361\u66FF\u6362\uFF09', 'success'); + } catch (err) { + palShowStatus('\u8272\u5361\u66FF\u6362\u5931\u8D25\uFF1A' + err.message, 'error'); + } + } +} + +// ── Download ────────────────────────────────────────────────────────────── +var _lastBlobUrl = null; + +function triggerDownload(scale) { + if (!EditorState.pixels || !EditorState.width) return; + var newW = EditorState.width * scale; + var newH = EditorState.height * scale; + + var src = document.createElement('canvas'); + src.width = EditorState.width; src.height = EditorState.height; + src.getContext('2d').putImageData( + new ImageData(EditorState.pixels.slice(), EditorState.width, EditorState.height), 0, 0 + ); + + var off = document.createElement('canvas'); + off.width = newW; off.height = newH; + var dctx = off.getContext('2d'); + dctx.imageSmoothingEnabled = false; + dctx.drawImage(src, 0, 0, newW, newH); + + off.toBlob(function(blob) { + if (_lastBlobUrl) { URL.revokeObjectURL(_lastBlobUrl); _lastBlobUrl = null; } + var blobUrl = URL.createObjectURL(blob); + _lastBlobUrl = blobUrl; + + var previewImg = document.getElementById('dl-preview-img'); + previewImg.src = blobUrl; + previewImg.style.width = ''; + previewImg.style.height = ''; + document.getElementById('dl-size-info').textContent = newW + ' \u00d7 ' + newH + ' px'; + document.getElementById('dl-preview').style.display = 'block'; + + var baseName = (EditorState.filename || '').replace(/\.[^.]+$/, '') || 'output'; + var filename = baseName + '_pixelated_' + scale + 'x.png'; + var a = document.createElement('a'); + a.href = blobUrl; a.download = filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + }, 'image/png'); +} + +function goHome() { + if (EditorState.pixels) { + if (!confirm('\u5C06\u4E22\u5931\u5F53\u524D\u8FDB\u5EA6\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F')) return; + } + window.location.href = '/'; +} + +function openDownloadModal() { + document.getElementById('dl-preview').style.display = 'none'; + document.getElementById('dl-scale-slider').value = 1; + document.getElementById('dl-scale-num').value = 1; + var modal = document.getElementById('download-modal'); + modal.style.display = 'flex'; +} + +function closeDownloadModal() { + document.getElementById('download-modal').style.display = 'none'; + if (_lastBlobUrl) { URL.revokeObjectURL(_lastBlobUrl); _lastBlobUrl = null; } +} diff --git a/editor/js/rotsprite.js b/editor/js/rotsprite.js new file mode 100644 index 0000000..943bf6d --- /dev/null +++ b/editor/js/rotsprite.js @@ -0,0 +1,209 @@ +// ── RotSprite rotation algorithm ───────────────────────────────────────── + +function _showStatus(msg) { + var toast = document.getElementById('editor-status-toast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'editor-status-toast'; + toast.style.cssText = [ + 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)', + 'background:rgba(40,38,58,0.95)', 'color:#e0ddf8', + 'border:1px solid #7c6af7', 'border-radius:8px', + 'padding:8px 18px', 'font-size:13px', 'z-index:9999', + 'pointer-events:none', 'white-space:nowrap', + ].join(';'); + document.body.appendChild(toast); + } + toast.textContent = msg; + toast.style.display = 'block'; + clearTimeout(_showStatus._timer); + _showStatus._timer = setTimeout(function() { toast.style.display = 'none'; }, 3000); + console.info('[status]', msg); +} +_showStatus._timer = null; + +function colorEq(pixels, i1, i2) { + if (pixels[i1+3] === 0 && pixels[i2+3] === 0) return true; + if (pixels[i1+3] === 0 || pixels[i2+3] === 0) return false; + return pixels[i1] === pixels[i2] && + pixels[i1+1] === pixels[i2+1] && + pixels[i1+2] === pixels[i2+2] && + pixels[i1+3] === pixels[i2+3]; +} + +function scale2x(pixels, w, h) { + var OW = w * 2; + var out = new Uint8ClampedArray(OW * h * 2 * 4); + + function copyPixel(src, dst) { + out[dst] = pixels[src]; + out[dst+1] = pixels[src+1]; + out[dst+2] = pixels[src+2]; + out[dst+3] = pixels[src+3]; + } + + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + var B = (Math.max(0, y-1) * w + x) * 4; + var D = (y * w + Math.max(0, x-1)) * 4; + var E = (y * w + x) * 4; + var F = (y * w + Math.min(w-1, x+1)) * 4; + var H = (Math.min(h-1, y+1) * w + x) * 4; + + var oy = y * 2, ox = x * 2; + var E0 = (oy * OW + ox) * 4; + var E1 = (oy * OW + ox + 1) * 4; + var E2 = ((oy+1) * OW + ox) * 4; + var E3 = ((oy+1) * OW + ox + 1) * 4; + + if (!colorEq(pixels, B, H) && !colorEq(pixels, D, F)) { + copyPixel(colorEq(pixels, D, B) && pixels[D+3] > 0 ? D : E, E0); + copyPixel(colorEq(pixels, B, F) && pixels[B+3] > 0 ? F : E, E1); + copyPixel(colorEq(pixels, D, H) && pixels[D+3] > 0 ? D : E, E2); + copyPixel(colorEq(pixels, H, F) && pixels[H+3] > 0 ? F : E, E3); + } else { + copyPixel(E, E0); copyPixel(E, E1); + copyPixel(E, E2); copyPixel(E, E3); + } + } + } + return out; +} + +function scaleNearestNeighbor(pixels, srcW, srcH, dstW, dstH) { + var out = new Uint8ClampedArray(dstW * dstH * 4); + var xRatio = srcW / dstW; + var yRatio = srcH / dstH; + for (var dy = 0; dy < dstH; dy++) { + var sy = Math.min(srcH - 1, Math.floor(dy * yRatio)); + for (var dx = 0; dx < dstW; dx++) { + var sx = Math.min(srcW - 1, Math.floor(dx * xRatio)); + var srcI = (sy * srcW + sx) * 4; + var dstI = (dy * dstW + dx) * 4; + out[dstI] = pixels[srcI]; + out[dstI+1] = pixels[srcI+1]; + out[dstI+2] = pixels[srcI+2]; + out[dstI+3] = pixels[srcI+3]; + } + } + return out; +} + +function rotSprite(pixels, w, h, angleDeg) { + var buf = pixels, bw = w, bh = h; + for (var pass = 0; pass < 3; pass++) { + buf = scale2x(buf, bw, bh); + bw *= 2; bh *= 2; + } + var rad = -angleDeg * Math.PI / 180; + var cos = Math.cos(rad), sin = Math.sin(rad); + var cx = bw / 2, cy = bh / 2; + var rotBuf = new Uint8ClampedArray(bw * bh * 4); + + for (var dy = 0; dy < bh; dy++) { + for (var dx = 0; dx < bw; dx++) { + var relX = dx - cx, relY = dy - cy; + var srcX = Math.round(relX * cos - relY * sin + cx); + var srcY = Math.round(relX * sin + relY * cos + cy); + if (srcX >= 0 && srcX < bw && srcY >= 0 && srcY < bh) { + var si = (srcY * bw + srcX) * 4; + var di = (dy * bw + dx) * 4; + rotBuf[di] = buf[si]; + rotBuf[di+1] = buf[si+1]; + rotBuf[di+2] = buf[si+2]; + rotBuf[di+3] = buf[si+3]; + } + } + } + + var out = new Uint8ClampedArray(w * h * 4); + for (var oy = 0; oy < h; oy++) { + for (var ox = 0; ox < w; ox++) { + var sx2 = ox * 8 + 4; + var sy2 = oy * 8 + 4; + if (sx2 < bw && sy2 < bh) { + var si2 = (sy2 * bw + sx2) * 4; + var di2 = (oy * w + ox) * 4; + out[di2] = rotBuf[si2]; + out[di2+1] = rotBuf[si2+1]; + out[di2+2] = rotBuf[si2+2]; + out[di2+3] = rotBuf[si2+3]; + } + } + } + return out; +} + +function _tightCrop(pixels, w, h) { + var minX = w, maxX = -1, minY = h, maxY = -1; + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + if (pixels[(y * w + x) * 4 + 3] > 0) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + if (maxX < 0) return { pixels: pixels, w: w, h: h, offX: 0, offY: 0 }; + if (minX === 0 && minY === 0 && maxX === w - 1 && maxY === h - 1) { + return { pixels: pixels, w: w, h: h, offX: 0, offY: 0 }; + } + var cw = maxX - minX + 1, ch = maxY - minY + 1; + var out = new Uint8ClampedArray(cw * ch * 4); + for (var y2 = 0; y2 < ch; y2++) { + for (var x2 = 0; x2 < cw; x2++) { + var si = ((y2 + minY) * w + (x2 + minX)) * 4; + var di = (y2 * cw + x2) * 4; + out[di] = pixels[si]; out[di+1] = pixels[si+1]; + out[di+2] = pixels[si+2]; out[di+3] = pixels[si+3]; + } + } + return { pixels: out, w: cw, h: ch, offX: minX, offY: minY }; +} + +function _rotSpriteExpanded(pixels, w, h, angleDeg) { + var buf = pixels, bw = w, bh = h; + for (var pass = 0; pass < 3; pass++) { + buf = scale2x(buf, bw, bh); + bw *= 2; bh *= 2; + } + var rad = -angleDeg * Math.PI / 180; + var cos = Math.cos(rad), sin = Math.sin(rad); + var absCos = Math.abs(cos), absSin = Math.abs(sin); + var EPS = 1e-9; + var outW = Math.ceil(w * absCos + h * absSin - EPS); + var outH = Math.ceil(w * absSin + h * absCos - EPS); + var outBw = outW * 8; + var outBh = outH * 8; + var icx = bw / 2, icy = bh / 2; + var ocx = outBw / 2, ocy = outBh / 2; + + var rotBuf = new Uint8ClampedArray(outBw * outBh * 4); + for (var dy = 0; dy < outBh; dy++) { + for (var dx = 0; dx < outBw; dx++) { + var relX = dx - ocx, relY = dy - ocy; + var srcX = Math.round(relX * cos - relY * sin + icx); + var srcY = Math.round(relX * sin + relY * cos + icy); + if (srcX >= 0 && srcX < bw && srcY >= 0 && srcY < bh) { + var si = (srcY * bw + srcX) * 4; + var di = (dy * outBw + dx) * 4; + rotBuf[di] = buf[si]; rotBuf[di+1] = buf[si+1]; + rotBuf[di+2] = buf[si+2]; rotBuf[di+3] = buf[si+3]; + } + } + } + var out = new Uint8ClampedArray(outW * outH * 4); + for (var oy = 0; oy < outH; oy++) { + for (var ox = 0; ox < outW; ox++) { + var sx = ox * 8 + 4, sy = oy * 8 + 4; + if (sx < outBw && sy < outBh) { + var si2 = (sy * outBw + sx) * 4, di2 = (oy * outW + ox) * 4; + out[di2] = rotBuf[si2]; out[di2+1] = rotBuf[si2+1]; + out[di2+2] = rotBuf[si2+2]; out[di2+3] = rotBuf[si2+3]; + } + } + } + return { pixels: out, w: outW, h: outH }; +} diff --git a/editor/js/selection.js b/editor/js/selection.js new file mode 100644 index 0000000..e56d097 --- /dev/null +++ b/editor/js/selection.js @@ -0,0 +1,181 @@ +// ── Selection state ─────────────────────────────────────────────────────── +var antsRafId = null; + +function snapToGrid(v, gridSize) { + if (gridSize <= 1) return v; + return Math.round(v / gridSize) * gridSize; +} + +function updateSelectionUI() { + var hasSelection = !!EditorState.selectionMask; + var isMoveActive = EditorState.activeTool === 'move'; + var btnDeselect = document.getElementById('btn-deselect'); + var btnInverse = document.getElementById('btn-inverse'); + if (btnDeselect) btnDeselect.style.display = (hasSelection && !isMoveActive) ? '' : 'none'; + if (btnInverse) btnInverse.style.display = (hasSelection && !isMoveActive) ? '' : 'none'; + var btnMove = document.querySelector('.tool-btn[data-tool="move"]'); + if (btnMove) btnMove.disabled = !(hasSelection || !!EditorState.transformState); +} + +function clearSelection() { + EditorState.selectionMask = null; + EditorState.selection = null; + if (antsRafId) { cancelAnimationFrame(antsRafId); antsRafId = null; } + if (selCtx) { + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + } + updateSelectionUI(); +} + +function computeBoundingBox(mask, W, H) { + var minX = W, minY = H, maxX = -1, maxY = -1; + for (var y = 0; y < H; y++) { + for (var x = 0; x < W; x++) { + if (mask[x + y * W]) { + if (x < minX) minX = x; if (y < minY) minY = y; + if (x > maxX) maxX = x; if (y > maxY) maxY = y; + } + } + } + if (maxX === -1) return null; + return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; +} + +function setSelection(mask, bbox) { + EditorState.selectionMask = mask; + EditorState.selection = bbox; + updateSelectionUI(); + scheduleAnts(); +} + +function isSelectedPixel(x, y) { + if (!EditorState.selectionMask) return true; + var bb = EditorState.selection; + if (!bb || x < bb.x || x >= bb.x + bb.w || y < bb.y || y >= bb.y + bb.h) return false; + return EditorState.selectionMask[x + y * EditorState.width] === 1; +} + +function unionMasks(existingMask, newMask, totalPixels) { + if (!existingMask) return newMask; + var result = new Uint8Array(totalPixels); + for (var i = 0; i < totalPixels; i++) + result[i] = existingMask[i] | newMask[i]; + return result; +} + +function invertSelection() { + var W = EditorState.width, H = EditorState.height; + var total = W * H; + if (!EditorState.selectionMask) { + var allMask = new Uint8Array(total).fill(1); + setSelection(allMask, { x: 0, y: 0, w: W, h: H }); + } else { + var newMask = new Uint8Array(total); + for (var i = 0; i < total; i++) newMask[i] = EditorState.selectionMask[i] ? 0 : 1; + var bbox = computeBoundingBox(newMask, W, H); + if (bbox) { + setSelection(newMask, bbox); + } else { + clearSelection(); + } + } +} + +function deleteSelection() { + if (!EditorState.selectionMask) return; + pushHistory(); + var W = EditorState.width; + for (var i = 0; i < EditorState.selectionMask.length; i++) { + if (EditorState.selectionMask[i]) { + var x = i % W, y = (i / W) | 0; + setPixel(x, y, [0, 0, 0, 0]); + } + } + flushPixels(); +} + +function fillSelection() { + if (!EditorState.selectionMask) return; + pushHistory(); + var W = EditorState.width; + var color = [EditorState.foregroundColor[0], EditorState.foregroundColor[1], EditorState.foregroundColor[2], EditorState.foregroundColor[3]]; + color[3] = 255; + for (var i = 0; i < EditorState.selectionMask.length; i++) { + if (EditorState.selectionMask[i]) { + var x = i % W, y = (i / W) | 0; + setPixel(x, y, color); + } + } + flushPixels(); +} + +function drawAnts() { + if (!EditorState.selection) { antsRafId = null; return; } + + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + selCtx.globalCompositeOperation = 'source-over'; + selCtx.setLineDash([]); + + var caRect = document.getElementById('canvas-area').getBoundingClientRect(); + var pixRect = pixelCanvas.getBoundingClientRect(); + var originX = pixRect.left - caRect.left; + var originY = pixRect.top - caRect.top; + var ps = pixRect.width / EditorState.width; + + var bright = Math.floor(Date.now() / 500) % 2 === 0; + selCtx.strokeStyle = bright ? 'rgba(255,255,255,1)' : 'rgba(255,255,255,0.15)'; + selCtx.lineWidth = 2; + var off = 1; + + var mask = EditorState.selectionMask; + var bx = EditorState.selection.x, by = EditorState.selection.y; + var bw = EditorState.selection.w, bh = EditorState.selection.h; + + if (mask) { + var W = EditorState.width, H = EditorState.height; + var path = new Path2D(); + + for (var py = by; py < by + bh; py++) { + for (var px = bx; px < bx + bw; px++) { + if (!mask[px + py * W]) continue; + + var sx = originX + px * ps; + var sy = originY + py * ps; + + if (py === 0 || !mask[px + (py - 1) * W]) { + path.moveTo(sx - off, sy - off); + path.lineTo(sx + ps + off, sy - off); + } + if (py === H - 1 || !mask[px + (py + 1) * W]) { + path.moveTo(sx - off, sy + ps + off); + path.lineTo(sx + ps + off, sy + ps + off); + } + if (px === 0 || !mask[(px - 1) + py * W]) { + path.moveTo(sx - off, sy - off); + path.lineTo(sx - off, sy + ps + off); + } + if (px === W - 1 || !mask[(px + 1) + py * W]) { + path.moveTo(sx + ps + off, sy - off); + path.lineTo(sx + ps + off, sy + ps + off); + } + } + } + selCtx.stroke(path); + } else { + selCtx.strokeRect( + originX + bx * ps - off, + originY + by * ps - off, + bw * ps + off * 2, + bh * ps + off * 2 + ); + } + + antsRafId = requestAnimationFrame(drawAnts); +} + +function scheduleAnts() { + if (antsRafId) { cancelAnimationFrame(antsRafId); antsRafId = null; } + antsRafId = requestAnimationFrame(drawAnts); +} diff --git a/editor/js/state.js b/editor/js/state.js new file mode 100644 index 0000000..9bc3156 --- /dev/null +++ b/editor/js/state.js @@ -0,0 +1,121 @@ +// ── EditorState — Single Source of Truth ───────────────────────────── +// Source: CLAUDE.md "EditorState" section / RESEARCH.md Pattern 4 +var EditorState = { + width: 0, height: 0, + pixels: null, // Uint8ClampedArray, RGBA, length = w*h*4 + gridW: 0, gridH: 0, // pixel art grid cell size (from Ver 1.1) + zoom: 4, panX: 0, panY: 0, + activeTool: 'pencil', // 'pencil'|'eraser'|'bucket'|'wand'|'marquee'|'move' + foregroundColor: [0, 0, 0, 255], + toolOptions: { + brushSize: 1, brushShape: 'round', + pixelPerfect: false, + eraserPixelPerfect: false, + bucketTolerance: 15, wandTolerance: 15, + contiguous: true, + wandContiguous: true, + }, + selection: null, // null | {x, y, w, h} in canvas pixels + selectionMask: null, // Uint8Array, length = width*height; 1=selected, 0=not + selectionPixels: null, // Uint8ClampedArray for move/transform + transformState: null, + history: [], // Uint8ClampedArray snapshots + historyIndex: -1, + MAX_HISTORY: 100, + palette: [], // [[r,g,b], ...] — same format as web_ui.html + filename: '', // original filename for download naming + + // Minimal pub/sub (no library needed) + _listeners: {}, + on: function(event, fn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + }, + emit: function(event, data) { + (this._listeners[event] || []).forEach(function(fn) { fn(data); }); + }, +}; + +// ── Palette Panel State ─────────────────────────────────────────────────── +var currentPalette = []; // [[r,g,b], ...] +var editingSwatchIdx = null; +var selectedPaletteKey = ''; +var currentPaletteMeta = { name: '', source: 'upload', algorithm: '', count: 0 }; + +// ── Canvas context refs (set by initCanvases, used by all tools) ───────── +var pixelCanvas = null; +var selCanvas = null; +var cursorCanvas = null; +var pixelCtx = null; +var selCtx = null; +var cursorCtx = null; + +// ── Eyedropper: AbortController for open EyeDropper API session ────────── +var _eyedropperAborter = null; + +// Hook: called by setActiveTool (outer scope) to activate transform when switching to +// Move tool. activateTransform is defined inside DOMContentLoaded so it needs bridging. +var _onMoveToolSelected = null; + +// B2 fix: hook set by DOMContentLoaded to redraw transform overlay on zoom change. +// Multicast array replaces single _onZoomChanged reference. +var _onZoomChangedListeners = []; + +// ── Tool activation ─────────────────────────────────────────────────────── +function setActiveTool(name) { + // If leaving move tool with active transform, cancel it (Plan 06-03) + // cancelTransform is defined inside DOMContentLoaded; guard with typeof check + if (EditorState.activeTool === 'move' && name !== 'move' && EditorState.transformState) { + if (typeof cancelTransform === 'function') cancelTransform(); + } + // Eyedropper: canvas picks use getPixel(EditorState.pixels); off-canvas + // auto-triggers EyeDropper API after cursor leaves canvas for 300 ms. + if (name === 'eyedropper' && EditorState.activeTool !== 'eyedropper') { + EditorState._prevTool = EditorState.activeTool; + document.body.classList.add('eyedropper-active'); + } else if (name !== 'eyedropper' && EditorState.activeTool === 'eyedropper') { + document.body.classList.remove('eyedropper-active'); + // Abort any open EyeDropper API session + if (_eyedropperAborter) { _eyedropperAborter.abort(); _eyedropperAborter = null; } + } + + EditorState.activeTool = name; + document.querySelectorAll('.tool-btn[data-tool]').forEach(function(btn) { + btn.classList.toggle('tool-btn-active', btn.dataset.tool === name); + }); + // Auto-activate transform when switching to move tool (hook bridges outer->inner scope) + if (name === 'move' && _onMoveToolSelected) _onMoveToolSelected(); + updateSelectionUI(); + // Show/hide tool settings panels + var panelIds = ['tool-settings-pencil', 'tool-settings-eraser', 'tool-settings-bucket', + 'tool-settings-marquee', 'tool-settings-wand', 'tool-settings-move']; + panelIds.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + var activePanel = document.getElementById('tool-settings-' + name); + if (activePanel) activePanel.style.display = 'flex'; + // canvas-size: initialize inputs and draw guides + if (name === 'canvas-size') { + var W = EditorState.width || 0; + var H = EditorState.height || 0; + var set = function(id) { return function(v) { var el = document.getElementById(id); if (el) el.value = v; }; }; + set('cfg-left')(0); set('cfg-right')(W); + set('cfg-top')(0); set('cfg-bottom')(H); + set('cfg-width')(W); set('cfg-height')(H); + clearSelection(); // clear existing selection, selection-canvas reserved for guides + setTimeout(function() { if (typeof drawCanvasSizeGuides === 'function') drawCanvasSizeGuides(); }, 0); + } + // Cursor style follows active tool (eyedropper-active body class handles global cursor) + if (cursorCanvas) { + if (name === 'marquee' || name === 'wand' || name === 'eyedropper') { + cursorCanvas.style.cursor = 'crosshair'; + } else if (name === 'pencil' || name === 'eraser' || name === 'bucket') { + cursorCanvas.style.cursor = 'none'; // custom cursor drawn on canvas + } else if (name === 'move') { + cursorCanvas.style.cursor = 'move'; + } else { + cursorCanvas.style.cursor = 'default'; + } + } +} diff --git a/editor/js/tools.js b/editor/js/tools.js new file mode 100644 index 0000000..8e63793 --- /dev/null +++ b/editor/js/tools.js @@ -0,0 +1,967 @@ +// ── Tool dispatch + implementations ───────────────────────────────────────── + +var tools = { + pencil: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, + eraser: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, + bucket: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, + eyedropper: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, + marquee: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, + wand: { onDown: function(){}, onMove: function(){}, onUp: function(){}, onCursor: function(){} }, +}; +var isDrawing = false; + +// ── Transform helper state ───────────────────────────────────────────────── +var _scaleLinkActive = false; +var _scaleInputTimer = null; +var _rotateInputTimer = null; + +// ── Pencil state ─────────────────────────────────────────────────────────── +var _pencilStamp = null; +var _lastPencilX = null, _lastPencilY = null; + +// ── Eraser state ─────────────────────────────────────────────────────────── +var _eraserStamp = null; +var _lastEraserX = null, _lastEraserY = null; + +// ── Marquee state ────────────────────────────────────────────────────────── +var _marqueeStartX = null, _marqueeStartY = null; +var _marqueeCurrentX = null, _marqueeCurrentY = null; + +// ── Transform: helper functions ──────────────────────────────────────────── + +function _getSelCanvasCoords() { + var caRect = document.getElementById('canvas-area').getBoundingClientRect(); + var pixRect = pixelCanvas.getBoundingClientRect(); + return { + originX: pixRect.left - caRect.left, + originY: pixRect.top - caRect.top, + ps: pixRect.width / EditorState.width, + }; +} + +function _selCanvasPointerDown(e) { + var ts = EditorState.transformState; + if (!ts) return; + e.preventDefault(); + + var hitResult = hitTestHandle(e.clientX, e.clientY); + if (hitResult !== null && hitResult.type === 'scale') { + var handleIdx = hitResult.handleIdx; + ts._origFloatPixels = ts.floatPixels.slice(); + ts._origFloatW = ts.floatW; + ts._origFloatH = ts.floatH; + ts._dragStartScaleX = ts.scaleX; + ts._dragStartScaleY = ts.scaleY; + ts._dragMode = 'handle-' + handleIdx; + ts._dragStartClientX = e.clientX; + ts._dragStartClientY = e.clientY; + ts._dragStartFloatX = ts.floatX; + ts._dragStartFloatY = ts.floatY; + var anchorFrac = [[1,1],[0.5,1],[0,1],[1,0.5],[0,0.5],[1,0],[0.5,0],[0,0]]; + var af = anchorFrac[handleIdx]; + ts._dragAnchorX = ts.floatX + af[0] * ts.floatW; + ts._dragAnchorY = ts.floatY + af[1] * ts.floatH; + ts._dragAnchorFracX = af[0]; + ts._dragAnchorFracY = af[1]; + } else if (hitResult !== null && hitResult.type === 'rotate') { + tools.move.onDown(0, 0, e); + } else { + ts._dragMode = 'move'; + ts._dragStartClientX = e.clientX; + ts._dragStartClientY = e.clientY; + ts._dragStartFloatX = ts.floatX; + ts._dragStartFloatY = ts.floatY; + } + selCanvas.setPointerCapture(e.pointerId); + + function onSelMove(ev) { tools.move.onMove(0, 0, ev); } + function onSelUp(ev) { + tools.move.onUp(0, 0, ev); + selCanvas.removeEventListener('pointermove', onSelMove); + selCanvas.removeEventListener('pointerup', onSelUp); + } + selCanvas.addEventListener('pointermove', onSelMove); + selCanvas.addEventListener('pointerup', onSelUp); +} + +function activateTransform() { + if (!EditorState.selectionMask || !EditorState.selection) return; + if (EditorState.transformState) return; + + var bb = EditorState.selection; + var W = EditorState.width; + var mask = EditorState.selectionMask; + + var originalPixels = EditorState.pixels.slice(); + + var floatPx = new Uint8ClampedArray(bb.w * bb.h * 4); + for (var fy = 0; fy < bb.h; fy++) { + for (var fx = 0; fx < bb.w; fx++) { + var cx = bb.x + fx, cy = bb.y + fy; + if (mask[cx + cy * W]) { + var srcI = (cy * W + cx) * 4; + var dstI = (fy * bb.w + fx) * 4; + floatPx[dstI] = EditorState.pixels[srcI]; + floatPx[dstI+1] = EditorState.pixels[srcI+1]; + floatPx[dstI+2] = EditorState.pixels[srcI+2]; + floatPx[dstI+3] = EditorState.pixels[srcI+3]; + } + } + } + + for (var i = 0; i < mask.length; i++) { + if (mask[i]) { + var px = i % W, py = (i / W) | 0; + setPixel(px, py, [0, 0, 0, 0]); + } + } + flushPixels(); + + if (antsRafId) { cancelAnimationFrame(antsRafId); antsRafId = null; } + + EditorState.transformState = { + originalPixels: originalPixels, + floatPixels: floatPx, + floatW: bb.w, + floatH: bb.h, + floatX: bb.x, + floatY: bb.y, + scaleX: 1.0, + scaleY: 1.0, + angleDeg: 0, + origBbox: { x: bb.x, y: bb.y, w: bb.w, h: bb.h }, + _baseFloatPixels: floatPx, + _baseFloatW: bb.w, + _baseFloatH: bb.h, + _rotatePivot: { x: Math.round(bb.x + bb.w / 2), y: Math.round(bb.y + bb.h / 2) }, + _dragMode: null, + _dragStartClientX: 0, _dragStartClientY: 0, + _dragStartFloatX: 0, _dragStartFloatY: 0, + }; + + _drawTransformUI(); + _showTransformTopBar(); + + selCanvas.style.pointerEvents = 'auto'; + selCanvas.addEventListener('pointerdown', _selCanvasPointerDown); +} + +// ── _applyRotationPreview ──────────────────────────────────────────────── +function _applyRotationPreview() { + var ts = EditorState.transformState; + if (!ts) return; + + var angleDeg = ts.angleDeg; + + if (ts.origBbox.w * ts.origBbox.h > 128 * 128) { + _showStatus('Selection too large for rotation \u2014 max 128\u00d7128px'); + return; + } + + var srcPx = ts._baseFloatPixels; + var srcW = ts._baseFloatW; + var srcH = ts._baseFloatH; + + var scaledPx = srcPx; + var scaledW = srcW; + var scaledH = srcH; + if (Math.abs(ts.scaleX - 1.0) > 0.001 || Math.abs(ts.scaleY - 1.0) > 0.001) { + scaledW = Math.max(1, Math.round(ts.origBbox.w * ts.scaleX)); + scaledH = Math.max(1, Math.round(ts.origBbox.h * ts.scaleY)); + scaledPx = scaleNearestNeighbor(srcPx, srcW, srcH, scaledW, scaledH); + } + + if (!ts._rotatePivot) { + ts._rotatePivot = { x: Math.round(ts.floatX + ts.floatW / 2), y: Math.round(ts.floatY + ts.floatH / 2) }; + } + + var result = _rotSpriteExpanded(scaledPx, scaledW, scaledH, angleDeg); + + ts.floatPixels = result.pixels; + ts.floatW = result.w; + ts.floatH = result.h; + ts.floatX = Math.round(ts._rotatePivot.x - result.w / 2); + ts.floatY = Math.round(ts._rotatePivot.y - result.h / 2); + ts._borderRect = { + x: Math.round(ts._rotatePivot.x - scaledW / 2), + y: Math.round(ts._rotatePivot.y - scaledH / 2), + w: scaledW, + h: scaledH, + }; + + _drawTransformUI(); +} + +function _finalizeRotationBbox() { + var ts = EditorState.transformState; + if (!ts || ts.angleDeg === 0) return; + + if (ts.origBbox.w * ts.origBbox.h > 128 * 128) return; + + var srcPx = ts._baseFloatPixels; + var srcW = ts._baseFloatW; + var srcH = ts._baseFloatH; + + var scaledPx = srcPx, scaledW = srcW, scaledH = srcH; + if (Math.abs(ts.scaleX - 1.0) > 0.001 || Math.abs(ts.scaleY - 1.0) > 0.001) { + scaledW = Math.max(1, Math.round(ts.origBbox.w * ts.scaleX)); + scaledH = Math.max(1, Math.round(ts.origBbox.h * ts.scaleY)); + scaledPx = scaleNearestNeighbor(srcPx, srcW, srcH, scaledW, scaledH); + } + + var result = _rotSpriteExpanded(scaledPx, scaledW, scaledH, ts.angleDeg); + + var crop = _tightCrop(result.pixels, result.w, result.h); + + var piv = ts._rotatePivot || { x: Math.round(ts.floatX + ts.floatW / 2), y: Math.round(ts.floatY + ts.floatH / 2) }; + var baseX = Math.round(piv.x - result.w / 2); + var baseY = Math.round(piv.y - result.h / 2); + + ts.floatPixels = crop.pixels; + ts.floatW = crop.w; + ts.floatH = crop.h; + ts.floatX = baseX + crop.offX; + ts.floatY = baseY + crop.offY; + ts._borderRect = null; + + _drawTransformUI(); +} + +// ── hitTestHandle ──────────────────────────────────────────────────────── +function hitTestHandle(clientX, clientY) { + if (!EditorState.transformState) return null; + var ts = EditorState.transformState; + var caRect = document.getElementById('canvas-area').getBoundingClientRect(); + var pixRect = pixelCanvas.getBoundingClientRect(); + var originX = pixRect.left - caRect.left; + var originY = pixRect.top - caRect.top; + var ps = pixRect.width / EditorState.width; + + var lx = clientX - caRect.left; + var ly = clientY - caRect.top; + + var br = ts._borderRect || { x: ts.floatX, y: ts.floatY, w: ts.floatW, h: ts.floatH }; + var sx = originX + br.x * ps; + var sy = originY + br.y * ps; + var sw = br.w * ps; + var sh = br.h * ps; + + var INNER = 6; + var OUTER = 20; + + var cornerIndices = [0, 2, 5, 7]; + + var handlePositions = [ + [sx, sy ], + [sx + sw/2, sy ], + [sx + sw, sy ], + [sx, sy + sh/2], + [sx + sw, sy + sh/2], + [sx, sy + sh ], + [sx + sw/2, sy + sh ], + [sx + sw, sy + sh ], + ]; + for (var i = 0; i < handlePositions.length; i++) { + var hx = handlePositions[i][0]; + var hy = handlePositions[i][1]; + var dx = Math.abs(lx - hx); + var dy = Math.abs(ly - hy); + if (dx <= INNER && dy <= INNER) { + return { type: 'scale', handleIdx: i }; + } + if (cornerIndices.indexOf(i) !== -1 && dx <= OUTER && dy <= OUTER) { + return { type: 'rotate', handleIdx: i }; + } + } + return null; +} + +function _drawTransformUI() { + if (!EditorState.transformState) return; + var ts = EditorState.transformState; + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + + var coords = _getSelCanvasCoords(); + var originX = coords.originX, originY = coords.originY, ps = coords.ps; + + var sx = originX + ts.floatX * ps; + var sy = originY + ts.floatY * ps; + var sw = ts.floatW * ps; + var sh = ts.floatH * ps; + + var br = ts._borderRect || { x: ts.floatX, y: ts.floatY, w: ts.floatW, h: ts.floatH }; + var bx = originX + br.x * ps; + var by = originY + br.y * ps; + var bw = br.w * ps; + var bh = br.h * ps; + + if (ts.floatW > 0 && ts.floatH > 0) { + var off = document.createElement('canvas'); + off.width = ts.floatW; off.height = ts.floatH; + var offCtx = off.getContext('2d'); + offCtx.putImageData(new ImageData(ts.floatPixels.slice(), ts.floatW, ts.floatH), 0, 0); + selCtx.imageSmoothingEnabled = false; + selCtx.drawImage(off, sx, sy, sw, sh); + } + + selCtx.strokeStyle = 'rgba(160,150,220,0.9)'; + selCtx.lineWidth = 2; + selCtx.setLineDash([4, 3]); + selCtx.beginPath(); + selCtx.strokeRect(bx - 1, by - 1, bw + 2, bh + 2); + selCtx.setLineDash([]); + + var HALF = 4; + var handlePositions = [ + [bx, by ], + [bx + bw/2, by ], + [bx + bw, by ], + [bx, by + bh/2], + [bx + bw, by + bh/2], + [bx, by + bh ], + [bx + bw/2, by + bh ], + [bx + bw, by + bh ], + ]; + selCtx.fillStyle = '#7c6af7'; + selCtx.strokeStyle = '#fff'; + selCtx.lineWidth = 1; + selCtx.setLineDash([]); + for (var j = 0; j < handlePositions.length; j++) { + var hx = handlePositions[j][0]; + var hy = handlePositions[j][1]; + selCtx.fillRect(hx - HALF, hy - HALF, HALF * 2, HALF * 2); + selCtx.strokeRect(hx - HALF, hy - HALF, HALF * 2, HALF * 2); + } +} + +function _showTransformTopBar() { + var panel = document.getElementById('tool-settings-move'); + if (panel) panel.style.display = 'flex'; + var ts = EditorState.transformState; + if (!ts) return; + var sxEl = document.getElementById('opt-scale-x'); + var syEl = document.getElementById('opt-scale-y'); + var aEl = document.getElementById('opt-rotate-angle'); + if (sxEl) sxEl.value = Math.round(ts.scaleX * 100); + if (syEl) syEl.value = Math.round(ts.scaleY * 100); + if (aEl) aEl.value = ts.angleDeg; +} + +function _hideTransformTopBar() { + var panel = document.getElementById('tool-settings-move'); + if (panel) panel.style.display = 'none'; +} + +function _hideDistanceLabel() { + var label = document.getElementById('transform-distance-label'); + if (label) label.style.display = 'none'; +} + +function _updateDistanceLabel() { + var ts = EditorState.transformState; + if (!ts) return; + var label = document.getElementById('transform-distance-label'); + if (!label) return; + + var br = ts._borderRect || { x: ts.floatX, y: ts.floatY, w: ts.floatW, h: ts.floatH }; + var left = br.x; + var top = br.y; + var right = EditorState.width - (br.x + br.w); + var bottom = EditorState.height - (br.y + br.h); + label.textContent = '\u2190' + left + ' \u2191' + top + ' \u2192' + right + ' \u2193' + bottom; + + var pixRect = pixelCanvas.getBoundingClientRect(); + var ps = pixRect.width / EditorState.width; + var screenX = pixRect.left + br.x * ps; + var screenY = pixRect.top + (br.y + br.h) * ps + 6; + label.style.left = screenX + 'px'; + label.style.top = screenY + 'px'; + label.style.display = 'block'; +} + +function applyTransform() { + var ts = EditorState.transformState; + if (!ts) return; + + var W = EditorState.width, H = EditorState.height; + var newMask = new Uint8Array(W * H); + var hasAppliedPixels = false; + for (var fy = 0; fy < ts.floatH; fy++) { + for (var fx = 0; fx < ts.floatW; fx++) { + var cx = (ts.floatX + fx) | 0, cy = (ts.floatY + fy) | 0; + if (cx < 0 || cx >= W || cy < 0 || cy >= H) continue; + var si = (fy * ts.floatW + fx) * 4; + if (ts.floatPixels[si + 3] === 0) continue; + setPixel(cx, cy, [ + ts.floatPixels[si], ts.floatPixels[si+1], + ts.floatPixels[si+2], ts.floatPixels[si+3], + ]); + newMask[cy * W + cx] = 1; + hasAppliedPixels = true; + } + } + flushPixels(); + pushHistory(); + EditorState.transformState = null; + _hideTransformTopBar(); + _hideDistanceLabel(); + selCanvas.style.pointerEvents = 'none'; + selCanvas.removeEventListener('pointerdown', _selCanvasPointerDown); + + if (hasAppliedPixels) { + EditorState.selectionMask = newMask; + EditorState.selection = computeBoundingBox(newMask, W, H); + if (antsRafId) { cancelAnimationFrame(antsRafId); antsRafId = null; } + if (selCtx) { + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + } + antsRafId = requestAnimationFrame(drawAnts); + } else { + clearSelection(); + } + setActiveTool(null); +} + +function cancelTransform() { + var ts = EditorState.transformState; + if (!ts) return; + EditorState.pixels = ts.originalPixels; + flushPixels(); + EditorState.transformState = null; + clearSelection(); + _hideTransformTopBar(); + _hideDistanceLabel(); + selCanvas.style.pointerEvents = 'none'; + selCanvas.removeEventListener('pointerdown', _selCanvasPointerDown); + if (EditorState.activeTool === 'move') setActiveTool('marquee'); +} + +function _applyScaleFromInputs() { + var ts = EditorState.transformState; + if (!ts) return; + + var sxEl = document.getElementById('opt-scale-x'); + var syEl = document.getElementById('opt-scale-y'); + var sxVal = parseFloat(sxEl ? sxEl.value : '100') || 100; + var syVal = parseFloat(syEl ? syEl.value : '100') || 100; + + ts.scaleX = Math.max(0.0625, Math.min(16, sxVal / 100)); + ts.scaleY = Math.max(0.0625, Math.min(16, syVal / 100)); + + var srcPx = ts._origFloatPixels || ts.floatPixels; + var srcW = ts._origFloatW || ts.origBbox.w; + var srcH = ts._origFloatH || ts.origBbox.h; + + var newW = Math.max(1, Math.round(ts.origBbox.w * ts.scaleX)); + var newH = Math.max(1, Math.round(ts.origBbox.h * ts.scaleY)); + + ts.floatPixels = scaleNearestNeighbor(srcPx, srcW, srcH, newW, newH); + ts.floatW = newW; + ts.floatH = newH; + + _drawTransformUI(); +} + +// ── Marquee helper functions ────────────────────────────────────────────── + +function _marqueeGetSnappedRect(ax, ay, bx, by) { + var gW = EditorState.gridW || 1; + var gH = EditorState.gridH || 1; + var x0 = snapToGrid(ax, gW), y0 = snapToGrid(ay, gH); + var x1 = snapToGrid(bx, gW), y1 = snapToGrid(by, gH); + return { + rx: Math.min(x0, x1), ry: Math.min(y0, y1), + rw: Math.abs(x1 - x0), rh: Math.abs(y1 - y0) + }; +} + +function _marqueeDrawPreview(rx, ry, rw, rh) { + var dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / dpr, selCanvas.height / dpr); + if (rw <= 0 || rh <= 0) return; + + var caRect = document.getElementById('canvas-area').getBoundingClientRect(); + var pixRect = pixelCanvas.getBoundingClientRect(); + var originX = pixRect.left - caRect.left; + var originY = pixRect.top - caRect.top; + var ps = pixRect.width / EditorState.width; + + var border = []; + var x0 = rx, y0 = ry, x1 = rx + rw - 1, y1 = ry + rh - 1; + for (var px = x0; px <= x1; px++) { + border.push([px, y0]); + if (rh > 1) border.push([px, y1]); + } + for (var py = y0 + 1; py <= y1 - 1; py++) { + border.push([x0, py]); + if (rw > 1) border.push([x1, py]); + } + + selCtx.globalCompositeOperation = 'source-over'; + selCtx.setLineDash([]); + for (var k = 0; k < border.length; k++) { + var bpx = border[k][0], bpy = border[k][1]; + if (bpx < 0 || bpy < 0 || bpx >= EditorState.width || bpy >= EditorState.height) continue; + var pix = getPixel(bpx, bpy); + selCtx.fillStyle = 'rgb(' + (255 - pix[0]) + ',' + (255 - pix[1]) + ',' + (255 - pix[2]) + ')'; + selCtx.fillRect(originX + bpx * ps, originY + bpy * ps, ps, ps); + } +} + +// ── initTools: sets up all tool implementations and event bindings ──────── +function initTools() { + // ── Pencil ────────────────────────────────────────────────────────────── + tools.pencil = { + onDown: function(x, y) { + _pencilStamp = getBrushStamp(EditorState.toolOptions.brushSize, EditorState.toolOptions.brushShape); + resetPixelPerfect(); + _lastPencilX = x; _lastPencilY = y; + applyStamp(x, y, _pencilStamp, [EditorState.foregroundColor[0], EditorState.foregroundColor[1], EditorState.foregroundColor[2], EditorState.foregroundColor[3]]); + _ppHistory.push([x, y]); + }, + onMove: function(x, y) { + if (_lastPencilX === null) return; + var pts = bresenhamLine(_lastPencilX, _lastPencilY, x, y); + for (var i = 1; i < pts.length; i++) { + var px = pts[i][0], py = pts[i][1]; + if (EditorState.toolOptions.pixelPerfect && shouldSkipPixelPerfect(px, py)) continue; + applyStamp(px, py, _pencilStamp, [EditorState.foregroundColor[0], EditorState.foregroundColor[1], EditorState.foregroundColor[2], EditorState.foregroundColor[3]]); + } + _lastPencilX = x; _lastPencilY = y; + }, + onUp: function() { + pushHistory(); + _lastPencilX = null; _lastPencilY = null; + resetPixelPerfect(); + }, + onCursor: function(x, y) { drawCursorPreview(x, y, EditorState.foregroundColor); }, + }; + + // ── Eraser ────────────────────────────────────────────────────────────── + tools.eraser = { + onDown: function(x, y) { + _eraserStamp = getBrushStamp(EditorState.toolOptions.brushSize, EditorState.toolOptions.brushShape); + if (EditorState.toolOptions.eraserPixelPerfect) { resetPixelPerfect(); _ppHistory.push([x, y]); } + _lastEraserX = x; _lastEraserY = y; + applyStamp(x, y, _eraserStamp, [0, 0, 0, 0]); + }, + onMove: function(x, y) { + if (_lastEraserX === null) return; + var pts = bresenhamLine(_lastEraserX, _lastEraserY, x, y); + for (var i = 1; i < pts.length; i++) { + var px = pts[i][0], py = pts[i][1]; + if (EditorState.toolOptions.eraserPixelPerfect && shouldSkipPixelPerfect(px, py)) continue; + applyStamp(px, py, _eraserStamp, [0, 0, 0, 0]); + } + _lastEraserX = x; _lastEraserY = y; + }, + onUp: function() { + pushHistory(); + _lastEraserX = null; _lastEraserY = null; + if (EditorState.toolOptions.eraserPixelPerfect) resetPixelPerfect(); + }, + onCursor: function(x, y) { + drawCursorPreview(x, y, [200, 200, 200, 128]); + }, + }; + + // ── Bucket ────────────────────────────────────────────────────────────── + tools.bucket = { + onDown: function(x, y) { + pushHistory(); + floodFill( + x, y, + [EditorState.foregroundColor[0], EditorState.foregroundColor[1], EditorState.foregroundColor[2], EditorState.foregroundColor[3]], + EditorState.toolOptions.bucketTolerance, + EditorState.toolOptions.contiguous + ); + }, + onMove: function() {}, + onUp: function() {}, + onCursor: function(x, y) { + cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height); + if (!EditorState.pixels) return; + var fc = EditorState.foregroundColor; + cursorCtx.fillStyle = 'rgba(' + fc[0] + ',' + fc[1] + ',' + fc[2] + ',' + (fc[3] / 255) + ')'; + cursorCtx.fillRect(x, y, 1, 1); + }, + }; + + // ── Eyedropper ────────────────────────────────────────────────────────── + tools.eyedropper = { + onDown: function(x, y) { + var pix = getPixel(x, y); + if (pix[3] === 0) return; + EditorState.foregroundColor = [pix[0], pix[1], pix[2], 255]; + syncColorUI(); + }, + onMove: function() {}, + onUp: function() {}, + onCursor: function(x, y) { + clearCursorPreview(); + }, + }; + + // ── Marquee ───────────────────────────────────────────────────────────── + tools.marquee = { + onDown: function(x, y, e) { + if (!e.shiftKey) { + if (antsRafId) { cancelAnimationFrame(antsRafId); antsRafId = null; } + var _dpr = window.devicePixelRatio || 1; + selCtx.clearRect(0, 0, selCanvas.width / _dpr, selCanvas.height / _dpr); + } + _marqueeStartX = x; _marqueeStartY = y; + _marqueeCurrentX = x; _marqueeCurrentY = y; + }, + onMove: function(x, y) { + if (_marqueeStartX === null) return; + _marqueeCurrentX = x; _marqueeCurrentY = y; + var r = _marqueeGetSnappedRect(_marqueeStartX, _marqueeStartY, x, y); + if (r.rw > 0 && r.rh > 0) _marqueeDrawPreview(r.rx, r.ry, r.rw, r.rh); + }, + onUp: function(x, y, e) { + if (_marqueeStartX === null) return; + var r = _marqueeGetSnappedRect(_marqueeStartX, _marqueeStartY, x, y); + _marqueeStartX = null; _marqueeCurrentX = null; + + if (r.rw === 0 || r.rh === 0) { + clearSelection(); + return; + } + + var W = EditorState.width, H = EditorState.height; + var newMask = new Uint8Array(W * H); + for (var py = r.ry; py < r.ry + r.rh; py++) { + for (var px = r.rx; px < r.rx + r.rw; px++) { + if (px >= 0 && px < W && py >= 0 && py < H) { + newMask[px + py * W] = 1; + } + } + } + + var finalMask = e.shiftKey + ? unionMasks(EditorState.selectionMask, newMask, W * H) + : newMask; + var bbox = computeBoundingBox(finalMask, W, H); + if (bbox) setSelection(finalMask, bbox); + }, + onCursor: function(x, y) { + cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height); + }, + }; + + // ── Wand ──────────────────────────────────────────────────────────────── + tools.wand = { + onDown: function(x, y, e) { + if (!EditorState.pixels) return; + var tolerance = EditorState.toolOptions.wandTolerance; + var contiguous = EditorState.toolOptions.wandContiguous; + var result = wandSelect(x, y, tolerance, contiguous); + if (!result) { + if (!e.shiftKey) clearSelection(); + return; + } + var W = EditorState.width, H = EditorState.height; + if (e.shiftKey) { + var unified = unionMasks(EditorState.selectionMask, result.mask, W * H); + var bbox = computeBoundingBox(unified, W, H); + if (bbox) setSelection(unified, bbox); + } else { + setSelection(result.mask, result.bbox); + } + }, + onMove: function() {}, + onUp: function() {}, + onCursor: function(x, y) { + cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height); + }, + }; + + // ── Move ──────────────────────────────────────────────────────────────── + tools.move = { + onDown: function(x, y, e) { + var existingTs = EditorState.transformState; + if (existingTs) { + var hitResult = hitTestHandle(e.clientX, e.clientY); + if (hitResult !== null && hitResult.type === 'scale') { + var handleIdx = hitResult.handleIdx; + existingTs._origFloatPixels = existingTs.floatPixels.slice(); + existingTs._origFloatW = existingTs.floatW; + existingTs._origFloatH = existingTs.floatH; + existingTs._dragMode = 'handle-' + handleIdx; + existingTs._dragStartClientX = e.clientX; + existingTs._dragStartClientY = e.clientY; + existingTs._dragStartScaleX = existingTs.scaleX; + existingTs._dragStartScaleY = existingTs.scaleY; + existingTs._dragStartFloatX = existingTs.floatX; + existingTs._dragStartFloatY = existingTs.floatY; + var anchorFrac = [ + [1, 1], [0.5, 1], [0, 1], [1, 0.5], [0, 0.5], [1, 0], [0.5, 0], [0, 0], + ]; + var af = anchorFrac[handleIdx]; + existingTs._dragAnchorX = existingTs.floatX + af[0] * existingTs.floatW; + existingTs._dragAnchorY = existingTs.floatY + af[1] * existingTs.floatH; + existingTs._dragAnchorFracX = af[0]; + existingTs._dragAnchorFracY = af[1]; + return; + } else if (hitResult !== null && hitResult.type === 'rotate') { + var pivCanvasX = Math.round(existingTs.floatX + existingTs.floatW / 2); + var pivCanvasY = Math.round(existingTs.floatY + existingTs.floatH / 2); + existingTs._rotatePivot = { x: pivCanvasX, y: pivCanvasY }; + + var pixRect = pixelCanvas.getBoundingClientRect(); + var ps = pixRect.width / EditorState.width; + var centerClientX = pixRect.left + pivCanvasX * ps; + var centerClientY = pixRect.top + pivCanvasY * ps; + + existingTs._dragMode = 'rotate'; + existingTs._rotCenterX = centerClientX; + existingTs._rotCenterY = centerClientY; + existingTs._rotRefAngle = Math.atan2(e.clientY - centerClientY, e.clientX - centerClientX); + existingTs._rotStartAngleDeg = existingTs.angleDeg; + return; + } + existingTs._dragMode = 'move'; + existingTs._dragStartClientX = e.clientX; + existingTs._dragStartClientY = e.clientY; + existingTs._dragStartFloatX = existingTs.floatX; + existingTs._dragStartFloatY = existingTs.floatY; + return; + } + if (!EditorState.selection) return; + activateTransform(); + if (EditorState.transformState) { + EditorState.transformState._dragMode = 'move'; + EditorState.transformState._dragStartClientX = e.clientX; + EditorState.transformState._dragStartClientY = e.clientY; + EditorState.transformState._dragStartFloatX = EditorState.transformState.floatX; + EditorState.transformState._dragStartFloatY = EditorState.transformState.floatY; + } + }, + onMove: function(x, y, e) { + var ts = EditorState.transformState; + if (!ts) return; + + if (ts._dragMode === 'move') { + var pixRect = pixelCanvas.getBoundingClientRect(); + var ps = pixRect.width / EditorState.width; + var dx = Math.round((e.clientX - ts._dragStartClientX) / ps); + var dy = Math.round((e.clientY - ts._dragStartClientY) / ps); + ts.floatX = ts._dragStartFloatX + dx; + ts.floatY = ts._dragStartFloatY + dy; + _drawTransformUI(); + _updateDistanceLabel(); + return; + } + + if (ts._dragMode && ts._dragMode.indexOf('handle-') === 0) { + var handleIdx = parseInt(ts._dragMode.split('-')[1], 10); + var pixRect2 = pixelCanvas.getBoundingClientRect(); + var ps2 = pixRect2.width / EditorState.width; + + var dxPx = Math.round((e.clientX - ts._dragStartClientX) / ps2); + var dyPx = Math.round((e.clientY - ts._dragStartClientY) / ps2); + + var origW = ts.origBbox.w; + var origH = ts.origBbox.h; + var lockAspect = _scaleLinkActive; + + var newScaleX = ts._dragStartScaleX; + var newScaleY = ts._dragStartScaleY; + + var corners = [0, 2, 5, 7]; + var yOnlyH = [1, 6]; + var xOnlyH = [3, 4]; + + if (corners.indexOf(handleIdx) !== -1) { + var xSign = [2, 4, 7].indexOf(handleIdx) !== -1 ? 1 : -1; + var ySign = [5, 6, 7].indexOf(handleIdx) !== -1 ? 1 : -1; + newScaleX = Math.max(0.0625, (origW * ts._dragStartScaleX + dxPx * xSign) / origW); + newScaleY = Math.max(0.0625, (origH * ts._dragStartScaleY + dyPx * ySign) / origH); + if (lockAspect) { + var relX = Math.abs(newScaleX - ts._dragStartScaleX); + var relY = Math.abs(newScaleY - ts._dragStartScaleY); + if (relX > relY) newScaleY = newScaleX; + else newScaleX = newScaleY; + } + } else if (yOnlyH.indexOf(handleIdx) !== -1) { + var ySign2 = handleIdx === 6 ? 1 : -1; + newScaleY = Math.max(0.0625, (origH * ts._dragStartScaleY + dyPx * ySign2) / origH); + if (lockAspect) newScaleX = newScaleY; + } else if (xOnlyH.indexOf(handleIdx) !== -1) { + var xSign2 = handleIdx === 4 ? 1 : -1; + newScaleX = Math.max(0.0625, (origW * ts._dragStartScaleX + dxPx * xSign2) / origW); + if (lockAspect) newScaleY = newScaleX; + } + + ts.scaleX = newScaleX; + ts.scaleY = newScaleY; + + var newW = Math.max(1, Math.round(ts.origBbox.w * ts.scaleX)); + var newH = Math.max(1, Math.round(ts.origBbox.h * ts.scaleY)); + ts.floatPixels = scaleNearestNeighbor(ts._origFloatPixels, ts._origFloatW, ts._origFloatH, newW, newH); + ts.floatW = newW; + ts.floatH = newH; + if (ts._dragAnchorFracX !== undefined) { + ts.floatX = ts._dragAnchorX - ts._dragAnchorFracX * newW; + ts.floatY = ts._dragAnchorY - ts._dragAnchorFracY * newH; + } + + var sxEl = document.getElementById('opt-scale-x'); + var syEl = document.getElementById('opt-scale-y'); + if (sxEl) sxEl.value = Math.round(ts.scaleX * 100); + if (syEl) syEl.value = Math.round(ts.scaleY * 100); + + _drawTransformUI(); + return; + } + + if (ts._dragMode === 'rotate') { + var curAngle = Math.atan2( + e.clientY - ts._rotCenterY, + e.clientX - ts._rotCenterX + ); + var deltaDeg = (curAngle - ts._rotRefAngle) * 180 / Math.PI; + ts.angleDeg = ts._rotStartAngleDeg + deltaDeg; + + var aEl = document.getElementById('opt-rotate-angle'); + if (aEl) aEl.value = Math.round(ts.angleDeg); + + _applyRotationPreview(); + return; + } + }, + onUp: function(x, y, e) { + var ts = EditorState.transformState; + if (!ts) return; + var prevMode = ts._dragMode; + ts._dragMode = null; + + if (prevMode === 'rotate') { + _finalizeRotationBbox(); + ts._rotatePivot = { x: Math.round(ts.floatX + ts.floatW / 2), y: Math.round(ts.floatY + ts.floatH / 2) }; + } else if (prevMode === 'move') { + ts._rotatePivot = { x: Math.round(ts.floatX + ts.floatW / 2), y: Math.round(ts.floatY + ts.floatH / 2) }; + } + _updateDistanceLabel(); + }, + onCursor: function(x, y, e) { + if (!EditorState.transformState || !e) return; + var hit = hitTestHandle(e.clientX, e.clientY); + if (!hit) { + cursorCanvas.style.cursor = 'move'; + return; + } + if (hit.type === 'rotate') { + cursorCanvas.style.cursor = 'crosshair'; + return; + } + var scaleCursors = [ + 'nw-resize', 'n-resize', 'ne-resize', + 'w-resize', 'e-resize', + 'sw-resize', 's-resize', 'se-resize', + ]; + cursorCanvas.style.cursor = scaleCursors[hit.handleIdx] || 'move'; + }, + }; + + // ── Pointer event dispatch on cursorCanvas ───────────────────────────── + cursorCanvas.addEventListener('pointerdown', function(e) { + e.preventDefault(); + cursorCanvas.setPointerCapture(e.pointerId); + if (!EditorState.pixels) return; + isDrawing = true; + var coords = viewportToCanvas(e.clientX, e.clientY); + var cx = coords[0], cy = coords[1]; + var selTool = EditorState.activeTool === 'marquee' || EditorState.activeTool === 'wand'; + if (selTool && EditorState.selectionMask && !EditorState.transformState && isSelectedPixel(cx, cy)) { + setActiveTool('move'); + if (tools.move) tools.move.onDown(cx, cy, e); + return; + } + if (tools[EditorState.activeTool]) tools[EditorState.activeTool].onDown(cx, cy, e); + }); + cursorCanvas.addEventListener('pointermove', function(e) { + if (!EditorState.pixels) return; + var coords = viewportToCanvas(e.clientX, e.clientY); + var cx = coords[0], cy = coords[1]; + if (tools[EditorState.activeTool]) tools[EditorState.activeTool].onCursor(cx, cy, e); + if (isDrawing && tools[EditorState.activeTool]) tools[EditorState.activeTool].onMove(cx, cy, e); + }); + cursorCanvas.addEventListener('pointerup', function(e) { + if (!isDrawing) return; + isDrawing = false; + var coords = viewportToCanvas(e.clientX, e.clientY); + if (tools[EditorState.activeTool]) tools[EditorState.activeTool].onUp(coords[0], coords[1], e); + }); + cursorCanvas.addEventListener('pointercancel', function() { isDrawing = false; }); + cursorCanvas.addEventListener('pointerleave', function() { clearCursorPreview(); }); + + // Tool button clicks + document.querySelectorAll('.tool-btn[data-tool]').forEach(function(btn) { + btn.addEventListener('click', function() { setActiveTool(btn.dataset.tool); }); + }); + setActiveTool('pencil'); + + // Apply/Cancel transform button bindings + var btnApplyTransform = document.getElementById('btn-apply-transform'); + var btnCancelTransform = document.getElementById('btn-cancel-transform'); + if (btnApplyTransform) btnApplyTransform.addEventListener('click', applyTransform); + if (btnCancelTransform) btnCancelTransform.addEventListener('click', cancelTransform); + + // ── Scale input bindings ─────────────────────────────────────────────── + var scaleXInput = document.getElementById('opt-scale-x'); + var scaleYInput = document.getElementById('opt-scale-y'); + var scaleLinkBtn = document.getElementById('btn-scale-link'); + + if (scaleLinkBtn) { + scaleLinkBtn.addEventListener('click', function() { + _scaleLinkActive = !_scaleLinkActive; + scaleLinkBtn.style.background = _scaleLinkActive ? 'var(--accent)' : 'transparent'; + scaleLinkBtn.style.borderColor = _scaleLinkActive ? 'var(--accent)' : 'var(--border)'; + scaleLinkBtn.style.color = _scaleLinkActive ? '#fff' : 'var(--text-muted)'; + }); + } + + if (scaleXInput) { + scaleXInput.addEventListener('input', function() { + if (_scaleLinkActive && scaleYInput) scaleYInput.value = scaleXInput.value; + clearTimeout(_scaleInputTimer); + _scaleInputTimer = setTimeout(_applyScaleFromInputs, 300); + }); + } + if (scaleYInput) { + scaleYInput.addEventListener('input', function() { + if (_scaleLinkActive && scaleXInput) scaleXInput.value = scaleYInput.value; + clearTimeout(_scaleInputTimer); + _scaleInputTimer = setTimeout(_applyScaleFromInputs, 300); + }); + } + + // ── Angle input binding ──────────────────────────────────────────────── + var rotateAngleInput = document.getElementById('opt-rotate-angle'); + if (rotateAngleInput) { + rotateAngleInput.addEventListener('input', function() { + var ts = EditorState.transformState; + if (!ts) return; + ts.angleDeg = parseFloat(rotateAngleInput.value) || 0; + clearTimeout(_rotateInputTimer); + _rotateInputTimer = setTimeout(_applyRotationPreview, 300); + }); + } + + // ── Zoom hook: redraw transform UI on zoom ───────────────────────────── + _onZoomChangedListeners.push(function() { + if (EditorState.transformState) _drawTransformUI(); + }); + _onZoomChangedListeners.push(function() { + if (EditorState.activeTool === 'canvas-size' && typeof drawCanvasSizeGuides === 'function') drawCanvasSizeGuides(); + }); + + // Wire move-tool hook + _onMoveToolSelected = function() { + if (EditorState.selectionMask && !EditorState.transformState) activateTransform(); + }; +} diff --git a/editor/shared.css b/editor/shared.css new file mode 100644 index 0000000..54ade7f --- /dev/null +++ b/editor/shared.css @@ -0,0 +1,25 @@ +/* ── Shared CSS Variables ─────────────────────────────────────────────── */ +:root { + --bg: #0f0f13; + --surface: #1a1a22; + --surface2: #22222e; + --border: #2e2e3e; + --accent: #7c6af7; + --accent-hover: #9080ff; + --text: #e8e6f0; + --text-muted: #7a7890; + --success: #4ade80; + --error: #f87171; + --warning: #fbbf24; + --radius: 10px; +} + +/* ── Shared Button Styles ────────────────────────────────────────────── */ +.btn { padding: 8px 12px; border-radius: 7px; border: none; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s, opacity .2s; white-space: nowrap; } +.btn:disabled { opacity: .4; cursor: not-allowed; } +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn-secondary:hover:not(:disabled) { border-color: var(--accent); } +.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); padding: 5px 9px; font-size: 12px; } +.btn-ghost:hover { color: var(--text); border-color: var(--accent); } diff --git a/integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py b/integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py index 46395f9..19c6cae 100644 --- a/integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py +++ b/integrations/comfyui/PerfectPixelComfy/nodes_perfect_pixel.py @@ -57,7 +57,7 @@ def _load_backend(backend: str): import cv2 # noqa: F401 from perfect_pixel import get_perfect_pixel return get_perfect_pixel - except Exception: + except ImportError: from perfect_pixel_noCV2 import get_perfect_pixel return get_perfect_pixel diff --git a/src/perfect_pixel/__init__.py b/src/perfect_pixel/__init__.py index 9f26e3b..036594e 100644 --- a/src/perfect_pixel/__init__.py +++ b/src/perfect_pixel/__init__.py @@ -3,7 +3,7 @@ Perfect Pixel: A library for auto grid detection and pixel art refinement. """ -__version__ = "0.1.2" +__version__ = "0.2.0" from .perfect_pixel_noCV2 import get_perfect_pixel as _get_perfect_pixel_numpy @@ -15,4 +15,4 @@ _get_perfect_pixel_opencv = None get_perfect_pixel = _get_perfect_pixel_numpy -__all__ = ["get_perfect_pixel"] \ No newline at end of file +__all__ = ["get_perfect_pixel"] diff --git a/src/perfect_pixel/backend_cv2.py b/src/perfect_pixel/backend_cv2.py new file mode 100644 index 0000000..28ff055 --- /dev/null +++ b/src/perfect_pixel/backend_cv2.py @@ -0,0 +1,37 @@ +"""OpenCV-backed implementation of ImageOps.""" + +from __future__ import annotations + +from typing import Tuple + +import cv2 +import numpy as np +from numpy import ndarray + + +class CV2Ops: + """ImageOps adapter using OpenCV.""" + + # -- protocol methods -------------------------------------------------- + + def to_gray(self, image: ndarray) -> ndarray: + if image.ndim == 2: + return image.astype(np.float32) + return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32) + + def sobel(self, gray: ndarray) -> Tuple[ndarray, ndarray]: + gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) + return gx, gy + + def normalize_1d(self, v: ndarray) -> ndarray: + return cv2.normalize(v.astype(np.float32), None, 0, 1, cv2.NORM_MINMAX).flatten() + + def kmeans_2(self, pixels: ndarray, iters: int) -> ndarray: + criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, iters, 1.0) + _, labels, centers = cv2.kmeans( + pixels.astype(np.float32), 2, None, criteria, 1, cv2.KMEANS_RANDOM_CENTERS + ) + count1 = int(np.sum(labels)) + count0 = len(labels) - count1 + return centers[1] if count1 >= count0 else centers[0] diff --git a/src/perfect_pixel/backend_numpy.py b/src/perfect_pixel/backend_numpy.py new file mode 100644 index 0000000..e98db7a --- /dev/null +++ b/src/perfect_pixel/backend_numpy.py @@ -0,0 +1,100 @@ +"""Pure-NumPy implementation of ImageOps (no OpenCV dependency).""" + +from __future__ import annotations + +from typing import Tuple + +import numpy as np +from numpy import ndarray + + +# --------------------------------------------------------------------------- +# Internal helpers (ported from perfect_pixel_noCV2.py) +# --------------------------------------------------------------------------- + +def _rgb_to_gray(image_rgb: ndarray) -> ndarray: + """RGB uint8/float -> gray float32.""" + img = image_rgb.astype(np.float32) + if img.ndim == 2: + return img + return (0.299 * img[..., 0] + 0.587 * img[..., 1] + 0.114 * img[..., 2]).astype(np.float32) + + +def _conv2d_same(image: ndarray, kernel: ndarray) -> ndarray: + """2-D convolution (same) for grayscale float32.""" + img = image.astype(np.float32, copy=False) + k = kernel.astype(np.float32, copy=False) + kh, kw = k.shape + ph, pw = kh // 2, kw // 2 + pad = np.pad(img, ((ph, ph), (pw, pw)), mode="reflect") + out = np.zeros_like(img, dtype=np.float32) + for dy in range(kh): + for dx in range(kw): + w = k[dy, dx] + if w == 0: + continue + out += w * pad[dy : dy + img.shape[0], dx : dx + img.shape[1]] + return out + + +def _normalize_minmax(x: ndarray, a: float = 0.0, b: float = 1.0) -> ndarray: + x = x.astype(np.float32, copy=False) + mn = float(x.min()) + mx = float(x.max()) + if mx - mn < 1e-8: + return np.zeros_like(x, dtype=np.float32) + a + y = (x - mn) / (mx - mn) + return (a + (b - a) * y).astype(np.float32) + + +def _sobel_xy(gray: ndarray, ksize: int = 3) -> Tuple[ndarray, ndarray]: + """Return (gx, gy) float32 using manual Sobel kernels.""" + if ksize == 3: + kx = np.array([[-1, 0, 1], + [-2, 0, 2], + [-1, 0, 1]], dtype=np.float32) + ky = np.array([[-1, -2, -1], + [ 0, 0, 0], + [ 1, 2, 1]], dtype=np.float32) + elif ksize == 5: + kx = np.array([[-5, -4, 0, 4, 5], + [-8, -10, 0, 10, 8], + [-10, -20, 0, 20, 10], + [-8, -10, 0, 10, 8], + [-5, -4, 0, 4, 5]], dtype=np.float32) + ky = kx.T + else: + raise ValueError("ksize must be 3 or 5") + return _conv2d_same(gray, kx), _conv2d_same(gray, ky) + + +# --------------------------------------------------------------------------- +# Public adapter +# --------------------------------------------------------------------------- + +class NumpyOps: + """ImageOps adapter using only NumPy.""" + + def to_gray(self, image: ndarray) -> ndarray: + return _rgb_to_gray(image) + + def sobel(self, gray: ndarray) -> Tuple[ndarray, ndarray]: + return _sobel_xy(gray, ksize=3) + + def normalize_1d(self, v: ndarray) -> ndarray: + return _normalize_minmax(v, 0.0, 1.0).flatten() + + def kmeans_2(self, pixels: ndarray, iters: int) -> ndarray: + """Manual 2-means clustering; return the majority centre.""" + cell = pixels.astype(np.float32, copy=False) + c0 = cell[0] + c1 = cell[np.argmax(((cell - c0) ** 2).sum(1))] + for _ in range(iters): + d0 = ((cell - c0) ** 2).sum(1) + d1 = ((cell - c1) ** 2).sum(1) + m1 = d1 < d0 + if np.any(~m1): + c0 = cell[~m1].mean(0) + if np.any(m1): + c1 = cell[m1].mean(0) + return c1 if m1.sum() >= (~m1).sum() else c0 diff --git a/src/perfect_pixel/core.py b/src/perfect_pixel/core.py new file mode 100644 index 0000000..91ae44e --- /dev/null +++ b/src/perfect_pixel/core.py @@ -0,0 +1,440 @@ +"""Shared core algorithms for perfect_pixel (backend-agnostic).""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional, Tuple + +import numpy as np +from numpy import ndarray + +if TYPE_CHECKING: + from .ops import ImageOps + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Pure-NumPy helpers (identical in both legacy backends) +# --------------------------------------------------------------------------- + +def compute_fft_magnitude(gray_image: ndarray) -> ndarray: + f = np.fft.fft2(gray_image.astype(np.float32)) + fshift = np.fft.fftshift(f) + mag = np.abs(fshift) + mag = 1 - np.log1p(mag) + mn, mx = float(mag.min()), float(mag.max()) + if mx - mn < 1e-8: + return np.zeros_like(mag, dtype=np.float32) + mag = (mag - mn) / (mx - mn) + return mag + + +def smooth_1d(v: ndarray, k: int = 17) -> ndarray: + """Simple 1-D smoothing with a Gaussian-like kernel.""" + k = int(k) + if k < 3: + return v + if k % 2 == 0: + k += 1 + sigma = k / 6.0 + x = np.arange(k) - k // 2 + ker = np.exp(-(x * x) / (2 * sigma * sigma)) + ker = ker / (ker.sum() + 1e-8) + return np.convolve(v, ker, mode="same") + + +def detect_peak(proj: ndarray, peak_width: int = 6, rel_thr: float = 0.35, min_dist: int = 6): + center = len(proj) // 2 + mx = float(proj.max()) + if mx < 1e-6: + return None + + thr = mx * float(rel_thr) + + candidates = [] + for i in range(1, len(proj) - 1): + is_peak = True + for j in range(1, peak_width): + if i - j < 0 or i + j >= len(proj): + continue + if proj[i - j + 1] < proj[i - j] or proj[i + j - 1] < proj[i + j]: + is_peak = False + break + if is_peak and proj[i] >= thr: + left_climb = 0 + for k in range(i, 0, -1): + if proj[k] > proj[k - 1]: + left_climb = abs(proj[i] - proj[k - 1]) + else: + break + + right_fall = 0 + for k in range(i, len(proj) - 1): + if proj[k] > proj[k + 1]: + right_fall = abs(proj[i] - proj[k + 1]) + else: + break + + candidates.append({ + "index": i, + "climb": left_climb, + "fall": right_fall, + "score": max(left_climb, right_fall), + }) + + if not candidates: + return None + + left = [c for c in candidates if c["index"] < center - min_dist and c["index"] > center * 0.25] + right = [c for c in candidates if c["index"] > center + min_dist and c["index"] < center * 1.75] + + left.sort(key=lambda x: x["score"], reverse=True) + right.sort(key=lambda x: x["score"], reverse=True) + + if not left or not right: + return None + + peak_left = left[0]["index"] + peak_right = right[0]["index"] + return abs(peak_right - peak_left) / 2 + + +def find_best_grid(origin, range_val_min, range_val_max, grad_mag, thr: float = 0): + best = round(origin) + peaks = [] + mx = np.max(grad_mag) + if mx < 1e-6: + return best + rel_thr = mx * thr + for i in range(-round(range_val_min), round(range_val_max) + 1): + candidate = round(origin + i) + if candidate <= 0 or candidate >= len(grad_mag) - 1: + continue + if (grad_mag[candidate] > grad_mag[candidate - 1] + and grad_mag[candidate] > grad_mag[candidate + 1] + and grad_mag[candidate] >= rel_thr): + peaks.append((grad_mag[candidate], candidate)) + if len(peaks) == 0: + return best + peaks.sort(key=lambda x: x[0], reverse=True) + return peaks[0][1] + + +# --------------------------------------------------------------------------- +# Sampling helpers +# --------------------------------------------------------------------------- + +def sample_center(image: ndarray, x_coords, y_coords) -> ndarray: + x = np.asarray(x_coords) + y = np.asarray(y_coords) + centers_x = np.clip((x[1:] + x[:-1]) * 0.5, 0, image.shape[1] - 1).astype(np.int32) + centers_y = np.clip((y[1:] + y[:-1]) * 0.5, 0, image.shape[0] - 1).astype(np.int32) + return image[centers_y[:, None], centers_x[None, :]] + + +def sample_majority(image: ndarray, x_coords, y_coords, ops: "ImageOps", + max_samples: int = 128, iters: int = 6, seed: int = 42) -> ndarray: + rng = np.random.default_rng(seed) + + img = image.astype(np.float32) if image.dtype != np.float32 else image + H, W = img.shape[:2] + if img.ndim == 2: + img = img[..., None] + C = img.shape[2] + + x = np.asarray(x_coords, dtype=np.int32) + y = np.asarray(y_coords, dtype=np.int32) + + nx, ny = len(x) - 1, len(y) - 1 + out = np.empty((ny, nx, C), dtype=np.float32) + + for j in range(ny): + y0, y1 = int(y[j]), int(y[j + 1]) + y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) + if y1 <= y0: + y1 = min(y0 + 1, H) + + for i in range(nx): + x0, x1 = int(x[i]), int(x[i + 1]) + x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) + if x1 <= x0: + x1 = min(x0 + 1, W) + + cell = img[y0:y1, x0:x1].reshape(-1, C) + n = cell.shape[0] + if n == 0: + out[j, i] = 0 + continue + if n > max_samples: + cell = cell[rng.integers(0, n, size=max_samples)] + + if cell.shape[0] < 2: + out[j, i] = cell[0] + else: + out[j, i] = ops.kmeans_2(cell, iters) + + if image.dtype == np.uint8: + return np.clip(np.rint(out), 0, 255).astype(np.uint8) + return out + + +def sample_median(image: ndarray, x_coords, y_coords) -> ndarray: + img = image.astype(np.float32) if image.dtype != np.float32 else image + H, W = img.shape[:2] + if img.ndim == 2: + img = img[..., None] + C = img.shape[2] + + x = np.asarray(x_coords, dtype=np.int32) + y = np.asarray(y_coords, dtype=np.int32) + + nx, ny = len(x) - 1, len(y) - 1 + out = np.empty((ny, nx, C), dtype=np.float32) + + for j in range(ny): + y0, y1 = int(y[j]), int(y[j + 1]) + y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) + if y1 <= y0: + y1 = min(y0 + 1, H) + + for i in range(nx): + x0, x1 = int(x[i]), int(x[i + 1]) + x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) + if x1 <= x0: + x1 = min(x0 + 1, W) + + cell = img[y0:y1, x0:x1].reshape(-1, C) + if cell.shape[0] == 0: + out[j, i] = 0 + else: + out[j, i] = np.median(cell, axis=0) + + if image.dtype == np.uint8: + return np.clip(np.rint(out), 0, 255).astype(np.uint8) + return out + + +# --------------------------------------------------------------------------- +# Grid estimation / refinement (ops-dependent) +# --------------------------------------------------------------------------- + +def refine_grids(image: ndarray, grid_x: int, grid_y: int, ops: "ImageOps", + refine_intensity: float = 0.25): + H, W = image.shape[:2] + cell_w = W / grid_x + cell_h = H / grid_y + + gray = ops.to_gray(image) + gx, gy = ops.sobel(gray) + + grad_x_sum = np.sum(np.abs(gx), axis=0).reshape(-1) + grad_y_sum = np.sum(np.abs(gy), axis=1).reshape(-1) + + x_coords = [] + y_coords = [] + + x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) + while x < W + cell_w / 2: + x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) + x_coords.append(x) + x += cell_w + x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) - cell_w + while x > -cell_w / 2: + x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) + x_coords.append(x) + x -= cell_w + + y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) + while y < H + cell_h / 2: + y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) + y_coords.append(y) + y += cell_h + y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) - cell_h + while y > -cell_h / 2: + y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) + y_coords.append(y) + y -= cell_h + + x_coords = sorted(x_coords) + y_coords = sorted(y_coords) + return x_coords, y_coords + + +def estimate_grid_fft(gray: ndarray, ops: "ImageOps", peak_width: int = 6): + """Return (grid_w, grid_h) or (None, None).""" + H, W = gray.shape + + mag = compute_fft_magnitude(gray) + + band_row = W // 2 + band_col = H // 2 + row_sum = np.sum(mag[:, W // 2 - band_row: W // 2 + band_row], axis=1) + col_sum = np.sum(mag[H // 2 - band_col: H // 2 + band_col, :], axis=0) + + row_sum = ops.normalize_1d(row_sum) + col_sum = ops.normalize_1d(col_sum) + + row_sum = smooth_1d(row_sum, k=17) + col_sum = smooth_1d(col_sum, k=17) + + scale_row = detect_peak(row_sum, peak_width) + scale_col = detect_peak(col_sum, peak_width) + + if scale_row is None or scale_col is None or scale_col <= 0: + return None, None + + grid_w = int(round(scale_col)) + grid_h = int(round(scale_row)) + return grid_w, grid_h + + +def estimate_grid_gradient(gray: ndarray, ops: "ImageOps", rel_thr: float = 0.2): + H, W = gray.shape + + grad_x, grad_y = ops.sobel(gray) + + grad_x_sum = np.sum(np.abs(grad_x), axis=0).reshape(-1) + grad_y_sum = np.sum(np.abs(grad_y), axis=1).reshape(-1) + + peak_x = [] + peak_y = [] + + thr_x = float(rel_thr) * float(grad_x_sum.max()) + thr_y = float(rel_thr) * float(grad_y_sum.max()) + + min_interval = 4 + for i in range(1, len(grad_x_sum) - 1): + if (grad_x_sum[i] > grad_x_sum[i - 1] + and grad_x_sum[i] > grad_x_sum[i + 1] + and grad_x_sum[i] >= thr_x): + if len(peak_x) == 0 or i - peak_x[-1] >= min_interval: + peak_x.append(i) + + for i in range(1, len(grad_y_sum) - 1): + if (grad_y_sum[i] > grad_y_sum[i - 1] + and grad_y_sum[i] > grad_y_sum[i + 1] + and grad_y_sum[i] >= thr_y): + if len(peak_y) == 0 or i - peak_y[-1] >= min_interval: + peak_y.append(i) + + if len(peak_x) < 4 or len(peak_y) < 4: + return None, None + + intervals_x = [peak_x[i] - peak_x[i - 1] for i in range(1, len(peak_x))] + intervals_y = [peak_y[i] - peak_y[i - 1] for i in range(1, len(peak_y))] + + scale_x = W / np.median(intervals_x) + scale_y = H / np.median(intervals_y) + + log.debug("Detected grid size from gradient: (%.2f, %.2f)", scale_x, scale_y) + + return int(round(scale_x)), int(round(scale_y)) + + +def detect_grid_scale(image: ndarray, ops: "ImageOps", peak_width: int = 6, + max_ratio: float = 1.5, min_size: float = 4.0): + gray = ops.to_gray(image) + H, W = gray.shape + + grid_w, grid_h = estimate_grid_fft(gray, ops, peak_width=peak_width) + if grid_w is None or grid_h is None: + log.info("FFT-based grid estimation failed, fallback to gradient-based method.") + grid_w, grid_h = estimate_grid_gradient(gray, ops) + else: + pixel_size_x = W / grid_w + pixel_size_y = H / grid_h + max_pixel_size = 20.0 + if (min(pixel_size_x, pixel_size_y) < min_size + or max(pixel_size_x, pixel_size_y) > max_pixel_size + or pixel_size_x / pixel_size_y > max_ratio + or pixel_size_y / pixel_size_x > max_ratio): + log.info("Inconsistent grid size detected (FFT-based), fallback to gradient-based method.") + grid_w, grid_h = estimate_grid_gradient(gray, ops) + + if grid_w is None or grid_h is None: + log.warning("Gradient-based grid estimation failed.") + return None, None + + pixel_size_x = W / grid_w + pixel_size_y = H / grid_h + + if pixel_size_x / pixel_size_y > max_ratio or pixel_size_y / pixel_size_x > max_ratio: + pixel_size = min(pixel_size_x, pixel_size_y) + else: + pixel_size = (pixel_size_x + pixel_size_y) / 2.0 + + log.info("Detected pixel size: %.2f", pixel_size) + + grid_w = int(round(W / pixel_size)) + grid_h = int(round(H / pixel_size)) + + return grid_w, grid_h + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def get_perfect_pixel(image: ndarray, ops: "ImageOps", + sample_method: str = "center", + grid_size=None, + min_size: float = 4.0, + peak_width: int = 6, + refine_intensity: float = 0.25, + fix_square: bool = True): + """ + Args: + image: RGB/BGR image ndarray (H, W, 3). + ops: Backend adapter implementing ImageOps protocol. + sample_method: "majority", "center", or "median". + grid_size: Manually set grid size (grid_w, grid_h) to override auto-detection. + min_size: Minimum pixel size to consider valid. + peak_width: Minimum peak width for peak detection. + refine_intensity: Intensity for grid line refinement [0, 0.5]. + fix_square: Enforce square output when detected image is almost square. + + Returns: + (refined_w, refined_h, scaled_image) + """ + H, W = image.shape[:2] + if grid_size is not None: + scale_col, scale_row = grid_size + else: + scale_col, scale_row = detect_grid_scale( + image, ops, peak_width=peak_width, max_ratio=1.5, min_size=min_size + ) + if scale_col is None or scale_row is None: + log.warning("Failed to estimate grid size.") + return None, None, image + + size_x = int(round(scale_col)) + size_y = int(round(scale_row)) + x_coords, y_coords = refine_grids(image, size_x, size_y, ops, refine_intensity) + + refined_size_x = len(x_coords) - 1 + refined_size_y = len(y_coords) - 1 + + if sample_method == "majority": + scaled_image = sample_majority(image, x_coords, y_coords, ops) + elif sample_method == "median": + scaled_image = sample_median(image, x_coords, y_coords) + else: + scaled_image = sample_center(image, x_coords, y_coords) + + # fix square + if fix_square and abs(refined_size_x - refined_size_y) == 1: + if refined_size_x > refined_size_y: + if refined_size_x % 2 == 1: + scaled_image = scaled_image[:, :-1] + else: + scaled_image = np.concatenate([scaled_image[:1, :], scaled_image], axis=0) + else: + if refined_size_y % 2 == 1: + scaled_image = scaled_image[:-1, :] + else: + scaled_image = np.concatenate([scaled_image[:, :1], scaled_image], axis=1) + + refined_size_y, refined_size_x = scaled_image.shape[:2] + log.info("Refined grid size: (%d, %d)", refined_size_x, refined_size_y) + + return refined_size_x, refined_size_y, scaled_image diff --git a/src/perfect_pixel/ops.py b/src/perfect_pixel/ops.py new file mode 100644 index 0000000..5eea4e5 --- /dev/null +++ b/src/perfect_pixel/ops.py @@ -0,0 +1,28 @@ +"""ImageOps protocol — pluggable backend interface for perfect_pixel.""" + +from __future__ import annotations + +from typing import Protocol, Tuple + +import numpy as np +from numpy import ndarray + + +class ImageOps(Protocol): + """Minimal set of image operations needed by the core algorithms.""" + + def to_gray(self, image: ndarray) -> ndarray: + """Convert an RGB (or BGR) image to float32 grayscale.""" + ... + + def sobel(self, gray: ndarray) -> Tuple[ndarray, ndarray]: + """Return (grad_x, grad_y) as float32 arrays (same shape as *gray*).""" + ... + + def normalize_1d(self, v: ndarray) -> ndarray: + """Min-max normalize a 1-D array to [0, 1] float32.""" + ... + + def kmeans_2(self, pixels: ndarray, iters: int) -> ndarray: + """2-means on *pixels* (N, C) float32. Return the majority centre (C,).""" + ... diff --git a/src/perfect_pixel/perfect_pixel.py b/src/perfect_pixel/perfect_pixel.py index d51ea12..c0345ea 100644 --- a/src/perfect_pixel/perfect_pixel.py +++ b/src/perfect_pixel/perfect_pixel.py @@ -1,436 +1,13 @@ -import numpy as np -import cv2 +"""Backward-compatible thin wrapper (OpenCV backend).""" -def compute_fft_magnitude(gray_image): - f = np.fft.fft2(gray_image.astype(np.float32)) - fshift = np.fft.fftshift(f) - mag = np.abs(fshift) - mag = 1 - np.log1p(mag) # log(1 + |F|) - # normalize to [0, 1] - mn, mx = float(mag.min()), float(mag.max()) - if mx - mn < 1e-8: - return np.zeros_like(mag, dtype=np.float32) - mag = (mag - mn) / (mx - mn) - return mag +from .core import get_perfect_pixel as _core_get +from .backend_cv2 import CV2Ops -def smooth_1d(v, k = 17): - """Simple 1D smoothing with a Gaussian-like kernel (no scipy).""" - k = int(k) - if k < 3: - return v - if k % 2 == 0: - k += 1 - sigma = k / 6.0 - x = np.arange(k) - k // 2 - ker = np.exp(-(x * x) / (2 * sigma * sigma)) - ker = ker / (ker.sum() + 1e-8) - vv = np.convolve(v, ker, mode="same") - return vv +_ops = CV2Ops() -def detect_peak(proj, peak_width = 6, rel_thr=0.35, min_dist=6): - center = len(proj) // 2 - mx = float(proj.max()) - if mx < 1e-6: - return None - - thr = mx * float(rel_thr) - - candidates = [] - for i in range(1, len(proj) - 1): - is_peak = True - for j in range(1, peak_width): - if i - j < 0 or i + j >= len(proj): - continue - if proj[i-j+1] < proj[i - j] or proj[i+j-1] < proj[i + j]: - is_peak = False - break - if is_peak and proj[i] >= thr: - left_climb = 0 - for k in range(i, 0, -1): - if proj[k] > proj[k-1]: - left_climb = abs(proj[i] - proj[k-1]) - else: - break - - right_fall = 0 - for k in range(i, len(proj) - 1): - if proj[k] > proj[k+1]: - right_fall = abs(proj[i] - proj[k+1]) - else: - break - - candidates.append({ - 'index': i, - 'climb': left_climb, - 'fall': right_fall, - 'score': max(left_climb, right_fall) - }) - - if not candidates: - return None - - # enforce a dead-zone around center - left = [i for i in candidates if i['index'] < center - min_dist and i['index'] > center * 0.25] - right = [i for i in candidates if i['index'] > center + min_dist and i['index'] < center * 1.75] - - left.sort(key=lambda x: x['score'], reverse=True) - right.sort(key=lambda x: x['score'], reverse=True) - - if not left or not right: - return None - - # pick nearest to center on each side - peak_left = left[0]['index'] - peak_right = right[0]['index'] - - return abs(peak_right - peak_left)/2 - -def find_best_grid(origin, range_val_min, range_val_max, grad_mag, thr = 0): - best = round(origin) - peaks = [] - mx = np.max(grad_mag) - if mx < 1e-6: - return best - rel_thr = mx * thr - for i in range(-round(range_val_min), round(range_val_max)+1): - candidate = round(origin + i) - if candidate <= 0 or candidate >= len(grad_mag) - 1: - continue - if grad_mag[candidate] > grad_mag[candidate -1] and grad_mag[candidate] > grad_mag[candidate +1] and grad_mag[candidate] >= rel_thr: - peaks.append((grad_mag[candidate], candidate)) - if len(peaks) == 0: - return best - - # find the brightest peak - peaks.sort(key=lambda x: x[0], reverse=True) - best = peaks[0][1] - return best - -def sample_center(image, x_coords, y_coords): - x = np.asarray(x_coords) - y = np.asarray(y_coords) - - centers_x = np.clip((x[1:] + x[:-1]) * 0.5, 0, image.shape[1] - 1).astype(np.int32) - centers_y = np.clip((y[1:] + y[:-1]) * 0.5, 0, image.shape[0] - 1).astype(np.int32) - - scaled_image = image[centers_y[:, None], centers_x[None, :]] - return scaled_image - -def sample_majority(image, x_coords, y_coords, max_samples=128, iters=6, seed=42): - rng = np.random.default_rng(seed) - - img = image.astype(np.float32) if image.dtype != np.float32 else image - H, W = img.shape[:2] - if img.ndim == 2: - img = img[..., None] - C = img.shape[2] - - x = np.asarray(x_coords, dtype=np.int32) - y = np.asarray(y_coords, dtype=np.int32) - - nx, ny = len(x) - 1, len(y) - 1 - out = np.empty((ny, nx, C), dtype=np.float32) - criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, iters, 1.0) - - for j in range(ny): - y0, y1 = int(y[j]), int(y[j + 1]) - y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) - if y1 <= y0: y1 = min(y0 + 1, H) - - for i in range(nx): - x0, x1 = int(x[i]), int(x[i + 1]) - x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) - if x1 <= x0: x1 = min(x0 + 1, W) - - cell = img[y0:y1, x0:x1].reshape(-1, C) - n = cell.shape[0] - if n == 0: - out[j, i] = 0 - continue - if n > max_samples: - cell = cell[rng.integers(0, n, size=max_samples)] - - if cell.shape[0] < 2: - out[j, i] = cell[0] - else: - # 使用 cv2.kmeans 聚成 2 类 - # K=2, attempts=1, 使用 KMEANS_RANDOM_CENTERS 或 PP 模式 - _, labels, centers = cv2.kmeans( - cell, 2, None, criteria, 1, cv2.KMEANS_RANDOM_CENTERS - ) - - # 计算两个簇的像素数量,labels 是二维数组 (N, 1) - count1 = np.sum(labels) # 标签是 0 和 1 - count0 = len(labels) - count1 - - # 多数表决:取成员较多的中心点 - out[j, i] = centers[1] if count1 >= count0 else centers[0] - # --- 替换部分结束 --- - - if image.dtype == np.uint8: - return np.clip(np.rint(out), 0, 255).astype(np.uint8) - return out - -def sample_median(image, x_coords, y_coords): - img = image.astype(np.float32) if image.dtype != np.float32 else image - H, W = img.shape[:2] - if img.ndim == 2: - img = img[..., None] - C = img.shape[2] - - x = np.asarray(x_coords, dtype=np.int32) - y = np.asarray(y_coords, dtype=np.int32) - - nx, ny = len(x) - 1, len(y) - 1 - out = np.empty((ny, nx, C), dtype=np.float32) - - for j in range(ny): - y0, y1 = int(y[j]), int(y[j + 1]) - y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) - if y1 <= y0: y1 = min(y0 + 1, H) - - for i in range(nx): - x0, x1 = int(x[i]), int(x[i + 1]) - x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) - if x1 <= x0: x1 = min(x0 + 1, W) - - cell = img[y0:y1, x0:x1].reshape(-1, C) - if cell.shape[0] == 0: - out[j, i] = 0 - else: - out[j, i] = np.median(cell, axis=0) - - if image.dtype == np.uint8: - return np.clip(np.rint(out), 0, 255).astype(np.uint8) - return out - -def refine_grids(image, grid_x, grid_y, refine_intensity=0.25): - H, W = image.shape[:2] - x_coords = [] - y_coords = [] - cell_w = W / grid_x - cell_h = H / grid_y - - # calculate gradient magnitude - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - grad_x = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) - grad_y = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) - - grad_x_sum = np.sum(np.abs(grad_x), axis=0).reshape(-1) - grad_y_sum = np.sum(np.abs(grad_y), axis=1).reshape(-1) - - # refine grid lines based on gradient magnitude from center - x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) - while(x < W + cell_w/2): - x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) - x_coords.append(x) - x += cell_w - x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) - cell_w - while(x > -cell_w/2): - x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) - x_coords.append(x) - x -= cell_w - - y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) - while(y < H + cell_h/2): - y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) - y_coords.append(y) - y += cell_h - y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) - cell_h - while(y > -cell_h/2): - y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) - y_coords.append(y) - y -= cell_h - - x_coords = sorted(x_coords) - y_coords = sorted(y_coords) - - return x_coords, y_coords - -def estimate_grid_fft(gray, peak_width=6): - """Return (grid_w, grid_h) or None.""" - H, W = gray.shape - - mag = compute_fft_magnitude(gray) - - band_row = W // 2 - band_col = H // 2 - row_sum = np.sum(mag[:, W//2 - band_row: W//2 + band_row], axis=1) - col_sum = np.sum(mag[H//2 - band_col: H//2 + band_col, :], axis=0) - - row_sum = cv2.normalize(row_sum, None, 0, 1, cv2.NORM_MINMAX).flatten() - col_sum = cv2.normalize(col_sum, None, 0, 1, cv2.NORM_MINMAX).flatten() - - row_sum = smooth_1d(row_sum, k=17) - col_sum = smooth_1d(col_sum, k=17) - - scale_row = detect_peak(row_sum, peak_width) - scale_col = detect_peak(col_sum, peak_width) - - if scale_row is None or scale_col is None or scale_col <= 0: - return None, None - - grid_w = int(round(scale_col)) - grid_h = int(round(scale_row)) - return grid_w, grid_h - -def estimate_grid_gradient(gray, rel_thr=0.2): - H, W = gray.shape - - grad_x = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) - grad_y = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) - - grad_x_sum = np.sum(np.abs(grad_x), axis=0).reshape(-1) - grad_y_sum = np.sum(np.abs(grad_y), axis=1).reshape(-1) - - peak_x = [] - peak_y = [] - - thr_x = float(rel_thr) * float(grad_x_sum.max()) - thr_y = float(rel_thr) * float(grad_y_sum.max()) - - min_interval = 4 - for i in range(1, len(grad_x_sum) - 1): - if grad_x_sum[i] > grad_x_sum[i - 1] and grad_x_sum[i] > grad_x_sum[i + 1] and grad_x_sum[i] >= thr_x: - if len(peak_x) == 0 or i - peak_x[-1] >= min_interval: - peak_x.append(i) - - for i in range(1, len(grad_y_sum) - 1): - if grad_y_sum[i] > grad_y_sum[i - 1] and grad_y_sum[i] > grad_y_sum[i + 1] and grad_y_sum[i] >= thr_y: - if len(peak_y) == 0 or i - peak_y[-1] >= min_interval: - peak_y.append(i) - - if len(peak_x) < 4 or len(peak_y) < 4: - return None, None - - # get median interval - intervals_x = [] - for i in range(1, len(peak_x)): - intervals_x.append(peak_x[i] - peak_x[i - 1]) - intervals_y = [] - for i in range(1, len(peak_y)): - intervals_y.append(peak_y[i] - peak_y[i - 1]) - - scale_x = W / np.median(intervals_x) - scale_y = H / np.median(intervals_y) - - print(f"Detected grid size from gradient: ({scale_x:.2f}, {scale_y:.2f})") - - return int(round(scale_x)), int(round(scale_y)) - -def detect_grid_scale(image, peak_width=6, max_ratio=1.5, min_size=4.0): - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - H, W = gray.shape - - grid_w, grid_h = estimate_grid_fft(gray, peak_width=peak_width) - if grid_w is None or grid_h is None: - print("FFT-based grid estimation failed, fallback to gradient-based method.") - grid_w, grid_h = estimate_grid_gradient(gray) - else: - pixel_size_x = W / grid_w - pixel_size_y = H / grid_h - max_pixel_size = 20.0 - if min(pixel_size_x, pixel_size_y) < min_size or max(pixel_size_x, pixel_size_y) > max_pixel_size or pixel_size_x / pixel_size_y > max_ratio or pixel_size_y / pixel_size_x > max_ratio: - print("Inconsistent grid size detected (FFT-based), fallback to gradient-based method.") - grid_w, grid_h = estimate_grid_gradient(gray) - - if grid_w is None or grid_h is None: - print("Gradient-based grid estimation failed.") - return None, None - - pixel_size_x = W / grid_w - pixel_size_y = H / grid_h - - if pixel_size_x / pixel_size_y > max_ratio or pixel_size_y / pixel_size_x > max_ratio: - pixel_size = min(pixel_size_x, pixel_size_y) - else: - pixel_size = (pixel_size_x + pixel_size_y) / 2.0 - - print(f"Detected pixel size: {pixel_size:.2f}") - - grid_w = int(round(W / pixel_size)) - grid_h = int(round(H / pixel_size)) - - return grid_w, grid_h - -def grid_layout(image, x_coords, y_coords, scale_x, scale_y): - import matplotlib.pyplot as plt - plt.figure() - plt.imshow(image) - plt.title(f"Scaled Image by Grid Sampling({scale_x}x{scale_y})") - for x in x_coords: - plt.axvline(x=x, linewidth=0.6) - for y in y_coords: - plt.axhline(y=y, linewidth=0.6) - plt.show() - -def get_perfect_pixel(image, sample_method="center", grid_size = None, min_size = 4.0, peak_width = 6, refine_intensity = 0.25, fix_square = True, debug=False): - """ - Args: - image: RGB Image (H * W * 3) - sample_method: "majority", "center", or "median" - grid_size: Manually set grid size (grid_w, grid_h) to override auto-detection - min_size: Minimum pixel size to consider valid - peak_width: Minimum peak width for peak detection. - refine_intensity: Intensity for grid line refinement. Recommended range is [0, 0.5]. Given original estimated grid line at x, the refinement will search in [x * (1 - refine_intensity), x * (1 + refine_intensity)]. - fix_square: Whether to enforce output to be square when detected image is almost square. - debug: Whether to show debug plots. - - returns: - refined_w, refined_h, scaled_image - """ - H, W = image.shape[:2] - if grid_size is not None: - # use provided grid size - scale_col, scale_row = grid_size - else: - scale_col, scale_row = detect_grid_scale(image, peak_width=peak_width, max_ratio=1.5, min_size=min_size) - if scale_col is None or scale_row is None: - print("Failed to estimate grid size.") - return None, None, image - - size_x = int(round(scale_col)) - size_y = int(round(scale_row)) - x_coords, y_coords = refine_grids(image, size_x, size_y, refine_intensity) - - refined_size_x = len(x_coords) - 1 - refined_size_y = len(y_coords) - 1 - - # sample by majority - if sample_method == "majority": - scaled_image = sample_majority(image, x_coords, y_coords) - - # sample by median - elif sample_method == "median": - scaled_image = sample_median(image, x_coords, y_coords) - - # sample by center - else: - scaled_image = sample_center(image, x_coords, y_coords) - - # fix square - if fix_square and abs(refined_size_x - refined_size_y) == 1: - # align to even sized square - if refined_size_x > refined_size_y: - if refined_size_x % 2 == 1: - # remove one column - scaled_image = scaled_image[:, :-1] - else: - # add one row by duplicating first row - scaled_image = np.concatenate([scaled_image[:1, :], scaled_image], axis=0) - else: - if refined_size_y % 2 == 1: - # remove one row - scaled_image = scaled_image[:-1, :] - else: - # add one col by duplicating first col - scaled_image = np.concatenate([scaled_image[:, :1], scaled_image], axis=1) - refined_size_y, refined_size_x = scaled_image.shape[:2] - print(f"Refined grid size: ({refined_size_x}, {refined_size_y})") - - # debug - if debug: - grid_layout(image, x_coords, y_coords, refined_size_x, refined_size_y) - - return refined_size_x, refined_size_y, scaled_image +def get_perfect_pixel(image, sample_method="center", grid_size=None, min_size=4.0, + peak_width=6, refine_intensity=0.25, fix_square=True, debug=False): + return _core_get(image, ops=_ops, sample_method=sample_method, grid_size=grid_size, + min_size=min_size, peak_width=peak_width, + refine_intensity=refine_intensity, fix_square=fix_square) diff --git a/src/perfect_pixel/perfect_pixel_noCV2.py b/src/perfect_pixel/perfect_pixel_noCV2.py index 0f25fbb..13c5249 100644 --- a/src/perfect_pixel/perfect_pixel_noCV2.py +++ b/src/perfect_pixel/perfect_pixel_noCV2.py @@ -1,490 +1,13 @@ -import numpy as np +"""Backward-compatible thin wrapper (pure-NumPy backend).""" -# ---------------------------- -# Small utilities (no cv2) -# ---------------------------- +from .core import get_perfect_pixel as _core_get +from .backend_numpy import NumpyOps -def rgb_to_gray(image_rgb: np.ndarray) -> np.ndarray: - """RGB uint8/float -> gray float32""" - img = image_rgb.astype(np.float32) - if img.ndim == 2: - return img - # assume RGB - return (0.299 * img[..., 0] + 0.587 * img[..., 1] + 0.114 * img[..., 2]).astype(np.float32) +_ops = NumpyOps() -def normalize_minmax(x: np.ndarray, a=0.0, b=1.0) -> np.ndarray: - x = x.astype(np.float32, copy=False) - mn = float(x.min()) - mx = float(x.max()) - if mx - mn < 1e-8: - return np.zeros_like(x, dtype=np.float32) + a - y = (x - mn) / (mx - mn) - return (a + (b - a) * y).astype(np.float32) - - -def conv2d_same(image: np.ndarray, kernel: np.ndarray) -> np.ndarray: - """2D convolution (same) for grayscale float32, naive but ok for demo sizes.""" - img = image.astype(np.float32, copy=False) - k = kernel.astype(np.float32, copy=False) - kh, kw = k.shape - ph, pw = kh // 2, kw // 2 - pad = np.pad(img, ((ph, ph), (pw, pw)), mode="reflect") - out = np.zeros_like(img, dtype=np.float32) - - # sum of shifted windows (vectorized over shifts) - for dy in range(kh): - for dx in range(kw): - w = k[dy, dx] - if w == 0: - continue - out += w * pad[dy:dy + img.shape[0], dx:dx + img.shape[1]] - return out - - -def sobel_xy(gray: np.ndarray, ksize: int = 3): - """Return (gx, gy) similar to cv2.Sobel for ksize 3 or 5.""" - if ksize == 3: - kx = np.array([[-1, 0, 1], - [-2, 0, 2], - [-1, 0, 1]], dtype=np.float32) - ky = np.array([[-1, -2, -1], - [ 0, 0, 0], - [ 1, 2, 1]], dtype=np.float32) - elif ksize == 5: - # Common 5x5 Sobel kernel (approx). Good enough for grid refinement. - kx = np.array([[-5, -4, 0, 4, 5], - [-8, -10, 0, 10, 8], - [-10,-20, 0, 20, 10], - [-8, -10, 0, 10, 8], - [-5, -4, 0, 4, 5]], dtype=np.float32) - ky = kx.T - else: - raise ValueError("ksize must be 3 or 5") - - gx = conv2d_same(gray, kx) - gy = conv2d_same(gray, ky) - return gx, gy - - -def magnitude(gx: np.ndarray, gy: np.ndarray) -> np.ndarray: - return np.sqrt(gx * gx + gy * gy).astype(np.float32) - - -# ---------------------------- -# Your original logic (ported) -# ---------------------------- - -def compute_fft_magnitude(gray_image): - f = np.fft.fft2(gray_image.astype(np.float32)) - fshift = np.fft.fftshift(f) - mag = np.abs(fshift) - mag = 1 - np.log1p(mag) - return normalize_minmax(mag, 0.0, 1.0) - - -def smooth_1d(v, k=17): - k = int(k) - if k < 3: - return v - if k % 2 == 0: - k += 1 - sigma = k / 6.0 - x = np.arange(k) - k // 2 - ker = np.exp(-(x * x) / (2 * sigma * sigma)) - ker = ker / (ker.sum() + 1e-8) - return np.convolve(v, ker, mode="same") - - -def detect_peak(proj, peak_width=6, rel_thr=0.35, min_dist=6): - center = len(proj) // 2 - mx = float(proj.max()) - if mx < 1e-6: - return None - - thr = mx * float(rel_thr) - - candidates = [] - for i in range(1, len(proj) - 1): - is_peak = True - for j in range(1, peak_width): - if i - j < 0 or i + j >= len(proj): - continue - if proj[i - j + 1] < proj[i - j] or proj[i + j - 1] < proj[i + j]: - is_peak = False - break - if is_peak and proj[i] >= thr: - left_climb = 0 - for k in range(i, 0, -1): - if proj[k] > proj[k - 1]: - left_climb = abs(proj[i] - proj[k - 1]) - else: - break - - right_fall = 0 - for k in range(i, len(proj) - 1): - if proj[k] > proj[k + 1]: - right_fall = abs(proj[i] - proj[k + 1]) - else: - break - - candidates.append({ - "index": i, - "climb": left_climb, - "fall": right_fall, - "score": max(left_climb, right_fall), - }) - - if not candidates: - return None - - left = [c for c in candidates if c["index"] < center - min_dist and c["index"] > center * 0.25] - right = [c for c in candidates if c["index"] > center + min_dist and c["index"] < center * 1.75] - - left.sort(key=lambda x: x["score"], reverse=True) - right.sort(key=lambda x: x["score"], reverse=True) - - if not left or not right: - return None - - peak_left = left[0]["index"] - peak_right = right[0]["index"] - - return abs(peak_right - peak_left) / 2 - - -def find_best_grid(origin, range_val_min, range_val_max, grad_mag, thr = 0): - best = round(origin) - peaks = [] - mx = np.max(grad_mag) - if mx < 1e-6: - return best - rel_thr = mx * thr - for i in range(-round(range_val_min), round(range_val_max)+1): - candidate = round(origin + i) - if candidate <= 0 or candidate >= len(grad_mag) - 1: - continue - if grad_mag[candidate] > grad_mag[candidate -1] and grad_mag[candidate] > grad_mag[candidate +1] and grad_mag[candidate] >= rel_thr: - peaks.append((grad_mag[candidate], candidate)) - if len(peaks) == 0: - return best - - # find the brightest peak - peaks.sort(key=lambda x: x[0], reverse=True) - best = peaks[0][1] - return best - - -def sample_center(image, x_coords, y_coords): - x = np.asarray(x_coords) - y = np.asarray(y_coords) - centers_x = ((x[1:] + x[:-1]) * 0.5).astype(np.int32) - centers_y = ((y[1:] + y[:-1]) * 0.5).astype(np.int32) - return image[centers_y[:, None], centers_x[None, :]] - - -def sample_majority(image, x_coords, y_coords, max_samples=128, iters=6, seed=0): - rng = np.random.default_rng(seed) - - img = image.astype(np.float32) if image.dtype != np.float32 else image - H, W = img.shape[:2] - if img.ndim == 2: - img = img[..., None] - C = img.shape[2] - - x = np.asarray(x_coords, dtype=np.int32) - y = np.asarray(y_coords, dtype=np.int32) - - nx, ny = len(x) - 1, len(y) - 1 - out = np.empty((ny, nx, C), dtype=np.float32) - - for j in range(ny): - y0, y1 = int(y[j]), int(y[j + 1]) - y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) - if y1 <= y0: y1 = min(y0 + 1, H) - - for i in range(nx): - x0, x1 = int(x[i]), int(x[i + 1]) - x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) - if x1 <= x0: x1 = min(x0 + 1, W) - - cell = img[y0:y1, x0:x1].reshape(-1, C) - n = cell.shape[0] - if n == 0: - out[j, i] = 0 - continue - if n > max_samples: - cell = cell[rng.integers(0, n, size=max_samples)] - - c0 = cell[0] - c1 = cell[np.argmax(((cell - c0) ** 2).sum(1))] - - for _ in range(iters): - d0 = ((cell - c0) ** 2).sum(1) - d1 = ((cell - c1) ** 2).sum(1) - m1 = d1 < d0 - if np.any(~m1): c0 = cell[~m1].mean(0) - if np.any(m1): c1 = cell[m1].mean(0) - - out[j, i] = c1 if m1.sum() >= (~m1).sum() else c0 - - if image.dtype == np.uint8: - return np.clip(np.rint(out), 0, 255).astype(np.uint8) - return out - -def sample_median(image, x_coords, y_coords): - img = image.astype(np.float32) if image.dtype != np.float32 else image - H, W = img.shape[:2] - if img.ndim == 2: - img = img[..., None] - C = img.shape[2] - - x = np.asarray(x_coords, dtype=np.int32) - y = np.asarray(y_coords, dtype=np.int32) - - nx, ny = len(x) - 1, len(y) - 1 - out = np.empty((ny, nx, C), dtype=np.float32) - - for j in range(ny): - y0, y1 = int(y[j]), int(y[j + 1]) - y0 = np.clip(y0, 0, H); y1 = np.clip(y1, 0, H) - if y1 <= y0: y1 = min(y0 + 1, H) - - for i in range(nx): - x0, x1 = int(x[i]), int(x[i + 1]) - x0 = np.clip(x0, 0, W); x1 = np.clip(x1, 0, W) - if x1 <= x0: x1 = min(x0 + 1, W) - - cell = img[y0:y1, x0:x1].reshape(-1, C) - if cell.shape[0] == 0: - out[j, i] = 0 - else: - out[j, i] = np.median(cell, axis=0) - - if image.dtype == np.uint8: - return np.clip(np.rint(out), 0, 255).astype(np.uint8) - return out - -def refine_grids(image, grid_x, grid_y, refine_intensity=0.25): - H, W = image.shape[:2] - cell_w = W / grid_x - cell_h = H / grid_y - - gray = rgb_to_gray(image) - gx, gy = sobel_xy(gray, ksize=3) - - grad_x_sum = np.sum(np.abs(gx), axis=0).reshape(-1) - grad_y_sum = np.sum(np.abs(gy), axis=1).reshape(-1) - - x_coords = [] - y_coords = [] - - x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) - while x < W + cell_w/2: - x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) - x_coords.append(x) - x += cell_w - x = find_best_grid(W / 2, cell_w, cell_w, grad_x_sum) - cell_w - while x > -cell_w/2: - x = find_best_grid(x, cell_w * refine_intensity, cell_w * refine_intensity, grad_x_sum) - x_coords.append(x) - x -= cell_w - - y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) - while y < H + cell_h/2: - y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) - y_coords.append(y) - y += cell_h - y = find_best_grid(H / 2, cell_h, cell_h, grad_y_sum) - cell_h - while y > -cell_h/2: - y = find_best_grid(y, cell_h * refine_intensity, cell_h * refine_intensity, grad_y_sum) - y_coords.append(y) - y -= cell_h - - x_coords = sorted(x_coords) - y_coords = sorted(y_coords) - return x_coords, y_coords - -def estimate_grid_fft(gray, peak_width=6): - """Return (grid_w, grid_h) or None.""" - H, W = gray.shape - - mag = compute_fft_magnitude(gray) - - band_row = W // 2 - band_col = H // 2 - row_sum = np.sum(mag[:, W//2 - band_row: W//2 + band_row], axis=1) - col_sum = np.sum(mag[H//2 - band_col: H//2 + band_col, :], axis=0) - - row_sum = normalize_minmax(row_sum, 0.0, 1.0).flatten() - col_sum = normalize_minmax(col_sum, 0.0, 1.0).flatten() - - row_sum = smooth_1d(row_sum, k=17) - col_sum = smooth_1d(col_sum, k=17) - - scale_row = detect_peak(row_sum, peak_width=peak_width) - scale_col = detect_peak(col_sum, peak_width=peak_width) - - if scale_row is None or scale_col is None or scale_col <= 0: - return None - - return scale_col, scale_row - -def estimate_grid_gradient(gray, rel_thr=0.2): - H, W = gray.shape - - grad_x, grad_y = sobel_xy(gray, ksize=3) - - grad_x_sum = np.sum(np.abs(grad_x), axis=0).reshape(-1) - grad_y_sum = np.sum(np.abs(grad_y), axis=1).reshape(-1) - - peak_x = [] - peak_y = [] - - thr_x = float(rel_thr) * float(grad_x_sum.max()) - thr_y = float(rel_thr) * float(grad_y_sum.max()) - - min_interval = 4 - for i in range(1, len(grad_x_sum) - 1): - if grad_x_sum[i] > grad_x_sum[i - 1] and grad_x_sum[i] > grad_x_sum[i + 1] and grad_x_sum[i] >= thr_x: - if len(peak_x) == 0 or i - peak_x[-1] >= min_interval: - peak_x.append(i) - - for i in range(1, len(grad_y_sum) - 1): - if grad_y_sum[i] > grad_y_sum[i - 1] and grad_y_sum[i] > grad_y_sum[i + 1] and grad_y_sum[i] >= thr_y: - if len(peak_y) == 0 or i - peak_y[-1] >= min_interval: - peak_y.append(i) - - if len(peak_x) < 4 or len(peak_y) < 4: - return None, None - - # get median interval - intervals_x = [] - for i in range(1, len(peak_x)): - intervals_x.append(peak_x[i] - peak_x[i - 1]) - intervals_y = [] - for i in range(1, len(peak_y)): - intervals_y.append(peak_y[i] - peak_y[i - 1]) - - scale_x = W / np.median(intervals_x) - scale_y = H / np.median(intervals_y) - - print(f"Detected grid size from gradient: ({scale_x:.2f}, {scale_y:.2f})") - - return int(round(scale_x)), int(round(scale_y)) - -def detect_grid_scale(image, peak_width=6, max_ratio=1.5, min_size=4.0): - gray = rgb_to_gray(image) - H, W = gray.shape - - grid_w, grid_h = estimate_grid_fft(gray, peak_width=peak_width) - if grid_w is None or grid_h is None: - print("FFT-based grid estimation failed, fallback to gradient-based method.") - grid_w, grid_h = estimate_grid_gradient(gray) - else: - pixel_size_x = W / grid_w - pixel_size_y = H / grid_h - max_pixel_size = 20.0 - if min(pixel_size_x, pixel_size_y) < min_size or max(pixel_size_x, pixel_size_y) > max_pixel_size or pixel_size_x / pixel_size_y > max_ratio or pixel_size_y / pixel_size_x > max_ratio: - print("Inconsistent grid size detected (FFT-based), fallback to gradient-based method.") - grid_w, grid_h = estimate_grid_gradient(gray) - - if grid_w is None or grid_h is None: - print("Gradient-based grid estimation failed.") - return None, None - - pixel_size_x = W / grid_w - pixel_size_y = H / grid_h - - if pixel_size_x / pixel_size_y > max_ratio or pixel_size_y / pixel_size_x > max_ratio: - pixel_size = min(pixel_size_x, pixel_size_y) - else: - pixel_size = (pixel_size_x + pixel_size_y) / 2.0 - - print(f"Detected pixel size: {pixel_size:.2f}") - - grid_w = int(round(W / pixel_size)) - grid_h = int(round(H / pixel_size)) - - return grid_w, grid_h - -def grid_layout(image, x_coords, y_coords, scale_x, scale_y): - import matplotlib.pyplot as plt - plt.figure() - plt.imshow(image) - plt.title(f"Scaled Image by Grid Sampling({scale_x}x{scale_y})") - for x in x_coords: - plt.axvline(x=x, linewidth=0.6) - for y in y_coords: - plt.axhline(y=y, linewidth=0.6) - plt.show() - -def get_perfect_pixel(image, sample_method="center", grid_size = None, min_size = 4.0, peak_width = 6, refine_intensity = 0.25, fix_square = True, debug=False): - """ - Args: - image: RGB ndArray (H * W * 3) - sample_method: "majority", "center", or "median" - grid_size: Manually set grid size (grid_w, grid_h) to override auto-detection - min_size: Minimum pixel size to consider valid - peak_width: Minimum peak width for peak detection. - refine_intensity: Intensity for grid line refinement. Recommended range is [0, 0.5]. Given original estimated grid line at x, the refinement will search in [x * (1 - refine_intensity), x * (1 + refine_intensity)]. - fix_square: Whether to enforce output to be square when detected image is almost square. - debug: Whether to show debug plots. - - returns: - refined_w, refined_h, scaled_image - """ - H, W = image.shape[:2] - if grid_size is not None: - # use provided grid size - scale_col, scale_row = grid_size - else: - scale_col, scale_row = detect_grid_scale(image, peak_width=peak_width, max_ratio=1.5, min_size=min_size) - if scale_col is None or scale_row is None: - print("Failed to estimate grid size.") - return None, None, image - - size_x = int(round(scale_col)) - size_y = int(round(scale_row)) - x_coords, y_coords = refine_grids(image, size_x, size_y, refine_intensity) - - refined_size_x = len(x_coords) - 1 - refined_size_y = len(y_coords) - 1 - - # sample by majority - if sample_method == "majority": - scaled_image = sample_majority(image, x_coords, y_coords) - - # sample by median - elif sample_method == "median": - scaled_image = sample_median(image, x_coords, y_coords) - - # sample by center - else: - scaled_image = sample_center(image, x_coords, y_coords) - - # fix square - if fix_square and abs(refined_size_x - refined_size_y) == 1: - # align to even sized square - if refined_size_x > refined_size_y: - if refined_size_x % 2 == 1: - # remove one column - scaled_image = scaled_image[:, :-1] - else: - # add one row by duplicating first row - scaled_image = np.concatenate([scaled_image[:1, :], scaled_image], axis=0) - else: - if refined_size_y % 2 == 1: - # remove one row - scaled_image = scaled_image[:-1, :] - else: - # add one col by duplicating first col - scaled_image = np.concatenate([scaled_image[:, :1], scaled_image], axis=1) - refined_size_y, refined_size_x = scaled_image.shape[:2] - print(f"Refined grid size: ({refined_size_x}, {refined_size_y})") - - # debug - if debug: - grid_layout(image, x_coords, y_coords, refined_size_x, refined_size_y) - - return refined_size_x, refined_size_y, scaled_image +def get_perfect_pixel(image, sample_method="center", grid_size=None, min_size=4.0, + peak_width=6, refine_intensity=0.25, fix_square=True, debug=False): + return _core_get(image, ops=_ops, sample_method=sample_method, grid_size=grid_size, + min_size=min_size, peak_width=peak_width, + refine_intensity=refine_intensity, fix_square=fix_square) diff --git a/web_app.py b/web_app.py index f3ceb98..8d85d51 100644 --- a/web_app.py +++ b/web_app.py @@ -17,6 +17,22 @@ app.config["MAX_CONTENT_LENGTH"] = 32 * 1024 * 1024 # 32 MB +# ── Shared helpers ───────────────────────────────────────────────────────── + +def _decode_b64_image(b64_str): + """Decode base64 string to RGB numpy array.""" + data = base64.b64decode(b64_str) + arr = np.frombuffer(data, dtype=np.uint8) + bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if bgr is None: + raise ValueError("Cannot decode image data") + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + +def _make_error(msg, code=400): + """Uniform error response.""" + return jsonify({"error": msg}), code + + # ── Palette file parsers ──────────────────────────────────────────────────── def parse_gpl(text): @@ -334,13 +350,6 @@ def encode_png_b64(rgb_array): return base64.b64encode(buf).decode("utf-8") -def b64_to_rgb(b64_str): - data = base64.b64decode(b64_str) - arr = np.frombuffer(data, dtype=np.uint8) - bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) - return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - - # ── Flask routes ──────────────────────────────────────────────────────────── @app.route("/") @@ -353,6 +362,10 @@ def editor(): return send_file(os.path.join(os.path.dirname(__file__), "editor.html")) +@app.route("/editor/") +def editor_static(filename): + return send_file(os.path.join(os.path.dirname(__file__), "editor", filename)) + @app.route("/icons/") def icons(filename): @@ -367,7 +380,7 @@ def output_png(): @app.route("/api/process", methods=["POST"]) def process(): if "image" not in request.files: - return jsonify({"error": "No image uploaded"}), 400 + return _make_error("No image uploaded") file = request.files["image"] sample_method = request.form.get("sample_method", "center") @@ -378,7 +391,7 @@ def process(): file_bytes = np.frombuffer(file.read(), dtype=np.uint8) bgr = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR) if bgr is None: - return jsonify({"error": "Cannot decode image"}), 400 + return _make_error("Cannot decode image") rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) grid_w, grid_h, out = get_perfect_pixel( @@ -390,7 +403,7 @@ def process(): ) if grid_w is None or grid_h is None: - return jsonify({"error": "Grid detection failed. Try a clearer pixel art image."}), 422 + return _make_error("Grid detection failed. Try a clearer pixel art image.", 422) out_b64 = encode_png_b64(out) scaled = cv2.resize( @@ -418,7 +431,7 @@ def api_generate_palette(): min_region_pct = float(request.form.get("min_region_pct", 1.0)) if not image_b64: - return jsonify({"error": "No image data"}), 400 + return _make_error("No image data") try: # Decode with RGBA to avoid transparent pixels being composited to black @@ -429,10 +442,10 @@ def api_generate_palette(): alpha_mask = rgba_arr[:, :, 3] >= 128 opaque_pixels = rgba_arr[:, :, :3][alpha_mask] # (N, 3) if opaque_pixels.shape[0] == 0: - return jsonify({"error": "No opaque pixels to quantize"}), 400 + return _make_error("No opaque pixels to quantize") rgb = opaque_pixels.reshape(-1, 1, 3) # (N, 1, 3) strip — quantizers work on pixel color, not layout except Exception as e: - return jsonify({"error": f"Cannot decode image: {e}"}), 400 + return _make_error(f"Cannot decode image: {e}") try: if algorithm == "mediancut": @@ -442,7 +455,7 @@ def api_generate_palette(): else: # fastoctree (default) palette = _pillow_quantize(rgb, n_colors, Image.Quantize.FASTOCTREE) except Exception as e: - return jsonify({"error": f"Quantization failed: {e}"}), 500 + return _make_error(f"Quantization failed: {e}", 500) return jsonify({"palette": palette, "count": len(palette)}) @@ -456,16 +469,16 @@ def api_apply_palette(): export_scale = int(request.form.get("scale", 8)) if not image_b64 or not palette_json: - return jsonify({"error": "Missing image or palette"}), 400 + return _make_error("Missing image or palette") try: - rgb = b64_to_rgb(image_b64) + rgb = _decode_b64_image(image_b64) palette = json.loads(palette_json) except Exception as e: - return jsonify({"error": f"Invalid input: {e}"}), 400 + return _make_error(f"Invalid input: {e}") if not palette: - return jsonify({"error": "Empty palette"}), 400 + return _make_error("Empty palette") if mode == "swap": result = apply_palette_swap(rgb, palette) @@ -491,7 +504,7 @@ def api_apply_palette(): def api_parse_palette(): """Parse an uploaded palette file (.act, .gpl, .pal, .png).""" if "file" not in request.files: - return jsonify({"error": "No file uploaded"}), 400 + return _make_error("No file uploaded") f = request.files["file"] filename = f.filename.lower() @@ -513,12 +526,12 @@ def api_parse_palette(): rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) palette = parse_png_palette(rgb) else: - return jsonify({"error": "Unsupported file format"}), 400 + return _make_error("Unsupported file format") except Exception as e: - return jsonify({"error": f"Parse error: {e}"}), 400 + return _make_error(f"Parse error: {e}") if not palette: - return jsonify({"error": "No colors found in file"}), 400 + return _make_error("No colors found in file") return jsonify({"palette": palette, "name": name}) @@ -531,12 +544,12 @@ def api_export_palette(): name = request.form.get("name", "Custom Palette") if not palette_json: - return jsonify({"error": "No palette data"}), 400 + return _make_error("No palette data") try: palette = json.loads(palette_json) except Exception: - return jsonify({"error": "Invalid palette JSON"}), 400 + return _make_error("Invalid palette JSON") if fmt == "act": data = export_act(palette) diff --git a/web_ui.html b/web_ui.html index 29eebd3..12ceed8 100644 --- a/web_ui.html +++ b/web_ui.html @@ -4,24 +4,10 @@ Perfect Pixel — 本地版 +