Skip to content

Commit 4052bba

Browse files
committed
merge: feature/visual-grid-50px into develop (v1.3.0)
2 parents 4be6d7a + 71f39f3 commit 4052bba

14 files changed

Lines changed: 229 additions & 188 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jscircuit",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"description": "A web-based circuit editor for designing circuits and exporting netlists.",
55
"main": "src/gui/main.js",
66
"scripts": {

src/config/gridConfig.js

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,82 +6,93 @@
66

77
import { CoordinateAdapter } from '../infrastructure/adapters/CoordinateAdapter.js';
88

9-
// Use CoordinateAdapter as the single source of truth
10-
export const GRID_SPACING = CoordinateAdapter.CONFIG.PIXELS_PER_GRID_UNIT; // 10 pixels between logical grid units
11-
export const COMPONENT_GRID_POINTS = CoordinateAdapter.CONFIG.V2_COMPONENT_SPAN; // Components span 5 logical grid intervals (50 pixels)
12-
export const COMPONENT_SPAN_PIXELS = COMPONENT_GRID_POINTS * GRID_SPACING; // 60 pixels
9+
// Coordinate system constants
10+
const LOGICAL_PIXELS_PER_GRID_UNIT = CoordinateAdapter.CONFIG.PIXELS_PER_GRID_UNIT;
11+
12+
// Visual grid display spacing (independent from coordinate system)
13+
// Wires snap and jump by this amount on screen
14+
const VISUAL_GRID_SPACING = 50; // Visual grid spacing for user interactions
15+
16+
// Use CoordinateAdapter as the single source of truth for coordinates
17+
export const GRID_SPACING = LOGICAL_PIXELS_PER_GRID_UNIT; // Logical grid unit spacing
18+
export const COMPONENT_GRID_POINTS = CoordinateAdapter.CONFIG.V2_COMPONENT_SPAN; // Component span in logical grid intervals
19+
export const COMPONENT_SPAN_PIXELS = COMPONENT_GRID_POINTS * GRID_SPACING;
1320

1421
/**
1522
* Central grid configuration object for component sizing
1623
*/
1724
export const GRID_CONFIG = {
18-
// Basic measurements
19-
spacing: GRID_SPACING, // 10 pixels between points
20-
componentGridPoints: COMPONENT_GRID_POINTS, // 5 grid points span
21-
componentSpanPixels: COMPONENT_SPAN_PIXELS, // 50 pixels total
22-
25+
// Visual grid display (for wire snapping and grid rendering)
26+
visualGridSpacing: VISUAL_GRID_SPACING, // Visual grid spacing for wire interactions
27+
28+
// Logical grid (coordinate system)
29+
spacing: GRID_SPACING, // Logical grid unit spacing
30+
componentGridPoints: COMPONENT_GRID_POINTS, // Component grid span
31+
componentSpanPixels: COMPONENT_SPAN_PIXELS,
32+
2333
// Integration test compatibility properties
24-
pixelsPerGridUnit: GRID_SPACING, // 10 pixels per grid unit
25-
componentLogicalSpan: COMPONENT_GRID_POINTS, // 5 logical grid intervals
34+
pixelsPerGridUnit: GRID_SPACING,
35+
componentLogicalSpan: COMPONENT_GRID_POINTS,
2636
v1ComponentSpan: CoordinateAdapter.CONFIG.V1_COMPONENT_SPAN, // v1.0: 1 interval
27-
37+
2838
// Component height (2 grid points for visual appeal)
2939
componentHeightPixels: 2 * GRID_SPACING, // 20 pixels
30-
40+
3141
// Legacy compatibility
3242
legacyComponentGridPoints: 5, // Old system (migration)
33-
43+
3444
// Grid snapping utility function
3545
snapToGrid: (value) => Math.round(value / GRID_SPACING) * GRID_SPACING,
36-
46+
47+
// Visual grid snapping - snaps to visual grid increments
48+
snapToVisualGrid: (value) => Math.round(value / VISUAL_GRID_SPACING) * VISUAL_GRID_SPACING,
49+
3750
// Logical grid snapping (pixels to logical grid units)
3851
snapToLogicalGrid: (pixelValue) => Math.round(pixelValue / GRID_SPACING),
39-
52+
4053
// Convert logical grid units to pixels
4154
logicalToPixel: (logicalValue) => logicalValue * GRID_SPACING,
4255

4356

4457
// Calculate node positions for 2-node components using logical coordinates
45-
// For v2.0: components span 5 logical grid intervals (50 pixels)
58+
// Center position should follow mouse smoothly during preview, snapping happens on finalization
4659
calculateNodePositions: (centerX, centerY, angleRadians = 0) => {
4760
if (angleRadians === 0) {
48-
// Horizontal orientation: ensure nodes land on grid points
49-
// For 5-interval span, center must be at N+0.5 logical positions
50-
const centerLogicalX = Math.round(centerX / GRID_SPACING);
51-
const centerLogicalY = Math.round(centerY / GRID_SPACING);
52-
53-
// Offset by ±2.5 intervals, then round to nearest grid points
54-
const startLogicalX = Math.round(centerLogicalX - 2.5);
55-
const endLogicalX = Math.round(centerLogicalX + 2.5);
56-
61+
// Horizontal orientation: calculate nodes without snapping
62+
const halfSpanPixels = COMPONENT_SPAN_PIXELS / 2;
63+
64+
const startX = centerX - halfSpanPixels;
65+
const endX = centerX + halfSpanPixels;
66+
67+
// Return unsnapped positions for smooth movement
5768
return {
5869
start: {
59-
x: startLogicalX * GRID_SPACING,
60-
y: centerLogicalY * GRID_SPACING
70+
x: startX,
71+
y: centerY
6172
},
6273
end: {
63-
x: endLogicalX * GRID_SPACING,
64-
y: centerLogicalY * GRID_SPACING
74+
x: endX,
75+
y: centerY
6576
}
6677
};
6778
} else {
68-
// For other orientations, use trigonometry but snap to grid
69-
const halfSpanPixels = COMPONENT_SPAN_PIXELS / 2; // 30 pixels
70-
79+
// For other orientations, use trigonometry without snapping
80+
const halfSpanPixels = COMPONENT_SPAN_PIXELS / 2;
81+
7182
const startX = centerX - halfSpanPixels * Math.cos(angleRadians);
7283
const startY = centerY - halfSpanPixels * Math.sin(angleRadians);
7384
const endX = centerX + halfSpanPixels * Math.cos(angleRadians);
7485
const endY = centerY + halfSpanPixels * Math.sin(angleRadians);
75-
76-
// Snap both nodes to nearest grid points
86+
87+
// Return unsnapped positions for smooth movement
7788
return {
7889
start: {
79-
x: Math.round(startX / GRID_SPACING) * GRID_SPACING,
80-
y: Math.round(startY / GRID_SPACING) * GRID_SPACING
90+
x: startX,
91+
y: startY
8192
},
8293
end: {
83-
x: Math.round(endX / GRID_SPACING) * GRID_SPACING,
84-
y: Math.round(endY / GRID_SPACING) * GRID_SPACING
94+
x: endX,
95+
y: endY
8596
}
8697
};
8798
}

src/gui/adapters/GUIAdapter.js

Lines changed: 62 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export class GUIAdapter {
180180
/** @private */ this._onKeydown = null;
181181
/** @private */ this._onWheel = null;
182182
/** @private */ this._onImageLoaded = null;
183+
/** @private */ this._onDocMouseMove = null;
183184
}
184185

185186
/**
@@ -233,6 +234,7 @@ export class GUIAdapter {
233234
if (this._onKeydown) document.removeEventListener("keydown", this._onKeydown);
234235
if (this._onWheel) this.canvas.removeEventListener("wheel", this._onWheel);
235236
if (this._onImageLoaded) document.removeEventListener("renderer:imageLoaded", this._onImageLoaded);
237+
if (this._onDocMouseMove) document.removeEventListener("mousemove", this._onDocMouseMove);
236238
}
237239

238240
/* ---------------------------------------------------------------------- */
@@ -365,6 +367,23 @@ export class GUIAdapter {
365367
document.addEventListener("renderer:imageLoaded", this._onImageLoaded);
366368
}
367369

370+
/**
371+
* Track mouse position globally so currentMousePos is always up-to-date
372+
* when a toolbar button is clicked (mouse may be off-canvas at that moment).
373+
*/
374+
bindGlobalMouseTracking() {
375+
this._onDocMouseMove = (e) => {
376+
const rect = this.canvas.getBoundingClientRect();
377+
this.currentMousePos.x =
378+
(e.clientX - rect.left - this.circuitRenderer.offsetX) /
379+
this.circuitRenderer.scale;
380+
this.currentMousePos.y =
381+
(e.clientY - rect.top - this.circuitRenderer.offsetY) /
382+
this.circuitRenderer.scale;
383+
};
384+
document.addEventListener("mousemove", this._onDocMouseMove);
385+
}
386+
368387
/* ---------------------------------------------------------------------- */
369388
/* ACTION ROUTING (DECLARATIVE) */
370389
/* ---------------------------------------------------------------------- */
@@ -559,29 +578,29 @@ export class GUIAdapter {
559578

560579
// If placing an element, finalize its position on left click
561580
if (event.button === 0 && this.placingElement) {
562-
const snappedX = GRID_CONFIG.snapToGrid(offsetX);
563-
const snappedY = GRID_CONFIG.snapToGrid(offsetY);
581+
const snappedX = GRID_CONFIG.snapToVisualGrid(offsetX);
582+
const snappedY = GRID_CONFIG.snapToVisualGrid(offsetY);
564583

565584
// Get current orientation from element properties (preserve rotation)
566585
const currentOrientation = this.placingElement.properties?.values?.orientation || 0;
567-
const angleRad = (currentOrientation * Math.PI) / 180;
568-
569-
// For ground: shift the center so the visual icon center (not the
570-
// node midpoint) lands under the mouse cursor.
571-
let centerX = snappedX;
572-
let centerY = snappedY;
573-
if (this.placingElement.type === 'ground') {
574-
const visualOffset = GRID_CONFIG.componentSpanPixels / 2 + 20; // halfSpan + SCALED_WIDTH/2
575-
centerX += visualOffset * Math.cos(angleRad);
576-
centerY += visualOffset * Math.sin(angleRad);
577-
}
586+
// Ground's base 180° orientation is rendering-only for geometry placement.
587+
const nodeAngle = this.placingElement.type === 'ground'
588+
? currentOrientation - 180
589+
: currentOrientation;
590+
const angleRad = (nodeAngle * Math.PI) / 180;
591+
592+
// Use cursor position directly as center for all elements
593+
const centerX = snappedX;
594+
const centerY = snappedY;
578595

579596
// Use grid configuration to calculate proper node positions that align to grid
580597
const nodePositions = GRID_CONFIG.calculateNodePositions(centerX, centerY, angleRad);
581-
this.placingElement.nodes[0].x = nodePositions.start.x;
582-
this.placingElement.nodes[0].y = nodePositions.start.y;
583-
this.placingElement.nodes[1].x = nodePositions.end.x;
584-
this.placingElement.nodes[1].y = nodePositions.end.y;
598+
599+
// Snap node positions to visual grid when finalizing
600+
this.placingElement.nodes[0].x = GRID_CONFIG.snapToVisualGrid(nodePositions.start.x);
601+
this.placingElement.nodes[0].y = GRID_CONFIG.snapToVisualGrid(nodePositions.start.y);
602+
this.placingElement.nodes[1].x = GRID_CONFIG.snapToVisualGrid(nodePositions.end.x);
603+
this.placingElement.nodes[1].y = GRID_CONFIG.snapToVisualGrid(nodePositions.end.y);
585604

586605
this.circuitService.emit("update", {
587606
type: "finalizePlacement",
@@ -662,21 +681,24 @@ export class GUIAdapter {
662681

663682
// Live update for placing element
664683
if (this.placingElement) {
665-
const snappedX = GRID_CONFIG.snapToGrid(offsetX);
666-
const snappedY = GRID_CONFIG.snapToGrid(offsetY);
667-
668684
// Get current orientation from element properties (preserve rotation)
669685
const currentOrientation = this.placingElement.properties?.values?.orientation || 0;
670-
const angleRad = (currentOrientation * Math.PI) / 180;
671-
672-
// For ground: shift the center so the visual icon center follows the cursor.
673-
let centerX = snappedX;
674-
let centerY = snappedY;
675-
if (this.placingElement.type === 'ground') {
676-
const visualOffset = GRID_CONFIG.componentSpanPixels / 2 + 20;
677-
centerX += visualOffset * Math.cos(angleRad);
678-
centerY += visualOffset * Math.sin(angleRad);
679-
}
686+
// Ground's base 180° orientation is rendering-only for geometry placement.
687+
const nodeAngle = this.placingElement.type === 'ground'
688+
? currentOrientation - 180
689+
: currentOrientation;
690+
const angleRad = (nodeAngle * Math.PI) / 180;
691+
692+
// Ground's visible content spans SCALED_WIDTH/2 = 20px from connectionNode in the
693+
// body direction. Its visual center is 10px (SCALED_WIDTH/4) from connectionNode.
694+
// To place the cursor at the icon's visual center:
695+
// groundAdj = halfSpan - iconContentHalfWidth = 25 - 10 = 15
696+
const GROUND_CONTENT_HALF = 10; // GroundRenderer SCALED_WIDTH / 4
697+
const groundAdj = this.placingElement.type === 'ground'
698+
? GRID_CONFIG.componentSpanPixels / 2 - GROUND_CONTENT_HALF
699+
: 0;
700+
const centerX = offsetX + groundAdj * Math.cos(angleRad);
701+
const centerY = offsetY + groundAdj * Math.sin(angleRad);
680702

681703
// Use grid configuration to calculate proper node positions that align to grid
682704
const nodePositions = GRID_CONFIG.calculateNodePositions(centerX, centerY, angleRad);
@@ -765,38 +787,12 @@ export class GUIAdapter {
765787
// Clear existing selections and select only the placing element
766788
// This ensures rotation during placement only affects the placing element
767789
this.circuitRenderer.setSelectedElements([element]);
768-
769-
// Immediately position the element at the current mouse position
770-
// This prevents the element from staying at default coordinates until mouse movement
771-
const snappedX = GRID_CONFIG.snapToGrid(this.currentMousePos.x);
772-
const snappedY = GRID_CONFIG.snapToGrid(this.currentMousePos.y);
773-
774-
// Get current orientation from element properties (preserve rotation)
775-
const currentOrientation = element.properties?.values?.orientation || 0;
776-
const angleRad = (currentOrientation * Math.PI) / 180;
777-
778-
// For ground: shift the center so the visual icon center appears at the cursor.
779-
let centerX = snappedX;
780-
let centerY = snappedY;
781-
if (element.type === 'ground') {
782-
const visualOffset = GRID_CONFIG.componentSpanPixels / 2 + 20;
783-
centerX += visualOffset * Math.cos(angleRad);
784-
centerY += visualOffset * Math.sin(angleRad);
785-
}
786790

787-
// Use grid configuration to calculate proper node positions that align to grid
788-
const nodePositions = GRID_CONFIG.calculateNodePositions(centerX, centerY, angleRad);
789-
element.nodes[0].x = nodePositions.start.x;
790-
element.nodes[0].y = nodePositions.start.y;
791-
element.nodes[1].x = nodePositions.end.x;
792-
element.nodes[1].y = nodePositions.end.y;
793-
794-
// Emit update to immediately show the element at the correct position
795-
this.circuitService.emit("update", {
796-
type: "movePreview",
797-
element: element,
798-
});
799-
791+
// Move nodes off-screen so the element is invisible until the first canvas
792+
// mousemove positions it correctly. This avoids a visible jump from the
793+
// element's initial creation position (DEFAULT_X/Y) to the actual cursor.
794+
element.nodes.forEach(node => { node.x = -10000; node.y = -10000; });
795+
800796
// If user starts placing a non-wire element while in wire drawing mode, exit wire mode
801797
if (this.wireDrawingMode && element.type !== 'wire') {
802798
this.resetCursor();
@@ -1117,15 +1113,16 @@ export class GUIAdapter {
11171113
// node[0] is the fixed anchor; rotate node[1] around it (QuCat convention)
11181114
const anchor = this.placingElement.nodes[0];
11191115
const angleRad = (angle * Math.PI) / 180;
1120-
const cos = Math.round(Math.cos(angleRad));
1121-
const sin = Math.round(Math.sin(angleRad));
1116+
const cos = Math.cos(angleRad);
1117+
const sin = Math.sin(angleRad);
11221118

11231119
for (let i = 1; i < this.placingElement.nodes.length; i++) {
11241120
const relX = this.placingElement.nodes[i].x - anchor.x;
11251121
const relY = this.placingElement.nodes[i].y - anchor.y;
11261122

1127-
this.placingElement.nodes[i].x = GRID_CONFIG.snapToGrid(anchor.x + relX * cos - relY * sin);
1128-
this.placingElement.nodes[i].y = GRID_CONFIG.snapToGrid(anchor.y + relX * sin + relY * cos);
1123+
// Rotate around anchor without snapping during preview
1124+
this.placingElement.nodes[i].x = anchor.x + relX * cos - relY * sin;
1125+
this.placingElement.nodes[i].y = anchor.y + relX * sin + relY * cos;
11291126
}
11301127

11311128
// Emit update event for rotation

src/gui/commands/AddElementCommand.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export class AddElementCommand extends GUICommand {
2323
this.elementRegistry = elementRegistry;
2424
this.elementType = elementType;
2525

26-
// Defaults for positioning - ensure they're grid-aligned
26+
// Defaults for positioning - ensure they're grid-aligned to visual grid
2727
const defaultPos = new Position(400, 300);
28-
const snappedDefaults = CoordinateAdapter.snapToGrid(defaultPos);
29-
this.DEFAULT_X = snappedDefaults.x;
30-
this.DEFAULT_Y = snappedDefaults.y;
28+
const snappedDefaults = GRID_CONFIG.snapToVisualGrid(defaultPos.x);
29+
this.DEFAULT_X = snappedDefaults;
30+
this.DEFAULT_Y = GRID_CONFIG.snapToVisualGrid(defaultPos.y);
3131

3232
// Store current mouse position for placement mode
3333
this.currentMousePosition = null;
@@ -43,7 +43,7 @@ export class AddElementCommand extends GUICommand {
4343

4444
/**
4545
* Executes the command, creating an element with proper grid-based sizing.
46-
* For 2-node components, creates nodes that span exactly 5 grid intervals (50 pixels).
46+
* For 2-node components, creates nodes that span based on configured grid intervals.
4747
* Mouse position is snapped to logical grid for proper alignment.
4848
*
4949
* @param {Array<{x: number, y: number}>} customNodes - Optional custom node positions (for testing)
@@ -68,18 +68,17 @@ export class AddElementCommand extends GUICommand {
6868
let centerX, centerY;
6969

7070
if (this.currentMousePosition) {
71-
// Snap mouse position to logical grid first
72-
const snappedPixelPos = CoordinateAdapter.snapToGrid(this.currentMousePosition);
73-
centerX = snappedPixelPos.x;
74-
centerY = snappedPixelPos.y;
71+
// Use mouse position directly for smooth following (no snapping during preview)
72+
centerX = this.currentMousePosition.x;
73+
centerY = this.currentMousePosition.y;
7574
} else {
7675
// Use grid-aligned default position
7776
centerX = this.DEFAULT_X;
7877
centerY = this.DEFAULT_Y;
7978
}
8079

8180
// Calculate node positions using grid configuration
82-
// This ensures 2-node components span exactly 5 grid intervals (50 pixels)
81+
// This ensures 2-node components span based on configured grid intervals
8382
const nodePositions = GRID_CONFIG.calculateNodePositions(centerX, centerY, 0); // 0 degrees initially
8483

8584

@@ -89,8 +88,9 @@ export class AddElementCommand extends GUICommand {
8988
];
9089
}
9190

92-
// Create Properties instance with default orientation for all elements
93-
const properties = new Properties({ orientation: 0 });
91+
// Ground starts at 180° so newly created grounds match imported QuCat grounds.
92+
const defaultOrientation = this.elementType === "ground" ? 180 : 0;
93+
const properties = new Properties({ orientation: defaultOrientation });
9494

9595
// Normalize element type for registry lookup
9696
// Special case: "Wire" (capital) is used as a flag in GUIAdapter for wire drawing mode,

0 commit comments

Comments
 (0)