PerfStudio v0.5.0
Added
-
A part can be given a value (double-click it, or
F2). This was the one field on
the document no human could reach: every part the window placed was created with
value=""and nothing could change it afterwards, while an agent on the MCP server has
been able to pass one toplace_componentsince that server existed.guide._bom
groups on exactly this field, so the tool's own build guide printed "Resistor × 4"
where it meant "10k × 4".- The dialog carries only what
component.updatecarries — reference, value, lock.
Rotation is a command of its own, and putting it here would make one press of OK into
two entries on the undo stack for what the user experienced as one edit. - The Parts panel has a value box as well, applied to each part as it is placed. A
board is populated in runs — five 10k resistors, then three 100nF — and naming each
one afterwards through its own dialog is the same work done once per part instead of
once per run.
- The dialog carries only what
-
Right-click menus on the board, the Nets panel and the findings panel. There were
none anywhere: everything a part can be told to do lived in the menu bar at the top of a
window a metre wide, while the part itself was under the pointer in the middle of the
board. Every menu is built from the sameQActionobjects the menu bar holds, so an
action greyed out in one place is greyed out in the other. Right-click already means
"finish" inside a board mode, and the guards for that are described in
BoardView.contextMenuEvent. -
The window remembers its layout — geometry, dock sizes and positions, board colour,
the ratsnest, ruler and hatch toggles, and the preferred connection style. All of it
reset on every launch, and the cost was paid by whoever used the tool most. The 3D panel
deliberately still starts closed: restoring it open would build VTK's whole pipeline
during startup to show a board nobody has looked at yet. -
The build guide, in the window (
Ctrl+4). The soldering order is the thing this
application is for, and the only way to see one was to export four files and go and
find them — so the order the tool had worked out was invisible while the board was being
designed, which is when it is worth knowing. Picking a step selects its parts on the 2D
board, brings its holes into view, and moves the 3D assembly slider with it. Closed by
default and rebuilt only while open, because building a guide runs DRC and LVS. -
The export offers to open what it wrote, rather than ending at a line in the status
bar naming a file in a directory the user then has to go and find. -
A language menu (View ▸ Language). The Turkish catalogue has existed the whole time
and could be selected only by an environment variable or a command-line flag. Applied at
the next start, and it says so: every label is translated once as the window is built,
and the widgets a live rebuild missed would be exactly the ones nobody would notice had
stayed English. -
Files can be dropped on the window — a
.perfto open it (through the same
unsaved-work guard the Open menu item uses) or a.netto import it. Nothing happened
before, which reads as the application refusing that kind of file rather than refusing
drops. -
The findings panel has a filter box, like the parts and nets panels. A board
mid-layout carries a hundred proximity warnings, and "show me the errors" is how anybody
reads a list that long. -
Copy, paste and duplicate a block of board (
Ctrl+C/Ctrl+V/Ctrl+D). A
perfboard project repeats itself in a way a PCB does not — eight identical channels,
the same RC pair at every op-amp — and until now the only way to build the second one
was to place every part again by hand.- A block is parts AND the copper between them, placed by one new command,
block.place. Two commands would put a state on the undo stack nobody chose: the
parts down with their wiring gone, oneCtrl+Zfrom a board that looks finished and
is not. The copper is validated against a document the new parts have already
joined, which is what lets a pasted lead bend name the part it is a leg of. - JSON on the system clipboard, not a variable on the window, so a block crosses
documents and crosses two running copies of the application — which is the "channel 1
into that other board" case. It is readable, so a block can be pasted into a bug
report, for the same reason the project file is diffable. - A copy of R1 is not R1. Pasted parts get fresh references counted from the board,
and pasted copper carries no net claim: copper that keptnet_idwould tell LVS
the new block is wired to a schematic that has never heard of it. Unclaimed copper is
also the one kind rip-up and the stale-conductor cleanup both promise never to touch. - Three things are deliberately left behind, and each is reported rather than silent: a
lead bend whose part was not in the selection (it would be a leg of nothing), the
lock (a pasted part is one you are still positioning), and copper that the offset
would have pushed off the board. - Duplicate does not touch the system clipboard. Duplicating a part is a board
operation; it has no business throwing away what somebody copied elsewhere.
- A block is parts AND the copper between them, placed by one new command,
-
Stripboard is a board type this application can actually design on, rather than a
string the data model accepted and nothing understood.board.typeandstrip_axis
have been inmodel.py,persist.pyand the.perfformat since the first commit, a
cut.addcommand has existed the whole time, and nothing read any of it: the
connectivity engine did not know a strip joins the holes along it, so a stripboard
loaded as a pad-per-hole board with a decorative field set.- A cut destroys the copper AT a hole (
stripboard.py). That is how a track is
actually broken — a spot-face cutter or a drill bit turned by hand in the hole, which
takes the pad with it. The alternative model, a cut between two holes, describes a
knife scored across the track: a real technique, much harder to do reliably at 2.54 mm
and much harder to inspect, and one that would leave a cut without an address in an
application where every message is addressed. - Connectivity gained a fourth rule beside the three it has always had: on
stripboard the BOARD joins holes, and nobody soldered those connections. Only holes
something is soldered into take part — a strip physically joins all thirty holes in
its row, and registering the twenty-six nobody used would put every empty pad on the
board into a net, which is exactly what the module's existing note says not to do.
Gated on the board type, which is why fifteen golden fixtures still reproduce byte
for byte. - The autorouter for a stripboard subtracts before it adds (
striproute.py). Two
pins of different nets on one strip are shorted by the board itself, so the first
pass is cuts and the second is links; the links go over the COMPONENT side, because
the solder side is one sheet of parallel copper and a wire laid across it there shorts
every strip it crosses. Pins it cannot separate — adjacent, with no hole between them
to drill — are reported by name rather than routed around, because the fix is to move
a part and that is the user's decision. - The cuts and the links commit as one command (
stripboard.apply). Separately,
oneCtrl+Zleaves a board cut apart with nothing linking it, or linked with nothing
cut — which is a short across two nets, and a state nobody designed. - DRC reports a pin standing in a cut hole (
cut-track-conflict), an error for the
same reasonmounting-hole-conflictis: the pad is gone, so the board cannot work
rather than probably will not. - Both renderers draw the strips, and the 2D view marks each cut. A stripboard drawn
as a grid of separate pads is a picture of a different board, on a view whose whole
job is to be checked against the real one. - The build guide cuts first and measures each one. The cuts are made from the
copper side with a drill, and once a part is over a hole there is no way back to it —
so they are phase 0, and each gets a blocking isolation probe, because a cut that did
not go all the way through looks exactly like one that did. - Board Setup chooses the type and which way the strips run, and Draw ▸ Cut Track
(X) makes and un-makes cuts by clicking. Not done: the placer does not yet reward
putting a net's pins on one strip, which on stripboard is most of the design — said
here rather than left to be discovered.
- A cut destroys the copper AT a hole (
-
Something now checks what the render looks like (PLAN.md §10 asks for visual
regression). The headless run has always produced PNGs and CI has always kept them as
artefacts, and the only thing asserted about one was that it began with the PNG magic
bytes — so a render that lost every pad, drew the board inside out or came out blank
passed the build, and somebody had to open the artefact and look.- Not a pixel diff. Antialiasing and Qt's own version move individual pixels across
platforms and this suite runs on three; a per-pixel golden would fail on macOS for
reasons that have nothing to do with the board, and a test that fails for the wrong
reason gets switched off.tests/test_render_golden.pycompares the mean colour of
each cell of a 6 × 6 grid against a checked-in signature: re-rendering the board
moves it by 0.0 of 255, rendering the other face by 27.5, and rendering it with every
part and conductor removed by 22.6 — against a tolerance of 3. - The first attempt measured how much of each cell was covered in ink and was nearly
useless: a perfboard is mostly board, so losing every part moved a cell by 2.6 points
against a tolerance of 2. That is recorded in the file, because it looked reasonable.
- Not a pixel diff. Antialiasing and Qt's own version move individual pixels across
-
The window notices when the file changes underneath it (PLAN.md §9.3). The project
file is diffable and agent-friendly precisely so that a session which only writes
files still works — and the window was the one participant that did not notice: the
board on screen went quietly stale and the next save overwrote everything the agent had
done.- A window with no unsaved edits reloads itself. One with unsaved edits does not,
and says so instead: the file and the window have both moved, and only the person in
front of it can say which is right. Losing somebody's work to a background event is
the one outcome that must not happen, so File ▸ Reload from Disk (F5) is the
manual way to take the file's version. - The viewport is left where it was on a reload — somebody watching an agent work is
looking at a particular corner of the board — and a save does not trigger one, which
would have thrown away the window's own undo history for nothing.
- A window with no unsaved edits reloads itself. One with unsaved edits does not,
-
Five MCP tools for the board itself:
set_board,add_mounting_hole,
add_edge_connector,cut_trackandremove_board_feature. The asymmetry they fix is
the argument for them:get_board_inforeported mounting holes and edge connectors that
nothing could add, and the only route to a different board size, material or type was
new_document, which throws the work away. One delete covers all three kinds of feature
because they differ only in which list the id is in. Forty-four tools now, against
PLAN.md §2's "~25, deliberately narrow" — the cap is a decision somebody has to make on
purpose, andtests/test_mcp.pystill makes them make it. -
Measure the distance between two holes (View ▸ Measure Distance,
Ctrl+M). It
reports three numbers because they answer three different questions: holes across
is what a footprint and the build guide are written in, mm is what a lead-bending
jig and a pair of pliers are set to, and steps is how much solder trace it would
take — a diagonal is two steps of copper, not 1.4, because solder crosses the 0.6 mm
orthogonal gap and not the 1.7 mm diagonal one. The answer follows the pointer, because
the question is usually "how far to about there". The one tool in the window that
changes nothing. -
Go to Part (View ▸ Go to Part…,
Ctrl+G) — filters on reference, value and
footprint together and centres the view on what it finds. Which of the three somebody
remembers depends on why they are looking:R37from a DRC message,10kfrom the
schematic,TO-220from the pile of parts on the bench. On a dense board there was no
way to answer "where is R37" except to read the screen until it turned up.
Changed
-
The Turkish translation is complete. 216 strings were wrapped in
t()and roughly
67 were not — including every tooltip and all the tree headers, so--lang trgave
Turkish menu items with English explanations under them, which is the half a user stops
to read.tests/test_i18n.pynow checks the direction it could not see before: a
user-facing string never wrapped int()is not a missing translation, it is not in the
system at all, so it moved no number and nothing reported it.- The catalogue scanner understands adjacent string literals as one key, because a
tooltip lives in the source as three quoted fragments on three lines. Without that,
wrapping a tooltip both failed the coverage check and reported its own catalogue key
as stale — which is why the tooltips stayed English.
- The catalogue scanner understands adjacent string literals as one key, because a
-
The findings panel keeps your place across an edit. It rebuilt from
clear()on
every command, throwing away the expanded groups and the selected row — so working
through a rule meant re-expanding it after every attempt to fix what the rule was
complaining about. Groups are restored by name, since a rule that gained a violation
moves down the tree and one that lost its last disappears. Severity now colours the row
too, in the colours the status bar already uses for the same counts. -
The Nets panel's "Left" column is now "To route". One English key cannot carry two
meanings in a catalogue whose keys are the English strings, and Board Features already
has an edge called Left. Saying what the number counts is better English anyway. -
Autorouting a big board is a third faster, and the interesting part is which third.
A 100 × 60 board with 60 parts took 6.8 s; it takes 4.5 s. Every golden route
reproduces byte for byte, which is the only reason to believe the change was safe.- The A* open list was not the problem, which was worth measuring before trading
away the differential proof to fix it.router.py's docstring has always said a
binary heap would change tie-breaking among equal-f nodes and could silently pick a
different equal-cost path; a profile put the search loop at under a tenth of the time.
It stays a linear scan, now with the measurement written beside the reason. - The time was in R5'. Pricing bridging risk into the search — the thing this
project is organised around — asked_has_foreign_neighboura million times for about
two thousand distinct questions per route, rebuilding a set of "our" nets on every one
of them for a value that cannot change while a search runs. Both are memoised on the
route context now. - And in building
"37,12"strings: 15.8 million calls togeometry.hole_key, more
than the search itself cost.router.pykeys its own sets on(col, row)tuples.
hole_keystays the one encoding for everything that crosses a module boundary —
occupancy, connectivity, DRC — all of which have golden output that must not move. - Two tests pin the properties rather than a stopwatch: a timing assertion is a flaky
test wearing a useful hat.
- The A* open list was not the problem, which was worth measuring before trading
-
The headless run is its own module (
ui/headless.py). It is a program in its own
right, it shares nothing with the window but the scene it renders, and being importable
on its own is what lets a test call it without standing up aMainWindow.main.py
loses 228 lines, and starting the GUI no longer imports the CLI. -
Importing a netlist places its parts in one undo step, which is what the code doing
it has claimed in its own docstring since it was written. It dispatched one
component.placeper part, so taking back a thirty-part import took thirty presses of
Ctrl+Zand every one of them left a half-imported board. It is oneblock.placenow. -
The build guide is a third of the size it was.
dense.perfproduced a 6378 KB
guide.html; the same board now produces 2070 KB, and nothing was dropped from it.- The step images are JPEG rather than PNG. They are photographs of a lit 3D scene
— smooth shading, no flat colour, no sharp text — which is the exact content PNG
stores worst: 135.6 KB per image against 47.0 KB at quality 82, measured on the 33
steps ofdense.perf. Below about quality 70 the compression starts ringing around
the thin leader lines in the exploded shots, so 82 is where it stopped rather than at
the smallest number that still looked fine in a thumbnail. - JPEG and not WebP, which would have been smaller again:
vtkJPEGWriteris linked
into VTK, while Qt's WebP writer is an image-format plugin that has to be collected
into a PyInstaller bundle — and a missing plugin is a failure on the user's machine,
not on ours. The guide's whole promise is that it opens anywhere, later. guide_exportreads the media type off the bytes instead of naming PNG in the
data URI. It is inlined into a file with no network behind it, so a picture announced
as the wrong type is a broken image in the one place nobody can re-fetch it — and the
renderer's format has now changed once, which is the argument against agreeing on it
in two places.
- The step images are JPEG rather than PNG. They are photographs of a lit 3D scene
-
The printed guide is legible when the browser is in dark mode.
@media printreset
the body colours but not the palette tokens, and browsers drop background colours when
they print — so on a dark-mode machine every.metaline printed pale grey on white
paper. The print block now redefines the whole palette. This guide is meant to be taped
next to the board. -
Ruff is a gate. It reported 466 findings and did not block, which is the worst of
both worlds: a permanently red tick is one nobody reads, so it protects nothing while
looking like it does.- 389 of the 466 were two rules that are wrong for this codebase, and they are off
inpyproject.tomlwith the argument written at the switch.E501fired 235 times at
a median of 105 characters, almost all of it prose in a comment or a message string —
which a formatter cannot split either, and ruff's own guidance is to leave line length
to the formatter.RUF001/2/3fired 154 times and 137 were the dotless ı in
the Turkish catalogue: not a suspicious lookalike ofi, but a different letter of the
language the interface speaks. The rest wereρfor resistivity and×in "5 × 7 cm". - The other 77 were fixed rather than configured away, including a
zipthat now
saysstrict=Falsewhere it means it, two nestedifs that read better as one, and
anint(round(...))that was rounding an int. - Four were fixed by hand because the automatic fix made the code worse.
RUF005
rewrites[a] + bas[*a, *b], and on a multi-line constructor call it does that by
inlining the whole thing onto one 200-character line — which only looked acceptable
becauseE501had just been switched off. The same edit made by hand is an
improvement; made by the tool it was vandalism with a green tick. UP040would have broken the application silently, and the suite caught it.
RewritingX: TypeAlias = Literal[...]astype X = Literal[...]is correct for 32
of the 33 aliases and wrong for the four that are read at RUN time:get_argsreturns
an empty tuple for a PEP 695 alias, soBoardMaterial,BodyArchetypeand
ConductorKindstopped listing anything and three completeness tests started
asserting that an empty set equals an empty set.RoutingStylewas worse — MCP
validates an agent's requested style againstget_args(RoutingStyle), so every style
would have been refused, by a check that raises nothing. All four keep the old
spelling with the reason written above them.- The linter is pinned to a minor version. An unbounded dependency took this project
apart twice this morning; a linter is the same hazard in a smaller way, since rules
arrive with releases and a gate that fails on a tree nobody touched is a gate people
switch off. ruff formatstill is not adopted, and making the linter a gate nearly adopted it
by accident. The same job also ranruff format --check, which had been failing
quietly undercontinue-on-errorfor as long as it existed; removing that flag turned
"41 files would be reformatted" into a failed build, deciding the exact question the
comment beside it says is not being decided. It reports now and does not block. The
number is worth watching; it is not worth watching from behind a red tick that means
something else.
- 389 of the 466 were two rules that are wrong for this codebase, and they are off
-
CI runs all three platforms on every push, which is what
ci.yml's own condition
said to do on going public: standard runners are free on public repositories, so the
metered-minutes trade-off it encoded stopped applying. The first full matrix that ran
under the old rule is the argument for not restricting it again — it found a VTK abort
on Windows and two footprint goldens off by a ULP on macOS arm64, neither visible on
Linux, both an ordinary edit away from coming back. "Seen before a release" turns out
to be a weak property when a release is where they were seen.
Fixed
-
Exporting a build guide on a machine with no OpenGL killed the application, and the
code that was supposed to prevent it could not run. Both export paths wrap the render in
except Exceptionand fall back to a picture-less guide — the promise that a guide
without illustrations is still a complete guide — but VTK does not raise when there is
no context behind an offscreen window. It ends the process. On a virtual machine, a
remote desktop session or an old driver, File ▸ Export Build Guide took the window
down with every unsaved edit in it, and the handler for exactly that case never
executed. 0.4.0 shipped with this; the release notes named it as unfixed.- A crash cannot be caught where it happens, so it is spent where it costs nothing.
view3d.offscreen_gl_available()opens a 16 × 16 offscreen window in a child
process and reports by exit status. One spawn per run, cached — the answer cannot
change while the application is open — and about 0.9 s. - A frozen build has no separate Python to spawn, so it probes by running itself.
sys.executableis the application, and--probe-offscreen-glis answered before Qt
is touched. That is not a documented option and is not meant to be used by hand; it
exists because the installed build is exactly where this has to keep working, and the
Windows release job now runs it that way on a runner with no GPU. - Three consumers, one answer: the MCP
generate_guide, the GUI's export, and
--headless, which now prints "3D SKIPPED: no offscreen GL context on this machine"
and writes everything else rather than stopping at the first stage that needs a
graphics driver. The suite asks the same function, so a test skips exactly where the
application would have declined to render — rather than the two disagreeing about what
the machine can do. - Both CI workflows are held to the exit status again. The Windows steps had been
allowed to fail while this was outstanding, which meant the one runner most likely to
show a real Windows fault was the one whose result was being ignored.
- A crash cannot be caught where it happens, so it is spent where it costs nothing.
-
A machine-wide installer was putting its shortcuts in one person's profile. Found
by installing v0.4.0 on a real desktop, which is a thing that had never been done: CI
unpacks the bundle and asks the binary its version, and the installer around it had only
ever been built. It installs into$PROGRAMFILES64underRequestExecutionLevel admin, and$SMPROGRAMS/$DESKTOPdefault to the current user — which under
elevation is whoever answered the UAC prompt. Install for a standard user from an
administrator account and every shortcut lands in the administrator's profile and none
in the profile of the person who will use the program.SetShellVarContext all, in the
install and uninstall sections both.- And its uninstall entry was in the 32-bit registry.
makensisproduces a 32-bit
installer, soHKLM\Softwareis redirected through WOW64: a 64-bit application in
Program Fileswas registering itself where 32-bit applications live. Add/Remove
Programs reads both views so it still appeared, which is why nothing looked wrong —
but every inventory tool and script that reads the 64-bit view saw nothing.
SetRegView 64. - Both fixes have to look backwards. Every v0.4.0 install in the world recorded
itself in the 32-bit view with per-user shortcuts, so the next installer searches both
registry views for a previous version and clears both shortcut contexts. Without that
it would find nothing to remove and unpack itself over the bundle it is replacing —
the exact failure that block exists to prevent. - What did work, end to end and on the first try: silent install in 21 s, 522 MB across
1008 files, the.perfassociation (double-clicking a board opened it in the installed
build), the Turkish Start-menu entries the machine's locale selected, the full headless
pipeline out ofProgram Filesincluding 29 rendered step images, and a silent
uninstall that left nothing behind — no files, no registry keys, no shortcuts.
- And its uninstall entry was in the 32-bit registry.
-
The release ritual's own last step failed the tests that enforce it.
docs/RELEASING.md step 4 opens the next cycle by putting the
.devNsuffix back on and leaving an empty## [Unreleased]heading — and
test_development_builds_have_an_open_unreleased_sectionthen failed the whole suite,
because it also demanded that section have entries in it. Nothing has accumulated
towards a version opened a minute ago; that is what opening one means. The
contradiction stood through three versions because reaching it requires finishing a
release, and 0.4.0 is the first release this project has actually completed. The test
now checks the half that cannot be satisfied by forgetting to write anything down —
that a development version is not describing a version which already shipped — and the
release side still refuses a closed section with no entries, and a released build whose
section is not the newest.
Windows - PerfStudio_..._Setup.exe. It is unsigned, so SmartScreen will
warn on first run: More info -> Run anyway. A signed build is planned.
Linux - perfstudio-...-x86_64.AppImage. chmod +x and run it; it needs
glibc 2.35 or newer. If it will not start, the machine has no FUSE 2: install
libfuse2, or run the file with --appimage-extract-and-run, which unpacks it
instead of mounting it.
macOS - perfstudio-...-arm64.dmg, Apple silicon. Drag it to Applications,
then run this before opening it:
xattr -dr com.apple.quarantine /Applications/PerfStudio.app
It is signed ad-hoc but not notarized, so Gatekeeper blocks the first launch.
On macOS 15 and later the dialog it raises defaults to Move to Trash, which
deletes the install. Clearing the flag first avoids the dialog entirely. On an
Intel Mac, install from source.
Anywhere with Python 3.12+: pip install -e . from a clone, then
perfstudio.
This is pre-alpha software. Nobody has yet built a real board by following a
generated guide - check every measurement against the part in your hand.