CAM: Fix CAM Sanity Report

Fix base template substitution and improve tool/op formatting in sanity report, also update HTML/CSS and image handling.

- Major HTML/CSS refactor for CAM Sanity Report template:
  - Rewrote HTMLTemplate.py with modern, responsive CSS, semantic HTML, and accessibility improvements.
  - Added CSS reset, responsive image handling, and improved table/list styling.
  - Introduced .heading-container and .top-link for right-aligned "Top" navigation links on all major sections and tool headers (hidden in print).
  - Updated all section and tool headers to use new navigation and layout.
  - Cleaned up legacy markup, removed inline styles, and standardized variable substitution using string.Template syntax (${key}, ${val}).
  - Updated base_template in HTMLTemplate.py to use string.Template syntax (${key}, ${val}) instead of %{key}, %{val} for correct variable substitution.

- Enhanced image generation and embedding:
  - Updated ImageBuilder to support high-DPI (800x800) images and direct byte output for embedding.
  - All report images (base, stock, datum, tool) now use in-memory bytes for embedding when possible.
  - Tool images support a toggle for using toolbit thumbnails or fallback head-on renders.
  - ReportGenerator now embeds images as base64 when requested, with correct HTML tags.

- Improved squawk, tool, and operation data formatting:
  - Squawk dates now use localized string formatting.
  - Tool diameter and feedrate now use .UserString for better display.
  - Spindle speed now formatted as integer with "rpm" suffix.
  - Operation feed and speed values also use .UserString and "rpm" formatting.
  - Fixed _format_bases in ReportGenerator.py to iterate over base_data.items() and pass {"key": key, "val": val} to the template, ensuring all bases are listed correctly.
  - General code cleanup and improved maintainability throughout the CAM Sanity reporting stack.
This commit is contained in:
Billy Huddleston
2025-09-18 18:58:59 -04:00
parent dd08f6c845
commit c2d8077d96
4 changed files with 700 additions and 249 deletions

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 Ondsel <development@ondsel.com> *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
@@ -33,304 +34,627 @@ html_template = Template(
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<title>Setup Report for FreeCAD Job: Path Special</title>
<style type="text/css">
/* Reset margins and padding */
div, dl, dt, dd, ul, ol, li, h1, h2, h3, #toctitle, .sidebarblock>.content>.title, h4, h5, h6, pre, form, p, blockquote, th, td {
margin: 0;
padding: 0;
}
/* Responsive image handling */
img, object, embed {
max-width: 100%;
height: auto;
}
/* Base styling */
body {
background-color: #FFFFFF;
color: #000000;
font-family: "Open Sans, DejaVu Sans, sans-serif";
background: #fff;
color: rgba(0,0,0,.8);
padding: 0;
margin: 0;
font-family: "Noto Serif", "DejaVu Serif", serif;
line-height: 1;
position: relative;
cursor: auto;
word-wrap: anywhere;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
max-width: 62.5em;
margin-left: auto;
margin-right: auto;
padding: 20px;
}
h2.western, .ToC {
font-size: 20pt;
/* Container styling */
.center-container {
max-width: 62.5em;
margin-left: auto;
margin-right: auto;
padding: 20px;
}
/* Headings */
h1, h2, h3, #toctitle, h4, h5, h6 {
font-family: "Open Sans", "DejaVu Sans", sans-serif;
font-weight: 300;
font-style: normal;
color: #ba3925;
margin-bottom: 0.5cm;
text-rendering: optimizeLegibility;
margin-top: 1em;
margin-bottom: .5em;
line-height: 1.0125em;
}
a.customLink {
h1 {
font-size: 2.125em;
}
h2 {
font-size: 1.6875em;
}
h3 {
font-size: 1.375em;
}
h4, h5 {
font-size: 1.125em;
}
h6 {
font-size: 1em;
}
#toctitle {
font-size: 1.2em;
}
@media screen and (min-width: 768px) {
#toctitle {
font-size: 1.375em;
}
}
/* Media query for larger screens */
@media screen and (min-width: 768px) {
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
}
h1 {
font-size: 2.75em;
}
h2 {
font-size: 2.3125em;
}
h3 {
font-size: 1.6875em;
}
h4 {
font-size: 1.4375em;
}
}
/* Links */
a {
color: #2156a5;
text-decoration: underline;
line-height: inherit;
}
/* TOC styling */
#toc {
border-bottom: 1px solid #e7e7e9;
padding-bottom: .5em;
}
#header>h1:first-child+#toc {
margin-top: 8px;
border-top: 1px solid #dddddf;
}
#toc>ul {
margin-left: .125em;
}
#toc ul {
font-family: "Open Sans", "DejaVu Sans", sans-serif;
list-style-type: none;
}
#toc li {
line-height: 1.3334;
margin-top: .3334em;
}
#toc a {
text-decoration: none;
}
a:hover, a:focus {
color: #1d4b8f;
}
/* Lists */
ul, ol, dl {
line-height: 1.6;
margin-bottom: 1.25em;
list-style-position: outside;
font-family: inherit;
margin-left: 1.5em;
}
ul {
list-style: disc;
}
ul li ul, ul li ol {
margin-left: 1.25em;
margin-bottom: 0;
}
/* TOC section levels */
#toc ul.sectlevel0>li>a {
font-style: italic;
}
#toc ul.sectlevel0 ul.sectlevel1 {
margin: .5em 0;
}
@media screen and (min-width: 768px) {
#toc.toc2 ul ul {
margin-left: 0;
padding-left: 1em;
}
#toc.toc2 ul.sectlevel0 ul.sectlevel1 {
padding-left: 0;
margin-top: .5em;
margin-bottom: .5em;
}
}
@media screen and (min-width: 1280px) {
#toc.toc2 ul ul {
padding-left: 1.25em;
}
}
/* Responsive image handling at different screen sizes */
@media screen and (max-width: 768px) {
td.image-container {
max-width: 100%;
display: block;
margin: 1em auto;
}
table td[rowspan] {
display: table-cell;
}
img {
max-width: 100%;
height: auto;
}
}
/* Tables - more detailed styling */
table {
background: #fff;
margin-bottom: 1.25em;
border: 1px solid #dedede;
word-wrap: normal;
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
table thead, table tfoot {
background: #f7f8f7;
}
table thead tr th, table thead tr td, table tfoot tr th, table tfoot tr td {
padding: .5em .625em .625em;
font-size: inherit;
color: rgba(0,0,0,.8);
text-align: left;
}
table tr th, table tr td {
padding: .5625em .625em;
font-size: inherit;
color: rgba(0,0,0,.8);
line-height: 1.6;
border: 1px solid #dedede;
}
/* Anchor links styling */
#content h1>a.anchor,h2>a.anchor,h3>a.anchor,#toctitle>a.anchor,.sidebarblock>.content>.title>a.anchor,h4>a.anchor,h5>a.anchor,h6>a.anchor{
position:absolute;
z-index:1001;
width:1.5ex;
margin-left:-1.5ex;
display:block;
text-decoration:none!important;
visibility:hidden;
text-align:center;
font-weight:400
}
table tr.even, table tr.alt {
background: #f8f8f7;
}
/* Image container styling */
td.image-container {
vertical-align: middle;
text-align: center;
width: 40%;
max-width: 300px;
}
td.image-container img {
max-width: 100%;
height: auto;
object-fit: contain;
}
/* Text styling */
p {
line-height: 1.6;
margin-bottom: 1.25em;
text-rendering: optimizeLegibility;
}
strong, b {
font-weight: bold;
line-height: inherit;
}
em, i {
font-style: italic;
line-height: inherit;
}
/* Top navigation links - base style */
.top-link {
display: inline-block;
float: right;
font-size: 0.8em;
font-weight: normal;
text-transform: uppercase;
color: #2156a5;
text-decoration: none;
font-size: 12pt;
position: relative;
}
ul {
padding-left: 0;
list-style: none;
/* Specific positioning for h2 headings */
.heading-container h2 + .top-link {
margin-top: 4.75em;
bottom: 0.3em;
}
ul.subList {
padding-left: 20px;
/* Specific positioning for h3 headings */
.heading-container h3 + .top-link {
margin-top: 3.5em;
bottom: 0.3em;
}
li.subItem {
padding-top: 5px;
/* Fallbacks in case the adjacent sibling selector doesn't work as expected */
.heading-container:has(h2) .top-link {
margin-top: 4.75em;
}
.heading-container:has(h3) .top-link {
margin-top: 3.5em;
}
/* Clearfix for headings with top links */
.heading-container {
overflow: hidden;
width: 100%;
position: relative;
}
.heading-container h2,
.heading-container h3 {
float: left;
margin-bottom: 0;
}
/* Hide top links when printing */
@media print {
.top-link {
display: none;
}
}
</style>
</head>
<body>
<div id="header" class="center-container">
<h1>${headingLabel}: ${JobLabel}</h1>
<div id="toc">
<h2 class="ToC">${tableOfContentsLabel}</h2>
<ul>
<li><a class="customLink" href="#_part_information">${partInformationLabel}</a></li>
<li><a class="customLink" href="#_run_summary">${runSummaryLabel}</a></li>
<li><a class="customLink" href="#_rough_stock">${roughStockLabel}</a></li>
<li><a class="customLink" href="#_tool_data">${toolDataLabel}</a>
<ul class="subList">
<div id="toc" class="toc">
<div id="toctitle">${tableOfContentsLabel}</div>
<ul class="sectlevel1">
<li><a href="#_part_information">${partInformationLabel}</a></li>
<li><a href="#_run_summary">${runSummaryLabel}</a></li>
<li><a href="#_rough_stock">${roughStockLabel}</a></li>
<li><a href="#_tool_data">${toolDataLabel}</a>
<ul class="sectlevel2">
${tool_list}
</ul>
</li>
<li><a class="customLink" href="#_output">${outputLabel}</a></li>
<li><a class="customLink" href="#_fixtures_and_workholding">${fixturesLabel}</a></li>
<li><a class="customLink" href="#_squawks">${squawksLabel}</a></li>
<li><a href="#_output">${outputLabel}</a></li>
<li><a href="#_fixtures_and_workholding">${fixturesLabel}</a></li>
<li><a href="#_squawks">${squawksLabel}</a></li>
</ul>
</div>
<h2 class="western"><a name="_part_information"></a>${partInformationLabel}</h2>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<div class="heading-container">
<h2 id="_part_information"><a name="_part_information"></a>${partInformationLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table>
<colgroup>
<col width="200"/>
<col width="525"/>
<col width="250"/>
<col width="20%"/>
<col width="50%"/>
<col width="30%"/>
</colgroup>
<tr valign="top">
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<tr>
<td>
<strong>${PartLabel}</strong>
</td>
<td>
<table style="background-color: #ffffff;">
<table>
<colgroup>
<col width="175"/>
<col width="175"/>
<col width="50%"/>
<col width="50%"/>
</colgroup>
${bases}
</table>
</td>
<td rowspan="7" style="border: 1px solid #dedede; padding: 0.05cm;">
<td rowspan="7" class="image-container">
${baseimage}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
<strong>${SequenceLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
${Sequence}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
<strong>${JobTypeLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
${JobType}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
<strong>${CADLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
${FileName}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
<strong>${LastSaveLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
${LastModifiedDate}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
<strong>${CustomerLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm;">
<td>
${Customer}
</td>
</tr>
</table>
<h2 class="western"><a name="_run_summary"></a>${runSummaryLabel}</h2>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<div class="heading-container">
<h2 id="_run_summary"><a name="_run_summary"></a>${runSummaryLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table>
<colgroup>
<col width="210"/>
<col width="210"/>
<col width="210"/>
<col width="210"/>
<col width="210"/>
<col width="20%"/>
<col width="20%"/>
<col width="20%"/>
<col width="20%"/>
<col width="20%"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<strong>${opLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<strong>${jobMinZLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<strong>${jobMaxZLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<strong>${coolantLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<strong>${cycleTimeLabel}</strong>
</td>
</tr>
${run_summary_ops}
<thead>
<tr>
<th><strong>${opLabel}</strong></th>
<th><strong>${jobMinZLabel}</strong></th>
<th><strong>${jobMaxZLabel}</strong></th>
<th><strong>${coolantLabel}</strong></th>
<th><strong>${cycleTimeLabel}</strong></th>
</tr>
</thead>
<tbody>
${run_summary_ops}
</tbody>
</table>
<h2 class="western"><a name="_rough_stock"></a>${roughStockLabel}</h2>
<div class="heading-container">
<h2 id="_rough_stock"><a name="_rough_stock"></a>${roughStockLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<table>
<colgroup>
<col width="350"/>
<col width="350"/>
<col width="350"/>
<col width="30%"/>
<col width="30%"/>
<col width="40%"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${materialLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${material}</td>
<td rowspan="7" style="border: 1px solid #dedede; padding: 0.05cm">
${stockImage}
</td>
<tbody>
<tr>
<td><strong>${materialLabel}</strong></td>
<td>${material}</td>
<td rowspan="7" class="image-container">
${stockImage}
</td>
</tr>
<tr>
<td><strong>${sSpeedHSSLabel}</strong></td>
<td>${surfaceSpeedHSS}</td>
</tr>
<tr>
<td><strong>${sSpeedCarbideLabel}</strong></td>
<td>${surfaceSpeedCarbide}</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${sSpeedHSSLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${surfaceSpeedHSS}</td>
<td><strong>${xDimLabel}</strong></td>
<td>${xLen}</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${sSpeedCarbideLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${surfaceSpeedCarbide}</td>
<td><strong>${yDimLabel}</strong></td>
<td>${yLen}</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${xDimLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${xLen}</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${yDimLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${yLen}</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm"><strong>${zDimLabel}</strong></td>
<td style="border: 1px solid #dedede; padding: 0.05cm">${zLen}</td>
<td><strong>${zDimLabel}</strong></td>
<td>${zLen}</td>
</tr>
</table>
<h2 class="western"><a name="_tool_data"></a>${toolDataLabel}</h2>
<div class="heading-container">
<h2 id="_tool_data"><a name="_tool_data"></a>${toolDataLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
${tool_data}
<h2 class="western"><a name="_output"></a>${outputLabel}</h2>
<div class="heading-container">
<h2 id="_output"><a name="_output"></a>${outputLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<colgroup>
<col width="525"/>
<col width="525"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${gcodeFileLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${lastgcodefile}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${lastpostLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${lastpostprocess}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${stopsLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${optionalstops}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${programmerLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${programmer}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${machineLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${machine}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${postLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${postprocessor}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${flagsLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${postprocessorFlags}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${fileSizeLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${filesize}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${lineCountLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${linecount}
</td>
</tr>
</table>
<h2 class="western"><a name="_fixtures_and_workholding"></a>${fixturesLabel}</h2>
<div class="heading-container">
<h2 id="_fixtures_and_workholding"><a name="_fixtures_and_workholding"></a>${fixturesLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<colgroup>
<col width="525"/>
<col width="525"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${offsetsLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${fixtures}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${orderByLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${orderBy}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${datumLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td class="image-container">
${datumImage}
</td>
</tr>
</table>
<h2 class="western"><a name="_squawks"></a>${squawksLabel}</h2>
<div class="heading-container">
<h2 id="_squawks"><a name="_squawks"></a>${squawksLabel}</h2><a href="#header" class="top-link">Top</a>
</div>
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background-color: #ffffff;">
<colgroup>
<col width="100"/>
@@ -339,16 +663,16 @@ ${tool_data}
<col width="550"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${noteLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${operatorLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${dateLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${noteLabel}</strong>
</td>
${squawks}
@@ -356,8 +680,8 @@ ${tool_data}
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
</div>
</body>
</html>
"""
@@ -366,11 +690,11 @@ ${tool_data}
base_template = Template(
"""
<tr>
<td style='border: 1px solid #dedede; padding: 0.05cm;'>
%{key}
<td>
${key}
</td>
<td style='border: 1px solid #dedede; padding: 0.05cm;'>
%{val}
<td>
${val}
</td>
</tr>
"""
@@ -379,16 +703,16 @@ base_template = Template(
squawk_template = Template(
"""
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${squawkIcon}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${Operator}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${Date}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="3">
<td colspan="3">
${Note}
</td>
</tr>
@@ -397,68 +721,71 @@ squawk_template = Template(
tool_template = Template(
"""
<table cellpadding="2" cellspacing="2" bgcolor="#ffffff" style="background: #ffffff;">
<div class="heading-container">
<h3 id="_tool_data_T${toolNumber}">Tool Number: T${toolNumber}</h3><a href="#header" class="top-link">Top</a>
</div>
<table>
<colgroup>
<col width="350"/>
<col width="350"/>
<col width="350"/>
<col width="30%"/>
<col width="30%"/>
<col width="40%"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${descriptionLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${description}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td class="image-container">
${imagepath}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${manufLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${manufacturer}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${partNumberLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${partNumber}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${urlLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${url}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${shapeLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${shape}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${inspectionNotesLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${inspectionNotes}
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${diameterLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm" colspan="2">
<td colspan="2">
${diameter}
</td>
</tr>
@@ -477,30 +804,30 @@ op_tool_template = Template(
<col width="262"/>
</colgroup>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${opLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${tcLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${feedLabel}</strong>
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
<strong>${speedLabel}</strong>
</td>
</tr>
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${Operation}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${ToolController}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${Feed}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${Speed}
</td>
</tr>
@@ -511,19 +838,19 @@ op_tool_template = Template(
op_run_template = Template(
"""
<tr>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${opName}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${minZ}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${maxZ}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${coolantMode}
</td>
<td style="border: 1px solid #dedede; padding: 0.05cm">
<td>
${cycleTime}
</td>
</tr>
@@ -532,6 +859,6 @@ op_run_template = Template(
tool_item_template = Template(
"""
<li class="subItem"><a class="customLink" href="#_tool_data_T${toolNumber}">T${toolNumber}-${description}</a></li>
<li><a href="#_tool_data_T${toolNumber}">Tool Number: T${toolNumber}</a></li>
"""
)

View File

@@ -1,5 +1,6 @@
# ***************************************************************************
# * Copyright (c) 2024 Ondsel <development@ondsel.com> *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This file is part of the FreeCAD CAx development system. *
# * *
@@ -21,12 +22,13 @@
# * *
# ***************************************************************************
from PySide import QtGui
from PySide import QtGui, QtCore
import FreeCAD
import FreeCADGui
import Path.Log
import os
import time
import tempfile
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -39,7 +41,7 @@ class ImageBuilder:
def __init__(self, file_path):
self.file_path = file_path
def build_image(self, obj, image_name):
def build_image(self, obj, image_name, as_bytes=False, view="default"):
raise NotImplementedError("Subclass must implement abstract method")
def save_image(self, image):
@@ -49,7 +51,6 @@ class ImageBuilder:
class ImageBuilderFactory:
@staticmethod
def get_image_builder(file_path, **kwargs):
# return DummyImageBuilder(file_path, **kwargs)
if FreeCAD.GuiUp:
return GuiImageBuilder(file_path, **kwargs)
@@ -63,7 +64,9 @@ class DummyImageBuilder(ImageBuilder):
Path.Log.debug("Initializing dummyimagebuilder")
super().__init__(file_path)
def build_image(self, obj, imageName):
def build_image(self, obj, imageName, as_bytes=False, view="default"):
if as_bytes:
return b""
return self.file_path
@@ -85,7 +88,7 @@ class GuiImageBuilder(ImageBuilder):
Path.Log.debug("Destroying GuiImageBuilder")
self.restore_visibility()
def prepare_view(self, obj):
def prepare_view(self, obj, view="default"):
# Create a new view
Path.Log.debug("CAM - Preparing view\n")
@@ -93,10 +96,13 @@ class GuiImageBuilder(ImageBuilder):
num_windows = len(mw.getWindows())
# Create and configure the view
view = FreeCADGui.ActiveDocument.createView("Gui::View3DInventor")
view.setAnimationEnabled(False)
view.viewIsometric()
view.setCameraType("Perspective")
view_obj = FreeCADGui.ActiveDocument.createView("Gui::View3DInventor")
view_obj.setAnimationEnabled(False)
view_obj.setCameraType("Orthographic")
if view == "headon":
view_obj.viewFront()
else:
view_obj.viewIsometric()
# Resize the window
mdi = mw.findChild(QtGui.QMdiArea)
@@ -104,10 +110,24 @@ class GuiImageBuilder(ImageBuilder):
view_window.resize(500, 500)
view_window.showMaximized()
FreeCADGui.Selection.clearSelection()
# First make everything invisible
self.record_visibility()
# Then make only our target object visible and select it
obj.Visibility = True
FreeCADGui.Selection.clearSelection()
FreeCADGui.Selection.addSelection(obj)
FreeCADGui.Selection.clearSelection() # Clear so the selection highlight does not appear in the image
# Get the active view and fit to selection
a_view = FreeCADGui.activeDocument().activeView()
try:
a_view.fitAll() # First fit all to ensure the object is in view
FreeCADGui.updateGui()
a_view.fitSelection() # Then try to fit to the selection
except Exception:
# If fitSelection fails, we already called fitAll
pass
# Return the index of the new window (= old number of windows)
return num_windows
@@ -128,41 +148,90 @@ class GuiImageBuilder(ImageBuilder):
for o in self.visible:
o.Visibility = True
def build_image(self, obj, image_name):
def build_image(self, obj, image_name, as_bytes=False, view="default"):
Path.Log.debug("CAM - Building image\n")
"""
Makes an image of the target object. Returns filename.
Makes an image of the target object. Returns either the image as bytes or a filename.
"""
file_path = os.path.join(self.file_path, image_name)
idx = self.prepare_view(obj, view=view)
idx = self.prepare_view(obj)
self.capture_image(file_path)
self.destroy_view(idx)
result = f"{file_path}_t.png"
Path.Log.debug(f"Saving image to: {file_path}")
Path.Log.debug(f"Image saved to: {result}")
return result
if as_bytes:
# Capture directly to memory without writing to disk
img_bytes = self.capture_image_to_bytes()
self.destroy_view(idx)
return img_bytes
else:
# Write to disk as before
file_path = os.path.join(self.file_path, image_name)
self.capture_image(file_path)
self.destroy_view(idx)
result = f"{file_path}_t.png"
Path.Log.debug(f"Image saved to: {result}")
return result
def capture_image(self, file_path):
FreeCADGui.updateGui()
Path.Log.debug("CAM - capture image\n")
Path.Log.debug("CAM - capture image to file\n")
a_view = FreeCADGui.activeDocument().activeView()
a_view.saveImage(file_path + ".png", 500, 500, "Current")
a_view.saveImage(file_path + "_t.png", 500, 500, "Transparent")
# Generate higher resolution images - 800x800 pixels for better quality on high-DPI displays
a_view.saveImage(file_path + ".png", 800, 800, "Current")
a_view.saveImage(file_path + "_t.png", 800, 800, "Transparent")
a_view.setAnimationEnabled(True)
def capture_image_to_bytes(self):
"""Capture the current view directly to bytes without writing to disk"""
FreeCADGui.updateGui()
Path.Log.debug("CAM - capture image to bytes\n")
a_view = FreeCADGui.activeDocument().activeView()
try:
# Use FreeCAD's built-in method for getting a QImage directly
# This approach is based on the same method the viewport uses internally
qimg = a_view.grabFramebuffer()
# Convert QImage to bytes using QBuffer (purely in memory)
buffer = QtCore.QBuffer()
buffer.open(QtCore.QIODevice.WriteOnly)
qimg.save(buffer, "PNG")
img_bytes = buffer.data().data()
buffer.close()
a_view.setAnimationEnabled(True)
return img_bytes
except Exception as e:
# Fallback to temporary file approach if the direct method fails
Path.Log.debug(f"Direct image capture failed: {e}, using fallback method")
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp:
temp_path = temp.name
# saveImage doesn't write to memory directly, so we need to use a temporary file
# Generate higher resolution images - 800x800 pixels for better quality on high-DPI displays
a_view.saveImage(temp_path, 800, 800, "Transparent")
# Read the temporary file into memory
with open(temp_path, "rb") as f:
img_bytes = f.read()
# Clean up the temporary file
try:
os.unlink(temp_path)
except Exception:
# Ignore errors during temporary file cleanup, as failure to delete is non-critical
pass
a_view.setAnimationEnabled(True)
return img_bytes
class NonGuiImageBuilder(ImageBuilder):
def __init__(self, file_path):
super().__init__(file_path)
Path.Log.debug("nonguiimagebuilder")
def build_image(self, obj, image_name):
def build_image(self, obj, image_name, as_bytes=False, view="default"):
"""
Generates a headless picture of a 3D object and saves it as a PNG and optionally a PostScript file.
@@ -212,20 +281,20 @@ class NonGuiImageBuilder(ImageBuilder):
root.ref()
ret = off.render(root)
root.unref()
# Saving the rendered image
if off.isWriteSupported("PNG"):
file_path = f"{self.file_path}{os.path.sep}{imageName}.png"
off.writeToFile(file_path, "PNG")
if as_bytes:
qimg = off.getQImage()
buffer = QtCore.QBuffer()
buffer.open(QtCore.QIODevice.WriteOnly)
qimg.save(buffer, "PNG")
return buffer.data().data()
else:
Path.Log.debug("PNG format is not supported.")
# return False
# Optionally save as PostScript if supported
file_path = f"{self.file_path}{os.path.sep}{imageName}.ps"
off.writeToPostScript(ps_file_path)
return file_path
if off.isWriteSupported("PNG"):
file_path = f"{self.file_path}{os.path.sep}{image_name}.png"
off.writeToFile(file_path, "PNG")
return file_path
else:
Path.Log.debug("PNG format is not supported.")
return False
except Exception as e:
print(f"An error occurred: {e}")

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 Ondsel <development@ondsel.com> *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
@@ -46,6 +47,18 @@ else:
class ReportGenerator:
def bytes_to_base64_with_tag(self, image_bytes, mime_type="image/png", alt="Image"):
"""
Takes image bytes and returns (base64_string, <img> tag) for embedding in HTML.
Default mime_type is image/png.
"""
if not image_bytes:
return "", ""
encoded_string = base64.b64encode(image_bytes).decode()
html_tag = f'<img src="data:{mime_type};base64,{encoded_string}" alt="{alt}" />'
return encoded_string, html_tag
def __init__(self, data, embed_images=False):
self.embed_images = embed_images
self.squawks = ""
@@ -128,7 +141,12 @@ class ReportGenerator:
Path.Log.debug(f"key: {key} val: {val}")
if self.embed_images:
Path.Log.debug("Embedding images")
encoded_image, tag = self.file_to_base64_with_tag(val)
if isinstance(val, bytes):
encoded_image, tag = self.bytes_to_base64_with_tag(
val, mime_type="image/png", alt=key
)
else:
encoded_image, tag = self.file_to_base64_with_tag(val)
else:
Path.Log.debug("Not Embedding images")
tag = f"<img src={val} name='Image' alt={key} />"
@@ -140,13 +158,20 @@ class ReportGenerator:
for key, val in data["toolData"].items():
if key == "squawkData":
self._format_squawks(val)
# else:
# self._format_tool(key, val)
else:
toolNumber = key
toolAttributes = val
if "imagepath" in toolAttributes and toolAttributes["imagepath"] != "":
# Prefer imagebytes for embedding if present
if (
self.embed_images
and "imagebytes" in toolAttributes
and toolAttributes["imagebytes"]
):
_, tag = self.bytes_to_base64_with_tag(
toolAttributes["imagebytes"], mime_type="image/png", alt=key
)
toolAttributes["imagepath"] = tag
elif "imagepath" in toolAttributes and toolAttributes["imagepath"] != "":
if self.embed_images:
encoded_image, tag = self.file_to_base64_with_tag(
toolAttributes["imagepath"]
@@ -165,7 +190,6 @@ class ReportGenerator:
# Path.Log.debug(self.formatted_data)
def _format_tool_list(self, tool_data):
tool_list = ""
for key, val in tool_data.items():
if key == "squawkData":
@@ -181,6 +205,8 @@ class ReportGenerator:
def _format_tool(self, tool_number, tool_data):
td = {}
td["toolNumber"] = tool_number
for key, val in tool_data.items():
if key == "squawkData":
self._format_squawks(val)
@@ -200,8 +226,8 @@ class ReportGenerator:
def _format_bases(self, base_data):
bases = ""
for base in base_data:
bases += base_template.substitute(base)
for key, val in base_data.items():
bases += base_template.substitute({"key": key, "val": val})
self.formatted_data["bases"] = bases
def _format_squawks(self, squawk_data):

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2016 sliptonic <shopinthewoods@gmail.com> *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This file is part of the FreeCAD CAx development system. *
# * *
@@ -36,7 +37,6 @@ import Path.Log
import Path.Main.Sanity.ImageBuilder as ImageBuilder
import Path.Main.Sanity.ReportGenerator as ReportGenerator
import os
import tempfile
import Path.Dressup.Utils as PathDressup
translate = FreeCAD.Qt.translate
@@ -49,6 +49,9 @@ else:
class CAMSanity:
# Toggle: True = use thumbnail, False = always use fallback image
USE_TOOL_THUMBNAIL = False
"""
This class has the functionality to harvest data from a CAM Job
and export it in a format that is useful to the user.
@@ -102,8 +105,9 @@ class CAMSanity:
path = f"{FreeCAD.getHomePath()}Mod/CAM/Path/Main/Sanity/{squawk_icon}.svg"
local_date_str = date.strftime("%c")
squawk = {
"Date": str(date),
"Date": local_date_str,
"Operator": operator,
"Note": note,
"squawkType": squawkType,
@@ -120,7 +124,9 @@ class CAMSanity:
[obj.Proxy.baseObject(obj, o).Label for o in obj.Model.Group]
).items():
bases[name] = str(count)
data["baseimage"] = self.image_builder.build_image(obj.Model, "baseimage")
data["baseimage"] = self.image_builder.build_image(
obj.Model, "baseimage", as_bytes=True
)
data["bases"] = bases
return data
@@ -146,7 +152,15 @@ class CAMSanity:
data["FileName"] = obj.Document.FileName
data["LastModifiedDate"] = str(obj.Document.LastModifiedDate)
data["Customer"] = obj.Document.Company
data["Designer"] = obj.Document.LastModifiedBy
lastmod = obj.Document.LastModifiedDate
if lastmod:
try:
# Parse ISO 8601 string and format
data["LastModifiedDate"] = datetime.fromisoformat(str(lastmod)).strftime("%c")
except Exception:
data["LastModifiedDate"] = str(lastmod)
else:
data["LastModifiedDate"] = ""
data["JobDescription"] = obj.Description
data["JobLabel"] = obj.Label
@@ -170,7 +184,7 @@ class CAMSanity:
data["fixtures"] = str(obj.Fixtures)
data["orderBy"] = str(obj.OrderOutputBy)
data["datumImage"] = self.image_builder.build_image(obj, "datumImage")
data["datumImage"] = self.image_builder.build_image(obj, "datumImage", as_bytes=True)
return data
@@ -337,7 +351,7 @@ class CAMSanity:
)
)
data["stockImage"] = self.image_builder.build_image(obj.Stock, "stockImage")
data["stockImage"] = self.image_builder.build_image(obj.Stock, "stockImage", as_bytes=True)
return data
@@ -384,34 +398,49 @@ class CAMSanity:
tooldata["manufacturer"] = ""
tooldata["url"] = ""
tooldata["inspectionNotes"] = ""
tooldata["diameter"] = str(TC.Tool.Diameter)
tooldata["diameter"] = str(TC.Tool.Diameter.UserString)
tooldata["shape"] = TC.Tool.ShapeType
tooldata["partNumber"] = ""
if os.path.isfile(TC.Tool.ShapeType):
imagedata = TC.Tool.Proxy.get_thumbnail()
else:
imagedata = None
data["squawkData"].append(
self.squawk(
"CAMSanity",
translate("CAM_Sanity", "Toolbit Shape for TC: {} not found").format(
TC.ToolNumber
),
squawkType="WARNING",
# Use the toggle to determine which image to use
imagebytes = None
if self.USE_TOOL_THUMBNAIL:
# Try to get the thumbnail
thumb_bytes = None
if hasattr(TC.Tool, "Proxy") and hasattr(TC.Tool.Proxy, "get_thumbnail"):
try:
thumb_bytes = TC.Tool.Proxy.get_thumbnail()
except Exception:
thumb_bytes = None
if thumb_bytes:
imagebytes = thumb_bytes
else:
# Warn and use fallback head-on image
data["squawkData"].append(
self.squawk(
"CAMSanity",
translate("CAM_Sanity", "Toolbit Shape for TC: {} not found").format(
TC.ToolNumber
),
squawkType="WARNING",
)
)
imagebytes = self.image_builder.build_image(
TC.Tool, f"T{TC.ToolNumber}", as_bytes=True, view="headon"
)
else:
# Always use fallback head-on image
imagebytes = self.image_builder.build_image(
TC.Tool, f"T{TC.ToolNumber}", as_bytes=True, view="headon"
)
tooldata["image"] = ""
tooldata["imagebytes"] = imagebytes
imagepath = os.path.join(self.filelocation, f"T{TC.ToolNumber}.png")
tooldata["imagepath"] = imagepath
Path.Log.debug(imagepath)
if imagedata is not None:
with open(imagepath, "wb") as fd:
fd.write(imagedata)
fd.close()
# No longer writing imagedata to disk; handled by imagebytes logic above
tooldata["feedrate"] = str(TC.HorizFeed)
tooldata["feedrate"] = str(TC.HorizFeed.UserString)
if TC.HorizFeed.Value == 0.0:
data["squawkData"].append(
self.squawk(
@@ -423,7 +452,7 @@ class CAMSanity:
)
)
tooldata["spindlespeed"] = str(TC.SpindleSpeed)
tooldata["spindlespeed"] = f"{int(TC.SpindleSpeed)} rpm"
if TC.SpindleSpeed == 0.0:
data["squawkData"].append(
self.squawk(
@@ -444,8 +473,8 @@ class CAMSanity:
{
"Operation": base_op.Label,
"ToolController": TC.Label,
"Feed": str(TC.HorizFeed),
"Speed": str(TC.SpindleSpeed),
"Feed": str(TC.HorizFeed.UserString),
"Speed": f"{int(TC.SpindleSpeed)} rpm",
}
)