PerfStudio v0.4.0
Added
-
Installers, on all three platforms, from one PyInstaller spec. Pushing a
v*tag
builds a Windows installer, a Linux AppImage and a macOS disk image, smoke-tests each
one by unpacking it and asking the binary inside what version it is, and attaches them
to the release. Nothing has been tagged yet, so nothing has been published.- The tag is checked against the source rather than trusted. A
v0.4.0tag on a
tree that still says0.4.0.dev0publishes an installer that disagrees with its own
file properties, and nothing downstream would notice — so the workflow refuses that,
refuses a version that still carries a.devNsuffix, and refuses one with no
CHANGELOG.mdsection. The release notes are that section, so the two cannot say
different things about what shipped. - Nothing is code-signed, and the release notes say so along with the click-through
each platform needs. A Windows EV certificate is ~$300/year and Apple notarization
$99/year (PLAN.md §12). The macOS bundle is signed ad-hoc, which is not a trust
decision — it is the minimum Apple silicon will execute at all. - Ubuntu 22.04 and macOS 15 are pinned rather than
-latest: a PyInstaller bundle
carries Python and Qt but links against the host's glibc, and glibc is forward
compatible only, so a 24.04 build would require 2.39 and rule out Debian 12 and every
enterprise distribution still in service. - The Windows installer speaks English and Turkish, because the application does,
and an installer that could only speak English would be the one part of the product
that does not. All three platforms claim.perf, so double-clicking a board opens it.
- The tag is checked against the source rather than trusted. A
-
Four example circuits, each shipped as both the netlist and the finished board.
There was one netlist and no board.examples/now carries the NE555 astable, an LM317
adjustable supply, a one-transistor guitar booster and an Arduino I/O shield — the
.neta schematic tool exports, and the.perfthat importing, placing and routing it
produces.- Chosen for what they make the tool say rather than for variety. The LM317 is a
TO-220, soheat-proximityhas a hot part to measure from, and that board carries
the set's onesolder-trace-proximitywarning — R5' doing its job and becoming a
measurement checkpoint. The booster is on FR-2, so the guide drops the iron 30 °C
andpad-lifting-riskcan fire at all. The shield is two headers, which is where lead
bends and short traces do nearly all the work. tests/test_examples.pyasserts on every commit that all four load without warnings,
round-trip byte-identically, match their schematics under LVS and carry no DRC error.
A broken example on the front page is worse than no example.
- Chosen for what they make the tool say rather than for variety. The LM317 is a
-
An application icon, drawn in code by
tools/make_assets.pyand committed as a
.pngand an.ico— because unlike the toolbar, an installer and a.desktopentry
need real files to point at. Every colour comes fromui/boardcolors, so the mark is
the same green and the same gold as the board in the editor. -
A demo animation of the build order (
tools/make_demo.py), generated by playing the
guide back throughdocument_at_step— the same function behind the 3D panel's assembly
slider, so it cannot show an order the guide does not actually prescribe. -
Copper on the face you are not looking at is hatched.
View ▸ Hatch Copper on the Far Side, on by default. The board is opaque: a solder-side trace drawn solid while you are
looking at the component side says this is in front of you, which is exactly the
misreading_paint_body_shadowalready exists to prevent for part bodies — and the one
that gets a board soldered on the wrong face. A conductor and a part now say "I am on the
other side" the same way, in the one visual word this application already had for it.- Stroked into a fillable shape rather than dashed. A dash already means a top jumper
inCONDUCTOR_STYLE, and giving one mark two meanings costs more than it saves. The
outline around the hatch is what keeps a run traceable end to end at low zoom, where a
0.9 mm trace is a few pixels wide and hatching alone cannot read. - The joints stay solid. Where a conductor is soldered down does not change with the
face you look from — the hole goes through the board — and it is what someone counts
pads against while tracing a run. A test pins that a hatched trace still marks every
hole it contacts. - The hatch brush carries the inverse of the painter transform, so it holds its spacing on
screen while the board zooms instead of turning solid zoomed in and vanishing zoomed out
— the same correction the body shadow needs, for the same reason. - Off is a real option, not a concession: someone tracing a dense solder side may simply
want to see it plainly. On is the default because the default has to be the reading that
cannot mislead.
- Stroked into a fillable shape rather than dashed. A dash already means a top jumper
-
The router can try every style and keep the best.
Route ▸ Preferred Connection ▸ Try each and keep the best,style: "best"over MCP. Picking a routing style meant
guessing — before seeing a single route — whether this particular board comes out better
with solder or with wire. Planning is pure and a plan is cheap, so the tool can stop
guessing and measure: it routes the board once per style and keeps the one that is least
work to build. Costs about two ordinary routes (607 ms against 273 ms on the NE555
fixture), becausebalanced's own rip-up passes are the slow ones.- It does not compare costs, and that is the whole trick. Each style's plan carries a
total_costquoted in that style's own currency: thewiretable prices a solder step
at 4 and an insulated wire at 6, so its plans are cheap by its own definition of
cheap, andmin(total_cost)would pick wire on every board ever. The comparison is on
physical facts instead — traces, wires, millimetres of wire, holes at R5' bridging
risk — which mean the same thing whichever table produced them. A test asserts
score_plannever mentionstotal_cost. - An unrouted connection is a gate, not a term. A plan that leaves one can never win
on being tidier elsewhere, however large the gap: PLAN.md §13 names "it routed most of
it and left four connections" as the trap every previous perfboard autorouter fell into. - A wire costs more to build than a trace, and by a fixed amount before a single
millimetre exists — measure, cut, strip, tin, dress, solder twice. The first version of
this scoring priced them the same, which undercharges the one primitive with real
preparation behind it and makes every comparison meaningless. - Every loser is kept and reported, with its measurements, in the status bar's tooltip
and incomparisonover MCP. The winner is chosen on an exchange rate between wires and
bridging risk that the user is entitled to disagree with, and they cannot disagree with
numbers they were never shown — so all four styles stay pickable by hand. - Ties fall to the earlier style tried, so
balancedkeeps its place and the sweep
reduces to today's behaviour when nothing beats it. On both the NE555 and dense
fixtures, nothing does. - Headless prints the whole table, so a change to any cost table shows up in CI as a
different winner rather than as a silently different board.
- It does not compare costs, and that is the whole trick. Each style's plan carries a
-
Resistors wear their colour code, in both views. A resistor is the commonest part on
almost any board and every one of them was an anonymous beige blob — so "is the 10k in
the right place" could not be answered by looking, which is the one job the 3D view has.
The bands are decoded fromComponentInstance.value, which is already in the document,
so nothing new is stored and the bands cannot disagree with the netlist.- It never guesses. A wrong band is worse than no band: somebody would read it and
fit the wrong part. The parser understands the way schematics actually write a
resistance —470,470R,4R7,10k,4k7,2.2k,2M2,1kΩ, where the unit
letter stands in for the decimal point — and returns nothing at all for anything else.
100nF,10uH,2A,NE555and dense.perf's placeholderv12all decode to no
bands rather than to a plausible resistance, and that is the load-bearing test. - A diode is not a resistor, though they share the
axial-cylinderarchetype. A
polarized axial body keeps its cathode stripe and is never banded, the same split
style_foralready makes. - Both views read
bodies.resistor_bands, so the editor and the 3D view cannot print
different parts. The library icons still draw three generic bands, and correctly: that
list shows footprints, which have no value to decode — it is a picture of a resistor,
where the board is a picture of a 10k.
- It never guesses. A wrong band is worse than no band: somebody would read it and
-
Parts are lit like the material they are made of.
BodyStyle.metallicand
BodyStyle.lenswere documented as shading hints for both renderers and read by almost
neither — the HC-49 crystal, the one part in the registry that is literally a metal can,
hardcoded its own metal shading while carrying the flag that says so, and no LED was ever
lit as a lens. Two sources of truth for one fact, in the module that exists to prevent
exactly that.bodies.surface_foris now the single answer, and both views ask it.- The 2D view gained a highlight swept across the short axis of a body, because that is
the direction a cylinder curves in. A flat fill is what made every part read as a
sticker printed on the board rather than an object standing on it. - A LED now looks lit, a crystal can looks like metal, and a DIP still looks like matte
plastic — which is most of what makes a rendered board look like a board.
- The 2D view gained a highlight swept across the short axis of a body, because that is
-
A solder run reads as copper again. Each joint gained a thin darker rim and, close
in, a small highlight off its top-left. At 2.54 mm pitch a run of beads brighter than the
trace beneath them merged into one lumpy caterpillar, so the eye read a necklace rather
than a length of copper soldered down at every pad. Each joint is individually countable
now — which is what somebody tracing the run against the real board is doing — while the
trace itself carries the line. The bead-per-pad distinction is untouched: it still comes
frommodel.contacts_every_path_hole, and a wire still gets a fillet at its two ends and
nothing in between. -
Two clicks join two pins.
Net ▸ Connect Two Pins(C, and the first button on the
toolbar) is the netlist reduced to what a person is actually doing: pointing at two legs
and saying those go together. Click one pin, click another, and they end up on the
same net — an existing one if either pin is already on it, or a new one namedN1for
you if neither is. Declaring a net, filling it and then routing it is still the honest
model, and it was also four steps deep in two menus before a single pin could be joined
to anything.- It produces exactly the documents the long way round does: one command per pair, on
the same bus, undoing one pair at a time. - Both pins already on different nets is refused, naming both. Merging two nets is a
change to the circuit, and not one a two-click tool may make on its own. - The tool stays armed, because a board is a list of connections rather than one, and a
refused pair clears the half-made connection — otherwise the next click joins something
the user has stopped thinking about.
- It produces exactly the documents the long way round does: one command per pair, on
-
The parts library has pictures of the parts. Sixty-one footprints were sixty-one
lines of grey text, which is a poor way to answer the question people bring to that
panel: which of these is the fat blue one with a stripe. Every row now carries a small
colour drawing — a beige resistor with bands, a dark blue electrolytic with its polarity
stripe, a black DIP with its pin-1 notch, a red LED with one lead longer than the other.- The colours are not chosen here. Every one comes from
bodies.style_for, the same
fill, edge and accent the 2D board and the 3D view draw that part with, so picking a
part from the list and finding it on the board is recognition rather than reading. A
second palette would have drifted from the first the moment either was touched. - The view is per archetype and deliberately not the board's top-down one: a resistor is
recognised side-on by its bands and a DIP from above by its notch. This list is about
the part in the drawer; the board beside it is what shows the footprint. - A test fails if the model gains an archetype this cannot draw, because a blank row in
a list of pictures reads as breakage rather than as a missing icon.
- The colours are not chosen here. Every one comes from
-
A toolbar with pictures on it. Every tool is now on the bar — connect, new net, all
five conductor kinds, auto-place, autoroute, rotate, mirror, delete, flip, ratsnest, 3D,
fit — each with an icon and a short label under it. The old bar was eleven identical grey
rectangles of text that had to be read left to right every time, which is how the tools
ended up being hunted for in the menus, which is the thing a toolbar exists to prevent.- The icons are drawn in code (
ui/icons.py), from the same palette as the rest of
the chrome, so they cannot fall out of step with the window and there is no asset
directory, resource system or licence to track. - The four conductor icons share one drawing and differ only in what runs between the
two pads — a solid bar for solder, a thin line for bare wire, a sleeved line for
insulated, an arc that lifts off the board for a top jumper. That difference is what
this application is about, so it is what the icons are built around. - Buttons carry a short label and menus keep the full wording (Qt draws
iconTexton a
toolbar andtextin a menu), without which sixteen tools at menu length run off the
end of a 1600 px window and half of them end up behind an overflow arrow.
- The icons are drawn in code (
-
The window now says what state it is in. Placing, drawing and picking pins each arm
a mode in which a click means something other than what it usually means, and the only
place any of them said so was the status bar — the bottom edge of a window a metre wide,
while the cursor is in the middle of the board. A mode nobody can see is
indistinguishable from an application that has stopped responding to clicks.- A banner over the board names the armed mode and both ways out of it ("Adding pins
to GND: U1.8, C2.2 · Enter or right-click finishes, Esc cancels"). It is derived from
the scene on every change rather than remembered, so it cannot disagree with what the
next click will actually do, and it is transparent to the mouse — an overlay that ate
the click it was describing would be worse than no overlay. - An empty board says what to do with itself. The application opens on a blank
5 × 7, and every route, check and export needs something on it first; a blank viewport
under a full menu bar is the one screen where a person cannot tell a tool that is ready
from one that is broken. It withdraws as soon as a part lands or a mode is armed.
- A banner over the board names the armed mode and both ways out of it ("Adding pins
-
Recent files, and a toolbar you can save and undo from.
File ▸ Open Recentkeeps
the last eight boards across runs, skipping any that have since moved — a perfboard
project is worked on across evenings, and hunting the same file out of a tree every time
was friction the application was adding for no reason. Save, Undo and Redo joined the
toolbar, and undo and redo now grey out when there is nothing behind or ahead: the
bus has always known, the window simply never asked, so an undo at the bottom of the
stack looked exactly like one that worked. Undo's tooltip names the command it would
take back. -
Help ▸ Keyboard Shortcuts…(F1), read off the real menus. A hand-kept shortcut card
goes stale the first time an action moves, and a stale card teaches something that no
longer works, so this one is generated from the menus themselves and cannot describe a
binding the application does not have. It also lists the board gestures — middle-drag to
pan, right-click to finish a run, arrows to nudge a part a hole at a time — which are on
no menu at all and were previously discoverable only by reading the source. A test fails
if two actions ever claim one binding. -
Panel ergonomics. A filter box on the Nets panel that matches pins as well as names,
because half the time the question is "what is U1.3 on" rather than "where is GND"; full
part names in a tooltip where the dock elides them; a wider parts column and a shorter
DRC panel, which was opening a quarter of the window tall to show four rows of a clean
board. -
A netlist you can write yourself, without KiCad.
netlist.importwas the only way
a net could ever enter a document, which quietly made a schematic capture package a
prerequisite for the whole tool: with no net there is no ratsnest, and so nothing for
autoroute to route, nothing for LVS to check and no continuity tests in the build
guide. Nobody opens KiCad to wire four parts on a scrap of perfboard. Five commands —
net.add,net.update,net.delete,net.connect,net.disconnect— put the same
intent in by hand, and import still replaces the netlist wholesale because that is
what re-exporting a schematic means.- You click the pins, rather than typing their names. "New Net…" asks for a name
and a class and then arms a mode over the board: every click adds the pin in that
hole, right-click or Enter finishes, and the whole session lands on the history as
onenet.connect. On a perfboard a pin is a hole you can point at, so a dialog
listing "U1.8, U1.7, C2.2" would ask the user to do a translation the board is
already doing for them. Every refusal the command would make is made per click
instead — an empty hole, a pin already listed, a pin another net holds — because a
session that collects five pins and then bounces the batch for something done four
clicks ago is worse than no help at all. - The Nets panel became an editor. Each net now lists its pins as rows underneath
it, which is what makes a pin selectable and so removable; expansion survives the
rebuild that follows every command, so a net opened to take a pin off does not shut
as the pin comes off. - A pin belongs to exactly one net, so claiming one another net holds is refused
and the message names the holder, rather than the pin being silently moved out of a
net the command never mentioned. A pin naming a part that is not on the board yet is
allowed: declaring the circuit and then placing what it asks for is a real order of
work, it is what importing a netlist does, and the ratsnest already reports those as
unresolved pins. - Deleting a net leaves its copper alone and releases the
net_idclaim on it in
the same step — a reference to a net that is gone is exactly what commands exist to
prevent. That copper becomes indistinguishable from hand-drawn work, which is
precisely what re-route and the stale-conductor sweep both promise never to touch:
with the intent gone there is nothing left to route it against. current_aandvoltage_vfinally have a way in. No netlist format carries
them, so DRC's current-capacity rule, its creepage rule and the wire gauge on the
build guide's cut list have been silent since they were written. The net dialog and
update_netare their only route into a document.- Five MCP tools (
create_net,connect_pins,disconnect_pins,update_net,
delete_net), which is the largest single addition the server has taken and is
argued at length in its module docstring: an agent could place parts, draw copper and
route, but could not state the intent all three are measured against.
- You click the pins, rather than typing their names. "New Net…" asks for a name
-
The last two DRC rules, which are the two a top-down view cannot see. PLAN.md
§5.2's table had eleven rows and nine of them were implemented; the missing pair
(rule 8, height and envelope, and rule 9, heat proximity) were both missing for the
same reason, which is that from directly above — every view a 2D editor can offer — a
20 mm TO-220 and a 2.3 mm resistor look identical. This is PLAN.md §8.4's first
functional justification for having a third dimension at all, and it is now a rule
rather than a picture.heat-proximity: a TO-220 or a relay sitting within 12 mm of an electrolytic,
which loses roughly half its rated life for every 10 °C it runs hotter. The
placement optimiser has priced this since it was written and DRC said nothing, so
auto-place moved parts apart for a reason the user was never told, and a board
placed by hand got no warning at all. Which parts run hot, which mind, and how close
is too close now live inmodel.pyand both modules read them.- Measured between bodies, not anchors — and the placer was changed to match. An
anchor is pin 1, which on a TO-220 is one end of a 10 mm tab and on a DIP is a
corner. Rotating a TO-220 180° swings its body to the other side of an anchor that
has not moved, and the old measure reported the same distance for two placements
that differ by a centimetre in the only way that matters. There is now one number
with two consumers, and a test that fails if they drift apart. component-too-tall, against aheightLimitMmthe document now carries — the
clear height inside the case, set from File → Board Features…, from the
height-limit.setcommand, or from MCP. Silent until one is declared, which is the
honest default: with no case chosen there is nothing to be too tall for. The build
guide's own note stops guessing when the real number exists — it used to say "10 mm
or over, check it clears anything meant to go over the board", and now says what
will not fit.jumper-under-body, a top-side jumper that has to run beneath a part. The
router has always refused to lay one and asksoccupancy.body_coversto decide;
DRC did not know the rule existed, so moving a part on top of an existing jumper
was completely silent — the copper was legal when it was laid and nothing looked
at it again. A warning rather than an error, because it is buildable: a wire
threaded under a DIP socket is ordinary practice. What it is not is buildable in any
order, which turned out to matter — see below.- Only holes strictly between a jumper's ends count. For a DIP, an electrolytic or a
TO-92 the body's bounding box covers its own pin holes, so counting the ends would
flag every jumper that lands on a part. That makes the rule a strict subset of the
router's guard, which is the right direction: DRC never objects to copper the router
was willing to lay.
-
check_heightsandset_height_limiton the MCP server, taking it to 33
tools.check_heightsis named in PLAN.md §9.2 and answers what neither render tool
can: how tall the build stands, tallest part first, whether or not a limit is set —
because "what decides the enclosure height" is a question worth asking before there is
an enclosure. Parts whose footprint is unknown are named rather than skipped, so an
emptyover_limitcannot be read as "everything was measured". -
The board can now be described as the ones people actually buy — three features
that a bare grid of round pads cannot express, added together because each of them
changes what the other layers say.- Oblong pads (
board.padShape/padLength/padAxis). Not cosmetic: the R5'
bridging risk this whole tool is organised around is a function of the gap between
one pad's edge and the next, and an oblong pad has two such gaps. At 2.54 mm
pitch a 2.25 × 1.9 mm pad leaves 0.29 mm down a column and 0.64 mm along a row — so
a solder trace one way is easy to make and easy to make by accident, and the other
way is neither.geometry.copper_gap_mmmeasures it per pair, DRC's proximity
message quotes the direction it found, and the build guide's preparation phase says
which way the board favours before a single joint is made. - The addresses printed on the board (
board.labels), theA..Z/01..22
legend these boards carry. It is the same address space the guide, DRC and the MCP
tools already speak, so a builder reads "C7" off the copper instead of counting holes
from a corner — and the guide's phase 0 stops telling them to mark A1, because the
board already has. Drawn in 2D, in 3D and on the 1:1 PDF that gets taped to the board.
Printed row numbers may be zero-padded (rowDigits), which is typography and not a
different numbering:A07is still rejected as an address. - Mounting holes and edge-connector fingers (
mountingHoles,edgeConnectors),
withmounting-hole.add/.addMany/.deleteandedge-connector.add/.delete
on the same bus as everything else, and a File → Board Features… dialog.
Four corner holes go in as one command, so one Ctrl+Z does not leave three drilled.
- Oblong pads (
-
board.borderXMm/borderYMm, substrate beyond the usual half pitch. It exists
because the legend has to be printed somewhere: half a pitch past the outer holes
leaves 0.32 mm of bare board at 2.54 mm pitch with 1.9 mm pads, which is not room for a
character, and the boards being modelled are physically wider at the edge for exactly
that reason. Two numbers, not one: a 5 x 7 cm board carries about 2.1 mm at the
sides and 4.5 mm top and bottom, and a single figure puts the 1:1 printout millimetres
out on one axis — on the printout that gets taped onto the board.
geometry.board_edge_margin_mmis the one place that says how much substrate is
outside the grid;hole_span_mmis deliberately untouched, so mirroring to the solder
side still lands hole 0 on hole cols-1. -
The boards you can actually buy, as presets:
2 x 8through20 x 30 cmin the
two families they are sold in, picked from File → Board Setup…. Perfboard is bought
as "a 5 by 7" and never as a hole count, so the preset is keyed on the advertised size
and the grid is what fits inside the printed border — which is why a 4 x 6 is 20 x 14
and not the 15 x 23 that dividing by the pitch suggests. The border is then solved
from the two, so the outline is the advertised size to the tenth of a millimetre.- A preset is a product, not a grid size. The green double-sided board arrives with
its printed legend, oblong finger strips down the two edges that have room for them,
and a screw hole in each corner sitting in the border; the orange phenolic one
arrives with none of that, copper on one face and round pads throughout. Applying one
is a singleboard.applyPreset— board, fingers and corner holes are one decision,
and four commands would put four entries in the history and leave a board describable
as a product nobody sells partway down the undo stack. - Which two edges carry the fingers is derived from the border, not named: the
answer flips with the aspect ratio, and a named pair puts the strip down the cramped
side of a portrait board.
- A preset is a product, not a grid size. The green double-sided board arrives with
-
Single-sided boards (
board.singleSided) — the cheap brown/orange phenolic kind.
Copper on the solder side only: the component side is bare substrate with drilled holes
and nothing to solder to, which is most of what makes those boards look and behave
differently from the double-sided FR-4 ones. Both renderers draw the holes on that face
and no pads, rather than the blank slab that skipping the grid entirely would give. -
edgeConnector.insetMm, bare substrate between a finger's outer end and the board
edge. Zero is a true card edge, where reaching the edge is the point; anything else is
what the prototyping boards do — the elongated pads stop short, and the strip left
outside them is where the row numbers are printed. Without it the fingers swallow the
whole border and the legend has nowhere to go, which is exactly what the first attempt
did. -
mountingHole.offsetXMm/offsetYMm, so a corner hole can sit in the border
instead of on the grid. That is where every real board puts them: the copper is
untouched and the screws go outside it. Pinned to a grid position, a mounting hole
reports four pads destroyed that are perfectly intact. Still addressed by the nearest
hole, so "the hole outside A1" is something a builder can still find. -
Two DRC rules for mounting holes.
mounting-hole-conflictis an error, and the
only rule in the file that is: every other one describes a board that will probably
fail, while this one describes a board that cannot work — there is no pad there to
solder to. A 3.2 mm bore reaches 1.6 mm out and the neighbouring pad's near edge is
1.59 mm away, so an M3 hole takes the copper off its four orthogonal neighbours as well
as its own, which is not something anyone notices before the iron is hot.
mounting-hole-clearanceis a warning: the board is buildable, the screw just
cannot be fitted without pressing on a part. -
scenetext.draw_physical_label, the exact opposite ofdraw_labeland needed
alongside it. An annotation this program adds should hold its size as the board zooms;
ink printed on the board should not, and has to come out 1.2 mm on the 1:1 export. Both
exist because asking for a millimetre-sized font directly gets a fraction of a point,
which some font engines decline to draw at all while reporting no error. -
The placement optimiser (PLAN.md §6.3,
placer.py): seeded simulated annealing
over translate/rotate/swap, with a cost of HPWL + rail alignability + courtyard
overlap + pin collisions + off-board pins + edge-seeking connectors + heat proximity.
Deterministic — same document and seed, same board. Reachable from Place →
Auto-place Board (Ctrl+Shift+A), which shows what it found and what it bought before
moving anything, and Try Another Arrangement, which advances the seed.- Candidates are chosen by routing each one rather than by trusting HPWL. Measuring
is what settled it: on NE555 one candidate with 152 mm of HPWL routes for 191 while
another with 145 mm routes for 209, because half-perimeter cannot see that a shorter
net crosses three others. The cheap heuristic searches, the expensive truth decides. - On the fixtures: NE555 5 → 3 insulated wires (routing cost 202 → 151); the same
circuit from a grid import 7 → 3 with 2 unroutable connections becoming 0;dense
3 → 0 and its 6 courtyard overlaps cleared;sparse2 → 0.
- Candidates are chosen by routing each one rather than by trusting HPWL. Measuring
-
component.moveMany, so a whole placement is one undo step — the counterpart of
conductor.addMany. All-or-nothing, and it refuses a locked part, an off-board anchor
or the same component twice. -
Headless mode reports a dry-run placement, so CI has a number that moves when either
the placer or the router changes. -
The soldering guide (PLAN.md §7, milestone M5,
guide.py+guide_export.py) —
the thing the project exists to produce. Nine phases in build order, a step per part
with its hole addresses, lead-bend pitch and orientation, a step per connection with
its path, length and estimated resistance, a wire cut list, a spine list and a BOM.
File → Export Build Guide (Ctrl+B) writes four files; headless mode writes them too.- Verification checkpoints, which is the part no competing tool has. Continuity
comes from the schematic's own nets and lands in the phase that finishes each net.
Isolation comes from DRC: every R5′ proximity warning — a solder trace running
0.6 mm from another net's pad — becomes a specific probe, so the risk the tool
predicted and the measurement the user performs come off one list. Long runs get an
end-to-end resistance expectation computed from the same model DRC prints. - Polarity is read from the registry's pin names, not from a convention about
pin 1, because no one convention covers an electrolytic (pin 1 is+), an LED
(pin 1 is the anode) and a diode (pin 1 is the cathode) at once. - The HTML is one self-contained offline file with tickable steps and progress in
localStorage— no CDN, no fonts, no network, so it still opens from a USB stick on
a phone in five years. - Anything the guide cannot cover — no netlist, an unknown footprint, an open net, a
DRC error — is reported as a warning rather than producing a quietly shorter guide.
- Verification checkpoints, which is the part no competing tool has. Continuity
-
drc.trace_electricalis public, so the guide and DRC rule 9 quote one resistance
model rather than two. -
The MCP server (PLAN.md §9, milestone M6,
perfstudio.mcp): 31 tools over stdio or
streamable HTTP, driving the same command bus the GUI does, so an agent's edits undo
the same way and land in the same journal.python -m perfstudio.mcp, or
perfstudio-mcp. Setup and the full tool list are in docs/MCP.md.- Holes are addressed as
C7everywhere — there are no raw coordinates in the API,
and a test enforces it. - A refused command comes back as data with a code, not as an exception; only
malformed input raises, and the message names what would have worked. BoardSessionholds every operation and imports no MCP at all, so the tools are
tested by calling them — a test that stands up a stdio server tests the transport.examples/ne555-astable.netships as something to import, and the end-to-end test
takes a blank board through import → place → optimise → route → verify → guide with
7/7 nets matched, 0 opens, 0 shorts, 0 DRC errors and no guide warnings.
- Holes are addressed as
-
Rip-up and re-route (
autoroute.plan_reroute,conductor.replace,
Route → Re-route Everything / Re-route Nets of Selection (Ctrl+Alt+R), and the
rerouteMCP tool). Autoroute only adds, which is right for finishing a board and
wrong after a part has moved: the copper laid for the old position still joins the
right pins, so it is neither stale nor floating nor redundant, and routing again puts
more copper beside it. Measured on the NE555 fixture — 14 conductors routed fresh, 16
after moving one resistor and autorouting again, none of them removable without
disconnecting something, and 14 again after a re-route. Ctrl+R now notices when a net's
parts have moved since it was routed and offers to re-route it instead. -
Nothing is lost by closing the window. There was no
closeEventand no notion of
a modified document, so the X button silently discarded the work. Save / Discard /
Cancel on close, open and new, a bullet in the title bar, and File → New Board,
which did not exist at all — the only way to start a board was to quit and relaunch. -
File → Board Setup — the first thing anywhere able to reach
board.set, which was
unreachable from the GUI and MCP alike, so the grid was frozen at 60×40 FR-4 unless
you hand-edited JSON. The material matters: it sets the iron temperature and dwell the
build guide gives, and DRC's pad-lifting rule only fires on FR-2/FR-1 — the cheap
phenolic most perfboard is actually sold as. -
A Draw menu, reaching
conductor.add, which had existed since the first commit
with nothing able to call it: on a perfboard tool there was no way to run a wire or lay
a solder trace by hand. A wire is two clicks; a trace is a chain ending in Enter. The
preview refuses a diagonal step on a trace visibly rather than ignoring the click. -
Conductors are selectable and deletable. Before this a single bad route could only
be removed by undoing the whole autoroute or re-routing the entire board. -
The hole under the cursor is in the status bar. Every DRC message, guide step and MCP
argument says "C7", and there was no way to tell which hole the pointer was on. -
A Turkish interface (
ui/i18n.py):perfstudio --lang tr,PERFSTUDIO_LANG=tr,
or the system locale. A dict rather than Qt Linguist, so there is no build step and no
binary catalogue, and every key is the English string — a translation cannot attach to
the wrong message and English is never "missing".tests/test_i18n.pyfails if the
catalogue names a string the interface no longer has (the way translation files
normally rot), if a translation drops its&accelerator, or if two items in one menu
claim the same one — which is how it caught Aç and Ayarları both claimingA
in the File menu. Hole addresses, rule ids and every engine message stay untranslated
on purpose: the addresses are the tool's vocabulary, and the engine's strings are
compared byte for byte against the reference implementation. -
The exploded view (PLAN.md D7, milestone M4), View → Exploded View in the 3D
panel. Every part rises off the board with a leader line down to each hole it drops
into, which is not decoration: a plain vertical lift is ambiguous, and measurably so.
From the standard three-quarter viewpoint a part over the middle of the board projects
onto the board and reads as sitting on it, while an identical part near an edge reads
as floating — the same lift meaning two different things depending only on where the
part happens to be. The lines settle it at any lift, and they answer the question the
view exists to ask, which is not "what is on this board" but "which holes does this
one go in". The camera is left alone, as everywhere else in this view. -
The board part-way through being built (
guide.document_at_step,
guide.step_focus). One function that knows what "partly built" means, because the two
things that need it must agree: an assembly animation is these documents played in
sequence, and a step image is one of them with the step's own part picked out. Worked
out separately they would drift, and a board would end up drawn with a part the step
beside it has not asked for yet.- Build order, not document order and not the order the router produced: lowest part
first, jumpers before whatever stands on them. - The board, its mechanical features and the schematic intent never change — they are
what you started with. Only the parts and the copper arrive over time, one per step,
and the last step is the document itself rather than a reconstruction of it. - The index is clamped at both ends, so a caller rendering a "before" frame and a
"done" frame needs no special cases. A part the guide could not write a step for
never appears at any index: a picture must not show a part in a hole the guide
declined to name.
- Build order, not document order and not the order the router produced: lowest part
-
view3d.render_offscreentakesexploded_mmandhighlight, so one call is one
step card's illustration. Highlighting dims the other parts and the copper and never
the board — a step card says which holes a part goes in, and a reader who cannot see
the holes has been handed the answer with the question rubbed out. -
CI (PLAN.md §14), on every push and pull request. This project's central claim is
that the Python engine reproduces the retired TypeScript one byte for byte, and until
now nothing checked that except somebody remembering to. Three jobs:- tests on Linux, Windows and macOS, on the Python floor
pyprojectpromises and
on the next version up.fail-fastis off so every platform reports: a rendering
fault is usually specific to one of them, which is the whole reason the matrix
exists. Linux installs the X, GL and dbus libraries Qt and VTK link against, and runs
underxvfb— Qt's offscreen platform is not the same thing as a GL context, and the
build guide's step images need a real one. - Linux on every push; the whole matrix on
main, on tags, and on request. The
repository is private, so runner minutes are metered, and not evenly: Windows bills
at 2× and macOS at 10×. The full matrix everywhere costs upwards of sixty billed
minutes per push, which for a thirty-second test suite is an allowance spent
re-proving that Windows survived a docstring edit. A platform-specific fault still
cannot reach a release without being seen, which is the property worth paying for. mypy --strict src, andsrcdeliberately: the engine is strict-clean and must
stay that way, while the tests are not and never have been. Gating on something
already broken teaches everyone to ignore the red tick.- The headless run on a golden fixture, which is the only thing that exercises 2D,
3D and the PDF export against a real board rather than against assertions about them.
What it drew is uploaded as an artifact. ruffreports and does not block, and the reason is written into the workflow.
ruff check src testsfinds a few hundred things — overwhelminglyE501on message
strings andRUF001on the Turkish catalogue's dotless i — andruff formatwould
rewrite 40 of the 57 files. Both are worth settling. Neither is worth settling by
surprise inside a CI change, because the answer decides whether every line of blame
in this repository points at a reformat.
- tests on Linux, Windows and macOS, on the Python floor
-
Assembly playback in the 3D panel (PLAN.md D7, closing milestone M4): a slider and
a Play button under the view. Drag back and the parts and copper come off in reverse
build order; press Play and the board assembles itself a step at a time, with the step
being done picked out and named in the caption beside it.- There is no "animation mode". The slider's maximum is the finished board, which
is where it sits, so an untouched panel behaves exactly as it did before. A mode
would mean a way to be stuck in one, and a second thing to remember to turn off
before the view means what it looks like it means. - The slider counts things fitted, not steps done, so its two ends are the two
states anybody actually asks for: a bare board at 0 and a finished one at the top.
assembly_step_foris a plain function for that arithmetic, because the first
version returned -1 at both ends and drew a complete board at the position that means
nothing has been fitted yet. - It returns to the end on every edit. A position part-way through a build that no
longer exists is not a position: adding a part renumbers everything after it, so
holding the index would quietly show a different moment than the one being looked at. - The camera is left alone throughout, as everywhere else in this view.
- There is no "animation mode". The slider's maximum is the finished board, which
-
The build guide has pictures (PLAN.md §7.2), one per step: the board as it stands
at that point with the thing that step asks for picked out of it. Written by
File → Export Build Guide, by headless mode, and by the MCPgenerate_guide.
22 renders in about half a second on the NE555 fixture, because one render window is
re-actored per step rather than stood up again for each.- Photographed from the side the work is done on. Almost every connection is made
on the solder side, and shot from the component side it is behind 1.6 mm of board:
the first version produced fourteen pictures of a board with nothing happening in
them. There are two cameras now, and a step is shot from the face the builder is
actually looking at when they do it. - Tinted, not merely brightened. The subject was first given its own colour with
the light turned up, which is invisible when the subject is a black DIP and
everything around it has been dimmed to near-black. A step image cannot depend on the
part happening to be a light colour. - Within a face the camera frames the finished board once and is then left alone, so
flipping through the guide reads as one board being built rather than a series of
unrelated photographs. guide_export.guide_to_htmltakes raw PNG bytes and base64s them into the
document itself. Not paths and not URLs: a caller cannot hand it a link, so the
finished guide cannot acquire a dependency on a server or on the folder it was
written into. It still opens from a USB stick in five years, which is the whole
reason that file has no CDN, no fonts and no network. Headless prints the resulting
size, because that is the property that would quietly stop being true.- A guide with no pictures is still a complete guide. On an install where VTK will not
load, MCP reportsstep_images: 0and writes every word of it anyway.
- Photographed from the side the work is done on. Almost every connection is made
Changed
-
A routing style is now a commitment, not a weighting. Picking
Solder trace where possibleused to mean "solder where solder happens to be cheapest", and the difference
showed: on the NE555 fixture the default table turned a clean five-pad trace into a
10 mm bare wire because one pad sat next to another net.RouterOptions.prefermakes
the menu item mean what it says — every strategy in the family the builder committed to
outranks every strategy outside it, whatever the two cost, and cost only decides within
the family. Wire is reached when a trace physically cannot make the connection, not when
it scores badly.- Two numbers in the default cost table are why this was needed, and they are worth
writing down because they are not obvious:proximity_riskis 12 a hole while
bare_wire_fixedis 8, so a single risky pad costs more than an entire wire; and
bare_wire_per_mmat 0.15 works out to 0.38 per pad againstsolder_trace_step's
1.0, so wire is 2.6× cheaper per unit distance and wins every long run outright. The
table is unchanged — the commitment sits above it — so all the golden routes stand. - NE555 with
solder: all 14 connections are traces (7 plain, 6 hopped over a crossing,
1 spined) and not one is a wire. Nothing is left unrouted, and LVS and DRC still pass
for every style. - A hopped trace counts as solder. It is a solder run with a two-hole jumper where it
had to cross something; classing it as wire would make a preference for solder reject
the one mechanism that gets solder past an obstacle, leaving the whole connection to be
a wire — more wire, not less. - A rail is a solder concept, so committing to wire no longer comes back with solder
rails in it. This is the one place a commitment has to be honoured outside
route_connection's candidate sort, because_rail_netreaches pastresult.bestto
pick a strategy that contacts every pad it passes. balancedalone makes no commitment, and that is what balanced means. It is also what
every golden route is produced with, so that branch stays a no-op.
- Two numbers in the default cost table are why this was needed, and they are worth
-
The document format version stays at 1, and that is a deliberate call rather than
an oversight. Every field above is omitted from the JSON when it holds its default, so
a board using none of them serializes to the bytes a build predating them wrote — all
15 golden fixtures still round-trip byte for byte, and an older build opens such a file
unchanged. The rule in this project is that the format version moves when an older file
needs migrating in order to load, and none does. The cost is that a build predating
these features will silently drop them from a file that does use them; the migration
seam inpersist.pyis where that would be addressed if it ever bites.heightLimitMm
follows the same rule, and a hand-edited zero or negative loads with a warning and is
dropped rather than reporting every part on the board as too tall. -
jumper-under-bodyjoinsconductor-crossinginPYTHON_ONLY_RULES, and for a
stronger reason: the TypeScript engine has no counterpart to disagree with, so no
fixture records anything for it and the expected files cannot be regenerated to include
it. It fires 15 times across 6 of the 15 fixtures —denseearns 6 on its own, because
cond-12is a 29-hole top jumper straight across row 18 of a board that already has
six overlapping bodies, and it runs over a header, a TO-92 and an LED on the way. A
test pins those counts, so the divergence stays an improvement rather than becoming a
hole in the proof.heat-proximityandcomponent-too-tallfire nowhere in the
fixtures — none carries a TO-220, a relay or a height limit — so the golden DRC data is
untouched by them. -
The editor's ruler stands down when the board prints its own addresses. Drawing
both put the same twenty-four letters on screen twice, a few millimetres apart and in
two different styles, which reads as a rendering fault rather than as two features. The
ruler is for boards that carry no addresses; View → Show Hole Addresses is greyed
out with a reason on boards that do, and comes back when the board is flipped to a face
the legend cannot be read from. -
The printed legend goes on all four edges, letters top and bottom and numbers down
both sides, with the numbers turned on their side as the real boards set them — the
strip beside a row is narrow across and a whole pitch deep, so a turned number fits
where an upright one has to shrink. -
An edge-connector finger is now the pad, not a layer over it. The grid no longer
draws a round pad underneath one, which is what made the fingers look like something
laid on top of the board.geometry.holes_without_grid_padis the single answer to
"this hole has no ordinary pad", for either reason (a bore took the copper, or a finger
is the copper) and for either face. -
DRC's proximity rule measures the gap per pair instead of once per board. The
number waspitch - padDiameter, which is right for round pads and wrong for every
other case — it cannot see that an oblong pad's neighbour is half as far away one way
as the other, or that a pad widened into a connector finger has less clearance than the
pad it replaced. Round-pad boards report exactly what they did before. -
The 2D view is seven times faster on a large board: a 100×60 grid (6000 holes) went
from 112 ms a frame (8.9 fps) to 16 ms (62 fps), by rasterising one pad and blitting
it. Two approaches that did not work are recorded inPadGridItem— one even-odd path
for every ring took 5.8 seconds, and disabling antialiasing bought half as much while
making the board look cheap. -
Auto-place and autoroute run off the UI thread, with a progress dialog that appears
only if the work outlasts a grace period, and a Cancel that asks the placer to stop and
return its best result so far rather than discarding it. -
Conductors are drawn at physical widths. They were set for legibility alone, which
made every solder trace wider than the pads it joins and turned a routed board into a
diagram of coloured bars with a board somewhere underneath. Solder beads now sit inside
the pad, a wired trace shows its copper spine as a core, and bare wire is half a
millimetre. -
Red is no longer a conductor colour. It is the error and R5′ risk colour, and it
was also every insulated wire, so a completely correct board looked alarming and a real
risk had nothing to stand out against. Insulated wire takes its net's colour
instead — the same convention the build guide's cut list prints, so the screen and the
list someone works from cannot disagree about which wire is which. -
The solder side hatches each part's footprint, so it is clear something is on the other
side without drawing a body as seen from above — which is how a board gets soldered
backwards. The hatch deliberately carries no cathode band, pin-1 notch or tab: those
are moulded into the top of a part and cannot be seen from below.
Fixed
-
The MCP server did not import at all against the current SDK.
pyprojectasked for
mcp>=1.0with no upper bound, so a fresh install resolved tomcp2.0.0 — which
removesmcp.server.fastmcp, the decorator API every one of the 39 tools is bound with.
python -m perfstudio.mcpdied on the import. Capped at<2: 1.29.0 still ships
fastmcp and deprecates nothing.- A development machine could not have noticed.
pip install -e ".[mcp]"leaves an
already-satisfied requirement alone, so a tree that installed 1.x weeks ago keeps it
and stays green — while every CI runner, every packaging job and every new user starts
from nothing and gets 2.0.0. The local suite and a fresh clone were testing two
different dependency sets, which is exactly the failure an unbounded range invites. - Lifting the cap means porting the tool surface to the 2.x API. That is a change with
its own verification attached, not a version bump, so it is not being done on the way
out of the door.
- A development machine could not have noticed.
-
No installer could be built on any platform, and the reason was older than the SDK
break above.perfstudio.speccollects the MCP package with
collect_submodules("mcp"), which imports every module it walks — including
mcp.cli, a Typer front end that doesprint(...); sys.exit(1)at import time when
typer is absent. So the collecting child process exits, PyInstaller reports "Child
process call to _collect_submodules() failed", and all three bundles die before a byte
is packed.mcp.cliis excluded now; nothing in this application invokes the SDK's
command line, and the 19fastmcpmodules that matter are untouched.- This is present in
mcp1.x as well as 2.x, so capping the version did not fix it
and would not have. It built on the machine it was written on because that machine
happened to have typer pulled in by something unrelated — the same class of fault as
the cap above, found the same way, and the reason both are in this release rather
than in the first bug report from somebody who downloaded nothing.
- This is present in
-
The macOS bundle could not be built at all, on a line that has never run anywhere
else.perfstudio.specbuilds the.icnsby scaling the mark to each size Finder
wants, and passed the aspect and transformation modes as the bare integers1and1
with the enum names in a comment beside them. PySide6 6.10 refuses an int where an enum
is declared — "QImage.scaled called with wrong argument values" — and that branch runs
on macOS alone, so the machine this was written on could not have executed it. The enums
are passed by name now, which is also what the comment said all along. -
Two footprints failed their golden comparison on macOS arm64, and the bound was
measuring at the wrong scale. A circle vertex iscentre + radius * cos(theta), and
the ULP bound the test allows for a trig disagreement was counted on the vertex — which
is right until that addition cancels, and on a circle it cancels somewhere by
construction.led-3mmvertex 8 is1.27 + (-1.385): one ULP at the scale the
arithmetic is done at is sixteen ULPs at the scale of the 0.115 that survives it.
The observed failures were exactly 16 ULPs there and 4 onc-elec-d10-p3, matching each
vertex's own cancellation factor to the digit — a second libm disagreeing by the
smallest amount a libm can, not a formula difference. The bound is now applied at the
scale the terms are added at, where the error is actually made, and it still rejects a
divergence of a nanometre. -
The suite aborted rather than failed on a machine with no OpenGL. VTK does not
decline when there is no context behind an offscreen window — it takes the interpreter
down, so on GitHub's Windows runnerswin.Render()inrender_step_imagesended the
pytest process with an access violation and every test after it was not reported at all.
tests/test_gl.pynow asks the question in a child process, where a crash is an answer
rather than the end of the session, and the five tests that put a board through VTK
skip when it says no. Qt's offscreen platform plugin is not a GL context, which is the
same reason the Linux job runs under xvfb.- Which five is not left to whoever remembers. Marking them by hand found three and
missed two, at a full CI round each, because a test reaches the renderer through
on_export_guideorgenerate_guide(directory)without naming it. A test now reads
the sources and fails if a test function calls one of those and is not marked — worth
a static check rather than a convention, since the cost of missing one is not a red
test but a run that stops reporting partway through. - The frozen bundle's smoke test on that runner is no longer held to its exit status
either, and the reason is written into both workflows. Everything the check exists for
still happens before the 3D stage — the app starts, opens a document, renders 2D,
writes the 1:1 PDF, runs DRC and LVS — and a missingout_2d.pngstill fails the job. - What this does not fix:
generate_guidecatches an exception from the render and
writes a picture-less guide, which is the right behaviour and is unreachable when the
failure is an abort rather than an exception. Surviving that means rendering in a
subprocess, which is a change to the application rather than to its tests.
- Which five is not left to whoever remembers. Marking them by hand found three and
-
Escape did not cancel placing a part. Reported. It was bound to the Draw menu's stop
entry, which cancelled drawing, pin-picking and connecting — and not placement. Worse,
being a window shortcut it fires before the board scene sees the key at all, so the
scene's own Escape handling for placement was unreachable in the running application:
a part armed from the parts list could not be cancelled from the keyboard by any route,
while the hint under that very list said "Esc cancels" the whole time. Leaving a mode is
now one method on the scene that disarms all four, called by both the shortcut and the
key handler, so the two cannot cancel different sets of things. The menu entry says what
it now does — "Stop the Current Tool" rather than "Stop Drawing". Four regression tests
press a real Escape from the focus a user actually has, because a test that called the
handler directly would have passed against the broken build. -
The menus could be destroyed out from under the menu bar.
QMenuBar.addMenuhands
PySide aQMenuthat Python believes it owns, and every menu in this window was held
only by a local variable inside the builder — so a garbage collection was free to delete
the real menu and leave the bar holding an action that pointed at freed memory. It
survived this long because nothing had ever walked the menus after building them; the
shortcut card is the first thing that does, and it found a destroyedQMenuon its
first run. Every menu is now referenced by the window, and a test collects garbage and
checks they are all still there. -
Quit had no working shortcut on Windows.
QKeySequence.StandardKey.Quitresolves
there to a key almost no keyboard has (it reports itself as "Exit"), so the binding was
effectively absent — and the new shortcut card printed it, which is how it was noticed.
It is Ctrl+Q now, which Qt maps to Cmd+Q on macOS. -
The pertinax board was still wrong in three more ways, all corrected against the
board in a user's hand rather than against a guess.- Its finger strips were missing entirely. The preset gave them only to the green
board. Both families have them. - They go across the SHORT edges. The edges used to be derived from which border
was wider, which gives the right answer on the green boards and the wrong one on the
phenolic: its two borders are 2.14 mm and 1.98 mm, so a tenth of a millimetre decided
which way a strip of contacts ran.preset_strip_edgesasks the board's proportions
now — a strip belongs across the narrow end, not down the length. - A finger has no hole through it (
geometry.undrilled_holes). It is a solid
contact soldered to from the surface, which is the whole difference between a finger
and a pad. Both renderers were drilling them: the grid drills every position it has,
and 2D then punched the bore back through the finger on top, on the stated reasoning
that "the finger is copper laid over the pad". It is not laid over the pad; it is
the pad. - And they are solder-side only on a single-sided board.
face: "both"had put a
strip of contacts on the bare phenolic face, where the board has nothing but
substrate.
- Its finger strips were missing entirely. The preset gave them only to the green
-
A corner screw hole was drilled through the end of the finger strip. The strips ran
the full width of the board and the corner holes went in independently, so nothing
reconciled them: measured at 0.21 mm of overlap on the 2 × 8 and 6 × 8 presets, on a
board the program produced before anybody had touched it. A bore removes copper, so
that is a destroyed contact. The strip is trimmed to clear them now, and
preset_edge_connectorsaskspreset_mounting_holesitself rather than leaving two
features to be combined by a caller with no way to know they interfere.- Clearance, not merely not-overlapping. Trimming to first contact left 0.01 mm of
board between copper and drill on the 5 × 7 and 0.09 mm on the 4 × 6, which is not a
gap — a hole drilled that close breaks out into the pad.FINGER_BORE_CLEARANCE_MM
is 0.3 mm and every preset now clears by at least 2.3 mm. - The 5 × 7 was clear by luck of the arithmetic before any of this, which is why it
looked fine: it is the board that gets rendered when something is being checked.
- Clearance, not merely not-overlapping. Trimming to first contact left 0.01 mm of
-
A phenolic board wore FR-4's gold pads.
BoardSchemedescribed the substrate and
the silkscreen but not the copper, so one global gold served every board. The finish is
one of the two things you notice first: a plated FR-4 board is yellow, a cheap phenolic
board's pads are bare copper. The copper is part of the scheme now.- The values are measured, not chosen. Sampled off photographs of the boards: the
substrate lands at#c67a3f,#c17c58and#bb7441across three of them, and the
pads at hue 25-28° with saturation around 0.35 — warm, so copper rather than tin, and
much paler than a first guess at "pink-brown" had them. - That same measurement caught a wrong turn in this changelog. The substrate was
briefly changed to a mid-brown, on the reasoning that pertinax is the colour of
cardboard. It is not; it is orange, near enough to what was there before, and the
boards say so. It is the one colour in that file anybody can check.
- The values are measured, not chosen. Sampled off photographs of the boards: the
-
The orange pertinax board had no printed addresses, and it should have. The preset
gave a legend to the green double-sided board and withheld it from the phenolic one, on
the reasoning that the phenolic board is the stripped-down product — no fingers, no
corner holes, copper on one face. That was wrong about the one thing it is not stripped
of: these boards carry the sameA..Z/01..NNprint, and it is the cheapest
marking on a board to apply.- It is not a cosmetic miss, which is why it was worth chasing. With no legend the 2D
editor falls back to its own ruler — drawn outside the board, sized in screen
pixels — so the addresses existed on the screen and not on the board in your hand,
and they were absent from the 3D view and the 1:1 printout altogether. The board on
screen stopped being the board you are holding, which is the one thing this view has
to get right. - There was room all along: the 5 × 7 phenolic preset leaves 2.46 mm and 2.30 mm of
bare substrate outside the outer pads, so the legend prints at the full 1.15 mm cap
height a real board uses, with nothing clipped. test_every_preset_prints_its_own_addressesnow holds it for every preset in
both families, rather than two tests pinning the old answer for one family each.
- It is not a cosmetic miss, which is why it was worth chasing. With no legend the 2D
-
The application opened on a board nobody sells. With no file to open, and behind
File → New Board, it usedDEFAULT_BOARD— a bare 60 × 40 grid with a zero border.
A zero border leaves nowhere to print an address, so the very first board a user saw
had no legend and fell back to the editor's ruler, and the 3D view and the 1:1 printout
showed no addresses at all because neither has a ruler to fall back on. It now opens on
the 5 × 7 cm double-sided board viacommands.create_starter_document, as a product
— printed legend, a finger strip down each of the two edges with room for one, and a
screw hole in each corner.DEFAULT_BOARDstays exactly as it was: a bare grid is the
right engine default and the wrong thing to open an application on.- Built directly rather than dispatched as
board.applyPreset, so a new document does
not open with an undo step already on its stack. - File → New Board now applies a chosen preset's fingers and corner holes too. It
readdialog.board()and ignoreddialog.preset_features(), so picking a preset
there produced the grid and none of the product — the half-applied state
board.applyPresetexists to make unreachable.
- Built directly rather than dispatched as
-
Column letters were printed underneath the connector fingers. The legend's position
was measured out from the grid pad, which is correct until the copper on that edge is
an elongated finger reaching most of the way to the board edge. Ink is drawn under
copper, so the letters did not come out faint — they did not come out at all, and the
board showed row numbers and no letters. Both renderers now measure in from the board
edge, which is the same answer on a plain board and the right one on a board with
fingers;legend_strip_mmalready reported the correct width there and this is the
position to match it. The existing border test could not catch it: its band runs from
the grid pad to the board edge, and the middle of a finger is inside that band. -
The 3D legend read backwards on the underside. Both faces were built from one set
of glyphs at two different depths, so turning the board over showed every address
mirrored — on the view whose whole job is checking the solder side. The bottom face is
now reflected about the hole span, the same axisview2d.hole_to_screenmirrors about,
soAstill sits on the hole it names. 2D deliberately does the opposite for the face
it is seeing through the board, and the two are not in conflict: 3D is looking at ink
directly, 2D is looking at it through 1.6 mm of phenolic. -
The build guide scheduled top jumpers after the parts they run under, which is an
order nobody can follow. Jumpers sit in phase 7 because they are usually soldered to
pins already fitted, and that is still right for almost all of them — but by phase 7 a
part standing over a jumper is on the board and the wire has nowhere to go. The ones
jumper-under-bodyflags now move to phase 1, where PLAN.md §7.1 puts top-side
jumpers in the first place, and the part standing over one gets a note saying to check
it is down and lying flat first. Both read the same rule result, so the order and the
note cannot disagree. -
bodies.polarity_pin_offset's docstring claimed pin 1 is "the cathode of a diode or
LED", which the registry contradicts for the LED (its pin 1 is namedA). The drawing
was right; the sentence a reader would have believed was not. -
Versioning.
perfstudio.__version__is single-sourced fromversion.py, the wheel's
version is derived from it rather than repeated inpyproject.toml, and this file
exists.perfstudio --versionprints the app version, the document format version and
the Python/PySide6 it is running on, which is what a bug report should quote. -
The version is in the window title and in Help → About, so a screenshot says which
build produced it.
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.