Skip to content

Fix flip/mirror bugs: preserve on morph, keep pin text upright, flip non-rotatable ports#412

Merged
darktorres merged 12 commits into
masterfrom
fix/flip-preserve-on-morph-and-text
Jun 14, 2026
Merged

Fix flip/mirror bugs: preserve on morph, keep pin text upright, flip non-rotatable ports#412
darktorres merged 12 commits into
masterfrom
fix/flip-preserve-on-morph-and-text

Conversation

@darktorres

@darktorres darktorres commented Jun 12, 2026

Copy link
Copy Markdown
Member

Flip/mirror correctness for circuit elements — plus the vector-rendering (including ICs and truth tables), font, and SVG-asset work that grew out of fixing the baked-in pin labels. Organised as a focused, self-verifying commit sequence.

1. Rename rotatablerotatesGraphic (no behaviour change)

The .rotatable / isRotatable() name was misleading: a rotatable = false element still rotates and flips — it just keeps its icon upright and repositions its ports instead of transforming the graphic. Renamed the constraint/metadata field and the getter to rotatesGraphic so the name matches what it controls. Pure mechanical rename across the metadata, getter, every call site (incl. the MCP handler) and the affected element tests.

2. Preserve flip / audio / volume / delay when morphing

MorphCommand::transferConnections() copied only a subset of element properties, so morphing a flipped element silently un-flipped it — and the audio file, volume and clock delay were dropped too. Flip is now carried over unconditionally (a base property of every element); audio/volume/delay are copied guarded by the existing hasAudio()/hasVolume()/hasDelay() checks, and the state round-trips through undo.

3. Flip & rotate fixed-graphic inputs/outputs by repositioning their ports (+ reconcile MCP)

Flipping a non-rotatable input/output — pushbutton, switch, LED, VCC/GND, clock, buzzer, 7/14/16-seg display — was a no-op: FlipCommand::redo() mirrored only elements that rotate their graphic. Flip now mirrors the ports to the opposite side while keeping the graphic upright, exactly how rotation already repositions them. A new GraphicElement::applyPortOrientation() applies the combined rotation + flip to each port (involutive; reproduces the previous rotation exactly when not flipped), and the old gate is removed. A pushbutton's output port moves to the left edge on horizontal flip; a display's pin columns swap; wires follow. The graphic is never mirrored, so a 7-seg display never renders reversed.

This also reconciles the MCP with the GUI: the context menu rotates these elements fine, but the rotate_element handler rejected them and setElementProperties ignored the request. Those guards are dropped so the MCP repositions ports exactly as the GUI does; the stale negative-case test is fixed.

4. Keep baked-in SVG pin text upright when flipping and rotating

Latches/flip-flops bake their pin letters (D, E, Q, S, R…) into the SVG as <text>, so the item flip (scale(-1)) and rotation rendered them backwards/sideways. When flipped or rotated, the displayed pixmap is derived from a variant rendered out of the same SVG with each <text> counter-oriented (Rotate(-angle) after the flip) about its own centre: the body moves to the mirrored/rotated side but the letters stay upright. No asset edits; unflipped/unrotated/text-free elements are byte-identical. Two robustness measures are folded in — the correction is gated on rotatesGraphic() (a non-rotatable element's text is never corrected) and the variant cache key canonicalizes the angle to [0,360) so equivalent rotations share one entry.

5. Crisp vector element rendering

Element SVGs were rasterized once into a fixed ~64×64 QPixmap and scaled by the view, blurring when zoomed in. GraphicElement::paint() now renders the SVG as vectors via QSvgRenderer (one parsed renderer per element, rebuilt only on appearance/flip/rotate change; Qt's existing DeviceCoordinateCache re-renders per zoom level), so every SVG element stays sharp at any magnification. Raster (PNG/JPG) appearances fall back to drawPixmap; pin labels stay upright via the same <text> counter-orientation as §4.

6. Inverted outputs as real glyphs + Noto Sans bundled on every platform

The inverted-output overlines (//) were hand-drawn <path> bars that desynced from their letter under rotation; replaced with a single combining-overline glyph (U+0305) that rides with the letter through the counter-orientation. Noto Sans — the maintained successor of Droid Sans, which the SVGs already named but was never shipped — is bundled and registered in Application's constructor, and now ships on every platform: Fonts.qrc was previously compiled only into the WASM build, so on desktop the glyph silently fell back to a system font. Resources are split: desktop Fonts.qrc = NotoSans-Regular; WASM-only ScriptFonts.qrc = the large CJK/script fonts.

7. Re-center memory SVG pin labels

Removed dead Inkscape "Glow" <defs> filter blocks and leftover empty <text> placeholders from the memory-element SVGs, and re-centered each pin letter on its column (Noto Sans advances; baselines normalized to y=23/52) across the D/SR latches and D/SR/JK/T flip-flops in both Dark and Light themes. The clock triangle and overline glyphs are unchanged.

8. Repo-wide SVG cruft scrub (render-identical)

Stripped Inkscape editor cruft from all 191 SVGs via a conservative SVGO allow-list (no geometry/path/style plugins): XML comments, <sodipodi:namedview>, <metadata>/RDF, every inkscape:/sodipodi: attribute (including inkscape:export-filename, which leaked the original author's local /home/… paths in 121 files), unreferenced <defs>, and unused ids/namespaces. Most files shrink dramatically (e.g. a counter segment 109 → 6 lines); ~−20k lines net. The dolphin's embedded PNG is kept as clean single-line base64 (SVGO 4 line-wraps and can corrupt whitespace inside base64 data URIs). svgo.config.mjs makes the cleanup reproducible and documents that hazard.

9. Vectorize dolphin_icon

Traced dolphin_icon.svg's 64×64 embedded PNG — the last embedded raster in the repo — into a single vector <path> (potrace, 96% IoU vs the original mask), replacing the <image>. The toolbar icon is now fully vector, crisp at any zoom, with no base64.

10. Locale flags render correctly in Qt (clip-paths baked to geometry)

Qt's QSvgRenderer does not support clip-path (confirmed against Qt's own docs — the lone gap; filter/feGaussianBlur, mask, pattern, symbol, marker are all parsed since 6.7). Three locale flags rendered correctly in browsers but wrong in-app. Each clip is baked into explicit QPainterPath-boolean geometry, dropping the clip-path attributes and <clipPath> defs while staying pixel-identical in spec-compliant renderers:

  • brasil.svg — the white "Ordem e Progresso" band (clipped to the blue disc) overflowed the whole flag, and the letter G in "PROGRESSO" (an O-ring carved by a second clip) was malformed. Band = disc ∩ outer band-circle − inner band-circle (4 arcs); G = O-ring with the arm notch (~5 commands).
  • english.svg — the red St Patrick saltire (full diagonals clipped to the counterchanged halves) drew as a symmetric red X. Baked as stroke(diagonals, 4) ∩ the 4 counterchange triangles.
  • slovak.svg — the blue hills (clipped to the coat-of-arms shield) overflowed past the shield. Baked as hills ∩ shield (compact polygon; the shield path #s is kept in <defs> for its two <use> refs).

11. Crisp IC and TruthTable bodies

§5 missed ICs and truth tables: each composed its whole body into a fixed-resolution raster QPixmap (the DIP package rect, a rasterised mascot/table logo, the shadow and — for IC — the pin-1 notch) in generatePixmap() and blitted it in an overridden paint(), so the body blurred when zoomed. Both now draw the body directly on the painter every paint — the rounded-rects and the notch are vector primitives, and the logo renders through a shared lazily-built QSvgRenderer instead of a pre-rasterised pixmap. generatePixmap() keeps m_pixmap only as a transparent, correctly-sized placeholder (so pixmapCenter()/boundingRect() and the ports are unchanged), and the existing DeviceCoordinateCache caches the crisp result per zoom level.

12. Refresh translation source locations

Mechanical update_translations pass after the rework: only the <location line="N"> references across the 39 .ts files shift to track the new source line numbers (548 strings, 0 new or removed). No translatable text or translations change.

Tests & verification

  • testMorphPreservesFlip / testMorphPreservesVolume — flip, and a non-default volume, survive morph + undo (Buzzer↔AudioBox).
  • testFlipTextPixmapVariant / testRotateTextPixmapVariant / testFlipRotateTextPixmapVariant — a flipped / rotated / both latch gets a text-corrected variant distinct from base, rotate-only and flip-only; round-trips; no-op for a text-free gate.
  • testFlipNonRotatablePorts / testRotateNonRotatablePorts / testFlipRotateNonRotatablePort — a pushbutton's (and Display7's) port mirrors/rotates to the opposite side while the item transform stays identity, and round-trips on undo.
  • The SVG scrub is verified render-identical: a QSvgRenderer pixel-compare of all 191 SVGs (pristine vs cleaned) shows 0 differences at 256px. (The same compare caught — and this PR avoids — a base64 character SVGO had mangled in the dolphin's PNG.)
  • Each baked flag is verified by rendering the asset through Qt's own QSvgRenderer (before: band/saltire/hills overflow or malformed; after: correct) and pixel-matching the baked geometry against the QPainterPath intersection ground truth (edge anti-aliasing only).
  • IC/TruthTable crispness is GUI-confirmed (the body and logo stay sharp at high zoom instead of pixelating); the change is logic-preserving for the existing IC/TruthTable tests — sizing, ports and save/load are untouched.
  • Full suite green locally: ctest --preset debug, 176/176.

@darktorres darktorres changed the title Fix two flip/mirror bugs: preserve mirror on morph + keep pin text upright Fix flip/mirror bugs: preserve on morph, keep pin text upright, flip non-rotatable ports Jun 13, 2026
The `.rotatable` metadata flag / `isRotatable()` getter was a misnomer: when it is
false the element still rotates and flips -- it just keeps its icon upright and
repositions its ports around the centre instead of transforming the graphic. Rename
the constraint/metadata field and the getter to `rotatesGraphic` so the name matches
what it controls. Pure mechanical rename across the metadata, getter, all call sites
(incl. the MCP handler, whose guard behaviour is unchanged here) and the affected
element tests; no behaviour change.
MorphCommand::transferConnections() copied only a subset of element properties
(rotation/label/color/frequency/trigger), so morphing a flipped element silently lost
its mirror state -- and the audio file, volume and clock delay were dropped too.

Carry the flip flags over unconditionally (flip is a base property of every element)
and copy audio/volume/delay guarded by the existing hasAudio()/hasVolume()/hasDelay()
capability checks, matching the surrounding pattern. The state round-trips through undo
automatically. Covered by testMorphPreservesFlip and testMorphPreservesVolume (a
Buzzer->AudioBox morph carrying a non-default volume).
…ning their ports

Flipping a pushbutton -- or any non-rotatable input/output element (switch, LED,
VCC/GND, clock, buzzer, 7/14/16-seg displays) -- did nothing: FlipCommand::redo()
applied the mirror only when rotatesGraphic(), and these declare rotatesGraphic=false,
so only their position was mirrored (a no-op for a lone element). Rotation already
repositioned their ports.

Make flip behave the same way: mirror the PORTS to the opposite side while keeping the
graphic upright. A new GraphicElement::applyPortOrientation() applies the combined
Rotate(centre, angle) then Flip about the centre to each port; it is recomputed from
the flags each call (involutive) and reproduces the previous rotation when not flipped.
rotatePorts() (now argument-less), the base updatePortsProperties() and the load path
all route through it, and the FlipCommand gate is removed. Display7/14/16 override
updatePortsProperties without chaining to the base, so they re-apply the orientation
explicitly. The graphic is never mirrored, so a 7-seg display never renders reversed.

This also reconciles the MCP with the GUI: the context menu rotates these elements
fine, but the rotate_element handler rejected them and setElementProperties ignored a
rotation request. Drop those guards so the MCP repositions the ports exactly as the GUI
does, and fix the now-stale negative-case test.

Covered by testFlipNonRotatablePorts, testRotateNonRotatablePorts and
testFlipRotateNonRotatablePort.
…tating

Latches and flip-flops bake their pin letters (D, E, Q, S, R...) into the SVG as
<text>. The item-level flip (scale(-1)) and rotation rotated those glyphs with the
body, so they read backwards or sideways.

When an element is flipped or rotated, derive its pixmap from a variant rendered out of
the same SVG with each <text> node counter-oriented about its own centre (Rotate(-angle)
after the flip) via QSvgRenderer. The item transform then mirrors/rotates the whole
pixmap: the body moves but the per-text counter cancels the glyph transform, so the
letters stay upright. Counter-transforming about each text's own centre composes cleanly
with any parent transform. Unflipped, unrotated and text-free SVGs go through the plain
base pixmap unchanged (byte-identical), and variants are cached per (path, angle, flip).

Two robustness measures are folded in: the counter-orientation is gated on
rotatesGraphic() -- a non-rotatable element keeps its icon upright, so its text must
never be corrected -- and the cache key canonicalizes the angle to [0,360) so equivalent
rotations share one entry.

Covered by testFlipTextPixmapVariant, testRotateTextPixmapVariant and
testFlipRotateTextPixmapVariant.
…zoomed

Element appearances were rasterised once into a fixed ~64x64 QPixmap at load time, and
paint() blitted that bitmap, so zooming scaled the raster up and blurred every SVG
element. Render the SVG as vectors instead: each element holds one parsed QSvgRenderer
(rebuilt only when its appearance or flip/rotate state changes, via
applyPixmapOrientation) and paint() calls QSvgRenderer::render() at the painter's current
resolution. Qt's existing DeviceCoordinateCache re-renders per zoom level, so the body
stays sharp at any magnification.

Raster (PNG/JPG) appearances fall back to drawPixmap; the rasterised m_pixmap is kept for
sizing, palette thumbnails and the flip/rotate test probe. Pin labels stay upright under
rotation/flip via the same orientSvgTextNodes correction, now also fed to the renderer
(gated on rotatesGraphic()).
…to Sans everywhere

The inverted outputs (Q/P/C with overline) on latches & flip-flops drew the overline as
a separate hand-drawn <path> grouped with the letter, so on rotation the bar desynced
from its letter. Collapse each into a single <text> whose letter carries a combining
overline (U+0305); the bar is now part of the glyph and rides with the letter through
the counter-orientation, staying upright and attached at any angle. font-family
'Droid Sans' -> 'Noto Sans' throughout.

Bundle Noto Sans (the maintained successor of Droid Sans, which the SVGs already named
but was never shipped -- the bar was hand-drawn precisely because Droid Sans has no
combining overline). Register it in the Application constructor so it loads on desktop
and in the test runner before any element pixmap is built. Crucially, ship it on EVERY
platform: Fonts.qrc previously compiled only into the WASM build, so on desktop the glyph
silently fell back to a system font. Split the resources -- Fonts.qrc now carries only
NotoSans-Regular and is compiled everywhere, while the large per-script CJK/etc. fonts
move to a new WASM-only ScriptFonts.qrc.

NotoSans-Regular.ttf is OFL, covered by the existing App/Resources/Fonts/OFL.txt.
Remove dead Inkscape "Glow" <defs> filter blocks and leftover empty <text> placeholder
nodes from the memory-element SVGs, and re-center each pin letter on its column using
Noto Sans 18px advances (x = columnCenter - advance/2; baselines normalized to y=23/52).
Left inputs, mid preset/clear, and right outputs each share a calibrated column centre,
applied uniformly across the D/SR latches and D/SR/JK/T flip-flops in both Dark and Light
themes so the labels line up consistently. The clock triangle and overline glyphs are
unchanged.
…r-identical)

Ran SVGO 4 with a conservative allow-list config (no geometry/path/viewBox/style/color
plugins) over all 191 resource SVGs, removing editor-only bloat: XML comments,
<sodipodi:namedview>, <metadata>/RDF, every inkscape:/sodipodi: attribute (including
inkscape:export-filename, which leaked the original author's local /home/davi/... paths
in 121 files), unreferenced <defs> filters/gradients, unused ids, and now-unused xmlns
declarations. Most files shrink dramatically (e.g. counter_a.svg 109 -> 6 lines).

The pass is render-identical: QSvgRenderer ignores all the removed content, and path/
coordinate data, styles and used gradients/filters are left byte-for-byte intact;
verified by a QSvgRenderer pixel-compare of all 191 SVGs (0 differences at 256px). The 16
memory SVGs additionally had a stray, unbound sodipodi:role="line" removed.

dolphin_icon.svg's embedded PNG is kept as clean single-line base64 (SVGO 4 line-wraps
and can corrupt whitespace inside base64 data URIs). svgo.config.mjs is added so the
cleanup is reproducible, and documents that hazard.
…a path

The dolphin in dolphin_icon.svg was a 64x64 embedded PNG (raster -- blurry when zoomed,
and the last embedded base64 in the repo). Traced its flat #181638 silhouette with
potrace into a single vector <path> (96% IoU vs the original mask), replacing the
<image>. The icon is now fully vector -- blue-circle rects/gradient + dolphin path --
crisp at any zoom, with no base64.
…rrectly

Qt's QSvgRenderer ignores SVG clip-path entirely, so three locale flags rendered wrong
in-app while looking correct in browsers. Bake each clip into explicit path geometry
(QPainterPath boolean ops) and drop the clip-path attributes + <clipPath> defs:

- brasil.svg: the white "Ordem e Progresso" band (clipped to the blue disc) overflowed
  the flag, and the letter G in "PROGRESSO" (an O-ring carved by a clip) was malformed.
  Band = disc intersect outer band-circle minus inner band-circle (4 arcs); G = O-ring
  with the arm notch (~5 commands).
- english.svg: the red St Patrick saltire (full diagonals clipped to the counterchanged
  halves) drew as a symmetric red X; baked as stroke(diagonals,4) intersect the 4
  counterchange triangles.
- slovak.svg: the blue hills (clipped to the shield) overflowed; baked as hills intersect
  shield (compact polygon; the shield path #s is kept in <defs> for its two <use> refs).

Verified by rendering each asset through QSvgRenderer offscreen and pixel-matching the
baked geometry against the QPainterPath intersection ground truth (edge anti-aliasing
only).
@darktorres darktorres force-pushed the fix/flip-preserve-on-morph-and-text branch from d6e3c4f to cbaafa4 Compare June 14, 2026 21:27
…y zoom)

ICs and TruthTables composed their whole body into a fixed-resolution QPixmap in
generatePixmap() and blitted it in an overridden paint(), so the body -- the DIP package
rect, the rasterised mascot/table logo, the shadow and (IC) the pin-1 notch -- blurred
when zoomed, bypassing the vector rendering the other SVG elements gained.

Draw the body straight onto the painter every paint() instead: the rounded-rects and the
notch are vector primitives, and the logo renders through a shared lazily-built
QSvgRenderer rather than a pre-rasterised QPixmap. generatePixmap() now only produces a
transparent, correctly-sized m_pixmap so pixmapCenter()/boundingRect() are unchanged; the
ports and boundingRect() are untouched, and the existing DeviceCoordinateCache caches the
crisp result per zoom level.
Re-ran the update_translations target after the history rework and the IC/TruthTable
change: only the <location line="N"> references shift to track the new source line
numbers (548 strings, 0 new or removed). No translatable text or translations change.
@darktorres darktorres merged commit f433e07 into master Jun 14, 2026
29 checks passed
@darktorres darktorres deleted the fix/flip-preserve-on-morph-and-text branch June 14, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant