From bf6f1df79e1df1218f1424dfd36084565e002912 Mon Sep 17 00:00:00 2001 From: Abdo-Eid <65284648+Abdo-Eid@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:09:16 +0300 Subject: [PATCH 1/4] UI: improve hints and context menu behavior - Added close button to hints for better UX - Improved context menu handling and grid resizing - Prevented default browser context menu interference --- .gitignore | 3 + index.html | 16 +- src/js/2d/floor2d.js | 427 ++++++------------------------------------ src/js/main.js | 5 +- src/styles/styles.css | 36 +++- 5 files changed, 116 insertions(+), 371 deletions(-) diff --git a/.gitignore b/.gitignore index 924d11f..8a2fc8c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist # Documentation output created by jsdoc docs/ + +# sometimes i copy files as refrence +ref-* \ No newline at end of file diff --git a/index.html b/index.html index ec7fd69..0d4bbf5 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,13 @@
+

Keyboard Shortcuts

@@ -30,13 +37,20 @@

Keyboard Shortcuts

- + diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e69de29 diff --git a/src/js/2d/contextMenu2d.js b/src/js/2d/contextMenu2d.js new file mode 100644 index 0000000..7390b4d --- /dev/null +++ b/src/js/2d/contextMenu2d.js @@ -0,0 +1,75 @@ +// Context menu object +const ContextMenu = { + element: null, + options: [], // Store added options + + create(position) { + // Remove any existing context menu + this.remove() + + // Create a new context menu + this.element = document.createElement('div') + this.element.className = 'context-menu' + this.element.style.top = `${position.y}px` + this.element.style.left = `${position.x}px` + + // Add all stored options + this.options.forEach((option) => { + this.element.appendChild(option) + }) + + document.body.appendChild(this.element) + }, + + remove() { + if (this.element) { + this.element.remove() + this.element = null + } + }, + + addOption(text, callback) { + const optionElement = document.createElement('div') + optionElement.textContent = text + optionElement.className = 'context-menu-option' + optionElement.onclick = () => { + callback() + this.remove() // Close menu after clicking an option + } + this.options.push(optionElement) + }, + + clearOptions() { + this.options = [] + }, +} + +// Default option +ContextMenu.addOption('Close Menu', () => ContextMenu.remove()) + +// ========================================== +// Safe initialization function +// ========================================== +export function initContextMenu(canvas2D) { + if (!canvas2D) { + console.warn('initContextMenu: canvas2D not found') + return + } + + // Right-click opens custom menu + canvas2D.addEventListener('contextmenu', (e) => { + e.preventDefault() + ContextMenu.create({ x: e.clientX, y: e.clientY }) + }) + + // Click anywhere else closes it + document.addEventListener('mousedown', (e) => { + if (ContextMenu.element && !ContextMenu.element.contains(e.target)) { + ContextMenu.remove() + } + }) + + console.log('Context menu initialized') +} + +export { ContextMenu } diff --git a/src/js/2d/core2d.js b/src/js/2d/core2d.js new file mode 100644 index 0000000..16a0cec --- /dev/null +++ b/src/js/2d/core2d.js @@ -0,0 +1,112 @@ +import paper from 'paper' + +import { setupMouseHandlers } from './drawing2d.js' +import { initContextMenu } from './contextMenu2d.js' + +// ---------------------------------------------------- +// 1. Canvas and Paper.js Setup +// ---------------------------------------------------- +const canvas2D = document.getElementById('canvas2D') +paper.setup(canvas2D) + +// ---------------------------------------------------- +// 2. Constants +// ---------------------------------------------------- +export const GRID_SPACING = 30 +export const LINE_STROKE_WIDTH = 3 +export const MIN_DRAG_DISTANCE = 5 + +// ---------------------------------------------------- +// 3. Grid Management +// ---------------------------------------------------- +let gridGroup = null +let currentGridSpacing = GRID_SPACING +let currentGridColor = '#d0d0d0' + +/** + * Create the 2D grid based on view bounds + */ +export function createGrid(spacing = GRID_SPACING, color = '#d0d0d0') { + currentGridSpacing = spacing + currentGridColor = color + + if (!gridGroup) { + gridGroup = new paper.Group() + gridGroup.name = 'grid' + } else { + gridGroup.removeChildren() + } + + const viewBounds = paper.view.bounds + const left = Math.floor(viewBounds.left / spacing) * spacing + const right = Math.ceil(viewBounds.right / spacing) * spacing + const top = Math.floor(viewBounds.top / spacing) * spacing + const bottom = Math.ceil(viewBounds.bottom / spacing) * spacing + + // Vertical lines + for (let x = left; x <= right; x += spacing) { + const line = new paper.Path.Line( + new paper.Point(x, top), + new paper.Point(x, bottom) + ) + line.strokeColor = color + line.strokeWidth = 1 + line.opacity = 0.7 + gridGroup.addChild(line) + } + + // Horizontal lines + for (let y = top; y <= bottom; y += spacing) { + const line = new paper.Path.Line( + new paper.Point(left, y), + new paper.Point(right, y) + ) + line.strokeColor = color + line.strokeWidth = 1 + line.opacity = 0.7 + gridGroup.addChild(line) + } + + gridGroup.sendToBack() + return gridGroup +} + +/** + * Ensure grid is visible and created + */ +export function ensureGridVisible() { + if (!gridGroup) { + createGrid() + } else { + gridGroup.visible = true + } + return gridGroup +} + +// Auto-refresh grid when view changes +paper.view.onFrame = function () { + if (gridGroup) { + createGrid(currentGridSpacing, currentGridColor) + } +} + +// ---------------------------------------------------- +// 4. Initialization (replaces floor2d.js) +// ---------------------------------------------------- +export function setup2D() { + // Setup drawing handlers + setupMouseHandlers(canvas2D) + + // Initialize context menu + initContextMenu(canvas2D) + + // Create grid initially + createGrid() + + console.log('2D environment initialized') +} + +// ---------------------------------------------------- +// 5. Exports +// ---------------------------------------------------- +export { paper, canvas2D } diff --git a/src/js/2d/drawing2d.js b/src/js/2d/drawing2d.js new file mode 100644 index 0000000..3d9f442 --- /dev/null +++ b/src/js/2d/drawing2d.js @@ -0,0 +1,243 @@ +import { paper, LINE_STROKE_WIDTH, MIN_DRAG_DISTANCE, GRID_SPACING } from './core2d.js' + +let vertices = [] + +/** + * Snap a point to the grid + */ +export function snapToGrid(point) { + return new paper.Point( + Math.round(point.x / GRID_SPACING) * GRID_SPACING, + Math.round(point.y / GRID_SPACING) * GRID_SPACING + ) +} + +/** + * Create a red vertex circle at given point + */ +export function createVertex(point) { + const vertex = new paper.Path.Circle({ + center: point, + radius: 6, + fillColor: 'red', + strokeColor: 'darkred', + strokeWidth: 1, + }) + vertex.data = { connectedPaths: [] } + vertices.push(vertex) + vertex.bringToFront() + return vertex +} + +export function getVertices() { + return vertices +} + +export function clearVertices() { + vertices.forEach((vertex) => vertex.remove()) + vertices = [] +} + + +let isPanning = false +let startPoint = null +let tempLine = null +let tempText = null +let snapIndicator = null +let isDragging = false +let selectedVertex = null +let isDraggingVertex = false + +/** + * Update length text + */ +function updateLengthText(path, textItem) { + if (!textItem) return + + const lengthInPixels = path.length + const metersPerPixel = 10 / paper.view.bounds.width + const lengthInMeters = (lengthInPixels * metersPerPixel).toFixed(2) + + const direction = path.firstSegment.point.subtract(path.lastSegment.point) + if (direction.length === 0) return + const offset = direction.normalize().rotate(90).multiply(20) + + textItem.content = `${lengthInMeters} m` + textItem.point = path.getPointAt(path.length / 2).add(offset) +} + +function cleanupPreview() { + if (tempLine) { + tempLine.remove() + tempLine = null + } + if (tempText) { + tempText.remove() + tempText = null + } + if (snapIndicator) { + snapIndicator.remove() + snapIndicator = null + } +} + +// Mouse event handlers +export function setupMouseHandlers(canvas2D) { + paper.view.onMouseDown = (event) => { + isDragging = false + isDraggingVertex = false + + // ✅ Read Alt state from mouse event + const isAltPressed = event.event.altKey + + const hitResult = paper.project.hitTest(event.point, { + fill: true, + stroke: true, + segments: true, + tolerance: 12, + match: (hit) => hit.item && getVertices().includes(hit.item), + }) + + if (hitResult?.item && getVertices().includes(hitResult.item)) { + if (isAltPressed) { + selectedVertex = hitResult.item + startPoint = null + } else { + startPoint = hitResult.item.position.clone() + selectedVertex = null + } + } else { + // Not clicking on a vertex + if (isAltPressed) { + // Start panning + isPanning = true + canvas2D.style.cursor = 'grab' + } else { + // Start drawing + selectedVertex = null + startPoint = event.point.clone() + } + } + } + + paper.view.onMouseDrag = (event) => { + const isAltPressed = event.event.altKey + + if (isPanning) { + canvas2D.style.cursor = 'grabbing' + paper.view.translate(event.delta.divide(2)) // the divition for stability + } + + const dragDistance = selectedVertex + ? selectedVertex.position.subtract(event.point).length + : startPoint + ? startPoint.subtract(event.point).length + : 0 + + if (dragDistance < MIN_DRAG_DISTANCE) return + + if (isAltPressed && selectedVertex) { + isDraggingVertex = true + cleanupPreview() + + const snappedPos = snapToGrid(event.point) + selectedVertex.position = snappedPos + + selectedVertex.data.connectedPaths.forEach(({ path, index }) => { + path.segments[index].point = snappedPos + if (path.data?.lengthText) { + updateLengthText(path, path.data.lengthText) + } + }) + } else if (startPoint) { + isDragging = true + cleanupPreview() + + const snappedStart = snapToGrid(startPoint) + const snappedEnd = snapToGrid(event.point) + + snapIndicator = new paper.Path.Circle({ + center: snappedEnd, + radius: 4, + fillColor: 'red', + strokeColor: 'darkred', + strokeWidth: 0.5, + opacity: 0.8, + }) + + tempLine = new paper.Path.Line({ + from: snappedStart, + to: snappedEnd, + strokeColor: 'black', + strokeWidth: LINE_STROKE_WIDTH, + dashArray: [6, 4], + opacity: 0.6, + }) + + tempText = new paper.PointText({ + point: snappedStart, + content: '', + fillColor: 'black', + fontSize: 14, + fontFamily: 'Arial', + }) + updateLengthText(tempLine, tempText) + } + } + + paper.view.onMouseUp = (event) => { + const isAltPressed = event.event.altKey // ✅ From mouse event + cleanupPreview() + if (isPanning) { + isPanning = false + canvas2D.style.cursor = 'default' + } + + if (isDraggingVertex && isAltPressed && selectedVertex) { + const snappedPos = snapToGrid(selectedVertex.position) + selectedVertex.position = snappedPos + selectedVertex.data.connectedPaths.forEach(({ path, index }) => { + path.segments[index].point = snappedPos + if (path.data?.lengthText) { + updateLengthText(path, path.data.lengthText) + } + }) + } else if (isDragging && startPoint) { + const snappedStart = snapToGrid(startPoint) + const snappedEnd = snapToGrid(event.point) + + if (!snappedStart.equals(snappedEnd)) { + let startVertex = getVertices().find((v) => v.position.equals(snappedStart)) + let endVertex = getVertices().find((v) => v.position.equals(snappedEnd)) + + if (!startVertex) startVertex = createVertex(snappedStart) + if (!endVertex) endVertex = createVertex(snappedEnd) + + const finalLine = new paper.Path.Line({ + from: snappedStart, + to: snappedEnd, + strokeColor: 'black', + strokeWidth: LINE_STROKE_WIDTH, + }) + + const finalText = new paper.PointText({ + point: snappedStart, + content: '', + fillColor: 'black', + fontSize: 14, + fontFamily: 'Arial', + }) + updateLengthText(finalLine, finalText) + finalLine.data = { lengthText: finalText } + + startVertex.data.connectedPaths.push({ path: finalLine, index: 0 }) + endVertex.data.connectedPaths.push({ path: finalLine, index: 1 }) + } + } + + startPoint = null + selectedVertex = null + isDragging = false + isDraggingVertex = false + } +} \ No newline at end of file diff --git a/src/js/2d/dxfLoader.js b/src/js/2d/dxfLoader.js index 5d2f6a4..a1f2ca2 100644 --- a/src/js/2d/dxfLoader.js +++ b/src/js/2d/dxfLoader.js @@ -1,5 +1,5 @@ import paper from 'paper' -import { ensureGridVisible } from './floor2d.js' +import { ensureGridVisible } from './core2d.js' /** * loadDxfFile loads and parses a DXF file, scales its entities to fit the canvas, diff --git a/src/js/2d/floor2d.js b/src/js/2d/floor2d.js deleted file mode 100644 index 3722df4..0000000 --- a/src/js/2d/floor2d.js +++ /dev/null @@ -1,394 +0,0 @@ -import paper from 'paper' - -const canvas2D = document.getElementById('canvas2D') -export { canvas2D } - -paper.setup(canvas2D) - -// #################### Grid logic #################### - -const GRID_SPACING = 30 -const GRID_RANGE = 5000 // ±10,000 units (covers huge floor plans) - -/** - * createGrid creates an INFINITE grid background on the Paper.js canvas. - * @param {number} spacing - * @param {string | paper.Color} color - * @returns {paper.Group} - */ -export function createGrid(spacing = GRID_SPACING, color = '#d0d0d0') { - // Remove existing grid first - const existingGrid = paper.project.getItem({ name: 'grid' }) - if (existingGrid) { - existingGrid.remove() - } - - const gridGroup = new paper.Group() - gridGroup.name = 'grid' - - // Vertical lines (x = ..., -2*spacing, -spacing, 0, spacing, 2*spacing, ...) - const startX = -GRID_RANGE - const endX = GRID_RANGE - for ( - let x = Math.floor(startX / spacing) * spacing; - x <= endX; - x += spacing - ) { - const line = new paper.Path.Line( - new paper.Point(x, -GRID_RANGE), - new paper.Point(x, GRID_RANGE) - ) - line.strokeColor = color - line.strokeWidth = 1 - line.opacity = 0.7 - gridGroup.addChild(line) - } - - // Horizontal lines (y = ..., -2*spacing, -spacing, 0, spacing, 2*spacing, ...) - const startY = -GRID_RANGE - const endY = GRID_RANGE - for ( - let y = Math.floor(startY / spacing) * spacing; - y <= endY; - y += spacing - ) { - const line = new paper.Path.Line( - new paper.Point(-GRID_RANGE, y), - new paper.Point(GRID_RANGE, y) - ) - line.strokeColor = color - line.strokeWidth = 1 - line.opacity = 0.7 - gridGroup.addChild(line) - } - - gridGroup.sendToBack() - gridGroup.visible = true - gridGroup.opacity = 1 - // console.log('Infinite grid created with', gridGroup.children.length, 'lines') - return gridGroup -} - -/** - * ensureGridVisible ensures that a grid is visible on the canvas. - * @returns {paper.Group} - */ -export function ensureGridVisible() { - let grid = paper.project.getItem({ name: 'grid' }) - if (!grid) { - console.log('No grid found, creating new infinite grid') - grid = createGrid() - } else { - grid.visible = true - grid.opacity = 1 - grid.sendToBack() - console.log('Grid made visible') - } - return grid -} - -// #################### Context window #################### - -// Context menu object -const ContextMenu = { - element: null, - options: [], // Store added options - - create(position) { - // Remove any existing context menu - if (this.element) { - this.remove() - } - - // Create a new context menu - this.element = document.createElement('div') - this.element.className = 'context-menu' - this.element.style.top = `${position.y}px` - this.element.style.left = `${position.x}px` - - // Add all stored options - this.options.forEach((option) => { - this.element.appendChild(option) - }) - - document.body.appendChild(this.element) - }, - - remove() { - if (this.element) { - this.element.remove() - this.element = null - } - }, - - addOption(text, callback) { - const optionElement = document.createElement('div') - optionElement.textContent = text - optionElement.className = 'context-menu-option' - optionElement.onclick = () => { - callback() - this.remove() // Close menu after clicking an option - } - this.options.push(optionElement) - }, - - clearOptions() { - this.options = [] - }, -} - -// Add options from outside the object -// ContextMenu.addOption('Delete Line', () => { -// if (selectedLine) { -// deleteLine(selectedLine) -// selectedLine = null -// } -// }) - -// refresh the grid option -ContextMenu.addOption('Close Menu', ContextMenu.remove) - -// Event listeners -canvas2D.addEventListener('contextmenu', (e) => { - e.preventDefault() - ContextMenu.create({ x: e.clientX, y: e.clientY }) -}) - -document.addEventListener('mousedown', (e) => { - ContextMenu.remove() -}) - -// #################### Draw logic #################### -let isPanning = false -let lastPanPoint = null -let startPoint = null -let tempLine = null -let tempText = null -let snapIndicator = null -let isDragging = false -let selectedVertex = null -let isDraggingVertex = false -let vertices = [] -const LINE_STROKE_WIDTH = 3 -const MIN_DRAG_DISTANCE = 5 - -/** - * Snap a point to the grid - */ -function snapToGrid(point) { - return new paper.Point( - Math.round(point.x / GRID_SPACING) * GRID_SPACING, - Math.round(point.y / GRID_SPACING) * GRID_SPACING - ) -} - -/** - * Create a red vertex circle at given point - */ -function createVertex(point) { - const vertex = new paper.Path.Circle({ - center: point, - radius: 6, - fillColor: 'red', - strokeColor: 'darkred', - strokeWidth: 1, - }) - vertex.data = { connectedPaths: [] } - vertices.push(vertex) - vertex.bringToFront() - return vertex -} - -/** - * Update length text - */ -function updateLengthText(path, textItem) { - if (!textItem) return - - const lengthInPixels = path.length - const lengthInMeters = (lengthInPixels / 100).toFixed(2) - - const direction = path.firstSegment.point.subtract(path.lastSegment.point) - if (direction.length === 0) return - const offset = direction.normalize().rotate(90).multiply(20) - - textItem.content = `${lengthInMeters} m` - textItem.point = path.getPointAt(path.length / 2).add(offset) -} - -function cleanupPreview() { - if (tempLine) { - tempLine.remove() - tempLine = null - } - if (tempText) { - tempText.remove() - tempText = null - } - if (snapIndicator) { - snapIndicator.remove() - snapIndicator = null - } -} - -paper.view.onMouseDown = (event) => { - isDragging = false - isDraggingVertex = false - - // ✅ Read Alt state from mouse event - const isAltPressed = event.event.altKey - - const hitResult = paper.project.hitTest(event.point, { - fill: true, - stroke: true, - segments: true, - tolerance: 12, - match: (hit) => hit.item && vertices.includes(hit.item), - }) - - if (hitResult?.item && vertices.includes(hitResult.item)) { - if (isAltPressed) { - selectedVertex = hitResult.item - startPoint = null - } else { - startPoint = hitResult.item.position.clone() - selectedVertex = null - } - } else { - // Not clicking on a vertex - if (isAltPressed) { - // Start panning - isPanning = true - canvas2D.style.cursor = 'grab' - lastPanPoint = event.point.clone() - } else { - // Start drawing - selectedVertex = null - startPoint = event.point.clone() - } - } -} - -paper.view.onMouseDrag = (event) => { - const isAltPressed = event.event.altKey - - if (isPanning && lastPanPoint) { - canvas2D.style.cursor = 'grabbing' - paper.view.translate(event.point.subtract(lastPanPoint)) - lastPanPoint = event.point.clone() - - } - - const dragDistance = selectedVertex - ? selectedVertex.position.subtract(event.point).length - : startPoint - ? startPoint.subtract(event.point).length - : 0 - - if (dragDistance < MIN_DRAG_DISTANCE) return - - if (isAltPressed && selectedVertex) { - isDraggingVertex = true - cleanupPreview() - - const snappedPos = snapToGrid(event.point) - selectedVertex.position = snappedPos - - selectedVertex.data.connectedPaths.forEach(({ path, index }) => { - path.segments[index].point = snappedPos - if (path.data?.lengthText) { - updateLengthText(path, path.data.lengthText) - } - }) - } else if (startPoint) { - isDragging = true - cleanupPreview() - - const snappedStart = snapToGrid(startPoint) - const snappedEnd = snapToGrid(event.point) - - snapIndicator = new paper.Path.Circle({ - center: snappedEnd, - radius: 4, - fillColor: 'red', - strokeColor: 'darkred', - strokeWidth: 0.5, - opacity: 0.8, - }) - - tempLine = new paper.Path.Line({ - from: snappedStart, - to: snappedEnd, - strokeColor: 'black', - strokeWidth: LINE_STROKE_WIDTH, - dashArray: [6, 4], - opacity: 0.6, - }) - - tempText = new paper.PointText({ - point: snappedStart, - content: '', - fillColor: 'black', - fontSize: 14, - fontFamily: 'Arial', - }) - updateLengthText(tempLine, tempText) - } -} - -paper.view.onMouseUp = (event) => { - const isAltPressed = event.event.altKey // ✅ From mouse event - cleanupPreview() - if (isPanning) { - isPanning = false - lastPanPoint = null - canvas2D.style.cursor = 'default' - } - - if (isDraggingVertex && isAltPressed && selectedVertex) { - const snappedPos = snapToGrid(selectedVertex.position) - selectedVertex.position = snappedPos - selectedVertex.data.connectedPaths.forEach(({ path, index }) => { - path.segments[index].point = snappedPos - if (path.data?.lengthText) { - updateLengthText(path, path.data.lengthText) - } - }) - } else if (isDragging && startPoint) { - const snappedStart = snapToGrid(startPoint) - const snappedEnd = snapToGrid(event.point) - - if (!snappedStart.equals(snappedEnd)) { - let startVertex = vertices.find((v) => v.position.equals(snappedStart)) - let endVertex = vertices.find((v) => v.position.equals(snappedEnd)) - - if (!startVertex) startVertex = createVertex(snappedStart) - if (!endVertex) endVertex = createVertex(snappedEnd) - - const finalLine = new paper.Path.Line({ - from: snappedStart, - to: snappedEnd, - strokeColor: 'black', - strokeWidth: LINE_STROKE_WIDTH, - }) - - const finalText = new paper.PointText({ - point: snappedStart, - content: '', - fillColor: 'black', - fontSize: 14, - fontFamily: 'Arial', - }) - updateLengthText(finalLine, finalText) - finalLine.data = { lengthText: finalText } - - startVertex.data.connectedPaths.push({ path: finalLine, index: 0 }) - endVertex.data.connectedPaths.push({ path: finalLine, index: 1 }) - } - } - - startPoint = null - selectedVertex = null - isDragging = false - isDraggingVertex = false -} diff --git a/src/js/main.js b/src/js/main.js index b627e0a..d7d2008 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,41 +1,52 @@ import 'bootstrap/dist/js/bootstrap.bundle.min.js' import paper from 'paper' -import { createGrid } from './2d/floor2d.js' +import { createGrid, setup2D } from './2d/core2d.js' import { setupDxfUpload } from './2d/dxfLoader.js' import { init3D, camera, renderer } from './3d/scene3d.js' import { convertPathsTo3D } from './3d/pathsTo3d.js' -// import parseDXF from 'dxf-parser' -createGrid() -setupDxfUpload() -init3D() +// ---------------------------------------------------- +// 1. Initialize 2D + 3D environments +// ---------------------------------------------------- +setup2D() // Sets up Paper.js, grid, and context menu +setupDxfUpload() // Enables DXF import +init3D() // Sets up 3D scene -// Switch between 2D and 3D modes +// ---------------------------------------------------- +// 2. Switch between 2D and 3D modes +// ---------------------------------------------------- const container2D = document.getElementById('container2D') const container3D = document.getElementById('container3D') const switchButton = document.getElementById('switchMode') + switchButton.addEventListener('click', () => { if (container3D.style.display === 'none') { + // Switch to 3D container2D.style.display = 'none' container3D.style.display = 'block' convertPathsTo3D() switchButton.textContent = 'Switch to 2D' } else { + // Switch to 2D container2D.style.display = 'block' container3D.style.display = 'none' switchButton.textContent = 'Switch to 3D' - // fixes when window is small that part of floorplan is cutted then turn to 3D the window is big again then turn to 2d the canvas is striched not resized + + // Fix canvas stretch issue after resizing paper.view.viewSize = new paper.Size(window.innerWidth, window.innerHeight) + createGrid() // Recreate grid to fit new view bounds } }) -// Handle keyboard shortcuts toggle +// ---------------------------------------------------- +// 3. Keyboard Shortcuts Toggle Panels +// ---------------------------------------------------- function setupShortcutsToggle(headerId, contentId) { const shortcutsHeader = document.getElementById(headerId) const shortcutsContent = document.getElementById(contentId) const shortcutsArrow = document.getElementsByClassName('shortcutsArrow') - let shortcutsExpanded = false // Start expanded + let shortcutsExpanded = false // Start collapsed shortcutsHeader.addEventListener('click', () => { shortcutsExpanded = !shortcutsExpanded @@ -53,10 +64,10 @@ function setupShortcutsToggle(headerId, contentId) { setupShortcutsToggle('shortcutsHeader2D', 'shortcutsContent2D') setupShortcutsToggle('shortcutsHeader3D', 'shortcutsContent3D') -// Handle window resizing +// ---------------------------------------------------- +// 4. Handle window resizing (3D responsiveness) +// ---------------------------------------------------- window.addEventListener('resize', () => { - // console.log('resizing...') - // paper.view.viewSize = new paper.Size(window.innerWidth, window.innerHeight) # replaced by resize att in html camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) From 4913f37013f22a3dee274d8645791252e4345c78 Mon Sep 17 00:00:00 2001 From: Abdo-Eid <65284648+Abdo-Eid@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:33:37 +0300 Subject: [PATCH 4/4] UI: extend context menu with delete and clear actions - Added 'Delete Line' and 'Clear Canvas' options to context menu - Improved user workflow for canvas management --- index.html | 1 - src/js/2d/contextMenu2d.js | 23 ++++ src/js/2d/drawing2d.js | 213 +++++++++++++++++++++++++++++++++---- src/js/2d/dxfLoader.js | 6 +- 4 files changed, 222 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 62c9d97..a32fbe0 100644 --- a/index.html +++ b/index.html @@ -33,7 +33,6 @@

Keyboard Shortcuts

diff --git a/src/js/2d/contextMenu2d.js b/src/js/2d/contextMenu2d.js index 7390b4d..ee34ea7 100644 --- a/src/js/2d/contextMenu2d.js +++ b/src/js/2d/contextMenu2d.js @@ -2,6 +2,7 @@ const ContextMenu = { element: null, options: [], // Store added options + lastClickClient: null, // store last right-click client coords create(position) { // Remove any existing context menu @@ -12,6 +13,8 @@ const ContextMenu = { this.element.className = 'context-menu' this.element.style.top = `${position.y}px` this.element.style.left = `${position.x}px` + // store last client coords for other modules to use + this.lastClickClient = { x: position.x, y: position.y } // Add all stored options this.options.forEach((option) => { @@ -47,6 +50,26 @@ const ContextMenu = { // Default option ContextMenu.addOption('Close Menu', () => ContextMenu.remove()) +ContextMenu.addOption('Clear Canvas', () => { + const evt = new CustomEvent('datacenter:clearCanvas') + window.dispatchEvent(evt) +}) + +// Delete line option: will call into drawing logic using the last right-click position +// Note: drawing2d.deleteLineNear expects a Paper.js Point; the context that opens +// this menu (core2d) calls create with client coordinates. Consumers should convert +// client->paper coordinates before calling deleteLineNear. To keep this file decoupled +// we dispatch a custom event with the client coords and let drawing code listen for it. +ContextMenu.addOption('Delete Line', () => { + const detail = { + clientX: ContextMenu.lastClickClient?.x, + clientY: ContextMenu.lastClickClient?.y, + } + // dispatch a custom event that drawing2d can listen to + const evt = new CustomEvent('datacenter:deleteLineAt', { detail }) + window.dispatchEvent(evt) +}) + // ========================================== // Safe initialization function // ========================================== diff --git a/src/js/2d/drawing2d.js b/src/js/2d/drawing2d.js index 3d9f442..fff4bc6 100644 --- a/src/js/2d/drawing2d.js +++ b/src/js/2d/drawing2d.js @@ -1,9 +1,15 @@ -import { paper, LINE_STROKE_WIDTH, MIN_DRAG_DISTANCE, GRID_SPACING } from './core2d.js' +import { + paper, + LINE_STROKE_WIDTH, + MIN_DRAG_DISTANCE, + GRID_SPACING, +} from './core2d.js' let vertices = [] /** - * Snap a point to the grid + * Snap a point to the nearest grid intersection + * Ensures consistent alignment for drawing precision */ export function snapToGrid(point) { return new paper.Point( @@ -13,7 +19,8 @@ export function snapToGrid(point) { } /** - * Create a red vertex circle at given point + * Create a visible vertex (red circle) at the given point + * Each vertex tracks its connected paths for later updates */ export function createVertex(point) { const vertex = new paper.Path.Circle({ @@ -23,22 +30,157 @@ export function createVertex(point) { strokeColor: 'darkred', strokeWidth: 1, }) - vertex.data = { connectedPaths: [] } + vertex.data = { connectedPaths: [] } // store connections for interactive updates vertices.push(vertex) vertex.bringToFront() return vertex } +/** + * Accessor for all created vertices + */ export function getVertices() { return vertices } +/** + * Remove all vertex objects from the canvas + * Used when resetting or clearing the drawing state + */ export function clearVertices() { vertices.forEach((vertex) => vertex.remove()) vertices = [] } +/** + * Find and delete a line near the given paper.Point within tolerance (pixels). + * Also cleans up connected vertices if they become orphaned. + * Returns true if a line was deleted, false otherwise. + */ +export function deleteLineNear(paperPoint, tolerance = 12) { + if (!paperPoint) return false + + // Use Paper.js hit testing to get candidates near the point (optimized) + const hits = paper.project.hitTestAll(paperPoint, { + stroke: true, + tolerance, + fill: false, + segments: true, + }) + + if (!hits || hits.length === 0) return false + + // Map to unique path items and filter to simple two-segment lines + const candidateItems = hits + .map((h) => h.item) + .filter((item) => item && item.segments && item.segments.length === 2) + + if (candidateItems.length === 0) return false + + // Pick the geometrically nearest candidate + let nearest = null + let nearestDist = Infinity + candidateItems.forEach((p) => { + try { + const nearestPt = p.getNearestPoint(paperPoint) + const d = nearestPt.getDistance(paperPoint) + if (d < nearestDist) { + nearestDist = d + nearest = p + } + } catch (err) { + // ignore malformed items + } + }) + + if (!nearest || nearestDist > tolerance) return false + // Remove associated length text if present + if (nearest.data?.lengthText) { + try { + nearest.data.lengthText.remove() + } catch (e) { + // ignore + } + } + + // For each endpoint, find matching vertex and remove the connection entry + const startPt = nearest.firstSegment.point + const endPt = nearest.lastSegment.point + + const affectedVertices = vertices.filter( + (v) => v.position.equals(startPt) || v.position.equals(endPt) + ) + + affectedVertices.forEach((vertex) => { + vertex.data.connectedPaths = vertex.data.connectedPaths.filter( + (entry) => entry.path !== nearest + ) + }) + + // Remove the path + nearest.remove() + + // Remove orphaned vertices from canvas and vertices array + const remaining = [] + vertices.forEach((v) => { + if ( + !v.data || + !v.data.connectedPaths || + v.data.connectedPaths.length === 0 + ) { + v.remove() + } else { + remaining.push(v) + } + }) + vertices = remaining + + return true +} + +// Listen for context menu deletion events (client coordinates) and convert +// to paper coordinates here to avoid introducing a dependency in the context menu. +window.addEventListener('datacenter:deleteLineAt', (e) => { + const detail = e?.detail || {} + const clientX = detail.clientX + const clientY = detail.clientY + if (typeof clientX !== 'number' || typeof clientY !== 'number') return + // convert client to view/paper coordinates + const rect = document.getElementById('canvas2D')?.getBoundingClientRect() + if (!rect) return + const canvasPoint = new paper.Point(clientX - rect.left, clientY - rect.top) + deleteLineNear(canvasPoint) +}) + +/** + * Remove all user-drawn items from the canvas (except grid) + */ +export function clearCanvas() { + // Remove all items except the grid group (if present) + const all = paper.project.activeLayer.children.slice() // snapshot + all.forEach((item) => { + try { + if (item && item.name === 'grid') return // keep grid + // If the gridGroup is a group named 'grid', preserve it + if (item.parent && item.parent.name === 'grid') return + // Remove other items + item.remove() + } catch (e) { + // ignore + } + }) + + // Clear vertices array and any leftover visuals + clearVertices() +} + +// Listen for clear canvas events from the context menu +window.addEventListener('datacenter:clearCanvas', () => { + clearCanvas() +}) + +// Interaction state flags let isPanning = false let startPoint = null let tempLine = null @@ -49,15 +191,17 @@ let selectedVertex = null let isDraggingVertex = false /** - * Update length text + * Update the position and value of a line's length text + * Converts length from pixels to meters based on canvas scale */ function updateLengthText(path, textItem) { if (!textItem) return const lengthInPixels = path.length - const metersPerPixel = 10 / paper.view.bounds.width + const metersPerPixel = 10 / paper.view.bounds.width // dynamic scaling factor const lengthInMeters = (lengthInPixels * metersPerPixel).toFixed(2) + // Position the text slightly offset from the midpoint const direction = path.firstSegment.point.subtract(path.lastSegment.point) if (direction.length === 0) return const offset = direction.normalize().rotate(90).multiply(20) @@ -66,6 +210,10 @@ function updateLengthText(path, textItem) { textItem.point = path.getPointAt(path.length / 2).add(offset) } +/** + * Remove any temporary elements (preview line, text, snap indicator) + * Prevents clutter and overlapping visuals while dragging + */ function cleanupPreview() { if (tempLine) { tempLine.remove() @@ -81,15 +229,19 @@ function cleanupPreview() { } } -// Mouse event handlers +/** + * Initialize interactive mouse event handling for drawing and editing + * - Left click: draw new lines or select vertices + * - Alt + drag: move vertices or pan view + */ export function setupMouseHandlers(canvas2D) { paper.view.onMouseDown = (event) => { isDragging = false isDraggingVertex = false - // ✅ Read Alt state from mouse event const isAltPressed = event.event.altKey + // Detect if user clicked on an existing vertex const hitResult = paper.project.hitTest(event.point, { fill: true, stroke: true, @@ -99,21 +251,24 @@ export function setupMouseHandlers(canvas2D) { }) if (hitResult?.item && getVertices().includes(hitResult.item)) { + // Clicking directly on a vertex if (isAltPressed) { + // Alt + click: prepare for vertex drag selectedVertex = hitResult.item startPoint = null } else { + // Regular click: prepare to start drawing from this vertex startPoint = hitResult.item.position.clone() selectedVertex = null } } else { - // Not clicking on a vertex + // Clicking empty space if (isAltPressed) { - // Start panning + // Alt + click empty: start panning isPanning = true canvas2D.style.cursor = 'grab' } else { - // Start drawing + // Regular click: start drawing a new line selectedVertex = null startPoint = event.point.clone() } @@ -123,19 +278,22 @@ export function setupMouseHandlers(canvas2D) { paper.view.onMouseDrag = (event) => { const isAltPressed = event.event.altKey + // Handle view panning if (isPanning) { canvas2D.style.cursor = 'grabbing' - paper.view.translate(event.delta.divide(2)) // the divition for stability + paper.view.translate(event.delta.divide(2)) // divide to reduce pan speed } + // Compute drag distance for action threshold const dragDistance = selectedVertex ? selectedVertex.position.subtract(event.point).length : startPoint ? startPoint.subtract(event.point).length : 0 - if (dragDistance < MIN_DRAG_DISTANCE) return + if (dragDistance < MIN_DRAG_DISTANCE) return // ignore micro movements + // Vertex movement mode (Alt + drag vertex) if (isAltPressed && selectedVertex) { isDraggingVertex = true cleanupPreview() @@ -143,19 +301,23 @@ export function setupMouseHandlers(canvas2D) { const snappedPos = snapToGrid(event.point) selectedVertex.position = snappedPos + // Update all lines connected to this vertex dynamically selectedVertex.data.connectedPaths.forEach(({ path, index }) => { path.segments[index].point = snappedPos if (path.data?.lengthText) { updateLengthText(path, path.data.lengthText) } }) - } else if (startPoint) { + } + // Line drawing mode + else if (startPoint) { isDragging = true cleanupPreview() const snappedStart = snapToGrid(startPoint) const snappedEnd = snapToGrid(event.point) + // Show snap target indicator snapIndicator = new paper.Path.Circle({ center: snappedEnd, radius: 4, @@ -165,6 +327,7 @@ export function setupMouseHandlers(canvas2D) { opacity: 0.8, }) + // Draw preview dashed line tempLine = new paper.Path.Line({ from: snappedStart, to: snappedEnd, @@ -174,6 +337,7 @@ export function setupMouseHandlers(canvas2D) { opacity: 0.6, }) + // Show temporary measurement label tempText = new paper.PointText({ point: snappedStart, content: '', @@ -186,33 +350,43 @@ export function setupMouseHandlers(canvas2D) { } paper.view.onMouseUp = (event) => { - const isAltPressed = event.event.altKey // ✅ From mouse event + const isAltPressed = event.event.altKey cleanupPreview() + + // End panning if (isPanning) { isPanning = false canvas2D.style.cursor = 'default' } + // Finalize vertex drag if (isDraggingVertex && isAltPressed && selectedVertex) { const snappedPos = snapToGrid(selectedVertex.position) selectedVertex.position = snappedPos + selectedVertex.data.connectedPaths.forEach(({ path, index }) => { path.segments[index].point = snappedPos if (path.data?.lengthText) { updateLengthText(path, path.data.lengthText) } }) - } else if (isDragging && startPoint) { + } + // Finalize line creation + else if (isDragging && startPoint) { const snappedStart = snapToGrid(startPoint) const snappedEnd = snapToGrid(event.point) if (!snappedStart.equals(snappedEnd)) { - let startVertex = getVertices().find((v) => v.position.equals(snappedStart)) + // Reuse existing vertices if they already exist on those points + let startVertex = getVertices().find((v) => + v.position.equals(snappedStart) + ) let endVertex = getVertices().find((v) => v.position.equals(snappedEnd)) if (!startVertex) startVertex = createVertex(snappedStart) if (!endVertex) endVertex = createVertex(snappedEnd) + // Create final line between vertices const finalLine = new paper.Path.Line({ from: snappedStart, to: snappedEnd, @@ -220,6 +394,7 @@ export function setupMouseHandlers(canvas2D) { strokeWidth: LINE_STROKE_WIDTH, }) + // Attach a permanent length label const finalText = new paper.PointText({ point: snappedStart, content: '', @@ -230,14 +405,16 @@ export function setupMouseHandlers(canvas2D) { updateLengthText(finalLine, finalText) finalLine.data = { lengthText: finalText } + // Register mutual connections for dynamic updates startVertex.data.connectedPaths.push({ path: finalLine, index: 0 }) endVertex.data.connectedPaths.push({ path: finalLine, index: 1 }) } } + // Reset interaction state startPoint = null selectedVertex = null isDragging = false isDraggingVertex = false } -} \ No newline at end of file +} diff --git a/src/js/2d/dxfLoader.js b/src/js/2d/dxfLoader.js index a1f2ca2..0eec9d7 100644 --- a/src/js/2d/dxfLoader.js +++ b/src/js/2d/dxfLoader.js @@ -1,5 +1,6 @@ import paper from 'paper' import { ensureGridVisible } from './core2d.js' +import { clearCanvas } from './drawing2d.js' /** * loadDxfFile loads and parses a DXF file, scales its entities to fit the canvas, @@ -28,8 +29,9 @@ export function loadDxfFile(file) { console.log('Scale factor:', scale) console.log('Offset:', offset) - // Clear existing content - paper.project.clear() + // Clear existing content but preserve the grid + // Use the clearCanvas helper which removes user drawings but keeps the grid group + clearCanvas() dxfCircles.length = 0 // Clear previous circles // Process entities with scaling and positioning