Skip to content

Perfboard Studio v0.11.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 09:28
· 227 commits to main since this release

Changed

  • The application is called Perfboard Studio; its package and command are
    perfboard-studio and its Python module is perfboard_studio.
    It was PerfStudio
    through ten releases. Two things were wrong with that name, and the second is the one
    that decided it. The first is how it reads: one letter separates "perf" from "perv",
    and not every eye lands on the right one. The second is what the abbreviation already
    means in software — "perf" is performance. It is the name of Linux's profiler, and
    AMD shipped a graphics profiler called GPU PerfStudio for years. PLAN.md §12 picked
    "perf" for open-source discoverability, and discoverability is exactly what it cost:
    a search for "perfstudio" lands among profiling tools, not among perfboard.

    The identifier is perfboard-studio rather than the bare perfboard, and that is
    a second decision rather than a longer spelling of the first. perfboard is the name
    of the board, not of this program; taking the generic word as a package name confuses
    the product with the material it works on. It is also a word this repository writes in
    prose on nearly every page, which would have made the name impossible to change again
    without reading every line by hand.

    What it means for an existing install:

    • uv tool install perfboard-studio, or pipx install perfboard-studio. The old
      perfstudio distribution is not renamed in place — PyPI has no such operation — so
      it stays where it is and stops at v0.10.0.
    • The command is perfboard-studio and the MCP server is perfboard-studio-mcp. A
      client configured with python -m perfstudio.mcp has to be pointed at
      python -m perfboard_studio.mcp.
    • The environment variables are PERFBOARD_STUDIO_LANG, PERFBOARD_STUDIO_DOCUMENT,
      PERFBOARD_STUDIO_LOG and the three PERFBOARD_STUDIO_BLESS_*.
    • The window's stored settings are keyed on the application name, so the first start
      after upgrading comes up with the default layout, the default board colour and an
      empty recent-files list. Nothing on disk is lost; the old keys are simply not read.

    The document format did not move. DOCUMENT_FORMAT_VERSION is still 1, the
    extension is still .perf — it was always short for perfboard, never for the
    application — and every golden fixture in tools/diffcheck/golden/ is byte-identical
    across the rename. A board written by v0.10.0 opens in this build unchanged, and the
    differential proof against the TypeScript engine is untouched.

  • The board is the board people actually buy, checked against a photograph of one.
    Somebody held a 5 × 7 cm double-sided board up against the screen and asked whether it
    matched. The geometry did — 70 × 50 mm, the grid, the four corner screw holes in the
    border, the finger strips down both short edges — and two things did not:

    • The pads are TINNED, not gold. _GOLD claimed to be "what the green, blue and
      black prototyping boards are finished with", and on the photograph they are plainly
      not: the pads there are neutral and light, at a blue/red ratio of about 1.0 where
      gold's is 0.4. It is HASL — hot-air levelled solder over copper. _TINNED replaces it
      for every masked scheme, kept warmer and lighter than the grey a solder run is drawn
      in, because those two are nearly the same metal on a real board and telling them apart
      is what this application is for. The 2D render signatures were re-blessed after
      reading the diff: ink coverage identical to three decimals, every cell moved about ten
      levels — a colour change and not a geometry one, which is exactly what it should be.
    • The legend was taken off the green board in the same pass and put straight back:
      the 400-pixel product shot resolved no printing, a photograph of the same product at a
      usable size shows the column letters plainly along the border under the bottom row,
      beside the part number and "5X7CM". A thumbnail too small to resolve a print is
      evidence that the print cannot be SEEN, not that the board has none — which is now
      written at board_from_preset and in the test, because the same mistake is available
      to anyone reading the same photograph.
  • The corner screw hole is AT the corner position, centred, and the pad it replaces is
    gone.
    It used to be pushed as far diagonally into the printed border as it would fit,
    to spare that pad — and one offset was used for both axes, so on a 5 x 7, whose two
    borders are 3.41 and 5.79 mm, the bore ended up 0.20 mm from one edge of the board and
    floating 2.6 mm from the other, with the corner pad jammed against it. Nothing was
    centred in anything, and it is the corner somebody looking at the board looks at first.

    • The board is manufactured with a screw hole at that position INSTEAD of a pad, so the
      pad is consumed — through consumed_holes, which DRC, both renderers and the guide
      already read, with no special case for corners.
    • A board whose border is too thin to hold a screw steps the hole one position inward
      rather than drilling a notch out of its own edge: CORNER_HOLE_WEB_MM is 1 mm of board
      and the corner position leaves 0.31 mm of it on the 20 x 30 and 0.88 mm on the 7 x 9.
      Both are now measured by a test rather than left to the eye. It also means every
      double-sided preset gets its four screw holes; the six largest boards used to get none
      at all, because the old arithmetic gave up and returned zero.
    • The finger strip already trimmed itself clear of the screws, but by taking the run
      from the first clear finger to the last — which assumed the obstruction is always at an
      END. An inset screw obstructs one in the middle, and first-to-last quietly put it back;
      it takes the longest contiguous clear run now.
  • A mounting bore takes the copper it is drilled through, including a finger. A hole
    put one position in from the corner — which is what Board Features offers by default —
    reaches the edge-connector strip, and the finger it went through was still drawn intact
    in both views: a 3.2 mm hole sitting on top of a contact the board no longer has. A bore
    does not distinguish between the two shapes of copper it destroys, so
    geometry.surviving_finger_holes answers that for both renderers the way
    consumed_holes already answered it for a round pad.

  • The printed legend is not printed into a hole. Ink goes ON the substrate and a bore
    takes the substrate away, so a letter under one is not faint, it is absent — which is
    what a real board shows. geometry.printed_label_is_clear is asked by the 2D legend and
    the 3D one, in the same units, because a legend that agreed with the board in one view
    and not the other is the disagreement this pair keeps producing.

  • The board in 3D has holes in it. Which sounds like a statement of the obvious and
    was not true: the substrate was one solid cube and every hole on it was faked by laying
    a dark cylinder over the top. A dark disc on green reads as a mark printed on the board,
    not as something you can push a lead through — and it was worst on a mounting bore,
    which has no copper ring around it to explain the darkness, so a screw hole read as a
    sticker. The plate is punched now: both faces, every hole, and the wall of the bore
    visible inside it.

    • Affordably, which is why the fake existed. A boolean subtraction per hole is
      nearly two thousand of them on a 945-hole board, and that argument still holds. This
      is neither: a face is ONE TILE — a pitch square with its hole taken out — glyphed at
      every hole, so both faces of any board cost one source and two actors. The tiles are
      exactly a pitch across, so neighbours share an edge and the surface is watertight;
      what is left between the outermost tiles and the board's edge is the printed border,
      drawn as rectangles, and a bore in it is cut out of them.
    • A mounting bore takes whole tiles and lays one patch over them, and the tiles it
      takes are exactly the holes geometry.consumed_holes reports the copper gone from —
      one bore, one answer, in the renderer and in DRC. It was also being drawn at its hole
      ADDRESS rather than at mounting_hole_centre_mm, so every corner hole appeared back
      on the grid in the middle of four pads that are perfectly intact. CLAUDE.md has said
      not to do that since the feature was written.
    • An edge-connector finger keeps a solid tile: it is a contact with no bore, and
      drilling through it is what made a finger look like a long pad.
  • The parts look like the parts. Every model was looked at on its own, close up, which
    is how most of these were found: at the whole-board zoom a wrong shape reads as a small
    coloured blob and nothing more.

    • A resistor was a flat-ended tube; it has the moulded shoulder at each end now,
      with the barrel shortened by what the domes add back so the part still measures the
      length its footprint says it does.
    • A film capacitor was a plain orange brick. The dipped case is a slab with
      half-round ends, so it is one now.
    • A disc ceramic was a coin with a sharp rim; the dipped case is a lens, thicker in
      the middle, and that is the same one solid.
    • A TO-92 was a squashed cylinder — which is an ellipse, so it has two flats and
      marks nothing. It is the D-shaped case now, with the flat facing its row of pins, and
      getting a transistor round the wrong way is the classic way to spend an evening.
    • A TO-220's tab was a slab the width of the package sitting on top of it. The tab
      is sheet metal the plastic is moulded around: thin, flush with the back face, and with
      the bolt hole through it — the side a heatsink goes on.
    • An HC-49 crystal was a flat-topped tube. It has the pressed cap and the welded lip
      at its base, which is also what says which way up it goes.
    • A DIP's pins are flat blades rather than round wire, it has the semicircular notch
      at the pin-1 end as well as the dot, and the dot is a dimple rather than a headlamp:
      at a tenth of the package in the accent colour it read as a component of its own.
    • An electrolytic gained the crimped rim and the vent scored into its top. It had
      been given a WHITE top — and two of them on a board then read as screw heads, which is
      exactly what the first person to see it called them.
  • The 2D and the 3D board draw the same board. Three things had them disagreeing, and
    all three were the 2D view or the renderer being wrong rather than a difference of
    opinion:

    • A corner mounting hole was drawn on the grid in 2D. MountingHoleItem read the
      hole ADDRESS and ignored the offset — the very thing CLAUDE.md says to go through
      mounting_hole_centre_mm for — so a bore that sits in the printed border was drawn a
      whole pad's width away from where 3D and DRC both put it. view2d.mm_to_screen is the
      mapping it needed: the same reflection hole_to_screen applies, for the things that
      are not on the grid.
    • A bore in the border blinded the hole beside it. The patch of plate laid over a
      mounting bore takes whole tiles, and it was grown OUT to tile edges — so a corner bore
      that touches no tile at all swallowed its neighbour, leaving a pad drawn over solid
      board with no hole through it. The patch now takes the tiles the bore actually reaches
      and, for whatever of it lies outside the tiled grid, a rectangle clipped so it cannot
      reach a tile the bore never touched.
    • The copper was clipping. VTK's default specular POWER is 1.0, which is not a
      highlight — it adds the specular term flat across the whole surface. A pad carrying
      0.4 of it rendered a fifth brighter than its own colour and blew out: measured at
      (255, 255, 125) against the #c8a951 the 2D view paints from the same table, which
      also flattened the shading off the metal and made the board a grid of flat yellow
      rings. COPPER_SPECULAR_POWER is on the pads, the strips and the fingers now, and
      test_the_board_s_copper_is_never_blown_out renders a board and counts the clipped
      pixels.
  • A part is now the height its footprint says it is, and that is measured. VTK applies
    an actor's scale BEFORE its orientation, and the turn that stands a cylinder up maps the
    source's own z onto world y — so a scale written to flatten the width of an upright can
    flattened its LENGTH instead. The crystal came out a quarter short with its cap floating
    in the air above it, and the render looked like a part, just not that part.
    test_every_part_stands_exactly_as_tall_as_its_footprint_says measures every archetype
    against the number the height-limit rule works from.

  • No mesh library, and that is still the decision. The only comprehensive set of
    through-hole models is KiCad's, under CC-BY-SA-4.0: a share-alike licence in a
    repository that is Apache-2.0, for assets that would then have to be shipped, versioned
    and kept in step with the footprints. PLAN.md D6 declined it and the reason has not
    changed — and there is a second one now, which is that bodies.py derives the 2D
    footprint and the 3D solid from the same BodySpec, so a mesh would be a third
    description of a part free to disagree with both.

  • The repository page describes the project, and says where everything is. Three
    things had drifted apart from what is actually here.

    • GitHub's language bar read 14% TypeScript, HTML, NSIS and JavaScript. All of it
      came from things nobody installs: packages/ (the retired reference engine),
      prototypes/ (the Qt spike ui/ came out of), tools/bench-3d (the M0 WebGL
      harness for the Tauri architecture that was not taken) and the golden fixtures. They
      are linguist-vendored in .gitattributes now — not linguist-generated, which
      would collapse them in a diff, and a readable golden diff is the point of the
      goldens. Only the statistics change.
    • Both READMEs now say where the rest of the documentation is. They linked
      PLAN.md four times without mentioning it is written in Turkish, and never linked
      the changelog, the security policy or the code of conduct at all. One table names all
      nine, with what each is for; a nav line under the status callout links to the install
      commands, which were 110 lines down the page.
    • prototypes/ and tools/bench-3d say what they are. A visitor clicking into
      either found a working application built on an architecture this project does not
      use, with nothing to say it was a spike.
  • Both READMEs open the way cycloidgen's does, because they came from one hand.
    The other Medinstech desktop application had already settled a house style and this one
    had never been held next to it: the Medinstech wordmark linking to the site, with a
    light and a dark variant behind a <picture>; a centred title, one-line positioning
    and a centred description; four flat-square shields in the brand's #0d00ff, two of
    them live (the workflow's status, the latest release) rather than hand-written text
    that goes stale; a centred row of jumps with the two that matter in bold; then the
    screenshot and a caption saying what to look at in it. The old ## What it does is
    gone — its paragraph IS the centred description now and its three numbered points are
    six bullets, which is the shape the other page states its case in.

    • A Jump to line under them, and in the Turkish file one heading needed an
      explicit ASCII anchor: GitHub lowercases İ to i plus a COMBINING DOT ABOVE, so
      the automatic anchor for İki yüz holds a character nobody can see and nobody would
      type correctly.
  • The card GitHub unfurls a link into is the product. It was the generic
    auto-generated one. tools/make_social_preview.py draws
    docs/images/social-preview.png from ui/theme.py's colours and the editor
    screenshot, so it cannot become a picture of an older version of either. It is
    committed even though GitHub will not take it from a commit — there is no API to set a
    social preview — because "which image is up there?" should still be answerable from the
    tree.

  • The installers come first, and pip install is no longer the headline. It is a
    desktop application and the three platform builds — none of which had been downloaded
    once — were a paragraph below a pip install perfboard-studio that does not work for most of
    the people this is for: an Intel Mac's system Python is 3.9, and Debian, Ubuntu and
    Fedora refuse a system-wide pip install outright under PEP 668. The releases table
    is the first thing under Running it now, and PyPI is a subsection under it, spelled
    uv tool install / pipx install — an application in an environment of its own, which
    is the shape of thing this is. It is still the right answer for the two platforms with
    no build, an Intel Mac and Linux on ARM, and that is what the section says it is for.

Fixed

  • Every lead now goes down its hole and out the other side. A part in the 3D view
    hovered over the holes it is meant to be soldered into: an axial lead ran horizontally
    to the pin position and stopped there in mid-air, a radial capacitor had no legs at all,
    and a DIP had no pins — it was a black block resting on the board. The lead turns down
    at the pin, disappears into the hole (the bore is opaque, as a board is) and reappears
    trimmed on the solder side, which is the only evidence that face has that anything came
    through it. Every archetype, because the omission was per-builder, and a DIP's pins run
    down the OUTSIDE of its two long sides the way the real package's do.

    • One instanced actor per component, not one per pin: a 2×20 header has forty, and the
      pad grid is instanced for exactly this reason.
    • _upright_cylinder hands the glyph its polydata rather than a pipeline connection.
      A connection holds a RAW pointer back to the source that produced it, and the source
      there is a local — connecting one segfaults the interpreter outright, which is how
      this was found rather than reasoned about.
  • The holes read as holes rather than as buttons. The dark bore stood 0.10 mm PROUD of
    the copper at both faces, so at any grazing angle — which is most of them, since this
    view is orbited — every hole showed a black cap above its own pad, occluding the pads on
    the rows behind it. A board of 700 holes read as a grid of black buttons. The bore now
    stops just short of the copper on both faces (BORE_UNDER_PAD_MM), so the ring is the
    topmost thing at every hole. Mounting bores use the same span, because a screw hole
    drawn to a different depth from the grid around it reads as a different kind of thing.

    • pad_z and the new bore_span_z are pure and asserted directly, as the pad plane
      already was: what a photograph shows and no test could see is exactly what needs one.
  • pnpm build works from a clean clone. The root tsconfig.json that tsc -b looks
    for was never committed, so the command failed with TS6053: File 'tsconfig.json' not found — and pnpm build && node tools/diffcheck/generate.mjs is the documented way to
    regenerate the golden fixtures this Python engine is proved byte-for-byte against. The
    solution file compiles nothing itself; it names the four packages, in whatever order
    their own references imply. tools/bench-3d is deliberately not among them: it is
    composite: false and noEmit, which a solution build cannot reference. Verified by
    regenerating every fixture and finding the tree unchanged.

  • The MCP recipe in both READMEs was two unrelated halves. pip install -e ".[mcp]"
    installs a clone in editable mode; uvx --from "perfboard-studio[mcp]" fetches from PyPI and
    ignores it entirely. Printed as consecutive lines of one block they read as a two-step
    setup where either alone is the whole thing — which is what docs/MCP.md says, and now
    what the READMEs say too.

  • Three stale numbers. Both READMEs announced v0.9.0 as the current release with
    v0.10.0 on PyPI and the releases page, and counted "~2070 tests" against 2084.
    PLAN.md's front matter still read "planlama tamamlandı, geliştirme başlamadı" and
    called the name temporary, and its §11 put the MCP tool count at 50 where §13 and
    docs/MCP.md say 51 — test_mcp.py reads §13's capitalised spelling, so §11's escaped
    the check written for exactly this.

  • A net name is no longer drawn on the wire it names. Label.at for a net was the
    trunk's y and the trunk's left end, so the baseline WAS the wire: the line ran through
    every descender and the branch dropping into that end ran up through the first letter.
    Measured across every board in the repository, 41 of 41 net names were drawn on a
    wire
    — VIN, VOUT and ADJ on the LM317 sheet, OUT, THRESH, DISCH and CTRL on the NE555
    one. References, values and pin numbers were already clear; this was the whole defect
    and it was in every sheet the tool has ever produced.

    • The name now sits in a band above the run, and it is the BAND that carries the
      guarantee rather than any search: NET_LABEL_CLEARANCE_MM + NET_LABEL_MM is shorter
      than TRACK_PITCH_MM, so it cannot reach the lane above, and its own trunk is below
      it. No horizontal run can be in it — which is the reading that matters, since text and
      a horizontal wire lie ALONG each other rather than crossing, and a line drawn through
      a word is the one overlap that makes it unreadable. Same shape of rule as
      RAIL_GLYPH_MM's, and cheap for the same reason.
    • What is left is vertical branches, and _net_label_at searches for those: it starts
      at the left-hand end of the run, where a reader looks for the name, and only slides
      RIGHT ALONG THE SAME RUN in half grid squares when the band there is occupied — never
      to another wire, never off the run. Twenty-nine of the 41 do not move at all. Three
      end up crossed by another net, and two by a branch of the net they name, which is not
      ambiguous about anything and is left alone.
    • The size is one fact with two consumers. The layout has to know how much room a
      name takes in order to keep a wire out of it, and only a renderer knows the real size,
      so SheetInk.net_mm reads schematic.NET_LABEL_MM rather than naming its own — a
      sheet that drew the name at some other size would be filling a gap that was measured
      for a different piece of text. The panel draws text at a fixed PIXEL size on purpose
      and treats this as the nominal it was always laid out against.
    • The two schematic goldens were re-blessed after reading the diff, and the diff is the
      argument: every net name moved up 0.89 mm and right 0.76 mm, two of them further along
      their own trunk, and not one symbol, wire, rail or junction moved at all.
  • A bless run on Windows no longer writes CRLF into the goldens. test_guide_golden
    passed newline="\n" and the other three golden writers did not, so re-blessing a
    schematic, an exported sheet or a render signature from this platform rewrote the line
    endings of a file every other platform leaves alone. .gitattributes normalises it away
    at commit, which is exactly why it went unnoticed.

  • A BFS layer is a hint about distance, and the sheet was treating it as a constraint.
    Everything one hop from the root went into one column. On lm317-supply ten of the
    eleven parts hang directly off U1, so the sheet came out 129 x 239 mm — nearly twice
    as tall as it was wide, on a circuit that fits across a page with room to spare. Nothing
    was being respected by that arrangement: a schematic has no precedence order, so a part
    moved one column further out only makes its own wire span one more channel.

    • _split_tall_layers caps a layer at about the side of a square —
      max(3, ceil(sqrt(n))), the same arithmetic the loose-parts block already used — and
      chunks the overflow into columns of its own, consecutively in the reference order the
      layer already carries, so R1 stays beside R2.
    • It fires only where a layer was genuinely over-full. The fourteen random fixtures are
      untouched
      ; the five real circuits went from a worst aspect of 1.84 to 0.92. LM317 is
      now 185 x 132 mm and the NE555 went from 4 columns by 7 rows to 5 by 4, which reads
      left to right the way a schematic should.
    • The goldens were re-blessed after checking that every element count is identical —
      same symbols, wires, rails, junctions and labels, only positions moved.
    • test_the_pdf_is_a_pdf_and_takes_its_orientation_from_the_sheet built its tall case
      from lm317-supply and its guard fired, correctly, which is why the guard was there.
      It builds both sheets from SchematicDrawing(width=..., height=...) now: what is under
      test is svg_to_pdf reading a shape and picking a page, so the shape is the input and
      no future layout change can take the tall case away again.
  • A pin no net reaches is marked, so "not connected" reads differently from "not drawn".
    An unwired pin was a plain lead stopping in mid-air — which is also exactly what a pin
    whose wire the sheet failed to draw would look like, and a reader could not tell them
    apart. Ten of the eleven parts on arduino-io-shield have one; the NE555's is U1 pin 4.

    • A statement of fact, not of intent, and that is the difference from KiCad's marker
      of the same shape. There somebody places one to say "I meant to leave this open"; here
      nothing is placed by hand, so what this can honestly say is that the netlist does not
      mention the pin.
    • Not a defect and no note. Unused pins on a header are the ordinary case, and a
      sheet that added ten notes for one connector would bury the note that matters. LVS is
      where a connection that was supposed to exist gets reported. Drawn in the dim ink for
      the same reason.
    • schematic.no_connect_arms owns the two strokes, the same shape as rail_glyph_bars:
      the panel and the exported sheet ask rather than each deciding. NO_CONNECT_MM is
      small enough that the cross cannot reach the body or the pin next door, which is
      asserted rather than eyeballed.
    • The golden text dump gained a no-connect line. A thing that is generated and not
      dumped is a thing that can quietly stop being generated, which is what the goldens are
      for; the NE555 diff was exactly one line.

Windows - PerfboardStudio_..._Setup.exe. It is unsigned, so SmartScreen will
warn on first run: More info -> Run anyway. A signed build is planned.

Linux - perfboard-studio-...-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 - perfboard-studio-...-arm64.dmg, Apple silicon. Drag it to Applications,
then run this before opening it:

xattr -dr com.apple.quarantine /Applications/PerfboardStudio.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 -U perfboard-studio, then perfboard-studio.
Same release, no installer and no SmartScreen warning; Qt and VTK are most of a
400 MB download the first time.

Checking your download - SHA256SUMS carries the SHA-256 of all three
files. sha256sum -c SHA256SUMS on Linux, shasum -a 256 -c SHA256SUMS on
macOS, Get-FileHash <file> in PowerShell. Perfboard Studio does this for you when it
fetches an update itself: Help - Check for Updates. It proves the file
arrived intact, not who built it - that second claim needs the code signing
this project has not bought yet.

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.