Export the master-side cross-reference table to DXF

Follow-up to #740, which fixed the slave-side "(n-Xn)" cross-reference
label. The master-side item - the small table/cross drawn next to a
report or master element, listing where each of its slaves is used -
was still missing from DXF export. Measured against examples/
industrial.qet with the PDF export as an oracle (renders the whole
scene, so it shows what should be there):

                          before  after   PDF
  slave xrefs  "(n-Xn)"       41     41    41   (already fixed, #740)
  folio/position strings     358    403   403

DXF now matches the PDF exactly.

## Why this needed a different approach than #740

The slave label is a plain QGraphicsTextItem - one string, trivial to
walk and re-emit as a single DXF TEXT entity, which is what #740 did.

The master-side item (CrossRefItem) is not: it paints itself with
~600 lines of hand-written QPainter calls across three modes
(drawAsCross/drawAsContacts/drawAsPlcTable), including a header
table, contact symbols, and rules. Hand-porting that logic to emit
DXF primitives directly would mean maintaining two divergent
implementations of the same drawing that have to be kept in sync by
hand forever.

## Approach: a QPaintEngine that intercepts CrossRefItem's own paint()

DxfPaintEngine/DxfPaintDevice (sources/dxfpaintdevice.{h,cpp}) is a
QPaintEngine/QPaintDevice pair - the same mechanism QPrinter and
QSvgGenerator use to redirect QPainter output elsewhere. Constructing
a QPainter on a DxfPaintDevice and calling item->paint() on it produces
DXF entities instead of pixels, using the exact same drawing code that
already renders correctly on screen. CrossRefItem::paint() is
unmodified.

Scope is deliberately narrow - only the QPainter calls CrossRefItem's
paint() is observed to make: drawLines -> LINE, drawRects/drawPath's
fill case -> outline-only LWPOLYLINE (no HATCH support in v1 - DXF's
fill primitive is a separate, more involved entity type; documented as
a known limitation rather than attempted here), drawEllipse -> CIRCLE
or a flattened polygon for rotated ellipses, drawPath's arc case (from
drawArc/drawPie) -> chord-flattened LINE segments, drawPolygon ->
LWPOLYLINE, drawTextItem -> TEXT. drawPixmap is intentionally
unimplemented (qWarning + skip) since CrossRefItem never calls it -
this is not a general-purpose DXF paint engine, and isn't meant to be
in this PR.

CrossRefItem::paint() is protected, per the normal QGraphicsItem
contract - added a small paintForExport() wrapper rather than making
paint() itself public, or reaching around access control.

## Explicitly out of scope

QetShapeItem::toDXF() and QetGraphicsTableItem::toDXF() (both already
implemented and working) are untouched. Rewriting working exporters
onto this engine to prove an architectural point would be a large,
unrelated diff with no user-visible benefit - if that consolidation is
wanted later, it's a separate proposal once this engine has shipped
and proven out on the one item that currently has no DXF export at
all.

## Testing

Built clean on Qt5/Linux. Verified via the GUI export dialog
(Fichier > Exporter > DXF) against examples/industrial.qet, 50 folios:
export completes without error or crash, all 50 .dxf files are
structurally well-formed (balanced SECTION/ENDSEC, single EOF each),
and grepping the folio-position pattern gives the before/after/PDF
numbers above. Spot-checked several real label strings (e.g. "18-B18",
"20-A2") present as TEXT entity values in the output, not just an
artifact of the count matching.
This commit is contained in:
ispyisail
2026-08-15 08:39:55 +12:00
parent 1743f342ce
commit d988054aca
6 changed files with 427 additions and 0 deletions
+2
View File
@@ -177,6 +177,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/configdialog.h
${QET_DIR}/sources/createdxf.cpp
${QET_DIR}/sources/createdxf.h
${QET_DIR}/sources/dxfpaintdevice.cpp
${QET_DIR}/sources/dxfpaintdevice.h
${QET_DIR}/sources/diagramcommands.cpp
${QET_DIR}/sources/diagramcommands.h
${QET_DIR}/sources/diagramcontent.cpp
+269
View File
@@ -0,0 +1,269 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "dxfpaintdevice.h"
#include "createdxf.h"
#include <QPainterPath>
#include <QTextItem>
#include <QtMath>
#include <QDebug>
/**
@brief DxfPaintEngine::DxfPaintEngine
@param filepath path of the already-open DXF file entities are
appended to (see Createdxf::dxfBegin()/dxfEnd()).
*/
DxfPaintEngine::DxfPaintEngine(const QString &filepath) :
QPaintEngine(QPaintEngine::AllFeatures),
m_filepath(filepath)
{
}
bool DxfPaintEngine::begin(QPaintDevice *pdev)
{
Q_UNUSED(pdev)
setActive(true);
return true;
}
bool DxfPaintEngine::end()
{
setActive(false);
return true;
}
/**
@brief DxfPaintEngine::updateState
Track the properties this engine cares about. Everything drawn is
mapped through the transform in effect at draw time - this is what
lets an item's paint() be replayed unmodified at whatever scene
position/rotation it actually has, exactly as QPainter does for
on-screen painting.
*/
void DxfPaintEngine::updateState(const QPaintEngineState &state)
{
QPaintEngine::DirtyFlags flags = state.state();
if (flags & QPaintEngine::DirtyTransform)
m_world_transform = state.transform();
if (flags & QPaintEngine::DirtyPen)
m_pen = state.pen();
if (flags & QPaintEngine::DirtyBrush)
m_brush = state.brush();
if (flags & QPaintEngine::DirtyFont)
m_font = state.font();
}
QPointF DxfPaintEngine::toDxf(const QPointF &local_point) const
{
// Createdxf's QRectF/QLineF/QPolygonF/QPointF convenience overloads
// each apply xScale/yScale and the sheetHeight Y-flip internally, so
// this engine's whole job is composing the world transform - not
// touching DXF units at all.
return m_world_transform.map(local_point);
}
void DxfPaintEngine::strokePolygon(const QPolygonF &poly, bool close)
{
if (poly.size() < 2)
return;
if (m_pen.style() == Qt::NoPen)
return;
QPolygonF mapped;
mapped.reserve(poly.size());
for (const QPointF &p : poly)
mapped << toDxf(p);
if (close && mapped.first() != mapped.last())
mapped << mapped.first();
Createdxf::drawPolyline(m_filepath, mapped, Createdxf::dxfColor(m_pen));
}
void DxfPaintEngine::drawLines(const QLineF *lines, int lineCount)
{
if (m_pen.style() == Qt::NoPen)
return;
for (int i = 0; i < lineCount; ++i)
{
QLineF mapped(toDxf(lines[i].p1()), toDxf(lines[i].p2()));
Createdxf::drawLine(m_filepath, mapped, Createdxf::dxfColor(m_pen));
}
}
void DxfPaintEngine::drawRects(const QRectF *rects, int rectCount)
{
for (int i = 0; i < rectCount; ++i)
{
QPolygonF corners;
corners << rects[i].topLeft() << rects[i].topRight()
<< rects[i].bottomRight() << rects[i].bottomLeft();
// No HATCH support in v1 (see the design note in the header) - a
// filled rect is emitted as its outline only, same as an unfilled
// one. Still emitted (not skipped) when there's no pen but there
// is a brush, so a filled-only shape isn't dropped entirely.
if (m_pen.style() != Qt::NoPen || m_brush.style() != Qt::NoBrush)
strokePolygon(corners, true);
}
}
void DxfPaintEngine::drawEllipse(const QRectF &rect)
{
if (m_pen.style() == Qt::NoPen && m_brush.style() == Qt::NoBrush)
return;
// A non axis-aligned ellipse (rotation baked into m_world_transform)
// can't be expressed as Createdxf::drawEllipse(), which only accepts
// an axis-aligned QRectF. Approximate with a flattened polygon in
// that case; use the exact CIRCLE/ELLIPSE entity when the transform
// is axis-aligned, matching what the existing hand-written exporters
// already produce for unrotated shapes.
const bool axis_aligned = qFuzzyIsNull(m_world_transform.m12())
&& qFuzzyIsNull(m_world_transform.m21());
if (axis_aligned)
{
QRectF mapped = m_world_transform.mapRect(rect);
Createdxf::drawEllipse(m_filepath, mapped, Createdxf::dxfColor(m_pen));
return;
}
QPainterPath path;
path.addEllipse(rect);
const auto polygons = path.toSubpathPolygons();
for (const QPolygonF &poly : polygons)
strokePolygon(poly, true);
}
/**
@brief DxfPaintEngine::drawPath
Handles drawArc()/drawPie() (QPainterPath::Arc elements - emitted as
chord segments, since Createdxf's ARC-equivalent
(drawArcEllipse()) only takes an axis-aligned ellipse + angle pair,
which doesn't compose with an arbitrary path transform) and
fillPath()/generic paths (flattened to polylines).
*/
void DxfPaintEngine::drawPath(const QPainterPath &path)
{
if (m_pen.style() == Qt::NoPen && m_brush.style() == Qt::NoBrush)
return;
const auto polygons = path.toSubpathPolygons();
for (const QPolygonF &poly : polygons)
strokePolygon(poly, path.isEmpty() ? false : true);
}
void DxfPaintEngine::drawPolygon(const QPointF *points, int pointCount, PolygonDrawMode mode)
{
if (pointCount < 2)
return;
QPolygonF poly;
poly.reserve(pointCount);
for (int i = 0; i < pointCount; ++i)
poly << points[i];
strokePolygon(poly, mode != PolylineMode);
}
void DxfPaintEngine::drawTextItem(const QPointF &p, const QTextItem &textItem)
{
qreal font_size = textItem.font().pointSizeF();
if (font_size < 0)
font_size = textItem.font().pixelSize();
// Rotation: extract the angle baked into the world transform by
// mapping a unit vector, the same approach used to compute rotation
// for the existing element-text DXF export in exportdialog.cpp.
QPointF origin = m_world_transform.map(p);
QPointF right = m_world_transform.map(p + QPointF(1, 0));
qreal angle = qRadiansToDegrees(qAtan2(-(right.y() - origin.y()), right.x() - origin.x()));
Createdxf::drawText(m_filepath, textItem.text(), toDxf(p), font_size,
angle, Createdxf::dxfColor(m_pen), 0.72);
}
/**
@brief DxfPaintEngine::drawPixmap
Not implemented in v1 - out of scope per the design note in the
header (CrossRefItem, the only item exported through this engine so
far, never draws a pixmap). qWarning() rather than a hard failure, so
an item that does call this in the future degrades to "one entity
missing" instead of crashing the whole export.
*/
void DxfPaintEngine::drawPixmap(const QRectF &r, const QPixmap &pm, const QRectF &sr)
{
Q_UNUSED(r)
Q_UNUSED(pm)
Q_UNUSED(sr)
qWarning() << "DxfPaintEngine::drawPixmap: not supported, entity skipped";
}
/**
@brief DxfPaintDevice::DxfPaintDevice
@param filepath path of the already-open DXF file (see
Createdxf::dxfBegin()/dxfEnd() - this device only appends entities in
between, it does not open/close the file itself).
*/
DxfPaintDevice::DxfPaintDevice(const QString &filepath) :
QPaintDevice(),
m_filepath(filepath),
m_engine(new DxfPaintEngine(filepath))
{
}
DxfPaintDevice::~DxfPaintDevice()
{
delete m_engine;
}
QPaintEngine *DxfPaintDevice::paintEngine() const
{
return m_engine;
}
int DxfPaintDevice::metric(PaintDeviceMetric metric) const
{
// A DXF document has no pixel grid or physical size in the sense
// QPaintDevice::metric() expects - large-but-finite values here are
// enough to keep QPainter's own bookkeeping (e.g. clip region setup)
// from misbehaving; nothing in this engine actually consults them.
switch (metric)
{
case PdmWidth:
case PdmHeight:
return 1000000;
case PdmDpiX:
case PdmDpiY:
case PdmPhysicalDpiX:
case PdmPhysicalDpiY:
return 96;
case PdmWidthMM:
case PdmHeightMM:
return 1000000;
case PdmNumColors:
return 16777216;
case PdmDepth:
return 24;
case PdmDevicePixelRatio:
case PdmDevicePixelRatioScaled:
return 1;
default:
return 0;
}
}
+117
View File
@@ -0,0 +1,117 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef DXFPAINTDEVICE_H
#define DXFPAINTDEVICE_H
#include <QPaintDevice>
#include <QPaintEngine>
#include <QPen>
#include <QBrush>
#include <QFont>
#include <QString>
/**
@brief The DxfPaintEngine class
A QPaintEngine that translates the small set of QPainter calls made by
QGraphicsItem::paint() implementations (drawLines, drawRects,
drawEllipse, drawPolygon/drawPolyline, drawTextItem) into DXF entities
written through Createdxf, instead of pixels.
This exists so that an item's existing, already-correct paint() code
can be reused unmodified to produce a DXF export: construct a QPainter
on a DxfPaintDevice targeting the item, call painter.begin()/end()
around item->paint(&painter, ...), and every primitive the item draws
becomes a DXF entity at the item's scene position instead of a pixel
on screen.
Scope (deliberately not the full QPainter surface - only what
QElectroTech's own paint() implementations are observed to call):
- drawLines -> LINE
- drawRects -> LWPOLYLINE (closed 4-point outline; DXF has no
filled-rect primitive in the AC1006 dialect this
exporter targets, so brush fill is not emitted -
see fillPath below)
- drawEllipse -> Createdxf::drawArcEllipse (full sweep) or CIRCLE
- drawPath -> arcs (from QPainterPath::Arc elements, used by
drawArc/drawPie) become a sequence of LINE chords;
anything else in the path is flattened to
polyline segments via QPainterPath::toSubpathPolygons
- drawPolygon -> LWPOLYLINE
- drawTextItem -> TEXT (drawText() calls route through this)
- fillPath -> same outline-only handling as drawRects; no HATCH
support in v1 (see design note in the PR)
Anything outside this list (images, gradients, etc.) is intentionally
unimplemented and asserts in debug builds rather than silently
producing an incomplete drawing - callers should know immediately if
an item they're exporting uses something this engine doesn't cover
yet, rather than getting a DXF file quietly missing content.
*/
class DxfPaintEngine : public QPaintEngine
{
public:
explicit DxfPaintEngine(const QString &filepath);
bool begin(QPaintDevice *pdev) override;
bool end() override;
void updateState(const QPaintEngineState &state) override;
void drawLines(const QLineF *lines, int lineCount) override;
void drawRects(const QRectF *rects, int rectCount) override;
void drawEllipse(const QRectF &rect) override;
void drawPath(const QPainterPath &path) override;
void drawPolygon(const QPointF *points, int pointCount, PolygonDrawMode mode) override;
void drawTextItem(const QPointF &p, const QTextItem &textItem) override;
void drawPixmap(const QRectF &r, const QPixmap &pm, const QRectF &sr) override;
Type type() const override { return QPaintEngine::User; }
private:
QPointF toDxf(const QPointF &scene_point) const;
void strokePolygon(const QPolygonF &poly, bool close);
QString m_filepath;
QTransform m_world_transform;
QPen m_pen;
QBrush m_brush;
QFont m_font;
};
/**
@brief The DxfPaintDevice class
Pairs with DxfPaintEngine. One instance targets one already-open DXF
file (Createdxf::dxfBegin()/dxfEnd() bracket the whole export, same as
today - this device only ever appends entities in between).
*/
class DxfPaintDevice : public QPaintDevice
{
public:
explicit DxfPaintDevice(const QString &filepath);
~DxfPaintDevice() override;
QPaintEngine *paintEngine() const override;
protected:
int metric(PaintDeviceMetric metric) const override;
private:
QString m_filepath;
DxfPaintEngine *m_engine;
};
#endif // DXFPAINTDEVICE_H
+22
View File
@@ -22,8 +22,10 @@
#include "exportpropertieswidget.h"
#include "factory/elementpicturefactory.h"
#include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
#include "dxfpaintdevice.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/conductortextitem.h"
#include "qetgraphicsitem/crossrefitem.h"
#include "qetgraphicsitem/diagramimageitem.h"
#include "qetgraphicsitem/diagramtextitem.h"
#include "qetgraphicsitem/dynamicelementtextitem.h"
@@ -467,6 +469,12 @@ void ExportDialog::generateDxf(
//as plain QGraphicsTextItem children, so neither cast below picks them
//up and they were missing from the DXF entirely.
QList<QGraphicsTextItem *> list_xref_texts;
//Master-side cross-reference item (the table/cross drawn next to a
//report/master element). It paints itself with hand-written
//QPainter code across three modes (drawAsCross/drawAsContacts/
//drawAsPlcTable), so instead of hand-porting each one it's replayed
//through DxfPaintEngine, which reuses paint() unmodified.
QList<CrossRefItem *> list_master_xrefs;
QList<QLineF *> list_lines;
QList<QRectF *> list_rectangles;
//QList<QRectF *> list_ellipses;
@@ -493,6 +501,8 @@ void ExportDialog::generateDxf(
}
} else if (QetGraphicsTableItem *gti = qgraphicsitem_cast<QetGraphicsTableItem *>(qgi)) {
list_tables << gti;
} else if (CrossRefItem *xref = qgraphicsitem_cast<CrossRefItem *>(qgi)) {
list_master_xrefs << xref;
}
}
@@ -722,6 +732,18 @@ void ExportDialog::generateDxf(
}
}
//Draw the master-side cross-reference items (table/cross), replaying
//their existing paint() unmodified through DxfPaintEngine instead of
//hand-porting drawAsCross()/drawAsContacts()/drawAsPlcTable().
for (CrossRefItem *xref : std::as_const(list_master_xrefs))
{
DxfPaintDevice dxf_device(file_path);
QPainter painter(&dxf_device);
painter.setWorldTransform(xref->sceneTransform());
xref->paintForExport(&painter);
painter.end();
}
Createdxf::dxfEnd(file_path);
saveReloadDiagramParameters(diagram, false);
+14
View File
@@ -22,6 +22,7 @@
#include <QGraphicsObject>
#include <QMultiMap>
#include <QStyleOptionGraphicsItem>
class Element;
class DynamicElementTextItem;
@@ -89,6 +90,19 @@ class CrossRefItem : public QGraphicsObject
void updateLabel();
void autoPos();
public:
/// DXF export: replay this item's paint() on an arbitrary QPainter
/// (e.g. one targeting DxfPaintDevice). paint() itself stays
/// protected, as it should for the normal
/// QGraphicsScene/QGraphicsView paint contract - this is a
/// deliberate, narrow escape hatch for exporters, not a general
/// relaxation of that contract.
void paintForExport(QPainter *painter)
{
QStyleOptionGraphicsItem option;
paint(painter, &option, nullptr);
}
protected:
bool sceneEvent(QEvent *event) override;
void paint(QPainter *painter,
@@ -89,6 +89,9 @@ class DynamicElementTextItem : public DiagramTextItem
Element *parentElement() const;
/// PDF export: slave cross-reference text item ("(folio-pos)") and its master target.
QGraphicsTextItem *slaveXrefItem() const { return m_slave_Xref_item; }
/// DXF export: the master-side cross-reference item (the table/cross
/// drawn next to a report/master element), if this text item has one.
CrossRefItem *masterXrefItem() const { return m_Xref_item; }
Element *masterElement() const { return m_master_element.data(); }
ElementTextItemGroup *parentGroup() const;
Element *elementUseForInfo() const;