@@ -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
0 commit comments