diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..2acd3f9
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,27 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(wc -l scripts/*.js css/*.css)",
+ "Bash(npm install:*)",
+ "Bash(uvx blender-mcp:*)",
+ "Bash(uvx --version)",
+ "Bash(pip show:*)",
+ "Bash(uvx --from blender-mcp python -c \"import blender_mcp; print\\(blender_mcp.__file__\\)\")",
+ "Bash(uvx --from blender-mcp python -c ':*)",
+ "Bash(where uvx:*)",
+ "mcp__blender__execute_blender_code",
+ "WebSearch",
+ "WebFetch(domain:github.com)",
+ "WebFetch(domain:projects.blender.org)",
+ "Bash(gh api:*)",
+ "WebFetch(domain:raw.githubusercontent.com)",
+ "Bash(curl -s \"https://raw.githubusercontent.com/blender/blender/main/source/blender/editors/uvedit/uvedit_select.cc\")",
+ "Bash(git add:*)",
+ "Bash(git commit -m ':*)",
+ "Bash(git stash:*)",
+ "Bash(git checkout:*)",
+ "Bash(git log:*)",
+ "Bash(git branch:*)"
+ ]
+ }
+}
diff --git a/index.html b/index.html
index 39bc353..84ff1a2 100644
--- a/index.html
+++ b/index.html
@@ -69,6 +69,7 @@
+
diff --git a/scripts/leftPanelManager.js b/scripts/leftPanelManager.js
index a666e4c..f5e7081 100644
--- a/scripts/leftPanelManager.js
+++ b/scripts/leftPanelManager.js
@@ -5,9 +5,9 @@ const LeftPanelManager = {
const dirtyPolygons = new Set();
const container = document.getElementById(containerId);
- const stage = new Konva.Stage({
- container: containerId,
- width: container.clientWidth,
+ const stage = new Konva.Stage({
+ container: containerId,
+ width: container.clientWidth,
height: container.clientHeight
});
@@ -20,11 +20,46 @@ const LeftPanelManager = {
const bgImages = []; // Store multiple images
let selectedGroup = null;
let imagesLocked = false; // Track lock state
-
+
// Drawing mode state variables
let drawingMode = false;
let drawingModeHandlers = null;
+ // Helper to create a background image with undo support
+ function addBackgroundImage(img) {
+ const scale = Math.min(stage.width() / img.width, stage.height() / img.height);
+ const konvaImg = new Konva.Image({
+ x: (stage.width() - img.width * scale) / 2,
+ y: (stage.height() - img.height * scale) / 2,
+ image: img,
+ width: img.width * scale,
+ height: img.height * scale,
+ draggable: !imagesLocked
+ });
+
+ bgLayer.add(konvaImg);
+ bgImages.push(konvaImg);
+ bgLayer.batchDraw();
+
+ // Undo for image add
+ UndoManager.push({
+ undo: () => {
+ konvaImg.remove();
+ const idx = bgImages.indexOf(konvaImg);
+ if (idx > -1) bgImages.splice(idx, 1);
+ tr.nodes([]);
+ bgLayer.batchDraw();
+ },
+ redo: () => {
+ bgLayer.add(konvaImg);
+ bgImages.push(konvaImg);
+ bgLayer.batchDraw();
+ }
+ });
+
+ return konvaImg;
+ }
+
// Lock/Unlock Images button
const lockBtn = document.getElementById('lockImagesLeft');
lockBtn.addEventListener('click', () => {
@@ -36,7 +71,7 @@ const LeftPanelManager = {
bgImages.forEach(img => {
img.draggable(!imagesLocked);
});
-
+
// Also update transformer state
if (imagesLocked && tr.nodes().length > 0) {
const selectedImage = tr.nodes()[0];
@@ -44,7 +79,7 @@ const LeftPanelManager = {
tr.nodes([]); // Deselect any selected image when locking
}
}
-
+
bgLayer.batchDraw();
});
@@ -52,36 +87,21 @@ const LeftPanelManager = {
document.addEventListener('paste', (e) => {
// Don't handle paste events if we're in drawing mode
if (drawingMode) return;
-
+
// Check if we're pasting image data
const items = e.clipboardData.items;
let imageFound = false;
-
+
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
imageFound = true;
const blob = items[i].getAsFile();
const reader = new FileReader();
-
+
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
- const scale = Math.min(stage.width() / img.width, stage.height() / img.height);
-
- const konvaImg = new Konva.Image({
- x: (stage.width() - img.width * scale) / 2,
- y: (stage.height() - img.height * scale) / 2,
- image: img,
- width: img.width * scale,
- height: img.height * scale,
- draggable: !imagesLocked
- });
-
- bgLayer.add(konvaImg);
- bgImages.push(konvaImg);
- bgLayer.batchDraw();
-
- // Show feedback
+ addBackgroundImage(img);
FeedbackManager.show('Image pasted successfully!');
};
img.src = evt.target.result;
@@ -90,7 +110,7 @@ const LeftPanelManager = {
break; // Only handle the first image
}
}
-
+
if (!imageFound) {
FeedbackManager.show('No image found in clipboard');
}
@@ -101,17 +121,34 @@ const LeftPanelManager = {
drawingMode = !drawingMode;
const button = document.getElementById('toggleDrawingMode');
button.classList.toggle('drawing-active');
-
+
if (drawingMode) {
button.textContent = 'Exit Drawing Mode';
// Initialize drawing mode with callback for when polygon is created
drawingModeHandlers = PolygonManager.initDrawingMode(
- stage,
- polygonLayer,
+ stage,
+ polygonLayer,
() => drawingMode,
(newPolygon) => {
- // Set the newly created polygon as selected
selectedGroup = newPolygon;
+ // Guard against duplicate callback (initDrawingMode fires twice)
+ if (!newPolygon._undoPushed) {
+ newPolygon._undoPushed = true;
+ UndoManager.push({
+ undo: () => {
+ newPolygon.remove();
+ dirtyPolygons.delete(newPolygon._id);
+ if (selectedGroup === newPolygon) selectedGroup = null;
+ polygonLayer.batchDraw();
+ },
+ redo: () => {
+ polygonLayer.add(newPolygon);
+ dirtyPolygons.add(newPolygon._id);
+ selectedGroup = newPolygon;
+ polygonLayer.batchDraw();
+ }
+ });
+ }
},
dirtyPolygons
);
@@ -166,27 +203,14 @@ const LeftPanelManager = {
// Upload handler
document.getElementById(uploadId).addEventListener('change', e => {
- const file = e.target.files[0];
+ const file = e.target.files[0];
if (!file) return;
-
+
const reader = new FileReader();
reader.onload = evt => {
const img = new Image();
img.onload = () => {
- const scale = Math.min(stage.width() / img.width, stage.height() / img.height);
-
- const konvaImg = new Konva.Image({
- x: (stage.width() - img.width * scale) / 2,
- y: (stage.height() - img.height * scale) / 2,
- image: img,
- width: img.width * scale,
- height: img.height * scale,
- draggable: !imagesLocked // Set initial draggable state based on lock status
- });
-
- bgLayer.add(konvaImg);
- bgImages.push(konvaImg);
- bgLayer.batchDraw();
+ addBackgroundImage(img);
};
img.src = evt.target.result;
};
@@ -197,13 +221,25 @@ const LeftPanelManager = {
const dragDropHandler = DragDropManager.init(
container,
(files) => {
- DragDropManager.handleImageFiles(
- files,
- stage,
- bgLayer,
- bgImages,
- imagesLocked
- );
+ let loaded = 0;
+ const total = files.length;
+ UndoManager.beginBatch();
+ files.forEach(file => {
+ const reader = new FileReader();
+ reader.onload = evt => {
+ const img = new Image();
+ img.onload = () => {
+ addBackgroundImage(img);
+ loaded++;
+ if (loaded === total) {
+ UndoManager.endBatch();
+ FeedbackManager.show(`${total} image(s) dropped`);
+ }
+ };
+ img.src = evt.target.result;
+ };
+ reader.readAsDataURL(file);
+ });
},
{
showOverlay: true,
@@ -215,6 +251,20 @@ const LeftPanelManager = {
document.getElementById(addBtnId).addEventListener('click', () => {
const newGroup = PolygonManager.createPolygonGroup(stage, polygonLayer, null, dirtyPolygons);
setSelectedPolygon(newGroup);
+ UndoManager.push({
+ undo: () => {
+ newGroup.remove();
+ dirtyPolygons.delete(newGroup._id);
+ if (selectedGroup === newGroup) selectedGroup = null;
+ polygonLayer.batchDraw();
+ },
+ redo: () => {
+ polygonLayer.add(newGroup);
+ dirtyPolygons.add(newGroup._id);
+ setSelectedPolygon(newGroup);
+ polygonLayer.batchDraw();
+ }
+ });
});
// Delete button
@@ -243,10 +293,10 @@ const LeftPanelManager = {
prevPolygon.strokeWidth(CONFIG.POLYGON.STROKE_WIDTH);
}
}
-
+
// Clear image selection
tr.nodes([]);
-
+
// Set new selection
selectedGroup = group;
if (selectedGroup) {
@@ -256,36 +306,80 @@ const LeftPanelManager = {
polygon.strokeWidth(CONFIG.POLYGON.SELECTED_STROKE_WIDTH);
}
}
-
+
polygonLayer.draw();
}
- // Helper function for deleting selected objects
+ // Helper function for deleting selected objects (with undo)
function deleteSelectedObjects() {
// Case 1: polygon selected
if (selectedGroup) {
- if (window.rightPanel) window.rightPanel.removeTexture(selectedGroup._id);
- selectedGroup.destroy();
+ const groupToDelete = selectedGroup;
+ const groupId = groupToDelete._id;
+ let detachedTexture = null;
+ if (window.rightPanel && window.rightPanel.detachTexture) {
+ detachedTexture = window.rightPanel.detachTexture(groupId);
+ }
+ groupToDelete.remove();
+ dirtyPolygons.delete(groupId);
selectedGroup = null;
polygonLayer.draw();
+
+ UndoManager.push({
+ undo: () => {
+ polygonLayer.add(groupToDelete);
+ dirtyPolygons.add(groupId);
+ if (detachedTexture && window.rightPanel) {
+ window.rightPanel.restoreTexture(groupId, detachedTexture);
+ }
+ polygonLayer.batchDraw();
+ },
+ redo: () => {
+ groupToDelete.remove();
+ dirtyPolygons.delete(groupId);
+ if (detachedTexture && window.rightPanel) {
+ window.rightPanel.detachTexture(groupId);
+ }
+ selectedGroup = null;
+ polygonLayer.batchDraw();
+ }
+ });
return;
}
// Case 2: background image selected with transformer
const selectedNodes = tr.nodes();
if (selectedNodes.length > 0) {
- selectedNodes.forEach(node => {
- if (node instanceof Konva.Image) {
- node.destroy();
- // Remove from bgImages array
- const index = bgImages.indexOf(node);
- if (index > -1) {
- bgImages.splice(index, 1);
- }
- }
+ const imagesToDelete = selectedNodes.filter(node => node instanceof Konva.Image);
+ if (imagesToDelete.length === 0) return;
+
+ imagesToDelete.forEach(node => {
+ node.remove();
+ const index = bgImages.indexOf(node);
+ if (index > -1) bgImages.splice(index, 1);
});
- tr.nodes([]); // clear transformer
+ tr.nodes([]);
bgLayer.draw();
+
+ UndoManager.push({
+ undo: () => {
+ imagesToDelete.forEach(node => {
+ bgLayer.add(node);
+ bgImages.push(node);
+ });
+ tr.nodes([]);
+ bgLayer.batchDraw();
+ },
+ redo: () => {
+ imagesToDelete.forEach(node => {
+ node.remove();
+ const index = bgImages.indexOf(node);
+ if (index > -1) bgImages.splice(index, 1);
+ });
+ tr.nodes([]);
+ bgLayer.batchDraw();
+ }
+ });
}
}
@@ -300,11 +394,76 @@ const LeftPanelManager = {
});
uiLayer.add(tr);
+ // Image drag undo (stage-level events)
+ let imgDragStartPos = null;
+
+ stage.on('dragstart', (e) => {
+ if (!(e.target instanceof Konva.Image)) return;
+ imgDragStartPos = { x: e.target.x(), y: e.target.y() };
+ });
+ stage.on('dragend', (e) => {
+ if (!(e.target instanceof Konva.Image) || !imgDragStartPos) return;
+ const img = e.target;
+ const start = { ...imgDragStartPos };
+ const end = { x: img.x(), y: img.y() };
+ imgDragStartPos = null;
+ if (start.x === end.x && start.y === end.y) return;
+ UndoManager.push({
+ undo: () => { img.position(start); tr.forceUpdate(); stage.batchDraw(); },
+ redo: () => { img.position(end); tr.forceUpdate(); stage.batchDraw(); }
+ });
+ });
+
+ // Transformer undo/redo for background images
+ let trStartState = null;
+ tr.on('transformstart', () => {
+ const nodes = tr.nodes();
+ trStartState = nodes.map(node => ({
+ node, x: node.x(), y: node.y(),
+ scaleX: node.scaleX(), scaleY: node.scaleY(),
+ rotation: node.rotation(),
+ width: node.width(), height: node.height()
+ }));
+ });
+ tr.on('transformend', () => {
+ if (!trStartState) return;
+ const beforeStates = trStartState;
+ const afterStates = beforeStates.map(s => ({
+ node: s.node, x: s.node.x(), y: s.node.y(),
+ scaleX: s.node.scaleX(), scaleY: s.node.scaleY(),
+ rotation: s.node.rotation(),
+ width: s.node.width(), height: s.node.height()
+ }));
+ trStartState = null;
+ UndoManager.push({
+ undo: () => {
+ beforeStates.forEach(s => {
+ s.node.position({ x: s.x, y: s.y });
+ s.node.scale({ x: s.scaleX, y: s.scaleY });
+ s.node.rotation(s.rotation);
+ s.node.size({ width: s.width, height: s.height });
+ });
+ tr.forceUpdate();
+ stage.batchDraw();
+ },
+ redo: () => {
+ afterStates.forEach(s => {
+ s.node.position({ x: s.x, y: s.y });
+ s.node.scale({ x: s.scaleX, y: s.scaleY });
+ s.node.rotation(s.rotation);
+ s.node.size({ width: s.width, height: s.height });
+ });
+ tr.forceUpdate();
+ stage.batchDraw();
+ }
+ });
+ });
+
// Click to select background image or polygon
stage.on('click', (e) => {
// Don't process clicks if we're in drawing mode
if (drawingMode) return;
-
+
// Reset previous selection visual for polygons
if (selectedGroup) {
const polygon = selectedGroup.findOne('.polygon');
@@ -313,19 +472,19 @@ const LeftPanelManager = {
polygon.strokeWidth(CONFIG.POLYGON.STROKE_WIDTH);
}
}
-
+
// Reset selection
let selectedImage = null;
selectedGroup = null;
tr.nodes([]);
-
+
// Check what was clicked
const clickedNode = e.target;
-
+
// Handle polygon selection
if (clickedNode instanceof Konva.Group && clickedNode.name() === 'group') {
setSelectedPolygon(clickedNode);
- }
+ }
// Handle polygon parts (vertices, midpoints, edges, drag surface)
else if (clickedNode.getParent() instanceof Konva.Group && clickedNode.getParent().name() === 'group') {
setSelectedPolygon(clickedNode.getParent());
@@ -340,7 +499,7 @@ const LeftPanelManager = {
tr.nodes([]);
selectedGroup = null;
}
-
+
bgLayer.batchDraw();
polygonLayer.batchDraw();
});
@@ -349,7 +508,7 @@ const LeftPanelManager = {
stage.on('keydown', (e) => {
if (e.key === 'Shift') tr.keepRatio(true);
});
-
+
stage.on('keyup', (e) => {
if (e.key === 'Shift') tr.keepRatio(false);
});
@@ -360,4 +519,4 @@ const LeftPanelManager = {
return stage;
}
-};
\ No newline at end of file
+};
diff --git a/scripts/polygonManager.js b/scripts/polygonManager.js
index aa56e29..a05c1d5 100644
--- a/scripts/polygonManager.js
+++ b/scripts/polygonManager.js
@@ -1,32 +1,33 @@
// ==================== POLYGON MANAGEMENT ====================
const PolygonManager = {
// Unified polygon creation function
- createPolygonGroup: (stage, layer, points = null, dirtyPolygons = null) => {
- const group = new Konva.Group({
- draggable: true,
+ createPolygonGroup: (stage, layer, points = null, dirtyPolygons = null, skipReorder = false) => {
+ const group = new Konva.Group({
+ draggable: true,
name: 'group',
_id: Utils.generateId()
});
if (group && group._id)
dirtyPolygons.add(group._id);
-
+
// Get vertices - either from provided points or create default rectangle
let vertices;
if (points && points.length === 4) {
// REORDER vertices to ensure consistent order for both polygon types
- vertices = Utils.reorderPolygonVertices(points);
+ // Skip reorder when restoring from undo (vertices already in correct order)
+ vertices = skipReorder ? points.map(p => ({ x: p.x, y: p.y })) : Utils.reorderPolygonVertices(points);
} else {
// Create default rectangle centered on stage
const stageCenterX = stage.width() / 2;
const stageCenterY = stage.height() / 2;
-
+
// Convert stage center to absolute coordinates
const absoluteCenter = {
x: (stageCenterX - stage.x()) / stage.scaleX(),
y: (stageCenterY - stage.y()) / stage.scaleY()
};
-
+
// Create rectangle centered on the stage
const rectSize = 100;
vertices = [
@@ -36,7 +37,7 @@ const PolygonManager = {
{ x: absoluteCenter.x + rectSize/2, y: absoluteCenter.y - rectSize/2 } // Top-right (4)
];
}
-
+
// Create midpoints for each edge
const midpoints = [];
for (let i = 0; i < vertices.length; i++) {
@@ -48,32 +49,7 @@ const PolygonManager = {
});
}
- // Create a more accurate drag surface that follows the curved edges
- const createCurvedDragSurface = (vertices, midpoints) => {
- const points = [];
-
- for (let i = 0; i < vertices.length; i++) {
- const nextIdx = (i + 1) % vertices.length;
- const P0 = vertices[i];
- const P2 = vertices[nextIdx];
- const M = midpoints[i]; // single control point
-
- const numSamples = 10; // resolution
- for (let j = 0; j <= numSamples; j++) {
- const t = j / numSamples;
- const mt = 1 - t;
-
- const x = mt*mt*P0.x + 2*mt*t*M.x + t*t*P2.x;
- const y = mt*mt*P0.y + 2*mt*t*M.y + t*t*P2.y;
-
- points.push(x, y);
- }
- }
-
- return points;
- };
-
- const dragSurfacePoints = createCurvedDragSurface(vertices, midpoints);
+ const dragSurfacePoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints);
const dragSurface = new Konva.Line({
points: dragSurfacePoints,
closed: true,
@@ -82,16 +58,41 @@ const PolygonManager = {
name: 'drag-surface',
listening: true // Ensure it listens to events
});
-
+
group.add(dragSurface);
dragSurface.moveToBottom();
-
+
group.on("dragmove", () => {
if (dirtyPolygons && group && group._id) {
dirtyPolygons.add(group._id);
}
});
+ // Group drag undo
+ let groupDragStart = null;
+ group.on('dragstart', () => {
+ groupDragStart = { x: group.x(), y: group.y() };
+ });
+ group.on('dragend', () => {
+ if (!groupDragStart) return;
+ const startPos = { ...groupDragStart };
+ const endPos = { x: group.x(), y: group.y() };
+ groupDragStart = null;
+ if (startPos.x === endPos.x && startPos.y === endPos.y) return;
+ UndoManager.push({
+ undo: () => {
+ group.position(startPos);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ if (group.getLayer()) group.getLayer().batchDraw();
+ },
+ redo: () => {
+ group.position(endPos);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ if (group.getLayer()) group.getLayer().batchDraw();
+ }
+ });
+ });
+
// Draw the curved polygon
PolygonManager.drawCurvedPolygon(group, vertices, midpoints);
GridManager.drawGrid(group, vertices, midpoints);
@@ -136,6 +137,28 @@ const PolygonManager = {
name: 'vertex-label'
});
+ // Vertex drag undo
+ let vertexDragStartState = null;
+ vertex.on('dragstart', () => {
+ vertexDragStartState = PolygonManager.snapshotPolygonState(group);
+ });
+ vertex.on('dragend', () => {
+ if (!vertexDragStartState) return;
+ const before = vertexDragStartState;
+ const after = PolygonManager.snapshotPolygonState(group);
+ vertexDragStartState = null;
+ UndoManager.push({
+ undo: () => {
+ PolygonManager.restorePolygonState(group, before);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ },
+ redo: () => {
+ PolygonManager.restorePolygonState(group, after);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ }
+ });
+ });
+
vertex.on('dragmove', () => {
// Update vertex position
vertices[i] = { x: vertex.x(), y: vertex.y() };
@@ -144,11 +167,11 @@ const PolygonManager = {
// Update label position when vertex moves
label.position({ x: vertex.x(), y: vertex.y() });
-
+
// Update adjacent midpoints to maintain relative position
const prevIdx = (i + vertices.length - 1) % vertices.length;
const nextIdx = (i + 1) % vertices.length;
-
+
// Update previous edge midpoint if not locked
if (!midpoints[prevIdx].locked) {
midpoints[prevIdx].x = (vertices[prevIdx].x + vertices[i].x) / 2;
@@ -160,18 +183,18 @@ const PolygonManager = {
midpoints[i].x = (vertices[i].x + vertices[nextIdx].x) / 2;
midpoints[i].y = (vertices[i].y + vertices[nextIdx].y) / 2;
}
-
+
// Update polygon and grid
PolygonManager.drawCurvedPolygon(group, vertices, midpoints);
GridManager.drawGrid(group, vertices, midpoints);
-
+
// Update midpoint visual positions
group.find('.midpoint').forEach((midpoint, idx) => {
midpoint.position(midpoints[idx]);
});
-
+
// Update drag surface with curved edges
- const updatedPoints = createCurvedDragSurface(vertices, midpoints);
+ const updatedPoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints);
PolygonManager.updateDragSurface(group, updatedPoints);
// Update reference circles
@@ -238,7 +261,29 @@ const PolygonManager = {
context.fillStrokeShape(this);
}
});
-
+
+ // Midpoint drag undo
+ let midpointDragStartState = null;
+ midpoint.on('dragstart', () => {
+ midpointDragStartState = PolygonManager.snapshotPolygonState(group);
+ });
+ midpoint.on('dragend', () => {
+ if (!midpointDragStartState) return;
+ const before = midpointDragStartState;
+ const after = PolygonManager.snapshotPolygonState(group);
+ midpointDragStartState = null;
+ UndoManager.push({
+ undo: () => {
+ PolygonManager.restorePolygonState(group, before);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ },
+ redo: () => {
+ PolygonManager.restorePolygonState(group, after);
+ if (dirtyPolygons && group._id) dirtyPolygons.add(group._id);
+ }
+ });
+ });
+
midpoint.on('dragmove', () => {
// If this is the first manual drag, lock it
if (!midpoints[i].locked) midpoints[i].locked = true;
@@ -249,31 +294,31 @@ const PolygonManager = {
// Mark polygon as dirty
if (group && group._id) dirtyPolygons.add(group._id);
-
+
// Update polygon and grid
PolygonManager.drawCurvedPolygon(group, vertices, midpoints);
GridManager.drawGrid(group, vertices, midpoints);
-
+
// Update drag surface with curved edges
- const updatedPoints = createCurvedDragSurface(vertices, midpoints);
+ const updatedPoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints);
PolygonManager.updateDragSurface(group, updatedPoints);
});
-
+
// Prevent click events from bubbling to group
midpoint.on('click', (e) => {
e.cancelBubble = true;
});
-
+
group.add(midpoint);
});
GridManager.drawGrid(group, vertices, midpoints);
-
+
// Add to layer if provided
if (layer) {
layer.add(group);
}
-
+
return group;
},
@@ -325,6 +370,76 @@ const PolygonManager = {
group.add(polygon);
},
+ // Compute curved drag surface points (quadratic Bezier sampling)
+ computeDragSurfacePoints: (vertices, midpoints) => {
+ const points = [];
+ for (let i = 0; i < vertices.length; i++) {
+ const nextIdx = (i + 1) % vertices.length;
+ const P0 = vertices[i];
+ const P2 = vertices[nextIdx];
+ const M = midpoints[i];
+ const numSamples = 10;
+ for (let j = 0; j <= numSamples; j++) {
+ const t = j / numSamples;
+ const mt = 1 - t;
+ const x = mt*mt*P0.x + 2*mt*t*M.x + t*t*P2.x;
+ const y = mt*mt*P0.y + 2*mt*t*M.y + t*t*P2.y;
+ points.push(x, y);
+ }
+ }
+ return points;
+ },
+
+ // Snapshot polygon state for undo/redo
+ snapshotPolygonState: (group) => {
+ return {
+ groupX: group.x(),
+ groupY: group.y(),
+ vertices: group.vertices.map(v => ({ x: v.x, y: v.y })),
+ midpoints: group.midpoints.map(m => ({ x: m.x, y: m.y, locked: m.locked }))
+ };
+ },
+
+ // Restore polygon state from snapshot
+ restorePolygonState: (group, state) => {
+ group.x(state.groupX);
+ group.y(state.groupY);
+
+ for (let i = 0; i < state.vertices.length; i++) {
+ group.vertices[i].x = state.vertices[i].x;
+ group.vertices[i].y = state.vertices[i].y;
+ }
+ for (let i = 0; i < state.midpoints.length; i++) {
+ group.midpoints[i].x = state.midpoints[i].x;
+ group.midpoints[i].y = state.midpoints[i].y;
+ group.midpoints[i].locked = state.midpoints[i].locked;
+ }
+
+ group.find('.vertex').forEach((v, i) => {
+ v.position({ x: group.vertices[i].x, y: group.vertices[i].y });
+ });
+ group.find('.vertex-label').forEach((l, i) => {
+ l.position({ x: group.vertices[i].x, y: group.vertices[i].y });
+ });
+ group.find('.midpoint').forEach((m, i) => {
+ m.position({ x: group.midpoints[i].x, y: group.midpoints[i].y });
+ });
+
+ group.referencePoints.forEach((ref, i) => {
+ const v1 = group.vertices[i];
+ const v2 = group.vertices[(i + 1) % group.vertices.length];
+ ref.position({ x: (v1.x + v2.x) / 2, y: (v1.y + v2.y) / 2 });
+ });
+
+ PolygonManager.drawCurvedPolygon(group, group.vertices, group.midpoints);
+ GridManager.drawGrid(group, group.vertices, group.midpoints);
+
+ const pts = PolygonManager.computeDragSurfacePoints(group.vertices, group.midpoints);
+ PolygonManager.updateDragSurface(group, pts);
+
+ if (group.getLayer()) group.getLayer().batchDraw();
+ },
+
// Helper to update drag surface
updateDragSurface: (group, points) => {
const dragSurface = group.findOne('.drag-surface');
@@ -360,18 +475,18 @@ const PolygonManager = {
initDrawingMode: (stage, polygonLayer, getDrawingMode, onPolygonCreated, dirtyPolygons) => {
const drawingGroup = new Konva.Group();
polygonLayer.add(drawingGroup);
-
+
let tempPoints = [];
let tempLines = [];
let tempVertices = [];
-
+
function clearTempElements() {
tempLines.forEach(line => line.destroy());
tempVertices.forEach(vertex => vertex.destroy());
tempLines = [];
tempVertices = [];
}
-
+
function createDrawingVertex(x, y, isTemp = false) {
const vertex = new Konva.Circle({
x, y,
@@ -390,7 +505,7 @@ const PolygonManager = {
context.fillStrokeShape(this);
}
});
-
+
vertex.on('dragmove', () => {
if (isTemp) {
// Update the last temporary point position
@@ -400,10 +515,10 @@ const PolygonManager = {
}
}
});
-
+
return vertex;
}
-
+
function updateTempLine() {
if (tempLines.length > 0 && tempPoints.length >= 2) {
const lastLine = tempLines[tempLines.length - 1];
@@ -414,12 +529,12 @@ const PolygonManager = {
lastLine.points(points);
}
}
-
+
function completePolygon() {
if (tempPoints.length === 4) {
// REORDER vertices to match regular polygon order
const reorderedPoints = Utils.reorderPolygonVertices(tempPoints);
-
+
// Create new edges between the reordered points
const updatedPoints = [];
for (let i = 0; i < 4; i++) {
@@ -432,17 +547,17 @@ const PolygonManager = {
// Connect last point back to first
updatedPoints.push(reorderedPoints[3]);
updatedPoints.push(reorderedPoints[0]);
-
+
const polygonGroup = PolygonManager.createPolygonGroup(stage, polygonLayer, reorderedPoints, dirtyPolygons);
-
+
// Clear temporary elements
clearTempElements();
tempPoints = [];
-
+
if (typeof onPolygonCreated === 'function') {
onPolygonCreated(polygonGroup);
}
-
+
return polygonGroup;
}
return null;
@@ -455,21 +570,21 @@ const PolygonManager = {
onPolygonCreated(polygonGroup);
}
}
-
+
// Event handlers
const clickHandler = (e) => {
// Don't process clicks if panning is active
if (PanZoomManager.isPanning) return;
-
+
// Only respond to left mouse button (button 0)
if (e.evt.button !== 0) return;
-
+
if (!getDrawingMode()) return;
-
+
// Don't process clicks on existing polygons or their parts
let node = e.target;
let clickedOnExistingPolygon = false;
-
+
while (node && node !== stage) {
if (node instanceof Konva.Group && node.name() === 'group') {
clickedOnExistingPolygon = true;
@@ -477,25 +592,25 @@ const PolygonManager = {
}
node = node.getParent();
}
-
+
if (clickedOnExistingPolygon) return;
-
+
// Allow clicking on any part of the stage, not just empty space
const pos = stage.getPointerPosition();
-
+
// Convert to stage coordinates (accounting for pan/zoom)
const stageX = (pos.x - stage.x()) / stage.scaleX();
const stageY = (pos.y - stage.y()) / stage.scaleY();
-
+
if (tempPoints.length < 4) {
// Add point
tempPoints.push({ x: stageX, y: stageY });
-
+
// Create vertex
const vertex = createDrawingVertex(stageX, stageY, tempPoints.length < 4);
drawingGroup.add(vertex);
tempVertices.push(vertex);
-
+
// Create line if we have at least 2 points
if (tempPoints.length >= 2) {
const line = new Konva.Line({
@@ -511,7 +626,7 @@ const PolygonManager = {
drawingGroup.add(line);
tempLines.push(line);
}
-
+
// If we have 4 points, complete the polygon
if (tempPoints.length === 4) {
const completedPolygon = completePolygon();
@@ -528,15 +643,15 @@ const PolygonManager = {
const mouseMoveHandler = () => {
// Don't process mouse movement if panning is active
if (PanZoomManager.isPanning) return;
-
+
if (!getDrawingMode() || tempPoints.length === 4) return;
-
+
const pos = stage.getPointerPosition();
-
+
// Convert to stage coordinates (accounting for pan/zoom)
const stageX = (pos.x - stage.x()) / stage.scaleX();
const stageY = (pos.y - stage.y()) / stage.scaleY();
-
+
// Update or create temporary vertex
if (tempVertices.length > tempPoints.length) {
// Update existing temp vertex
@@ -550,7 +665,7 @@ const PolygonManager = {
updateTempLine();
}
};
-
+
const contextMenuHandler = (e) => {
// Use the passed function to get drawing mode state
if (getDrawingMode()) {
@@ -558,12 +673,12 @@ const PolygonManager = {
clearTempElements();
}
};
-
+
// Register event listeners
stage.on('click', clickHandler);
stage.on('mousemove', mouseMoveHandler);
stage.on('contextmenu', contextMenuHandler);
-
+
// Return cleanup function
return {
clearTempElements: () => {
@@ -578,4 +693,4 @@ const PolygonManager = {
}
};
}
-};
\ No newline at end of file
+};
diff --git a/scripts/rightPanelManager.js b/scripts/rightPanelManager.js
index d9c8cff..ebc48e8 100644
--- a/scripts/rightPanelManager.js
+++ b/scripts/rightPanelManager.js
@@ -34,7 +34,7 @@ const RightPanelManager = {
listening: false,
name: 'bgRect'
});
-
+
bgLayer.add(bgRect);
stage.bgRect = bgRect;
@@ -76,7 +76,56 @@ const RightPanelManager = {
// Transformer events (for resizing/rotating)
tr.on('transform', snapping.handleResizing);
tr.on('transformend', snapping.handleResizeEnd);
-
+
+ // Transformer undo/redo
+ let trStartState = null;
+ tr.on('transformstart', () => {
+ const nodes = tr.nodes();
+ trStartState = nodes.map(node => ({
+ node, x: node.x(), y: node.y(),
+ scaleX: node.scaleX(), scaleY: node.scaleY(),
+ rotation: node.rotation(),
+ width: node.width(), height: node.height(),
+ offsetX: node.offsetX(), offsetY: node.offsetY()
+ }));
+ });
+ tr.on('transformend', () => {
+ if (!trStartState) return;
+ const beforeStates = trStartState;
+ const afterStates = beforeStates.map(s => ({
+ node: s.node, x: s.node.x(), y: s.node.y(),
+ scaleX: s.node.scaleX(), scaleY: s.node.scaleY(),
+ rotation: s.node.rotation(),
+ width: s.node.width(), height: s.node.height(),
+ offsetX: s.node.offsetX(), offsetY: s.node.offsetY()
+ }));
+ trStartState = null;
+ UndoManager.push({
+ undo: () => {
+ beforeStates.forEach(s => {
+ s.node.position({ x: s.x, y: s.y });
+ s.node.scale({ x: s.scaleX, y: s.scaleY });
+ s.node.rotation(s.rotation);
+ s.node.size({ width: s.width, height: s.height });
+ s.node.offset({ x: s.offsetX, y: s.offsetY });
+ });
+ tr.forceUpdate();
+ stage.batchDraw();
+ },
+ redo: () => {
+ afterStates.forEach(s => {
+ s.node.position({ x: s.x, y: s.y });
+ s.node.scale({ x: s.scaleX, y: s.scaleY });
+ s.node.rotation(s.rotation);
+ s.node.size({ width: s.width, height: s.height });
+ s.node.offset({ x: s.offsetX, y: s.offsetY });
+ });
+ tr.forceUpdate();
+ stage.batchDraw();
+ }
+ });
+ });
+
uiLayer.add(tr);
// Selection rectangle
@@ -85,7 +134,7 @@ const RightPanelManager = {
visible: false,
listening: false // don't interfere with other mouse events
});
-
+
uiLayer.add(selectionRectangle);
// Initialize tiedRects object
@@ -198,23 +247,58 @@ const RightPanelManager = {
// Flip X
menu.appendChild(createMenuItem('Flip X', () => {
+ const before = {
+ x: target.x(), offsetX: target.offsetX(), scaleX: target.scaleX()
+ };
if (target.offsetX() !== target.width() / 2) {
- // Move node to keep center position consistent
target.offsetX(target.width() / 2);
target.x(target.x() + target.width() / 2);
}
target.scaleX(-target.scaleX());
target.getLayer().batchDraw();
+ const after = {
+ x: target.x(), offsetX: target.offsetX(), scaleX: target.scaleX()
+ };
+ UndoManager.push({
+ undo: () => {
+ target.x(before.x); target.offsetX(before.offsetX);
+ target.scaleX(before.scaleX);
+ target.getLayer().batchDraw();
+ },
+ redo: () => {
+ target.x(after.x); target.offsetX(after.offsetX);
+ target.scaleX(after.scaleX);
+ target.getLayer().batchDraw();
+ }
+ });
}));
// Flip Y
menu.appendChild(createMenuItem('Flip Y', () => {
+ const before = {
+ y: target.y(), offsetY: target.offsetY(), scaleY: target.scaleY()
+ };
if (target.offsetY() !== target.height() / 2) {
target.offsetY(target.height() / 2);
target.y(target.y() + target.height() / 2);
}
target.scaleY(-target.scaleY());
target.getLayer().batchDraw();
+ const after = {
+ y: target.y(), offsetY: target.offsetY(), scaleY: target.scaleY()
+ };
+ UndoManager.push({
+ undo: () => {
+ target.y(before.y); target.offsetY(before.offsetY);
+ target.scaleY(before.scaleY);
+ target.getLayer().batchDraw();
+ },
+ redo: () => {
+ target.y(after.y); target.offsetY(after.offsetY);
+ target.scaleY(after.scaleY);
+ target.getLayer().batchDraw();
+ }
+ });
}));
document.body.appendChild(menu);
@@ -230,50 +314,50 @@ const RightPanelManager = {
async function copyTexture(texture) {
// Get the absolute transformation matrix
const transform = texture.getAbsoluteTransform().copy();
-
+
// Get the bounding box of the transformed texture
const rect = texture.getClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
-
+
// Create a temporary canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const ctx = tempCanvas.getContext('2d');
-
+
// Apply the inverse translation to position the texture correctly
ctx.translate(-rect.x, -rect.y);
-
+
// Apply the Konva transformation matrix
const matrix = transform.getMatrix();
ctx.transform(
- matrix[0], matrix[1],
- matrix[2], matrix[3],
+ matrix[0], matrix[1],
+ matrix[2], matrix[3],
matrix[4], matrix[5]
);
-
+
// Draw the original image with proper dimensions
ctx.drawImage(
- texture.image(),
- 0, 0,
+ texture.image(),
+ 0, 0,
texture.width(), texture.height()
);
-
+
try {
// Get the image data from the canvas
const imageData = ctx.getImageData(0, 0, width, height);
-
+
// Create a new canvas with just the relevant pixels (remove transparent padding)
const contentRect = getContentBoundingBox(imageData);
-
+
if (contentRect.width > 0 && contentRect.height > 0) {
// Create final canvas with only the content
const finalCanvas = document.createElement('canvas');
finalCanvas.width = contentRect.width;
finalCanvas.height = contentRect.height;
const finalCtx = finalCanvas.getContext('2d');
-
+
// Copy only the relevant pixels
finalCtx.putImageData(
imageData,
@@ -281,7 +365,7 @@ const RightPanelManager = {
contentRect.x, contentRect.y,
contentRect.width, contentRect.height
);
-
+
// Convert to blob and copy to clipboard
const blob = await new Promise(resolve => finalCanvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
@@ -300,12 +384,12 @@ const RightPanelManager = {
const data = imageData.data;
const width = imageData.width;
const height = imageData.height;
-
+
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
-
+
// Find the bounds of non-transparent pixels
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
@@ -318,7 +402,7 @@ const RightPanelManager = {
}
}
}
-
+
// Return the bounding box (add 1 to max to include the edge pixel)
return {
x: minX,
@@ -350,15 +434,40 @@ const RightPanelManager = {
konvaImg.on('dragmove', snapping.handleDragging);
konvaImg.on('dragend', snapping.handleDragEnd);
-
+
+ // Drag undo for extracted textures
+ let dragStartPos = null;
+ konvaImg.on('dragstart', () => {
+ dragStartPos = { x: konvaImg.x(), y: konvaImg.y() };
+ });
+ konvaImg.on('dragend', () => {
+ if (!dragStartPos) return;
+ const start = { ...dragStartPos };
+ const end = { x: konvaImg.x(), y: konvaImg.y() };
+ dragStartPos = null;
+ if (start.x === end.x && start.y === end.y) return;
+ UndoManager.push({
+ undo: () => {
+ konvaImg.position(start);
+ tr.forceUpdate();
+ stage.batchDraw();
+ },
+ redo: () => {
+ konvaImg.position(end);
+ tr.forceUpdate();
+ stage.batchDraw();
+ }
+ });
+ });
+
imageLayer.add(konvaImg);
tiedRects[groupId] = konvaImg;
}
};
-
+
img.src = textureData;
},
-
+
removeTexture: (groupId) => {
if (tiedRects[groupId]) {
tiedRects[groupId].destroy();
@@ -367,23 +476,42 @@ const RightPanelManager = {
}
},
+ detachTexture: (groupId) => {
+ const node = tiedRects[groupId];
+ if (node) {
+ node.remove();
+ delete tiedRects[groupId];
+ imageLayer.draw();
+ return node;
+ }
+ return null;
+ },
+
+ restoreTexture: (groupId, node) => {
+ if (node) {
+ imageLayer.add(node);
+ tiedRects[groupId] = node;
+ imageLayer.draw();
+ }
+ },
+
updateBackground: () => {
const width = parseInt(document.getElementById('rightWidth').value);
const height = parseInt(document.getElementById('rightHeight').value);
-
+
if (stage.isTransparentBackground) {
const checkerboardPattern = CheckerboardManager.createCheckerboard(
- width,
- height,
+ width,
+ height,
CONFIG.CHECKERBOARD.CELL_SIZE
);
-
+
// Create a new image and wait for it to load
const checkerboardImg = new Image();
checkerboardImg.onload = () => {
// Remove old background
stage.bgRect.destroy();
-
+
// Create new checkerboard background
const newBgRect = new Konva.Image({
x: 0,
@@ -394,7 +522,7 @@ const RightPanelManager = {
listening: false,
name: 'bgRect'
});
-
+
stage.bgLayer.add(newBgRect);
stage.bgRect = newBgRect;
stage.bgLayer.draw();
@@ -416,30 +544,30 @@ const RightPanelManager = {
// Toggle between solid color and checkerboard background
toggleTransparency: (stage, isTransparent) => {
stage.isTransparentBackground = isTransparent;
-
+
// Use the stored reference to bgLayer
const bgLayer = stage.bgLayer;
if (!bgLayer) {
console.error('Background layer not found');
return;
}
-
+
const width = stage.bgRect.width();
const height = stage.bgRect.height();
-
+
if (isTransparent) {
const checkerboardPattern = CheckerboardManager.createCheckerboard(
- width,
- height,
+ width,
+ height,
CONFIG.CHECKERBOARD.CELL_SIZE
);
-
+
// Create a new image and wait for it to load
const checkerboardImg = new Image();
checkerboardImg.onload = () => {
// Remove old background
const oldBgRect = stage.bgRect;
-
+
// Create new checkerboard background
const newBgRect = new Konva.Image({
x: 0,
@@ -450,7 +578,7 @@ const RightPanelManager = {
listening: false,
name: 'bgRect'
});
-
+
bgLayer.add(newBgRect);
stage.bgRect = newBgRect;
oldBgRect.destroy();
@@ -460,7 +588,7 @@ const RightPanelManager = {
} else {
// Switch back to solid color
const oldBgRect = stage.bgRect;
-
+
const newBgRect = new Konva.Rect({
x: 0,
y: 0,
@@ -470,7 +598,7 @@ const RightPanelManager = {
listening: false,
name: 'bgRect'
});
-
+
bgLayer.add(newBgRect);
stage.bgRect = newBgRect;
oldBgRect.destroy();
@@ -486,6 +614,14 @@ const RightPanelManager = {
const textures = Object.values(tiedRects);
if (textures.length === 0) return { packed: 0, skipped: 0 };
+ // Snapshot before state for undo
+ const beforeState = textures.map(t => ({
+ texture: t,
+ x: t.x(), y: t.y(),
+ scaleX: t.scaleX(), scaleY: t.scaleY(),
+ rotation: t.rotation()
+ }));
+
const getDims = (texture, rotation = 0, scaleFactor = 1) => {
const scaleX = (texture.scaleX() || 1) * scaleFactor;
const scaleY = (texture.scaleY() || 1) * scaleFactor;
@@ -514,7 +650,7 @@ const RightPanelManager = {
for (let rot of rotations) {
let { width, height } = getDims(texture, rot);
-
+
// Optional auto-scaling
let scaleFactor = 1;
if (autoScale) {
@@ -573,7 +709,35 @@ const RightPanelManager = {
alert(`${skippedCount} texture(s) were skipped because they don't fit in the container. Consider increasing the container size.`);
}
+ // Push undo for auto-pack
+ const afterState = textures.map(t => ({
+ texture: t,
+ x: t.x(), y: t.y(),
+ scaleX: t.scaleX(), scaleY: t.scaleY(),
+ rotation: t.rotation()
+ }));
+ UndoManager.push({
+ undo: () => {
+ beforeState.forEach(s => {
+ s.texture.position({ x: s.x, y: s.y });
+ s.texture.scale({ x: s.scaleX, y: s.scaleY });
+ s.texture.rotation(s.rotation);
+ });
+ stage.findOne('Transformer').nodes([]);
+ stage.draw();
+ },
+ redo: () => {
+ afterState.forEach(s => {
+ s.texture.position({ x: s.x, y: s.y });
+ s.texture.scale({ x: s.scaleX, y: s.scaleY });
+ s.texture.rotation(s.rotation);
+ });
+ stage.findOne('Transformer').nodes([]);
+ stage.draw();
+ }
+ });
+
console.log(`Packed ${packedCount} textures, ${skippedCount} skipped`);
return { packed: packedCount, skipped: skippedCount };
}
-};
\ No newline at end of file
+};
diff --git a/scripts/shortcutsManager.js b/scripts/shortcutsManager.js
index c9c39c6..f6e3360 100644
--- a/scripts/shortcutsManager.js
+++ b/scripts/shortcutsManager.js
@@ -1,5 +1,21 @@
document.addEventListener('keydown', (e) => {
+ // Undo/Redo
+ if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ') {
+ if (e.shiftKey) {
+ UndoManager.redo();
+ } else {
+ UndoManager.undo();
+ }
+ e.preventDefault();
+ return;
+ }
+ if ((e.ctrlKey || e.metaKey) && e.code === 'KeyY') {
+ UndoManager.redo();
+ e.preventDefault();
+ return;
+ }
+
// Drawing mode toggle
if (e.code === CONFIG.SHORTCUTS.toggleDrawingMode) {
document.getElementById('toggleDrawingMode').click();
diff --git a/scripts/undoManager.js b/scripts/undoManager.js
new file mode 100644
index 0000000..02c3219
--- /dev/null
+++ b/scripts/undoManager.js
@@ -0,0 +1,40 @@
+const UndoManager = {
+ _undoStack: [],
+ _redoStack: [],
+ _maxSize: 100,
+ _batch: null,
+ beginBatch() { this._batch = []; },
+ endBatch() {
+ if (!this._batch) return;
+ const actions = this._batch;
+ this._batch = null;
+ if (actions.length === 0) return;
+ this.push({
+ undo: () => { for (let i = actions.length - 1; i >= 0; i--) actions[i].undo(); },
+ redo: () => { for (let i = 0; i < actions.length; i++) actions[i].redo(); }
+ });
+ },
+ push(action) {
+ if (this._batch) { this._batch.push(action); return; }
+ this._undoStack.push(action);
+ if (this._undoStack.length > this._maxSize) this._undoStack.shift();
+ this._redoStack = [];
+ },
+ undo() {
+ const action = this._undoStack.pop();
+ if (!action) return false;
+ action.undo();
+ this._redoStack.push(action);
+ return true;
+ },
+ redo() {
+ const action = this._redoStack.pop();
+ if (!action) return false;
+ action.redo();
+ this._undoStack.push(action);
+ return true;
+ },
+ clear() { this._undoStack = []; this._redoStack = []; this._batch = null; },
+ canUndo() { return this._undoStack.length > 0; },
+ canRedo() { return this._redoStack.length > 0; }
+};