11/*
2- Copyright 2025 Damian Nikodem
2+ Copyright 2025-2026 Damian Nikodem
33
44 This file is part of Universal Gcode Sender (UGS).
55
@@ -18,21 +18,32 @@ This file is part of Universal Gcode Sender (UGS).
1818 */
1919package com .willwinder .ugs .designer .actions ;
2020
21+ import com .willwinder .ugs .designer .entities .entities .Entity ;
22+ import com .willwinder .ugs .designer .entities .entities .controls .GridControl ;
23+ import com .willwinder .ugs .designer .gui .Drawing ;
2124import com .willwinder .ugs .designer .logic .Controller ;
2225import com .willwinder .ugs .designer .logic .ControllerFactory ;
26+ import com .willwinder .ugs .designer .print .DesignPrintable ;
27+ import com .willwinder .ugs .designer .print .EngineeringTemplateRenderer ;
28+ import com .willwinder .ugs .designer .print .PrintConfig ;
29+ import com .willwinder .ugs .designer .print .PrintPreviewDialog ;
30+ import com .willwinder .universalgcodesender .model .BackendAPI ;
31+ import com .willwinder .universalgcodesender .model .UnitUtils ;
32+ import com .willwinder .universalgcodesender .services .LookupService ;
2333import com .willwinder .universalgcodesender .utils .SvgIconLoader ;
2434
2535import javax .swing .JOptionPane ;
26- import java .awt .Graphics ;
36+ import java .awt .Color ;
2737import java .awt .Graphics2D ;
28- import java .awt .Point ;
38+ import java .awt .RenderingHints ;
2939import java .awt .event .ActionEvent ;
30- import java .awt .geom .Point2D ;
40+ import java .awt .geom .AffineTransform ;
41+ import java .awt .geom .Rectangle2D ;
3142import java .awt .image .BufferedImage ;
3243import java .awt .print .PageFormat ;
33- import java .awt .print .Printable ;
3444import java .awt .print .PrinterException ;
3545import java .awt .print .PrinterJob ;
46+ import java .time .LocalDate ;
3647
3748/**
3849 * @author Damian Nikodem
@@ -41,8 +52,11 @@ public class PrintDesignAction extends AbstractDesignAction {
4152 public static final String SMALL_ICON_PATH = "img/print.svg" ;
4253 public static final String LARGE_ICON_PATH = "img/print24.svg" ;
4354
44- private final static double MM_IN_INCH = 0.0393700787401575 ; // 5 / 127
45- private final static double PRINT_SYSTEM_DPI = 72.0 ;
55+ private static final double MM_IN_INCH = 0.0393700787401575 ; // 5 / 127
56+ private static final double PRINT_SYSTEM_DPI = 72.0 ;
57+ private static final double MM_PER_POINT = 25.4 / 72.0 ;
58+
59+ private String filenameHint = "" ;
4660
4761 public PrintDesignAction () {
4862 putValue ("iconBase" , SMALL_ICON_PATH );
@@ -52,78 +66,151 @@ public PrintDesignAction() {
5266 putValue (NAME , "Print" );
5367 }
5468
55- private int centerPos (double pageSize , double imageWidth ) {
56- int result = 0 ;
57- if ((imageWidth / pageSize ) < 1.0 ) {
58- result = (int ) ((pageSize - imageWidth ) / 2.0 );
59- }
60- return result ;
61- }
62-
63- private Point centerPoint (PageFormat pageFormat , BufferedImage bi ) {
64- return new Point (
65- centerPos (pageFormat .getImageableWidth (), bi .getWidth ()),
66- centerPos (pageFormat .getImageableHeight (), bi .getHeight ())
67- );
68- }
69-
70- private boolean translateGraphicsForPage (PageFormat pageFormat , BufferedImage bi , int pageIndex , Graphics2D g2d ) {
71- int xPageCount = (int ) Math .ceil (bi .getWidth () / pageFormat .getImageableWidth ());
72- int yPageCount = (int ) Math .ceil (bi .getHeight () / pageFormat .getImageableHeight ());
73-
74- int totalPages = xPageCount * yPageCount ;
75-
76- if (pageIndex >= totalPages ) {
77- return false ;
78- }
79-
80- int yPageIndex = (int ) Math .floor (pageIndex / xPageCount );
81- int xPageIndex = pageIndex % xPageCount ;
82-
83- g2d .translate (pageFormat .getImageableX () - (xPageIndex * pageFormat .getImageableWidth ()),
84- pageFormat .getImageableY () - (yPageIndex * pageFormat .getImageableHeight ()));
85- // Page is valid
86- return true ;
69+ /**
70+ * Platform wrappers set this so the printed title block can show the current file's name.
71+ * The core {@link Controller} has no file-path concept of its own.
72+ */
73+ public void setFilenameHint (String filenameHint ) {
74+ this .filenameHint = filenameHint == null ? "" : filenameHint ;
8775 }
8876
8977 @ Override
9078 public void actionPerformed (ActionEvent e ) {
91- // Render image first.
9279 Controller controller = ControllerFactory .getController ();
9380 controller .getSelectionManager ().clearSelection ();
9481
9582 PrinterJob job = PrinterJob .getPrinterJob ();
96- double oldScale = controller .getDrawing ().getScale ();
97- Point2D .Double oldPos = controller .getDrawing ().getPosition ();
98-
99- // Reset Scale so it draws properly.
100- controller .getDrawing ().setScale (MM_IN_INCH * PRINT_SYSTEM_DPI );
83+ PageFormat pageFormat = job .defaultPage ();
84+ pageFormat .setOrientation (PageFormat .LANDSCAPE );
10185
102- BufferedImage bi = controller .getDrawing ().getImage ();
86+ PrintConfig config = buildConfig (controller , pageFormat );
87+ BufferedImage bi = renderSourceImage (controller , config );
88+ config .setSourceImageSize (bi .getWidth (), bi .getHeight ());
10389
104- controller . getDrawing (). setScale ( oldScale );
105- controller . getDrawing (). setPosition ( oldPos . x , oldPos . y );
90+ EngineeringTemplateRenderer templateRenderer = new EngineeringTemplateRenderer ( );
91+ DesignPrintable printable = new DesignPrintable ( bi , config , templateRenderer );
10692
107- job .setPrintable ((Graphics graphics , PageFormat pageFormat , int pageIndex ) -> {
108- Graphics2D g2d = (Graphics2D ) graphics ;
93+ PrintPreviewDialog dialog = new PrintPreviewDialog (controller .getDrawing (), printable , job );
94+ dialog .setVisible (true );
95+ if (!dialog .isConfirmed ()) {
96+ return ;
97+ }
10998
110- if (!translateGraphicsForPage (pageFormat , bi , pageIndex , g2d )) {
111- return Printable .NO_SUCH_PAGE ;
99+ job .setPrintable (printable , config .getPageFormat ());
100+ if (job .printDialog ()) {
101+ try {
102+ job .print ();
103+ } catch (PrinterException exc ) {
104+ JOptionPane .showMessageDialog (null , "Error Printing: " + exc .getMessage (), "Error Printing!" ,
105+ JOptionPane .ERROR_MESSAGE );
112106 }
107+ }
108+ }
113109
114- Point imagePosition = centerPoint (pageFormat , bi );
115- g2d .drawImage (bi , null , imagePosition .x , imagePosition .y );
110+ private PrintConfig buildConfig (Controller controller , PageFormat pageFormat ) {
111+ PrintConfig config = new PrintConfig ();
112+ config .setPageFormat (pageFormat );
113+ config .setFilename (filenameHint );
114+ config .setAuthor (System .getProperty ("user.name" , "" ));
115+ config .setDate (LocalDate .now ());
116+ config .setIncludeTemplate (true );
117+
118+ BackendAPI backend = LookupService .lookup (BackendAPI .class );
119+ if (backend != null && backend .getSettings () != null
120+ && backend .getSettings ().getPreferredUnits () != null ) {
121+ config .setDimensionsUnits (backend .getSettings ().getPreferredUnits ());
122+ } else {
123+ config .setDimensionsUnits (UnitUtils .Units .MM );
124+ }
116125
117- return Printable .PAGE_EXISTS ;
118- });
126+ Rectangle2D designBounds = controller .getDrawing ().getRootEntity ().getBounds ();
127+ config .setDesignWidthMm (designBounds .getWidth ());
128+ config .setDesignHeightMm (designBounds .getHeight ());
129+ return config ;
130+ }
119131
120- if (job .printDialog ()) {
132+ /**
133+ * Renders the current design to a {@link BufferedImage} at printer DPI.
134+ *
135+ * <p>Bypasses {@link Drawing#getImage()} because that method derives both image size and
136+ * transform from {@code globalRoot.getBounds()}, which unions with empty controls anchored at
137+ * the origin and silently clips designs whose minX/minY are non-zero. Here we own the bounds
138+ * decision: when the design fits on a single page the image becomes page-sized with the
139+ * design centered; otherwise the image matches the design bounds, so the grid clips and the
140+ * page count is driven purely by the drawing extent.
141+ */
142+ private BufferedImage renderSourceImage (Controller controller , PrintConfig config ) {
143+ Drawing drawing = controller .getDrawing ();
144+ PageFormat pageFormat = config .getPageFormat ();
145+ double oldScale = drawing .getScale ();
146+ GridControl gridControl = drawing .getGridControl ();
147+
148+ double pageWidthMm = pageFormat .getImageableWidth () * MM_PER_POINT ;
149+ double pageHeightMm = pageFormat .getImageableHeight () * MM_PER_POINT ;
150+ Rectangle2D designBounds = drawing .getRootEntity ().getBounds ();
151+ Rectangle2D targetBounds = computeTargetBounds (designBounds , pageWidthMm , pageHeightMm );
152+
153+ double scale = MM_IN_INCH * PRINT_SYSTEM_DPI ;
154+ int imgWidth = Math .max (1 , (int ) Math .round (targetBounds .getWidth () * scale ));
155+ int imgHeight = Math .max (1 , (int ) Math .round (targetBounds .getHeight () * scale ));
156+
157+ BufferedImage bi = new BufferedImage (imgWidth , imgHeight , BufferedImage .TYPE_INT_RGB );
158+ Graphics2D g = bi .createGraphics ();
159+ try {
160+ g .setColor (Color .WHITE );
161+ g .fillRect (0 , 0 , imgWidth , imgHeight );
162+ g .setRenderingHint (RenderingHints .KEY_ANTIALIASING , RenderingHints .VALUE_ANTIALIAS_ON );
163+ g .setRenderingHint (RenderingHints .KEY_TEXT_ANTIALIASING , RenderingHints .VALUE_TEXT_ANTIALIAS_ON );
164+ g .setRenderingHint (RenderingHints .KEY_RENDERING , RenderingHints .VALUE_RENDER_QUALITY );
165+
166+ // GridControl.drawLargeGridAndText reads getClipBounds() to skip off-image grid lines;
167+ // it crashes if no clip is set. Set the clip in pixel space; once we apply the transform
168+ // the clip becomes the design-space rectangle that maps to the full image.
169+ g .setClip (0 , 0 , imgWidth , imgHeight );
170+
171+ // Map design (minX, minY) → pixel (0, imgHeight) and (maxX, maxY) → (imgWidth, 0).
172+ AffineTransform tx = new AffineTransform ();
173+ tx .scale (1 , -1 );
174+ tx .translate (0 , -imgHeight );
175+ tx .scale (scale , scale );
176+ tx .translate (-targetBounds .getMinX (), -targetBounds .getMinY ());
177+ g .setTransform (tx );
178+
179+ drawing .setScale (scale );
121180 try {
122- job .print ();
123- } catch (PrinterException exc ) {
124- JOptionPane .showMessageDialog (null , "Error Printing: " + exc .getMessage (), "Error Printing!" , JOptionPane .ERROR_MESSAGE );
181+ gridControl .setPrintBoundsOverride (targetBounds );
182+ try {
183+ gridControl .render (g , drawing );
184+ } finally {
185+ gridControl .setPrintBoundsOverride (null );
186+ }
187+ for (Entity entity : drawing .getRootEntity ().getChildren ()) {
188+ entity .render (g , drawing );
189+ }
190+ } finally {
191+ drawing .setScale (oldScale );
192+ drawing .refresh ();
125193 }
194+ } finally {
195+ g .dispose ();
126196 }
197+ return bi ;
127198 }
128199
200+ private static Rectangle2D computeTargetBounds (Rectangle2D designBounds , double pageWidthMm , double pageHeightMm ) {
201+ double designW = designBounds .getWidth ();
202+ double designH = designBounds .getHeight ();
203+ if (designW <= pageWidthMm && designH <= pageHeightMm ) {
204+ // Single page — center a page-sized rectangle on the design's center so the design
205+ // ends up centered within the rendered image.
206+ double cx = (designBounds .getMinX () + designBounds .getMaxX ()) / 2.0 ;
207+ double cy = (designBounds .getMinY () + designBounds .getMaxY ()) / 2.0 ;
208+ return new Rectangle2D .Double (
209+ cx - pageWidthMm / 2.0 , cy - pageHeightMm / 2.0 , pageWidthMm , pageHeightMm );
210+ }
211+ // Multi-page — image matches design exactly so the grid clips at design bounds and the
212+ // page count follows the drawing's actual extent.
213+ return new Rectangle2D .Double (
214+ designBounds .getMinX (), designBounds .getMinY (), designW , designH );
215+ }
129216}
0 commit comments