Skip to content

PerfStudio v0.4.0

Choose a tag to compare

@github-actions github-actions released this 14 Aug 09:21
· 21 commits to main since this release

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.0 tag on a
      tree that still says 0.4.0.dev0 publishes 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 .devN suffix, and refuses one with no
      CHANGELOG.md section. 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.
  • 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
    .net a schematic tool exports, and the .perf that importing, placing and routing it
    produces.

    • Chosen for what they make the tool say rather than for variety. The LM317 is a
      TO-220, so heat-proximity has a hot part to measure from, and that board carries
      the set's one solder-trace-proximity warning — R5' doing its job and becoming a
      measurement checkpoint. The booster is on FR-2, so the guide drops the iron 30 °C
      and pad-lifting-risk can fire at all. The shield is two headers, which is where lead
      bends and short traces do nearly all the work.
    • tests/test_examples.py asserts 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.
  • An application icon, drawn in code by tools/make_assets.py and committed as a
    .png and an .ico — because unlike the toolbar, an installer and a .desktop entry
    need real files to point at. Every colour comes from ui/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 through document_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_shadow already 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
      in CONDUCTOR_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.
  • 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), because balanced'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_cost quoted in that style's own currency: the wire table prices a solder step
      at 4 and an insulated wire at 6, so its plans are cheap by its own definition of
      cheap
      , and min(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_plan never mentions total_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 in comparison over 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 balanced keeps 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.
  • 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 from ComponentInstance.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, NE555 and dense.perf's placeholder v12 all 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-cylinder archetype. A
      polarized axial body keeps its cathode stripe and is never banded, the same split
      style_for already 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.
  • Parts are lit like the material they are made of. BodyStyle.metallic and
    BodyStyle.lens were 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_for is 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.
  • 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
    from model.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 named N1 for
    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.
  • 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.
  • 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 iconText on a
      toolbar and text in 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 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.
  • Recent files, and a toolbar you can save and undo from. File ▸ Open Recent keeps
    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.import was 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
      one net.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_id claim 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_a and voltage_v finally 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_net are 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.
  • 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 in model.py and 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 a heightLimitMm the document now carries — the
      clear height inside the case, set from File → Board Features…, from the
      height-limit.set command, 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 asks occupancy.body_covers to 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_heights and set_height_limit on the MCP server, taking it to 33
    tools. check_heights is 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
    empty over_limit cannot 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_mm measures 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), the A..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: A07 is still rejected as an address.
    • Mounting holes and edge-connector fingers (mountingHoles, edgeConnectors),
      with mounting-hole.add / .addMany / .delete and edge-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.
  • 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_mm is the one place that says how much substrate is
    outside the grid; hole_span_mm is 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 8 through 20 x 30 cm in 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 single board.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.
  • 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-conflict is 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-clearance is a warning: the board is buildable, the screw just
    cannot be fitted without pressing on a part.

  • scenetext.draw_physical_label, the exact opposite of draw_label and 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; sparse 2 → 0.
  • 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.
  • drc.trace_electrical is 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 C7 everywhere — 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.
    • BoardSession holds 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.net ships 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.
  • Rip-up and re-route (autoroute.plan_reroute, conductor.replace,
    Route → Re-route Everything / Re-route Nets of Selection (Ctrl+Alt+R), and the
    reroute MCP 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 closeEvent and 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.py fails 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 and Ayarları both claiming A
    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.
  • view3d.render_offscreen takes exploded_mm and highlight, 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 pyproject promises and
      on the next version up. fail-fast is 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
      under xvfb — 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, and src deliberately: 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.
    • ruff reports and does not block, and the reason is written into the workflow.
      ruff check src tests finds a few hundred things — overwhelmingly E501 on message
      strings and RUF001 on the Turkish catalogue's dotless i — and ruff format would
      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.
  • 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_for is 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.
  • 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 MCP generate_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_html takes 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 reports step_images: 0 and writes every word of it anyway.

Changed

  • A routing style is now a commitment, not a weighting. Picking Solder trace where possible used 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.prefer makes
    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_risk is 12 a hole while
      bare_wire_fixed is 8, so a single risky pad costs more than an entire wire; and
      bare_wire_per_mm at 0.15 works out to 0.38 per pad against solder_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_net reaches past result.best to
      pick a strategy that contacts every pad it passes.
    • balanced alone 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.
  • 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 in persist.py is 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-body joins conductor-crossing in PYTHON_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 — dense earns 6 on its own, because
    cond-12 is 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-proximity and component-too-tall fire 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_pad is 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 was pitch - 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 in PadGridItem — 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. pyproject asked for
    mcp>=1.0 with no upper bound, so a fresh install resolved to mcp 2.0.0 — which
    removes mcp.server.fastmcp, the decorator API every one of the 39 tools is bound with.
    python -m perfstudio.mcp died 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.
  • No installer could be built on any platform, and the reason was older than the SDK
    break above.
    perfstudio.spec collects the MCP package with
    collect_submodules("mcp"), which imports every module it walks — including
    mcp.cli, a Typer front end that does print(...); 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.cli is excluded now; nothing in this application invokes the SDK's
    command line, and the 19 fastmcp modules that matter are untouched.

    • This is present in mcp 1.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.
  • The macOS bundle could not be built at all, on a line that has never run anywhere
    else. perfstudio.spec builds the .icns by scaling the mark to each size Finder
    wants, and passed the aspect and transformation modes as the bare integers 1 and 1
    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 is centre + 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-3mm vertex 8 is 1.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 on c-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 runners win.Render() in render_step_images ended the
    pytest process with an access violation and every test after it was not reported at all.
    tests/test_gl.py now 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_guide or generate_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 missing out_2d.png still fails the job.
    • What this does not fix: generate_guide catches 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.
  • 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.addMenu hands
    PySide a QMenu that 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 destroyed QMenu on 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.Quit resolves
    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_edges asks 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.
  • 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_connectors asks preset_mounting_holes itself 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.
  • A phenolic board wore FR-4's gold pads. BoardScheme described 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, #c17c58 and #bb7441 across 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 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 same A..Z / 01..NN print, 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_addresses now holds it for every preset in
      both families, rather than two tests pinning the old answer for one family each.
  • The application opened on a board nobody sells. With no file to open, and behind
    File → New Board, it used DEFAULT_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 via commands.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_BOARD stays 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
      read dialog.board() and ignored dialog.preset_features(), so picking a preset
      there produced the grid and none of the product — the half-applied state
      board.applyPreset exists to make unreachable.
  • 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_mm already 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 axis view2d.hole_to_screen mirrors about,
    so A still 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-body flags 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 named A). The drawing
    was right; the sentence a reader would have believed was not.

  • Versioning. perfstudio.__version__ is single-sourced from version.py, the wheel's
    version is derived from it rather than repeated in pyproject.toml, and this file
    exists. perfstudio --version prints 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.