-
+
-
-
+
@@ -1026,3915 +406,36 @@
white-space:nowrap;
">
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
下载图片
+
下载图片
-
-
+
- ×
+ ×
-
-
-
+
+ style="font-size:14px; padding:10px 20px; font-weight:700;">⬇ 下载
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 — 本地版
+