|
| 1 | +'use strict'; |
| 2 | +const { Document } = require('/document'); |
| 3 | +const { Dialog, DialogResult } = require('/dialog'); |
| 4 | +const { UnitType } = require('/units'); |
| 5 | +const { DocumentCommand } = require('/commands'); |
| 6 | +const { CurveBuilder } = require('/geometry'); |
| 7 | +const { Selection } = require('/selections'); |
| 8 | + |
| 9 | +// ── State ───────────────────────────────────────────────────────────────────── |
| 10 | +let currentPieces = []; |
| 11 | +let config = { cols: 3, rows: 3, gap: 10 }; |
| 12 | + |
| 13 | +// ── Cleanup: remove slices from previous preview ────────────────────────────── |
| 14 | +function deletePieces() { |
| 15 | + const doc = Document.current; |
| 16 | + for (const p of currentPieces) { |
| 17 | + try { doc.executeCommand(DocumentCommand.createDeleteSelection(Selection.create(doc, p), false)); } |
| 18 | + catch (e) { /* already gone */ } |
| 19 | + } |
| 20 | + currentPieces = []; |
| 21 | +} |
| 22 | + |
| 23 | +// ── Core: knife-cut grid with equal-size pieces ─────────────────────────────── |
| 24 | +// |
| 25 | +// Equal-size fix: subtract all gap space first, then divide remaining area |
| 26 | +// evenly. Each divider center sits at: |
| 27 | +// midX = origBox.x + c * pieceW + (c − 0.5) * gap |
| 28 | +// so every column is exactly pieceW wide and every gap is exactly `gap` wide. |
| 29 | +// |
| 30 | +// Stability fix: null-guard after ConvertToCurves (node ref is replaced by |
| 31 | +// Affinity), try/catch around every knife call. |
| 32 | +function generateGrid(origNode, origBox) { |
| 33 | + const doc = Document.current; |
| 34 | + const { cols, rows, gap } = config; |
| 35 | + |
| 36 | + const pieceW = (origBox.width - (cols - 1) * gap) / cols; |
| 37 | + const pieceH = (origBox.height - (rows - 1) * gap) / rows; |
| 38 | + const half = gap / 2; |
| 39 | + |
| 40 | + // Duplicate hidden original, show & convert the duplicate |
| 41 | + const dupCmd = DocumentCommand.createTransform( |
| 42 | + origNode.selfSelection, null, { duplicateNodes: true }); |
| 43 | + doc.executeCommand(dupCmd); |
| 44 | + |
| 45 | + if (!dupCmd.newNodes || dupCmd.newNodes.length === 0) { |
| 46 | + throw new Error( |
| 47 | + 'Duplicate failed. Object must be a shape or image — not a group or text frame.'); |
| 48 | + } |
| 49 | + |
| 50 | + const dup = dupCmd.newNodes[0]; |
| 51 | + doc.executeCommand(DocumentCommand.createSetVisibility(dup.selfSelection, true)); |
| 52 | + doc.executeCommand(DocumentCommand.createConvertToCurves(Selection.create(doc, dup))); |
| 53 | + |
| 54 | + // createConvertToCurves replaces the node; read result from selection |
| 55 | + const selAfter = doc.selection.nodes; |
| 56 | + const converted = (selAfter && selAfter.length > 0) ? selAfter.first : null; |
| 57 | + if (!converted) { |
| 58 | + throw new Error( |
| 59 | + 'Convert to Curves produced no output. Try manually converting the object first.'); |
| 60 | + } |
| 61 | + |
| 62 | + let pieces = [converted]; |
| 63 | + |
| 64 | + function cutAll(line) { |
| 65 | + const next = []; |
| 66 | + for (const p of pieces) { |
| 67 | + try { |
| 68 | + const cmd = DocumentCommand.createKnifeCut(line, Selection.create(doc, p)); |
| 69 | + doc.executeCommand(cmd); |
| 70 | + next.push(...(cmd.newNodes && cmd.newNodes.length === 2 ? cmd.newNodes : [p])); |
| 71 | + } catch (e) { next.push(p); } |
| 72 | + } |
| 73 | + pieces = next; |
| 74 | + } |
| 75 | + |
| 76 | + function deleteStrips(lo, hi, axis) { |
| 77 | + const keep = [], del = []; |
| 78 | + for (const p of pieces) { |
| 79 | + try { |
| 80 | + const bb = p.baseBox; |
| 81 | + if (!bb) { keep.push(p); continue; } |
| 82 | + const mid = axis === 'x' ? bb.x + bb.width / 2 : bb.y + bb.height / 2; |
| 83 | + (mid > lo && mid < hi ? del : keep).push(p); |
| 84 | + } catch (e) { keep.push(p); } |
| 85 | + } |
| 86 | + for (const p of del) { |
| 87 | + try { doc.executeCommand(DocumentCommand.createDeleteSelection(Selection.create(doc, p), false)); } |
| 88 | + catch (e) { /* already gone */ } |
| 89 | + } |
| 90 | + pieces = keep; |
| 91 | + } |
| 92 | + |
| 93 | + // Horizontal dividers |
| 94 | + for (let r = 1; r < rows; r++) { |
| 95 | + const midY = origBox.y + r * pieceH + (r - 0.5) * gap; |
| 96 | + if (gap > 0) { |
| 97 | + cutAll(new CurveBuilder().beginXY(origBox.x - 200, midY - half).lineToXY(origBox.x + origBox.width + 200, midY - half).createCurve()); |
| 98 | + cutAll(new CurveBuilder().beginXY(origBox.x - 200, midY + half).lineToXY(origBox.x + origBox.width + 200, midY + half).createCurve()); |
| 99 | + deleteStrips(midY - half, midY + half, 'y'); |
| 100 | + } else { |
| 101 | + cutAll(new CurveBuilder().beginXY(origBox.x - 200, midY).lineToXY(origBox.x + origBox.width + 200, midY).createCurve()); |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + // Vertical dividers |
| 106 | + for (let c = 1; c < cols; c++) { |
| 107 | + const midX = origBox.x + c * pieceW + (c - 0.5) * gap; |
| 108 | + if (gap > 0) { |
| 109 | + cutAll(new CurveBuilder().beginXY(midX - half, origBox.y - 200).lineToXY(midX - half, origBox.y + origBox.height + 200).createCurve()); |
| 110 | + cutAll(new CurveBuilder().beginXY(midX + half, origBox.y - 200).lineToXY(midX + half, origBox.y + origBox.height + 200).createCurve()); |
| 111 | + deleteStrips(midX - half, midX + half, 'x'); |
| 112 | + } else { |
| 113 | + cutAll(new CurveBuilder().beginXY(midX, origBox.y - 200).lineToXY(midX, origBox.y + origBox.height + 200).createCurve()); |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + currentPieces = pieces; |
| 118 | +} |
| 119 | + |
| 120 | +// ── Main ────────────────────────────────────────────────────────────────────── |
| 121 | +function run() { |
| 122 | + const doc = Document.current; |
| 123 | + const sel = doc.selection; |
| 124 | + |
| 125 | + if (!sel || sel.length === 0) { |
| 126 | + const dlg = Dialog.create('Split to Grid'); |
| 127 | + dlg.addColumn().addGroup('').addStaticText('', |
| 128 | + 'No object selected. Please select one object first.').isFullWidth = true; |
| 129 | + dlg.show(); |
| 130 | + return; |
| 131 | + } |
| 132 | + |
| 133 | + const origNode = sel.nodes.first; |
| 134 | + |
| 135 | + let origBox; |
| 136 | + try { |
| 137 | + origBox = origNode.baseBox; |
| 138 | + if (!origBox || origBox.width <= 0 || origBox.height <= 0) throw new Error(); |
| 139 | + } catch (e) { |
| 140 | + const dlg = Dialog.create('Split to Grid'); |
| 141 | + dlg.addColumn().addGroup('').addStaticText('', |
| 142 | + 'Selected object has no valid dimensions.\n' + |
| 143 | + 'Please select a shape or image (not a group or text frame).').isFullWidth = true; |
| 144 | + dlg.show(); |
| 145 | + return; |
| 146 | + } |
| 147 | + |
| 148 | + // Hide original; only the live-preview slices will be visible |
| 149 | + doc.executeCommand(DocumentCommand.createSetVisibility(origNode.selfSelection, false)); |
| 150 | + |
| 151 | + // ── Build dialog once (RadialRepeat pattern) ────────────────────────────── |
| 152 | + const dialog = Dialog.create('Split to Grid'); |
| 153 | + const col = dialog.addColumn(); |
| 154 | + |
| 155 | + col.addGroup('Selected Object').addStaticText('', |
| 156 | + `"${origNode.description}" — ${Math.round(origBox.width)} × ${Math.round(origBox.height)} px` |
| 157 | + ).isFullWidth = true; |
| 158 | + |
| 159 | + const sg = col.addGroup('Grid Settings'); |
| 160 | + |
| 161 | + const colsCtrl = sg.addUnitValueEditor('Columns', UnitType.None, UnitType.None, config.cols, 1, 24); |
| 162 | + colsCtrl.showPopupSlider = true; |
| 163 | + colsCtrl.precision = 0; |
| 164 | + |
| 165 | + const rowsCtrl = sg.addUnitValueEditor('Rows', UnitType.None, UnitType.None, config.rows, 1, 24); |
| 166 | + rowsCtrl.showPopupSlider = true; |
| 167 | + rowsCtrl.precision = 0; |
| 168 | + |
| 169 | + const gapCtrl = sg.addUnitValueEditor('Gap', UnitType.Pixel, UnitType.Pixel, config.gap, 0, 200); |
| 170 | + gapCtrl.showPopupSlider = true; |
| 171 | + |
| 172 | + // Separator + Preview / Apply button set (same pattern as RadialRepeat) |
| 173 | + const sepGrp = col.addGroup(''); |
| 174 | + sepGrp.enableSeparator = true; |
| 175 | + const btns = sepGrp.addButtonSet('', ['Preview', 'Apply'], 0); |
| 176 | + |
| 177 | + // ── Initial preview (before first dialog.show) ──────────────────────────── |
| 178 | + try { |
| 179 | + generateGrid(origNode, origBox); |
| 180 | + } catch (e) { |
| 181 | + try { doc.executeCommand(DocumentCommand.createSetVisibility(origNode.selfSelection, true)); } catch (_) {} |
| 182 | + const errDlg = Dialog.create('Split to Grid – Error'); |
| 183 | + errDlg.addColumn().addGroup('').addStaticText('', |
| 184 | + `Could not split the object:\n${e.message || e}\n\n` + |
| 185 | + 'Tip: convert it to curves first via Layer › Convert to Curves.' |
| 186 | + ).isFullWidth = true; |
| 187 | + errDlg.show(); |
| 188 | + return; |
| 189 | + } |
| 190 | + |
| 191 | + // ── Dialog loop ─────────────────────────────────────────────────────────── |
| 192 | + // OK + Preview → delete old slices, regenerate, loop |
| 193 | + // OK + Apply → delete old slices, regenerate, delete hidden orig, done |
| 194 | + // Cancel → delete old slices, restore orig, done |
| 195 | + let running = true; |
| 196 | + while (running) { |
| 197 | + btns.selectedIndex = 0; // reset button highlight to "Preview" |
| 198 | + const result = dialog.show(); |
| 199 | + |
| 200 | + // Read current control values |
| 201 | + config.cols = Math.max(1, Math.round(colsCtrl.value)); |
| 202 | + config.rows = Math.max(1, Math.round(rowsCtrl.value)); |
| 203 | + config.gap = Math.max(0, gapCtrl.value); |
| 204 | + const mode = btns.selectedIndex; // 0 = Preview, 1 = Apply |
| 205 | + |
| 206 | + if (result.value === DialogResult.Ok.value) { |
| 207 | + deletePieces(); |
| 208 | + try { |
| 209 | + generateGrid(origNode, origBox); |
| 210 | + } catch (e) { |
| 211 | + try { doc.executeCommand(DocumentCommand.createSetVisibility(origNode.selfSelection, true)); } catch (_) {} |
| 212 | + const errDlg = Dialog.create('Split to Grid – Error'); |
| 213 | + errDlg.addColumn().addGroup('').addStaticText('', |
| 214 | + `Could not split the object:\n${e.message || e}` |
| 215 | + ).isFullWidth = true; |
| 216 | + errDlg.show(); |
| 217 | + running = false; |
| 218 | + return; |
| 219 | + } |
| 220 | + |
| 221 | + if (mode === 1) { |
| 222 | + // Apply: finalize — delete the hidden original, keep the slices |
| 223 | + try { |
| 224 | + doc.executeCommand( |
| 225 | + DocumentCommand.createDeleteSelection(origNode.selfSelection, false)); |
| 226 | + } catch (e) { /* already gone */ } |
| 227 | + running = false; |
| 228 | + } |
| 229 | + // mode === 0 (Preview): loop back, dialog re-opens automatically |
| 230 | + |
| 231 | + } else { |
| 232 | + // Cancel: discard slices, restore the original object |
| 233 | + deletePieces(); |
| 234 | + try { |
| 235 | + doc.executeCommand( |
| 236 | + DocumentCommand.createSetVisibility(origNode.selfSelection, true)); |
| 237 | + } catch (e) { /* already visible */ } |
| 238 | + running = false; |
| 239 | + } |
| 240 | + } |
| 241 | +} |
| 242 | + |
| 243 | +run(); |
0 commit comments