From 28a712a4ff12f3ec242aaefc8c65cf2358a2e603 Mon Sep 17 00:00:00 2001 From: Geoff McIntyre Date: Sun, 13 Sep 2026 10:29:56 -0600 Subject: [PATCH 01/48] v0.4 updates --- README.md | 17 +- demo/Blink Example/diagram.json | 52 +- demo/Fade Example/diagram.json | 2 +- docs/github-auth.md | 83 ++ electron.vite.config.ts | 11 + examples-tagged.json | 1064 +++++++++++++++++ examples.json | 27 +- scripts/_to_delete/gen-tinyboards-v2.py | 830 +++++++++++++ scripts/gen-example-tags.mjs | 520 ++++++++ scripts/gen-tinyboards.py | 830 +++++++++++++ scripts/render-circuit-preview.mjs | 166 +++ scripts/test-circuit.mjs | 55 +- src/main/githubAuth.ts | 236 ++++ src/main/index.ts | 24 + src/preload/index.d.ts | 30 + src/preload/index.ts | 32 + src/renderer/index.html | 2 +- src/renderer/src/assets/base.css | 76 ++ src/renderer/src/assets/ds-components.css | 143 ++- .../src/assets/tinyboards/tinycore.svg | 1 + .../src/assets/tinyboards/tinydisplay.svg | 1 + .../src/assets/tinyboards/tinyglow.svg | 1 + .../src/assets/tinyboards/tinyproto.svg | 1 + .../src/assets/tinyboards/tinysniff.svg | 1 + .../src/assets/tinyboards/tinyspeak.svg | 1 + .../__tests__/_to_delete/demo-rectifier.json | 25 + .../src/circuit/__tests__/_to_delete/demo.svg | 1 + .../__tests__/_to_delete/preview-bb.svg | 1 + .../__tests__/_to_delete/preview-sch.svg | 1 + .../circuit/__tests__/_to_delete/spot.json | 15 + .../src/circuit/__tests__/_to_delete/spot.svg | 1 + .../__tests__/_to_delete/zz-dump.test.ts | 5 + .../src/circuit/__tests__/breadboard.test.ts | 10 +- .../src/circuit/__tests__/clipboard.test.ts | 16 +- .../src/circuit/__tests__/exportSch.test.ts | 5 +- .../src/circuit/__tests__/fzpz.test.ts | 50 +- .../src/circuit/__tests__/labels.test.ts | 72 ++ src/renderer/src/circuit/__tests__/m1.test.ts | 4 +- .../src/circuit/__tests__/model.test.ts | 14 +- .../src/circuit/__tests__/naming.test.ts | 107 ++ .../src/circuit/__tests__/netlist.test.ts | 5 +- .../src/circuit/__tests__/packs.test.ts | 11 +- .../src/circuit/__tests__/placement.test.ts | 60 + .../src/circuit/__tests__/renumber.test.ts | 88 ++ .../src/circuit/__tests__/sim.test.ts | 5 +- .../src/circuit/__tests__/simOutputs.test.ts | 237 ++++ .../src/circuit/__tests__/svg.test.ts | 13 +- .../circuit/__tests__/symbolLibrary.test.ts | 175 +++ .../src/circuit/__tests__/symbols.test.ts | 5 +- .../src/circuit/__tests__/tinyboards.test.ts | 213 ++++ src/renderer/src/circuit/core/clipboard.ts | 10 +- src/renderer/src/circuit/core/commands.ts | 95 ++ src/renderer/src/circuit/core/model.ts | 61 +- src/renderer/src/circuit/core/netlist.ts | 127 +- src/renderer/src/circuit/core/nets.ts | 7 +- src/renderer/src/circuit/core/probes.ts | 14 +- src/renderer/src/circuit/core/refdes.ts | 53 +- src/renderer/src/circuit/core/routing.ts | 17 +- src/renderer/src/circuit/core/simOutputs.ts | 276 +++++ src/renderer/src/circuit/core/store.ts | 10 +- src/renderer/src/circuit/parts/fzpz.ts | 17 +- src/renderer/src/circuit/parts/labels.ts | 160 +++ src/renderer/src/circuit/parts/naming.ts | 551 +++++++++ src/renderer/src/circuit/parts/netLabels.ts | 15 +- .../src/circuit/parts/normalizeSymbol.ts | 143 +++ src/renderer/src/circuit/parts/simParts.ts | 25 +- src/renderer/src/circuit/parts/simProbes.ts | 48 +- src/renderer/src/circuit/parts/style.ts | 142 +++ .../src/circuit/parts/symbolLibrary.ts | 646 ++++++++++ src/renderer/src/circuit/parts/symbols.ts | 158 ++- src/renderer/src/circuit/sim/backend.ts | 30 +- src/renderer/src/circuit/sim/engine.ts | 223 +++- src/renderer/src/circuit/sim/index.ts | 2 + src/renderer/src/circuit/sim/simWorker.ts | 117 +- .../src/circuit/views/CircuitView.tsx | 644 +++++++++- .../src/circuit/views/canvas/Canvas.tsx | 609 +++++++--- .../src/circuit/views/captureAnimation.ts | 167 +++ src/renderer/src/circuit/views/exportImage.ts | 192 ++- .../src/circuit/views/inspector/Inspector.tsx | 71 +- .../src/circuit/views/packs/PackManager.tsx | 37 +- .../src/circuit/views/palette/Palette.tsx | 9 +- .../src/circuit/views/partsAdapter.ts | 39 +- src/renderer/src/circuit/views/sim/Plot.tsx | 135 ++- .../src/circuit/views/sim/SimPanel.tsx | 413 +++++-- src/renderer/src/commands/fileCommands.ts | 171 ++- src/renderer/src/components/EditorPanel.tsx | 43 +- src/renderer/src/components/ExampleTag.tsx | 59 + .../src/components/ExamplesContent.tsx | 292 ++++- .../components/FileExplorer/SourceControl.tsx | 211 ++-- .../src/components/GitHubAccountButton.tsx | 86 +- src/renderer/src/components/GitHubSignIn.tsx | 221 ++++ src/renderer/src/components/MakeItMine.tsx | 209 ++++ src/renderer/src/components/Toolbar.tsx | 6 +- src/renderer/src/hooks/useGitHubAccount.ts | 98 +- .../src/hooks/useIsReadOnlyProject.ts | 15 + .../src/lib/__tests__/exampleTags.test.ts | 177 +++ src/renderer/src/lib/__tests__/github.test.ts | 170 +++ .../src/lib/__tests__/githubCopy.test.ts | 192 +++ .../lib/__tests__/virtualFileSystem.test.ts | 65 + src/renderer/src/lib/exampleTags.ts | 358 ++++++ src/renderer/src/lib/examples.ts | 159 ++- src/renderer/src/lib/github.ts | 846 +++++++++++-- src/renderer/src/lib/partsLibrary.ts | 470 ++++++-- src/renderer/src/lib/virtualFileSystem.ts | 40 + src/renderer/src/main.tsx | 43 +- src/renderer/src/redux/fileSlice.ts | 49 + .../adc-reading-an-analog-value/README.md | 27 + .../adc-reading-an-analog-value.ino | 28 + .../advanced/dac-mcp4725/README.md | 33 + .../advanced/dac-mcp4725/dac-mcp4725.ino | 31 + .../pwm-example-fade-an-led/README.md | 25 + .../pwm-example-fade-an-led.ino | 33 + .../pwm-example-play-a-tone-on/README.md | 29 + .../pwm-example-play-a-tone-on.ino | 27 + .../wifi-basic-connection-code/README.md | 29 + .../wifi-basic-connection-code.ino | 37 + .../basics/basic-button-reading/README.md | 31 + .../basic-button-reading.ino | 38 + .../basics/basic-wifi-connection/README.md | 25 + .../basic-wifi-connection.ino | 44 + .../basics/blink-alternate/README.md | 35 + .../blink-alternate/blink-alternate.ino | 37 + .../basics/blink-basic/README.md | 33 + .../basics/blink-basic/blink-basic.ino | 37 + .../basics/blink-breathing/README.md | 31 + .../blink-breathing/blink-breathing.ino | 41 + .../basics/blink-brightness/README.md | 31 + .../blink-brightness/blink-brightness.ino | 47 + .../basics/blink-external-led/README.md | 31 + .../blink-external-led/blink-external-led.ino | 27 + .../basics/blink-pwm-wave/README.md | 31 + .../basics/blink-pwm-wave/blink-pwm-wave.ino | 33 + .../basics/blink-sos/README.md | 27 + .../basics/blink-sos/blink-sos.ino | 54 + .../README.md | 25 + .../bluetooth-basic-ble-server-example.ino | 83 ++ .../README.md | 27 + .../bluetooth-rgb-mood-light-controller.ino | 38 + .../README.md | 29 + ...ss-multiple-buttons-multiple-behaviors.ino | 61 + .../README.md | 29 + ...button-press-toggle-behavior-vs-direct.ino | 48 + .../README.md | 29 + .../button-press-understanding-not-logic.ino | 34 + .../buzz-buzzer-basic-beeping-code/README.md | 33 + .../buzz-buzzer-basic-beeping-code.ino | 38 + .../buzz-buzzer-interactive-buzzer/README.md | 31 + .../buzz-buzzer-interactive-buzzer.ino | 98 ++ .../buzz-buzzer-play-a-real-song/README.md | 29 + .../buzz-buzzer-play-a-real-song.ino | 75 ++ .../README.md | 29 + .../buzz-buzzer-playing-different-tones.ino | 59 + .../basics/detecting-button-events/README.md | 29 + .../detecting-button-events.ino | 46 + .../basics/esp-now-on-the-tinycore/README.md | 31 + .../esp-now-on-the-tinycore.ino | 50 + .../basics/i2c-display-hello-world/README.md | 35 + .../i2c-display-hello-world.ino | 54 + .../README.md | 39 + ...2c-display-interactive-physics-the-imu.ino | 109 ++ .../basics/imu-motion-plotter/README.md | 35 + .../imu-motion-plotter/imu-motion-plotter.ino | 115 ++ .../basics/imu-serial-plotter/README.md | 35 + .../imu-serial-plotter/imu-serial-plotter.ino | 115 ++ .../basics/imu-using-it-in-code/README.md | 29 + .../imu-using-it-in-code.ino | 42 + .../basics/motion-tracker/README.md | 27 + .../basics/motion-tracker/motion-tracker.ino | 198 +++ .../read-sensor-value-light-sensor/README.md | 37 + .../read-sensor-value-light-sensor.ino | 43 + .../README.md | 29 + .../read-sensor-value-multiple-sensors-at.ino | 39 + .../README.md | 37 + ...d-sensor-value-reading-a-potentiometer.ino | 42 + .../README.md | 29 + .../read-sensor-value-smart-led-control.ino | 44 + .../README.md | 31 + ...d-sensor-value-visualizing-with-serial.ino | 36 + .../README.md | 29 + .../reading-input-from-serial-monitor.ino | 82 ++ .../README.md | 25 + .../save-data-to-sdcard-appending-to.ino | 64 + .../save-data-to-sdcard-csv-data/README.md | 25 + .../save-data-to-sdcard-csv-data.ino | 94 ++ .../save-data-to-sdcard-delete-and/README.md | 25 + .../save-data-to-sdcard-delete-and.ino | 101 ++ .../README.md | 27 + .../save-data-to-sdcard-initializing-the.ino | 63 + .../README.md | 25 + .../save-data-to-sdcard-reading-from.ino | 51 + .../save-data-to-sdcard-writing-to/README.md | 25 + .../save-data-to-sdcard-writing-to.ino | 48 + .../sd-card-basic-code-write-and/README.md | 27 + .../sd-card-basic-code-write-and.ino | 45 + .../README.md | 29 + ...l-monitor-plotter-basic-output-example.ino | 52 + .../README.md | 27 + ...itor-plotter-debugging-with-timestamps.ino | 69 ++ .../README.md | 25 + ...monitor-plotter-memory-and-performance.ino | 67 ++ .../README.md | 35 + ...tor-plotter-multiple-variable-plotting.ino | 45 + .../README.md | 29 + ...erial-monitor-plotter-real-sensor-data.ino | 46 + .../README.md | 27 + ...nitor-plotter-single-variable-plotting.ino | 31 + .../README.md | 29 + .../the-i2c-scanner-your-best-debugging.ino | 31 + .../basics/wifi-basic-web-server/README.md | 25 + .../wifi-basic-web-server.ino | 83 ++ .../basics/wifi-mqtt-client/README.md | 25 + .../wifi-mqtt-client/wifi-mqtt-client.ino | 71 ++ .../basics/wifi-ota-updates/README.md | 25 + .../wifi-ota-updates/wifi-ota-updates.ino | 29 + .../basics/wifi-websocket-server/README.md | 25 + .../wifi-websocket-server.ino | 51 + 216 files changed, 18177 insertions(+), 1126 deletions(-) create mode 100644 docs/github-auth.md create mode 100644 examples-tagged.json create mode 100644 scripts/_to_delete/gen-tinyboards-v2.py create mode 100644 scripts/gen-example-tags.mjs create mode 100644 scripts/gen-tinyboards.py create mode 100644 scripts/render-circuit-preview.mjs create mode 100644 src/main/githubAuth.ts create mode 100644 src/renderer/src/assets/tinyboards/tinycore.svg create mode 100644 src/renderer/src/assets/tinyboards/tinydisplay.svg create mode 100644 src/renderer/src/assets/tinyboards/tinyglow.svg create mode 100644 src/renderer/src/assets/tinyboards/tinyproto.svg create mode 100644 src/renderer/src/assets/tinyboards/tinysniff.svg create mode 100644 src/renderer/src/assets/tinyboards/tinyspeak.svg create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/demo-rectifier.json create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/demo.svg create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/preview-bb.svg create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/preview-sch.svg create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/spot.json create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/spot.svg create mode 100644 src/renderer/src/circuit/__tests__/_to_delete/zz-dump.test.ts create mode 100644 src/renderer/src/circuit/__tests__/labels.test.ts create mode 100644 src/renderer/src/circuit/__tests__/naming.test.ts create mode 100644 src/renderer/src/circuit/__tests__/placement.test.ts create mode 100644 src/renderer/src/circuit/__tests__/renumber.test.ts create mode 100644 src/renderer/src/circuit/__tests__/simOutputs.test.ts create mode 100644 src/renderer/src/circuit/__tests__/symbolLibrary.test.ts create mode 100644 src/renderer/src/circuit/__tests__/tinyboards.test.ts create mode 100644 src/renderer/src/circuit/core/simOutputs.ts create mode 100644 src/renderer/src/circuit/parts/labels.ts create mode 100644 src/renderer/src/circuit/parts/naming.ts create mode 100644 src/renderer/src/circuit/parts/normalizeSymbol.ts create mode 100644 src/renderer/src/circuit/parts/style.ts create mode 100644 src/renderer/src/circuit/parts/symbolLibrary.ts create mode 100644 src/renderer/src/circuit/views/captureAnimation.ts create mode 100644 src/renderer/src/components/ExampleTag.tsx create mode 100644 src/renderer/src/components/GitHubSignIn.tsx create mode 100644 src/renderer/src/components/MakeItMine.tsx create mode 100644 src/renderer/src/hooks/useIsReadOnlyProject.ts create mode 100644 src/renderer/src/lib/__tests__/exampleTags.test.ts create mode 100644 src/renderer/src/lib/__tests__/github.test.ts create mode 100644 src/renderer/src/lib/__tests__/githubCopy.test.ts create mode 100644 src/renderer/src/lib/__tests__/virtualFileSystem.test.ts create mode 100644 src/renderer/src/lib/exampleTags.ts create mode 100644 tinyStudio-examples-old/advanced/adc-reading-an-analog-value/README.md create mode 100644 tinyStudio-examples-old/advanced/adc-reading-an-analog-value/adc-reading-an-analog-value.ino create mode 100644 tinyStudio-examples-old/advanced/dac-mcp4725/README.md create mode 100644 tinyStudio-examples-old/advanced/dac-mcp4725/dac-mcp4725.ino create mode 100644 tinyStudio-examples-old/advanced/pwm-example-fade-an-led/README.md create mode 100644 tinyStudio-examples-old/advanced/pwm-example-fade-an-led/pwm-example-fade-an-led.ino create mode 100644 tinyStudio-examples-old/advanced/pwm-example-play-a-tone-on/README.md create mode 100644 tinyStudio-examples-old/advanced/pwm-example-play-a-tone-on/pwm-example-play-a-tone-on.ino create mode 100644 tinyStudio-examples-old/advanced/wifi-basic-connection-code/README.md create mode 100644 tinyStudio-examples-old/advanced/wifi-basic-connection-code/wifi-basic-connection-code.ino create mode 100644 tinyStudio-examples-old/basics/basic-button-reading/README.md create mode 100644 tinyStudio-examples-old/basics/basic-button-reading/basic-button-reading.ino create mode 100644 tinyStudio-examples-old/basics/basic-wifi-connection/README.md create mode 100644 tinyStudio-examples-old/basics/basic-wifi-connection/basic-wifi-connection.ino create mode 100644 tinyStudio-examples-old/basics/blink-alternate/README.md create mode 100644 tinyStudio-examples-old/basics/blink-alternate/blink-alternate.ino create mode 100644 tinyStudio-examples-old/basics/blink-basic/README.md create mode 100644 tinyStudio-examples-old/basics/blink-basic/blink-basic.ino create mode 100644 tinyStudio-examples-old/basics/blink-breathing/README.md create mode 100644 tinyStudio-examples-old/basics/blink-breathing/blink-breathing.ino create mode 100644 tinyStudio-examples-old/basics/blink-brightness/README.md create mode 100644 tinyStudio-examples-old/basics/blink-brightness/blink-brightness.ino create mode 100644 tinyStudio-examples-old/basics/blink-external-led/README.md create mode 100644 tinyStudio-examples-old/basics/blink-external-led/blink-external-led.ino create mode 100644 tinyStudio-examples-old/basics/blink-pwm-wave/README.md create mode 100644 tinyStudio-examples-old/basics/blink-pwm-wave/blink-pwm-wave.ino create mode 100644 tinyStudio-examples-old/basics/blink-sos/README.md create mode 100644 tinyStudio-examples-old/basics/blink-sos/blink-sos.ino create mode 100644 tinyStudio-examples-old/basics/bluetooth-basic-ble-server-example/README.md create mode 100644 tinyStudio-examples-old/basics/bluetooth-basic-ble-server-example/bluetooth-basic-ble-server-example.ino create mode 100644 tinyStudio-examples-old/basics/bluetooth-rgb-mood-light-controller/README.md create mode 100644 tinyStudio-examples-old/basics/bluetooth-rgb-mood-light-controller/bluetooth-rgb-mood-light-controller.ino create mode 100644 tinyStudio-examples-old/basics/button-press-multiple-buttons-multiple-behaviors/README.md create mode 100644 tinyStudio-examples-old/basics/button-press-multiple-buttons-multiple-behaviors/button-press-multiple-buttons-multiple-behaviors.ino create mode 100644 tinyStudio-examples-old/basics/button-press-toggle-behavior-vs-direct/README.md create mode 100644 tinyStudio-examples-old/basics/button-press-toggle-behavior-vs-direct/button-press-toggle-behavior-vs-direct.ino create mode 100644 tinyStudio-examples-old/basics/button-press-understanding-not-logic/README.md create mode 100644 tinyStudio-examples-old/basics/button-press-understanding-not-logic/button-press-understanding-not-logic.ino create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-basic-beeping-code/README.md create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-basic-beeping-code/buzz-buzzer-basic-beeping-code.ino create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-interactive-buzzer/README.md create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-interactive-buzzer/buzz-buzzer-interactive-buzzer.ino create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-play-a-real-song/README.md create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-play-a-real-song/buzz-buzzer-play-a-real-song.ino create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-playing-different-tones/README.md create mode 100644 tinyStudio-examples-old/basics/buzz-buzzer-playing-different-tones/buzz-buzzer-playing-different-tones.ino create mode 100644 tinyStudio-examples-old/basics/detecting-button-events/README.md create mode 100644 tinyStudio-examples-old/basics/detecting-button-events/detecting-button-events.ino create mode 100644 tinyStudio-examples-old/basics/esp-now-on-the-tinycore/README.md create mode 100644 tinyStudio-examples-old/basics/esp-now-on-the-tinycore/esp-now-on-the-tinycore.ino create mode 100644 tinyStudio-examples-old/basics/i2c-display-hello-world/README.md create mode 100644 tinyStudio-examples-old/basics/i2c-display-hello-world/i2c-display-hello-world.ino create mode 100644 tinyStudio-examples-old/basics/i2c-display-interactive-physics-the-imu/README.md create mode 100644 tinyStudio-examples-old/basics/i2c-display-interactive-physics-the-imu/i2c-display-interactive-physics-the-imu.ino create mode 100644 tinyStudio-examples-old/basics/imu-motion-plotter/README.md create mode 100644 tinyStudio-examples-old/basics/imu-motion-plotter/imu-motion-plotter.ino create mode 100644 tinyStudio-examples-old/basics/imu-serial-plotter/README.md create mode 100644 tinyStudio-examples-old/basics/imu-serial-plotter/imu-serial-plotter.ino create mode 100644 tinyStudio-examples-old/basics/imu-using-it-in-code/README.md create mode 100644 tinyStudio-examples-old/basics/imu-using-it-in-code/imu-using-it-in-code.ino create mode 100644 tinyStudio-examples-old/basics/motion-tracker/README.md create mode 100644 tinyStudio-examples-old/basics/motion-tracker/motion-tracker.ino create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-light-sensor/README.md create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-light-sensor/read-sensor-value-light-sensor.ino create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-multiple-sensors-at/README.md create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-multiple-sensors-at/read-sensor-value-multiple-sensors-at.ino create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-reading-a-potentiometer/README.md create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-reading-a-potentiometer/read-sensor-value-reading-a-potentiometer.ino create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-smart-led-control/README.md create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-smart-led-control/read-sensor-value-smart-led-control.ino create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-visualizing-with-serial/README.md create mode 100644 tinyStudio-examples-old/basics/read-sensor-value-visualizing-with-serial/read-sensor-value-visualizing-with-serial.ino create mode 100644 tinyStudio-examples-old/basics/reading-input-from-serial-monitor/README.md create mode 100644 tinyStudio-examples-old/basics/reading-input-from-serial-monitor/reading-input-from-serial-monitor.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-appending-to/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-appending-to/save-data-to-sdcard-appending-to.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-csv-data/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-csv-data/save-data-to-sdcard-csv-data.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-delete-and/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-delete-and/save-data-to-sdcard-delete-and.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-initializing-the/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-initializing-the/save-data-to-sdcard-initializing-the.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-reading-from/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-reading-from/save-data-to-sdcard-reading-from.ino create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-writing-to/README.md create mode 100644 tinyStudio-examples-old/basics/save-data-to-sdcard-writing-to/save-data-to-sdcard-writing-to.ino create mode 100644 tinyStudio-examples-old/basics/sd-card-basic-code-write-and/README.md create mode 100644 tinyStudio-examples-old/basics/sd-card-basic-code-write-and/sd-card-basic-code-write-and.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-basic-output-example/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-basic-output-example/serial-monitor-plotter-basic-output-example.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-debugging-with-timestamps/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-debugging-with-timestamps/serial-monitor-plotter-debugging-with-timestamps.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-memory-and-performance/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-memory-and-performance/serial-monitor-plotter-memory-and-performance.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-multiple-variable-plotting/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-multiple-variable-plotting/serial-monitor-plotter-multiple-variable-plotting.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-real-sensor-data/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-real-sensor-data/serial-monitor-plotter-real-sensor-data.ino create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-single-variable-plotting/README.md create mode 100644 tinyStudio-examples-old/basics/serial-monitor-plotter-single-variable-plotting/serial-monitor-plotter-single-variable-plotting.ino create mode 100644 tinyStudio-examples-old/basics/the-i2c-scanner-your-best-debugging/README.md create mode 100644 tinyStudio-examples-old/basics/the-i2c-scanner-your-best-debugging/the-i2c-scanner-your-best-debugging.ino create mode 100644 tinyStudio-examples-old/basics/wifi-basic-web-server/README.md create mode 100644 tinyStudio-examples-old/basics/wifi-basic-web-server/wifi-basic-web-server.ino create mode 100644 tinyStudio-examples-old/basics/wifi-mqtt-client/README.md create mode 100644 tinyStudio-examples-old/basics/wifi-mqtt-client/wifi-mqtt-client.ino create mode 100644 tinyStudio-examples-old/basics/wifi-ota-updates/README.md create mode 100644 tinyStudio-examples-old/basics/wifi-ota-updates/wifi-ota-updates.ino create mode 100644 tinyStudio-examples-old/basics/wifi-websocket-server/README.md create mode 100644 tinyStudio-examples-old/basics/wifi-websocket-server/wifi-websocket-server.ino diff --git a/README.md b/README.md index fcff6f6..c2dc22d 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ > ### ⚠️ WARNING - This is a ROUGH Alpha. Please read this first > -> Because ya'll are impatient, I have decided to release tinyStudio in **Alpha** (Although that's giving it a lot.). Right now it's a ***demonstration of the concept***, not a -> finished product. It's buggy, incomplete, and quite rough around the edges. +> Because ya'll are impatient, I have decided to release tinyStudio in **Alpha** (Although that's giving it a lot.). Right now it's a **_demonstration of the concept_**, not a +> finished product. It's buggy, incomplete, and quite rough around the edges. > Almost everything you see here is **subject to change**. Some of it will probably (definitely) be ripped out and redone. > ->You may notice there's a fair amount of agentic code in this repo. +> You may notice there's a fair amount of agentic code in this repo. > > Treat it as a preview, not a tool you'd rely on yet. If you want to follow along or experiment, > hell yeah Batman. Just go in expecting stuff to break. See [Known bugs](#known-bugs) and @@ -60,6 +60,7 @@ Keep the alpha warning above in mind, all of these things work, but they're... e | `tinyProto` | Prototyping / Breakout board | | `tinySpeak` | Microphone and Speaker AI module | | `tinySniff` | MEMS Gas Sensor Array | +| `tinyDisplay` | Round LCD module | These ship as built-in parts in the Circuit view (see [`partsLibrary.ts`](src/renderer/src/lib/partsLibrary.ts)). @@ -112,10 +113,10 @@ Windows packaging notes live in [docs/packaging-windows.md](docs/packaging-windo The [`demo/`](demo) folder holds ready-to-open projects. Clone the repo, and open one the folders in the editor, then pick your board and port and hit **Verify** / **Upload**. -| Project | What it shows | -| -------------------------------------- | -------------------------------------------------------- | -| [Blink Example](demo/Blink%20Example) | Blink an LED and mirror its state in the Visual view | -| [Fade Example](demo/Fade%20Example) | PWM-fade an LED and chart the brightness curve live | +| Project | What it shows | +| ------------------------------------------- | ------------------------------------------------------ | +| [Blink Example](demo/Blink%20Example) | Blink an LED and mirror its state in the Visual view | +| [Fade Example](demo/Fade%20Example) | PWM-fade an LED and chart the brightness curve live | | [Joystick Example](demo/Joystick%20Example) | Read a Qwiic joystick and visualize the stick position | Each project is a folder with the same structure: @@ -171,7 +172,7 @@ This is an alpha, so the list is short only because we haven't written everythin Known issues right now: - **Chat sessions don't persist between tabbing.** Switch away from the AI assistant and back, -conversation is gone. + conversation is gone. ## Roadmap diff --git a/demo/Blink Example/diagram.json b/demo/Blink Example/diagram.json index d7daacf..d67dc84 100644 --- a/demo/Blink Example/diagram.json +++ b/demo/Blink Example/diagram.json @@ -30,50 +30,10 @@ } ], "connections": [ - [ - "tinycore:SIG", - "resistor:Pin 0", - "#36c46b" - ], - [ - "resistor:Pin 1", - "led:anode", - "#36c46b", - [ - "v-22.73", - "h-179.8", - "v-32.83" - ] - ], - [ - "led:cathode", - "tinycore:GND", - "#8b94c8", - [ - "h-265.24", - "v157.63", - "h52.4" - ] - ], - [ - "battery-aa_y90:-", - "tinycore:D3", - "#9b6cff", - [ - "h-45.29", - "v-80.66", - "h-92" - ] - ], - [ - "tinycore:D4", - "battery-aa_y90:+", - "#ff4d6d", - [ - "h72.8", - "v78.91", - "h64.49" - ] - ] + ["tinycore:D13", "resistor:Pin 0", "#36c46b"], + ["resistor:Pin 1", "led:anode", "#36c46b", ["v-22.73", "h-179.8", "v-32.83"]], + ["led:cathode", "tinycore:GND", "#8b94c8", ["h-265.24", "v157.63", "h52.4"]], + ["battery-aa_y90:-", "tinycore:D8", "#9b6cff", ["h-45.29", "v-80.66", "h-92"]], + ["tinycore:D9", "battery-aa_y90:+", "#ff4d6d", ["h72.8", "v78.91", "h64.49"]] ] -} \ No newline at end of file +} diff --git a/demo/Fade Example/diagram.json b/demo/Fade Example/diagram.json index 630a241..4876b5d 100644 --- a/demo/Fade Example/diagram.json +++ b/demo/Fade Example/diagram.json @@ -23,7 +23,7 @@ } ], "connections": [ - ["tinycore:SIG", "resistor:Pin 0", "#36c46b"], + ["tinycore:D13", "resistor:Pin 0", "#36c46b"], ["resistor:Pin 1", "led:anode", "#36c46b"], ["led:cathode", "tinycore:GND", "#8b94c8"] ] diff --git a/docs/github-auth.md b/docs/github-auth.md new file mode 100644 index 0000000..d9bee77 --- /dev/null +++ b/docs/github-auth.md @@ -0,0 +1,83 @@ +# GitHub sign-in + +tinyStudio signs users in with the GitHub **OAuth device flow** — the user gets +a short code, types it on github.com, and the app polls until GitHub says yes. +No token to paste, and nothing secret ships inside the app. + +## Why an OAuth App and not a GitHub App + +A GitHub App gives nicer per-repository permissions, and that was the first +instinct. It does not work here: **a GitHub App cannot create a repository in a +user's personal account**, which is exactly what "Make it mine" does. Repository +creation needs an OAuth scope, so an OAuth App it is. + +To keep the ask as small as possible we request `public_repo`, not `repo`: + +| scope | what it allows | +| --- | --- | +| `public_repo` | read/write **public** repos, and create new ones. Cannot see private repos at all. | +| `repo` | everything above **plus full access to every private repo** the user can reach. | + +`public_repo` is strictly less access than the full-`repo` Personal Access Token +the app used to ask people to paste. The trade-off is that copies can only be +public — which suits GitHub Pages anyway, since Pages needs a public repo on the +free plan. If private copies ever become a requirement, widen `SCOPE` in +[`src/main/githubAuth.ts`](../src/main/githubAuth.ts) to `repo` and re-enable the +private option in `MakeItMine`. + +## Registering the app (one-time) + +1. GitHub → **Settings → Developer settings → OAuth Apps → New OAuth App**. +2. Fill in: + - **Application name**: `tinyStudio` + - **Homepage URL**: `https://app.tinystudio.cc` + - **Authorization callback URL**: `https://app.tinystudio.cc/auth/callback` + (unused by the device flow; required by the form, and needed later for the + web build's PKCE flow) +3. Create it, then on the app's page tick **Enable Device Flow** and save. + Without this, sign-in fails with `device_flow_disabled`. +4. Copy the **Client ID**. It is public — it is fine in the repo, in the built + app, and in CI. + +Do **not** generate a client secret for the desktop app. The device flow does +not use one, and anything shipped in a desktop binary is not a secret. + +## Wiring it in + +Set `VITE_GITHUB_CLIENT_ID` at build time: + +```bash +# local dev +VITE_GITHUB_CLIENT_ID=Ov23li... npm run dev + +# packaged build +VITE_GITHUB_CLIENT_ID=Ov23li... npm run build:win +``` + +Or put it in a `.env` file at the repo root. Without it the app still runs and +falls back to the Personal Access Token path, with a message saying no client ID +is configured. + +## How it fits together + +- [`src/main/githubAuth.ts`](../src/main/githubAuth.ts) runs the flow and stores + the token with Electron `safeStorage` (OS keychain / DPAPI), the same way the + Anthropic API key is stored. It lives in **main**, not the renderer, because + GitHub's OAuth endpoints send no CORS headers — a renderer `fetch` to them + fails outright. +- The renderer receives the token **in memory only** (`initAccount()` in + `lib/github.ts`) and never writes it to `localStorage`, which is where it used + to sit in plaintext. +- [`components/GitHubSignIn.tsx`](../src/renderer/src/components/GitHubSignIn.tsx) + is the one sign-in surface, used by both the header control and the GitHub + sidebar tab. Pasting a token is still available behind "Advanced" for + enterprise and air-gapped setups. + +## Still to do: the web build + +`app.tinyStudio.cc` still uses the token path. The device flow cannot run there: +the browser cannot call GitHub's OAuth endpoints (no CORS), so the web build +needs the authorization-code flow with PKCE plus a small Netlify Function +holding the client secret to do the code→token exchange. That is the next piece +of work; everything else — the account store, the sign-in UI, the permission +checks — is already shared. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 66e67af..b1fca52 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,8 +3,19 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +/** + * The GitHub OAuth client ID is baked in at build time. It is a PUBLIC value — + * the device flow needs no client secret, which is the whole reason it is the + * flow we use — but process.env is not available in a packaged main bundle, so + * it has to be substituted here rather than read at run time. + */ +const GITHUB_CLIENT_ID = process.env.VITE_GITHUB_CLIENT_ID || '' + export default defineConfig({ main: { + define: { + 'process.env.VITE_GITHUB_CLIENT_ID': JSON.stringify(GITHUB_CLIENT_ID) + }, plugins: [externalizeDepsPlugin()] }, preload: { diff --git a/examples-tagged.json b/examples-tagged.json new file mode 100644 index 0000000..070bcf5 --- /dev/null +++ b/examples-tagged.json @@ -0,0 +1,1064 @@ +[ + { + "title": "Blink LED (Circuit + Visual)", + "description": "Blink the onboard LED and mirror its state in the Visual view.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Blink Example", + "board": "tinyCore / Arduino", + "category": "demos", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "led" + ] + }, + { + "title": "Fade LED (Circuit + Visual)", + "description": "PWM-fade an LED and chart the brightness curve live.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Fade Example", + "board": "tinyCore / Arduino", + "category": "demos", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/pwm/", + "tags": [ + "tinycore", + "pwm", + "plotter", + "led" + ] + }, + { + "title": "Joystick (Circuit + Visual)", + "description": "Read a Qwiic joystick and visualize the stick position.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Joystick Example", + "board": "tinyCore + Qwiic Joystick", + "category": "demos", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "qwiic", + "joystick" + ] + }, + { + "title": "Alternating Blink Pattern", + "description": "Hint: Try changing the values of the delay() functions to create different speeds.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-alternate", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "led", + "digital-io" + ] + }, + { + "title": "Appending to a File", + "description": "Appending to a File — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-append-file", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card", + "file-io" + ] + }, + { + "title": "Basic beeping code", + "description": "But don't worry - the ESP32's PWM system is actually way more powerful and flexible than the old tone() function.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/buzzer-basic-beep", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/buzz-buzzer/", + "tags": [ + "tinycore", + "buzzer", + "pwm" + ] + }, + { + "title": "Basic BLE Server Example", + "description": "Basic BLE Server Example — from the tinyDocs “Bluetooth Low Energy (BLE)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/bluetooth-ble-server", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/bluetooth/", + "tags": [ + "tinycore", + "bluetooth" + ] + }, + { + "title": "Basic Blink Program", + "description": "Both LEDs on your tinyCore should now be flashing On and Off like the GIF above.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-basic", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "led", + "digital-io" + ] + }, + { + "title": "Basic button reading", + "description": "You'll see it constantly telling you whether the button is pressed or not.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/button-press-basic", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/button-press/", + "tags": [ + "tinycore", + "button" + ] + }, + { + "title": "Basic Code: Write and Read a File", + "description": "Basic Code: Write and Read a File — from the tinyDocs “What is an SD Card?” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sd-card-write-and-read", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/5_reference/basics/sd-card/", + "tags": [ + "tinycore", + "sd-card" + ] + }, + { + "title": "Basic output example", + "description": "Make sure the baud rate dropdown (bottom right) is set to 115200 to match your code.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-monitor-basic-output", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore" + ] + }, + { + "title": "Basic Web Server", + "description": "Basic Web Server — from the tinyDocs “WiFi” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/wifi-basic-web-server", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/wifi/", + "tags": [ + "tinycore", + "wifi", + "web-server", + "serial", + "led", + "digital-io" + ] + }, + { + "title": "Basic WiFi Connection", + "description": "Basic WiFi Connection — from the tinyDocs “WiFi” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/wifi-basic-connection", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/wifi/", + "tags": [ + "tinycore", + "wifi" + ] + }, + { + "title": "Code for External LED", + "description": "Code for External LED — from the tinyDocs “How to Control LEDs with the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-external-led", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "led", + "digital-io" + ] + }, + { + "title": "CSV Data Logging", + "description": "CSV Data Logging — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-csv-logger", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card", + "file-io" + ] + }, + { + "title": "Debugging with timestamps", + "description": "This creates professional-looking debug logs with timestamps, making it easier to understand the timing of events in your code.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-monitor-timestamps", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore" + ] + }, + { + "title": "Delete and List Files", + "description": "Delete and List Files — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-delete-and-list", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card" + ] + }, + { + "title": "Detecting button events", + "description": "Constantly checking if a button is pressed gets annoying fast.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/button-press-events", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/button-press/", + "tags": [ + "tinycore", + "button" + ] + }, + { + "title": "Flash the tinyCore", + "description": "Our IMU example demonstrates how to initialize the IMU and view it’s data graphed out via the Serial Plotter in Arduino.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/imu-serial-plotter", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/1_get-started/imu/", + "tags": [ + "tinycore", + "i2c", + "imu", + "plotter", + "serial", + "digital-io" + ] + }, + { + "title": "Flash the tinyCore", + "description": "Our IMU example demonstrates how to initialize the IMU and view it’s data graphed out via the Serial Plotter in Arduino.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/imu-motion-plotter", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/detect-motion/", + "tags": [ + "tinycore", + "i2c", + "imu", + "plotter", + "serial", + "digital-io" + ] + }, + { + "title": "Hello World (Text on Screen!)", + "description": "The tinyCore does all the math in its own memory first, and then blasts the entire finished frame to the OLED all at once.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/i2c-display-hello-world", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/i2c-display/", + "tags": [ + "tinycore", + "i2c", + "oled", + "serial" + ] + }, + { + "title": "Initializing the SD Card", + "description": "Initializing the SD Card — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-init", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card" + ] + }, + { + "title": "Interactive buzzer", + "description": "Interactive buzzer — from the tinyDocs “How to Control a Buzzer (Analog Output)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/buzzer-interactive", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/buzz-buzzer/", + "tags": [ + "tinycore", + "buzzer" + ] + }, + { + "title": "Interactive Physics: The IMU Digital Hourglass", + "description": "Displaying static text is great, but your tinyCore has a built-in 6DOF IMU!", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/i2c-display-imu-hourglass", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/i2c-display/", + "tags": [ + "tinycore", + "i2c", + "imu", + "digital-io" + ] + }, + { + "title": "Light sensor (photoresistor)", + "description": "An LDR (Light Dependent Resistor) changes its resistance based on how much light hits it.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/read-sensor-photoresistor", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "light-sensor" + ] + }, + { + "title": "Manual Brightness Control", + "description": "Manual Brightness Control — from the tinyDocs “How to Control LEDs with the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-brightness", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "pwm", + "led" + ] + }, + { + "title": "Memory and performance monitoring", + "description": "Memory and performance monitoring — from the tinyDocs “How to use the Serial Monitor and Serial Plotter” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-monitor-memory-stats", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore", + "plotter", + "serial" + ] + }, + { + "title": "MQTT Client", + "description": "MQTT Client — from the tinyDocs “WiFi” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/wifi-mqtt-client", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/wifi/", + "tags": [ + "tinycore", + "wifi", + "mqtt", + "serial", + "timing" + ] + }, + { + "title": "Multiple buttons, multiple behaviors", + "description": "This gives you two different interaction modes: button 1 toggles the LED state permanently, while button 2 makes it flash rapidly but only while you hold it down.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/button-press-multiple-buttons", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/button-press/", + "tags": [ + "tinycore", + "button", + "led" + ] + }, + { + "title": "Multiple sensors at once", + "description": "Multiple sensors at once — from the tinyDocs “How to Read Sensor Values (Analog Input)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/read-sensor-multiple-sensors", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "adc" + ] + }, + { + "title": "Multiple variable plotting", + "description": "This creates four different colored lines on the plotter.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-plotter-multi-variable", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore", + "plotter" + ] + }, + { + "title": "On the tinyCore", + "description": "ESP-NOW is built into the ESP32-S3 — no libraries to install, no extra hardware.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/esp-now-basic", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/5_reference/basics/esp-now/", + "tags": [ + "tinycore", + "esp-now" + ] + }, + { + "title": "OTA (Over-the-Air) Updates", + "description": "OTA (Over-the-Air) Updates — from the tinyDocs “WiFi” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/wifi-ota-updates", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/wifi/", + "tags": [ + "tinycore", + "wifi", + "web-server", + "ota" + ] + }, + { + "title": "Play a real song", + "description": "Play a real song — from the tinyDocs “How to Control a Buzzer (Analog Output)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/buzzer-song", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/buzz-buzzer/", + "tags": [ + "tinycore", + "buzzer" + ] + }, + { + "title": "Playing different tones", + "description": "Playing different tones — from the tinyDocs “How to Control a Buzzer (Analog Output)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/buzzer-tones", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/buzz-buzzer/", + "tags": [ + "tinycore", + "buzzer" + ] + }, + { + "title": "PWM Control for All Three LEDs", + "description": "PWM Control for All Three LEDs — from the tinyDocs “How to Control LEDs with the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-pwm-wave", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "pwm", + "led" + ] + }, + { + "title": "Reading a potentiometer", + "description": "Middle pin of potentiometer → GPIO 1 on tinyCore 3.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/read-sensor-potentiometer", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "potentiometer" + ] + }, + { + "title": "Reading from a File", + "description": "Reading from a File — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-read-file", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card" + ] + }, + { + "title": "Reading input from Serial Monitor", + "description": "The Serial Monitor isn't just for output - you can send commands to your tinyCore too.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-monitor-read-input", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore", + "serial" + ] + }, + { + "title": "Real sensor data visualization", + "description": "This example shows how to plot real sensor data alongside simulated signals.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-plotter-sensor-data", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore", + "plotter" + ] + }, + { + "title": "RGB Mood Light Controller", + "description": "RGB Mood Light Controller — from the tinyDocs “Bluetooth Low Energy (BLE)” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/bluetooth-rgb-mood-light", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/bluetooth/", + "tags": [ + "tinycore", + "bluetooth", + "rgb-led", + "led" + ] + }, + { + "title": "Single variable plotting", + "description": "You'll see a beautiful sine wave scrolling across the screen in real-time.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/serial-plotter-single-variable", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/serial-monitor-plotter/", + "tags": [ + "tinycore", + "plotter" + ] + }, + { + "title": "Smart LED control", + "description": "Cover the sensor to make it \"dark\" and watch the LED turn on.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/read-sensor-smart-led", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "led" + ] + }, + { + "title": "Smooth Breathing Effect", + "description": "You should see your LEDs fading off and on smoothly, almost like two fireflies!", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-breathing", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "pwm", + "led" + ] + }, + { + "title": "SOS Signal", + "description": "SOS Signal — from the tinyDocs “How to Control LEDs with the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/blink-sos", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/blink-led/", + "tags": [ + "tinycore", + "blink", + "led", + "digital-io" + ] + }, + { + "title": "The Complete Motion Tracker Code", + "description": "The Complete Motion Tracker Code — from the tinyDocs “Building Your First Project: A Smart Motion Tracker” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/motion-tracker", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/1_get-started/motion-tracker/", + "tags": [ + "tinycore", + "wifi", + "i2c", + "spi", + "imu", + "sd-card" + ] + }, + { + "title": "The I2C Scanner: Your Best Debugging Tool", + "description": "An I2C scanner is a short program that checks every possible address (1–127) and reports which ones respond.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/i2c-scanner", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/5_reference/basics/i2c/", + "tags": [ + "tinycore", + "i2c" + ] + }, + { + "title": "Toggle behavior vs direct control", + "description": "This is like a light switch in your house - press once for on, press again for off.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/button-press-toggle-vs-direct", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/button-press/", + "tags": [ + "tinycore", + "button" + ] + }, + { + "title": "Understanding NOT logic", + "description": "The LED should be glowing when you're not touching the button, and turn off the moment you press it.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/button-press-not-logic", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/button-press/", + "tags": [ + "tinycore", + "button", + "led" + ] + }, + { + "title": "Using It in Code", + "description": "The tinyCore reads the LSM6DSOX via I2C using the Adafruit LSM6DS Arduino library.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/imu-read-orientation", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/5_reference/basics/imu/", + "tags": [ + "tinycore", + "i2c", + "imu" + ] + }, + { + "title": "Visualizing with Serial Plotter", + "description": "Reading numbers is okay, but seeing the data as a graph is way cooler.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/read-sensor-plotter", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/read-sensor-value/", + "tags": [ + "tinycore", + "plotter", + "serial" + ] + }, + { + "title": "WebSocket Server", + "description": "WebSocket Server — from the tinyDocs “WiFi” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/wifi-websocket-server", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/wifi/", + "tags": [ + "tinycore", + "wifi", + "websocket", + "serial", + "timing" + ] + }, + { + "title": "Writing to a File", + "description": "Writing to a File — from the tinyDocs “How to use SD Cards on the tinyCore” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "basics/sdcard-write-file", + "board": "tinyCore (ESP32-S3)", + "category": "basics", + "docsUrl": "https://tinydocs.cc/2_tiny-core/basics/save-data-to-sdcard/", + "tags": [ + "tinycore", + "sd-card" + ] + }, + { + "title": "Basic Connection Code", + "description": "That IP is what you type into a browser to reach a web server running on the tinyCore.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "advanced/wifi-connect-reference", + "board": "tinyCore (ESP32-S3)", + "category": "advanced", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/wifi/", + "tags": [ + "tinycore", + "wifi", + "web-server" + ] + }, + { + "title": "Fade an LED", + "description": "Fade an LED — from the tinyDocs “What is PWM?” guide.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "advanced/pwm-fade-led", + "board": "tinyCore (ESP32-S3)", + "category": "advanced", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/pwm/", + "tags": [ + "tinycore", + "pwm", + "led" + ] + }, + { + "title": "MCP4725 (I2C, 12-bit)", + "description": "One output channel, 4,096 voltage steps, dead-simple I2C interface.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "advanced/dac-mcp4725", + "board": "tinyCore (ESP32-S3)", + "category": "advanced", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/dac/", + "tags": [ + "tinycore", + "qwiic", + "i2c", + "dac", + "digital-io" + ] + }, + { + "title": "Play a Tone on a Buzzer", + "description": "The Arduino analogWrite() and tone() functions don't work on ESP32.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "advanced/pwm-play-tone-buzzer", + "board": "tinyCore (ESP32-S3)", + "category": "advanced", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/pwm/", + "tags": [ + "tinycore", + "buzzer", + "pwm" + ] + }, + { + "title": "Reading an Analog Value", + "description": "The ESP32-S3 stores factory calibration data in its eFuse memory, and analogReadMilliVolts() applies it automatically.", + "owner": "Mister-Industries", + "repo": "tinyStudio-examples", + "path": "advanced/adc-read-analog", + "board": "tinyCore (ESP32-S3)", + "category": "advanced", + "docsUrl": "https://tinydocs.cc/5_reference/advanced/adc/", + "tags": [ + "tinycore", + "adc" + ] + }, + { + "title": "tinySniff: Basic Demo", + "description": "Reads all three tinySniff MEMS gas sensors and streams them out as a graph.", + "owner": "Mister-Industries", + "repo": "tinySniff", + "path": "Software/Arduino/Examples/tinySniff_Monitor", + "board": "tinyCore + tinySniff HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinysniff/example-code/", + "tags": [ + "tinycore", + "tinysniff", + "plotter" + ] + }, + { + "title": "tinySniff: Google Sheets", + "description": "Logs all three tinySniff gas sensors to a Google Sheet in real time over WiFi.", + "owner": "Mister-Industries", + "repo": "tinySniff", + "path": "Software/Arduino/Examples/tinySniff_Sheets", + "board": "tinyCore + tinySniff HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinysniff/example-code/", + "tags": [ + "tinycore", + "tinysniff", + "wifi" + ] + }, + { + "title": "tinySniff: SD Card Logger", + "description": "Logs all three tinySniff MEMS gas sensors to a timestamped CSV file on the SD card.", + "owner": "Mister-Industries", + "repo": "tinySniff", + "path": "Software/Arduino/Examples/tinySniff_SDLogger", + "board": "tinyCore + tinySniff HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinysniff/example-code/", + "tags": [ + "tinycore", + "tinysniff", + "sd-card", + "file-io" + ] + }, + { + "title": "tinySniff: Text Notifications", + "description": "Monitors all three tinySniff gas sensors against configurable thresholds and sends an email alert when any channel exceeds its limit.", + "owner": "Mister-Industries", + "repo": "tinySniff", + "path": "Software/Arduino/Examples/tinySniff_Alarm", + "board": "tinyCore + tinySniff HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinysniff/example-code/", + "tags": [ + "tinycore", + "tinysniff" + ] + }, + { + "title": "tinySpeak: tinyAIText", + "description": "A text-based AI assistant.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyAIText", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak" + ] + }, + { + "title": "tinySpeak: tinyAIVoice", + "description": "Full Voice Assistant Pipeline.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyAIVoice", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak" + ] + }, + { + "title": "tinySpeak: tinyAIVoice (ElevenLabs)", + "description": "Premium Voice Assistant Pipeline using ElevenLabs TTS.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyAIVoice_EL", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak" + ] + }, + { + "title": "tinySpeak: tinyMP3Player", + "description": "Plays MP3 files from the SD card.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyMP3Player", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak", + "sd-card" + ] + }, + { + "title": "tinySpeak: tinyRecorder", + "description": "A standalone voice recorder.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyRecorder", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak" + ] + }, + { + "title": "tinySpeak: tinySoundboard", + "description": "A motion-triggered soundboard.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinySoundboard", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak", + "imu" + ] + }, + { + "title": "tinySpeak: tinyTheremin", + "description": "A motion-controlled musical instrument.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyTheremin", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak", + "imu" + ] + }, + { + "title": "tinySpeak: tinyWalkieTalkie", + "description": "Push-to-Talk Intercom using ESP-NOW.", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyWalkieTalkie", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak", + "esp-now" + ] + }, + { + "title": "tinySpeak: tinyWebSynth", + "description": "Chiptune synthesizer: live piano, 2-track melodic step sequencer, 3-track drum machine (Kick, Snare, Hi-Hat).", + "owner": "Mister-Industries", + "repo": "tinySpeak", + "path": "Software/Arduino/Examples/tinyWebSynth", + "board": "tinyCore + tinySpeak HAT", + "category": "hats", + "docsUrl": "https://tinydocs.cc/3_tiny-hats/tinyspeak/example-code/", + "tags": [ + "tinycore", + "tinyspeak" + ] + } +] diff --git a/examples.json b/examples.json index f40960c..5496f67 100644 --- a/examples.json +++ b/examples.json @@ -5,7 +5,14 @@ "owner": "Mister-Industries", "repo": "tinyStudio", "path": "demo/Blink Example", - "board": "tinyCore / Arduino" + "board": "tinyCore / Arduino", + "tags": [ + "tinycore", + "blink", + "led", + "digital-io", + "visual" + ] }, { "title": "Fade LED", @@ -13,7 +20,14 @@ "owner": "Mister-Industries", "repo": "tinyStudio", "path": "demo/Fade Example", - "board": "tinyCore / Arduino" + "board": "tinyCore / Arduino", + "tags": [ + "tinycore", + "pwm", + "led", + "plotter", + "visual" + ] }, { "title": "Joystick", @@ -21,6 +35,13 @@ "owner": "Mister-Industries", "repo": "tinyStudio", "path": "demo/Joystick Example", - "board": "tinyCore + Qwiic Joystick" + "board": "tinyCore + Qwiic Joystick", + "tags": [ + "tinycore", + "qwiic", + "joystick", + "i2c", + "visual" + ] } ] diff --git a/scripts/_to_delete/gen-tinyboards-v2.py b/scripts/_to_delete/gen-tinyboards-v2.py new file mode 100644 index 0000000..41b42bc --- /dev/null +++ b/scripts/_to_delete/gen-tinyboards-v2.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""gen-tinyboards - draws the built-in tinyBoard breadboard artwork. + +Writes one Fritzing-style SVG per board into + src/renderer/src/assets/tinyboards/ +and prints the pin map + tinyProto bus list that partsLibrary.ts hard-codes. + +Fritzing conventions the output follows: + * the root carries the real-world size in inches with a viewBox in + points (72 units/inch), so the part is dimensionally correct; + * the art sits in a layer; + * every connector carries a `connectorNpin` element and a + `connectorNterminal` marker at its attachment point; + * EVERY gradient id is namespaced per board - the Circuit view inlines all + the boards into one DOM, and shared ids make every board render with the + first board's fill. + +Geometry is measured off the product photos: a 1.9in (48.26 mm) regular +octagon, 0.1in pitch, 8 pins up the left (ANALOG), 8 up the right (DIGITAL), +9 across the bottom (SERIAL). tinyProto additionally carries its 183-hole +prototyping cross - 15 columns x the 8 header rows, plus the 9 header columns +x 15 rows - with five silkscreened power buses. + +Usage: python3 scripts/gen-tinyboards.py [--json] +Stdlib only. +""" +import json +import math +import os +import sys + + +DPI_VB = 72.0 +IN = 1.9 +W = IN * DPI_VB # 136.8 +H = W +U = 0.1 * DPI_VB # 7.2 — one 0.1" pitch +CH = W / (2 + math.sqrt(2)) # 40.07 — regular-octagon chamfer +CX = W / 2 +CY = H / 2 + +X_L = CX - 8.75 * U # 5.4 +X_R = CX + 8.75 * U # 131.4 +Y_B = CY + 8.25 * U # 127.8 +Y_SIDE = [CY - 3.5 * U + i * U for i in range(8)] +X_BOT = [CX - 4.0 * U + i * U for i in range(9)] + +LEFT_NAMES = ['GND', '3V3', 'A5', 'A4', 'A3', 'A2', 'A1', 'A0'] +RIGHT_NAMES = ['D8', 'D9', 'D10', 'D11', 'D12', 'D13', '3V3.2', 'GND.2'] +BOT_NAMES = ['SCK', 'MO', 'MI', 'RX', 'TX', 'SDA', 'SCL', 'PWR', 'GND.3'] +# what the family silkscreens by default +LEFT_SILK = ['GND', '3V3', 'A5', 'A4', 'A3', 'A2', 'A1', 'A0'] +RIGHT_SILK = ['8', '9', '10', '11', '12', '13', '3V3', 'GND'] +BOT_SILK = ['SCK', 'MO', 'MI', 'RX', 'TX', 'SDA', 'SCL', 'PWR', 'GND'] + +SANS = 'DejaVu Sans, Verdana, Arial, Helvetica, sans-serif' +PX = 96.0 / DPI_VB + + +def connectors(): + out = [] + for n, s, y in zip(LEFT_NAMES, LEFT_SILK, Y_SIDE): + out.append((n, s, X_L, y, 'L')) + for n, s, y in zip(RIGHT_NAMES, RIGHT_SILK, Y_SIDE): + out.append((n, s, X_R, y, 'R')) + for n, s, x in zip(BOT_NAMES, BOT_SILK, X_BOT): + out.append((n, s, x, Y_B, 'B')) + return out + + +def stack_pin_map(): + return {n: [round(x * PX, 2), round(y * PX, 2)] for n, _s, x, y, _d in connectors()} + + +def octagon(inset=0.0): + c = CH + inset * (math.sqrt(2) - 1) + a, b = inset, W - inset + return [(c, a), (W - c, a), (b, c), (b, H - c), + (W - c, b), (c, b), (a, H - c), (a, c)] + + +def poly(pts): + return ' '.join('%.2f,%.2f' % (x, y) for x, y in pts) + + +def inside(px, py, m=0.0): + """Is (px,py) at least m inside the octagon?""" + k = CH + m * (math.sqrt(2) - 1) + return (m < px < W - m and m < py < H - m and + px + py > k and (W - px) + py > k and + px + (H - py) > k and (W - px) + (H - py) > k) + + +# ── text ──────────────────────────────────────────────────────────────────── +def txt(x, y, s, size=3.2, fill='#fff', anchor='middle', weight='normal', + style='normal', deg=None, ls=None, family=None): + tr = ' transform="rotate(%g,%.2f,%.2f)"' % (deg, x, y) if deg else '' + sp = ' letter-spacing="%s"' % ls if ls else '' + return ('%s' + % (x, y, family or SANS, size, fill, anchor, weight, style, sp, tr, s)) + + +def credit(silk, name='GEOFF McINTYRE', size=2.5): + """The two-line designer credit that runs at 45 deg along the SE chamfer.""" + mid = (W + (W - CH)) / 2 + out = [] + for i, s in enumerate(('BOARD DESIGN BY:', name)): + d = 12.5 + i * 4.4 + c = mid - d / math.sqrt(2) + out.append(txt(c, c, s, size, silk, 'middle', deg=-45, ls='0.35')) + return ''.join(out) + + +# ── copper ────────────────────────────────────────────────────────────────── + + +def pad_ring(idx, x, y, r=2.6, hole=1.3, ring='#d7ab48', edge='#9b7a24', hole_fill='#141414'): + return ('' + '' + '' + % (idx, x, y, r, ring, edge, x, y, hole, hole_fill, idx, x - 0.15, y - 0.15)) + + +def pad_socket(idx, x, y, s=5.6): + """A 0.1in female receptacle: black shell, square cavity.""" + return ('' + '' + '' + % (idx, x - s / 2, y - s / 2, s, s, x - s * 0.29, y - s * 0.29, + s * 0.58, s * 0.58, idx, x - 0.15, y - 0.15)) + + +def pad_dome(idx, x, y, r=2.9): + """Solder-domed gold pad (HASL/ENIG finish, as photographed).""" + return ('' + '' + '' + '' + % (idx, x, y, r, x, y, r * 0.78, x, y, r * 0.40, idx, x - 0.15, y - 0.15)) + + +def header_pads(style='ring', **kw): + fn = {'ring': pad_ring, 'socket': pad_socket, 'dome': pad_dome}[style] + return ''.join(fn(i, x, y, **kw) for i, (_n, _s, x, y, _d) in enumerate(connectors())) + + +def header_boxes(silk, w=3.7, pin1=True): + """Silkscreen outline round each header, with pin 1 boxed on its own — + the convention used across the family.""" + o = [] + + def rect(x0, y0, x1, y1, rx=0.9): + o.append('' % (x0, y0, x1 - x0, y1 - y0, rx, silk)) + ys, ye = Y_SIDE[0], Y_SIDE[-1] + # left: pin 1 = top + rect(X_L - w, ys - w, X_L + w, ys + w) + rect(X_L - w, Y_SIDE[1] - w, X_L + w, ye + w) + # right: pin 1 = bottom + rect(X_R - w, ye - w, X_R + w, ye + w) + rect(X_R - w, ys - w, X_R + w, Y_SIDE[-2] + w) + # bottom: pin 1 = rightmost + rect(X_BOT[-1] - w, Y_B - w, X_BOT[-1] + w, Y_B + w) + rect(X_BOT[0] - w, Y_B - w, X_BOT[-2] + w, Y_B + w) + return ''.join(o) + + +def header_labels(silk, size=3.1, side_deg=0, bottom_deg=0, over=None, skip=(), + gap=4.6, bgap=4.8, bsize=None): + """Pin names. `side_deg`/`bottom_deg` are 0 or -90 (reading bottom-to-top); + the family is not consistent about this, so each board says what it prints.""" + o = [] + over = over or {} + for n, s, x, y, d in connectors(): + s = over.get(n, s) + if not s or n in skip: + continue + if d == 'B': + bs = bsize or size + if bottom_deg: + o.append(txt(x, y - bgap, s, bs, silk, 'start', deg=bottom_deg)) + else: + o.append(txt(x, y - bgap, s, bs, silk, 'middle')) + else: + sx = x + gap if d == 'L' else x - gap + if side_deg: + o.append(txt(sx, y, s, size, silk, 'middle', deg=side_deg)) + else: + o.append(txt(sx, y + size * 0.36, s, size, silk, + 'start' if d == 'L' else 'end')) + return ''.join(o) + + +def tiny_logo(x, y, k=13.0, silk='#fff'): + """The chip mark every tinyBoard carries.""" + sw = k * 0.105 + o = ['' % (x, y), + '' % (-k / 2, -k / 2, k, k, k * 0.13, silk, sw)] + for i in range(4): + t = -k / 2 + k * (i + 1) / 5.0 + for a, b, c, d in ((t, -k / 2, t, -k / 2 - k * 0.13), + (t, k / 2, t, k / 2 + k * 0.13), + (-k / 2, t, -k / 2 - k * 0.13, t), + (k / 2, t, k / 2 + k * 0.13, t)): + o.append('' % (a, b, c, d, silk, sw)) + o.append(txt(0, k * 0.20, 'tiny', k * 0.40, silk, 'middle', style='italic')) + o.append('') + return ''.join(o) + + +def defs(slug, mask, mask2, extra=''): + """Gradients etc. EVERY id is namespaced: several boards share one DOM in + the Circuit view, and duplicate ids made every board pick up the first + board's (near-black) soldermask.""" + return ('' + '' + '' + '' + '' + '' + '' + '' + '' + '' + % (slug, mask, mask2, slug, slug) + extra + '') + + +def board_base(slug, edge='#000'): + return ('' + '' + % (poly(octagon()), slug, poly(octagon(0.55)), edge)) + + +def svg(body, title): + return ('' + '%s%s' % (IN, IN, W, H, title, body)) + + + +def g(x, y, deg=0): + return '' % (x, y, deg) + +def smd_res(x, y, deg=0, code='103', w=4.6, h=2.5): + return (g(x, y, deg) + + '' + '' + % (-w/2, -h/2, w, h, -w/2+0.95, -h/2, w-1.9, h) + + txt(0, h/2-0.8, code, 1.6, '#eceff3') + '') + +def smd_cap(x, y, deg=0, w=5.0, h=3.4, body='#cfc0a2'): + return (g(x, y, deg) + + '' + '' + '' + '' % (-w/2, -h/2, w, h, -w/2+1.05, -h/2, w-2.1, h, body, + -w/2+1.05, -h/2, w-2.1, h*0.3)) + +def sot23(x, y, deg=0, w=4.6, h=3.3): + legs = ''.join('' % (lx, ly) + for lx, ly in ((-w/2-1.0, -h/4-0.6), (-w/2-1.0, h/4-0.6), (w/2-0.05, -0.58))) + return (g(x, y, deg) + legs + + '' + '' + '' % (-w/2, -h/2, w, h, -w/2, -h/2, w, h*0.3)) + +def soic(s, x, y, deg=0, w=7.2, h=5.2, n=4, label=''): + legs = '' + for i in range(n): + ly = -h/2 + h*(i+0.5)/n + legs += ('' + '' + % (-w/2-1.15, ly-0.5, w/2-0.1, ly-0.5)) + t = txt(0, 0.9, label, 1.9, '#8d9199') if label else '' + return (g(x, y, deg) + legs + + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.3, -h/2+1.3) + t + '') + +def inductor(s, x, y, size=15.5, deg=0): + """Shielded power inductor: dark ferrite body, bright plated end terminals.""" + k = size + return (g(x, y, deg) + + '' + '' + '' + '' + '' + '' + % (-k/2, -k/2, k, k, -k/2+0.9, -k/2+0.9, k-1.8, k-1.8, + -k*0.30, -k/2+0.7, k*0.34, k*0.20, -k*0.04, k/2-k*0.27, k*0.34, k*0.20, + -k/2+0.7, k*0.30, k*0.20, k*0.17, k/2-k*0.27, -k*0.47, k*0.20, k*0.17) + + txt(-k*0.06, k*0.10, '4R7', k*0.20, '#2b2e33', 'middle', deg=-30) + '') + +def screw_terminal(s, x, y, poles=3, pitch=10.0, deg=0): + """Pluggable green screw terminal, screws facing outward (up).""" + w, h = poles*pitch, 17.5 + o = [g(x, y, deg), + '' + % (-w/2, -h/2, w, h, s), + '' + % (-w/2, -h/2, w, h*0.22), + '' + % (-w/2, h/2-3.6, w, 3.6)] + for i in range(poles): + sx = -w/2 + pitch*(i+0.5) + o.append('' % (sx, -h/2+5.2, pitch*0.36)) + o.append('' % (sx, -h/2+5.2, pitch*0.30, s)) + o.append('' % (sx-pitch*0.26, -h/2+4.5, pitch*0.52, + (i*37) % 90 - 45, sx, -h/2+5.2)) + o.append('' + % (sx-pitch*0.30, h/2-7.0, pitch*0.60)) + if i: + o.append('' % (-w/2+pitch*i, -h/2, -w/2+pitch*i, h/2)) + o.append('' % (-w/2, -h/2, w, h)) + return ''.join(o) + +def slide_switch(s, x, y, w=12.6, h=24.6): + """SPDT slide switch, metal shell, actuator toward the ON (upper) end.""" + return (g(x, y) + + '' + '' + '' + '' + '' + '' + '' % (-w*0.22, -h/2-1.8, w*0.44, -w*0.22, h/2-0.8, w*0.44, + -w/2, -h/2, w, h, s, + -w/2+2.0, -h/2+2.6, w-4.0, h-5.2, + -w/2+2.6, -h/2+3.4, w-5.2, h*0.40, + -w/2+3.2, -h/2+4.2, w-6.4, h*0.28)) + +def tact_button(s, x, y, k=9.6): + return (g(x, y) + + '' + '' + '' + % (-k/2, -k/2, k, k, s, k*0.31, -k*0.03, k*0.24, k*0.22)) + +def usb_c(s, x, y, deg=0, w=22.0, h=13.0): + return (g(x, y, deg) + + '' + '' + '' + % (-w/2, -h/2, w, h, s, -w/2+2.8, -h/2+2.6, w-5.6, h-5.2, (h-5.2)/2, + -w/2+4.4, -1.2, w-8.8) + + txt(w*0.16, h*0.30, 'GCT', 2.2, '#9aa0a8') + '') + +def jst(x, y, deg=0, w=9.6, h=7.2): + return (g(x, y, deg) + + '' + '' + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.1, -h/2+1.1, w-2.2, h-2.8, + -h/2+1.7, h-4.0, -h/2+1.7, h-4.0)) + +def led(x, y, colour='#d8452f', k=3.0): + return ('' + '' + '' + % (x-k/2-0.8, y-k/2, k+1.6, k, x-k/2, y-k/2, k, k, colour, + x-k/2+0.35, y-k/2+0.35, k*0.38, k*0.38)) + +def esp_module(s, x, y, w=46.0, h=50.0): + """ESP32-S3-MINI-1: cream module PCB, shield can, antenna keep-out on top.""" + sh_x, sh_y, sh_w, sh_h = -w/2+2.0, -h/2+9.6, w-4.0, h-13.0 + o = [g(x, y), + '' % (-w/2, -h/2, w, h)] + for i in range(15): + px = -w/2 + 1.8 + i*(w-3.6)/14 + o.append('' % (px-0.75, -h/2-0.6)) + o.append('' % (px-0.75, h/2-1.2)) + # antenna + o.append('' % (-w/2+8.0, -h/2+2.4)) + o.append('' % (sh_x, sh_y, sh_w, sh_h, s)) + o.append('' % (sh_x+1.1, sh_y+1.1, sh_w-2.2, sh_h-2.2)) + o.append(txt(1.5, sh_y+7.0, 'ESPRESSIF', 3.1, '#484d55')) + o.append(txt(1.5, sh_y+11.6, 'ESP32-S3-MINI-1', 3.3, '#3c414a')) + o.append(txt(1.5, sh_y+16.6, 'CE', 3.0, '#585d65')) + for i, ln in enumerate(('FCC ID: 2AC7Z-ESP32S3MINI1', 'IC: 21098-ESP32S3MINI1', + 'CMIIT ID: 2022DP6085')): + o.append(txt(1.5, sh_y+21.0+i*3.0, ln, 1.75, '#6b7079')) + o.append('' % (-3.0, sh_y+sh_h-8.6)) + for i in range(6): + for j in range(5): + o.append('' + % (-2.4+i*1.32, sh_y+sh_h-7.9+j*1.16)) + o.append('') + return ''.join(o) + +def speaker(s, cx, cy, r=58.0): + """Round mylar speaker: raised rim, swirled cone, centre dome.""" + o = ['' % (cx+1.2, cy+1.6, r+1.4), + '' % (cx, cy, r), + '' % (cx, cy, r*0.955), + '' % (cx, cy, r*0.90)] + n, ri, ro = 34, r*0.30, r*0.885 + for i in range(n): + a0 = i*2*math.pi/n + x0, y0 = cx+ro*math.cos(a0), cy+ro*math.sin(a0) + a1 = a0 + 0.62 + x1, y1 = cx+ri*math.cos(a1), cy+ri*math.sin(a1) + am = a0 + 0.24 + rm = (ri+ro)*0.52 + xm, ym = cx+rm*math.cos(am), cy+rm*math.sin(am) + o.append('' + % (x0, y0, xm, ym, x1, y1, r*0.052)) + o.append('' % (cx, cy, r*0.33)) + o.append('' % (cx, cy, r*0.30)) + o.append('' % (cx, cy, r*0.125)) + o.append('' + % (cx-r*0.30, cy-r*0.34, r*0.30, r*0.20)) + return ''.join(o) + +def audio_jack(x, y, deg=0, w=15.0, h=11.0): + return (g(x, y, deg) + + '' + '' + '' + '' + '' + % (-w/2, -h/2, w, h, -w/2+3.4, h*0.30, -w/2+3.4, h*0.17, + w/2-3.4, -h/2-1.6, 3.2, w/2-3.4, h/2-1.6, 3.2)) + +def round_lcd(s, cx, cy, r_bezel=51.0, r_glass=46.0): + o = ['' % (cx+1.0, cy+1.4, r_bezel+1.0), + '' % (cx, cy, r_bezel), + '' % (cx, cy, r_bezel*0.965), + '' % (cx, cy, r_glass, s)] + for i in range(60): + a = i*2*math.pi/60 - math.pi/2 + L = r_glass*0.105 if i % 5 == 0 else r_glass*0.055 + wd = r_glass*0.020 if i % 5 == 0 else r_glass*0.011 + rr = r_glass*0.94 + o.append('' + % (cx+rr*math.cos(a), cy+rr*math.sin(a), + cx+(rr-L)*math.cos(a), cy+(rr-L)*math.sin(a), wd)) + o.append('' % (cx, cy, cx-r_glass*0.30, cy-r_glass*0.56)) + o.append('' % (cx, cy, cx+r_glass*0.42, cy-r_glass*0.52)) + o.append('' % (cx, cy, cx+r_glass*0.32, cy+r_glass*0.62)) + o.append(txt(cx-r_glass*0.16, cy+r_glass*0.40, '2:05', r_glass*0.115, '#dfe6ff')) + o.append('' + % (cx-r_glass*0.28, cy-r_glass*0.30, r_glass*0.42, r_glass*0.16)) + return ''.join(o) + +def gas_sensor(s, x, y, k=13.0): + """MEMS gas sensor in a gold-plated can with a 3x3 vent grid.""" + o = [g(x, y), + '' % (-k/2, -k/2, k, k), + '' + % (-k/2+0.7, -k/2+0.7, k-1.4, k-1.4), + '' + % (-k*0.30, -k*0.30, k*0.60, k*0.60)] + for i in range(3): + for j in range(3): + o.append('' + % (-k*0.19+i*k*0.19, -k*0.19+j*k*0.19, k*0.055)) + o.append('' % (-k/2, -k/2, k, k)) + return ''.join(o) + +def fpc(x, y, w=30.0, h=6.0): + return (g(x, y) + + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.4, -h/2+1.2, w-2.8, h-2.8)) + +def mount_hole(x, y, r=4.4, ring='#efeee9'): + return ('' + '' + % (x, y, r, ring, x, y, r*0.9)) + + + +WHITE = '#f4f6f8' + +# ── tinyCore ──────────────────────────────────────────────────────────────── +def tinycore(): + s = 'tc' + o = [defs(s, '#1b2422', '#0d1413'), board_base(s, '#000')] + o.append(esp_module(s, 68.0, 33.0, 46.0, 50.0)) + o.append(tact_button(s, 37.7, 16.0, 9.0)) + o.append(tact_button(s, 98.2, 16.0, 9.0)) + o.append(txt(37.7, 25.6, 'BOOT', 3.6, WHITE, weight='bold')) + o.append(txt(98.2, 25.6, 'RST', 3.6, WHITE, weight='bold')) + o.append(jst(19.5, 104.0, 90)) + o.append(usb_c(s, 111.5, 108.5, -45, 24.0, 14.0)) # on the SE chamfer, at 45deg + o.append(mount_hole(29.5, 72.0, 4.6)) + o.append(mount_hole(107.3, 72.0, 4.6)) + for x in (24.0, 30.5, 106.0, 112.5): + o.append(smd_res(x, 44.0, 90, '103', 4.4, 2.4)) + for x, y in ((22.0, 53.5), (114.5, 53.5), (20.5, 65.0), (116.0, 65.0)): + o.append(smd_cap(x, y, 0, 4.4, 2.9)) + for x in (30.0, 36.5, 100.0, 106.5): + o.append(smd_cap(x, 30.5, 0, 4.4, 2.9)) + o.append(sot23(23.0, 88.5, 0)) + o.append(sot23(114.0, 88.5, 0)) + o.append(soic(s, 68.0, 88.0, 0, 7.6, 5.2, 4, '')) + for x in (44.0, 52.0, 60.0, 76.0): + o.append(smd_cap(x, 99.5, 0, 4.4, 2.9)) + o.append(smd_res(34.0, 99.5, 0, '472', 4.4, 2.4)) + # status LEDs — measured off the board + for lx, col, nm in ((36.2, '#4fd07a', 'PWR'), (51.5, '#f0a93b', 'CRG'), + (66.5, '#4a8ff0', 'BOOT'), (82.1, '#e8524a', 'SIG')): + o.append(led(lx, 64.0, col)) + o.append(txt(lx + 4.8, 72.4, nm, 3.2, WHITE, weight='bold')) + o.append(tiny_logo(87.8, 83.7, 12.2, WHITE)) + o.append(txt(99.0, 89.6, '5V', 3.4, WHITE, weight='bold')) + o.append('' + '') + o.append(header_boxes(WHITE)) + o.append(header_pads('socket')) + # tinyCore prints its side names horizontally and its bottom names at 90deg + o.append(header_labels(WHITE, 3.4, side_deg=0, bottom_deg=-90, bgap=4.6)) + o.append(txt(X_L + 17.5, 64.0, 'ANALOG', 3.8, WHITE, deg=90, ls='1.0')) + o.append(txt(X_R - 17.5, 64.0, 'DIGITAL', 3.8, WHITE, deg=-90, ls='1.0')) + o.append(txt(68.0, 104.5, 'SERIAL', 3.8, WHITE, 'middle', ls='1.0')) + return ''.join(o) + +# ── tinyGlow ──────────────────────────────────────────────────────────────── +def tinyglow(): + s = 'tg' + o = [defs(s, '#1d4bad', '#0e2c73'), board_base(s, '#04102e')] + # three 3-pole terminals: the outer two ride the top chamfers at +/-45deg + ch = ((33.8, 11.3, -45, 'CH3'), (68.4, 4.3, 0, 'CH2'), (103.7, 11.7, 45, 'CH1')) + for x, y, d, _n in ch: + o.append(screw_terminal(s, x, y, 3, 10.0, d)) + o.append(txt(28.0, 30.0, 'CH3', 3.6, WHITE, deg=-90)) + o.append(txt(63.0, 32.0, 'CH2', 3.6, WHITE, deg=-90)) + o.append(txt(98.5, 30.0, 'CH1', 3.6, WHITE, deg=-90)) + o.append(txt(18.5, 24.5, '4A MAX', 2.6, WHITE, deg=-45)) + o.append(txt(68.4, 22.5, '4A MAX', 2.6, WHITE)) + o.append(txt(118.0, 24.5, '4A MAX', 2.6, WHITE, deg=45)) + for i, (ix, iy) in enumerate(((40.8, 46.4), (76.4, 47.1), (113.9, 47.2))): + o.append('' % (ix - 10.5, iy - 10.5, WHITE)) + o.append(inductor(s, ix, iy, 18.5)) + o.append(sot23(ix - 12.0, 35.5)) + o.append(smd_cap(ix - 15.5, 29.0, 0, 4.6, 3.0)) + o.append(smd_cap(ix - 15.5, 34.5, 0, 4.6, 3.0)) + o.append(smd_cap(ix - 3.0, 27.5, 0, 4.6, 3.0)) + o.append(smd_res(ix - 9.5, 60.5, 0, '103', 4.4, 2.4)) + o.append(smd_res(ix - 3.0, 60.5, 0, '472', 4.4, 2.4)) + o.append(smd_cap(ix + 10.5, 39.5, 90, 4.6, 3.0)) + o.append(smd_cap(ix + 15.0, 39.5, 90, 4.6, 3.0)) + o.append(sot23(ix - 9.0, 72.5, 0)) + o.append(mount_hole(29.7, 77.5, 4.2, '#dfe4ea')) + o.append(mount_hole(102.9, 76.0, 4.2, '#dfe4ea')) + sw = (39.2, 62.7, 86.0) + for i, x in enumerate(sw): + o.append(slide_switch(s, x, 97.0, 11.6, 26.0)) + o.append(txt(x - 0.5, 82.0, 'ON', 3.4, WHITE, weight='bold')) + o.append(txt(x - 6.4, 116.5, 'S%d' % (3 - i), 4.6, WHITE, weight='bold')) + o.append(txt(x + 7.0, 116.5, 'OFF', 3.2, WHITE, weight='bold')) + o.append(smd_res(x + 8.5, 97.0, 90, '103', 4.4, 2.4)) + # VIN input, bottom right, labelled along the chamfer + o.append(txt(112.5, 100.0, '+VIN', 2.9, WHITE, deg=-90)) + o.append(txt(118.0, 100.0, 'GND', 2.9, WHITE, deg=-90)) + o.append(txt(106.5, 112.0, '5V - 24V', 2.9, WHITE, deg=-45)) + for vx in (112.5, 119.0): + o.append('' + % (vx, vx)) + o.append(header_boxes(WHITE)) + o.append(header_pads('ring')) + # the real board only silkscreens the three channel pins + o.append(header_labels(WHITE, 2.9, side_deg=-90, + over={n: '' for n in LEFT_NAMES + BOT_NAMES + RIGHT_NAMES})) + for i, nm in enumerate(('1', '2', '3')): # CH1..CH3 print in two lines + o.append(txt(X_R - 8.4, Y_SIDE[i], 'CH', 2.6, WHITE, deg=-90)) + o.append(txt(X_R - 4.6, Y_SIDE[i], nm, 2.6, WHITE, deg=-90)) + o.append(tiny_logo(23.0, 96.5, 17.2, WHITE)) + return ''.join(o) + +# ── tinyProto ─────────────────────────────────────────────────────────────── +GRID_X = [18.0 + i * U for i in range(15)] # cols 3..11 are the bottom header +GRID_Y = [14.4 + i * U for i in range(15)] # rows 4..11 are the side headers +COL0, ROW0 = 3, 4 # index of X_BOT[0] / Y_SIDE[0] + +def proto_holes(): + """The cross: 15 cols x the 8 header rows, plus the 9 header cols x 15 rows.""" + out = [] + for r in range(15): + for c in range(15): + in_h = ROW0 <= r < ROW0 + 8 + in_v = COL0 <= c < COL0 + 9 + if in_h or in_v: + out.append((c, r)) + return out + +def hole_name(c, r): + return '%s.%d' % ('ABCDEFGHIJKLMNO'[c], r + 1) + +# silkscreen bus groups, read off the board photo +PROTO_BUSES = [ + [(0, 4), (1, 4), (2, 4), (3, 1), (3, 2), (3, 3)], # left GND (L-shaped) + [(0, 5), (1, 5), (2, 5)], # left 3V3 + [(12, 10), (13, 10), (14, 10)], # right 3V3 + [(12, 11), (13, 11), (14, 11), (11, 12), (11, 13), (11, 14)], # right + bottom GND + [(10, 12), (10, 13), (10, 14)] # bottom PWR +] + +def tinyproto(): + s = 'tp' + o = [defs(s, '#0a9950', '#046f39'), board_base(s, '#03361c')] + hs = set(proto_holes()) + ring = [] + for c, r in sorted(hs): + x, y = GRID_X[c], GRID_Y[r] + ring.append('' + % (x, y, x, y)) + o.append(''.join(ring)) + # bus outlines + the short leader from each header pad to its first hole + def cap(cells): + xs = [GRID_X[c] for c, _ in cells]; ys = [GRID_Y[r] for _, r in cells] + return (min(xs) - 3.6, min(ys) - 3.6, max(xs) + 3.6, max(ys) + 3.6) + def runs(cells): + """split an L-shaped bus into its axis-aligned oval segments""" + by_r, by_c = {}, {} + for c, r in cells: + by_r.setdefault(r, []).append(c); by_c.setdefault(c, []).append(r) + segs = [] + for r, cs in by_r.items(): + if len(cs) > 1: segs.append([(c, r) for c in sorted(cs)]) + for c, rs in by_c.items(): + if len(rs) > 1: segs.append([(c, r) for r in sorted(rs)]) + return segs + for bus in PROTO_BUSES: + segs = runs(bus) + for seg in segs: + x0, y0, x1, y1 = cap(seg) + o.append('' % (x0, y0, x1 - x0, y1 - y0, WHITE)) + if len(segs) == 2: # link the two arms of an L + a, b = segs + ax, ay = GRID_X[a[-1][0]], GRID_Y[a[-1][1]] + bx, by = GRID_X[b[0][0]], GRID_Y[b[0][1]] + o.append('' % (ax + 2.4, ay - 2.4, bx - 2.4, by + 2.4, WHITE)) + # single-hole rings for the signal pins + leaders from every pad + def leader(px, py, hx, hy): + o.append('' % (px, py, hx, hy, WHITE)) + busy = {cell for bs in PROTO_BUSES for cell in bs} + for i, y in enumerate(Y_SIDE): + r = ROW0 + i + # left pad -> its first hole; right pad -> its last hole + if (0, r) in busy: + leader(X_L + 3.8, y, GRID_X[2] + 3.6, y) + else: + o.append('' % (GRID_X[0], y, WHITE)) + leader(X_L + 3.8, y, GRID_X[0] - 3.6, y) + if (14, r) in busy: + leader(X_R - 3.8, y, GRID_X[12] - 3.6, y) + else: + o.append('' % (GRID_X[14], y, WHITE)) + leader(X_R - 3.8, y, GRID_X[14] + 3.6, y) + for i, x in enumerate(X_BOT): + c = COL0 + i + if (c, 14) in busy: + leader(x, Y_B - 3.8, x, GRID_Y[12] - 3.6) + else: + o.append('' % (x, GRID_Y[14], WHITE)) + leader(x, Y_B - 3.8, x, GRID_Y[14] + 3.6) + o.append(header_boxes(WHITE)) + o.append(header_pads('ring', ring='#cbd0d6', edge='#8d939b')) + o.append(header_labels(WHITE, 3.0, side_deg=-90, bottom_deg=0, gap=4.4, bgap=5.0, + bsize=2.6)) + o.append(txt(68.0, 9.5, 'tinyProto', 8.6, WHITE, style='italic', ls='0.7')) + o.append(credit(WHITE, size=2.2)) + o.append(tiny_logo(23.0, 103.0, 12.5, WHITE)) + return ''.join(o) + +# ── tinySniff ─────────────────────────────────────────────────────────────── +def tinysniff(): + s = 'ts' + o = [defs(s, '#efb812', '#c8900a'), board_base(s, '#7d5c06')] + # three silkscreen zones: H2 top-left, CH4 top-right, H2S bottom-centre + zones = [ + ('HYDROGEN', [(29.0, 14.0), (67.2, 14.0), (67.2, 68.5), (16.0, 68.5), (16.0, 27.0)], + 37.6, 38.4, 60.5), + ('METHANE', [(69.6, 14.0), (108.0, 14.0), (121.0, 27.0), (121.0, 68.5), (69.6, 68.5)], + 98.3, 37.7, 75.0), + ('HYDROGEN SULFIDE', [(45.0, 70.5), (121.0, 70.5), (121.0, 121.0), (45.0, 121.0)], + 69.2, 100.6, 92.0)] + for name, pts, _sx, _sy, lx in zones: + o.append('' % (poly(pts), WHITE)) + for name, pts, _sx, _sy, lx in zones: + ys = [p[1] for p in pts] + o.append(txt(lx, (min(ys) + max(ys)) / 2, name, 3.6, WHITE, deg=-90, ls='0.5')) + # sensor centres and the parts that ring them, measured off the photo + zoneparts = [ + (37.6, 38.4, 'R6', 'R5', ('C12', 'C9', 'C11', 'C10')), + (98.3, 37.7, 'R2', 'R1', ('C4', 'C1', 'C3', 'C2')), + (69.2, 100.6, 'R3', 'R4', ('C6', 'C7', 'C5', 'C8'))] + for (sx, sy, ra, rb, (cul, cll, cur, clr)) in zoneparts: + for dx, dy, nm, up in ((-14.5, -4.8, cul, True), (-14.3, 3.9, cll, False), + (15.1, -4.5, cur, True), (15.2, 4.0, clr, False)): + o.append(smd_cap(sx + dx, sy + dy, 90, 7.6, 5.0)) + o.append(txt(sx + dx, sy + dy + (-5.2 if up else 8.0), nm, 3.0, WHITE)) + o.append(smd_res(sx + 4.3, sy - 14.4, 0, '472', 7.4, 3.6)) + o.append(txt(sx + 4.3, sy - 18.6, ra, 3.0, WHITE)) + o.append(smd_res(sx - 6.6, sy + 15.6, 0, '104', 6.4, 3.4)) + o.append(txt(sx - 6.6, sy + 21.0, rb, 3.0, WHITE)) + o.append(sot23(sx + 4.0, sy + 16.0, 0, 6.4, 4.4)) + o.append(gas_sensor(s, sx, sy, 15.5)) + o.append(mount_hole(31.6, 75.5, 4.4, '#f6efdc')) + o.append(mount_hole(105.6, 75.5, 4.4, '#f6efdc')) + o.append(header_boxes(WHITE)) + o.append(header_pads('dome')) + o.append(header_labels(WHITE, 3.0, + over={n: '' for n in LEFT_NAMES + RIGHT_NAMES + BOT_NAMES} + | {'A2': 'A2-H2', 'A1': 'A1-H2S', 'A0': 'A0-CH4'})) + o.append(txt(CX, 10.5, 'tinySniff', 8.6, WHITE, style='italic', ls='0.6')) + o.append(txt(124.0, 66.0, '83070014A_Y1.09_250122', 2.7, WHITE, deg=-90, ls='0.3')) + o.append(tiny_logo(107.1, 94.9, 13.9, WHITE)) + o.append(credit(WHITE, size=2.2)) + return ''.join(o) + +# ── tinySpeak ─────────────────────────────────────────────────────────────── +SPEAK_SILK = {'D8': 'DIN', 'D9': 'BCLK', 'D10': 'LRC', 'D11': 'WS', + 'D12': 'DOUT', 'D13': 'SCLK', '3V3.2': '3V3', 'GND.2': 'GND', + 'GND.3': 'G'} + +def tinyspeak(): + s = 'tk' + o = [defs(s, '#e8382c', '#b81f18'), board_base(s, '#5d0b0a')] + o.append(smd_res(78.0, 8.5, 0, '105', 5.2, 2.8)) + o.append(txt(90.0, 9.0, 'R1 1M', 2.8, WHITE)) + o.append(soic(s, 104.0, 15.0, 0, 7.0, 5.0, 4, '')) + o.append(smd_cap(60.0, 7.5, 0, 4.6, 3.0)) + o.append(smd_cap(67.0, 7.5, 0, 4.6, 3.0)) + o.append(txt(30.0, 116.0, 'MIC', 2.8, WHITE)) + o.append('' + '') + o.append(header_boxes(WHITE)) + o.append(header_pads('ring', ring='#d3d8de', edge='#93989f')) + o.append(header_labels(WHITE, 3.0, over=SPEAK_SILK)) + o.append(credit(WHITE, 'JOHN LETTANG')) + # the speaker sits on top of everything, overhanging the top edge + o.append(audio_jack(28.0, 15.0, -45)) + o.append(speaker(s, 67.9, 66.4, 58.0)) + return ''.join(o) + +# ── tinyDisplay ───────────────────────────────────────────────────────────── +def tinydisplay(): + s = 'td' + lcd = ('' + '' + '') + o = [defs(s, '#0e7d6c', '#065247', lcd), board_base(s, '#032b25')] + for x in (46.0, 58.0, 79.0, 91.0): + o.append('' + '' % (x, x)) + o.append(fpc(68.4, 118.0, 30.0, 6.0)) + o.append(smd_cap(46.0, 118.5, 0, 4.6, 3.0)) + o.append(smd_cap(91.0, 118.5, 0, 4.6, 3.0)) + o.append(header_boxes(WHITE)) + o.append(header_pads('dome')) + o.append(header_labels(WHITE, 3.0, side_deg=-90, bottom_deg=0, gap=4.6, bgap=5.0)) + o.append(tiny_logo(25.1, 102.6, 9.5, WHITE)) + o.append(credit(WHITE)) + o.append(round_lcd(s, 67.6, 65.0, 49.0, 44.3)) + return ''.join(o) + + +BOARDS = { + 'tinycore': ('tinyCore', 'ESP32-S3', tinycore, 'var(--cyan)', '#1b2422', '#ffffff'), + 'tinyglow': ('tinyGlow', '3-channel LED driver', tinyglow, 'var(--pink)', '#1d4bad', '#ffffff'), + 'tinyproto': ('tinyProto', 'Prototyping / breakout', tinyproto, '#3fc47a', '#0a9950', '#ffffff'), + 'tinysniff': ('tinySniff', 'MEMS gas sensor array', tinysniff, '#e0a90d', '#efb812', '#2a2410'), + 'tinyspeak': ('tinySpeak', 'Microphone + speaker', tinyspeak, '#f0655c', '#e8382c', '#ffffff'), + 'tinydisplay': ('tinyDisplay', 'Round LCD module', tinydisplay, '#2fbfa6', '#0e7d6c', '#ffffff') +} + +def proto_pin_map(): + pins = stack_pin_map() + for c, r in proto_holes(): + pins[hole_name(c, r)] = [round(GRID_X[c] * PX, 2), round(GRID_Y[r] * PX, 2)] + return pins + +def proto_bus_names(): + return [[hole_name(c, r) for c, r in bus] for bus in PROTO_BUSES] + + + +if __name__ == '__main__': + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + dest = os.path.join(root, 'src', 'renderer', 'src', 'assets', 'tinyboards') + os.makedirs(dest, exist_ok=True) + for slug, (label, sub, fn, accent, mask, ink) in BOARDS.items(): + art = svg(fn(), label) + with open(os.path.join(dest, slug + '.svg'), 'w', encoding='utf-8') as fh: + fh.write(art) + print('%-13s %7d bytes -> %s.svg' % (slug, len(art), slug)) + print('board box: %.1f x %.1f px @ 96 DPI (%.2fin) | proto holes: %d' + % (W * 96 / 72, H * 96 / 72, IN, len(proto_holes()))) + if '--json' in sys.argv: + print(json.dumps({'stack': stack_pin_map(), 'proto': proto_pin_map(), + 'protoBuses': proto_bus_names()}, indent=1)) diff --git a/scripts/gen-example-tags.mjs b/scripts/gen-example-tags.mjs new file mode 100644 index 0000000..ee674c6 --- /dev/null +++ b/scripts/gen-example-tags.mjs @@ -0,0 +1,520 @@ +#!/usr/bin/env node +/** + * gen-example-tags — propose tags for example projects and emit a manifest. + * + * Reads every example folder under the given roots and infers tags from what + * the code actually does, rather than from what someone remembered to type: + * + * - the `Board:` header comment tinyDocs generates into each .ino + * - `#include <...>` lines (Wire.h -> i2c, SD.h -> sd-card, WiFi.h -> wifi…) + * - Arduino API calls in the source (analogWrite -> pwm, tone -> buzzer…) + * - the `Docs:` URL's topic segment (…/basics/i2c-display/ -> i2c, oled) + * - the folder name and README title as a last pass of keyword matching + * - sibling files (visual.js / *.js sketches -> visual, circuit.json) + * + * Every rule is listed in RULES below, so adding vocabulary is a one-line edit + * rather than a code change. Tags are emitted as canonical slugs matching + * src/renderer/src/lib/exampleTags.ts — keep the two in step. + * + * Usage: + * node scripts/gen-example-tags.mjs [roots...] [options] + * + * --owner GitHub owner for emitted entries (default Mister-Industries) + * --repo GitHub repo for emitted entries (default tinyStudio-examples) + * --category Force a category instead of inferring from the parent dir + * --base Emit paths relative to this dir (default: cwd). Point it at + * the root the examples repo will serve from. + * --out Write the manifest here (default: stdout) + * --manifest ENRICH MODE. Instead of scanning for example folders and + * inventing entries, read this manifest and add a `tags` + * field to every entry it already has, leaving titles, + * descriptions and paths untouched. Source folders given as + * roots are matched to entries by path so the sketch itself + * can be read; an entry with no local folder still gets tags + * from its title, description and board field. This is the + * mode to use on the published examples manifest. + * --merge Keep hand-authored fields from an existing manifest, + * matching on owner/repo/path. Existing tags are unioned + * with the inferred ones, so manual additions survive a + * re-run and nothing you typed gets clobbered. + * --report Print a per-example tag table to stderr for review + * + * Examples: + * node scripts/gen-example-tags.mjs tinyStudio-examples-old --report + * node scripts/gen-example-tags.mjs demo --category demos --repo tinyStudio \ + * --merge examples.json --out examples.json + * node scripts/gen-example-tags.mjs tinyStudio-examples-old demo \ + * --manifest remote.json --out examples-tagged.json --report + * + * Node stdlib only — no install step, runs anywhere the repo is checked out. + */ + +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { basename, join, relative, sep } from 'node:path' + +// ── rules ──────────────────────────────────────────────────────────────────── +// Each rule: a tag slug and the evidence that earns it. `include` matches +// #include filenames, `code` matches identifiers in the source, `text` matches +// folder name / README / docs URL. All matching is case-insensitive. +const RULES = [ + // boards & expansions + { tag: 'tinycore', include: [], code: [], text: ['tinycore', 'esp32-s3', 'esp32s3'] }, + { tag: 'tinyglow', text: ['tinyglow'] }, + { tag: 'tinyproto', text: ['tinyproto'] }, + { tag: 'tinysniff', text: ['tinysniff', 'gas sensor'] }, + { tag: 'tinyspeak', text: ['tinyspeak'] }, + { tag: 'tinydisplay', text: ['tinydisplay'] }, + { tag: 'qwiic', text: ['qwiic', 'stemma'] }, + + // connectivity + { tag: 'wifi', include: ['WiFi.h', 'WiFiUdp.h'], code: ['WiFi.begin'], text: ['wifi', 'wi-fi'] }, + { + tag: 'bluetooth', + include: ['BLEDevice.h', 'BLEServer.h', 'BLEUtils.h', 'BLE2902.h'], + text: ['bluetooth', 'ble'] + }, + { tag: 'i2c', include: ['Wire.h'], code: ['Wire.begin'], text: ['i2c', 'i²c'] }, + { tag: 'spi', include: ['SPI.h'], code: ['SPI.begin'], text: ['spi'] }, + { + tag: 'serial', + code: ['Serial.begin', 'Serial.println', 'Serial.print', 'Serial.read'], + text: ['serial monitor', 'serial plotter'] + }, + { tag: 'esp-now', include: ['esp_now.h'], code: ['esp_now_init'], text: ['esp-now', 'espnow'] }, + { tag: 'mqtt', include: ['PubSubClient.h'], text: ['mqtt'] }, + { tag: 'websocket', include: ['WebSocketsServer.h'], text: ['websocket'] }, + { + tag: 'web-server', + include: ['WebServer.h', 'ESPAsyncWebServer.h'], + text: ['web server', 'webserver', 'http server'] + }, + { tag: 'ota', include: ['AsyncElegantOTA.h', 'ArduinoOTA.h'], text: ['ota', 'over-the-air'] }, + + // parts & sensors + { tag: 'led', code: ['LED_BUILTIN'], text: ['led', 'blink', 'mood light'] }, + { tag: 'rgb-led', code: ['Adafruit_NeoPixel'], text: ['rgb', 'neopixel', 'rgb mood light'] }, + { tag: 'button', code: ['INPUT_PULLUP', 'digitalRead'], text: ['button', 'press'] }, + { + tag: 'buzzer', + code: ['tone(', 'noTone', 'ledcWriteTone'], + text: ['buzzer', 'buzz', 'song', 'tone'] + }, + { + tag: 'oled', + include: ['Adafruit_SSD1306.h', 'Adafruit_GFX.h'], + text: ['oled', 'ssd1306', 'i2c display'] + }, + { + tag: 'imu', + include: ['Adafruit_LSM6DSOX.h'], + code: ['getEvent', 'accel.acceleration'], + text: ['imu', 'accelerometer', 'gyro', 'motion'] + }, + { tag: 'sd-card', include: ['SD.h', 'FS.h'], code: ['SD.begin'], text: ['sd card', 'sdcard'] }, + { tag: 'potentiometer', text: ['potentiometer'] }, + { tag: 'light-sensor', text: ['light sensor', 'photoresistor', 'ldr'] }, + { tag: 'joystick', text: ['joystick'] }, + { tag: 'distance-sensor', text: ['distance sensor', 'proximity', 'ir distance', 'ultrasonic'] }, + { tag: 'dac', include: ['Adafruit_MCP4725.h'], text: ['dac', 'mcp4725'] }, + + // concepts + { tag: 'blink', text: ['blink', 'sos'] }, + { + tag: 'pwm', + code: ['analogWrite', 'ledcWrite', 'ledcSetup'], + text: ['pwm', 'fade', 'breathing', 'brightness'] + }, + { tag: 'adc', code: ['analogRead'], text: ['adc', 'analog value', 'analog input'] }, + { tag: 'digital-io', code: ['digitalWrite', 'pinMode'], text: ['digital'] }, + { tag: 'plotter', text: ['plotter', 'plotting', 'graph', 'chart', 'visuali'] }, + { + tag: 'file-io', + code: ['file.print', 'FILE_APPEND'], + text: ['csv', 'append', 'logging', 'save data'] + }, + { tag: 'timing', code: ['millis('], text: ['non-blocking', 'timestamp'] }, + { tag: 'interrupts', code: ['attachInterrupt'], text: ['interrupt', 'debounce'] } +] + +// ── cli ────────────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2) +const opt = { + owner: 'Mister-Industries', + repo: 'tinyStudio-examples', + category: null, + out: null, + merge: null, + report: false +} +const roots = [] +for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--report') opt.report = true + else if (a.startsWith('--')) opt[a.slice(2)] = argv[++i] + else roots.push(a) +} +if (roots.length === 0) { + console.error( + 'usage: node scripts/gen-example-tags.mjs [--out file] [--merge file] [--report]' + ) + process.exit(1) +} + +// ── scanning ───────────────────────────────────────────────────────────────── +const SOURCE_EXT = /\.(ino|cpp|c|h|js)$/i + +/** Every directory that directly contains a sketch file is one example. */ +function findExampleDirs(root) { + const found = [] + const walk = (dir) => { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + if (entries.some((e) => e.isFile() && /\.ino$/i.test(e.name))) { + found.push(dir) + return // an example is a leaf; don't descend into its subfolders + } + for (const e of entries) { + if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') + walk(join(dir, e.name)) + } + } + walk(root) + return found.sort() +} + +function readExample(dir) { + const files = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()) + let source = '' + let readme = '' + const names = [] + for (const f of files) { + names.push(f.name) + const p = join(dir, f.name) + if (statSync(p).size > 512 * 1024) continue // skip anything oversized + if (SOURCE_EXT.test(f.name)) source += '\n' + readFileSync(p, 'utf8') + else if (/^readme\.md$/i.test(f.name)) readme = readFileSync(p, 'utf8') + } + return { source, readme, names } +} + +/** + * These READMEs are generated from tinyDocs and carry a fixed tail ("## Open + * it", "## Where this came from", "## Files") plus a stock "Click the **Flash + * tinyCore** button" line. That prose is identical across every example, so + * feeding it to the matcher tags unrelated sketches with `button`. Everything + * from the first boilerplate heading on is cut before the README is used as + * either a description or tag evidence. + */ +const README_TAIL = /^##\s+(open it|where this came from|files)\b/im +/** H1s that say nothing about the example — never usable as a title. */ +const BOILERPLATE_TITLES = /^(flash (the )?tinycore|readme|example|getting started)$/i + +function readmeBody(readme) { + const cut = readme.search(README_TAIL) + return (cut === -1 ? readme : readme.slice(0, cut)).replace(/click the \*\*flash[^\n]*/gi, '') +} + +/** The `Board:` / `Docs:` / title metadata tinyDocs writes into each sketch. */ +function headerMeta(source, readme, dir) { + const board = source.match(/^\s*\*\s*Board:\s*(.+)$/m)?.[1]?.trim() ?? '' + const docsUrl = source.match(/^\s*\*\s*Docs:\s*(\S+)/m)?.[1]?.trim() ?? '' + // The tinyDocs page this was generated from — good tag evidence ("What is + // an ADC?"), too generic to use as the example's own title. + const docsTitle = readme.match(/From the tinyDocs page \[([^\]]+)\]/)?.[1]?.trim() ?? '' + const body = readmeBody(readme) + + // Title: the sketch's own block-comment title is the most specific thing + // available ("Alternating Blink Pattern"); the README H1 usually matches it + // but is sometimes stock. Folder name is the floor. + const candidates = [ + source.match(/^\/\*+\s*\n\s*\*\s*(.+?)\s*$/m)?.[1], + body.match(/^#\s+(.+)$/m)?.[1], + basename(dir) + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + ] + const title = + candidates.map((c) => c?.trim()).find((c) => c && !BOILERPLATE_TITLES.test(c)) ?? basename(dir) + + // Description: first substantive prose line of the README body. + const prose = body + .split('\n') + .map((l) => l.trim()) + .filter( + (l) => + l.length > 20 && + !l.startsWith('#') && + !l.startsWith('!') && + !l.startsWith('>') && + !l.startsWith('|') && + !l.startsWith('-') && + !l.startsWith('```') + ) + // A line ending in ':' is a lead-in to a code block ("Replace your loop() + // with this:"), which reads badly as a card description — prefer a line that + // stands on its own, and only fall back to the lead-in if there isn't one. + const description = prose.find((l) => !l.endsWith(':')) ?? prose[0] ?? '' + return { board, docsUrl, docsTitle, title, description, body } +} + +/** + * Text evidence is matched on word boundaries, never as a bare substring. + * Substring matching looks fine until "ble" tags every example that says + * "variable", "led" matches "called", and "http" in the Docs: URL tags all 55 + * examples as web servers — all three of which it did. + */ +const textHitCache = new Map() +function textHit(text, keyword) { + let re = textHitCache.get(keyword) + if (!re) { + const esc = keyword + .toLowerCase() + .trim() + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + re = new RegExp(`(?:^|[^a-z0-9])${esc}(?:[^a-z0-9]|$)`, 'i') + textHitCache.set(keyword, re) + } + return re.test(text) +} + +function inferTags({ source, readme, names }, meta, dir) { + const includes = [...source.matchAll(/#include\s*[<"]([^>"]+)[>"]/g)].map((m) => + m[1].toLowerCase() + ) + const code = source.toLowerCase() + // Text evidence: folder name, title, README, the docs URL topic, board line. + const text = [ + basename(dir).replace(/[-_]+/g, ' '), + meta.title, + meta.description, + meta.board, + meta.docsTitle, + decodeURIComponent(meta.docsUrl) + .replace(/^https?:\/\/[^/]+/i, '') + .replace(/[/_-]+/g, ' '), + (meta.body ?? readme).slice(0, 4000), + names.join(' ') + ] + .join('\n') + .toLowerCase() + + const tags = [] + for (const rule of RULES) { + const hit = + (rule.include ?? []).some((i) => includes.includes(i.toLowerCase())) || + (rule.code ?? []).some((c) => code.includes(c.toLowerCase())) || + (rule.text ?? []).some((t) => textHit(text, t)) + if (hit) tags.push(rule.tag) + } + // A p5 sketch beside the .ino means the example drives the Visual view. + if (names.some((n) => /\.js$/i.test(n))) tags.push('visual') + return [...new Set(tags)] +} + +/** + * Recognised tags inside a free-text `board` field — the same rules as + * lib/examples.ts#knownTagsIn, which the app applies at load time. Keep the two + * in step: "tinyCore (ESP32-S3)" must yield `tinycore`, not a junk slug, and + * "tinyCore + tinySpeak HAT" must yield both boards. + */ +const KNOWN_TAGS = new Set([ + ...RULES.map((r) => r.tag), + 'visual', + 'beginner', + 'advanced', + 'joystick' +]) +const BOARD_ALIASES = { + 'esp32 s3': 'tinycore', + esp32: 'tinycore', + core: 'tinycore', + sniff: 'tinysniff', + speak: 'tinyspeak', + glow: 'tinyglow', + proto: 'tinyproto' +} + +function tagsFromBoardField(board) { + if (!board) return [] + const out = [] + const fragments = board + .split(/[+/,&()[\]]|\bwith\b|\band\b/i) + .flatMap((part) => { + const t = part + .trim() + .replace(/\b(hat|board|module|breakout)\b/gi, '') + .trim() + return t.includes(' ') ? [t, ...t.split(/\s+/)] : [t] + }) + .filter(Boolean) + for (const f of fragments) { + const slug = f + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + const spaced = f + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + const tag = KNOWN_TAGS.has(slug) ? slug : (BOARD_ALIASES[spaced] ?? BOARD_ALIASES[slug]) + if (tag && !out.includes(tag)) out.push(tag) + } + return out +} + +/** Keep the chip row readable: boards, then the most specific topics. */ +const BOARD_TAGS = new Set([ + 'tinycore', + 'tinyglow', + 'tinyproto', + 'tinysniff', + 'tinyspeak', + 'tinydisplay', + 'qwiic' +]) +// Generic tags only earn a slot if the example has little else to say. +const GENERIC = ['serial', 'digital-io', 'timing', 'led'] +const MAX_TOPIC_TAGS = 5 + +function prioritize(tags) { + const boards = tags.filter((t) => BOARD_TAGS.has(t)) + const specific = tags.filter((t) => !BOARD_TAGS.has(t) && !GENERIC.includes(t)) + const generic = tags.filter((t) => GENERIC.includes(t)) + return [...boards, ...specific, ...generic].slice(0, boards.length + MAX_TOPIC_TAGS) +} + +// ── build ──────────────────────────────────────────────────────────────────── +const existing = new Map() +if (opt.merge) { + try { + for (const e of JSON.parse(readFileSync(opt.merge, 'utf8'))) { + existing.set(`${e.owner}/${e.repo}/${e.path}`, e) + } + } catch (err) { + console.error(`--merge: could not read ${opt.merge} (${err.message}); continuing without it`) + } +} + +const entries = [] +const report = [] + +// ── enrich mode ────────────────────────────────────────────────────────────── +// Add tags to a manifest that already exists, changing nothing else about it. +if (opt.manifest) { + const manifest = JSON.parse(readFileSync(opt.manifest, 'utf8')) + + // Index every local example folder by path and by basename, so a manifest + // entry can find its own sketch wherever the checkout happens to keep it. + const byPath = new Map() + for (const root of roots) { + for (const dir of findExampleDirs(root)) { + const rel = relative(root, dir).split(sep).join('/') + byPath.set(rel.toLowerCase(), dir) + byPath.set(basename(dir).toLowerCase(), dir) + byPath.set(relative(process.cwd(), dir).split(sep).join('/').toLowerCase(), dir) + } + } + + for (const e of manifest) { + const p = (e.path ?? '').toLowerCase() + const dir = byPath.get(p) ?? byPath.get(basename(p)) + let inferred = [] + if (dir) { + const files = readExample(dir) + inferred = inferTags(files, headerMeta(files.source, files.readme, dir), dir) + } else { + // No local folder (the tinyHAT examples live in their own repos): fall + // back to the entry's own prose. Fewer tags, but never wrong ones. + const text = [e.title, e.description, e.path, e.board].filter(Boolean).join('\n') + inferred = RULES.filter((r) => (r.text ?? []).some((t) => textHit(text, t))).map((r) => r.tag) + } + // The board field is authoritative for which hardware an example needs. + const boardTags = tagsFromBoardField(e.board) + const tags = prioritize([...new Set([...boardTags, ...inferred, ...(e.tags ?? [])])]) + entries.push({ ...e, tags }) + report.push({ path: e.path ?? e.title, tags, matched: Boolean(dir) }) + } +} else + for (const root of roots) { + for (const dir of findExampleDirs(root)) { + const files = readExample(dir) + const meta = headerMeta(files.source, files.readme, dir) + const inferred = prioritize(inferTags(files, meta, dir)) + + // Emitted paths are relative to --base (the root of the repo the examples + // will live in), so scanning ./tinyStudio-examples-old emits + // "basics/blink-basic" — what the examples repo actually serves — rather + // than a path that only makes sense in this checkout. + const relPath = relative(opt.base ?? process.cwd(), dir) + .split(sep) + .join('/') + const category = opt.category ?? relative(root, dir).split(sep)[0] ?? undefined + const key = `${opt.owner}/${opt.repo}/${relPath}` + const prev = existing.get(key) + + // Hand-authored fields win; tags are unioned so manual additions survive. + const tags = [...new Set([...(prev?.tags ?? []), ...inferred])] + entries.push({ + title: prev?.title ?? meta.title, + description: prev?.description ?? meta.description, + owner: opt.owner, + repo: opt.repo, + path: relPath, + ...((prev?.board ?? meta.board) ? { board: prev?.board ?? meta.board } : {}), + ...((prev?.category ?? category) ? { category: prev?.category ?? category } : {}), + ...((prev?.docsUrl ?? meta.docsUrl) ? { docsUrl: prev?.docsUrl ?? meta.docsUrl } : {}), + tags + }) + report.push({ path: relPath, tags }) + } + } + +if (opt.report) { + const width = Math.min(52, Math.max(...report.map((r) => basename(r.path).length), 10)) + for (const r of report) { + console.error(`${basename(r.path).padEnd(width)} ${r.tags.join(' ') || '(none)'}`) + } + const counts = new Map() + for (const r of report) for (const t of r.tags) counts.set(t, (counts.get(t) ?? 0) + 1) + console.error(`\n${report.length} examples, ${counts.size} distinct tags`) + console.error( + [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([t, n]) => `${t}:${n}`) + .join(' ') + ) + const untagged = report.filter((r) => r.tags.length === 0) + if (untagged.length) + console.error(`\nUNTAGGED (${untagged.length}): ${untagged.map((r) => r.path).join(', ')}`) + + // Enrich mode only. An entry whose sketch wasn't found locally is tagged from + // its title and description alone — the board tags stay correct, but the + // topic tags are whatever the prose happened to say. This is the loud warning + // that you are running against the wrong checkout: run the generator from a + // clone of the repo the manifest actually describes to get full tags. + const unmatched = report.filter((r) => r.matched === false) + if (unmatched.length) { + console.error( + `\nNO LOCAL SOURCE (${unmatched.length}/${report.length}) — tagged from manifest prose only.\n` + + `These entries' sketches were not found under the roots given, so their topic tags are\n` + + `incomplete. Re-run with a checkout that contains them:\n ` + + unmatched + .map((r) => r.path) + .slice(0, 12) + .join('\n ') + + (unmatched.length > 12 ? `\n … and ${unmatched.length - 12} more` : '') + ) + } +} + +const json = JSON.stringify(entries, null, 2) + '\n' +if (opt.out) { + writeFileSync(opt.out, json) + console.error(`wrote ${entries.length} entries to ${opt.out}`) +} else { + process.stdout.write(json) +} diff --git a/scripts/gen-tinyboards.py b/scripts/gen-tinyboards.py new file mode 100644 index 0000000..8600493 --- /dev/null +++ b/scripts/gen-tinyboards.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""gen-tinyboards - draws the built-in tinyBoard breadboard artwork. + +Writes one Fritzing-style SVG per board into + src/renderer/src/assets/tinyboards/ +and prints the pin map + tinyProto bus list that partsLibrary.ts hard-codes. + +Fritzing conventions the output follows: + * the root carries the real-world size in inches with a viewBox in + points (72 units/inch), so the part is dimensionally correct; + * the art sits in a layer; + * every connector carries a `connectorNpin` element and a + `connectorNterminal` marker at its attachment point; + * EVERY gradient id is namespaced per board - the Circuit view inlines all + the boards into one DOM, and shared ids make every board render with the + first board's fill. + +Geometry is measured off the product photos: a 1.9in (48.26 mm) regular +octagon, 0.1in pitch, 8 pins up the left (ANALOG), 8 up the right (DIGITAL), +9 across the bottom (SERIAL). tinyProto additionally carries its 183-hole +prototyping cross - 15 columns x the 8 header rows, plus the 9 header columns +x 15 rows - with five silkscreened power buses. + +Usage: python3 scripts/gen-tinyboards.py [--json] +Stdlib only. +""" +import json +import math +import os +import sys + + +DPI_VB = 72.0 +IN = 1.9 +W = IN * DPI_VB # 136.8 +H = W +U = 0.1 * DPI_VB # 7.2 — one 0.1" pitch +CH = W / (2 + math.sqrt(2)) # 40.07 — regular-octagon chamfer +CX = W / 2 +CY = H / 2 + +X_L = CX - 8.75 * U # 5.4 +X_R = CX + 8.75 * U # 131.4 +Y_B = CY + 8.25 * U # 127.8 +Y_SIDE = [CY - 3.5 * U + i * U for i in range(8)] +X_BOT = [CX - 4.0 * U + i * U for i in range(9)] + +LEFT_NAMES = ['GND', '3V3', 'A5', 'A4', 'A3', 'A2', 'A1', 'A0'] +RIGHT_NAMES = ['D8', 'D9', 'D10', 'D11', 'D12', 'D13', '3V3.2', 'GND.2'] +BOT_NAMES = ['SCK', 'MO', 'MI', 'RX', 'TX', 'SDA', 'SCL', 'PWR', 'GND.3'] +# what the family silkscreens by default +LEFT_SILK = ['GND', '3V3', 'A5', 'A4', 'A3', 'A2', 'A1', 'A0'] +RIGHT_SILK = ['8', '9', '10', '11', '12', '13', '3V3', 'GND'] +BOT_SILK = ['SCK', 'MO', 'MI', 'RX', 'TX', 'SDA', 'SCL', 'PWR', 'GND'] + +SANS = 'DejaVu Sans, Verdana, Arial, Helvetica, sans-serif' +PX = 96.0 / DPI_VB + + +def connectors(): + out = [] + for n, s, y in zip(LEFT_NAMES, LEFT_SILK, Y_SIDE): + out.append((n, s, X_L, y, 'L')) + for n, s, y in zip(RIGHT_NAMES, RIGHT_SILK, Y_SIDE): + out.append((n, s, X_R, y, 'R')) + for n, s, x in zip(BOT_NAMES, BOT_SILK, X_BOT): + out.append((n, s, x, Y_B, 'B')) + return out + + +def stack_pin_map(): + return {n: [round(x * PX, 2), round(y * PX, 2)] for n, _s, x, y, _d in connectors()} + + +def octagon(inset=0.0): + c = CH + inset * (math.sqrt(2) - 1) + a, b = inset, W - inset + return [(c, a), (W - c, a), (b, c), (b, H - c), + (W - c, b), (c, b), (a, H - c), (a, c)] + + +def poly(pts): + return ' '.join('%.2f,%.2f' % (x, y) for x, y in pts) + + +def inside(px, py, m=0.0): + """Is (px,py) at least m inside the octagon?""" + k = CH + m * (math.sqrt(2) - 1) + return (m < px < W - m and m < py < H - m and + px + py > k and (W - px) + py > k and + px + (H - py) > k and (W - px) + (H - py) > k) + + +# ── text ──────────────────────────────────────────────────────────────────── +def txt(x, y, s, size=3.2, fill='#fff', anchor='middle', weight='normal', + style='normal', deg=None, ls=None, family=None): + tr = ' transform="rotate(%g,%.2f,%.2f)"' % (deg, x, y) if deg else '' + sp = ' letter-spacing="%s"' % ls if ls else '' + return ('%s' + % (x, y, family or SANS, size, fill, anchor, weight, style, sp, tr, s)) + + +def credit(silk, name='GEOFF McINTYRE', size=2.5): + """The two-line designer credit that runs at 45 deg along the SE chamfer.""" + mid = (W + (W - CH)) / 2 + out = [] + for i, s in enumerate(('BOARD DESIGN BY:', name)): + d = 12.5 + i * 4.4 + c = mid - d / math.sqrt(2) + out.append(txt(c, c, s, size, silk, 'middle', deg=-45, ls='0.35')) + return ''.join(out) + + +# ── copper ────────────────────────────────────────────────────────────────── + + +def pad_ring(idx, x, y, r=2.6, hole=1.3, ring='#d7ab48', edge='#9b7a24', hole_fill='#141414'): + return ('' + '' + '' + % (idx, x, y, r, ring, edge, x, y, hole, hole_fill, idx, x - 0.15, y - 0.15)) + + +def pad_socket(idx, x, y, s=5.6): + """A 0.1in female receptacle: black shell, square cavity.""" + return ('' + '' + '' + % (idx, x - s / 2, y - s / 2, s, s, x - s * 0.29, y - s * 0.29, + s * 0.58, s * 0.58, idx, x - 0.15, y - 0.15)) + + +def pad_dome(idx, x, y, r=2.9): + """Solder-domed gold pad (HASL/ENIG finish, as photographed).""" + return ('' + '' + '' + '' + % (idx, x, y, r, x, y, r * 0.78, x, y, r * 0.40, idx, x - 0.15, y - 0.15)) + + +def header_pads(style='ring', **kw): + fn = {'ring': pad_ring, 'socket': pad_socket, 'dome': pad_dome}[style] + return ''.join(fn(i, x, y, **kw) for i, (_n, _s, x, y, _d) in enumerate(connectors())) + + +def header_boxes(silk, w=3.7, pin1=True): + """Silkscreen outline round each header, with pin 1 boxed on its own — + the convention used across the family.""" + o = [] + + def rect(x0, y0, x1, y1, rx=0.9): + o.append('' % (x0, y0, x1 - x0, y1 - y0, rx, silk)) + ys, ye = Y_SIDE[0], Y_SIDE[-1] + # left: pin 1 = top + rect(X_L - w, ys - w, X_L + w, ys + w) + rect(X_L - w, Y_SIDE[1] - w, X_L + w, ye + w) + # right: pin 1 = bottom + rect(X_R - w, ye - w, X_R + w, ye + w) + rect(X_R - w, ys - w, X_R + w, Y_SIDE[-2] + w) + # bottom: pin 1 = rightmost + rect(X_BOT[-1] - w, Y_B - w, X_BOT[-1] + w, Y_B + w) + rect(X_BOT[0] - w, Y_B - w, X_BOT[-2] + w, Y_B + w) + return ''.join(o) + + +def header_labels(silk, size=3.1, side_deg=0, bottom_deg=0, over=None, skip=(), + gap=4.6, bgap=4.8, bsize=None): + """Pin names. `side_deg`/`bottom_deg` are 0 or -90 (reading bottom-to-top); + the family is not consistent about this, so each board says what it prints.""" + o = [] + over = over or {} + for n, s, x, y, d in connectors(): + s = over.get(n, s) + if not s or n in skip: + continue + if d == 'B': + bs = bsize or size + if bottom_deg: + o.append(txt(x, y - bgap, s, bs, silk, 'start', deg=bottom_deg)) + else: + o.append(txt(x, y - bgap, s, bs, silk, 'middle')) + else: + sx = x + gap if d == 'L' else x - gap + if side_deg: + o.append(txt(sx, y, s, size, silk, 'middle', deg=side_deg)) + else: + o.append(txt(sx, y + size * 0.36, s, size, silk, + 'start' if d == 'L' else 'end')) + return ''.join(o) + + +def tiny_logo(x, y, k=13.0, silk='#fff'): + """The chip mark every tinyBoard carries.""" + sw = k * 0.105 + o = ['' % (x, y), + '' % (-k / 2, -k / 2, k, k, k * 0.13, silk, sw)] + for i in range(4): + t = -k / 2 + k * (i + 1) / 5.0 + for a, b, c, d in ((t, -k / 2, t, -k / 2 - k * 0.13), + (t, k / 2, t, k / 2 + k * 0.13), + (-k / 2, t, -k / 2 - k * 0.13, t), + (k / 2, t, k / 2 + k * 0.13, t)): + o.append('' % (a, b, c, d, silk, sw)) + o.append(txt(0, k * 0.20, 'tiny', k * 0.40, silk, 'middle', style='italic')) + o.append('') + return ''.join(o) + + +def defs(slug, mask, mask2, extra=''): + """Gradients etc. EVERY id is namespaced: several boards share one DOM in + the Circuit view, and duplicate ids made every board pick up the first + board's (near-black) soldermask.""" + return ('' + '' + '' + '' + '' + '' + '' + '' + '' + '' + % (slug, mask, mask2, slug, slug) + extra + '') + + +def board_base(slug, edge='#000'): + return ('' + '' + % (poly(octagon()), slug, poly(octagon(0.55)), edge)) + + +def svg(body, title): + return ('' + '%s%s' % (IN, IN, W, H, title, body)) + + + +def g(x, y, deg=0): + return '' % (x, y, deg) + +def smd_res(x, y, deg=0, code='103', w=4.6, h=2.5): + return (g(x, y, deg) + + '' + '' + % (-w/2, -h/2, w, h, -w/2+0.95, -h/2, w-1.9, h) + + txt(0, h/2-0.8, code, 1.6, '#eceff3') + '') + +def smd_cap(x, y, deg=0, w=5.0, h=3.4, body='#cfc0a2'): + return (g(x, y, deg) + + '' + '' + '' + '' % (-w/2, -h/2, w, h, -w/2+1.05, -h/2, w-2.1, h, body, + -w/2+1.05, -h/2, w-2.1, h*0.3)) + +def sot23(x, y, deg=0, w=4.6, h=3.3): + legs = ''.join('' % (lx, ly) + for lx, ly in ((-w/2-1.0, -h/4-0.6), (-w/2-1.0, h/4-0.6), (w/2-0.05, -0.58))) + return (g(x, y, deg) + legs + + '' + '' + '' % (-w/2, -h/2, w, h, -w/2, -h/2, w, h*0.3)) + +def soic(s, x, y, deg=0, w=7.2, h=5.2, n=4, label=''): + legs = '' + for i in range(n): + ly = -h/2 + h*(i+0.5)/n + legs += ('' + '' + % (-w/2-1.15, ly-0.5, w/2-0.1, ly-0.5)) + t = txt(0, 0.9, label, 1.9, '#8d9199') if label else '' + return (g(x, y, deg) + legs + + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.3, -h/2+1.3) + t + '') + +def inductor(s, x, y, size=15.5, deg=0): + """Shielded power inductor: dark ferrite body, bright plated end terminals.""" + k = size + return (g(x, y, deg) + + '' + '' + '' + '' + '' + '' + % (-k/2, -k/2, k, k, -k/2+0.9, -k/2+0.9, k-1.8, k-1.8, + -k*0.30, -k/2+0.7, k*0.34, k*0.20, -k*0.04, k/2-k*0.27, k*0.34, k*0.20, + -k/2+0.7, k*0.30, k*0.20, k*0.17, k/2-k*0.27, -k*0.47, k*0.20, k*0.17) + + txt(-k*0.06, k*0.10, '4R7', k*0.20, '#2b2e33', 'middle', deg=-30) + '') + +def screw_terminal(s, x, y, poles=3, pitch=10.0, deg=0): + """Pluggable green screw terminal, screws facing outward (up).""" + w, h = poles*pitch, 17.5 + o = [g(x, y, deg), + '' + % (-w/2, -h/2, w, h, s), + '' + % (-w/2, -h/2, w, h*0.22), + '' + % (-w/2, h/2-3.6, w, 3.6)] + for i in range(poles): + sx = -w/2 + pitch*(i+0.5) + o.append('' % (sx, -h/2+5.2, pitch*0.36)) + o.append('' % (sx, -h/2+5.2, pitch*0.30, s)) + o.append('' % (sx-pitch*0.26, -h/2+4.5, pitch*0.52, + (i*37) % 90 - 45, sx, -h/2+5.2)) + o.append('' + % (sx-pitch*0.30, h/2-7.0, pitch*0.60)) + if i: + o.append('' % (-w/2+pitch*i, -h/2, -w/2+pitch*i, h/2)) + o.append('' % (-w/2, -h/2, w, h)) + return ''.join(o) + +def slide_switch(s, x, y, w=12.6, h=24.6): + """SPDT slide switch, metal shell, actuator toward the ON (upper) end.""" + return (g(x, y) + + '' + '' + '' + '' + '' + '' + '' % (-w*0.22, -h/2-1.8, w*0.44, -w*0.22, h/2-0.8, w*0.44, + -w/2, -h/2, w, h, s, + -w/2+2.0, -h/2+2.6, w-4.0, h-5.2, + -w/2+2.6, -h/2+3.4, w-5.2, h*0.40, + -w/2+3.2, -h/2+4.2, w-6.4, h*0.28)) + +def tact_button(s, x, y, k=9.6): + return (g(x, y) + + '' + '' + '' + % (-k/2, -k/2, k, k, s, k*0.31, -k*0.03, k*0.24, k*0.22)) + +def usb_c(s, x, y, deg=0, w=22.0, h=13.0): + return (g(x, y, deg) + + '' + '' + '' + % (-w/2, -h/2, w, h, s, -w/2+2.8, -h/2+2.6, w-5.6, h-5.2, (h-5.2)/2, + -w/2+4.4, -1.2, w-8.8) + + txt(w*0.16, h*0.30, 'GCT', 2.2, '#9aa0a8') + '') + +def jst(x, y, deg=0, w=9.6, h=7.2): + return (g(x, y, deg) + + '' + '' + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.1, -h/2+1.1, w-2.2, h-2.8, + -h/2+1.7, h-4.0, -h/2+1.7, h-4.0)) + +def led(x, y, colour='#d8452f', k=3.0): + return ('' + '' + '' + % (x-k/2-0.8, y-k/2, k+1.6, k, x-k/2, y-k/2, k, k, colour, + x-k/2+0.35, y-k/2+0.35, k*0.38, k*0.38)) + +def esp_module(s, x, y, w=46.0, h=50.0): + """ESP32-S3-MINI-1: cream module PCB, shield can, antenna keep-out on top.""" + sh_x, sh_y, sh_w, sh_h = -w/2+2.0, -h/2+9.6, w-4.0, h-13.0 + o = [g(x, y), + '' % (-w/2, -h/2, w, h)] + for i in range(15): + px = -w/2 + 1.8 + i*(w-3.6)/14 + o.append('' % (px-0.75, -h/2-0.6)) + o.append('' % (px-0.75, h/2-1.2)) + # antenna + o.append('' % (-w/2+8.0, -h/2+2.4)) + o.append('' % (sh_x, sh_y, sh_w, sh_h, s)) + o.append('' % (sh_x+1.1, sh_y+1.1, sh_w-2.2, sh_h-2.2)) + o.append(txt(1.5, sh_y+7.0, 'ESPRESSIF', 3.1, '#484d55')) + o.append(txt(1.5, sh_y+11.6, 'ESP32-S3-MINI-1', 3.3, '#3c414a')) + o.append(txt(1.5, sh_y+16.6, 'CE', 3.0, '#585d65')) + for i, ln in enumerate(('FCC ID: 2AC7Z-ESP32S3MINI1', 'IC: 21098-ESP32S3MINI1', + 'CMIIT ID: 2022DP6085')): + o.append(txt(1.5, sh_y+21.0+i*3.0, ln, 1.75, '#6b7079')) + o.append('' % (-3.0, sh_y+sh_h-8.6)) + for i in range(6): + for j in range(5): + o.append('' + % (-2.4+i*1.32, sh_y+sh_h-7.9+j*1.16)) + o.append('') + return ''.join(o) + +def speaker(s, cx, cy, r=58.0): + """Round mylar speaker: raised rim, swirled cone, centre dome.""" + o = ['' % (cx+1.2, cy+1.6, r+1.4), + '' % (cx, cy, r), + '' % (cx, cy, r*0.955), + '' % (cx, cy, r*0.90)] + n, ri, ro = 34, r*0.30, r*0.885 + for i in range(n): + a0 = i*2*math.pi/n + x0, y0 = cx+ro*math.cos(a0), cy+ro*math.sin(a0) + a1 = a0 + 0.62 + x1, y1 = cx+ri*math.cos(a1), cy+ri*math.sin(a1) + am = a0 + 0.24 + rm = (ri+ro)*0.52 + xm, ym = cx+rm*math.cos(am), cy+rm*math.sin(am) + o.append('' + % (x0, y0, xm, ym, x1, y1, r*0.052)) + o.append('' % (cx, cy, r*0.33)) + o.append('' % (cx, cy, r*0.30)) + o.append('' % (cx, cy, r*0.125)) + o.append('' + % (cx-r*0.30, cy-r*0.34, r*0.30, r*0.20)) + return ''.join(o) + +def audio_jack(x, y, deg=0, w=15.0, h=11.0): + return (g(x, y, deg) + + '' + '' + '' + '' + '' + % (-w/2, -h/2, w, h, -w/2+3.4, h*0.30, -w/2+3.4, h*0.17, + w/2-3.4, -h/2-1.6, 3.2, w/2-3.4, h/2-1.6, 3.2)) + +def round_lcd(s, cx, cy, r_bezel=51.0, r_glass=46.0): + o = ['' % (cx+1.0, cy+1.4, r_bezel+1.0), + '' % (cx, cy, r_bezel), + '' % (cx, cy, r_bezel*0.965), + '' % (cx, cy, r_glass, s)] + for i in range(60): + a = i*2*math.pi/60 - math.pi/2 + L = r_glass*0.105 if i % 5 == 0 else r_glass*0.055 + wd = r_glass*0.020 if i % 5 == 0 else r_glass*0.011 + rr = r_glass*0.94 + o.append('' + % (cx+rr*math.cos(a), cy+rr*math.sin(a), + cx+(rr-L)*math.cos(a), cy+(rr-L)*math.sin(a), wd)) + o.append('' % (cx, cy, cx-r_glass*0.30, cy-r_glass*0.56)) + o.append('' % (cx, cy, cx+r_glass*0.42, cy-r_glass*0.52)) + o.append('' % (cx, cy, cx+r_glass*0.32, cy+r_glass*0.62)) + o.append(txt(cx-r_glass*0.16, cy+r_glass*0.40, '2:05', r_glass*0.115, '#dfe6ff')) + o.append('' + % (cx-r_glass*0.28, cy-r_glass*0.30, r_glass*0.42, r_glass*0.16)) + return ''.join(o) + +def gas_sensor(s, x, y, k=13.0): + """MEMS gas sensor in a gold-plated can with a 3x3 vent grid.""" + o = [g(x, y), + '' % (-k/2, -k/2, k, k), + '' + % (-k/2+0.7, -k/2+0.7, k-1.4, k-1.4), + '' + % (-k*0.30, -k*0.30, k*0.60, k*0.60)] + for i in range(3): + for j in range(3): + o.append('' + % (-k*0.19+i*k*0.19, -k*0.19+j*k*0.19, k*0.055)) + o.append('' % (-k/2, -k/2, k, k)) + return ''.join(o) + +def fpc(x, y, w=30.0, h=6.0): + return (g(x, y) + + '' + '' + % (-w/2, -h/2, w, h, -w/2+1.4, -h/2+1.2, w-2.8, h-2.8)) + +def mount_hole(x, y, r=4.4, ring='#efeee9'): + return ('' + '' + % (x, y, r, ring, x, y, r*0.9)) + + + +WHITE = '#f4f6f8' + +# ── tinyCore ──────────────────────────────────────────────────────────────── +def tinycore(): + s = 'tc' + o = [defs(s, '#1b2422', '#0d1413'), board_base(s, '#000')] + o.append(esp_module(s, 68.0, 33.0, 46.0, 50.0)) + o.append(tact_button(s, 37.7, 16.0, 9.0)) + o.append(tact_button(s, 98.2, 16.0, 9.0)) + o.append(txt(37.7, 25.6, 'BOOT', 3.6, WHITE, weight='bold')) + o.append(txt(98.2, 25.6, 'RST', 3.6, WHITE, weight='bold')) + o.append(jst(19.5, 104.0, 90)) + o.append(usb_c(s, 111.5, 108.5, -45, 24.0, 14.0)) # on the SE chamfer, at 45deg + o.append(mount_hole(29.5, 72.0, 4.6)) + o.append(mount_hole(107.3, 72.0, 4.6)) + for x in (24.0, 30.5, 106.0, 112.5): + o.append(smd_res(x, 44.0, 90, '103', 4.4, 2.4)) + for x, y in ((22.0, 53.5), (114.5, 53.5), (20.5, 65.0), (116.0, 65.0)): + o.append(smd_cap(x, y, 0, 4.4, 2.9)) + for x in (30.0, 36.5, 100.0, 106.5): + o.append(smd_cap(x, 30.5, 0, 4.4, 2.9)) + o.append(sot23(23.0, 88.5, 0)) + o.append(sot23(114.0, 88.5, 0)) + o.append(soic(s, 68.0, 88.0, 0, 7.6, 5.2, 4, '')) + for x in (44.0, 52.0, 60.0, 76.0): + o.append(smd_cap(x, 99.5, 0, 4.4, 2.9)) + o.append(smd_res(34.0, 99.5, 0, '472', 4.4, 2.4)) + # status LEDs — measured off the board + for lx, col, nm in ((36.2, '#4fd07a', 'PWR'), (51.5, '#f0a93b', 'CRG'), + (66.5, '#4a8ff0', 'BOOT'), (82.1, '#e8524a', 'SIG')): + o.append(led(lx, 64.0, col)) + o.append(txt(lx + 4.8, 72.4, nm, 3.2, WHITE, weight='bold')) + o.append(tiny_logo(87.8, 83.7, 12.2, WHITE)) + o.append(txt(99.0, 89.6, '5V', 3.4, WHITE, weight='bold')) + o.append('' + '') + o.append(header_boxes(WHITE)) + o.append(header_pads('socket')) + # tinyCore prints its side names horizontally and its bottom names at 90deg + o.append(header_labels(WHITE, 3.4, side_deg=0, bottom_deg=-90, bgap=4.6)) + o.append(txt(X_L + 21.0, 64.0, 'ANALOG', 3.8, WHITE, deg=90, ls='1.0')) + o.append(txt(X_R - 21.0, 64.0, 'DIGITAL', 3.8, WHITE, deg=-90, ls='1.0')) + o.append(txt(68.0, 104.5, 'SERIAL', 3.8, WHITE, 'middle', ls='1.0')) + return ''.join(o) + +# ── tinyGlow ──────────────────────────────────────────────────────────────── +def tinyglow(): + s = 'tg' + o = [defs(s, '#1d4bad', '#0e2c73'), board_base(s, '#04102e')] + # three 3-pole terminals: the outer two ride the top chamfers at +/-45deg + ch = ((33.8, 11.3, -45, 'CH3'), (68.4, 4.3, 0, 'CH2'), (103.7, 11.7, 45, 'CH1')) + for x, y, d, _n in ch: + o.append(screw_terminal(s, x, y, 3, 10.0, d)) + o.append(txt(28.0, 30.0, 'CH3', 3.6, WHITE, deg=-90)) + o.append(txt(63.0, 32.0, 'CH2', 3.6, WHITE, deg=-90)) + o.append(txt(98.5, 30.0, 'CH1', 3.6, WHITE, deg=-90)) + o.append(txt(18.5, 24.5, '4A MAX', 2.6, WHITE, deg=-45)) + o.append(txt(68.4, 22.5, '4A MAX', 2.6, WHITE)) + o.append(txt(118.0, 24.5, '4A MAX', 2.6, WHITE, deg=45)) + for i, (ix, iy) in enumerate(((40.8, 46.4), (76.4, 47.1), (113.9, 47.2))): + o.append('' % (ix - 10.5, iy - 10.5, WHITE)) + o.append(inductor(s, ix, iy, 18.5)) + o.append(sot23(ix - 12.0, 35.5)) + o.append(smd_cap(ix - 15.5, 29.0, 0, 4.6, 3.0)) + o.append(smd_cap(ix - 15.5, 34.5, 0, 4.6, 3.0)) + o.append(smd_cap(ix - 3.0, 27.5, 0, 4.6, 3.0)) + o.append(smd_res(ix - 9.5, 60.5, 0, '103', 4.4, 2.4)) + o.append(smd_res(ix - 3.0, 60.5, 0, '472', 4.4, 2.4)) + o.append(smd_cap(ix + 10.5, 39.5, 90, 4.6, 3.0)) + o.append(smd_cap(ix + 15.0, 39.5, 90, 4.6, 3.0)) + o.append(sot23(ix - 9.0, 72.5, 0)) + o.append(mount_hole(29.7, 77.5, 4.2, '#dfe4ea')) + o.append(mount_hole(102.9, 76.0, 4.2, '#dfe4ea')) + sw = (39.2, 62.7, 86.0) + for i, x in enumerate(sw): + o.append(slide_switch(s, x, 97.0, 11.6, 26.0)) + o.append(txt(x - 0.5, 82.0, 'ON', 3.4, WHITE, weight='bold')) + o.append(txt(x - 6.4, 116.5, 'S%d' % (3 - i), 4.6, WHITE, weight='bold')) + o.append(txt(x + 7.0, 116.5, 'OFF', 3.2, WHITE, weight='bold')) + o.append(smd_res(x + 8.5, 97.0, 90, '103', 4.4, 2.4)) + # VIN input, bottom right, labelled along the chamfer + o.append(txt(112.5, 100.0, '+VIN', 2.9, WHITE, deg=-90)) + o.append(txt(118.0, 100.0, 'GND', 2.9, WHITE, deg=-90)) + o.append(txt(106.5, 112.0, '5V - 24V', 2.9, WHITE, deg=-45)) + for vx in (112.5, 119.0): + o.append('' + % (vx, vx)) + o.append(header_boxes(WHITE)) + o.append(header_pads('ring')) + # the real board only silkscreens the three channel pins + o.append(header_labels(WHITE, 2.9, side_deg=-90, + over={n: '' for n in LEFT_NAMES + BOT_NAMES + RIGHT_NAMES})) + for i, nm in enumerate(('1', '2', '3')): # CH1..CH3 print in two lines + o.append(txt(X_R - 8.4, Y_SIDE[i], 'CH', 2.6, WHITE, deg=-90)) + o.append(txt(X_R - 4.6, Y_SIDE[i], nm, 2.6, WHITE, deg=-90)) + o.append(tiny_logo(23.0, 96.5, 17.2, WHITE)) + return ''.join(o) + +# ── tinyProto ─────────────────────────────────────────────────────────────── +GRID_X = [18.0 + i * U for i in range(15)] # cols 3..11 are the bottom header +GRID_Y = [14.4 + i * U for i in range(15)] # rows 4..11 are the side headers +COL0, ROW0 = 3, 4 # index of X_BOT[0] / Y_SIDE[0] + +def proto_holes(): + """The cross: 15 cols x the 8 header rows, plus the 9 header cols x 15 rows.""" + out = [] + for r in range(15): + for c in range(15): + in_h = ROW0 <= r < ROW0 + 8 + in_v = COL0 <= c < COL0 + 9 + if in_h or in_v: + out.append((c, r)) + return out + +def hole_name(c, r): + return '%s.%d' % ('ABCDEFGHIJKLMNO'[c], r + 1) + +# silkscreen bus groups, read off the board photo +PROTO_BUSES = [ + [(0, 4), (1, 4), (2, 4), (3, 1), (3, 2), (3, 3)], # left GND (L-shaped) + [(0, 5), (1, 5), (2, 5)], # left 3V3 + [(12, 10), (13, 10), (14, 10)], # right 3V3 + [(12, 11), (13, 11), (14, 11), (11, 12), (11, 13), (11, 14)], # right + bottom GND + [(10, 12), (10, 13), (10, 14)] # bottom PWR +] + +def tinyproto(): + s = 'tp' + o = [defs(s, '#0a9950', '#046f39'), board_base(s, '#03361c')] + hs = set(proto_holes()) + ring = [] + for c, r in sorted(hs): + x, y = GRID_X[c], GRID_Y[r] + ring.append('' + % (x, y, x, y)) + o.append(''.join(ring)) + # bus outlines + the short leader from each header pad to its first hole + def cap(cells): + xs = [GRID_X[c] for c, _ in cells]; ys = [GRID_Y[r] for _, r in cells] + return (min(xs) - 3.6, min(ys) - 3.6, max(xs) + 3.6, max(ys) + 3.6) + def runs(cells): + """split an L-shaped bus into its axis-aligned oval segments""" + by_r, by_c = {}, {} + for c, r in cells: + by_r.setdefault(r, []).append(c); by_c.setdefault(c, []).append(r) + segs = [] + for r, cs in by_r.items(): + if len(cs) > 1: segs.append([(c, r) for c in sorted(cs)]) + for c, rs in by_c.items(): + if len(rs) > 1: segs.append([(c, r) for r in sorted(rs)]) + return segs + for bus in PROTO_BUSES: + segs = runs(bus) + for seg in segs: + x0, y0, x1, y1 = cap(seg) + o.append('' % (x0, y0, x1 - x0, y1 - y0, WHITE)) + if len(segs) == 2: # link the two arms of an L + a, b = segs + ax, ay = GRID_X[a[-1][0]], GRID_Y[a[-1][1]] + bx, by = GRID_X[b[0][0]], GRID_Y[b[0][1]] + o.append('' % (ax + 2.4, ay - 2.4, bx - 2.4, by + 2.4, WHITE)) + # single-hole rings for the signal pins + leaders from every pad + def leader(px, py, hx, hy): + o.append('' % (px, py, hx, hy, WHITE)) + busy = {cell for bs in PROTO_BUSES for cell in bs} + for i, y in enumerate(Y_SIDE): + r = ROW0 + i + # left pad -> its first hole; right pad -> its last hole + if (0, r) in busy: + leader(X_L + 3.8, y, GRID_X[2] + 3.6, y) + else: + o.append('' % (GRID_X[0], y, WHITE)) + leader(X_L + 3.8, y, GRID_X[0] - 3.6, y) + if (14, r) in busy: + leader(X_R - 3.8, y, GRID_X[12] - 3.6, y) + else: + o.append('' % (GRID_X[14], y, WHITE)) + leader(X_R - 3.8, y, GRID_X[14] + 3.6, y) + for i, x in enumerate(X_BOT): + c = COL0 + i + if (c, 14) in busy: + leader(x, Y_B - 3.8, x, GRID_Y[12] - 3.6) + else: + o.append('' % (x, GRID_Y[14], WHITE)) + leader(x, Y_B - 3.8, x, GRID_Y[14] + 3.6) + o.append(header_boxes(WHITE)) + o.append(header_pads('ring', ring='#cbd0d6', edge='#8d939b')) + o.append(header_labels(WHITE, 3.0, side_deg=-90, bottom_deg=0, gap=4.4, bgap=5.0, + bsize=2.6)) + o.append(txt(68.0, 9.5, 'tinyProto', 8.6, WHITE, style='italic', ls='0.7')) + o.append(credit(WHITE, size=2.2)) + o.append(tiny_logo(23.0, 103.0, 12.5, WHITE)) + return ''.join(o) + +# ── tinySniff ─────────────────────────────────────────────────────────────── +def tinysniff(): + s = 'ts' + o = [defs(s, '#efb812', '#c8900a'), board_base(s, '#7d5c06')] + # three silkscreen zones: H2 top-left, CH4 top-right, H2S bottom-centre + zones = [ + ('HYDROGEN', [(29.0, 14.0), (67.2, 14.0), (67.2, 68.5), (16.0, 68.5), (16.0, 27.0)], + 37.6, 38.4, 60.5), + ('METHANE', [(69.6, 14.0), (108.0, 14.0), (121.0, 27.0), (121.0, 68.5), (69.6, 68.5)], + 98.3, 37.7, 75.0), + ('HYDROGEN SULFIDE', [(45.0, 70.5), (121.0, 70.5), (121.0, 121.0), (45.0, 121.0)], + 69.2, 100.6, 92.0)] + for name, pts, _sx, _sy, lx in zones: + o.append('' % (poly(pts), WHITE)) + for name, pts, _sx, _sy, lx in zones: + ys = [p[1] for p in pts] + o.append(txt(lx, (min(ys) + max(ys)) / 2, name, 3.6, WHITE, deg=-90, ls='0.5')) + # sensor centres and the parts that ring them, measured off the photo + zoneparts = [ + (37.6, 38.4, 'R6', 'R5', ('C12', 'C9', 'C11', 'C10')), + (98.3, 37.7, 'R2', 'R1', ('C4', 'C1', 'C3', 'C2')), + (69.2, 100.6, 'R3', 'R4', ('C6', 'C7', 'C5', 'C8'))] + for (sx, sy, ra, rb, (cul, cll, cur, clr)) in zoneparts: + for dx, dy, nm, up in ((-14.5, -4.8, cul, True), (-14.3, 3.9, cll, False), + (15.1, -4.5, cur, True), (15.2, 4.0, clr, False)): + o.append(smd_cap(sx + dx, sy + dy, 90, 7.6, 5.0)) + o.append(txt(sx + dx, sy + dy + (-5.2 if up else 8.0), nm, 3.0, WHITE)) + o.append(smd_res(sx + 4.3, sy - 14.4, 0, '472', 7.4, 3.6)) + o.append(txt(sx + 4.3, sy - 18.6, ra, 3.0, WHITE)) + o.append(smd_res(sx - 6.6, sy + 15.6, 0, '104', 6.4, 3.4)) + o.append(txt(sx - 6.6, sy + 21.0, rb, 3.0, WHITE)) + o.append(sot23(sx + 4.0, sy + 16.0, 0, 6.4, 4.4)) + o.append(gas_sensor(s, sx, sy, 15.5)) + o.append(mount_hole(31.6, 75.5, 4.4, '#f6efdc')) + o.append(mount_hole(105.6, 75.5, 4.4, '#f6efdc')) + o.append(header_boxes(WHITE)) + o.append(header_pads('dome')) + o.append(header_labels(WHITE, 3.0, + over={n: '' for n in LEFT_NAMES + RIGHT_NAMES + BOT_NAMES} + | {'A2': 'A2-H2', 'A1': 'A1-H2S', 'A0': 'A0-CH4'})) + o.append(txt(CX, 10.5, 'tinySniff', 8.6, WHITE, style='italic', ls='0.6')) + o.append(txt(124.0, 66.0, '83070014A_Y1.09_250122', 2.7, WHITE, deg=-90, ls='0.3')) + o.append(tiny_logo(107.1, 94.9, 13.9, WHITE)) + o.append(credit(WHITE, size=2.2)) + return ''.join(o) + +# ── tinySpeak ─────────────────────────────────────────────────────────────── +SPEAK_SILK = {'D8': 'DIN', 'D9': 'BCLK', 'D10': 'LRC', 'D11': 'WS', + 'D12': 'DOUT', 'D13': 'SCLK', '3V3.2': '3V3', 'GND.2': 'GND', + 'GND.3': 'G'} + +def tinyspeak(): + s = 'tk' + o = [defs(s, '#e8382c', '#b81f18'), board_base(s, '#5d0b0a')] + o.append(smd_res(78.0, 8.5, 0, '105', 5.2, 2.8)) + o.append(txt(90.0, 9.0, 'R1 1M', 2.8, WHITE)) + o.append(soic(s, 104.0, 15.0, 0, 7.0, 5.0, 4, '')) + o.append(smd_cap(60.0, 7.5, 0, 4.6, 3.0)) + o.append(smd_cap(67.0, 7.5, 0, 4.6, 3.0)) + o.append(txt(30.0, 116.0, 'MIC', 2.8, WHITE)) + o.append('' + '') + o.append(header_boxes(WHITE)) + o.append(header_pads('ring', ring='#d3d8de', edge='#93989f')) + o.append(header_labels(WHITE, 3.0, over=SPEAK_SILK)) + o.append(credit(WHITE, 'JOHN LETTANG')) + # the speaker sits on top of everything, overhanging the top edge + o.append(audio_jack(28.0, 15.0, -45)) + o.append(speaker(s, 67.9, 66.4, 58.0)) + return ''.join(o) + +# ── tinyDisplay ───────────────────────────────────────────────────────────── +def tinydisplay(): + s = 'td' + lcd = ('' + '' + '') + o = [defs(s, '#0e7d6c', '#065247', lcd), board_base(s, '#032b25')] + for x in (46.0, 58.0, 79.0, 91.0): + o.append('' + '' % (x, x)) + o.append(fpc(68.4, 118.0, 30.0, 6.0)) + o.append(smd_cap(46.0, 118.5, 0, 4.6, 3.0)) + o.append(smd_cap(91.0, 118.5, 0, 4.6, 3.0)) + o.append(header_boxes(WHITE)) + o.append(header_pads('dome')) + o.append(header_labels(WHITE, 3.0, side_deg=-90, bottom_deg=0, gap=4.6, bgap=5.0)) + o.append(tiny_logo(25.1, 102.6, 9.5, WHITE)) + o.append(credit(WHITE)) + o.append(round_lcd(s, 67.6, 65.0, 49.0, 44.3)) + return ''.join(o) + + +BOARDS = { + 'tinycore': ('tinyCore', 'ESP32-S3', tinycore, 'var(--cyan)', '#1b2422', '#ffffff'), + 'tinyglow': ('tinyGlow', '3-channel LED driver', tinyglow, 'var(--pink)', '#1d4bad', '#ffffff'), + 'tinyproto': ('tinyProto', 'Prototyping / breakout', tinyproto, '#3fc47a', '#0a9950', '#ffffff'), + 'tinysniff': ('tinySniff', 'MEMS gas sensor array', tinysniff, '#e0a90d', '#efb812', '#2a2410'), + 'tinyspeak': ('tinySpeak', 'Microphone + speaker', tinyspeak, '#f0655c', '#e8382c', '#ffffff'), + 'tinydisplay': ('tinyDisplay', 'Round LCD module', tinydisplay, '#2fbfa6', '#0e7d6c', '#ffffff') +} + +def proto_pin_map(): + pins = stack_pin_map() + for c, r in proto_holes(): + pins[hole_name(c, r)] = [round(GRID_X[c] * PX, 2), round(GRID_Y[r] * PX, 2)] + return pins + +def proto_bus_names(): + return [[hole_name(c, r) for c, r in bus] for bus in PROTO_BUSES] + + + +if __name__ == '__main__': + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + dest = os.path.join(root, 'src', 'renderer', 'src', 'assets', 'tinyboards') + os.makedirs(dest, exist_ok=True) + for slug, (label, sub, fn, accent, mask, ink) in BOARDS.items(): + art = svg(fn(), label) + with open(os.path.join(dest, slug + '.svg'), 'w', encoding='utf-8') as fh: + fh.write(art) + print('%-13s %7d bytes -> %s.svg' % (slug, len(art), slug)) + print('board box: %.1f x %.1f px @ 96 DPI (%.2fin) | proto holes: %d' + % (W * 96 / 72, H * 96 / 72, IN, len(proto_holes()))) + if '--json' in sys.argv: + print(json.dumps({'stack': stack_pin_map(), 'proto': proto_pin_map(), + 'protoBuses': proto_bus_names()}, indent=1)) diff --git a/scripts/render-circuit-preview.mjs b/scripts/render-circuit-preview.mjs new file mode 100644 index 0000000..c70d69e --- /dev/null +++ b/scripts/render-circuit-preview.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * render-circuit-preview.mjs — render a circuit document to a standalone SVG, + * headlessly, without launching the app. + * + * node scripts/render-circuit-preview.mjs [--view sch|bb] [--out file.svg] [doc.json] + * + * Why: the schematic's look (symbol weights, fonts, symbol scale, label + * placement) is the kind of thing you can only judge by looking at it, and + * booting Electron for every tweak is slow. This bundles the real rendering + * path — parts registry → partsAdapter geometry → views/exportImage — the same + * one the in-app PNG/SVG export uses, so what comes out is what the app draws. + * + * With no document argument it renders a built-in sampler: one of every part in + * the library, laid out on a grid, which is the fastest way to spot a symbol + * that is the wrong size or inked at the wrong weight. + * + * Design-system CSS variables are substituted with their light-theme values + * (the app inlines them at export time via resolveCssVars, which needs a DOM). + */ +import { build } from 'esbuild' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') +const src = join(root, 'src', 'renderer', 'src') + +const argv = process.argv.slice(2) +const flag = (name, fallback) => { + const i = argv.indexOf(`--${name}`) + return i >= 0 ? argv[i + 1] : fallback +} +const view = flag('view', 'sch') +const out = resolve(flag('out', join(root, `circuit-preview-${view}.svg`))) +const docPath = argv.find((a) => a.endsWith('.json')) + +/** Vite's `?raw` suffix — same shim the circuit test runner uses. */ +const rawImports = { + name: 'raw-imports', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (args) => ({ + path: resolve(args.resolveDir, args.path.slice(0, -4)), + namespace: 'raw' + })) + b.onLoad({ filter: /.*/, namespace: 'raw' }, (args) => ({ + contents: readFileSync(args.path, 'utf8'), + loader: 'text' + })) + } +} + +const entry = ` +import { writeFileSync, readFileSync } from 'node:fs' +import { emptyDoc, parseCircuitFile, GRID_BB } from ${JSON.stringify(join(src, 'circuit/core/model'))} +import { PART_MANIFEST, ensureParts, getPart, registerPart } from ${JSON.stringify(join(src, 'lib/partsLibrary'))} +import { BREADBOARDS, generateBreadboard } from ${JSON.stringify(join(src, 'circuit/parts/breadboard'))} +import { SIM_SOURCES, generateSimSource } from ${JSON.stringify(join(src, 'circuit/parts/simParts'))} +import { SIM_PROBES, generateSimProbe } from ${JSON.stringify(join(src, 'circuit/parts/simProbes'))} +import { composeSceneSvg } from ${JSON.stringify(join(src, 'circuit/views/exportImage'))} +import { visualFor } from ${JSON.stringify(join(src, 'circuit/views/partsAdapter'))} + +const VIEW = ${JSON.stringify(view)} +const OUT = ${JSON.stringify(out)} +const DOC_PATH = ${JSON.stringify(docPath ?? '')} + +for (const s of BREADBOARDS) registerPart(generateBreadboard(s).def) +for (const s of SIM_SOURCES) registerPart(generateSimSource(s)) +for (const s of SIM_PROBES) registerPart(generateSimProbe(s)) + +let doc +if (DOC_PATH) { + doc = parseCircuitFile(readFileSync(DOC_PATH, 'utf8')).doc + await ensureParts(doc.parts.map((p) => p.type)) +} else { + // sampler: every part in the library on a grid, biggest row height per row + const types = PART_MANIFEST.map((m) => m.type).filter((t) => !t.startsWith('breadboard-')) + await ensureParts(types) + doc = emptyDoc() + // shelf packing on measured symbol sizes — a fixed cell grid buries the + // big symbols (a 10-pin display is 20x a diode) under their neighbours + const COLS = 6 + const GAP = GRID_BB * 6 + const cells = types + .filter((t) => getPart(t) && visualFor(t, VIEW)) + .map((t) => ({ type: t, vis: visualFor(t, VIEW) })) + const colW = Math.max(...cells.map((c) => c.vis.v.w)) + GAP + let x = GRID_BB * 4 + let y = GRID_BB * 4 + let rowH = 0 + let col = 0 + for (const c of cells) { + if (col === COLS) { + col = 0 + x = GRID_BB * 4 + y += rowH + GAP + rowH = 0 + } + doc.parts.push({ + id: c.type, + type: c.type, + [VIEW]: { + x: Math.round(x / GRID_BB) * GRID_BB, + y: Math.round(y / GRID_BB) * GRID_BB + } + }) + rowH = Math.max(rowH, c.vis.v.h) + x += colW + col++ + } +} + +const svg = composeSceneSvg(doc, VIEW === 'sch' ? '#ffffff' : '#f5f7f9', VIEW) +if (!svg) { + console.error('nothing to render in the', VIEW, 'view') + process.exit(1) +} + +// The app inlines design-system variables through getComputedStyle; there is +// no DOM here, so substitute the light-theme values. +const VARS = { + '--text-strong': '#181a1e', + '--text-body': '#24272c', + '--text-muted': '#79818c', + '--text-faint': '#a3abb5', + '--border-strong': '#181a1e', + '--border-default': '#cfd5dc', + '--surface-card': '#ffffff', + '--bg-sunken': '#edf0f3', + '--brand': '#f3cb00', + '--yellow': '#f3cb00', + '--cyan': '#22b8cf', + '--pink': '#f06595', + '--font-sans': "'Plus Jakarta Sans', system-ui, sans-serif", + '--font-mono': "'Fira Code', ui-monospace, monospace" +} +const resolved = svg.replace(/var\\((--[A-Za-z0-9_-]+)\\)/g, (all, name) => VARS[name] ?? all) +writeFileSync(OUT, resolved) +console.log('wrote', OUT, '(' + doc.parts.length + ' parts,', VIEW + ')') +` + +const tmp = mkdtempSync(join(tmpdir(), 'circuit-preview-')) +try { + const entryFile = join(tmp, 'entry.ts') + writeFileSync(entryFile, entry) + const bundle = join(tmp, 'entry.mjs') + await build({ + entryPoints: [entryFile], + outfile: bundle, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + external: ['node:*'], + logLevel: 'error', + plugins: [rawImports], + absWorkingDir: root + }) + const res = spawnSync(process.execPath, [bundle], { stdio: 'inherit' }) + process.exit(res.status ?? 1) +} finally { + rmSync(tmp, { recursive: true, force: true }) +} diff --git a/scripts/test-circuit.mjs b/scripts/test-circuit.mjs index 7f4c7df..f5306b1 100644 --- a/scripts/test-circuit.mjs +++ b/scripts/test-circuit.mjs @@ -2,9 +2,9 @@ /** * test-circuit.mjs — zero-extra-dependency test runner for the Circuit v2 core. * - * Bundles each src/renderer/src/circuit/__tests__/*.test.ts with esbuild - * (already a transitive dependency via vite) into a temp dir, then runs them - * with Node's built-in test runner (node:test). + * Bundles every src/renderer/src/**\/__tests__/*.test.ts with esbuild (already + * a transitive dependency via vite) into a temp dir, then runs them with + * Node's built-in test runner (node:test). * * npm run test:circuit * @@ -13,24 +13,52 @@ */ import { build } from 'esbuild' import { spawnSync } from 'node:child_process' -import { mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) -const testDir = resolve(__dirname, '..', 'src', 'renderer', 'src', 'circuit', '__tests__') -const files = readdirSync(testDir).filter((f) => f.endsWith('.test.ts')) +const srcRoot = resolve(__dirname, '..', 'src', 'renderer', 'src') + +// Every `__tests__/*.test.ts` under the renderer, not just the circuit's — so a +// new suite (lib/__tests__, components/__tests__) is picked up by dropping the +// file in, with no change here. `outbase` below keeps the directory structure +// in the temp build, so two suites may share a basename. +const files = readdirSync(srcRoot, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.test.ts') && basename(e.parentPath ?? e.path) === '__tests__') + .map((e) => join(e.parentPath ?? e.path, e.name)) + .sort() if (files.length === 0) { - console.error('No test files found in', testDir) + console.error('No *.test.ts files found under', srcRoot) process.exit(1) } +/** + * Vite's `?raw` suffix, which esbuild knows nothing about: the built-in + * tinyBoards import their breadboard SVGs that way (see partsLibrary.ts). + * Resolve the suffix off the path and hand esbuild the file as text. + */ +const rawImports = { + name: 'raw-imports', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (args) => ({ + path: resolve(args.resolveDir, args.path.slice(0, -4)), + namespace: 'raw' + })) + b.onLoad({ filter: /.*/, namespace: 'raw' }, (args) => ({ + contents: readFileSync(args.path, 'utf8'), + loader: 'text' + })) + } +} + const out = mkdtempSync(join(tmpdir(), 'circuit-tests-')) try { await build({ - entryPoints: files.map((f) => join(testDir, f)), + entryPoints: files, outdir: out, + outbase: srcRoot, bundle: true, format: 'esm', platform: 'node', @@ -38,11 +66,12 @@ try { sourcemap: 'inline', external: ['node:*'], outExtension: { '.js': '.mjs' }, - logLevel: 'error' + logLevel: 'error', + plugins: [rawImports] }) - const compiled = readdirSync(out) - .filter((f) => f.endsWith('.mjs')) - .map((f) => join(out, f)) + const compiled = readdirSync(out, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.mjs')) + .map((e) => join(e.parentPath ?? e.path, e.name)) const res = spawnSync(process.execPath, ['--test', ...compiled], { stdio: 'inherit' }) process.exit(res.status ?? 1) } finally { diff --git a/src/main/githubAuth.ts b/src/main/githubAuth.ts new file mode 100644 index 0000000..acb29bd --- /dev/null +++ b/src/main/githubAuth.ts @@ -0,0 +1,236 @@ +/** + * githubAuth — GitHub sign-in for the desktop app, via the OAuth **device flow**. + * + * Why device flow: it is the only browser-less flow that needs no client secret + * and no redirect URI, so nothing confidential ships inside the app. The user + * gets a short code, types it on github.com, and we poll until GitHub says yes. + * + * Why this lives in the MAIN process rather than the renderer: + * • GitHub's OAuth endpoints send no CORS headers, so a renderer `fetch` to + * them fails outright. This is not a workaround — it's the supported shape. + * • The resulting token is written with safeStorage (OS keychain / DPAPI), + * the same way the Anthropic key already is. It used to sit in plaintext in + * the renderer's localStorage, readable by any process running as the user. + * + * Why an OAuth App and not a GitHub App: a GitHub App *cannot create a + * repository in a user's personal account*, which is the whole point of "make + * it mine". Repo creation needs an OAuth scope. We ask for `public_repo` + * rather than `repo`, which is strictly narrower than the full-`repo` PAT the + * app used to ask people to paste. + */ + +import { safeStorage } from 'electron' +import { promises as fs } from 'fs' +import { join } from 'path' +import { app } from 'electron' + +const DEVICE_CODE_URL = 'https://github.com/login/device/code' +const TOKEN_URL = 'https://github.com/login/oauth/access_token' +const API = 'https://api.github.com' + +/** + * Public client id — safe to ship; it is not a secret. Override at build time + * with VITE_GITHUB_CLIENT_ID, or at run time with GITHUB_CLIENT_ID for dev. + */ +const CLIENT_ID = process.env.GITHUB_CLIENT_ID || process.env.VITE_GITHUB_CLIENT_ID || '' + +/** + * `public_repo` covers reading and writing public repositories and creating new + * ones — everything tinyStudio does. It deliberately cannot touch private + * repos. Widen to `repo` only if private copies become a requirement. + */ +const SCOPE = 'public_repo' + +export interface DeviceCode { + userCode: string + verificationUri: string + /** seconds until the code expires */ + expiresIn: number +} + +export interface GitHubUser { + login: string + name: string + avatarUrl: string +} + +interface StoredAuth { + tokenEnc?: string + tokenEncrypted?: boolean + user?: GitHubUser +} + +function authPath(): string { + return join(app.getPath('userData'), 'github-auth.json') +} + +async function readAuth(): Promise { + try { + return JSON.parse(await fs.readFile(authPath(), 'utf-8')) as StoredAuth + } catch { + return {} + } +} + +async function writeAuth(a: StoredAuth): Promise { + await fs.writeFile(authPath(), JSON.stringify(a, null, 2), 'utf-8') +} + +function encrypt(token: string): Pick { + if (safeStorage.isEncryptionAvailable()) { + return { tokenEnc: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true } + } + // Platforms without an OS keychain. Base64 so it isn't in plain sight, but + // this is NOT encryption — same caveat as the Anthropic key. + return { tokenEnc: Buffer.from(token, 'utf-8').toString('base64'), tokenEncrypted: false } +} + +export async function getToken(): Promise { + const a = await readAuth() + if (!a.tokenEnc) return null + const buf = Buffer.from(a.tokenEnc, 'base64') + try { + return a.tokenEncrypted ? safeStorage.decryptString(buf) : buf.toString('utf-8') + } catch { + return null + } +} + +export async function getAccount(): Promise<(GitHubUser & { token: string }) | null> { + const [token, a] = await Promise.all([getToken(), readAuth()]) + if (!token || !a.user) return null + return { ...a.user, token } +} + +export async function signOut(): Promise { + await writeAuth({}) +} + +export function isConfigured(): boolean { + return CLIENT_ID.length > 0 +} + +/** Abort flag for an in-flight poll, so the user can cancel the dialog. */ +let cancelled = false +export function cancelSignIn(): void { + cancelled = true +} + +/** Step 1: ask GitHub for a device + user code. */ +export async function startDeviceFlow(): Promise< + DeviceCode & { deviceCode: string; interval: number } +> { + if (!CLIENT_ID) { + throw new Error( + 'No GitHub client ID configured. Set VITE_GITHUB_CLIENT_ID (see docs/github-auth.md).' + ) + } + cancelled = false + const r = await fetch(DEVICE_CODE_URL, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }) + }) + if (!r.ok) throw new Error(`GitHub declined the sign-in request (${r.status})`) + const j = (await r.json()) as { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number + error?: string + error_description?: string + } + if (j.error) { + throw new Error( + j.error === 'device_flow_disabled' + ? 'Device flow is not enabled on the GitHub OAuth app. Turn it on in the app settings.' + : j.error_description || j.error + ) + } + return { + deviceCode: j.device_code, + userCode: j.user_code, + verificationUri: j.verification_uri, + expiresIn: j.expires_in, + interval: j.interval || 5 + } +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +/** + * Step 2: poll until the user finishes on github.com. Returns the signed-in + * account, or throws with a message worth showing. + */ +export async function pollForToken( + deviceCode: string, + intervalSeconds: number, + expiresIn: number +): Promise { + let interval = Math.max(intervalSeconds, 1) * 1000 + const deadline = Date.now() + expiresIn * 1000 + + for (;;) { + if (cancelled) throw new Error('Sign-in cancelled') + if (Date.now() > deadline) throw new Error('The code expired — start sign-in again.') + await sleep(interval) + if (cancelled) throw new Error('Sign-in cancelled') + + const r = await fetch(TOKEN_URL, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }) + }) + const j = (await r.json()) as { + access_token?: string + error?: string + error_description?: string + } + + if (j.access_token) { + const user = await fetchUser(j.access_token) + await writeAuth({ ...encrypt(j.access_token), user }) + return { ...user, token: j.access_token } + } + + switch (j.error) { + case 'authorization_pending': + break // the user hasn't finished yet — keep waiting + case 'slow_down': + // GitHub asks us to back off; its own +5s is the documented step. + interval += 5000 + break + case 'expired_token': + throw new Error('The code expired — start sign-in again.') + case 'access_denied': + throw new Error('Sign-in was cancelled on GitHub.') + default: + throw new Error(j.error_description || j.error || 'Sign-in failed') + } + } +} + +async function fetchUser(token: string): Promise { + const r = await fetch(`${API}/user`, { + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}` } + }) + if (!r.ok) throw new Error(`Could not read your GitHub profile (${r.status})`) + const u = (await r.json()) as { login: string; name: string | null; avatar_url: string } + return { login: u.login, name: u.name || u.login, avatarUrl: u.avatar_url } +} + +/** + * Adopt a Personal Access Token as if it came from the device flow. Kept as an + * escape hatch for enterprise / air-gapped setups, but it now stores the token + * through safeStorage like any other rather than in renderer localStorage. + */ +export async function signInWithToken(token: string): Promise { + const user = await fetchUser(token) + await writeAuth({ ...encrypt(token), user }) + return { ...user, token } +} diff --git a/src/main/index.ts b/src/main/index.ts index 66fb407..9b49e2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,15 @@ import path, { join } from 'path' import icon from '../../resources/icon.png?asset' import { AgentService, type AgentSendArgs } from './AgentService' import { ServiceManager } from './ServiceManager' +import { + cancelSignIn as ghCancelSignIn, + getAccount as ghGetAccount, + isConfigured as ghIsConfigured, + pollForToken as ghPollForToken, + signInWithToken as ghSignInWithToken, + signOut as ghSignOut, + startDeviceFlow as ghStartDeviceFlow +} from './githubAuth' import { clearApiKey, getStatus, setApiKey } from './settings' // Initialize ServiceManager @@ -126,6 +135,21 @@ app.whenReady().then(async () => { agentService.resolvePermission(id, allow) }) + // --- GitHub sign-in (OAuth device flow) --- + // These live in main because GitHub's OAuth endpoints send no CORS headers, so + // the renderer cannot call them — and because the token is then stored with + // safeStorage instead of sitting in renderer localStorage. + ipcMain.handle('github:configured', () => ghIsConfigured()) + ipcMain.handle('github:account', () => ghGetAccount()) + ipcMain.handle('github:start-device', () => ghStartDeviceFlow()) + // Long-running on purpose: resolves once the user finishes on github.com. + ipcMain.handle('github:poll', (_, deviceCode: string, interval: number, expiresIn: number) => + ghPollForToken(deviceCode, interval, expiresIn) + ) + ipcMain.handle('github:cancel-sign-in', () => ghCancelSignIn()) + ipcMain.handle('github:sign-out', () => ghSignOut()) + ipcMain.handle('github:sign-in-token', (_, token: string) => ghSignInWithToken(token)) + // Window control handlers ipcMain.on('window:minimize', (event) => { const window = BrowserWindow.fromWebContents(event.sender) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 79dc726..8ea4314 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -132,6 +132,35 @@ interface AppAPI { getExamplesDir: () => Promise } +/** A signed-in GitHub account. The token is held in memory by the renderer only. */ +export interface GitHubAccountInfo { + login: string + name: string + avatarUrl: string + token: string +} + +export interface DeviceFlowStart { + deviceCode: string + /** the short code the user types on github.com */ + userCode: string + verificationUri: string + expiresIn: number + interval: number +} + +interface GitHubAuthAPI { + /** False when no OAuth client ID was built in — the UI falls back to a token. */ + isConfigured: () => Promise + getAccount: () => Promise + startDeviceFlow: () => Promise + /** Resolves only once the user finishes authorising on github.com. */ + poll: (deviceCode: string, interval: number, expiresIn: number) => Promise + cancelSignIn: () => Promise + signOut: () => Promise + signInWithToken: (token: string) => Promise +} + interface ServiceAPI { /** Real ws:// URL of the spawned tinyService backend (port may differ from 3000). */ getUrl: () => Promise @@ -177,6 +206,7 @@ declare global { fs: FileSystemAPI arduino: ArduinoAPI settings: SettingsAPI + github: GitHubAuthAPI agent: AgentAPI app: AppAPI service: ServiceAPI diff --git a/src/preload/index.ts b/src/preload/index.ts index 4f501e6..9ab286d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -74,6 +74,38 @@ const api = { clearApiKey: (): Promise => ipcRenderer.invoke('settings:clear-key') }, + // GitHub sign-in. The device flow and the token both live in main; the + // renderer receives the token in memory only and never persists it. + github: { + isConfigured: (): Promise => ipcRenderer.invoke('github:configured'), + getAccount: (): Promise<{ + login: string + name: string + avatarUrl: string + token: string + } | null> => ipcRenderer.invoke('github:account'), + startDeviceFlow: (): Promise<{ + deviceCode: string + userCode: string + verificationUri: string + expiresIn: number + interval: number + }> => ipcRenderer.invoke('github:start-device'), + // Resolves only once the user has finished authorising on github.com. + poll: ( + deviceCode: string, + interval: number, + expiresIn: number + ): Promise<{ login: string; name: string; avatarUrl: string; token: string }> => + ipcRenderer.invoke('github:poll', deviceCode, interval, expiresIn), + cancelSignIn: (): Promise => ipcRenderer.invoke('github:cancel-sign-in'), + signOut: (): Promise => ipcRenderer.invoke('github:sign-out'), + signInWithToken: ( + token: string + ): Promise<{ login: string; name: string; avatarUrl: string; token: string }> => + ipcRenderer.invoke('github:sign-in-token', token) + }, + // Studio AI agent. send() returns immediately; results stream over onEvent(). agent: { send: (args: { diff --git a/src/renderer/index.html b/src/renderer/index.html index d138fef..d30cd47 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 9620b94..07c4d6e 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -247,6 +247,51 @@ --sidebar-primary: var(--brand); --sidebar-primary-foreground: var(--brand-contrast); --sidebar-accent: var(--bg-sunken); + + /* ── BOARD INKS — one ramp per tinyBoard, keyed to its real solder mask ──── + These are the colours the Examples tab paints board tags with, so a tag + matches the PCB in your hand. Base values are the same masks + lib/partsLibrary paints the Circuit view with; *-soft is the chip fill and + *-on is accessible text on that fill. (LIGHT values; dark brightens.) */ + + /* tinyCore — ESP32-S3, black mask */ + --board-tinycore: #2c3a37; + --board-tinycore-soft: #e6eae9; + --board-tinycore-on: #1b2422; + + /* tinyGlow — 3-channel LED driver, blue mask */ + --board-tinyglow: #1d4bad; + --board-tinyglow-soft: #e0e7fa; + --board-tinyglow-on: #16357d; + + /* tinyProto — prototyping / breakout, green mask */ + --board-tinyproto: #0a9950; + --board-tinyproto-soft: #daf2e5; + --board-tinyproto-on: #076e3a; + + /* tinySniff — MEMS gas sensor array, yellow mask. Deepened from the literal + mask (#efb812): a 3px rail of the true gold scores 1.6:1 against its own + pale-yellow fill and effectively disappears. The fill still carries the + yellow identity; the rail just has to be visible. */ + --board-tinysniff: #a87a09; + --board-tinysniff-soft: #fbf0cb; + --board-tinysniff-on: #6f5405; + + /* tinySpeak — microphone + speaker, red mask */ + --board-tinyspeak: #e8382c; + --board-tinyspeak-soft: #fce1de; + --board-tinyspeak-on: #a3221a; + + /* tinyDisplay — round LCD module, teal mask */ + --board-tinydisplay: #0e7d6c; + --board-tinydisplay-soft: #d7f0ec; + --board-tinydisplay-on: #0a5a4e; + + /* Qwiic — not a tinyBoard, but plug-in hardware, so it gets a ramp too */ + --board-qwiic: #8a4fc4; + --board-qwiic-soft: #efe4fb; + --board-qwiic-on: #5a2a82; + --sidebar-accent-foreground: var(--text-strong); --sidebar-border: var(--border-default); --sidebar-ring: var(--ring-ink); @@ -311,6 +356,37 @@ (shadcn vars, legacy aliases) flips automatically. ────────────────────────── */ .dark, :root[data-theme='dark'] { + /* brightened board inks — the mask hue survives, lightened enough to read + on charcoal. tinyCore's black mask becomes a light graphite here, since a + black chip on a dark panel would vanish. */ + --board-tinycore: #9aa8a5; + --board-tinycore-soft: #23292b; + --board-tinycore-on: #d3dcda; + + --board-tinyglow: #5b86e8; + --board-tinyglow-soft: #1a2547; + --board-tinyglow-on: #c6d5fa; + + --board-tinyproto: #35c47e; + --board-tinyproto-soft: #11291e; + --board-tinyproto-on: #b6edd0; + + --board-tinysniff: #f0c542; + --board-tinysniff-soft: #2f2810; + --board-tinysniff-on: #f6e3a6; + + --board-tinyspeak: #f2695e; + --board-tinyspeak-soft: #3a201d; + --board-tinyspeak-on: #f9c9c4; + + --board-tinydisplay: #35bda7; + --board-tinydisplay-soft: #0f2b27; + --board-tinydisplay-on: #b2e9df; + + --board-qwiic: #b184e0; + --board-qwiic-soft: #2a1d3a; + --board-qwiic-on: #ddc9f4; + /* brightened inks */ --red-soft: #3a2421; --red: #f26d63; diff --git a/src/renderer/src/assets/ds-components.css b/src/renderer/src/assets/ds-components.css index a6e7c2c..b5f6d1d 100644 --- a/src/renderer/src/assets/ds-components.css +++ b/src/renderer/src/assets/ds-components.css @@ -319,20 +319,37 @@ /* ── TAG / CHIP — libraries, board capabilities, filters ─────────────────── */ .ts-tag { + /* Three variables drive every chip. The defaults below are the neutral + (topic) chip; a board chip re-points them at its solder-mask ramp via an + inline style from lib/exampleTags#tagChipStyle, and everything else — + hover, selected, the remove affordance — follows automatically. */ + --tag-soft: var(--bg-sunken); + --tag-line: var(--border-soft); + --tag-ink: var(--text-body); + display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: var(--text-xs); font-weight: var(--fw-medium); - color: var(--text-body); - background: var(--bg-sunken); + color: var(--tag-ink); + background: var(--tag-soft); border: 1.5px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 8px; line-height: 1.2; white-space: nowrap; } + +/* Board / expansion chip — colour-matched to the physical PCB. The mask + colour shows as a solid rail down the leading edge (a strong, consistent + read even at a glance) with the tint carrying the fill behind it. */ +.ts-tag--board { + border-color: color-mix(in srgb, var(--tag-line) 34%, transparent); + border-left: 3px solid var(--tag-line); + padding-left: 7px; +} .ts-tag--clickable { cursor: pointer; transition: @@ -340,21 +357,36 @@ border-color var(--dur-fast); } .ts-tag--clickable:hover { - background: var(--surface-card); - border-color: var(--border-interactive); -} -.ts-tag--selected { - background: var(--brand-soft); - border-color: var(--brand); - color: var(--brand-on); + border-color: color-mix(in srgb, var(--tag-line) 65%, transparent); + background: color-mix(in srgb, var(--tag-soft) 70%, var(--surface-card)); +} +/* Selected filter chip — the tag's own colour goes solid so an active board + filter is unmistakable, rather than every selection turning brand blue. */ +.ts-tag--selected, +.ts-tag--clickable.ts-tag--selected:hover { + background: var(--tag-soft); + border-color: var(--tag-line); + color: var(--tag-ink); + box-shadow: inset 0 0 0 1px var(--tag-line); +} +.ts-tag--selected:not(.ts-tag--board) { + --tag-soft: var(--brand-soft); + --tag-line: var(--brand); + --tag-ink: var(--brand-on); } .ts-tag__ico { display: inline-flex; line-height: 0; - color: var(--text-muted); + color: currentColor; + opacity: 0.75; } .ts-tag--selected .ts-tag__ico { - color: var(--brand-on); + opacity: 1; +} +/* Count badge on a filter chip. */ +.ts-tag__count { + font-variant-numeric: tabular-nums; + opacity: 0.6; } .ts-tag__x { display: inline-flex; @@ -732,3 +764,92 @@ .notif__link:hover { filter: brightness(1.1); } + +/* ── toasts (sonner) ──────────────────────────────────────────────────────── + A toast and a bell entry are the same notification shown two ways (see + lib/notify.ts, which writes to both), so they share one look. sonner ships + its own palette resolved from `data-sonner-theme`; we repaint every part + from the design tokens instead, mirroring the .notif__* rules above. + + Specificity note: sonner injects its stylesheet at runtime, so source order + can't be relied on. Every selector here pairs [data-sonner-toast] with the + .ts-toast class (set via toastOptions.className in main.tsx) to outrank + sonner's own rules without !important. */ +[data-sonner-toast].ts-toast { + width: 320px; + padding: 12px 14px; + gap: 9px; + background: var(--surface-overlay); + border: 1.5px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft-lg); + font-family: var(--font-sans); + color: var(--text-body); +} + +/* Tone marker: the bell uses a coloured dot rather than a glyph, so drop + sonner's icon and reuse .notif__dot's geometry. */ +[data-sonner-toast].ts-toast [data-icon] { + flex: none; + width: 8px; + height: 8px; + margin: 5px 0 0; + border-radius: 50%; + background: var(--blue); +} +[data-sonner-toast].ts-toast [data-icon] > * { + display: none; +} +[data-sonner-toast].ts-toast[data-type='success'] [data-icon] { + background: var(--green); +} +[data-sonner-toast].ts-toast[data-type='error'] [data-icon] { + background: var(--red); +} +[data-sonner-toast].ts-toast[data-type='warning'] [data-icon] { + background: var(--yellow); +} + +[data-sonner-toast].ts-toast [data-title] { + font-family: var(--font-sans); + font-size: 13px; + font-weight: 700; + color: var(--text-strong); +} +[data-sonner-toast].ts-toast [data-description] { + font-family: var(--font-sans); + font-size: 12.5px; + line-height: 1.45; + color: var(--text-muted); + margin-top: 2px; + white-space: pre-wrap; +} + +/* Action button — matches .notif__link. */ +[data-sonner-toast].ts-toast [data-button] { + flex: none; + align-self: center; + height: auto; + padding: 6px 12px; + border: none; + border-radius: var(--radius-sm); + background: var(--brand); + color: var(--text-on-brand, #fff); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; +} +[data-sonner-toast].ts-toast [data-button]:hover { + filter: brightness(1.1); + background: var(--brand); +} + +[data-sonner-toast].ts-toast [data-close-button] { + background: var(--surface-overlay); + border: 1.5px solid var(--border-default); + color: var(--text-muted); +} +[data-sonner-toast].ts-toast [data-close-button]:hover { + background: var(--bg-sunken); + color: var(--text-strong); +} diff --git a/src/renderer/src/assets/tinyboards/tinycore.svg b/src/renderer/src/assets/tinyboards/tinycore.svg new file mode 100644 index 0000000..cfed834 --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinycore.svg @@ -0,0 +1 @@ +tinyCoreESPRESSIFESP32-S3-MINI-1CEFCC ID: 2AC7Z-ESP32S3MINI1IC: 21098-ESP32S3MINI1CMIIT ID: 2022DP6085BOOTRSTGCT103103103103472PWRCRGBOOTSIGtiny5VGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDANALOGDIGITALSERIAL \ No newline at end of file diff --git a/src/renderer/src/assets/tinyboards/tinydisplay.svg b/src/renderer/src/assets/tinyboards/tinydisplay.svg new file mode 100644 index 0000000..e548f4c --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinydisplay.svg @@ -0,0 +1 @@ +tinyDisplayGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDtinyBOARD DESIGN BY:GEOFF McINTYRE2:05 \ No newline at end of file diff --git a/src/renderer/src/assets/tinyboards/tinyglow.svg b/src/renderer/src/assets/tinyboards/tinyglow.svg new file mode 100644 index 0000000..50f155b --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinyglow.svg @@ -0,0 +1 @@ +tinyGlowCH3CH2CH14A MAX4A MAX4A MAX4R71034724R71034724R7103472ONS3OFF103ONS2OFF103ONS1OFF103+VINGND5V - 24VCH1CH2CH3tiny \ No newline at end of file diff --git a/src/renderer/src/assets/tinyboards/tinyproto.svg b/src/renderer/src/assets/tinyboards/tinyproto.svg new file mode 100644 index 0000000..a1b4950 --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinyproto.svg @@ -0,0 +1 @@ +tinyProtoGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDtinyProtoBOARD DESIGN BY:GEOFF McINTYREtiny \ No newline at end of file diff --git a/src/renderer/src/assets/tinyboards/tinysniff.svg b/src/renderer/src/assets/tinyboards/tinysniff.svg new file mode 100644 index 0000000..905d856 --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinysniff.svg @@ -0,0 +1 @@ +tinySniffHYDROGENMETHANEHYDROGEN SULFIDEC12C9C11C10472R6104R5C4C1C3C2472R2104R1C6C7C5C8472R3104R4A2-H2A1-H2SA0-CH4tinySniff83070014A_Y1.09_250122tinyBOARD DESIGN BY:GEOFF McINTYRE \ No newline at end of file diff --git a/src/renderer/src/assets/tinyboards/tinyspeak.svg b/src/renderer/src/assets/tinyboards/tinyspeak.svg new file mode 100644 index 0000000..6ddc68f --- /dev/null +++ b/src/renderer/src/assets/tinyboards/tinyspeak.svg @@ -0,0 +1 @@ +tinySpeak105R1 1MMICGND3V3A5A4A3A2A1A0DINBCLKLRCWSDOUTSCLK3V3GNDSCKMOMIRXTXSDASCLPWRGBOARD DESIGN BY:JOHN LETTANG \ No newline at end of file diff --git a/src/renderer/src/circuit/__tests__/_to_delete/demo-rectifier.json b/src/renderer/src/circuit/__tests__/_to_delete/demo-rectifier.json new file mode 100644 index 0000000..eb075f6 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/demo-rectifier.json @@ -0,0 +1,25 @@ +{ + "format": "tinystudio-circuit", + "version": 2, + "parts": [ + { "id": "V1", "type": "sim-vsin", "attrs": { "amplitude": "5", "frequency": "1k" }, "sch": { "x": 96, "y": 115.2 } }, + { "id": "R_SRC", "type": "resistor", "attrs": { "resistance": "10" }, "sch": { "x": 192, "y": 105.6 } }, + { "id": "D1", "type": "diode-1n4001-300mil", "sch": { "x": 297.6, "y": 105.6 } }, + { "id": "C1", "type": "capacitor-electrolytic-medium", "attrs": { "capacitance": "10u" }, "sch": { "x": 384, "y": 144, "rotate": 90 } }, + { "id": "R_LOAD", "type": "resistor", "attrs": { "resistance": "100" }, "sch": { "x": 480, "y": 144, "rotate": 90 } } + ], + "wires": [ + { "id": "w1", "from": "V1:+", "to": "R_SRC:Pin 0", "view": "sch" }, + { "id": "w2", "from": "R_SRC:Pin 1", "to": "D1:anode", "view": "sch" }, + { "id": "w3", "from": "D1:cathode", "to": "C1:+", "view": "sch" }, + { "id": "w4", "from": "C1:+", "to": "R_LOAD:Pin 0", "view": "sch" }, + { "id": "w5", "from": "V1:-", "to": "nlg1:1", "view": "sch" }, + { "id": "w6", "from": "C1:-", "to": "nlg2:1", "view": "sch" }, + { "id": "w7", "from": "R_LOAD:Pin 1", "to": "nlg3:1", "view": "sch" } + ], + "netLabels": [ + { "id": "nlg1", "name": "GND", "kind": "ground", "sch": { "x": 105.6, "y": 249.6 } }, + { "id": "nlg2", "name": "GND", "kind": "ground", "sch": { "x": 393.6, "y": 249.6 } }, + { "id": "nlg3", "name": "GND", "kind": "ground", "sch": { "x": 489.6, "y": 249.6 } } + ] +} diff --git a/src/renderer/src/circuit/__tests__/_to_delete/demo.svg b/src/renderer/src/circuit/__tests__/_to_delete/demo.svg new file mode 100644 index 0000000..aca412c --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/demo.svg @@ -0,0 +1 @@ ++V15 V 1 kHzR_SRC10 ΩD1+C110 µFR_LOAD100 ΩStudiotiny \ No newline at end of file diff --git a/src/renderer/src/circuit/__tests__/_to_delete/preview-bb.svg b/src/renderer/src/circuit/__tests__/_to_delete/preview-bb.svg new file mode 100644 index 0000000..e156c31 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/preview-bb.svg @@ -0,0 +1 @@ +tinyCoreESPRESSIFESP32-S3-MINI-1CEFCC ID: 2AC7Z-ESP32S3MINI1IC: 21098-ESP32S3MINI1CMIIT ID: 2022DP6085BOOTRSTGCT103103103103472PWRCRGBOOTSIGtiny5VGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDANALOGDIGITALSERIALtinycoretinyGlowCH3CH2CH14A MAX4A MAX4A MAX4R71034724R71034724R7103472ONS3OFF103ONS2OFF103ONS1OFF103+VINGND5V - 24VCH1CH2CH3tinytinyglowtinyProtoGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDtinyProtoBOARD DESIGN BY:GEOFF McINTYREtinytinyprototinySniffHYDROGENMETHANEHYDROGEN SULFIDEC12C9C11C10472R6104R5C4C1C3C2472R2104R1C6C7C5C8472R3104R4A2-H2A1-H2SA0-CH4tinySniff83070014A_Y1.09_250122tinyBOARD DESIGN BY:GEOFF McINTYREtinysnifftinySpeak105R1 1MMICGND3V3A5A4A3A2A1A0DINBCLKLRCWSDOUTSCLK3V3GNDSCKMOMIRXTXSDASCLPWRGBOARD DESIGN BY:JOHN LETTANGtinyspeaktinyDisplayGND3V3A5A4A3A2A1A089101112133V3GNDSCKMOMIRXTXSDASCLPWRGNDtinyBOARD DESIGN BY:GEOFF McINTYRE2:05tinydisplaySPSTswitch-spstsmd-inductor-0805battery-aatransistor-signal-npn-to92-ebctransistor-signal-pnp-to92-ebccapacitor-ceramic-100milcapacitor-ceramic-200milcapacitor-electrolytic-medium7segment-100-catdiode-1n4001-300mildiode-zener-0-5w-3-6v-300milFritzing breadboard generated by brd2svglayer 21element:C1package:0603-CAPelement:C2package:0603-CAPelement:JP1package:1X03element:R1package:0603-RESelement:R2package:0603-RESelement:U$1package:SFE-LOGO-FLAMEelement:U1package:TSOP8523ir-receiver-v14led-generic-3mmled-generic-5mmFritzing breadboard generated by brd2svglayer 21text:--text:++element:U$1package:PETAL-NOHOLE-2SIDEelement:U$2package:LOGO-LILYPADelement:U$4package:PETAL-NOHOLE-2SIDEelement:U$6package:PETAL-NOHOLE-2SIDEelement:U$7package:PETAL-NOHOLE-2SIDEelement:U$8package:PETAL-SMALL-2SIDEelement:U$9package:PETAL-SMALL-2SIDElayer 1element:B1package:BUZZER-CCVelement:U$1package:PETAL-NOHOLE-2SIDEpolygonelement:U$2package:LOGO-LILYPADelement:U$4package:PETAL-NOHOLE-2SIDEpolygonelement:U$6package:PETAL-NOHOLE-2SIDEpolygonelement:U$7package:PETAL-NOHOLE-2SIDEpolygonelement:U$8package:PETAL-SMALL-2SIDEpolygonelement:U$9package:PETAL-SMALL-2SIDEpolygonelement:U$1CCV084B16 G4510buzzer-v15ldr-photocell-300mil-v5piezo-sensorpotentiometer-trimmer-6mm-5potentiometer-rotary-16mm-5reedswitch-500milresistorservoFritzing breadboard generated by brd2svgsparkfun-sensors-mic-electret-smdsparkfun-passives-fuse-x20mmsparkfun-discretesemi-mosfet-nchannel-pthpushbuttonsparkfun-electromechanical_relay-g5q_breadboard.svgVDEte-relaythermistor-300milvoltage-regulator-7805+sim-vdc+sim-vsin+sim-idcsim-probe-v+ −sim-probe-vdiffAsim-probe-itinyStudio \ No newline at end of file diff --git a/src/renderer/src/circuit/__tests__/_to_delete/preview-sch.svg b/src/renderer/src/circuit/__tests__/_to_delete/preview-sch.svg new file mode 100644 index 0000000..973c7b1 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/preview-sch.svg @@ -0,0 +1 @@ +tinyCoreGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3tinycoretinyGlowGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3tinyglowtinyProtoGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3A.5A.6A.7A.8A.9A.10A.11A.12B.5B.6B.7B.8B.9B.10B.11B.12C.5C.6C.7C.8C.9C.10C.11C.12D.1D.2D.3D.4D.5D.6D.7D.8D.9D.10D.11D.12D.13D.14D.15E.1E.2E.3E.4E.5E.6E.7E.8E.9E.10E.11E.12E.13E.14E.15F.1F.2F.3F.4F.5F.6F.7F.8F.9F.10F.11F.12F.13F.14F.15G.1G.2G.3G.4G.5G.6G.7G.8G.9G.10G.11G.12G.13G.14G.15H.1H.2H.3H.4H.5H.6H.7H.8H.9H.10H.11H.12H.13H.14H.15I.1I.2I.3I.4I.5I.6I.7I.8I.9I.10I.11I.12I.13I.14I.15J.1J.2J.3J.4J.5J.6J.7J.8J.9J.10J.11J.12J.13J.14J.15K.1K.2K.3K.4K.5K.6K.7K.8K.9K.10K.11K.12K.13K.14K.15L.1L.2L.3L.4L.5L.6L.7L.8L.9L.10L.11L.12L.13L.14L.15M.5M.6M.7M.8M.9M.10M.11M.12N.5N.6N.7N.8N.9N.10N.11N.12O.5O.6O.7O.8O.9O.10O.11O.12tinyprototinySniffGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3tinysnifftinySpeakGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3tinyspeaktinyDisplayGND3V3A5A4A3A2A1A0D8D9D10D11D12D133V3.2GND.2SCKMOMIRXTXSDASCLPWRGND.3tinydisplayswitch-spstsmd-inductor-080510 µH+battery-aa3 Vtransistor-signal-npn-to92-ebctransistor-signal-pnp-to92-ebccapacitor-ceramic-100mil100 nFcapacitor-ceramic-200mil100 nF+capacitor-electrolytic-medium10 µF7-Segment Display (Common Cathode)Segment ESegment Dcommon cathodeSegment CSegment DPSegment BSegment Acommon cathode.2Segment FSegment G7segment-100-catdiode-1n4001-300mildiode-zener-0-5w-3-6v-300milIR ReceiverGNDOUTVCCir-receiver-v14led-generic-3mmled-generic-5mmbuzzer-v15ldr-photocell-300mil-v510 kΩpiezo-sensorpotentiometer-trimmer-6mm-510 kΩpotentiometer-rotary-16mm-510 kΩreedswitch-500milresistor220 ΩServo Motorgndvccpulseservosparkfun-sensors-mic-electret-smdsparkfun-passives-fuse-x20mmsparkfun-discretesemi-mosfet-nchannel-pthpushbuttonte-relaythermistor-300mil10 kΩ5 V Linear Regulator (7805)input voltageGNDoutput voltagevoltage-regulator-7805+sim-vdc5 V+sim-vsin1 V 1 kHz+sim-idc1 mAsim-probe-v+ −sim-probe-vdiffAsim-probe-itinyStudio \ No newline at end of file diff --git a/src/renderer/src/circuit/__tests__/_to_delete/spot.json b/src/renderer/src/circuit/__tests__/_to_delete/spot.json new file mode 100644 index 0000000..439f50a --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/spot.json @@ -0,0 +1,15 @@ +{ + "format": "tinystudio-circuit", + "version": 2, + "parts": [ + { "id": "LS1", "type": "buzzer-v15", "sch": { "x": 48, "y": 48 } }, + { "id": "Q1", "type": "sparkfun-discretesemi-mosfet-nchannel-pth", "sch": { "x": 192, "y": 48 } }, + { "id": "K1", "type": "te-relay", "sch": { "x": 336, "y": 48 } }, + { "id": "MK1", "type": "sparkfun-sensors-mic-electret-smd", "sch": { "x": 480, "y": 48 } }, + { "id": "BT1", "type": "battery-aa", "sch": { "x": 48, "y": 192 } }, + { "id": "Q2", "type": "transistor-signal-npn-to92-ebc", "sch": { "x": 192, "y": 192 } }, + { "id": "SW1", "type": "pushbutton", "sch": { "x": 336, "y": 192 } }, + { "id": "U1", "type": "voltage-regulator-7805", "sch": { "x": 480, "y": 192 } } + ], + "wires": [] +} diff --git a/src/renderer/src/circuit/__tests__/_to_delete/spot.svg b/src/renderer/src/circuit/__tests__/_to_delete/spot.svg new file mode 100644 index 0000000..2c4cc14 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/spot.svg @@ -0,0 +1 @@ +LS1Q1K1MK1+BT13 VQ2SW15 V Linear Regulator (7805)input voltageGNDoutput voltageU1tinyStudio \ No newline at end of file diff --git a/src/renderer/src/circuit/__tests__/_to_delete/zz-dump.test.ts b/src/renderer/src/circuit/__tests__/_to_delete/zz-dump.test.ts new file mode 100644 index 0000000..1e83c46 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/_to_delete/zz-dump.test.ts @@ -0,0 +1,5 @@ +/** + * Scratch file left behind by a tooling session — safe to delete this whole + * `_to_delete/` folder. Emptied so it cannot affect typecheck or the test run. + */ +export {} diff --git a/src/renderer/src/circuit/__tests__/breadboard.test.ts b/src/renderer/src/circuit/__tests__/breadboard.test.ts index 949b416..0dbcd86 100644 --- a/src/renderer/src/circuit/__tests__/breadboard.test.ts +++ b/src/renderer/src/circuit/__tests__/breadboard.test.ts @@ -11,8 +11,14 @@ test('every hole sits on a GRID_BB multiple (snap-by-first-pin keeps holes on gr const { def } = generateBreadboard(spec) const pins = def.views.breadboard!.pins for (const [name, [x, y]] of Object.entries(pins)) { - assert.ok(Math.abs(x / GRID_BB - Math.round(x / GRID_BB)) < 1e-9, `${spec.type} ${name} x=${x}`) - assert.ok(Math.abs(y / GRID_BB - Math.round(y / GRID_BB)) < 1e-9, `${spec.type} ${name} y=${y}`) + assert.ok( + Math.abs(x / GRID_BB - Math.round(x / GRID_BB)) < 1e-9, + `${spec.type} ${name} x=${x}` + ) + assert.ok( + Math.abs(y / GRID_BB - Math.round(y / GRID_BB)) < 1e-9, + `${spec.type} ${name} y=${y}` + ) } } }) diff --git a/src/renderer/src/circuit/__tests__/clipboard.test.ts b/src/renderer/src/circuit/__tests__/clipboard.test.ts index 48ae5d8..aa6cd2c 100644 --- a/src/renderer/src/circuit/__tests__/clipboard.test.ts +++ b/src/renderer/src/circuit/__tests__/clipboard.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { buildClipboard, materializePaste, parseClipboard, CLIPBOARD_FORMAT } from '../core/clipboard' +import { + buildClipboard, + materializePaste, + parseClipboard, + CLIPBOARD_FORMAT +} from '../core/clipboard' import { emptyDoc, type CircuitDoc, type JunctionEnd } from '../core/model' function fixture(): CircuitDoc { @@ -26,7 +31,10 @@ test('buildClipboard keeps only wires fully inside the selection', () => { assert.equal(payload.parts.length, 2) // w1 is between R1 and R2 → kept; w2 leaves the selection → dropped; // w3 is a junction on w1 but its pin end (LED1) is outside → dropped. - assert.deepEqual(payload.wires.map((w) => w.id), ['w1']) + assert.deepEqual( + payload.wires.map((w) => w.id), + ['w1'] + ) }) test('buildClipboard keeps junction riders when the host and both parts are in', () => { @@ -74,7 +82,9 @@ test('materializePaste rewrites junction hosts to the new wire ids', () => { const payload = buildClipboard(doc, ['R1', 'R2', 'LED1'])! const { wires } = materializePaste(doc, payload, { x: 0, y: 0 }) const oldW1 = payload.wires.find((w) => w.id === 'w1')! - const newW1 = wires.find((w) => w.from.toString().startsWith('R') && (w.to as string).toString().startsWith('R'))! + const newW1 = wires.find( + (w) => w.from.toString().startsWith('R') && (w.to as string).toString().startsWith('R') + )! const rider = wires.find((w) => typeof w.to === 'object')! assert.notEqual(newW1.id, oldW1.id) assert.equal((rider.to as JunctionEnd).wire, newW1.id) diff --git a/src/renderer/src/circuit/__tests__/exportSch.test.ts b/src/renderer/src/circuit/__tests__/exportSch.test.ts index 4eaa160..ed062b8 100644 --- a/src/renderer/src/circuit/__tests__/exportSch.test.ts +++ b/src/renderer/src/circuit/__tests__/exportSch.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { emptyDoc, type CircuitDoc } from '../core/model' import { journeyFromPoints } from '../core/routing' +import { WIRE_STROKE } from '../parts/style' import { composeSceneSvg } from '../views/exportImage' test('composeSceneSvg (sch) renders ink wires + net labels with balanced svg tags', () => { @@ -28,7 +29,9 @@ test('composeSceneSvg (sch) renders ink wires + net labels with balanced svg tag ] const svg = composeSceneSvg(doc, '#ffffff', 'sch') assert.ok(svg, 'expected a scene svg') - assert.ok(svg!.includes('stroke-width="1"'), 'schematic ink wire present') + // one shared weight for schematic ink (parts/style.ts) — a wire must draw + // at the same width as the pin lead it lands on + assert.ok(svg!.includes(`stroke-width="${WIRE_STROKE}"`), 'schematic ink wire present') assert.ok(svg!.includes('Studio'), 'watermark present') const opens = (svg!.match(//g) || []).length diff --git a/src/renderer/src/circuit/__tests__/fzpz.test.ts b/src/renderer/src/circuit/__tests__/fzpz.test.ts index 576e0b1..7258127 100644 --- a/src/renderer/src/circuit/__tests__/fzpz.test.ts +++ b/src/renderer/src/circuit/__tests__/fzpz.test.ts @@ -26,7 +26,12 @@ function buildZip(files: { name: string; data: Uint8Array; store?: boolean }[]): const enc = new TextEncoder() const u16 = (n: number): number[] => [n & 0xff, (n >> 8) & 0xff] - const u32 = (n: number): number[] => [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff] + const u32 = (n: number): number[] => [ + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >> 24) & 0xff + ] for (const f of files) { const name = enc.encode(f.name) @@ -34,15 +39,38 @@ function buildZip(files: { name: string; data: Uint8Array; store?: boolean }[]): const comp = f.store ? f.data : new Uint8Array(deflateRawSync(f.data)) const crc = crc32(f.data) const local = new Uint8Array([ - ...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(method), ...u16(0), ...u16(0), - ...u32(crc), ...u32(comp.length), ...u32(f.data.length), ...u16(name.length), ...u16(0) + ...u32(0x04034b50), + ...u16(20), + ...u16(0), + ...u16(method), + ...u16(0), + ...u16(0), + ...u32(crc), + ...u32(comp.length), + ...u32(f.data.length), + ...u16(name.length), + ...u16(0) ]) chunks.push(local, name, comp) central.push( new Uint8Array([ - ...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(method), ...u16(0), ...u16(0), - ...u32(crc), ...u32(comp.length), ...u32(f.data.length), ...u16(name.length), ...u16(0), - ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(offset) + ...u32(0x02014b50), + ...u16(20), + ...u16(20), + ...u16(0), + ...u16(method), + ...u16(0), + ...u16(0), + ...u32(crc), + ...u32(comp.length), + ...u32(f.data.length), + ...u16(name.length), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ...u32(0), + ...u32(offset) ]), name ) @@ -52,8 +80,14 @@ function buildZip(files: { name: string; data: Uint8Array; store?: boolean }[]): let cenLen = 0 for (const c of central) cenLen += c.length const eocd = new Uint8Array([ - ...u32(0x06054b50), ...u16(0), ...u16(0), ...u16(files.length), ...u16(files.length), - ...u32(cenLen), ...u32(cenStart), ...u16(0) + ...u32(0x06054b50), + ...u16(0), + ...u16(0), + ...u16(files.length), + ...u16(files.length), + ...u32(cenLen), + ...u32(cenStart), + ...u16(0) ]) const total = offset + cenLen + eocd.length const out = new Uint8Array(total) diff --git a/src/renderer/src/circuit/__tests__/labels.test.ts b/src/renderer/src/circuit/__tests__/labels.test.ts new file mode 100644 index 0000000..7418452 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/labels.test.ts @@ -0,0 +1,72 @@ +/** Tests for schematic text: value formatting and where labels sit. */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { CircuitPart } from '../core/model' +import { formatValue, labelLayout, refdesOf, valueOf, visibleBox } from '../parts/labels' + +test('values print as an engineer writes them', () => { + assert.equal(formatValue('220', 'Ω'), '220 Ω') + assert.equal(formatValue('10k', 'Ω'), '10 kΩ') + assert.equal(formatValue('100n', 'F'), '100 nF') + assert.equal(formatValue('10u', 'F'), '10 µF') + assert.equal(formatValue('1k', 'Hz'), '1 kHz') + assert.equal(formatValue('4.7Meg', 'Ω'), '4.7 MΩ') + assert.equal(formatValue('5', 'V'), '5 V') +}) + +test('the printed value is the value that gets simulated', () => { + const r: CircuitPart = { id: 'R1', type: 'resistor', attrs: { resistance: '4.7k' } } + assert.equal(valueOf(r, 'Resistor'), '4.7 kΩ') + // unset falls back to the same default the netlist emitter uses + const bare: CircuitPart = { id: 'R2', type: 'resistor' } + assert.equal(valueOf(bare, 'Resistor'), '220 Ω') + // a 2xAA pack is 3 V, not the generic source default + const bat: CircuitPart = { id: 'BT1', type: 'battery-aa' } + assert.equal(valueOf(bat, 'battery'), '3 V') +}) + +test('sources print both terms; settings are not values', () => { + const v: CircuitPart = { id: 'V1', type: 'sim-vsin', attrs: { amplitude: '5', frequency: '1k' } } + assert.equal(valueOf(v, 'sim-vsin'), '5 V 1 kHz') + // a wiper position (0–1) and a switch state (true/false) belong in the + // inspector, not on the sheet + const pot: CircuitPart = { id: 'RV1', type: 'potentiometer-rotary-16mm-5' } + assert.equal(valueOf(pot, 'potentiometer'), '10 kΩ') + const sw: CircuitPart = { id: 'SW1', type: 'switch-spst' } + assert.equal(valueOf(sw, 'switch'), '') +}) + +test('a part with nothing to say prints no value line', () => { + const led: CircuitPart = { id: 'LED1', type: 'led-generic-5mm' } + assert.equal(valueOf(led, 'led'), '') + assert.equal(refdesOf(led), 'LED1') + // an explicit label wins over the refdes + assert.equal(refdesOf({ ...led, attrs: { label: 'Status' } }), 'Status') +}) + +test('rotating a symbol moves the box its labels hang off', () => { + assert.deepEqual(visibleBox(57.6, 19.2, 0), { left: 0, top: 0, w: 57.6, h: 19.2 }) + const turned = visibleBox(57.6, 19.2, 90) + const near = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6 + assert.ok(near(turned.w, 19.2)) + assert.ok(near(turned.h, 57.6)) + assert.ok(near(turned.left, 19.2)) + assert.ok(near(turned.top, -19.2)) +}) + +test('vertical parts get their text beside them, not under the wire', () => { + const horizontal = labelLayout(57.6, 19.2, { a: [0, 9.6], b: [57.6, 9.6] }) + assert.equal(horizontal.side, false) + assert.ok(horizontal.refdes[1] < 0, 'refdes sits above the symbol') + assert.ok(horizontal.value[1] > 19.2, 'value sits below the symbol') + + // same resistor, stood on end: text must move to the side + const rotated = labelLayout(57.6, 19.2, { a: [0, 9.6], b: [57.6, 9.6] }, 90) + assert.equal(rotated.side, true) + assert.ok(rotated.refdes[0] > rotated.box.left + rotated.box.w - 1, 'text clears the symbol') + + // a source drawn tall is vertical without any rotation at all + const source = labelLayout(38.4, 57.6, { '+': [19.2, 0], '-': [19.2, 57.6] }) + assert.equal(source.side, true) +}) diff --git a/src/renderer/src/circuit/__tests__/m1.test.ts b/src/renderer/src/circuit/__tests__/m1.test.ts index d2c82a4..b074ae5 100644 --- a/src/renderer/src/circuit/__tests__/m1.test.ts +++ b/src/renderer/src/circuit/__tests__/m1.test.ts @@ -42,7 +42,9 @@ test('composite commands with a mergeKey collapse into one undo step', () => { test('different mergeKeys do not merge', () => { const store = new CircuitStore(fixture()) store.dispatch(cmd.composite('Move', [cmd.placePart('R1', 'bb', { x: 9.6, y: 0 })], 'movebb:R1')) - store.dispatch(cmd.composite('Move', [cmd.placePart('R2', 'bb', { x: 105.6, y: 0 })], 'movebb:R2')) + store.dispatch( + cmd.composite('Move', [cmd.placePart('R2', 'bb', { x: 105.6, y: 0 })], 'movebb:R2') + ) store.undo() assert.equal(store.getDoc().parts[1].bb!.x, 96) assert.equal(store.getDoc().parts[0].bb!.x, 9.6) diff --git a/src/renderer/src/circuit/__tests__/model.test.ts b/src/renderer/src/circuit/__tests__/model.test.ts index ec44b22..1546c86 100644 --- a/src/renderer/src/circuit/__tests__/model.test.ts +++ b/src/renderer/src/circuit/__tests__/model.test.ts @@ -16,7 +16,13 @@ test('v2 round-trip is lossless and stable', () => { const doc: CircuitDoc = { ...emptyDoc('geoff'), parts: [ - { id: 'R1', type: 'resistor', attrs: { value: '220' }, bb: { x: 96, y: 48 }, sch: { x: 10, y: 10, rotate: 90 } } + { + id: 'R1', + type: 'resistor', + attrs: { value: '220' }, + bb: { x: 96, y: 48 }, + sch: { x: 10, y: 10, rotate: 90 } + } ], wires: [{ id: 'w1', from: 'R1:1', to: 'R1:2', view: 'bb', color: '#2fa46a', route: ['h10'] }] } @@ -76,12 +82,10 @@ test('v1 tinyStudio diagram.json migrates: placements, wires, schematic overlay' { type: 'tinycore', id: 'tinycore', left: 150, top: 240 }, { type: 'resistor', id: 'resistor', left: 520, top: 230, rotate: 90, attrs: { value: '1k' } } ], - connections: [ - ['tinycore:SIG', 'resistor:Pin 0', '#36c46b', ['v-22.73', 'h-179.8']] - ], + connections: [['tinycore:D13', 'resistor:Pin 0', '#36c46b', ['v-22.73', 'h-179.8']]], schematic: { pos: { resistor: [40, 60] }, - routes: { 'tinycore:SIG>resistor:Pin 0': ['h20'] } + routes: { 'tinycore:D13>resistor:Pin 0': ['h20'] } } }) const { doc, migrated } = parseCircuitFile(v1) diff --git a/src/renderer/src/circuit/__tests__/naming.test.ts b/src/renderer/src/circuit/__tests__/naming.test.ts new file mode 100644 index 0000000..f10a892 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/naming.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for parts/naming — the human-readable label + category layer, and its + * contract with the two systems that used to read the raw Fritzing family: + * SPICE emitter matching (netlist) and refdes assignment. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + CATEGORY_ORDER, + compareCategories, + humanizeCategory, + humanizeLabel, + resolveNaming +} from '../parts/naming' +import { PART_MANIFEST, partsByFamily, registerPart, type PartDef } from '../../lib/partsLibrary' +import { prefixForFamily } from '../core/refdes' +import { simAttrsFor } from '../core/netlist' + +test('curated parts get a real name, not the Fritzing slug', () => { + assert.equal(resolveNaming('led-generic-5mm', 'led', 'LED').label, 'LED (5 mm)') + assert.equal(resolveNaming('battery-aa', 'Battery', 'Battery').label, '2× AA Battery Pack (3 V)') + assert.equal( + resolveNaming('sparkfun-discretesemi-mosfet-nchannel-pth', 'MOSFET-NCHANNEL', 'sparkfun Mosfet') + .label, + 'N-Channel MOSFET' + ) + // a value never belongs in the name — it lives in attrs + assert.equal(resolveNaming('resistor', '220 Ω Resistor', 'Resistor').label, 'Resistor') +}) + +test('humanizeLabel handles slugs, acronyms, units and Fritzing noise suffixes', () => { + assert.equal(humanizeLabel('led'), 'LED') + assert.equal(humanizeLabel('battery-aa_y90'), 'Battery AA') + assert.equal(humanizeLabel('ir_receiver'), 'IR Receiver') + assert.equal(humanizeLabel('resistor-3mm'), 'Resistor 3 mm') + assert.equal(humanizeLabel('transistor-to92'), 'Transistor TO-92') + // deliberate mixed case survives untouched + assert.equal(humanizeLabel('tinyCore'), 'tinyCore') +}) + +test('unknown families map onto a display category', () => { + assert.equal(humanizeCategory('Capacitor [bidirectional]'), 'Passive Elements') + assert.equal(humanizeCategory('microcontroller board (lilypad)'), 'Integrated Circuits') + assert.equal(humanizeCategory('sparkfun Electret Mic'), 'Audio') + assert.equal(humanizeCategory('TE General Purpose Relays'), 'Relays') + assert.equal(humanizeCategory(''), 'Uncategorized') +}) + +test('every shipped part has a readable label and a known category', () => { + for (const meta of PART_MANIFEST) { + assert.ok( + !/^[a-z0-9]+([_-][a-z0-9]+)+$/.test(meta.label), + `${meta.type} still shows a slug label: ${meta.label}` + ) + assert.ok(meta.label.trim().length > 1, `${meta.type} has an empty label`) + assert.ok( + CATEGORY_ORDER.includes(meta.family), + `${meta.type} landed in an uncurated category: ${meta.family}` + ) + } +}) + +test('the components rail lists categories in the curated order', () => { + const groups = partsByFamily().map((g) => g.family) + const ranks = groups.map((g) => CATEGORY_ORDER.indexOf(g)) + for (let i = 1; i < ranks.length; i++) { + assert.ok(ranks[i] > ranks[i - 1], `${groups[i]} sorts before ${groups[i - 1]}`) + } + assert.equal(compareCategories('Breadboards', 'Sources') < 0, true) +}) + +test('renaming a category cannot change how a part simulates', () => { + // The display category is "Passive Elements"; the sim keywords must still + // read as a resistor so the netlist emitter (and its attrs) match. + const r = resolveNaming('resistor', '220 Ω Resistor', 'Resistor') + assert.equal(r.category, 'Passive Elements') + assert.deepEqual( + simAttrsFor('resistor', r.sim).map((a) => a.key), + ['resistance'] + ) + // LEDs show under "Diodes" but must not be emitted as a plain diode + const led = resolveNaming('led-generic-5mm', 'led', 'LED') + assert.equal(led.category, 'Diodes') + assert.equal(led.prefix, 'LED') + assert.equal(prefixForFamily(`${led.sim} led-generic-5mm`, led.prefix), 'LED') + // the relay is 4-pin: it must stay clear of the 2-terminal switch emitter + const relay = resolveNaming('te-relay', 'RELAY', 'TE General Purpose Relays') + assert.equal(relay.category, 'Relays') + assert.equal(simAttrsFor('te-relay', relay.sim).length, 0) +}) + +test('registerPart applies naming to dropped/imported parts', () => { + const raw: PartDef = { + type: 'my-photo-transistor', + label: 'my-photo-transistor', + family: 'Bipolar Transistor', + views: { breadboard: { svg: '', w: 10, h: 10, pins: { E: [0, 0], C: [10, 0] } } } + } + registerPart(raw) + const meta = PART_MANIFEST.find((m) => m.type === 'my-photo-transistor') + assert.ok(meta) + assert.equal(meta!.label, 'My Photo Transistor') + assert.equal(meta!.family, 'Transistors') + // provenance kept for sim matching + assert.equal(meta!.simFamily, 'Bipolar Transistor') +}) diff --git a/src/renderer/src/circuit/__tests__/netlist.test.ts b/src/renderer/src/circuit/__tests__/netlist.test.ts index 8da0a6b..605487e 100644 --- a/src/renderer/src/circuit/__tests__/netlist.test.ts +++ b/src/renderer/src/circuit/__tests__/netlist.test.ts @@ -90,7 +90,10 @@ test('LED maps anode/cathode by name, model card emitted once', () => { assert.equal(model.length, 1, 'one model card for two LEDs') // anode before cathode in the D card const d1 = res.netlist.split('\n').find((l) => l.startsWith('DLED1'))! - const anodeNet = res.netlist.split('\n').find((l) => l.startsWith('RR1'))!.split(' ')[2] + const anodeNet = res.netlist + .split('\n') + .find((l) => l.startsWith('RR1'))! + .split(' ')[2] assert.equal(d1.split(' ')[1], anodeNet) }) diff --git a/src/renderer/src/circuit/__tests__/packs.test.ts b/src/renderer/src/circuit/__tests__/packs.test.ts index c4133bd..5d38fdf 100644 --- a/src/renderer/src/circuit/__tests__/packs.test.ts +++ b/src/renderer/src/circuit/__tests__/packs.test.ts @@ -49,7 +49,11 @@ function stubFetch(routes: Record unknown) | { status: const hit = routes[url] if (hit === undefined) return { ok: false, status: 404, json: async () => ({}) } as Response if (typeof hit === 'object' && hit !== null && 'status' in hit && !('json' in hit)) { - return { ok: false, status: (hit as { status: number }).status, json: async () => ({}) } as Response + return { + ok: false, + status: (hit as { status: number }).status, + json: async () => ({}) + } as Response } const body = typeof hit === 'function' ? (hit as () => unknown)() : hit return { ok: true, status: 200, json: async () => body } as Response @@ -93,7 +97,10 @@ test('fetchManifest validates { id, parts: [...] } shape', async () => { }) const man = await fetchManifest('https://example.com/pack.json') assert.equal(man.id, 'core') - await assert.rejects(() => fetchManifest('https://example.com/bad.json'), /not a valid pack manifest/) + await assert.rejects( + () => fetchManifest('https://example.com/bad.json'), + /not a valid pack manifest/ + ) }) // ── installPack ────────────────────────────────────────────────────────────── diff --git a/src/renderer/src/circuit/__tests__/placement.test.ts b/src/renderer/src/circuit/__tests__/placement.test.ts new file mode 100644 index 0000000..4c24c8a --- /dev/null +++ b/src/renderer/src/circuit/__tests__/placement.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for cross-view auto-placement: a part added in one view must land, + * collision-free, in the other one too (no more silent trips to the tray). + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { GRID_BB, emptyDoc, type CircuitDoc } from '../core/model' +import { placePart } from '../core/commands' +import { registerPart, type PartDef } from '../../lib/partsLibrary' +import { autoPlacementFor, occupiedBoxes } from '../views/partsAdapter' + +const box = (type: string, w = 4 * GRID_BB, h = 2 * GRID_BB): PartDef => ({ + type, + label: type, + family: 'Passive Elements', + views: { + breadboard: { svg: '', w, h, pins: { '1': [0, h / 2], '2': [w, h / 2] } }, + schematic: { svg: '', w, h, pins: { '1': [0, h / 2], '2': [w, h / 2] } } + } +}) + +function overlapsAnything(doc: CircuitDoc, at: { x: number; y: number }): boolean { + const w = 4 * GRID_BB + const h = 2 * GRID_BB + return occupiedBoxes(doc, 'sch').some( + (b) => at.x < b.x + b.w && at.x + w > b.x && at.y < b.y + b.h && at.y + h > b.y + ) +} + +test('auto-placement clears everything already on the sheet', () => { + registerPart(box('test-widget')) + let doc: CircuitDoc = emptyDoc() + doc.parts = [ + { id: 'W1', type: 'test-widget', sch: { x: 0, y: 0 } }, + { id: 'W2', type: 'test-widget', sch: { x: 48, y: 0 } } + ] + // place five more and confirm none of them lands on top of an earlier one + for (let i = 3; i <= 7; i++) { + const id = `W${i}` + const pl = autoPlacementFor(doc, 'test-widget', 'sch') + assert.equal(overlapsAnything(doc, pl), false, `${id} overlaps an existing part`) + doc = { ...doc, parts: [...doc.parts, { id, type: 'test-widget' }] } + doc = placePart(id, 'sch', pl).apply(doc) + } +}) + +test('auto-placement lands on the major grid', () => { + registerPart(box('test-widget2')) + const doc: CircuitDoc = emptyDoc() + const pl = autoPlacementFor(doc, 'test-widget2', 'sch') + const onGrid = (n: number): boolean => Math.abs(n / GRID_BB - Math.round(n / GRID_BB)) < 1e-6 + assert.ok(onGrid(pl.x) && onGrid(pl.y), `placement off-grid: ${pl.x},${pl.y}`) +}) + +test('an empty sheet places at the fixed origin, not at 0,0', () => { + registerPart(box('test-widget3')) + const pl = autoPlacementFor(emptyDoc(), 'test-widget3', 'sch') + assert.ok(pl.x > 0 && pl.y > 0, 'first part should sit inside the sheet, with margin') +}) diff --git a/src/renderer/src/circuit/__tests__/renumber.test.ts b/src/renderer/src/circuit/__tests__/renumber.test.ts new file mode 100644 index 0000000..9617175 --- /dev/null +++ b/src/renderer/src/circuit/__tests__/renumber.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for reference-designator renumbering — the fix for a migrated + * diagram.json printing part-file slugs ("led", "battery-aa_y90") beside every + * symbol instead of R1 / LED2 / BT3. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { emptyDoc, type CircuitDoc } from '../core/model' +import { renumberParts } from '../core/commands' +import { looksLikeSlug, renumberAll } from '../core/refdes' + +const prefixOf = (type: string): string => + ({ resistor: 'R', 'led-generic-5mm': 'LED', 'battery-aa': 'BT', tinycore: 'U' })[type] ?? 'P' + +function migratedDoc(): CircuitDoc { + const doc = emptyDoc() + doc.parts = [ + { id: 'battery-aa_y90', type: 'battery-aa', sch: { x: 300, y: 200 } }, + { id: 'led', type: 'led-generic-5mm', sch: { x: 200, y: 100 } }, + { id: 'resistor', type: 'resistor', sch: { x: 100, y: 100 } }, + { id: 'tinycore', type: 'tinycore', sch: { x: 400, y: 100 } } + ] + doc.wires = [ + { id: 'w1', from: 'resistor:Pin 1', to: 'led:anode', view: 'sch' }, + { id: 'w2', from: 'led:cathode', to: 'battery-aa_y90:-', view: 'sch' } + ] + return doc +} + +test('slug ids are recognised, real refdes are left alone', () => { + assert.equal(looksLikeSlug('battery-aa_y90'), true) + assert.equal(looksLikeSlug('led'), true) + assert.equal(looksLikeSlug('resistor'), true) + assert.equal(looksLikeSlug('R1'), false) + assert.equal(looksLikeSlug('LED12'), false) + assert.equal(looksLikeSlug('U1'), false) +}) + +test('renumbering reads the sheet: top to bottom, left to right', () => { + const mapping = renumberAll(migratedDoc(), prefixOf, 'sch') + // top row first, left to right: resistor(100,100) → led(200,100) → tinycore(400,100) + assert.equal(mapping['resistor'], 'R1') + assert.equal(mapping['led'], 'LED1') + assert.equal(mapping['tinycore'], 'U1') + // then the row below + assert.equal(mapping['battery-aa_y90'], 'BT1') +}) + +test('renumbering rewrites every wire endpoint with the new names', () => { + const doc = migratedDoc() + const next = renumberParts(renumberAll(doc, prefixOf, 'sch')).apply(doc) + assert.deepEqual(next.parts.map((p) => p.id).sort(), ['BT1', 'LED1', 'R1', 'U1']) + assert.deepEqual( + next.wires.map((w) => `${w.from}>${w.to}`), + ['R1:Pin 1>LED1:anode', 'LED1:cathode>BT1:-'] + ) +}) + +test('a rename that collides with an existing id still lands', () => { + // renaming one at a time would hit renamePart's uniqueness guard and no-op: + // 'a' wants to become 'R1', but 'R1' is already taken by the part that is + // itself about to become 'R2' + const doc = emptyDoc() + doc.parts = [ + { id: 'R1', type: 'resistor', sch: { x: 200, y: 100 } }, + { id: 'a', type: 'resistor', sch: { x: 100, y: 100 } } + ] + doc.wires = [{ id: 'w1', from: 'a:Pin 0', to: 'R1:Pin 0', view: 'sch' }] + const mapping = renumberAll(doc, prefixOf, 'sch') + assert.deepEqual(mapping, { a: 'R1', R1: 'R2' }) + const next = renumberParts(mapping).apply(doc) + assert.deepEqual( + next.parts.map((p) => p.id), + ['R2', 'R1'] + ) + assert.equal(next.wires[0].from, 'R1:Pin 0') + assert.equal(next.wires[0].to, 'R2:Pin 0') +}) + +test('renumbering an already-tidy document is a no-op', () => { + const doc = emptyDoc() + doc.parts = [ + { id: 'R1', type: 'resistor', sch: { x: 100, y: 100 } }, + { id: 'R2', type: 'resistor', sch: { x: 200, y: 100 } } + ] + assert.deepEqual(renumberAll(doc, prefixOf, 'sch'), {}) +}) diff --git a/src/renderer/src/circuit/__tests__/sim.test.ts b/src/renderer/src/circuit/__tests__/sim.test.ts index ac03d77..1e6f52e 100644 --- a/src/renderer/src/circuit/__tests__/sim.test.ts +++ b/src/renderer/src/circuit/__tests__/sim.test.ts @@ -144,5 +144,8 @@ test('current probe (M4 leftover): engine reports the divider branch current', a assert.ok(i, `i(vamm1) present (got: ${res.data.map((d) => d.name).join(', ')})`) // 5V across 14.7k, current flows V+ -> AMM1 -> R1 -> R2 -> V- (into V- => negative by SPICE convention) const expected = 5 / 14700 - assert.ok(Math.abs(Math.abs(i![0]) - expected) / expected < 0.01, `i(vamm1)=${i![0]} ≈ ±${expected}`) + assert.ok( + Math.abs(Math.abs(i![0]) - expected) / expected < 0.01, + `i(vamm1)=${i![0]} ≈ ±${expected}` + ) }) diff --git a/src/renderer/src/circuit/__tests__/simOutputs.test.ts b/src/renderer/src/circuit/__tests__/simOutputs.test.ts new file mode 100644 index 0000000..8a4236c --- /dev/null +++ b/src/renderer/src/circuit/__tests__/simOutputs.test.ts @@ -0,0 +1,237 @@ +/** + * core/simOutputs + the analysis sizing guard (spec §10.4). + * + * The point of these: an output reference must survive edits that renumber the + * netlist's nodes (that's the whole reason we don't persist `v(n3)`), and an + * analysis that would allocate gigabytes must be caught before it reaches the + * engine rather than after it takes the renderer down. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + parseCircuitFile, + serializeDoc, + emptyDoc, + type CircuitPart, + type CircuitWire +} from '../core/model' +import { buildNets } from '../core/nets' +import { estimatePoints, generateNetlist, spiceValue, MAX_SIM_POINTS } from '../core/netlist' +import { + availableOutputs, + makeProbe, + netLabelFor, + probeAnchor, + probeFor, + PROBE_DEFAULT_OFFSET, + netOutputRef, + netIndexOfOutput, + outputFilter, + outputLabelFor, + parseOutputRef, + resolveOutputs, + vectorForOutput +} from '../core/simOutputs' + +let wid = 0 +const wire = (from: string, to: string): CircuitWire => ({ + id: `w${++wid}`, + from, + to, + view: 'sch' +}) +const part = (id: string, type: string, attrs?: CircuitPart['attrs']): CircuitPart => ({ + id, + type, + attrs, + sch: { x: 0, y: 0 } +}) + +/** V1 → R1 → R2 → GND, with the midpoint labelled OUT. */ +function divider(): ReturnType { + const doc = emptyDoc() + doc.parts = [ + part('V1', 'sim-vdc', { voltage: '5' }), + part('R1', 'resistor', { resistance: '10k' }), + part('R2', 'resistor', { resistance: '4.7k' }) + ] + doc.netLabels = [ + { id: 'nl1', name: 'GND', kind: 'ground', sch: { x: 0, y: 0 } }, + { id: 'nl2', name: 'OUT', kind: 'net', sch: { x: 0, y: 0 } } + ] + doc.wires = [ + wire('V1:+', 'R1:Pin 0'), + wire('R1:Pin 1', 'R2:Pin 0'), + wire('R2:Pin 1', 'V1:-'), + wire('nl1:1', 'V1:-'), + wire('nl2:1', 'R1:Pin 1') + ] + return doc +} + +test('available outputs list the measurable nets and source currents, not ground', () => { + const doc = divider() + const net = buildNets(doc) + const choices = availableOutputs(doc, net) + const labels = choices.map((c) => c.label) + + assert.ok(labels.includes('OUT'), `the named midpoint is offered (got ${labels.join(', ')})`) + assert.ok(!labels.includes('GND'), 'ground is 0 by definition — never an output') + assert.ok( + choices.some((c) => c.kind === 'i' && c.label === 'I(V1)'), + 'the voltage source can report its current' + ) + // every voltage choice must round-trip through parse + for (const c of choices) assert.ok(parseOutputRef(c.ref), `${c.ref} parses`) +}) + +test('an output reference survives a node renumber', () => { + const doc = divider() + let net = buildNets(doc) + const ref = netOutputRef(net, 'R1:Pin 1') + assert.ok(ref, 'the midpoint has a reference') + + let gen = generateNetlist(doc, net) + const before = vectorForOutput(ref!, net, gen) + assert.equal(before, 'v(out)') + + // add an unrelated branch — enough to shift the n numbering around + doc.parts.push(part('R3', 'resistor', { resistance: '1k' })) + doc.wires.push(wire('V1:+', 'R3:Pin 0'), wire('R3:Pin 1', 'V1:-')) + net = buildNets(doc) + gen = generateNetlist(doc, net) + + const after = vectorForOutput(ref!, net, gen) + assert.equal(after, 'v(out)', 'the same reference still names the same node') + assert.equal(netIndexOfOutput(ref!, net), net.pinToNet.get('R1:Pin 1')) +}) + +test('a current output resolves to the device ngspice actually emitted', () => { + const doc = divider() + const net = buildNets(doc) + const gen = generateNetlist(doc, net) + assert.equal(vectorForOutput('i@V1', net, gen), 'i(vv1)') + assert.equal(vectorForOutput('i@nope', net, gen), undefined) +}) + +test('no picks means every vector plots; a pick narrows to it', () => { + const doc = divider() + const net = buildNets(doc) + const gen = generateNetlist(doc, net) + + const none = outputFilter(resolveOutputs([], doc, net, gen)) + assert.ok(none('v(out)') && none('v(n1)') && none('i(vv1)')) + + const resolved = resolveOutputs(['v@R1:Pin 1'], doc, net, gen) + const only = outputFilter(resolved) + assert.ok(only('v(out)'), 'the picked node passes') + assert.ok(!only('i(vv1)'), 'everything else is filtered out') + assert.equal(outputLabelFor(resolved)('v(out)'), 'OUT') +}) + +test('an output whose part is gone degrades instead of throwing', () => { + const doc = divider() + const net = buildNets(doc) + const gen = generateNetlist(doc, net) + const [resolved] = resolveOutputs(['v@GONE:1'], doc, net, gen) + assert.equal(resolved.vector, undefined) + assert.equal(resolved.label, 'GONE:1') + // an unresolvable pick must not silently blank the plot + assert.ok(outputFilter([resolved])('v(out)')) +}) + +test('spiceValue reads the suffixes the schematic prints', () => { + const near = (got: number, want: number): void => + assert.ok(Math.abs(got - want) <= Math.abs(want) * 1e-12, `${got} ≈ ${want}`) + near(spiceValue('10u'), 10e-6) + assert.equal(spiceValue('4.7k'), 4700) + assert.equal(spiceValue('1M'), 1e6) // human megohm convention + near(spiceValue('1m'), 1e-3) + assert.equal(spiceValue('1e3'), 1000) + assert.equal(spiceValue('220'), 220) +}) + +test('analysis point counts are estimated before the engine allocates them', () => { + assert.equal(estimatePoints({ id: 'a', kind: 'op' }), 1) + assert.equal(estimatePoints({ id: 'a', kind: 'tran', step: '10u', stop: '10m' }), 1001) + assert.equal(estimatePoints({ id: 'a', kind: 'dc', from: '0', to: '5', step: '0.1' }), 51) + // 6 decades at 20 points/decade + assert.equal( + estimatePoints({ + id: 'a', + kind: 'ac', + variation: 'dec', + points: '20', + fstart: '1', + fstop: '1Meg' + }), + 121 + ) + // the shape that used to take the app down with it + assert.ok( + estimatePoints({ id: 'a', kind: 'tran', step: '1n', stop: '10' }) > MAX_SIM_POINTS, + 'a nanosecond step over ten seconds is refused' + ) + // nonsense parameters estimate to nothing rather than NaN/Infinity + assert.equal(estimatePoints({ id: 'a', kind: 'tran', step: '0', stop: '1' }), 0) +}) + +// ── placed probes ──────────────────────────────────────────────────────────── + +test('a probe anchors to the pin it was picked from, in both views', () => { + const doc = divider() + const net = buildNets(doc) + const ref = netOutputRef(net, 'R1:Pin 1')! + const probe = makeProbe(ref, 'sch', [12, -30]) + + assert.equal(probe.kind, 'voltage') + assert.equal(probe.at, ref) + assert.deepEqual(probe.sch, [12, -30], 'the view it was placed in keeps the drop point') + assert.deepEqual( + probe.bb, + PROBE_DEFAULT_OFFSET, + 'the other view still gets a tag rather than losing it' + ) + + const anchor = probeAnchor(ref) + assert.deepEqual(anchor, { kind: 'v', part: 'R1', pin: 'Pin 1' }) +}) + +test('a current probe anchors to the part, not a pin', () => { + assert.deepEqual(probeAnchor('i@V1'), { kind: 'i', part: 'V1' }) + assert.equal(makeProbe('i@V1', 'sch').kind, 'current') + assert.equal(makeProbe('d@P1', 'sch').kind, 'diff') +}) + +test('probeFor finds the tag already measuring a node (so picking toggles)', () => { + const doc = divider() + const net = buildNets(doc) + const ref = netOutputRef(net, 'R1:Pin 1')! + const probes = [makeProbe('i@V1', 'sch'), makeProbe(ref, 'sch')] + assert.equal(probeFor(probes, ref)?.at, ref) + assert.equal(probeFor(probes, 'v@nothing:1'), undefined) +}) + +test('probes survive a save/load round trip', () => { + const doc = divider() + const net = buildNets(doc) + const probe = makeProbe(netOutputRef(net, 'R1:Pin 1')!, 'sch', [8, -24]) + doc.sim = { probes: [probe] } + + const reloaded = parseCircuitFile(serializeDoc(doc)).doc + assert.deepEqual(reloaded.sim?.probes, [probe]) +}) + +test('a tag reads the same node name the netlist will emit', () => { + const doc = divider() + const net = buildNets(doc) + const gen = generateNetlist(doc, net) + net.nets.forEach((members, i) => { + if (members.length < 2) return + const label = netLabelFor(net, i) + const node = gen.nodeOfNet[i] + if (node === '0') return // ground is never tagged + assert.equal(label, node, `net ${i} tag and netlist node agree`) + }) +}) diff --git a/src/renderer/src/circuit/__tests__/svg.test.ts b/src/renderer/src/circuit/__tests__/svg.test.ts index b3afcba..2b5c9f1 100644 --- a/src/renderer/src/circuit/__tests__/svg.test.ts +++ b/src/renderer/src/circuit/__tests__/svg.test.ts @@ -38,14 +38,19 @@ test('svgNs sanitizes weird part ids', () => { }) test('stripSvgSize removes root width/height only', () => { - const out = stripSvgSize('') + const out = stripSvgSize( + '' + ) assert.ok(!/^]*width="10"/.test(out)) assert.ok(out.includes('')) assert.ok(out.includes('viewBox="0 0 10 20"')) }) test('escapeXml escapes the five specials', () => { - assert.equal(escapeXml(`R&D's`), '<a href="x">R&D's</a>') + assert.equal( + escapeXml(`R&D's`), + '<a href="x">R&D's</a>' + ) }) test('prepareSvgForEmbed strips prolog/doctype and root x/y/width/height (Fritzing exports)', () => { @@ -67,7 +72,9 @@ test('prepareSvgForEmbed strips prolog/doctype and root x/y/width/height (Fritzi }) test('prepareSvgForEmbed keeps inner-element geometry attributes', () => { - const out = prepareSvgForEmbed('') + const out = prepareSvgForEmbed( + '' + ) assert.ok(out.includes('')) }) diff --git a/src/renderer/src/circuit/__tests__/symbolLibrary.test.ts b/src/renderer/src/circuit/__tests__/symbolLibrary.test.ts new file mode 100644 index 0000000..d5f5dce --- /dev/null +++ b/src/renderer/src/circuit/__tests__/symbolLibrary.test.ts @@ -0,0 +1,175 @@ +/** + * Tests for the hand-authored schematic symbol set and the normaliser that + * cleans up imported art. The point of both is CONSISTENCY, so most of these + * assert sameness across the whole library rather than checking one symbol's + * shape: one stroke weight, one type stack, every pin on the major grid. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { GRID_BB } from '../core/model' +import { STROKE } from '../parts/style' +import { SYMBOLS, SYMBOL_BY_TYPE, bindSymbol, symbolIdForKeywords } from '../parts/symbolLibrary' +import { gridAlignOffset, normalizeAuthoredSymbol, reinkSvg } from '../parts/normalizeSymbol' +import { schematicVisual } from '../parts/symbols' +import { PART_MANIFEST, ensureParts, getPart, type PartView } from '../../lib/partsLibrary' + +const onGrid = (n: number): boolean => Math.abs(n / GRID_BB - Math.round(n / GRID_BB)) < 1e-6 + +function bindWithGenericPins(id: string): PartView { + const sym = SYMBOLS[id] + const v = bindSymbol( + sym, + sym.slots.map((_, i) => `p${i}`) + ) + assert.ok(v, `${id} failed to bind`) + return v! +} + +test('every symbol puts every pin on the major grid', () => { + for (const id of Object.keys(SYMBOLS)) { + const v = bindWithGenericPins(id) + for (const [pin, [x, y]] of Object.entries(v.pins)) { + assert.ok(onGrid(x) && onGrid(y), `${id}.${pin} is off-grid at ${x},${y}`) + } + } +}) + +test('every symbol draws at the one shared stroke weight', () => { + for (const id of Object.keys(SYMBOLS)) { + const v = bindWithGenericPins(id) + const widths = [...v.svg.matchAll(/stroke-width="([\d.]+)"/g)].map((m) => parseFloat(m[1])) + assert.ok(widths.length, `${id} draws nothing`) + for (const w of widths) { + // detail strokes (arrowheads, dashed links, glass envelopes) may be + // lighter, but nothing may be heavier than the body weight and nothing + // may be a hairline + assert.ok(w <= STROKE, `${id} has a ${w}px stroke, heavier than the ${STROKE}px body`) + assert.ok(w >= 1, `${id} has a ${w}px hairline`) + } + } +}) + +test('no symbol bakes in its own colour or font', () => { + for (const id of Object.keys(SYMBOLS)) { + const v = bindWithGenericPins(id) + const hardCoded = [...v.svg.matchAll(/(?:stroke|fill)="(#[0-9a-fA-F]{3,8}|[a-z]+)"/g)] + .map((m) => m[1]) + .filter((c) => !['none', 'transparent'].includes(c)) + // the probe pennant is deliberately amber; nothing else may be + .filter((c) => c !== '#f0b429') + assert.deepEqual(hardCoded, [], `${id} paints with literal colours: ${hardCoded.join(', ')}`) + assert.ok(!/font-family="(?!var\()/.test(v.svg), `${id} sets its own font`) + } +}) + +test('every mapped part actually fits the symbol it is mapped to', async () => { + const types = Object.keys(SYMBOL_BY_TYPE) + await ensureParts(types) + for (const type of types) { + const def = getPart(type) + if (!def) continue // builtin generated at view mount (breadboards, sim parts) + const symbol = SYMBOLS[SYMBOL_BY_TYPE[type]] + const partPins = Object.keys(def.views.breadboard?.pins ?? {}) + assert.equal( + partPins.length, + symbol.slots.length, + `${type} has ${partPins.length} pins but its symbol has ${symbol.slots.length} terminals` + ) + // and the resolved view must keep the part's own pin names, or existing + // wires would dangle the moment the symbol changed + const v = schematicVisual(def) + assert.deepEqual( + Object.keys(v.pins).sort(), + partPins.slice().sort(), + `${type} lost or renamed a pin when it took its symbol` + ) + } +}) + +test('polarised symbols bind polarity by name, not by pin order', () => { + // the LED part lists cathode first; the symbol must still put the anode left + const led = bindSymbol(SYMBOLS.led, ['cathode', 'anode']) + assert.ok(led) + assert.equal(led!.pins['anode'][0], 0, 'anode should be the left terminal') + assert.ok(led!.pins['cathode'][0] > 0, 'cathode should be the right terminal') + // a battery names its pins '-' and '+' + const bat = bindSymbol(SYMBOLS.battery, ['-', '+']) + assert.ok(bat) + assert.equal(bat!.pins['+'][0], 0) + // a three-terminal transistor binds by role + const q = bindSymbol(SYMBOLS.npn, ['E', 'B', 'C']) + assert.ok(q) + assert.equal(q!.pins['B'][0], 0, 'base is the left terminal') + assert.equal(q!.pins['C'][1], 0, 'collector is the top terminal') + assert.ok(q!.pins['E'][1] > 0, 'emitter is the bottom terminal') +}) + +test('a symbol refuses a part with the wrong number of terminals', () => { + assert.equal(bindSymbol(SYMBOLS.resistor, ['a', 'b', 'c']), null) + assert.equal(bindSymbol(SYMBOLS.npn, ['a', 'b']), null) +}) + +test('keyword matching only fires on things it can actually identify', () => { + assert.equal(symbolIdForKeywords('led-generic-5mm LED'), 'led') + assert.equal(symbolIdForKeywords('some-npn-thing Bipolar Transistor'), 'npn') + assert.equal(symbolIdForKeywords('electrolytic capacitor'), 'capacitor-polar') + assert.equal(symbolIdForKeywords('esp32-dev-board microcontroller'), undefined) +}) + +// ── normaliser ─────────────────────────────────────────────────────────────── + +test('re-inking replaces hairlines, literal colours and fonts', () => { + const raw = + '' + + '' + + 'VCC' + // this art is authored in inches: 10 viewBox units across 39.33 px + const out = reinkSvg(raw, 10 / 39.33) + assert.ok(!out.includes('#787878'), 'grey stroke survived') + assert.ok(!out.includes('#1f8ac0'), 'blue text survived') + assert.ok(!out.includes('OCRA'), 'imported font survived') + assert.ok(out.includes('stroke="none"'), 'a deliberate no-stroke was overpainted') + // the restroked width must be OUR weight expressed in the drawing's units + const w = parseFloat(/stroke-width="([\d.]+)"/.exec(out)![1]) + assert.ok(Math.abs(w - STROKE * (10 / 39.33)) < 1e-3, `restroked to ${w}`) +}) + +test('grid alignment removes the half-stroke offset Fritzing bakes in', () => { + const [dx, dy] = gridAlignOffset({ a: [0.47, 4.13], b: [38.87, 4.13] }) + assert.ok(Math.abs(dx - -0.47) < 0.06, `dx=${dx}`) + // 4.13 is not near a grid line at all — it must not drag the drawing sideways + assert.ok(Math.abs(dy) < GRID_BB / 2) + + const v: PartView = { + svg: '', + w: 39.33, + h: 8.26, + pins: { 'Pin 0': [0.47, 4.13], 'Pin 1': [38.87, 4.13] } + } + const out = normalizeAuthoredSymbol(v) + assert.ok(onGrid(out.pins['Pin 0'][0]), `left pin at ${out.pins['Pin 0'][0]}`) + assert.ok(onGrid(out.pins['Pin 1'][0]), `right pin at ${out.pins['Pin 1'][0]}`) + assert.equal((out.svg.match(/ m[1]) + assert.ok(ids.length > 0, `${type} should define namespaced gradients`) + for (const id of ids) { + const prev = seen.get(id) + assert.equal(prev, undefined, `id "${id}" is shared by ${prev} and ${type}`) + seen.set(id, type) + } + for (const ref of [...svg.matchAll(/url\(#([^)]+)\)/g)].map((m) => m[1])) { + assert.ok(ids.includes(ref), `${type} references #${ref} but does not define it`) + } + } +}) + +test('the art follows Fritzing conventions', () => { + for (const type of FAMILY) { + const svg = bb(type).svg + assert.match(svg, /width="1\.9in" height="1\.9in"/, type) + assert.match(svg, /viewBox="0 0 136\.8 136\.8"/, type) + assert.match(svg, //, type) + for (let i = 0; i < 25; i++) { + assert.ok(svg.includes(`id="connector${i}pin"`), `${type} connector${i}pin`) + assert.ok(svg.includes(`id="connector${i}terminal"`), `${type} connector${i}terminal`) + } + } +}) diff --git a/src/renderer/src/circuit/core/clipboard.ts b/src/renderer/src/circuit/core/clipboard.ts index 440b16d..37704d0 100644 --- a/src/renderer/src/circuit/core/clipboard.ts +++ b/src/renderer/src/circuit/core/clipboard.ts @@ -13,7 +13,15 @@ * wires get fresh ids, endpoints are rewritten, placements are offset. */ -import { newId, splitPinRef, isJunction, type CircuitDoc, type CircuitPart, type CircuitWire, type WireEnd } from './model' +import { + newId, + splitPinRef, + isJunction, + type CircuitDoc, + type CircuitPart, + type CircuitWire, + type WireEnd +} from './model' export const CLIPBOARD_FORMAT = 'tinystudio-circuit-clipboard' diff --git a/src/renderer/src/circuit/core/commands.ts b/src/renderer/src/circuit/core/commands.ts index ce01cf8..3c01902 100644 --- a/src/renderer/src/circuit/core/commands.ts +++ b/src/renderer/src/circuit/core/commands.ts @@ -108,6 +108,45 @@ export function renamePart(oldId: string, next: string): Command { } } +/** + * Rename many parts at once. + * + * Renaming one at a time would collide the moment a new name is already in + * use by a part further down the list (renumbering `led`→`LED1` while another + * part is called `LED1`), and `renamePart`'s uniqueness guard would silently + * no-op. This applies the whole mapping in a single pass instead, rewriting + * every wire endpoint and net-label-adjacent reference with it. + */ +export function renumberParts(mapping: Record): Command { + const count = Object.keys(mapping).length + return { + label: count === 1 ? 'Rename part' : `Renumber ${count} parts`, + apply: (doc) => { + if (!count) return doc + const fixEnd = (e: WireEnd): WireEnd => { + if (typeof e !== 'string') return e + const { part, pin } = splitPinRef(e) + const next = mapping[part] + return next ? `${next}:${pin}` : e + } + return { + ...doc, + parts: doc.parts.map((p) => (mapping[p.id] ? { ...p, id: mapping[p.id] } : p)), + wires: doc.wires.map((w) => ({ ...w, from: fixEnd(w.from), to: fixEnd(w.to) })), + sim: doc.sim?.probes + ? { + ...doc.sim, + probes: doc.sim.probes.map((pr) => { + const { part, pin } = splitPinRef(pr.at) + return mapping[part] ? { ...pr, at: `${mapping[part]}:${pin}` } : pr + }) + } + : doc.sim + } + } + } +} + /** * Delete parts (both views) and cascade: wires touching them are removed, * junction riders on removed wires are repaired (§ junction cascade). @@ -273,6 +312,62 @@ export function setProbes(probes: Probe[]): Command { } } +/** Drop a measurement tag on a node (the Simulate panel's picker). */ +export function addProbe(probe: Probe): Command { + return { + label: `Probe ${probe.label ?? probe.at}`, + apply: (doc) => ({ + ...doc, + sim: { ...(doc.sim ?? {}), probes: [...(doc.sim?.probes ?? []), probe] } + }) + } +} + +export function removeProbe(id: string): Command { + return { + label: 'Remove probe', + apply: (doc) => ({ + ...doc, + sim: { ...(doc.sim ?? {}), probes: (doc.sim?.probes ?? []).filter((p) => p.id !== id) } + }) + } +} + +/** Reposition a probe's tag in one view. Merges so a drag is one undo step. */ +export function moveProbe(id: string, view: ViewId, offset: [number, number]): Command { + return { + label: 'Move probe', + mergeKey: `probe:${view}:${id}`, + apply: (doc) => ({ + ...doc, + sim: { + ...(doc.sim ?? {}), + probes: (doc.sim?.probes ?? []).map((p) => (p.id === id ? { ...p, [view]: offset } : p)) + } + }) + } +} + +/** Re-anchor a probe to a different node (drag its tag onto another wire/pin). */ +export function reanchorProbe( + id: string, + at: string, + offset: [number, number], + view: ViewId +): Command { + return { + label: 'Move probe', + mergeKey: `probe:${view}:${id}`, + apply: (doc) => ({ + ...doc, + sim: { + ...(doc.sim ?? {}), + probes: (doc.sim?.probes ?? []).map((p) => (p.id === id ? { ...p, at, [view]: offset } : p)) + } + }) + } +} + // ── batch ──────────────────────────────────────────────────────────────────── /** Compose several commands into one undo step (cross-view cascades, paste…). diff --git a/src/renderer/src/circuit/core/model.ts b/src/renderer/src/circuit/core/model.ts index f8a0d8a..d5a6fd4 100644 --- a/src/renderer/src/circuit/core/model.ts +++ b/src/renderer/src/circuit/core/model.ts @@ -87,12 +87,29 @@ export interface Analysis { enabled?: boolean [k: string]: unknown } +/** + * A placed measurement tag (spec §10.4). Unlike the `sim-probe-*` PARTS, a + * probe is not in the circuit: it is a label pinned to a node, the thing you + * get by clicking a wire with the Simulate panel's picker on. It says what an + * analysis should report and where its tag sits, and nothing else — no pins, + * no SPICE card, no effect on the netlist. + */ export interface Probe { id: string kind: 'voltage' | 'current' | 'diff' + /** + * What it measures, as a core/simOutputs reference: `v@:` for the + * node that pin sits on, `i@` for the current through a source, + * `d@` for a differential probe part's own reading. Deliberately NOT a + * SPICE vector name — node names are assigned in net order and move whenever + * a wire is added. + */ at: string label?: string color?: string + /** Tag offset from its anchor, per view, in world units. */ + bb?: [number, number] + sch?: [number, number] } export interface PackRef { @@ -194,7 +211,8 @@ export function parseCircuitFile(text: string): ParseResult { let raw: Record try { raw = JSON.parse(text || '{}') - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('not an object') + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) + throw new Error('not an object') } catch (e) { return { doc: emptyDoc(), @@ -212,9 +230,17 @@ export function parseCircuitFile(text: string): ParseResult { } if (Array.isArray(raw.parts) || Array.isArray(raw.wires)) { // half-formed v2-ish content — salvage what we can - return { doc: normalizeV2({ format: 'tinystudio-circuit', version: 2, ...raw }, warnings), migrated: false, warnings } + return { + doc: normalizeV2({ format: 'tinystudio-circuit', version: 2, ...raw }, warnings), + migrated: false, + warnings + } + } + return { + doc: emptyDoc(), + migrated: false, + warnings: ['Unknown circuit file shape; starting empty.'] } - return { doc: emptyDoc(), migrated: false, warnings: ['Unknown circuit file shape; starting empty.'] } } function normalizeV2(raw: Record, warnings: string[]): CircuitDoc { @@ -277,7 +303,11 @@ type V1Conn = [unknown, unknown, string?, string[]?] */ function migrateV1(raw: Record, warnings: string[]): CircuitDoc { const doc = emptyDoc(typeof raw.author === 'string' ? raw.author : undefined) - const sch = (raw.schematic as { pos?: Record; routes?: Record }) || {} + const sch = + (raw.schematic as { + pos?: Record + routes?: Record + }) || {} for (const p of (raw.parts as Record[]) ?? []) { if (!p || p.id == null || p.type == null) continue @@ -287,13 +317,16 @@ function migrateV1(raw: Record, warnings: string[]): CircuitDoc bb: { x: Number(p.left ?? p.x ?? 0), y: Number(p.top ?? p.y ?? 0), - ...(p.rotate ? { rotate: (((Number(p.rotate) % 360) + 360) % 360) as 0 | 90 | 180 | 270 } : {}) + ...(p.rotate + ? { rotate: (((Number(p.rotate) % 360) + 360) % 360) as 0 | 90 | 180 | 270 } + : {}) } } const attrs = p.attrs as Record | undefined if (attrs && Object.keys(attrs).length) { const { labelOffset, ...rest } = attrs as Record - if (Array.isArray(labelOffset) && part.bb) part.bb.labelOffset = labelOffset as [number, number] + if (Array.isArray(labelOffset) && part.bb) + part.bb.labelOffset = labelOffset as [number, number] if (Object.keys(rest).length) part.attrs = rest as Record } const sp = sch.pos?.[part.id] @@ -324,7 +357,13 @@ function migrateV1(raw: Record, warnings: string[]): CircuitDoc }) const schRoute = sch.routes?.[key] if (schRoute) { - doc.wires.push({ id: newId('w'), from: c[0], to: c[1], view: 'sch', route: schRoute.slice() }) + doc.wires.push({ + id: newId('w'), + from: c[0], + to: c[1], + view: 'sch', + route: schRoute.slice() + }) } } else { pending.push({ conn: c, key }) @@ -357,7 +396,9 @@ function migrateV1(raw: Record, warnings: string[]): CircuitDoc color: typeof color === 'string' ? color : undefined, route: normalizeJourney(route) }) - warnings.push('A junction endpoint was migrated as a pending junction (resolved on first render).') + warnings.push( + 'A junction endpoint was migrated as a pending junction (resolved on first render).' + ) } // Preserve Wokwi/foreign keys. @@ -382,7 +423,9 @@ function migrateV1(raw: Record, warnings: string[]): CircuitDoc export function normalizeJourney(route: unknown): string[] | undefined { if (!Array.isArray(route)) return undefined const out = route.filter( - (s) => typeof s === 'string' && (/^[hv]-?[\d.]+$/.test(s) || /^d-?[\d.]+,-?[\d.]+$/.test(s) || s === '*') + (s) => + typeof s === 'string' && + (/^[hv]-?[\d.]+$/.test(s) || /^d-?[\d.]+,-?[\d.]+$/.test(s) || s === '*') ) as string[] return out.length ? out : undefined } diff --git a/src/renderer/src/circuit/core/netlist.ts b/src/renderer/src/circuit/core/netlist.ts index 1fa5ca9..41ee7e0 100644 --- a/src/renderer/src/circuit/core/netlist.ts +++ b/src/renderer/src/circuit/core/netlist.ts @@ -46,6 +46,24 @@ function nodeToken(name: string): string { return name.replace(/[^A-Za-z0-9_.]+/g, '_') } +/** + * Node name per net index (spec §10.2.2): a net named GND is `0`, other named + * nets keep their sanitized label, everything else is `n` in net order. + * + * Exported because the canvas needs the same names the netlist will use — a + * probe tag reading `n3` while SPICE calls that node `n4` is worse than no + * label at all, so there is exactly one implementation and both callers use it. + */ +export function nodeNamesForNets(net: NetModel): string[] { + let seq = 1 + return net.nets.map((_members, i) => { + const name = net.netNames[i] + if (name && name.toUpperCase() === 'GND') return '0' + if (name) return nodeToken(name) + return `n${seq++}` + }) +} + // ── part mapping ───────────────────────────────────────────────────────────── export interface SpiceCard { @@ -60,6 +78,14 @@ export type MappingKind = 'element' | 'transparent' | 'board' | 'unknown' export interface NetlistOptions { /** family for a part type (from the parts registry); improves matching */ familyOf?: (type: string) => string | undefined + /** + * Per-part starting values (parts/naming PART_DEFAULT_ATTRS), consulted + * before this table's generic keyword defaults. Without it a 2xAA pack that + * the user never edited would simulate as the generic 5 V source while the + * schematic prints 3 V beside it — the sheet must never disagree with what + * SPICE is given. + */ + defaultAttrsOf?: (type: string) => Record | undefined title?: string } @@ -210,8 +236,7 @@ const EMITTERS: { emit: (c) => { const plus = c.pins.find((p) => PLUS.some((re) => re.test(p))) const minus = c.pins.find((p) => MINUS.some((re) => re.test(p))) - const [a, b] = - plus && minus ? [c.nodeOf(plus), c.nodeOf(minus)] : two(c) + const [a, b] = plus && minus ? [c.nodeOf(plus), c.nodeOf(minus)] : two(c) return { lines: [`C${c.part.id} ${a} ${b} ${c.attr(['capacitance', 'value'], '100n')}`] } } }, @@ -310,7 +335,16 @@ function spiceScale(total: string, frac: number): string { const m = total.match(/^([0-9.eE+-]+)\s*(Meg|[kKmunpfgt])?$/) if (!m) return total const mult: Record = { - Meg: 1e6, k: 1e3, K: 1e3, m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12, f: 1e-15, g: 1e9, t: 1e12 + Meg: 1e6, + k: 1e3, + K: 1e3, + m: 1e-3, + u: 1e-6, + n: 1e-9, + p: 1e-12, + f: 1e-15, + g: 1e9, + t: 1e12 } const base = parseFloat(m[1]) * (m[2] ? mult[m[2]] : 1) const v = base * frac @@ -334,13 +368,7 @@ export function generateNetlist( const excluded: string[] = [] // node names per net (spec §10.2.2) - let seq = 1 - const nodeOfNet = net.nets.map((_members, i) => { - const name = net.netNames[i] - if (name && name.toUpperCase() === 'GND') return '0' - if (name) return nodeToken(name) - return `n${seq++}` - }) + const nodeOfNet = nodeNamesForNets(net) let ncSeq = 1 const lines: string[] = [] @@ -359,7 +387,9 @@ export function generateNetlist( if (entry.kind === 'transparent') continue if (entry.kind === 'board') { excluded.push(part.id) - warnings.push(`${part.id} (${part.type}) is a board — not simulated; drive its pins with sources`) + warnings.push( + `${part.id} (${part.type}) is a board — not simulated; drive its pins with sources` + ) continue } @@ -381,8 +411,9 @@ export function generateNetlist( return nodeOfNet[idx] }, attr: (names, fallback) => { + const defaults = opts.defaultAttrsOf?.(part.type) for (const n of names) { - const v = part.attrs?.[n] + const v = part.attrs?.[n] ?? defaults?.[n] if (v !== undefined) return spiceNum(v, fallback) } return fallback @@ -482,3 +513,75 @@ export function mapSimIssues(lines: string[], gen: NetlistResult): SimIssueRef { } return { parts: [...parts], nets: [...nets] } } + +// ── analysis sizing (crash guard) ──────────────────────────────────────────── + +const SPICE_SUFFIX: Record = { + meg: 1e6, + t: 1e12, + g: 1e9, + k: 1e3, + m: 1e-3, + u: 1e-6, + n: 1e-9, + p: 1e-12, + f: 1e-15 +} + +/** Numeric value of a SPICE-ish quantity ("10u", "4.7k", "1Meg", "1e3"). */ +export function spiceValue(v: string | number | boolean | undefined, fallback = NaN): number { + if (v === undefined || typeof v === 'boolean') return fallback + if (typeof v === 'number') return v + const s = spiceNum(v, '').toLowerCase() + const m = /^([-+]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]+)?)\s*(meg|[tgkmunpf])?/.exec(s) + if (!m) return fallback + const base = parseFloat(m[1]) + if (!Number.isFinite(base)) return fallback + return m[2] ? base * SPICE_SUFFIX[m[2]] : base +} + +/** + * Points an analysis will produce. ngspice materializes every vector at every + * point, so this is the number that decides whether a run costs a megabyte or + * eats the renderer: `.tran 1n 10` is ten billion points, and asking for it + * used to take the whole app down with it. The panel refuses anything past + * MAX_SIM_POINTS and says which knob to turn. + */ +export function estimatePoints(a: Analysis): number { + const n = (k: string, d: number): number => { + const v = spiceValue(a[k] as string | number | undefined, NaN) + return Number.isFinite(v) ? v : d + } + switch (a.kind) { + case 'op': + return 1 + case 'tran': { + const step = n('step', 10e-6) + const stop = n('stop', 10e-3) + const start = n('start', 0) + if (!(step > 0) || !(stop > start)) return 0 + return Math.floor((stop - start) / step) + 1 + } + case 'dc': { + const step = Math.abs(n('step', 0.1)) + const span = Math.abs(n('to', 5) - n('from', 0)) + if (!(step > 0)) return 0 + return Math.floor(span / step) + 1 + } + case 'ac': { + const pts = Math.max(1, n('points', 20)) + const f0 = n('fstart', 1) + const f1 = n('fstop', 1e6) + if (!(f1 > 0) || !(f0 > 0) || f1 <= f0) return Math.round(pts) + const variation = String(a.variation ?? 'dec') + if (variation === 'lin') return Math.round(pts) + const spans = variation === 'oct' ? Math.log2(f1 / f0) : Math.log10(f1 / f0) + return Math.round(pts * spans) + 1 + } + default: + return 0 + } +} + +/** Above this an analysis is refused before it reaches the engine. */ +export const MAX_SIM_POINTS = 250_000 diff --git a/src/renderer/src/circuit/core/nets.ts b/src/renderer/src/circuit/core/nets.ts index 0baa5e5..5261dce 100644 --- a/src/renderer/src/circuit/core/nets.ts +++ b/src/renderer/src/circuit/core/nets.ts @@ -16,12 +16,7 @@ * canonicalization for everyone — no more refKey/refStr divergence). */ -import { - isJunction, - splitPinRef, - type CircuitDoc, - type WireEnd -} from './model' +import { isJunction, splitPinRef, type CircuitDoc, type WireEnd } from './model' /** Canonical endpoint key: pins are "part:pin", junctions collapse to the host wire. */ export function endKey(end: WireEnd): string { diff --git a/src/renderer/src/circuit/core/probes.ts b/src/renderer/src/circuit/core/probes.ts index bb9c9f8..4dfeeab 100644 --- a/src/renderer/src/circuit/core/probes.ts +++ b/src/renderer/src/circuit/core/probes.ts @@ -47,7 +47,8 @@ export function probesIn(doc: CircuitDoc): ProbeInfo[] { } function vectorOfNode(run: SimRun, node: string): SimVector | undefined { - if (node === '0') return { name: 'v(0)', type: 'voltage', values: new Array(run.numPoints).fill(0) } + if (node === '0') + return { name: 'v(0)', type: 'voltage', values: new Array(run.numPoints).fill(0) } const key = `v(${node.toLowerCase()})` return run.vectors.find((v) => v.name.toLowerCase() === key) } @@ -97,7 +98,12 @@ export function diffProbeVectors( * the raw SPICE name: `v(n3)` → "Probe1" when a voltage probe sits on that * node; `i(v)` → the current probe's label; `vdiff()` → its label. */ -export function probeLabelFor(vecName: string, doc: CircuitDoc, net: NetModel, gen: NetlistResult): string | undefined { +export function probeLabelFor( + vecName: string, + doc: CircuitDoc, + net: NetModel, + gen: NetlistResult +): string | undefined { const diffM = /^vdiff\((.+)\)$/i.exec(vecName) if (diffM) { const p = doc.parts.find((p) => p.id.toLowerCase() === diffM[1].toLowerCase()) @@ -105,7 +111,9 @@ export function probeLabelFor(vecName: string, doc: CircuitDoc, net: NetModel, g } const iM = /^i\(v(.+)\)$/i.exec(vecName) if (iM) { - const p = doc.parts.find((p) => p.type === 'sim-probe-i' && p.id.toLowerCase() === iM[1].toLowerCase()) + const p = doc.parts.find( + (p) => p.type === 'sim-probe-i' && p.id.toLowerCase() === iM[1].toLowerCase() + ) return p ? String(p.attrs?.label ?? p.id) : undefined } const vM = /^v\((.+)\)$/i.exec(vecName) diff --git a/src/renderer/src/circuit/core/refdes.ts b/src/renderer/src/circuit/core/refdes.ts index a92da19..15e2cbb 100644 --- a/src/renderer/src/circuit/core/refdes.ts +++ b/src/renderer/src/circuit/core/refdes.ts @@ -3,7 +3,7 @@ * Part ids ARE refdes in circuit.json v2 (§6.4 of the tech spec). */ -import type { CircuitDoc } from './model' +import type { CircuitDoc, ViewId } from './model' /** Family → prefix map (extended by PartDef.prefix when the registry knows better). */ const FAMILY_PREFIX: Record = { @@ -58,3 +58,54 @@ export function isValidRefdes(id: string): boolean { function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } + +/** + * Assign conventional reference designators to every part in the document. + * + * A circuit migrated from a v1 `diagram.json` keeps whatever ids that file + * used — Fritzing/Wokwi slugs like `led`, `resistor`, `battery-aa_y90`. Those + * ids ARE what the schematic prints beside each symbol, so a migrated sheet + * reads like a directory listing instead of a schematic. Renumbering rewrites + * them to R1, C2, D3, LED4, U5… + * + * Order follows how a schematic is read: top to bottom, left to right, with + * rows banded so parts that sit at roughly the same height number left to + * right rather than by sub-pixel y. Parts already named correctly still get + * renumbered — partial renumbering is what produces R1, R7, R12 gaps. + * + * Returns a mapping of old id → new id, excluding parts whose id doesn't + * change; an empty result means there was nothing to do. + */ +export function renumberAll( + doc: CircuitDoc, + prefixOf: (type: string) => string, + view: ViewId = 'sch' +): Record { + const ROW_BAND = 48 // px — parts within this band count as the same row + const ordered = [...doc.parts].sort((a, b) => { + const pa = a[view] ?? a.bb ?? a.sch + const pb = b[view] ?? b.bb ?? b.sch + if (!pa && !pb) return a.id.localeCompare(b.id) + if (!pa) return 1 + if (!pb) return -1 + const rowA = Math.round(pa.y / ROW_BAND) + const rowB = Math.round(pb.y / ROW_BAND) + if (rowA !== rowB) return rowA - rowB + return pa.x - pb.x + }) + + const counters: Record = {} + const mapping: Record = {} + for (const part of ordered) { + const prefix = prefixOf(part.type) + counters[prefix] = (counters[prefix] ?? 0) + 1 + const next = `${prefix}${counters[prefix]}` + if (next !== part.id) mapping[part.id] = next + } + return mapping +} + +/** True when an id looks like a part-file slug rather than a refdes. */ +export function looksLikeSlug(id: string): boolean { + return /[_-]/.test(id) || /^[a-z]+$/.test(id) || /\d{3,}/.test(id) +} diff --git a/src/renderer/src/circuit/core/routing.ts b/src/renderer/src/circuit/core/routing.ts index 2f190f0..2c8fe02 100644 --- a/src/renderer/src/circuit/core/routing.ts +++ b/src/renderer/src/circuit/core/routing.ts @@ -108,7 +108,9 @@ export function decodeJourney(source: Pt, target: Pt, instr?: string[]): Pt[] { const a = fromSrc[fromSrc.length - 1] const b = fromTgt[0] - const mid: Pt[] = samePoint(a, b) ? [] : calculateOrthogonalPath(a.x, a.y, b.x, b.y, false).slice(1, -1) + const mid: Pt[] = samePoint(a, b) + ? [] + : calculateOrthogonalPath(a.x, a.y, b.x, b.y, false).slice(1, -1) return simplifyWirePoints([...fromSrc, ...mid, ...fromTgt]) } @@ -235,7 +237,10 @@ export function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[ points[index].x = x points[index].y = y if (index > 0) { - if (Math.abs(points[index - 1].y - points[index].y) < Math.abs(points[index - 1].x - points[index].x)) { + if ( + Math.abs(points[index - 1].y - points[index].y) < + Math.abs(points[index - 1].x - points[index].x) + ) { if (index - 1 > 0) points[index - 1].y = points[index].y else points[index].y = points[index - 1].y } else { @@ -244,7 +249,10 @@ export function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[ } } if (index < points.length - 1) { - if (Math.abs(points[index + 1].y - points[index].y) < Math.abs(points[index + 1].x - points[index].x)) { + if ( + Math.abs(points[index + 1].y - points[index].y) < + Math.abs(points[index + 1].x - points[index].x) + ) { if (index + 1 < points.length - 1) points[index + 1].y = points[index].y else points[index].y = points[index + 1].y } else { @@ -276,7 +284,8 @@ export function clampOntoSegment(p1: Pt, p2: Pt, x: number, y: number): Pt { /** Total polyline length. */ export function polylineLength(pts: Pt[]): number { let l = 0 - for (let i = 1; i < pts.length; i++) l += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y) + for (let i = 1; i < pts.length; i++) + l += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y) return l } diff --git a/src/renderer/src/circuit/core/simOutputs.ts b/src/renderer/src/circuit/core/simOutputs.ts new file mode 100644 index 0000000..0041381 --- /dev/null +++ b/src/renderer/src/circuit/core/simOutputs.ts @@ -0,0 +1,276 @@ +/** + * circuit/core/simOutputs — which points of the circuit an analysis reports + * (spec §10.4, CircuitLab-style output selection). + * + * ngspice hands back every node in the circuit; a schematic of any size turns + * that into an unreadable plot. CircuitLab's answer is to make the user name + * the outputs — click a node, or pick it from a list — and plot only those. + * This module is the pure half of that: the stable references we persist, and + * their resolution against a generated netlist. + * + * WHY REFERENCES AND NOT VECTOR NAMES: the netlist's node names (`n1`, `n2`…) + * are assigned in net order and change the moment a wire is added, so storing + * `v(n3)` in circuit.json would silently start pointing at a different node. + * We persist what the user actually picked instead: + * + * "v@R1:1" the net that R1's pin 1 sits on + * "i@V1" the current through V1 (a voltage source / current probe) + * "d@P2" a differential probe's own reading + * + * ZERO React, ZERO DOM — same rule as the rest of core/. + */ + +import { newId, type CircuitDoc, type Probe, type ViewId } from './model' +import { describeNet, type NetModel } from './nets' +import { nodeNamesForNets, type NetlistResult } from './netlist' + +export type OutputKind = 'v' | 'i' | 'd' + +/** Persisted form: `"@"`. */ +export type OutputRef = string + +export interface OutputChoice { + ref: OutputRef + kind: OutputKind + /** short display name ("VOUT", "node 3", "I(V1)") */ + label: string + /** longer explanation for a tooltip / secondary line */ + detail?: string + /** net index this output reads, when it has one (canvas highlighting) */ + netIndex?: number +} + +export interface ResolvedOutput extends OutputChoice { + /** lowercase vector name as ngspice reports it, when it can be resolved */ + vector?: string +} + +export function makeOutputRef(kind: OutputKind, target: string): OutputRef { + return `${kind}@${target}` +} + +export function parseOutputRef(ref: OutputRef): { kind: OutputKind; target: string } | null { + const i = ref.indexOf('@') + if (i < 0) return null + const kind = ref.slice(0, i) + if (kind !== 'v' && kind !== 'i' && kind !== 'd') return null + return { kind, target: ref.slice(i + 1) } +} + +/** Parts whose netlist card is a voltage source, so ngspice reports a current + * through them. Current sources are excluded: their current is the value the + * user typed, not a result. Kept in step with core/netlist's EMITTERS table. */ +const CURRENT_CAPABLE = /sim-vdc|sim-vsin|sim-probe-i|voltage source|battery|sine|waveform/i + +/** The stable pin reference we use to name a net: its lowest-sorted member. */ +function anchorPinOf(net: NetModel, index: number): string | undefined { + return (net.nets[index] ?? [])[0] +} + +/** Output reference for whichever net a pin belongs to. */ +export function netOutputRef(net: NetModel, pinRef: string): OutputRef | null { + const idx = net.pinToNet.get(pinRef) + if (idx == null) return null + const anchor = anchorPinOf(net, idx) + return anchor ? makeOutputRef('v', anchor) : null +} + +/** Output reference for a net given by index (a wire click resolves to this). */ +export function netOutputRefByIndex(net: NetModel, index: number): OutputRef | null { + const anchor = anchorPinOf(net, index) + return anchor ? makeOutputRef('v', anchor) : null +} + +/** Net an output reads, or undefined for a current/diff output. */ +export function netIndexOfOutput(ref: OutputRef, net: NetModel): number | undefined { + const p = parseOutputRef(ref) + if (!p || p.kind !== 'v') return undefined + return net.pinToNet.get(p.target) +} + +/** + * What a net is called on screen: its label if it has one, otherwise the same + * `n` token the netlist will hand ngspice — so the tag on the canvas, the + * row in the Outputs list and the vector in the results all read alike. + */ +export function netLabelFor(net: NetModel, index: number, nodeNames?: string[]): string { + const name = net.netNames[index] + if (name) return name + const node = (nodeNames ?? nodeNamesForNets(net))[index] + if (node && node !== '0') return node + const anchor = anchorPinOf(net, index) + return anchor ? `node ${anchor}` : `net ${index + 1}` +} + +/** + * Everything this circuit can report, ready for a checkbox list: one entry per + * multi-pin net (ground excluded — it is 0 by definition), one per + * current-measuring part, one per differential probe. + */ +export function availableOutputs( + doc: CircuitDoc, + net: NetModel, + familyOf?: (type: string) => string | undefined +): OutputChoice[] { + const out: OutputChoice[] = [] + const nodeNames = nodeNamesForNets(net) + net.nets.forEach((members, i) => { + if (members.length < 2) return + if ((net.netNames[i] ?? '').toUpperCase() === 'GND') return + const ref = netOutputRefByIndex(net, i) + if (!ref) return + out.push({ + ref, + kind: 'v', + label: netLabelFor(net, i, nodeNames), + detail: describeNet(net, i), + netIndex: i + }) + }) + for (const p of doc.parts) { + if (p.type === 'sim-probe-vdiff') { + out.push({ + ref: makeOutputRef('d', p.id), + kind: 'd', + label: String(p.attrs?.label ?? p.id), + detail: `differential voltage across ${p.id}` + }) + continue + } + if (CURRENT_CAPABLE.test(`${p.type} ${familyOf?.(p.type) ?? ''}`)) { + out.push({ + ref: makeOutputRef('i', p.id), + kind: 'i', + label: `I(${p.id})`, + detail: `current through ${p.id}` + }) + } + } + return out +} + +/** Attach the ngspice vector name each reference resolves to for THIS run. */ +export function resolveOutputs( + refs: readonly OutputRef[], + doc: CircuitDoc, + net: NetModel, + gen: NetlistResult | null, + familyOf?: (type: string) => string | undefined +): ResolvedOutput[] { + const known = new Map(availableOutputs(doc, net, familyOf).map((c) => [c.ref, c])) + const out: ResolvedOutput[] = [] + for (const ref of refs) { + const parsed = parseOutputRef(ref) + if (!parsed) continue + const base: OutputChoice = known.get(ref) ?? { + ref, + kind: parsed.kind, + label: parsed.target, + detail: 'no longer in the circuit' + } + out.push({ ...base, vector: vectorForOutput(ref, net, gen) }) + } + return out +} + +/** The lowercase ngspice vector name a reference reads, for a given run. */ +export function vectorForOutput( + ref: OutputRef, + net: NetModel, + gen: NetlistResult | null +): string | undefined { + const parsed = parseOutputRef(ref) + if (!parsed) return undefined + if (parsed.kind === 'd') return `vdiff(${parsed.target})`.toLowerCase() + if (parsed.kind === 'i') { + const devices = gen?.elementOfPart[parsed.target] ?? [] + const source = devices.find((d) => d.startsWith('v')) + return source ? `i(${source})`.toLowerCase() : undefined + } + const idx = net.pinToNet.get(parsed.target) + if (idx == null || !gen) return undefined + const node = gen.nodeOfNet[idx] + return node ? `v(${node})`.toLowerCase() : undefined +} + +/** + * Predicate for "does this result vector belong to the picked outputs?". + * An empty pick means "everything" — same as before outputs existed, so a + * circuit the user hasn't curated still plots. + */ +export function outputFilter(resolved: readonly ResolvedOutput[]): (vecName: string) => boolean { + if (!resolved.length) return () => true + const want = new Set( + resolved.map((r) => r.vector).filter((v): v is string => typeof v === 'string') + ) + if (!want.size) return () => true + return (name: string) => want.has(name.toLowerCase()) +} + +/** Display name for a resolved vector, preferring the picked output's label. */ +export function outputLabelFor( + resolved: readonly ResolvedOutput[] +): (vecName: string) => string | undefined { + const byVector = new Map() + for (const r of resolved) if (r.vector) byVector.set(r.vector, r.label) + return (name: string) => byVector.get(name.toLowerCase()) +} + +// ── placed probes ──────────────────────────────────────────────────────────── +// +// A picked output is not a highlight — it is a tag on the sheet. These helpers +// keep doc.sim.probes and the Outputs list describing the same set, so ticking +// a box in the panel and clicking a wire on the canvas do the same thing. + +/** Default distance a fresh tag sits above the point that was clicked. */ +export const PROBE_DEFAULT_OFFSET: [number, number] = [0, -26] + +export function probeKindForRef(ref: OutputRef): Probe['kind'] { + const parsed = parseOutputRef(ref) + if (parsed?.kind === 'i') return 'current' + if (parsed?.kind === 'd') return 'diff' + return 'voltage' +} + +/** The probe measuring a given output, if one is placed. */ +export function probeFor(probes: readonly Probe[], ref: OutputRef): Probe | undefined { + return probes.find((p) => p.at === ref) +} + +/** The outputs the placed probes ask for — the panel's "picked" set. */ +export function outputRefsOf(probes: readonly Probe[]): OutputRef[] { + return probes.map((p) => p.at) +} + +/** + * A new probe for an output. `offset` is where its tag sits relative to the + * anchor in the view it was placed from; the other view gets the default, so + * a probe dropped on the schematic is still findable on the breadboard. + */ +export function makeProbe( + ref: OutputRef, + view: ViewId, + offset: [number, number] = PROBE_DEFAULT_OFFSET +): Probe { + return { + id: newId('pb'), + kind: probeKindForRef(ref), + at: ref, + [view]: offset, + ...(view === 'sch' ? { bb: PROBE_DEFAULT_OFFSET } : { sch: PROBE_DEFAULT_OFFSET }) + } +} + +/** Which part a probe hangs off — the anchor for its leader line. */ +export function probeAnchor( + ref: OutputRef +): { kind: OutputKind; part: string; pin?: string } | null { + const parsed = parseOutputRef(ref) + if (!parsed) return null + if (parsed.kind === 'v') { + const i = parsed.target.lastIndexOf(':') + if (i < 0) return { kind: 'v', part: parsed.target } + return { kind: 'v', part: parsed.target.slice(0, i), pin: parsed.target.slice(i + 1) } + } + return { kind: parsed.kind, part: parsed.target } +} diff --git a/src/renderer/src/circuit/core/store.ts b/src/renderer/src/circuit/core/store.ts index 3b510fa..d792c33 100644 --- a/src/renderer/src/circuit/core/store.ts +++ b/src/renderer/src/circuit/core/store.ts @@ -12,11 +12,7 @@ */ import type { Command } from './commands' -import { - parseCircuitFile, - serializeDoc, - type CircuitDoc -} from './model' +import { parseCircuitFile, serializeDoc, type CircuitDoc } from './model' const MAX_UNDO = 200 /** Consecutive same-mergeKey commands within this window merge (ms). */ @@ -76,9 +72,7 @@ export class CircuitStore { const now = Date.now() const top = this.undoStack[this.undoStack.length - 1] const merged = - cmd.mergeKey !== undefined && - top?.mergeKey === cmd.mergeKey && - now - top.at < MERGE_WINDOW + cmd.mergeKey !== undefined && top?.mergeKey === cmd.mergeKey && now - top.at < MERGE_WINDOW if (!merged) { this.undoStack.push({ doc: this.doc, label: cmd.label, mergeKey: cmd.mergeKey, at: now }) diff --git a/src/renderer/src/circuit/parts/fzpz.ts b/src/renderer/src/circuit/parts/fzpz.ts index ec87f74..9d574f3 100644 --- a/src/renderer/src/circuit/parts/fzpz.ts +++ b/src/renderer/src/circuit/parts/fzpz.ts @@ -112,7 +112,10 @@ function localAnchor(el: Element): Anchor | null { } } if (tag === 'polygon' || tag === 'polyline') { - const pts = (el.getAttribute('points') || '').trim().split(/[\s,]+/).map(parseFloat) + const pts = (el.getAttribute('points') || '') + .trim() + .split(/[\s,]+/) + .map(parseFloat) if (pts.length >= 2) return bboxCenter(chunk(pts)) } if (tag === 'path') { @@ -211,7 +214,10 @@ function extractView( const svg = doc.getElementsByTagName('svg')[0] if (!svg) return null - let vb = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(parseFloat) + let vb = (svg.getAttribute('viewBox') || '') + .trim() + .split(/[\s,]+/) + .map(parseFloat) const wPx = toPx(svg.getAttribute('width')) const hPx = toPx(svg.getAttribute('height')) if (vb.length !== 4 || vb.some(Number.isNaN)) { @@ -235,8 +241,7 @@ function extractView( continue } // terminal is the precise wire point in schematic; pin element in breadboard - const ids = - view === 'schematic' ? [cv.terminalId, cv.svgId] : [cv.svgId, cv.terminalId] + const ids = view === 'schematic' ? [cv.terminalId, cv.svgId] : [cv.svgId, cv.terminalId] let pt: Pt2 | null = null for (const id of ids) { if (!id) continue @@ -322,9 +327,7 @@ export async function importFzpz(bytes: Uint8Array, fileName?: string): Promise< // "part..fzp" → ; fall back to the dropped filename / title const base = - /^part\.(.+)\.fzp$/i.exec(fzpEntry.name)?.[1] ?? - fileName?.replace(/\.fzpz$/i, '') ?? - fzp.title + /^part\.(.+)\.fzp$/i.exec(fzpEntry.name)?.[1] ?? fileName?.replace(/\.fzpz$/i, '') ?? fzp.title const type = slug(base) // view image "breadboard/foo.svg" is stored flat as "svg.breadboard.foo.svg" diff --git a/src/renderer/src/circuit/parts/labels.ts b/src/renderer/src/circuit/parts/labels.ts new file mode 100644 index 0000000..77b33c3 --- /dev/null +++ b/src/renderer/src/circuit/parts/labels.ts @@ -0,0 +1,160 @@ +/** + * circuit/parts/labels — the text that rides beside a schematic symbol. + * + * A schematic is only half symbols; the other half is "R1" and "220 Ω". The + * spec (§8) puts the reference designator above the symbol and the value + * below it, and that is what makes a sheet readable at a glance: you find the + * part by its refdes and read its behaviour off its value without opening an + * inspector. + * + * The value shown is the value that will actually be SIMULATED. It comes from + * the same `simAttrsFor` table the netlist generator uses, including the same + * defaults — so a resistor the user never edited reads "220 Ω" on the sheet + * and emits 220 Ω into SPICE. A sheet that showed nothing until you typed a + * value would quietly lie about what the simulator is doing. + * + * Pure formatting, no React and no DOM: the canvas and the image exporter both + * render from this, so the exported PNG matches the screen. + */ + +import { simAttrsFor } from '../core/netlist' +import type { CircuitPart } from '../core/model' +import { defaultAttrsFor } from './naming' +import { FONT_REFDES } from './style' + +/** SPICE magnitude suffix → the prefix a human expects to read. */ +const PREFIX: Record = { + t: 'T', + g: 'G', + meg: 'M', + k: 'k', + m: 'm', + u: 'µ', + n: 'n', + p: 'p', + f: 'f' +} + +/** Units we're willing to print. Everything else (0–1, true/false) is a + * setting, not a value, and belongs in the inspector rather than on the sheet. */ +const PRINTABLE_UNIT = /^(V|A|Ω|F|H|Hz|W|s)$/ + +/** + * Render a SPICE-style value with its unit: ("100n", "F") → "100 nF", + * ("10k", "Ω") → "10 kΩ", ("5", "V") → "5 V". + */ +export function formatValue(raw: string, unit: string): string { + const m = /^\s*(-?[\d.]+(?:[eE][-+]?\d+)?)\s*(Meg|meg|[a-zA-Z])?\s*$/.exec(String(raw)) + if (!m) return `${String(raw).trim()} ${unit}`.trim() + const n = m[1] + const suffix = m[2] ? PREFIX[m[2].toLowerCase()] : '' + // an unrecognised suffix is probably already a unit ("5V") — don't double it + if (m[2] && suffix === undefined) return `${n} ${unit}` + return `${n} ${suffix ?? ''}${unit}`.replace(/\s+/g, ' ').trim() +} + +/** The reference designator drawn above the symbol (R1, C3, LED2…). */ +export function refdesOf(part: CircuitPart): string { + return String(part.attrs?.label ?? part.id) +} + +/** + * The value line drawn below the symbol, or '' when the part has no value + * worth printing (a board, an LED, a bare diode). Sources print two terms — + * a sine source is meaningless without both amplitude and frequency. + */ +export function valueOf(part: CircuitPart, simFamily?: string): string { + const specs = simAttrsFor(part.type, simFamily ?? '') + if (!specs.length) return '' + const terms: string[] = [] + const partDefaults = defaultAttrsFor(part.type) + for (const spec of specs) { + const unit = (spec.hint ?? '').split(/\s+/)[0] + if (!PRINTABLE_UNIT.test(unit)) continue + const raw = part.attrs?.[spec.key] ?? partDefaults?.[spec.key] + const value = raw === undefined || raw === '' ? spec.default : String(raw) + if (value === undefined || value === '') continue + // a zero DC offset is noise on the sheet, not information + if (spec.key === 'offset' && parseFloat(value) === 0) continue + terms.push(formatValue(value, unit)) + if (terms.length === 2) break + } + return terms.join(' ') +} + +/** The on-screen box a symbol occupies once its placement rotation is applied. */ +export interface VisibleBox { + /** Offset from the placement origin to the visible top-left. */ + left: number + top: number + w: number + h: number +} + +/** + * Where a rotated symbol actually sits. Rotation happens about the symbol's + * centre, so a 90°-turned part keeps its placement origin but its ink moves: + * a 57.6 x 19.2 resistor stood on end covers 19.2 x 57.6, offset up and right. + * Labels anchor to THIS box, not the unrotated one — otherwise a vertical + * part's value text lands on top of the wire running past it. + */ +export function visibleBox(w: number, h: number, rotate?: number): VisibleBox { + const quarter = rotate === 90 || rotate === 270 + if (!quarter) return { left: 0, top: 0, w, h } + return { left: (w - h) / 2, top: (h - w) / 2, w: h, h: w } +} + +/** Gap between a symbol's visible edge and its text. */ +const GAP = 5 + +export interface LabelLayout { + /** The box the symbol visibly occupies, offset from the placement origin. */ + box: VisibleBox + /** True when the part runs vertically, so text sits beside it rather than under. */ + side: boolean + /** Top-left of the refdes text block, offset from the placement origin. */ + refdes: [number, number] + /** Top-left of the value text block. */ + value: [number, number] +} + +/** + * Where a symbol's refdes and value go. + * + * The deciding factor is which way the part's PINS run, not its rotation: a + * source drawn tall (pins top and bottom) and a resistor turned on its end are + * the same problem. Text under a vertical part lands on the wire leaving its + * bottom pin, so vertical parts get their text stacked beside them — which is + * also what every schematic tool does, and what a reader expects. + */ +export function labelLayout( + w: number, + h: number, + pins: Record, + rotate?: number +): LabelLayout { + const box = visibleBox(w, h, rotate) + const xs = Object.values(pins).map(([x]) => x) + const ys = Object.values(pins).map(([, y]) => y) + let spreadX = xs.length ? Math.max(...xs) - Math.min(...xs) : 0 + let spreadY = ys.length ? Math.max(...ys) - Math.min(...ys) : 0 + if (rotate === 90 || rotate === 270) [spreadX, spreadY] = [spreadY, spreadX] + const side = spreadY > spreadX + + if (side) { + const x = box.left + box.w + GAP + const mid = box.top + box.h / 2 + return { + box, + side, + refdes: [x, mid - FONT_REFDES - 2], + value: [x, mid + 2] + } + } + return { + box, + side, + refdes: [box.left, box.top - (FONT_REFDES + GAP)], + value: [box.left, box.top + box.h + 3] + } +} diff --git a/src/renderer/src/circuit/parts/naming.ts b/src/renderer/src/circuit/parts/naming.ts new file mode 100644 index 0000000..c34e961 --- /dev/null +++ b/src/renderer/src/circuit/parts/naming.ts @@ -0,0 +1,551 @@ +/** + * circuit/parts/naming — human-readable part names and categories. + * + * Fritzing-imported parts arrive with whatever the .fzp author typed: labels + * like "MOSFET-NCHANNEL", "FUSE" or "led", and families like + * "microcontroller board (lilypad)" or "Capacitor [bidirectional]". That + * leaks straight into the components rail, the inspector and the refdes + * generator. This module is the single place that turns raw part metadata + * into something a human wants to read: + * + * - `PART_NAMING` — a curated table for parts we ship (exact names, the + * category they belong in, and the refdes prefix they deserve). + * - `humanizeLabel` / `humanizeCategory` — the fallback for anything we + * don't know: dropped .fzpz files, pack installs, user-authored parts. + * + * Naming is applied centrally in `lib/partsLibrary` (manifest metadata, lazy + * part loads and `registerPart`), so every surface — palette, inspector, + * tray, exports — shows the same name without each one re-deriving it. + * + * IMPORTANT: the SPICE netlist generator and the refdes assigner match on + * keyword regexes over `" "`. Renaming a family could silently + * change how a part simulates, so a curated entry can carry: + * - `sim` — the keyword string handed to the netlist generator instead + * of the display category (keeps emitter matching stable), and + * - `prefix` — an explicit refdes prefix (R, C, D, Q…), which beats the + * keyword guess in core/refdes. + * + * Zero imports by design: this is pure string data, so `lib/partsLibrary` can + * depend on it without a cycle. + */ + +export interface PartNaming { + /** Display name shown in the palette, inspector and tray. */ + label: string + /** Display category — the collapsible group in the components rail. */ + category: string + /** Keyword string for the SPICE emitter match (defaults to the category). */ + sim?: string + /** Explicit refdes prefix; beats core/refdes keyword matching. */ + prefix?: string +} + +/** + * Category display order in the components rail. Anything not listed sorts + * alphabetically after these, with `Uncategorized` always last. + */ +export const CATEGORY_ORDER: string[] = [ + 'tinyStudio Boards', + 'Breadboards', + 'Sources', + 'Passive Elements', + 'Diodes', + 'Transistors', + 'Switches', + 'Relays', + 'Sensors', + 'Displays', + 'Audio', + 'Motors & Actuators', + 'Power', + 'Integrated Circuits', + 'Connectors', + 'Probes & Meters', + 'Imported', + 'Custom', + 'Uncategorized' +] + +const ORDER_INDEX = new Map(CATEGORY_ORDER.map((c, i) => [c, i])) + +/** Sort comparator for category groups (curated order, then alphabetical). */ +export function compareCategories(a: string, b: string): number { + const ia = ORDER_INDEX.get(a) + const ib = ORDER_INDEX.get(b) + if (ia !== undefined && ib !== undefined) return ia - ib + if (ia !== undefined) return -1 + if (ib !== undefined) return 1 + return a.localeCompare(b) +} + +/** + * Curated names for every part tinyStudio ships. Keyed by PartDef.type. + * + * Rules of thumb used here: + * - name the component, not the Fritzing file ("Pushbutton", not "pushbutton") + * - keep the distinguishing detail in parentheses (package, pitch, rating) + * - never bake a value into the name — values live in `attrs` and render as + * the schematic's value text (a resistor is "Resistor", not "220 Ω Resistor") + */ +export const PART_NAMING: Record = { + // ── passives ────────────────────────────────────────────────────────────── + resistor: { label: 'Resistor', category: 'Passive Elements', prefix: 'R' }, + 'potentiometer-rotary-16mm-5': { + label: 'Rotary Potentiometer (16 mm)', + category: 'Passive Elements', + sim: 'potentiometer', + prefix: 'RV' + }, + 'potentiometer-trimmer-6mm-5': { + label: 'Trimmer Potentiometer (6 mm)', + category: 'Passive Elements', + sim: 'trimmer potentiometer', + prefix: 'RV' + }, + 'capacitor-ceramic-100mil': { + label: 'Ceramic Capacitor (0.1 in)', + category: 'Passive Elements', + sim: 'capacitor', + prefix: 'C' + }, + 'capacitor-ceramic-200mil': { + label: 'Ceramic Capacitor (0.2 in)', + category: 'Passive Elements', + sim: 'capacitor', + prefix: 'C' + }, + 'capacitor-electrolytic-medium': { + label: 'Electrolytic Capacitor', + category: 'Passive Elements', + sim: 'capacitor polarized', + prefix: 'C' + }, + 'smd-inductor-0805': { + label: 'Inductor (0805)', + category: 'Passive Elements', + sim: 'inductor', + prefix: 'L' + }, + 'sparkfun-passives-fuse-x20mm': { + label: 'Fuse (20 mm)', + category: 'Passive Elements', + sim: 'fuse', + prefix: 'F' + }, + + // ── diodes ──────────────────────────────────────────────────────────────── + 'diode-1n4001-300mil': { + label: 'Rectifier Diode (1N4001)', + category: 'Diodes', + sim: 'diode', + prefix: 'D' + }, + 'diode-zener-0-5w-3-6v-300mil': { + label: 'Zener Diode (3.6 V, 0.5 W)', + category: 'Diodes', + sim: 'zener diode', + prefix: 'D' + }, + 'led-generic-3mm': { label: 'LED (3 mm)', category: 'Diodes', sim: 'led', prefix: 'LED' }, + 'led-generic-5mm': { label: 'LED (5 mm)', category: 'Diodes', sim: 'led', prefix: 'LED' }, + + // ── transistors ─────────────────────────────────────────────────────────── + 'transistor-signal-npn-to92-ebc': { + label: 'NPN Transistor (TO-92)', + category: 'Transistors', + sim: 'npn bipolar transistor', + prefix: 'Q' + }, + 'transistor-signal-pnp-to92-ebc': { + label: 'PNP Transistor (TO-92)', + category: 'Transistors', + sim: 'pnp bipolar transistor', + prefix: 'Q' + }, + 'sparkfun-discretesemi-mosfet-nchannel-pth': { + label: 'N-Channel MOSFET', + category: 'Transistors', + sim: 'n-channel mosfet', + prefix: 'Q' + }, + + // ── switches & relays ───────────────────────────────────────────────────── + pushbutton: { + label: 'Pushbutton (Momentary)', + category: 'Switches', + sim: 'button', + prefix: 'SW' + }, + 'switch-spst': { + label: 'Slide Switch (SPST)', + category: 'Switches', + sim: 'switch', + prefix: 'SW' + }, + 'reedswitch-500mil': { + label: 'Reed Switch', + category: 'Switches', + sim: 'reed switch', + prefix: 'SW' + }, + // 4-pin coil + contact relay: not a 2-terminal switch, so keep it OUT of the + // switch emitter's way (it would emit a bogus 1 mΩ resistor). + 'te-relay': { label: 'Relay (SPST, 5 V Coil)', category: 'Relays', sim: 'relay', prefix: 'K' }, + + // ── sensors ─────────────────────────────────────────────────────────────── + 'ldr-photocell-300mil-v5': { + label: 'Photoresistor (LDR)', + category: 'Sensors', + sim: 'ldr photocell', + prefix: 'R' + }, + 'thermistor-300mil': { + label: 'Thermistor (10 kΩ NTC)', + category: 'Sensors', + sim: 'thermistor', + prefix: 'R' + }, + 'ir-receiver-v14': { label: 'IR Receiver', category: 'Sensors', sim: 'ir receiver', prefix: 'U' }, + 'sparkfun-sensors-mic-electret-smd': { + label: 'Electret Microphone', + category: 'Sensors', + sim: 'microphone', + prefix: 'MK' + }, + 'piezo-sensor': { label: 'Piezo Element', category: 'Audio', sim: 'piezo', prefix: 'LS' }, + + // ── output devices ──────────────────────────────────────────────────────── + 'buzzer-v15': { label: 'LilyPad Buzzer', category: 'Audio', sim: 'buzzer piezo', prefix: 'LS' }, + servo: { label: 'Servo Motor', category: 'Motors & Actuators', sim: 'servo', prefix: 'M' }, + '7segment-100-cat': { + label: '7-Segment Display (Common Cathode)', + category: 'Displays', + sim: 'seven segment display', + prefix: 'DS' + }, + + // ── power ───────────────────────────────────────────────────────────────── + 'battery-aa': { + label: '2× AA Battery Pack (3 V)', + category: 'Sources', + sim: 'battery', + prefix: 'BT' + }, + 'voltage-regulator-7805': { + label: '5 V Linear Regulator (7805)', + category: 'Power', + sim: 'voltage regulator', + prefix: 'U' + }, + + // ── built-in simulation parts (art generated in simParts/simProbes) ─────── + 'sim-vdc': { label: 'DC Voltage Source', category: 'Sources', sim: 'sim-vdc', prefix: 'V' }, + 'sim-vsin': { label: 'Sine Voltage Source', category: 'Sources', sim: 'sim-vsin', prefix: 'V' }, + 'sim-idc': { label: 'DC Current Source', category: 'Sources', sim: 'sim-idc', prefix: 'I' }, + 'sim-probe-v': { + label: 'Voltage Probe', + category: 'Probes & Meters', + sim: 'sim-probe-v', + prefix: 'P' + }, + 'sim-probe-vdiff': { + label: 'Differential Voltage Probe', + category: 'Probes & Meters', + sim: 'sim-probe-vdiff', + prefix: 'P' + }, + 'sim-probe-i': { + label: 'Current Probe', + category: 'Probes & Meters', + sim: 'sim-probe-i', + prefix: 'P' + }, + + // ── breadboards ─────────────────────────────────────────────────────────── + 'breadboard-mini': { + label: 'Breadboard (Mini)', + category: 'Breadboards', + sim: 'breadboard', + prefix: 'BB' + }, + 'breadboard-half': { + label: 'Breadboard (Half+)', + category: 'Breadboards', + sim: 'breadboard', + prefix: 'BB' + }, + 'breadboard-full': { + label: 'Breadboard (Full+)', + category: 'Breadboards', + sim: 'breadboard', + prefix: 'BB' + } +} + +/** + * Per-part starting values, applied when a part is placed. + * + * The SPICE emitter table only knows generic defaults ("a resistor is 220 Ω", + * "a capacitor is 100 nF"), because it matches on keywords. A 2xAA pack is + * 3 V, not the generic source's 5 V, and an electrolytic is microfarads, not + * nanofarads. These land in the part's `attrs` on placement, so the value + * printed on the schematic is the value that gets simulated — the sheet never + * shows a number the netlist disagrees with. + */ +export const PART_DEFAULT_ATTRS: Record> = { + 'battery-aa': { voltage: '3' }, + 'capacitor-electrolytic-medium': { capacitance: '10u' }, + 'capacitor-ceramic-100mil': { capacitance: '100n' }, + 'capacitor-ceramic-200mil': { capacitance: '100n' }, + 'smd-inductor-0805': { inductance: '10u' }, + 'ldr-photocell-300mil-v5': { resistance: '10k' }, + 'thermistor-300mil': { resistance: '10k' }, + 'potentiometer-rotary-16mm-5': { resistance: '10k', position: '0.5' }, + 'potentiometer-trimmer-6mm-5': { resistance: '10k', position: '0.5' }, + resistor: { resistance: '220' } +} + +/** Starting attrs for a freshly placed part, if it has opinions about them. */ +export function defaultAttrsFor(type: string): Record | undefined { + const a = PART_DEFAULT_ATTRS[type] + return a ? { ...a } : undefined +} + +// ── fallback humanizer ─────────────────────────────────────────────────────── + +/** Tokens that are always fully uppercase when they stand alone as a word. */ +const ACRONYMS = new Set([ + 'ac', + 'adc', + 'aa', + 'aaa', + 'bjt', + 'dac', + 'dc', + 'dpdt', + 'dpst', + 'eeprom', + 'emf', + 'esp32', + 'fet', + 'gnd', + 'gpio', + 'gps', + 'i2c', + 'ic', + 'imu', + 'ir', + 'jfet', + 'lcd', + 'ldr', + 'led', + 'lipo', + 'mcu', + 'mems', + 'mic', + 'mosfet', + 'npn', + 'ntc', + 'oled', + 'pcb', + 'pnp', + 'ptc', + 'pth', + 'pwm', + 'rf', + 'rgb', + 'rtc', + 'sd', + 'smd', + 'spdt', + 'spi', + 'spst', + 'tft', + 'tht', + 'usb', + 'uart', + 'uv', + 'vcc', + 'vin' +]) + +/** Words that stay lowercase inside a name (never first or last). */ +const MINOR = new Set([ + 'a', + 'an', + 'and', + 'as', + 'at', + 'by', + 'for', + 'in', + 'of', + 'on', + 'or', + 'the', + 'to', + 'with' +]) + +/** Package families that read better hyphenated + uppercase: TO92 → TO-92. */ +const PACKAGE_RE = /^(to|sot|soic|tssop|qfn|dip|sod|smd)-?(\d+[a-z]?)$/i + +/** A bare measurement token: 3mm, 300mil, 0805, 10k, 100n, 5v, 0.25w. */ +const UNIT_RE = /^([\d.]+)(mm|cm|mil|in|k|m|meg|n|u|p|v|w|a|ma|f|nf|uf|pf|ohm|ω|hz|khz|mhz)$/i + +const UNIT_CASE: Record = { + mm: 'mm', + cm: 'cm', + mil: 'mil', + in: 'in', + k: 'k', + m: 'm', + meg: 'M', + n: 'n', + u: 'µ', + p: 'p', + v: 'V', + w: 'W', + a: 'A', + ma: 'mA', + f: 'F', + nf: 'nF', + uf: 'µF', + pf: 'pF', + ohm: 'Ω', + ω: 'Ω', + hz: 'Hz', + khz: 'kHz', + mhz: 'MHz' +} + +/** Brand-style camelCase that must survive intact: tinyCore, tinyGlow. */ +const BRAND_CAMEL_RE = /^[a-z]+[A-Z][A-Za-z]*$/ + +/** Noise suffixes Fritzing bakes into part ids: rotation, revision, variant. */ +const NOISE_RE = /(?:[_-](?:[xy]\d{1,3}|v\d+|rev\d*|\d+(?:st|nd|rd|th)))+$/i + +function capitalize(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1) +} + +function humanizeWord(word: string, isEdge: boolean): string { + const lower = word.toLowerCase() + if (ACRONYMS.has(lower)) return lower.toUpperCase() + if (!isEdge && MINOR.has(lower)) return lower + const pkg = PACKAGE_RE.exec(word) + if (pkg) return `${pkg[1].toUpperCase()}-${pkg[2].toUpperCase()}` + const unit = UNIT_RE.exec(word) + if (unit) return `${unit[1]} ${UNIT_CASE[unit[2].toLowerCase()] ?? unit[2]}` + // already mixed-case and deliberate (tinyCore, LilyPad) — leave it alone + if (/[a-z][A-Z]/.test(word)) return word + if (word === word.toUpperCase() && word.length > 3) return capitalize(lower) + return capitalize(word) +} + +/** + * Turn a raw part id or Fritzing title into a readable name: + * "led" → "LED" + * "battery-aa_y90" → "AA Battery" + * "MOSFET-NCHANNEL" → "MOSFET Nchannel" + * "diode-zener-0-5w-300mil" → "Diode Zener 0 5 W 300 mil" + * Curated entries in PART_NAMING always win over this; it exists so a dropped + * .fzpz never shows up in the rail as raw slug text. + */ +export function humanizeLabel(raw: string): string { + const cleaned = String(raw ?? '').replace(NOISE_RE, '') + const words: string[] = [] + for (const token of cleaned.split(/[_\-.\s]+/).filter(Boolean)) { + // brand camelCase (tinyCore, tinyGlow) is deliberate — never split it + if (BRAND_CAMEL_RE.test(token)) words.push(token) + else + words.push( + ...token + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(' ') + .filter(Boolean) + ) + } + if (!words.length) return 'Part' + return words + .map((w, i) => humanizeWord(w, i === 0 || i === words.length - 1)) + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Map a raw Fritzing family onto one of our display categories, using the + * same keyword sniffing the netlist generator does. Unknown families are + * humanized rather than dropped, so a pack can introduce its own category. + */ +export function humanizeCategory(raw: string | undefined): string { + const f = String(raw ?? '').toLowerCase() + if (!f.trim()) return 'Uncategorized' + if (/breadboard/.test(f)) return 'Breadboards' + if (/tinystudio|tinyboard/.test(f)) return 'tinyStudio Boards' + if (/probe|meter|multimeter/.test(f)) return 'Probes & Meters' + if (/battery|power supply|source|cell/.test(f)) return 'Sources' + if (/resistor|capacitor|inductor|potentiometer|trimmer|fuse|crystal|ferrite|passive/.test(f)) + return 'Passive Elements' + if (/\bled\b|diode|rectifier|zener|photodiode/.test(f)) return 'Diodes' + if (/transistor|mosfet|jfet|igbt|\bfet\b|thyristor|triac/.test(f)) return 'Transistors' + if (/relay|contactor|solenoid/.test(f)) return 'Relays' + if (/switch|button|encoder/.test(f)) return 'Switches' + if (/sensor|photo-?resistor|photocell|thermistor|accelerometer|gyro|\bir\b|proximity/.test(f)) + return 'Sensors' + if (/display|lcd|oled|segment|matrix|screen/.test(f)) return 'Displays' + if (/speaker|buzzer|piezo|audio|microphone|\bmic\b/.test(f)) return 'Audio' + if (/motor|servo|stepper|actuator|fan|pump/.test(f)) return 'Motors & Actuators' + if (/regulator|converter|charger|\bpower\b/.test(f)) return 'Power' + if (/connector|header|jack|socket|terminal|\busb\b/.test(f)) return 'Connectors' + if (/\bic\b|integrated|logic|op-?amp|amplifier|microcontroller|\bmcu\b|memory/.test(f)) + return 'Integrated Circuits' + return humanizeLabel(raw ?? '') +} + +export interface ResolvedNaming { + label: string + category: string + /** Keyword string for netlist/refdes matching (never the display category). */ + sim: string + prefix?: string +} + +/** + * Resolve the display name and category for a part. Curated entries win; then + * an authored label from the part file; then the humanized type slug. + * + * `rawLabel`/`rawFamily` are whatever the part file carried (Fritzing title + * and family) — they stay the sim-matching keywords when there's no curated + * entry, so importing a part never changes how it simulates. + */ +export function resolveNaming(type: string, rawLabel?: string, rawFamily?: string): ResolvedNaming { + const curated = PART_NAMING[type] + if (curated) { + return { + label: curated.label, + category: curated.category, + sim: curated.sim ?? `${curated.category} ${rawFamily ?? ''}`.trim(), + prefix: curated.prefix + } + } + const label = rawLabel && !isSluggy(rawLabel) ? tidyAuthored(rawLabel) : humanizeLabel(type) + return { + label, + category: humanizeCategory(rawFamily), + sim: `${rawFamily ?? ''}`.trim() + } +} + +/** An authored label that is really just the file name ("led", "battery-aa_y90"). */ +function isSluggy(label: string): boolean { + return /^[a-z0-9]+([_-][a-z0-9]+)*$/.test(label.trim()) +} + +/** Light touch-up for authored labels: fix ALL-CAPS shouting, keep the rest. */ +function tidyAuthored(label: string): string { + const t = label.trim().replace(/\s+/g, ' ') + if (t === t.toUpperCase() && /[A-Z]{3,}/.test(t)) return humanizeLabel(t) + return t +} diff --git a/src/renderer/src/circuit/parts/netLabels.ts b/src/renderer/src/circuit/parts/netLabels.ts index a903231..fc5ad4a 100644 --- a/src/renderer/src/circuit/parts/netLabels.ts +++ b/src/renderer/src/circuit/parts/netLabels.ts @@ -13,10 +13,9 @@ import type { PartView } from '../../lib/partsLibrary' import { pinWorld, snapPlacementToPinGrid } from '../core/geometry' import { GRID_BB, type NetLabel, type NetLabelKind, type Placement, type Pt } from '../core/model' +import { FONT_NET, INK, SCH_GRID, STROKE, symbolSvg, text } from './style' -const P = GRID_BB // 9.6 -const INK = 'var(--text-strong)' -const STROKE = 2 +const P = SCH_GRID // 9.6 export const NET_LABEL_PIN = '1' export interface NetLabelKindSpec { @@ -66,7 +65,7 @@ export function snapNetLabel(kind: NetLabelKind, name: string, pl: Placement): P function wrap(inner: string, w: number, h: number, pin: [number, number]): PartView { return { - svg: `${inner}`, + svg: symbolSvg(inner, w, h), w, h, pins: { [NET_LABEL_PIN]: pin } @@ -98,7 +97,7 @@ function powerGlyph(name: string): PartView { const h = 2 * P const cx = w / 2 const inner = - `${escape(name)}` + + text(cx, P - 5, name, { size: FONT_NET, anchor: 'middle' }) + `` + `` return wrap(inner, w, h, [cx, 2 * P]) @@ -113,10 +112,6 @@ function netGlyph(name: string): PartView { const inner = `` + `` + - `${escape(name)}` + text(stub + (tagW - 2) / 2, cy + 3, name, { size: FONT_NET, anchor: 'middle' }) return wrap(inner, w, h, [0, cy]) } - -function escape(s: string): string { - return s.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c] as string) -} diff --git a/src/renderer/src/circuit/parts/normalizeSymbol.ts b/src/renderer/src/circuit/parts/normalizeSymbol.ts new file mode 100644 index 0000000..aafe04b --- /dev/null +++ b/src/renderer/src/circuit/parts/normalizeSymbol.ts @@ -0,0 +1,143 @@ +/** + * circuit/parts/normalizeSymbol — make an imported schematic symbol behave. + * + * Parts we don't ship a hand-authored symbol for still have to sit on the same + * sheet as the ones we do. Their art comes from whoever drew the Fritzing part, + * and it arrives with three problems: + * + * 1. **Hairline strokes.** Fritzing schematic SVGs are authored in inch-based + * viewBox units — a typical `stroke-width="0.1524"` in a viewBox that maps + * ~3.8 units to the pixel renders as a 0.58 px line. Next to our 2 px + * symbols it disappears. + * 2. **Baked-in colour and type.** Pin names in blue, part names in orange, + * `font-family="OCRA"` — none of it follows the app theme, and none of it + * matches the rest of the sheet. + * 3. **Half-a-stroke offsets.** Pin pitch is almost always already correct + * (0.1 in = our 9.6 px grid), but the whole drawing sits shifted by the + * stroke's half-width, so pins land at 0.47 rather than 0. + * + * So: restroke to the shared weight, repaint to theme ink, re-set the type + * stack, and nudge the drawing onto the grid. Deliberately NOT rescaled — + * the pitch is right, and scaling would take the pins off-grid to fix a + * problem that isn't there. + * + * Pure string work, no DOM: this runs in the renderer, in the web build and + * under `node --test` alike. + */ + +import type { PartView } from '../../lib/partsLibrary' +import { FONT, FONT_PIN, INK, SCH_GRID, STROKE } from './style' + +const P = SCH_GRID + +/** Colour keywords that mean "don't paint" and must survive untouched. */ +const NO_PAINT = /^(none|transparent)$/i + +function round(n: number, places = 4): number { + const f = 10 ** places + return Math.round(n * f) / f +} + +/** viewBox width of an SVG string, if it declares one. */ +function viewBoxWidth(svg: string): number | null { + const m = /viewBox\s*=\s*"([^"]+)"/i.exec(svg) + if (!m) return null + const parts = m[1] + .trim() + .split(/[\s,]+/) + .map(Number) + return parts.length === 4 && Number.isFinite(parts[2]) && parts[2] > 0 ? parts[2] : null +} + +/** + * Re-ink an SVG: our stroke weight, our ink, our type stack. `unitScale` + * converts pixels into the drawing's own viewBox units, so a symbol authored + * in inches ends up with the same ON-SCREEN weight as one authored in pixels. + */ +export function reinkSvg(svg: string, unitScale: number): string { + const stroke = round(STROKE * unitScale) + const fontSize = round(FONT_PIN * unitScale) + + let out = svg + + // stroke width, attribute and inline-style forms + out = out.replace(/stroke-width\s*=\s*"[^"]*"/gi, `stroke-width="${stroke}"`) + out = out.replace(/stroke-width\s*:\s*[^;"']+/gi, `stroke-width:${stroke}`) + + // stroke colour — leave "none"/"transparent" alone or shapes lose their fill-only look + out = out.replace(/stroke\s*=\s*"([^"]*)"/gi, (all, c) => + NO_PAINT.test(String(c).trim()) ? all : `stroke="${INK}"` + ) + out = out.replace(/([;"'\s])stroke\s*:\s*([^;"']+)/gi, (all, lead, c) => + NO_PAINT.test(String(c).trim()) ? all : `${lead}stroke:${INK}` + ) + + // text: one type stack, one ink, sized in the drawing's units + out = out.replace(/]*)>/gi, (_all, attrs: string) => { + const kept = String(attrs) + .replace(/\s(font-family|font-size|fill|stroke|style|font-weight)\s*=\s*"[^"]*"/gi, '') + .trim() + return ( + `` + ) + }) + + return out +} + +/** + * Offset that brings a set of pins onto the major grid. Fritzing art is + * uniformly shifted (usually by half a stroke), so one translation fixes every + * pin at once — we take the average correction rather than snapping each pin + * independently, which would slide pins off the art they belong to. + */ +export function gridAlignOffset(pins: Record): [number, number] { + const vals = Object.values(pins) + if (!vals.length) return [0, 0] + const correct = (n: number): number => { + const d = Math.round(n / P) * P - n + // ignore pins that are nowhere near a grid line — they'd skew the average + return Math.abs(d) <= P / 2 ? d : 0 + } + const dx = vals.reduce((s, [x]) => s + correct(x), 0) / vals.length + const dy = vals.reduce((s, [, y]) => s + correct(y), 0) / vals.length + return [round(dx, 2), round(dy, 2)] +} + +/** + * Normalise an authored schematic view: shared ink weight, shared type, pins + * nudged onto the grid. Returns a new PartView; the input is untouched. + */ +export function normalizeAuthoredSymbol(v: PartView): PartView { + const vbw = viewBoxWidth(v.svg) + // viewBox units per pixel — 1 when the art is already authored in px + const unitScale = vbw && v.w > 0 ? vbw / v.w : 1 + + const [dx, dy] = gridAlignOffset(v.pins) + const inked = reinkSvg(v.svg, unitScale) + + // The translate has to happen in viewBox units, inside the svg, so the art + // and its pins move together. + const shifted = + dx || dy + ? inked.replace( + /(]*>)/i, + `$1` + ) + '' + : inked + const closed = dx || dy ? shifted.replace(/<\/svg>\s*$/i, '') : shifted + + const pins: Record = {} + for (const [name, [x, y]] of Object.entries(v.pins)) { + pins[name] = [round(x + dx, 2), round(y + dy, 2)] + } + + return { + ...v, + svg: closed, + w: round(v.w + Math.max(0, dx), 2), + h: round(v.h + Math.max(0, dy), 2), + pins + } +} diff --git a/src/renderer/src/circuit/parts/simParts.ts b/src/renderer/src/circuit/parts/simParts.ts index 2051d4b..a22bcfb 100644 --- a/src/renderer/src/circuit/parts/simParts.ts +++ b/src/renderer/src/circuit/parts/simParts.ts @@ -12,11 +12,9 @@ */ import type { PartDef, PartView } from '../../lib/partsLibrary' -import { GRID_BB } from '../core/model' +import { FONT_SYMBOL, INK, SCH_GRID, STROKE, circle, line, symbolSvg, text } from './style' -const P = GRID_BB // 9.6 -const INK = 'var(--text-strong)' -const STROKE = 2 +const P = SCH_GRID // 9.6 export interface SimSourceSpec { type: string @@ -65,14 +63,17 @@ export function generateSimSource(spec: SimSourceSpec): PartDef { const cy = 3 * P const r = 1.5 * P - const svg = - `` + - `` + - `` + - `` + - `+` + - spec.glyph(cx, cy, r) + - `` + const svg = symbolSvg( + line(cx, 0, cx, cy - r) + + line(cx, cy + r, cx, h) + + circle(cx, cy, r) + + // polarity beside the positive lead — at the symbol's right edge it + // was clipped by the viewBox + text(cx + 4, cy - r - 3, '+', { size: FONT_SYMBOL }) + + spec.glyph(cx, cy, r), + w, + h + ) const view: PartView = { svg, diff --git a/src/renderer/src/circuit/parts/simProbes.ts b/src/renderer/src/circuit/parts/simProbes.ts index e8fbe2a..f117f17 100644 --- a/src/renderer/src/circuit/parts/simProbes.ts +++ b/src/renderer/src/circuit/parts/simProbes.ts @@ -16,11 +16,20 @@ */ import type { PartDef, PartView } from '../../lib/partsLibrary' -import { GRID_BB } from '../core/model' +import { + FONT_PIN, + FONT_SYMBOL, + INK, + SCH_GRID, + STROKE, + circle, + line, + symbolSvg, + text +} from './style' -const P = GRID_BB // 9.6 -const INK = 'var(--text-strong)' -const FLAG = '#f0b429' // CircuitLab-style amber flag +const P = SCH_GRID // 9.6 +const FLAG = '#f0b429' // amber probe pennant export type ProbeKind = 'voltage' | 'diff' | 'current' @@ -42,7 +51,7 @@ const cache = new Map() function flag(cx: number, stemLen: number): string { const pennantH = Math.min(1.1 * P, stemLen * 0.6) return ( - `` + + line(cx, 0, cx, stemLen) + `` ) } @@ -58,31 +67,32 @@ export function generateSimProbe(spec: ProbeSpec): PartDef { const h = 2 * P const cy = P const r = 0.85 * P - const svg = - `` + - `` + - `` + - `` + - `A` + - `` + const svg = symbolSvg( + line(0, cy, w / 2 - r, cy) + + line(w / 2 + r, cy, w, cy) + + circle(w / 2, cy, r, STROKE, FLAG) + + text(w / 2, cy + 3, 'A', { size: FONT_SYMBOL + 1, anchor: 'middle' }), + w, + h + ) view = { svg, w, h, pins: { in: [0, cy], out: [w, cy] } } } else if (spec.kind === 'diff') { const stem = 2.4 * P const w = 3.6 * P const h = stem + 0.8 * P - const svg = - `` + + const svg = symbolSvg( `${flag(0, stem)}` + - `${flag(0, stem)}` + - `+ −` + - `` + `${flag(0, stem)}` + + text(w / 2, h - 1, '+ −', { size: FONT_PIN, anchor: 'middle' }), + w, + h + ) view = { svg, w, h, pins: { '+': [0.3 * P, stem], '-': [2 * P, stem] } } } else { const stem = 2.4 * P const w = 2.4 * P const h = stem - const svg = - `` + flag(0.4 * P, stem) + `` + const svg = symbolSvg(flag(0.4 * P, stem), w, h) view = { svg, w, h, pins: { '+': [0.4 * P, stem] } } } diff --git a/src/renderer/src/circuit/parts/style.ts b/src/renderer/src/circuit/parts/style.ts new file mode 100644 index 0000000..b9206e1 --- /dev/null +++ b/src/renderer/src/circuit/parts/style.ts @@ -0,0 +1,142 @@ +/** + * circuit/parts/style — the schematic drawing tokens. + * + * Everything that draws schematic ink — generated symbols, the hand-authored + * symbol library, net labels, sources, probes, annotations, the canvas overlay + * and the image exporter — pulls its stroke widths, ink colour, fonts and + * grid pitch from here. Before this existed each module picked its own (2 px + * here, 1.2 px there, `monospace` in one place and the UI font in another), + * which is why symbols looked like they came from four different programs. + * + * The look is the conventional US/IEEE schematic style the tech spec asks for + * (§8): black ink on paper, uniform 2 px bodies, thinner pin leads, sans-serif + * refdes and value text, everything on the 9.6 px major grid. + * + * Colours are CSS variables so symbols follow the tinyStudio theme; the image + * exporter inlines them at export time (resolveCssVars). + */ + +import { GRID_BB, GRID_SCH } from '../core/model' + +/** Major grid — pin pitch. Every pin tip lands on a multiple of this. */ +export const SCH_GRID = GRID_BB // 9.6 +/** Fine grid — the snap used for wire bends and annotation handles. */ +export const SCH_FINE = GRID_SCH // 4.8 + +/** Schematic ink (theme-aware; inlined on export). */ +export const INK = 'var(--text-strong)' +/** Secondary ink — pin names, unit suffixes, anything supporting. */ +export const INK_MUTED = 'var(--text-muted)' +/** Selection / probe accent. */ +export const ACCENT = 'var(--brand)' +/** Fill for symbol bodies that are closed shapes (IC boxes, meter circles). */ +export const BODY_FILL = 'none' + +/** Symbol body stroke — the single weight every symbol outline uses. */ +export const STROKE = 2 +/** Pin lead stroke: same weight as the body, so a lead reads as one line. */ +export const PIN_STROKE = 2 +/** Schematic wire stroke. Matches PIN_STROKE so a wire continues a lead. */ +export const WIRE_STROKE = 2 +/** Junction dot radius (a filled dot where 3+ wires meet). */ +export const JUNCTION_R = 3.2 +/** Standard pin lead length from the body edge to the connection point. */ +export const PIN_LEAD = SCH_GRID // 9.6 — one grid square + +/** Type stack. Sans for everything; the schematic is not a code listing. */ +export const FONT = 'var(--font-sans)' +/** Reference designator text (R1, C3, LED2). */ +export const FONT_REFDES = 9 +/** Value / parameter text (220 Ω, 10 µF, 1 kHz). */ +export const FONT_VALUE = 9 +/** Pin names inked inside an IC body. */ +export const FONT_PIN = 7 +/** Text drawn as part of a symbol (the `+` on a source, `A` in an ammeter). */ +export const FONT_SYMBOL = 8 +/** Net-label and power-rail names. */ +export const FONT_NET = 8.5 + +/** Gap between a symbol's bounding box and its refdes/value text. */ +export const LABEL_GAP = 4 + +/** + * Wrap symbol body markup in a correctly-sized SVG. Every symbol in the + * library goes through here so viewBox, namespace and shape-rendering are + * identical across parts (mismatched viewBoxes are why imported symbols used + * to render at wildly different sizes). + */ +export function symbolSvg(inner: string, w: number, h: number): string { + return ( + `${inner}` + ) +} + +/** A body line/lead at the standard weight. */ +export function line( + x1: number, + y1: number, + x2: number, + y2: number, + width = STROKE, + ink = INK +): string { + return ( + `` + ) +} + +/** A body path at the standard weight. */ +export function path(d: string, width = STROKE, ink = INK, fill = 'none'): string { + return `` +} + +export function circle( + cx: number, + cy: number, + r: number, + width = STROKE, + ink = INK, + fill = 'none' +): string { + return ( + `` + ) +} + +export interface TextOpts { + size?: number + anchor?: 'start' | 'middle' | 'end' + ink?: string + weight?: number | string + italic?: boolean +} + +/** Schematic text at the shared type stack. */ +export function text(x: number, y: number, s: string, opts: TextOpts = {}): string { + const { size = FONT_SYMBOL, anchor = 'start', ink = INK, weight, italic } = opts + return ( + `${escapeXml(s)}` + ) +} + +/** Snap a length to the major grid. */ +export function snapGrid(n: number, grid = SCH_GRID): number { + return Math.round(n / grid) * grid +} + +export function round(n: number): number { + return Math.round(n * 100) / 100 +} + +export function escapeXml(s: string): string { + return String(s).replace( + /[<>&"']/g, + (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] as string + ) +} diff --git a/src/renderer/src/circuit/parts/symbolLibrary.ts b/src/renderer/src/circuit/parts/symbolLibrary.ts new file mode 100644 index 0000000..be72ddf --- /dev/null +++ b/src/renderer/src/circuit/parts/symbolLibrary.ts @@ -0,0 +1,646 @@ +/** + * circuit/parts/symbolLibrary — the hand-authored schematic symbol set + * (spec §8: "US/IEEE default, black ink, 2 px strokes, standard library"). + * + * Fritzing ships a schematic SVG with every part, but those symbols were drawn + * by many different authors: stroke weights vary from hairline to heavy, text + * carries baked-in blues and oranges, and the same resistor can be twice the + * size of the capacitor next to it. Dropping them on one sheet looks like a + * ransom note. So the parts we ship draw from this library instead — one + * geometry vocabulary, one stroke weight, one type stack, every pin on the + * 9.6 px major grid. + * + * ── Geometry conventions ──────────────────────────────────────────────────── + * Two-terminal parts are 6 grid wide and 2 grid tall, pins at the left and + * right edges on the centre line, so any two of them stack and align without + * a jog in the wire. Parts that need headroom (an LED's emission arrows, a + * potentiometer's wiper) grow DOWNWARD to 3 grid and keep the pin row on a + * grid line. Three-terminal actives are 4 grid square with the control pin on + * the left and the two power pins top and bottom — the orientation schematics + * are normally read in. + * + * ── Pin binding ───────────────────────────────────────────────────────────── + * A symbol declares SLOTS by role ("anode", "wiper", "collector"), not by pin + * name, because the same symbol has to serve parts whose Fritzing pin names + * are 'Pin 0'/'Pin 1', '0'/'1', 'pin 0'/'pin 1', 'cathode'/'anode' or '-'/'+'. + * `bindSymbol` matches each slot to one of the part's real pin names by regex, + * falling back to definition order. The returned PartView is keyed by the + * part's OWN pin names, which is what keeps existing wires and nets valid when + * a part's symbol changes underneath them. + */ + +import type { PartView } from '../../lib/partsLibrary' +import { FONT_SYMBOL, INK, SCH_GRID, STROKE, circle, line, path, symbolSvg, text } from './style' + +const P = SCH_GRID // 9.6 + +// ── shared metrics ─────────────────────────────────────────────────────────── + +/** Two-terminal footprint: 6 grid long, pins on the centre line. */ +const W2 = 6 * P // 57.6 +const H2 = 2 * P // 19.2 +const CY = P // 9.6 — centre line of a plain two-terminal symbol +/** Two-terminal footprint with headroom (LED arrows, pot wiper): pins one grid lower. */ +const H3 = 3 * P // 28.8 +const CY3 = 2 * P // 19.2 +/** Body span for a two-terminal symbol — 1.5 grid of lead at each end. */ +const BX0 = 1.5 * P // 14.4 +const BX1 = W2 - BX0 // 43.2 + +/** Three-terminal (active) footprint: 4 grid square. */ +const WA = 4 * P // 38.4 +const HA = 4 * P // 38.4 + +export interface SymbolSlot { + /** What this terminal IS, independent of what the part file calls it. */ + role: string + /** Pin tip, local coordinates. Always lands on the major grid. */ + pos: [number, number] + /** Patterns tried, in order, against the part's real pin names. */ + match?: RegExp[] +} + +export interface SymbolDef { + w: number + h: number + slots: SymbolSlot[] + /** Body markup, drawn with the shared style tokens. */ + body: string +} + +// ── drawing helpers ────────────────────────────────────────────────────────── + +/** Horizontal leads from both edges to the body of a two-terminal symbol. */ +function leads(x0: number, x1: number, cy = CY): string { + return line(0, cy, x0, cy) + line(x1, cy, W2, cy) +} + +/** IEEE zigzag between x0 and x1 on centre line cy, `peaks` full swings. */ +function zigzag(x0: number, x1: number, cy: number, peaks = 6, amp = 0.5 * P): string { + const span = x1 - x0 + const step = span / (peaks + 1) + let d = `M${x0} ${cy}` + for (let i = 0; i <= peaks; i++) { + const x = x0 + step * (i + 0.5) + d += ` L${round(x)} ${round(cy + (i % 2 === 0 ? -amp : amp))}` + } + return path(d + ` L${x1} ${cy}`) +} + +/** An arrowhead at (x,y) pointing along (dx,dy), used for emission/wiper marks. */ +function arrowHead(x: number, y: number, dx: number, dy: number, size = 4): string { + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const bx = x - ux * size + const by = y - uy * size + const px = -uy * size * 0.45 + const py = ux * size * 0.45 + return path( + `M${round(x)} ${round(y)} L${round(bx + px)} ${round(by + py)} L${round(bx - px)} ${round(by - py)} Z`, + 1, + INK, + INK + ) +} + +/** A light-emission / light-sensing arrow pair beside a body. */ +function lightArrows(x: number, y: number, inward: boolean): string { + const draw = (ox: number): string => { + // arrows run at 45°, up and to the right of the body + const x1 = x + ox + const y1 = y + const x2 = x1 + 8 + const y2 = y - 8 + const tipX = inward ? x1 : x2 + const tipY = inward ? y1 : y2 + return ( + line(x1, y1, x2, y2, 1.4) + + arrowHead(tipX, tipY, inward ? x1 - x2 : x2 - x1, inward ? y1 - y2 : y2 - y1, 3.6) + ) + } + return draw(0) + draw(6) +} + +function round(n: number): number { + return Math.round(n * 100) / 100 +} + +// ── two-terminal slot presets ──────────────────────────────────────────────── + +const PLAIN2 = (cy = CY): SymbolSlot[] => [ + { role: 'a', pos: [0, cy] }, + { role: 'b', pos: [W2, cy] } +] + +/** Polarity-aware pair: the named terminal on the left. */ +const POLAR2 = ( + leftRole: string, + leftMatch: RegExp[], + rightRole: string, + rightMatch: RegExp[], + cy = CY +): SymbolSlot[] => [ + { role: leftRole, pos: [0, cy], match: leftMatch }, + { role: rightRole, pos: [W2, cy], match: rightMatch } +] + +const ANODE = [/anode/i, /^\+$/, /^a$/i] +const CATHODE = [/cathode|kath/i, /^-$/, /^k$/i, /^c$/i] +const POS = [/^\+$/, /^pos/i, /^vcc$/i, /anode/i] +const NEG = [/^-$/, /^neg/i, /^gnd$/i, /cathode/i] + +// ── the library ────────────────────────────────────────────────────────────── + +/** Resistor — IEEE zigzag. */ +const resistor: SymbolDef = { + w: W2, + h: H2, + slots: PLAIN2(), + body: leads(BX0, BX1) + zigzag(BX0, BX1, CY) +} + +/** Non-polarised capacitor — two parallel plates. */ +const capacitor: SymbolDef = (() => { + const gap = 0.75 * P + const x0 = W2 / 2 - gap / 2 + const x1 = W2 / 2 + gap / 2 + const half = 0.62 * P + return { + w: W2, + h: H2, + slots: PLAIN2(), + body: leads(x0, x1) + line(x0, CY - half, x0, CY + half) + line(x1, CY - half, x1, CY + half) + } +})() + +/** Polarised capacitor — straight plate, curved plate, plus sign. */ +const capacitorPolar: SymbolDef = (() => { + const x0 = W2 / 2 - 0.4 * P + const x1 = W2 / 2 + 0.4 * P + const half = 0.62 * P + return { + w: W2, + h: H2, + slots: POLAR2('+', POS, '-', NEG), + body: + leads(x0, x1) + + line(x0, CY - half, x0, CY + half) + + path(`M${x1} ${CY - half} A ${half * 1.5} ${half * 1.5} 0 0 1 ${x1} ${CY + half}`) + + text(x0 - 8, CY - half + 1, '+', { size: FONT_SYMBOL, anchor: 'middle' }) + } +})() + +/** Inductor — four half-circle humps. */ +const inductor: SymbolDef = (() => { + const humps = 4 + const span = BX1 - BX0 + const r = span / humps / 2 + let d = `M${BX0} ${CY}` + for (let i = 0; i < humps; i++) d += ` a ${round(r)} ${round(r)} 0 0 1 ${round(r * 2)} 0` + return { w: W2, h: H2, slots: PLAIN2(), body: leads(BX0, BX1) + path(d) } +})() + +/** Fuse — body with a conductor straight through it. */ +const fuse: SymbolDef = (() => { + const x0 = W2 / 2 - 1.2 * P + const x1 = W2 / 2 + 1.2 * P + const half = 0.45 * P + return { + w: W2, + h: H2, + slots: PLAIN2(), + body: + leads(x0, x1) + + `` + + line(x0, CY, x1, CY) + } +})() + +/** Diode body shared by the diode family: triangle + cathode bar. */ +function diodeBody(cy: number, barExtra = ''): string { + const tipX = W2 / 2 + 0.5 * P + const backX = W2 / 2 - 0.5 * P + const half = 0.62 * P + return ( + leads(backX, tipX, cy) + + path( + `M${backX} ${round(cy - half)} L${backX} ${round(cy + half)} L${tipX} ${cy} Z`, + STROKE, + INK, + INK + ) + + line(tipX, cy - half, tipX, cy + half) + + barExtra + ) +} + +const diode: SymbolDef = { + w: W2, + h: H2, + slots: POLAR2('anode', ANODE, 'cathode', CATHODE), + body: diodeBody(CY) +} + +/** Zener — cathode bar with the characteristic bent ends. */ +const zener: SymbolDef = (() => { + const tipX = W2 / 2 + 0.5 * P + const half = 0.62 * P + const flag = 0.42 * P + return { + w: W2, + h: H2, + slots: POLAR2('anode', ANODE, 'cathode', CATHODE), + body: diodeBody( + CY, + line(tipX, CY - half, tipX - flag, CY - half) + line(tipX, CY + half, tipX + flag, CY + half) + ) + } +})() + +/** LED — diode with emission arrows. */ +const led: SymbolDef = { + w: W2, + h: H3, + slots: POLAR2('anode', ANODE, 'cathode', CATHODE, CY3), + body: diodeBody(CY3) + lightArrows(W2 / 2 - 0.4 * P, CY3 - 0.9 * P, false) +} + +/** Photoresistor — zigzag with incident-light arrows. */ +const photoresistor: SymbolDef = { + w: W2, + h: H3, + slots: PLAIN2(CY3), + body: + leads(BX0, BX1, CY3) + + zigzag(BX0, BX1, CY3) + + lightArrows(W2 / 2 - 0.9 * P, CY3 - 1.1 * P, true) +} + +/** Thermistor — zigzag crossed by the temperature-dependence stroke. */ +const thermistor: SymbolDef = { + w: W2, + h: H2, + slots: PLAIN2(), + body: + leads(BX0, BX1) + + zigzag(BX0, BX1, CY) + + line(BX0 - 1.6, CY + 0.85 * P, BX1 - 4, CY - 0.95 * P, 1.4) + + line(BX0 - 1.6, CY + 0.85 * P, BX0 + 3.4, CY + 0.85 * P, 1.4) +} + +/** SPST switch — hinged blade between two contacts. */ +const switchSpst: SymbolDef = { + w: W2, + h: H2, + slots: PLAIN2(), + body: + line(0, CY, 2 * P, CY) + + line(4 * P, CY, W2, CY) + + circle(2 * P, CY, 1.7, STROKE, INK, INK) + + circle(4 * P, CY, 1.7, STROKE, INK, INK) + + line(2 * P, CY, 3.9 * P, CY - 0.72 * P) +} + +/** Momentary pushbutton — plunger over a pair of contacts. */ +const pushbutton: SymbolDef = { + w: W2, + h: H3, + slots: PLAIN2(CY3), + body: + line(0, CY3, 2 * P, CY3) + + line(4 * P, CY3, W2, CY3) + + circle(2 * P, CY3, 1.7, STROKE, INK, INK) + + circle(4 * P, CY3, 1.7, STROKE, INK, INK) + + line(1.7 * P, CY3 - 0.62 * P, 4.3 * P, CY3 - 0.62 * P) + + line(W2 / 2, CY3 - 0.62 * P, W2 / 2, CY3 - 1.35 * P) + + line(W2 / 2 - 0.5 * P, CY3 - 1.35 * P, W2 / 2 + 0.5 * P, CY3 - 1.35 * P) +} + +/** Reed switch — blades sealed in a glass envelope. */ +const reedSwitch: SymbolDef = { + w: W2, + h: H2, + slots: PLAIN2(), + body: + line(0, CY, 1.8 * P, CY) + + line(4.2 * P, CY, W2, CY) + + `` + + line(1.8 * P, CY, 2.9 * P, CY) + + line(4.2 * P, CY, 3.1 * P, CY - 0.42 * P) +} + +/** Potentiometer — resistor body with a wiper arrow onto it. */ +const potentiometer: SymbolDef = { + w: W2, + h: H3, + slots: [ + { role: 'leg1', pos: [0, CY3], match: [/leg1|^1$/i] }, + { role: 'leg2', pos: [W2, CY3], match: [/leg2|^3$/i] }, + { role: 'wiper', pos: [W2 / 2, 0], match: [/wiper|^2$/i] } + ], + body: + leads(BX0, BX1, CY3) + + zigzag(BX0, BX1, CY3) + + line(W2 / 2, 0, W2 / 2, CY3 - 0.62 * P) + + arrowHead(W2 / 2, CY3 - 0.42 * P, 0, 1, 5.5) +} + +/** + * Two-cell battery — alternating long (positive) and short (negative) plates. + * Uses the taller footprint so the polarity mark has room above the plates + * instead of being clipped by the symbol's own bounding box. + */ +const battery: SymbolDef = (() => { + const cells = 2 + const pitch = 0.72 * P + const startX = W2 / 2 - (cells * 2 - 1) * pitch * 0.5 + let art = '' + let x = startX + for (let i = 0; i < cells * 2; i++) { + const long = i % 2 === 0 + const half = long ? 0.78 * P : 0.34 * P + art += line(x, CY3 - half, x, CY3 + half) + x += pitch + } + const lastX = x - pitch + return { + w: W2, + h: H3, + slots: POLAR2('+', POS, '-', NEG, CY3), + body: + line(0, CY3, startX, CY3) + + line(lastX, CY3, W2, CY3) + + art + + text(startX - 4, CY3 - 1.05 * P, '+', { size: FONT_SYMBOL, anchor: 'middle' }) + } +})() + +/** Buzzer / piezo sounder — half-disc on its flat side. */ +const buzzer: SymbolDef = (() => { + const w = 4 * P + const h = 3 * P + const flatY = 2 * P + const x0 = P + const x1 = 3 * P + return { + w, + h, + slots: [ + { role: '+', pos: [x0, h], match: POS }, + { role: '-', pos: [x1, h], match: NEG } + ], + body: + // dome, then the flat face as its own stroke — a closed-path `Z` is at + // the mercy of the renderer's join handling; an explicit line is not + path( + `M${x0} ${flatY} A ${round((x1 - x0) / 2)} ${round((x1 - x0) / 2)} 0 0 1 ${x1} ${flatY}` + ) + + line(x0, flatY, x1, flatY) + + line(x0, flatY, x0, h) + + line(x1, flatY, x1, h) + } +})() + +/** Electret microphone — diaphragm chord across a capsule. */ +const microphone: SymbolDef = { + w: W2, + h: H2, + slots: PLAIN2(), + body: + leads(2.2 * P, 4.4 * P) + + circle(W2 / 2, CY, 0.85 * P) + + line(W2 / 2 - 0.4 * P, CY - 0.68 * P, W2 / 2 - 0.4 * P, CY + 0.68 * P) +} + +/** Bipolar transistor, drawn vertically: base left, collector top, emitter bottom. */ +function bjt(npn: boolean): SymbolDef { + const cx = 2 * P + const cy = 2 * P + const r = 1.42 * P + const barX = 1.5 * P + const railX = 3 * P + const barTop = cy - 1 * P + const barBot = cy + 1 * P + // diagonal legs from the base bar out to the collector/emitter rails + const colJoin: [number, number] = [barX, cy - 0.62 * P] + const emJoin: [number, number] = [barX, cy + 0.62 * P] + const colRail: [number, number] = [railX, cy - 1.42 * P] + const emRail: [number, number] = [railX, cy + 1.42 * P] + // NPN: arrow on the emitter pointing away from the base; PNP: toward it + const from = npn ? emJoin : emRail + const to = npn ? emRail : emJoin + const midX = (from[0] + to[0]) / 2 + const midY = (from[1] + to[1]) / 2 + return { + w: WA, + h: HA, + slots: [ + { role: 'base', pos: [0, cy], match: [/^b$/i, /base/i] }, + { role: 'collector', pos: [railX, 0], match: [/^c$/i, /collector/i] }, + { role: 'emitter', pos: [railX, HA], match: [/^e$/i, /emitter/i] } + ], + body: + circle(cx, cy, r, 1.4) + + line(0, cy, barX, cy) + + line(barX, barTop, barX, barBot) + + line(colJoin[0], colJoin[1], colRail[0], colRail[1]) + + line(colRail[0], colRail[1], railX, 0) + + line(emJoin[0], emJoin[1], emRail[0], emRail[1]) + + line(emRail[0], emRail[1], railX, HA) + + arrowHead(midX, midY, to[0] - from[0], to[1] - from[1], 5) + } +} + +/** N-channel enhancement MOSFET: gate left, drain top, source bottom. */ +const nmos: SymbolDef = (() => { + const cy = 2 * P + const gateX = P + const chanX = 1.65 * P + const railX = 3 * P + const seg = 0.5 * P + const rows: number[] = [cy - 1.05 * P, cy, cy + 1.05 * P] + let channel = '' + for (const y of rows) channel += line(chanX, y - seg, chanX, y + seg) + return { + w: WA, + h: HA, + slots: [ + { role: 'gate', pos: [0, cy], match: [/^g$/i, /gate/i] }, + { role: 'drain', pos: [railX, 0], match: [/^d$/i, /drain/i] }, + { role: 'source', pos: [railX, HA], match: [/^s$/i, /source/i] } + ], + body: + line(0, cy, gateX, cy) + + line(gateX, cy - 1.35 * P, gateX, cy + 1.35 * P) + + channel + + line(chanX, rows[0], railX, rows[0]) + + line(railX, rows[0], railX, 0) + + line(chanX, rows[2], railX, rows[2]) + + line(railX, rows[2], railX, HA) + + // bulk tie to the source, with the N-channel arrow pointing into the channel + line(chanX, cy, railX, cy) + + line(railX, cy, railX, rows[2]) + + arrowHead(chanX + 1.5, cy, -1, 0, 5.5) + } +})() + +/** Relay — coil on the left, changeover contact on the right. */ +const relay: SymbolDef = (() => { + const w = 6 * P + const h = 6 * P + const coilX0 = P + const coilX1 = 2.5 * P + const coilY0 = 1.5 * P + const coilY1 = 4.5 * P + const contactX = 4.5 * P + return { + w, + h, + slots: [ + { role: 'coil1', pos: [0, 2 * P], match: [/coil.*1|^coil$/i] }, + { role: 'coil2', pos: [0, 4 * P], match: [/coil.*2/i] }, + { role: 'no', pos: [w, 2 * P], match: [/^no$/i, /normally.?open/i] }, + { role: 'common', pos: [w, 4 * P], match: [/main|common|^com$/i] } + ], + body: + `` + + line(0, 2 * P, coilX0, 2 * P) + + line(0, 4 * P, coilX0, 4 * P) + + line(coilX1, 4 * P, w, 4 * P) + + line(contactX, 2 * P, w, 2 * P) + + circle(contactX, 2 * P, 1.7, STROKE, INK, INK) + + circle(contactX, 4 * P, 1.7, STROKE, INK, INK) + + line(contactX, 4 * P, contactX - 0.55 * P, 2.35 * P) + + // dashed actuation link from coil to blade + `` + } +})() + +/** Every symbol, by symbol id. */ +export const SYMBOLS: Record = { + resistor, + capacitor, + 'capacitor-polar': capacitorPolar, + inductor, + fuse, + diode, + zener, + led, + photoresistor, + thermistor, + 'switch-spst': switchSpst, + pushbutton, + 'reed-switch': reedSwitch, + potentiometer, + battery, + buzzer, + microphone, + npn: bjt(true), + pnp: bjt(false), + nmos, + relay +} + +/** Part type → symbol id, for everything tinyStudio ships. */ +export const SYMBOL_BY_TYPE: Record = { + resistor: 'resistor', + 'capacitor-ceramic-100mil': 'capacitor', + 'capacitor-ceramic-200mil': 'capacitor', + 'capacitor-electrolytic-medium': 'capacitor-polar', + 'smd-inductor-0805': 'inductor', + 'sparkfun-passives-fuse-x20mm': 'fuse', + 'diode-1n4001-300mil': 'diode', + 'diode-zener-0-5w-3-6v-300mil': 'zener', + 'led-generic-3mm': 'led', + 'led-generic-5mm': 'led', + 'ldr-photocell-300mil-v5': 'photoresistor', + 'thermistor-300mil': 'thermistor', + 'switch-spst': 'switch-spst', + pushbutton: 'pushbutton', + 'reedswitch-500mil': 'reed-switch', + 'potentiometer-rotary-16mm-5': 'potentiometer', + 'potentiometer-trimmer-6mm-5': 'potentiometer', + 'battery-aa': 'battery', + 'buzzer-v15': 'buzzer', + 'piezo-sensor': 'buzzer', + 'sparkfun-sensors-mic-electret-smd': 'microphone', + 'transistor-signal-npn-to92-ebc': 'npn', + 'transistor-signal-pnp-to92-ebc': 'pnp', + 'sparkfun-discretesemi-mosfet-nchannel-pth': 'nmos', + 'te-relay': 'relay' +} + +/** + * Keyword → symbol id, used ONLY for parts that arrive with no schematic art + * at all. A standard symbol beats a generated box; a part that DID ship its + * own art keeps it (normalised), because guessing at someone else's part is + * how you end up drawing a resistor for a current-sense shunt module. + */ +const KEYWORD_SYMBOLS: [RegExp, string][] = [ + [/\bled\b/i, 'led'], + [/zener/i, 'zener'], + [/photo-?(resistor|cell)|\bldr\b/i, 'photoresistor'], + [/thermistor/i, 'thermistor'], + [/potentiometer|trimmer|\bpot\b/i, 'potentiometer'], + [/electrolytic|polari[sz]ed|tantalum/i, 'capacitor-polar'], + [/capacitor|\bcap\b/i, 'capacitor'], + [/inductor|\bcoil\b|choke/i, 'inductor'], + [/\bfuse\b/i, 'fuse'], + [/diode|rectifier/i, 'diode'], + [/\bnpn\b/i, 'npn'], + [/\bpnp\b/i, 'pnp'], + [/mosfet|\bnmos\b|n-?channel/i, 'nmos'], + [/reed/i, 'reed-switch'], + [/push-?button|momentary/i, 'pushbutton'], + [/switch/i, 'switch-spst'], + [/relay/i, 'relay'], + [/battery|\bcell\b/i, 'battery'], + [/buzzer|piezo|sounder|speaker/i, 'buzzer'], + [/microphone|\bmic\b|electret/i, 'microphone'], + [/resistor/i, 'resistor'] +] + +export function symbolIdForKeywords(haystack: string): string | undefined { + for (const [re, id] of KEYWORD_SYMBOLS) if (re.test(haystack)) return id + return undefined +} + +/** + * Bind a symbol's slots to a part's real pin names and render the PartView. + * + * Returns null when the part cannot wear this symbol — a different pin count + * means we would silently drop or invent a terminal, and a symbol that hides + * a pin is worse than an ugly one, so the caller falls back. + */ +export function bindSymbol(sym: SymbolDef, pinNames: string[]): PartView | null { + if (pinNames.length !== sym.slots.length) return null + const taken = new Set() + const chosen: (string | undefined)[] = sym.slots.map(() => undefined) + + // pass 1: explicit name matches, most specific pattern first + sym.slots.forEach((slot, i) => { + if (!slot.match) return + for (const re of slot.match) { + const hit = pinNames.find((n) => !taken.has(n) && re.test(n)) + if (hit) { + chosen[i] = hit + taken.add(hit) + return + } + } + }) + // pass 2: whatever is left, in definition order + const rest = pinNames.filter((n) => !taken.has(n)) + let r = 0 + for (let i = 0; i < chosen.length; i++) if (!chosen[i]) chosen[i] = rest[r++] + + const pins: Record = {} + sym.slots.forEach((slot, i) => { + const name = chosen[i] + if (name) pins[name] = slot.pos + }) + if (Object.keys(pins).length !== sym.slots.length) return null + + return { svg: symbolSvg(sym.body, sym.w, sym.h), w: sym.w, h: sym.h, pins } +} diff --git a/src/renderer/src/circuit/parts/symbols.ts b/src/renderer/src/circuit/parts/symbols.ts index 030e851..18c1bf5 100644 --- a/src/renderer/src/circuit/parts/symbols.ts +++ b/src/renderer/src/circuit/parts/symbols.ts @@ -1,37 +1,111 @@ /** - * circuit/parts/symbols — generated schematic symbols (spec §5.1). + * circuit/parts/symbols — schematic symbol resolution (spec §5.1, §8). * - * Parts whose PartDef lacks schematic art get an auto-generated IC-style box - * symbol: type label on top, pins distributed left/right in definition order, - * pin names inked inside the body. This guarantees the schematic view (and, - * later, KiCad export) never blocks on missing artwork. + * A part's schematic art is resolved in this order: + * 1. a hand-authored standard symbol from the symbol library (the IEEE-style + * zigzag resistor, capacitor plates, diode triangle, BJT, source circles…) + * 2. the part file's own schematic view, normalised to our ink weight, font + * and scale so an imported Fritzing symbol doesn't sit on the sheet at a + * different size and line weight than everything around it + * 3. a generated IC-style box symbol: type label on top, pins distributed + * left/right in definition order, pin names inked inside the body * - * Ink is `var(--text-strong)` so symbols follow the tinyStudio theme; the - * image exporter inlines the variable at export time (resolveCssVars). + * Step 3 guarantees the schematic view (and, later, KiCad export) never blocks + * on missing artwork. + * + * Everything here draws with the tokens in parts/style.ts, so line weight, + * font and pin pitch are identical across all three paths. Ink is a CSS + * variable so symbols follow the tinyStudio theme; the image exporter inlines + * it at export time (resolveCssVars). * * Pin positions land on the 9.6 px major grid (spec §4 pin-on-grid contract). */ import type { PartDef, PartView } from '../../lib/partsLibrary' -import { GRID_BB } from '../core/model' +import { normalizeAuthoredSymbol } from './normalizeSymbol' +import { SYMBOLS, SYMBOL_BY_TYPE, bindSymbol, symbolIdForKeywords } from './symbolLibrary' +import { + FONT_PIN, + FONT_SYMBOL, + INK, + PIN_LEAD, + SCH_GRID, + STROKE, + line, + round, + symbolSvg, + text +} from './style' -const P = GRID_BB // 9.6 — schematic major grid -const INK = 'var(--text-strong)' -const STROKE = 2 +const P = SCH_GRID // 9.6 — schematic major grid const cache = new Map() -/** Schematic view for a part: authored art if present, else a generated box. */ +/** The pin names a symbol has to account for, in the part's own order. */ +function pinNamesOf(def: PartDef): string[] { + const source = def.views.schematic?.pins ?? def.views.breadboard?.pins ?? {} + return Object.keys(source) +} + +/** + * Schematic art for a part, resolved in preference order (see the module + * header). Cached per type — resolution walks several fallbacks and every + * render of every instance asks for it. + */ export function schematicVisual(def: PartDef): PartView { - if (def.views.schematic) return def.views.schematic - let v = cache.get(def.type) - if (!v) { - v = generateBoxSymbol(def) - cache.set(def.type, v) - } + const hit = cache.get(def.type) + if (hit) return hit + const v = resolveSchematic(def) + cache.set(def.type, v) return v } +/** + * Parts whose own schematic art is technically fine but unusable on a shared + * sheet: a 7-segment display drawn as a 4x8 LED matrix is twenty times the + * size of the diode beside it, and the breakout modules ship overlapping + * coloured captions. A generated box symbol is both smaller and clearer. + */ +const PREFER_BOX = new Set([ + '7segment-100-cat', + 'servo', + 'ir-receiver-v14', + 'voltage-regulator-7805' +]) + +function resolveSchematic(def: PartDef): PartView { + const pins = pinNamesOf(def) + + // 1. a hand-authored standard symbol for this exact part + const id = SYMBOL_BY_TYPE[def.type] + if (id && SYMBOLS[id]) { + const bound = bindSymbol(SYMBOLS[id], pins) + if (bound) return bound + } + + // 2. the part's own schematic art, re-inked to the sheet's weight and type + if (def.views.schematic && !PREFER_BOX.has(def.type)) + return normalizeAuthoredSymbol(def.views.schematic) + + // 3. no art at all: a standard symbol beats a box if we can identify the part + const guess = symbolIdForKeywords( + `${def.type} ${def.simFamily ?? ''} ${def.family ?? ''} ${def.label}` + ) + if (guess && SYMBOLS[guess]) { + const bound = bindSymbol(SYMBOLS[guess], pins) + if (bound) return bound + } + + // 4. generated IC-style box — never blocks on missing artwork + return generateBoxSymbol(def) +} + +/** Drop cached symbol art (part re-registered, e.g. edited in the Parts Editor). */ +export function invalidateSymbol(type?: string): void { + if (type) cache.delete(type) + else cache.clear() +} + export function generateBoxSymbol(def: PartDef): PartView { const source = def.views.breadboard?.pins ?? {} const names = Object.keys(source) @@ -41,51 +115,55 @@ export function generateBoxSymbol(def: PartDef): PartView { const right = names.slice(nLeft) const rows = Math.max(left.length, right.length, 1) - const stub = P // lead length from body to pin tip - const bodyW = P * Math.max(4, Math.min(10, 2 + Math.ceil(longest(names) * 0.62))) + const stub = PIN_LEAD // lead length from body to pin tip + const title = def.label || def.type + // The body has to hold the widest LEFT name and the widest RIGHT name side + // by side without them colliding in the middle, and the whole symbol has to + // be wide enough that the title above it isn't clipped by the viewBox. + const gutter = 4 + const inner = textWidth(widest(left), FONT_PIN) + textWidth(widest(right), FONT_PIN) + P * 2 + const bodyW = Math.max(P * 4, Math.ceil(inner / P) * P) const bodyH = P * (rows + 1) - const w = bodyW + stub * 2 + const w = Math.max(bodyW + stub * 2, textWidth(title, FONT_SYMBOL) + gutter * 2) const h = bodyH + P // headroom for the label const pins: Record = {} const parts: string[] = [] - const bodyX = stub + const bodyX = (w - bodyW) / 2 const bodyY = P parts.push( - ``, - `${escape(def.label || def.type)}` + ``, + text(w / 2, bodyY - 3, title, { size: FONT_SYMBOL, anchor: 'middle' }) ) left.forEach((name, i) => { const y = bodyY + P * (i + 1) pins[name] = [0, y] - parts.push( - ``, - `${escape(name)}` - ) + parts.push(line(0, y, bodyX, y), text(bodyX + gutter, y + 2.6, name, { size: FONT_PIN })) }) right.forEach((name, i) => { const y = bodyY + P * (i + 1) pins[name] = [w, y] parts.push( - ``, - `${escape(name)}` + line(bodyX + bodyW, y, w, y), + text(bodyX + bodyW - gutter, y + 2.6, name, { size: FONT_PIN, anchor: 'end' }) ) }) - return { - svg: `${parts.join('')}`, - w, - h, - pins - } + return { svg: symbolSvg(parts.join(''), w, h), w, h, pins } } -function longest(names: string[]): number { - return names.reduce((m, n) => Math.max(m, n.length), 0) +/** + * Rough advance width of a string at a font size. The renderer has no text + * metrics (this runs in a worker and under node too), and 0.58 em is a good + * average for the sans stack across mixed-case pin names — erring wide, since + * a slightly roomy box is invisible and a tight one collides. + */ +function textWidth(s: string, size: number): number { + return s.length * size * 0.58 } -function escape(s: string): string { - return s.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c] as string) +function widest(names: string[]): string { + return names.reduce((m, n) => (n.length > m.length ? n : m), '') } diff --git a/src/renderer/src/circuit/sim/backend.ts b/src/renderer/src/circuit/sim/backend.ts index 8b87a0b..8b6c1fa 100644 --- a/src/renderer/src/circuit/sim/backend.ts +++ b/src/renderer/src/circuit/sim/backend.ts @@ -5,6 +5,12 @@ * (eecircuit-engine) in a Web Worker; the M4 bake-off may swap in tscircuit's * build, and the future tinyservice MCU co-sim backend implements the same * interface. Nothing outside sim/ may assume "SPICE only". + * + * The engine is ~20 MB of WASM that has to download, compile and instantiate + * before the first analysis can run. That load is modelled explicitly here + * (`warmup` + `subscribe`) so the UI can show honest progress instead of + * letting a run's watchdog fire while the engine is still booting — the old + * behaviour, which read as "simulation timed out" on every cold start. */ export interface SimVector { @@ -34,11 +40,33 @@ export class SimError extends Error { } } +/** Lifecycle of the engine itself, independent of any one analysis. */ +export type EnginePhase = 'cold' | 'loading' | 'ready' | 'running' | 'failed' + +export interface EngineStatus { + phase: EnginePhase + /** short human line for the panel ("loading the SPICE engine…") */ + message: string + /** set on 'failed' */ + error?: string +} + export interface SimBackend { - /** Run a netlist; resolves with vectors or rejects with SimError. */ + /** + * Download/compile/instantiate the engine without running anything. Safe to + * call repeatedly — concurrent callers share one in-flight load. Resolves + * when the engine is ready; rejects (once) if it can't be loaded. + */ + warmup(): Promise + /** Run a netlist; resolves with vectors or rejects with SimError. The + * timeout covers the analysis only — engine load is waited out separately. */ run(netlist: string, timeoutMs?: number): Promise /** Abort the in-flight run (terminates + respawns the engine). */ cancel(): void + /** Current engine lifecycle state. */ + status(): EngineStatus + /** Observe engine lifecycle changes; returns an unsubscribe function. */ + subscribe(fn: (s: EngineStatus) => void): () => void dispose(): void } diff --git a/src/renderer/src/circuit/sim/engine.ts b/src/renderer/src/circuit/sim/engine.ts index e091327..db9e34f 100644 --- a/src/renderer/src/circuit/sim/engine.ts +++ b/src/renderer/src/circuit/sim/engine.ts @@ -1,75 +1,242 @@ /** * circuit/sim/engine — the worker-hosted SPICE backend (M4, spec §10.1). * - * Wraps simWorker.ts with a typed request/response protocol, a watchdog - * (default 10 s — WASM ngspice can spin on non-convergence), and cancellation - * by terminate+respawn (ngspice has no reentrant abort). The first run pays - * the engine download/compile; the instance is reused after that. + * Wraps simWorker.ts with a typed request/response protocol and two separate + * clocks: + * + * - the ENGINE LOAD (download + compile + instantiate ≈20 MB of WASM) has + * its own generous budget and its own observable phase, so a cold start + * reads as "loading the engine" rather than tripping an analysis watchdog + * and reporting a bogus timeout; + * - the ANALYSIS has the short watchdog — that one really does mean ngspice + * is spinning on a non-convergent circuit. + * + * Cancellation is terminate+respawn (ngspice has no reentrant abort). A worker + * that dies on its own (OOM during the first load is the usual cause) is + * reported through the same path and the next run respawns it, so a crash + * costs a message instead of taking the editor down with it. */ import SimWorker from './simWorker?worker' -import { SimError, type SimBackend, type SimFailure, type SimRun } from './backend' +import { + SimError, + type EngineStatus, + type SimBackend, + type SimFailure, + type SimRun +} from './backend' interface Pending { - resolve: (r: SimRun) => void + resolve: (r: SimRun | undefined) => void reject: (e: Error) => void - timer: ReturnType + timer: ReturnType | null } +type WorkerReply = + | { type: 'phase'; phase: 'loading' | 'ready' | 'running' } + | { id: number | null; ok: boolean; result?: SimRun; error?: SimFailure } + +/** Engine load budget. Generous on purpose — a cold cache on a slow link can + * legitimately take a minute, and killing it there only guarantees failure. */ +const LOAD_TIMEOUT_MS = 180_000 +/** How many times we'll respawn after an unexplained worker death before + * telling the user the engine won't start on this machine. */ +const MAX_RESPAWNS = 2 + export class SpiceWorkerBackend implements SimBackend { private worker: Worker | null = null private pending = new Map() private seq = 0 + private loaded = false + private loadPromise: Promise | null = null + private respawns = 0 + private state: EngineStatus = { phase: 'cold', message: 'engine not loaded yet' } + private listeners = new Set<(s: EngineStatus) => void>() + + // ── status ──────────────────────────────────────────────────────────────── + + status(): EngineStatus { + return this.state + } + + subscribe(fn: (s: EngineStatus) => void): () => void { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + + private setStatus(next: EngineStatus): void { + this.state = next + for (const fn of this.listeners) { + try { + fn(next) + } catch { + /* a listener must never break the engine */ + } + } + } + + // ── worker plumbing ─────────────────────────────────────────────────────── private spawn(): Worker { if (this.worker) return this.worker const w: Worker = new SimWorker() - w.onmessage = ( - e: MessageEvent<{ id: number; ok: boolean; result?: SimRun; error?: SimFailure }> - ): void => { - const p = this.pending.get(e.data.id) + w.onmessage = (e: MessageEvent): void => { + const data = e.data + if ('type' in data && data.type === 'phase') { + if (data.phase === 'loading') + this.setStatus({ phase: 'loading', message: 'loading the SPICE engine (~20 MB)…' }) + else if (data.phase === 'ready') { + this.loaded = true + this.respawns = 0 + this.setStatus({ phase: 'ready', message: 'engine ready' }) + } else this.setStatus({ phase: 'running', message: 'solving…' }) + return + } + const reply = data as Exclude + // a null id is a worker-level crash report — fail everything in flight + if (reply.id == null) { + this.onWorkerLost(reply.error?.message ?? 'the simulation engine stopped unexpectedly') + return + } + const p = this.pending.get(reply.id) if (!p) return - this.pending.delete(e.data.id) - clearTimeout(p.timer) - if (e.data.ok && e.data.result) p.resolve(e.data.result) - else p.reject(new SimError(e.data.error ?? { message: 'simulation failed' })) + this.pending.delete(reply.id) + if (p.timer) clearTimeout(p.timer) + if (reply.ok) p.resolve(reply.result) + else p.reject(new SimError(reply.error ?? { message: 'simulation failed' })) + if (!this.pending.size && this.loaded) + this.setStatus({ phase: 'ready', message: 'engine ready' }) } w.onerror = (e): void => { - this.failAll(new SimError({ message: e.message || 'simulation worker crashed' })) - this.worker = null - w.terminate() + e.preventDefault?.() + this.onWorkerLost(e.message || 'the simulation engine stopped unexpectedly') + } + w.onmessageerror = (): void => { + this.onWorkerLost('the simulation engine sent an unreadable result') } this.worker = w return w } - run(netlist: string, timeoutMs = 10000): Promise { + /** + * The worker died (OOM during the WASM load is the common one). Reject + * everything waiting on it with a message the panel can show, drop the + * instance so the next attempt starts clean, and stop retrying forever. + */ + private onWorkerLost(message: string): void { + this.loaded = false + this.loadPromise = null + const w = this.worker + this.worker = null + try { + w?.terminate() + } catch { + /* already gone */ + } + this.respawns++ + const exhausted = this.respawns > MAX_RESPAWNS + const text = exhausted + ? `${message} — it has failed to start ${this.respawns} times, so simulation is unavailable in this session` + : message + this.setStatus({ phase: 'failed', message: text, error: text }) + this.failAll(new SimError({ message: text })) + } + + private send( + msg: { op: 'warm' | 'run'; netlist?: string }, + timeoutMs: number | null + ): Promise { const id = ++this.seq const w = this.spawn() - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id) - this.cancel() // engine may be stuck in WASM — replace it - reject(new SimError({ message: `simulation timed out after ${timeoutMs / 1000}s` })) - }, timeoutMs) + return new Promise((resolve, reject) => { + const timer = + timeoutMs == null + ? null + : setTimeout(() => { + this.pending.delete(id) + this.cancel() // engine may be stuck in WASM — replace it + reject( + new SimError({ + message: + msg.op === 'warm' + ? `the simulation engine did not finish loading after ${Math.round(timeoutMs / 1000)}s` + : `the analysis did not converge within ${Math.round(timeoutMs / 1000)}s — try a coarser step, a shorter run, or check for a floating node` + }) + ) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) - w.postMessage({ id, netlist }) + try { + w.postMessage({ id, ...msg }) + } catch (err) { + this.pending.delete(id) + if (timer) clearTimeout(timer) + reject(new SimError({ message: (err as Error)?.message ?? 'could not reach the engine' })) + } }) } + // ── public API ──────────────────────────────────────────────────────────── + + warmup(): Promise { + if (this.loaded) return Promise.resolve() + if (this.state.phase === 'failed' && this.respawns > MAX_RESPAWNS) + return Promise.reject(new SimError({ message: this.state.error ?? 'engine unavailable' })) + if (!this.loadPromise) { + this.setStatus({ phase: 'loading', message: 'loading the SPICE engine (~20 MB)…' }) + this.loadPromise = this.send({ op: 'warm' }, LOAD_TIMEOUT_MS) + .then(() => { + this.loaded = true + this.respawns = 0 + this.setStatus({ phase: 'ready', message: 'engine ready' }) + }) + .catch((err) => { + this.loadPromise = null + const message = err instanceof Error ? err.message : String(err) + this.setStatus({ phase: 'failed', message, error: message }) + throw err + }) + } + return this.loadPromise + } + + async run(netlist: string, timeoutMs = 20_000): Promise { + // The load is waited out on its own clock; only the solve is watchdogged. + await this.warmup() + this.setStatus({ phase: 'running', message: 'solving…' }) + try { + const r = await this.send({ op: 'run', netlist }, timeoutMs) + if (!r) throw new SimError({ message: 'the engine returned no result' }) + if (this.loaded) this.setStatus({ phase: 'ready', message: 'engine ready' }) + return r + } catch (err) { + if (this.loaded && this.state.phase === 'running') + this.setStatus({ phase: 'ready', message: 'engine ready' }) + throw err + } + } + cancel(): void { this.failAll(new SimError({ message: 'simulation cancelled' })) - this.worker?.terminate() + try { + this.worker?.terminate() + } catch { + /* already gone */ + } this.worker = null // respawned lazily on the next run + this.loaded = false + this.loadPromise = null + if (this.state.phase !== 'failed') + this.setStatus({ phase: 'cold', message: 'engine stopped — it will reload on the next run' }) } dispose(): void { this.cancel() + this.listeners.clear() } private failAll(err: Error): void { for (const [, p] of this.pending) { - clearTimeout(p.timer) + if (p.timer) clearTimeout(p.timer) p.reject(err) } this.pending.clear() diff --git a/src/renderer/src/circuit/sim/index.ts b/src/renderer/src/circuit/sim/index.ts index af79fe4..1a1c129 100644 --- a/src/renderer/src/circuit/sim/index.ts +++ b/src/renderer/src/circuit/sim/index.ts @@ -1,6 +1,8 @@ export { SimError, voltageOf, + type EnginePhase, + type EngineStatus, type SimBackend, type SimFailure, type SimRun, diff --git a/src/renderer/src/circuit/sim/simWorker.ts b/src/renderer/src/circuit/sim/simWorker.ts index 95391ef..f1a9e51 100644 --- a/src/renderer/src/circuit/sim/simWorker.ts +++ b/src/renderer/src/circuit/sim/simWorker.ts @@ -2,12 +2,21 @@ * circuit/sim/simWorker — ngspice-WASM in a module worker (M4). * * The engine (eecircuit-engine ≈20 MB with embedded WASM) is imported lazily - * on the first message so opening the Circuit tab costs nothing; Vite splits - * it into its own chunk. One Simulation instance is reused across runs — - * cancellation is handled by the owner terminating this worker entirely. + * so opening the Circuit tab costs nothing; Vite splits it into its own chunk. + * One Simulation instance is reused across runs — cancellation is handled by + * the owner terminating this worker entirely. * - * Protocol: { id, netlist } in → { id, ok: true, result } | { id, ok: false, - * error: { message, details } } out. + * Two operations: + * { id, op: 'warm' } → load/compile/instantiate only + * { id, op: 'run', netlist } → analyse (loads first if it has to) + * Replies: { id, ok: true, result? } | { id, ok: false, error }, plus + * unsolicited { type: 'phase', phase } notes so the owner can drive UI. + * + * Every failure path posts a message. Nothing here may throw out of the + * handler: an unhandled rejection inside a worker takes the worker down + * without a reply, which is what left the panel spinning until its watchdog + * fired (and, on a cold start under memory pressure, took the renderer with + * it). `self.onerror`/`onunhandledrejection` are wired to report instead. */ import type { SimRun, SimVector } from './backend' @@ -33,6 +42,37 @@ interface EngineSim { } let sim: EngineSim | null = null +let loading: Promise | null = null +/** id of the request currently being served, so a stray error can be attributed */ +let currentId: number | null = null + +function post(msg: unknown): void { + ;(self as unknown as { postMessage: (m: unknown) => void }).postMessage(msg) +} + +function phase(p: 'loading' | 'ready' | 'running'): void { + post({ type: 'phase', phase: p }) +} + +async function ensureEngine(): Promise { + if (sim) return sim + if (!loading) { + phase('loading') + loading = (async () => { + const mod = (await import('eecircuit-engine')) as { Simulation: new () => EngineSim } + const s = new mod.Simulation() + await s.start() + sim = s + phase('ready') + return s + })().catch((err) => { + // let the next attempt retry from scratch rather than caching the failure + loading = null + throw err + }) + } + return loading +} function convert(res: EngineResult): SimRun { const vectors: SimVector[] = res.data.map((d) => { @@ -50,29 +90,62 @@ function convert(res: EngineResult): SimRun { return { header: res.header, numPoints: res.numPoints, vectors } } -self.onmessage = async (e: MessageEvent<{ id: number; netlist: string }>): Promise => { +function describe(err: unknown): string { + if (err instanceof Error) return err.message || String(err) + if (typeof err === 'string') return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +self.onmessage = async ( + e: MessageEvent<{ id: number; op?: 'warm' | 'run'; netlist?: string }> +): Promise => { const { id, netlist } = e.data + const op = e.data.op ?? 'run' + currentId = id try { - if (!sim) { - const mod = (await import('eecircuit-engine')) as { - Simulation: new () => EngineSim - } - sim = new mod.Simulation() - await sim.start() + const engine = await ensureEngine() + if (op === 'warm') { + post({ id, ok: true }) + return } - sim.setNetList(netlist) - const result = await sim.runSim() - const errors = sim.getError() + phase('running') + engine.setNetList(netlist ?? '') + const result = await engine.runSim() + const errors = engine.getError() + phase('ready') if ((!result || result.numPoints === 0) && errors.length) { - self.postMessage({ id, ok: false, error: { message: errors[0], details: errors } }) + post({ id, ok: false, error: { message: errors[0], details: errors } }) return } - self.postMessage({ id, ok: true, result: convert(result) }) + post({ id, ok: true, result: convert(result) }) } catch (err) { - self.postMessage({ - id, - ok: false, - error: { message: err instanceof Error ? err.message : String(err) } - }) + // a failed run can leave ngspice in a bad state — drop the instance so the + // next attempt boots a clean one instead of compounding the failure + if (op === 'run') { + sim = null + loading = null + } + post({ id, ok: false, error: { message: describe(err) } }) + } finally { + currentId = null } } + +self.onerror = (event: Event | string): void => { + const message = + typeof event === 'string' ? event : ((event as ErrorEvent).message ?? 'simulation engine error') + sim = null + loading = null + post({ id: currentId, ok: false, error: { message } }) +} + +self.onunhandledrejection = (event: PromiseRejectionEvent): void => { + event.preventDefault() + sim = null + loading = null + post({ id: currentId, ok: false, error: { message: describe(event.reason) } }) +} diff --git a/src/renderer/src/circuit/views/CircuitView.tsx b/src/renderer/src/circuit/views/CircuitView.tsx index 26b2fb9..1414af4 100644 --- a/src/renderer/src/circuit/views/CircuitView.tsx +++ b/src/renderer/src/circuit/views/CircuitView.tsx @@ -12,18 +12,22 @@ */ import { + ChevronDown, CircleAlert, CodeXml, + Download, Eye, FileCode2, Grid3x3, ImageDown, Info, + ListOrdered, Maximize, Pencil, Play, Redo2, ShieldCheck, + Share, TriangleAlert, Undo2, X, @@ -31,28 +35,52 @@ import { ZoomOut } from 'lucide-react' import React from 'react' -import { ensureParts, getPart, loadPart, registerPart, type PartDef } from '../../lib/partsLibrary' +import { + PART_MANIFEST, + ensureParts, + getPart, + loadPart, + registerPart, + type PartDef +} from '../../lib/partsLibrary' import { toast } from 'sonner' import { initUserParts, saveUserPart } from '../../lib/userParts' import { importFzpz } from '../parts/fzpz' import { PartsEditor } from '../../components/PartsEditor' import * as cmd from '../core/commands' -import { newId, type NetLabelKind, type Pt, type ViewId } from '../core/model' +import { + newId, + type NetLabelKind, + type Placement, + type Probe, + type Pt, + type ViewId +} from '../core/model' import { buildNets } from '../core/nets' -import type { SimIssueRef } from '../core/netlist' +import { nodeNamesForNets, type SimIssueRef } from '../core/netlist' import { runErc, type ErcIssue, type ErcSeverity } from '../core/erc' -import { nextRefdes, prefixForFamily } from '../core/refdes' +import { looksLikeSlug, nextRefdes, prefixForFamily, renumberAll } from '../core/refdes' import { CircuitStore } from '../core/store' import { BREADBOARDS, generateBreadboard, isBreadboard } from '../parts/breadboard' +import { defaultAttrsFor, resolveNaming } from '../parts/naming' import { SIM_SOURCES, generateSimSource, simSourceDefaultAttrs } from '../parts/simParts' import { SIM_PROBES, generateSimProbe, simProbeDefaultAttrs } from '../parts/simProbes' import { PackManager } from './packs/PackManager' import { snapNetLabel } from '../parts/netLabels' -import { Canvas, emptySel, type Cam, type CanvasHandle, type Selection } from './canvas/Canvas' -import { exportPng, exportSvg } from './exportImage' +import { + Canvas, + emptySel, + type Cam, + type CanvasHandle, + type ProbeTag, + type Selection +} from './canvas/Canvas' +import { playCaptureAnimation } from './captureAnimation' +import { renderPng, saveImage, exportSvg } from './exportImage' import { InspectorRail } from './inspector/Inspector' import { Palette, WIRE_COLORS } from './palette/Palette' import { + autoPlacementFor, circuitBuses, ercFloatingPins, findFreePlacement, @@ -60,8 +88,29 @@ import { pinWorldOf, ratsnest } from './partsAdapter' +import { + makeProbe, + netLabelFor, + netOutputRefByIndex, + probeAnchor, + probeFor, + vectorForOutput, + PROBE_DEFAULT_OFFSET, + type OutputRef +} from '../core/simOutputs' +import { getSimBackend } from '../sim' import { SimPanel, fmtSI, type SimState } from './sim/SimPanel' +/** + * Which panel the right-hand rail is showing. + * + * The rail is not always there: Properties belongs to editing and Simulate + * belongs to the schematic, so the breadboard in view-only mode has no rail at + * all and the canvas gets the whole width. `railVisible` below is the single + * place that rule lives. + */ +type RailTab = 'properties' | 'simulate' + export function CircuitViewV2({ content, onChange, @@ -93,10 +142,23 @@ export function CircuitViewV2({ const [editorPart, setEditorPart] = React.useState(undefined) const [showPacks, setShowPacks] = React.useState(false) const [showErc, setShowErc] = React.useState(false) - const [showSim, setShowSim] = React.useState(false) + // Simulate is opened explicitly (the toolbar button) and only ever shows on + // the schematic; Properties only shows while editing. + const [simOpen, setSimOpen] = React.useState(false) + const [railPref, setRailPref] = React.useState('properties') + const [picking, setPicking] = React.useState(false) + const [exportOpen, setExportOpen] = React.useState(false) const [sim, setSim] = React.useState({ run: null, netlist: null }) const [defsTick, bumpDefs] = React.useReducer((n: number) => n + 1, 0) const canvasRef = React.useRef(null) + // stage + export button anchor the capture animation; the chip is the + // "download landed" affordance it flies into + const stageRef = React.useRef(null) + const exportBtnRef = React.useRef(null) + // {name, id}: the id re-arms the chip when the same file is exported twice + const [savedFile, setSavedFile] = React.useState<{ name: string; id: number } | null>(null) + const saveSeq = React.useRef(0) + const [exporting, setExporting] = React.useState(false) // ── file sync ─────────────────────────────────────────────────────────────── @@ -126,6 +188,22 @@ export function CircuitViewV2({ }) }, [doc.parts]) + // The components rail draws each tile from the part's SCHEMATIC symbol when + // the schematic is open, and symbols are generated from the part definition + // — which is lazily loaded. Without this the rail fell back to `meta.icon`, + // the Fritzing breadboard photo, so the schematic palette showed pictures of + // components instead of symbols. Warm the whole catalogue once on entry. + React.useEffect(() => { + if (view !== 'sch') return + let live = true + void ensureParts(PART_MANIFEST.map((m) => m.type)).then(() => { + if (live) bumpDefs() + }) + return () => { + live = false + } + }, [view]) + // drop selection entries that no longer exist (undo, delete, external edit) React.useEffect(() => { const partIds = new Set(doc.parts.map((p) => p.id)) @@ -237,6 +315,19 @@ export function CircuitViewV2({ return bits }, [trayParts, rats, ercCount]) + // A migrated diagram.json has breadboard placements only, so the schematic + // opened completely empty with everything sitting in the tray. If a view has + // nothing placed at all but the document has parts for it, lay them out — + // one undoable step, and only ever on an empty sheet, so this can't shuffle + // a layout the user has arranged. + const autoPlacedViews = React.useRef>(new Set()) + React.useEffect(() => { + if (!trayParts.length || autoPlacedViews.current.has(view)) return + if (doc.parts.some((p) => p[view])) return // the view already has a layout + autoPlacedViews.current.add(view) + placeAllUnplacedIn(view) + }, [view, trayParts, doc]) + // switching views: selection is per-view state, wires especially const switchView = (v: ViewId): void => { if (v === view) return @@ -251,6 +342,150 @@ export function CircuitViewV2({ onEditChange?.(true) } + // ── right rail visibility ─────────────────────────────────────────────────── + // Simulate: schematic only, and only once the user has asked for it. + // Properties: editing only. Neither ⇒ no rail, canvas gets the full width. + const showSim = simOpen && view === 'sch' + const showProps = editable + const railVisible = showSim || showProps + const rail: RailTab = showSim && showProps ? railPref : showSim ? 'simulate' : 'properties' + + /** The Simulate button makes its own precondition true: schematic + panel. */ + const toggleSimulate = (): void => { + if (showSim) { + setSimOpen(false) + setPicking(false) + return + } + if (view !== 'sch') switchView('sch') + setSimOpen(true) + setRailPref('simulate') + // start the ~20 MB engine download now so the first Run isn't a cold start + void getSimBackend() + .warmup() + .catch(() => { + /* surfaced in the panel's engine status, not as a toast */ + }) + } + + // picking is a mode of the open Simulate panel — it can't outlive it + React.useEffect(() => { + if (!showSim && picking) setPicking(false) + }, [showSim, picking]) + + // ── sim probes (CircuitLab-style measurement tags) ────────────────────────── + // A picked output is a TAG on the sheet, not a highlight: it says what it + // reads, it can be dragged, and it can be thrown away. doc.sim.probes is the + // single source of truth — the Outputs list in the panel is a view of it. + + const probes = React.useMemo(() => doc.sim?.probes ?? [], [doc.sim]) + const nodeNames = React.useMemo(() => nodeNamesForNets(netModel), [netModel]) + + /** Where a probe hangs off the circuit, in this view's world coordinates. */ + const anchorWorldOf = React.useCallback( + (ref: string): Pt | null => { + const a = probeAnchor(ref) + if (!a) return null + const part = doc.parts.find((p) => p.id === a.part) + if (!part) return null + if (a.pin) return pinWorldOf(part, a.pin, undefined, view) + const pl = part[view] + return pl ? { x: pl.x, y: pl.y } : null + }, + // defsTick: pin geometry only exists once the part def has loaded + [doc.parts, view, defsTick] + ) + + const labelOfProbe = React.useCallback( + (p: Probe): string => { + const a = probeAnchor(p.at) + if (!a) return p.at + if (a.kind === 'i') return `I(${a.part})` + if (a.kind === 'd') + return String(doc.parts.find((x) => x.id === a.part)?.attrs?.label ?? a.part) + const idx = netModel.pinToNet.get(`${a.part}:${a.pin}`) + return idx != null ? netLabelFor(netModel, idx, nodeNames) : a.part + }, + [doc.parts, netModel, nodeNames] + ) + + /** A probe shows a number only when the run produced one (a DC operating + * point); for a sweep the reading lives in the plot, not on the sheet. */ + const valueOfProbe = React.useCallback( + (p: Probe): string | undefined => { + if (!sim.run || sim.run.numPoints !== 1 || !sim.netlist) return undefined + const name = vectorForOutput(p.at, netModel, sim.netlist) + if (!name) return undefined + const vec = sim.run.vectors.find((v) => v.name.toLowerCase() === name) + if (!vec || vec.values.length !== 1) return undefined + return fmtSI(vec.values[0], p.kind === 'current' ? 'A' : 'V') + }, + [sim, netModel] + ) + + const probeTags = React.useMemo(() => { + const out: ProbeTag[] = [] + for (const p of probes) { + const anchor = anchorWorldOf(p.at) + if (!anchor) continue // unplaced in this view — nothing to hang the tag on + const [dx, dy] = p[view] ?? PROBE_DEFAULT_OFFSET + out.push({ + id: p.id, + ax: anchor.x, + ay: anchor.y, + x: anchor.x + dx, + y: anchor.y + dy, + label: labelOfProbe(p), + value: valueOfProbe(p), + kind: p.kind + }) + } + return out + }, [probes, view, anchorWorldOf, labelOfProbe, valueOfProbe]) + + /** Outputs the panel shows as picked — exactly what has a tag on the sheet. */ + const pickedOutputs = React.useMemo(() => probes.map((p) => p.at), [probes]) + + /** Add a tag for an output (or remove the one that is already there). */ + const toggleOutput = (ref: OutputRef, at?: Pt): void => { + const existing = probeFor(store.getDoc().sim?.probes ?? [], ref) + if (existing) { + store.dispatch(cmd.removeProbe(existing.id)) + return + } + const anchor = at ? anchorWorldOf(ref) : null + const offset: [number, number] = + at && anchor + ? [at.x - anchor.x + 6, at.y - anchor.y - 22] + : ([...PROBE_DEFAULT_OFFSET] as [number, number]) + store.dispatch(cmd.addProbe(makeProbe(ref, view, offset))) + } + + /** Canvas click in pick mode: drop a tag on that net, right where clicked. */ + const pickNetAt = (netIndex: number, at: Pt): void => { + const ref = netOutputRefByIndex(netModel, netIndex) + if (ref) toggleOutput(ref, at) + } + + /** Tag dragged. A drop on a wire or pin re-anchors it to that node. */ + const moveProbeTo = (id: string, at: Pt, netIndex?: number): void => { + const current = (store.getDoc().sim?.probes ?? []).find((p) => p.id === id) + if (!current) return + let ref = current.at + if (netIndex != null) { + const next = netOutputRefByIndex(netModel, netIndex) + if (next) ref = next + } + const anchor = anchorWorldOf(ref) + if (!anchor) return + const offset: [number, number] = [at.x - anchor.x, at.y - anchor.y] + store.dispatch( + ref === current.at + ? cmd.moveProbe(id, view, offset) + : cmd.reanchorProbe(id, ref, offset, view) + ) + } + // Placing an unplaced part: drop it straight onto a free slot (no second // click) and flip into edit mode so it can be dragged immediately. const placeFromTray = (partId: string): void => { @@ -264,18 +499,96 @@ export function CircuitViewV2({ // ── actions ───────────────────────────────────────────────────────────────── + /** + * Placement for a part in the view the user is NOT looking at. Breadboards + * never enter the schematic (spec §10.2 — they're electrically transparent + * there), so they get no counterpart placement; everything else does, which + * is what keeps the tray empty for parts the user just added. + */ + const counterpartPlacement = (type: string, other: ViewId): Placement | undefined => { + if (other === 'sch' && isBreadboard(type)) return undefined + return autoPlacementFor(store.getDoc(), type, other) + } + const addPartAt = async (type: string, at?: Pt): Promise => { const def = getPart(type) || (await loadPart(type)) if (!def) return bumpDefs() const p = at ?? canvasRef.current?.centerWorld() ?? { x: 300, y: 200 } - const id = nextRefdes(store.getDoc(), prefixForFamily(`${def.family ?? ''} ${def.type}`)) - const pl = findFreePlacement(store.getDoc(), type, view, p) - const attrs = simSourceDefaultAttrs(type) ?? simProbeDefaultAttrs(type) - store.dispatch(cmd.addPart({ id, type, ...(attrs ? { attrs } : {}), [view]: pl })) + const id = nextRefdes( + store.getDoc(), + prefixForFamily(`${def.simFamily ?? def.family ?? ''} ${def.type}`, def.prefix) + ) + const attrs = simSourceDefaultAttrs(type) ?? simProbeDefaultAttrs(type) ?? defaultAttrsFor(type) + // The part lands where the user dropped it in this view, and auto-places + // (collision-avoided) in the other one — add an LED on the breadboard and + // its symbol is already sitting on the schematic. + const other: ViewId = view === 'bb' ? 'sch' : 'bb' + const placements: Partial> = { + [view]: findFreePlacement(store.getDoc(), type, view, p) + } + const counterpart = counterpartPlacement(type, other) + if (counterpart) placements[other] = counterpart + store.dispatch(cmd.addPart({ id, type, ...(attrs ? { attrs } : {}), ...placements })) setSel({ parts: new Set([id]), wires: new Set() }) } + /** + * Refdes prefix for a type. Prefers the loaded part definition (a pack can + * declare its own), and falls back to the static naming table so this works + * before a part's JSON has been lazily loaded. + */ + const prefixForType = React.useCallback((type: string): string => { + const def = getPart(type) + const naming = resolveNaming(type, def?.label, def?.simFamily ?? def?.family) + return prefixForFamily(`${naming.sim} ${type}`, def?.prefix ?? naming.prefix) + }, []) + + /** True when the document still carries part-file slugs as reference designators + * (`led`, `battery-aa_y90`) — what a migrated v1 diagram.json leaves behind. */ + const needsRenumber = React.useMemo(() => doc.parts.some((p) => looksLikeSlug(p.id)), [doc.parts]) + + /** Rewrite every part id to a conventional refdes (R1, C2, LED3…). */ + const renumber = (): void => { + const mapping = renumberAll(store.getDoc(), prefixForType, view) + const n = Object.keys(mapping).length + if (!n) { + toast.info('Reference designators are already in order.') + return + } + store.dispatch(cmd.renumberParts(mapping)) + setSel(emptySel()) + toast.success(`Renumbered ${n} part${n > 1 ? 's' : ''}`, { + description: Object.entries(mapping) + .slice(0, 3) + .map(([from, to]) => `${from} → ${to}`) + .join(' · ') + }) + } + + /** Auto-place every part missing from a view, in one undo step. */ + const placeAllUnplacedIn = (target: ViewId, select = false): void => { + const current = store.getDoc() + const missing = current.parts.filter( + (p) => !p[target] && (target === 'bb' ? p.sch : p.bb && !isBreadboard(p.type)) + ) + if (!missing.length) return + let next = current + const steps: cmd.Command[] = [] + for (const part of missing) { + const step = cmd.placePart(part.id, target, autoPlacementFor(next, part.type, target)) + steps.push(step) + next = step.apply(next) + } + store.dispatch(cmd.composite(`Place ${steps.length} part${steps.length > 1 ? 's' : ''}`, steps)) + if (select) { + setSel({ parts: new Set(missing.map((p) => p.id)), wires: new Set(), labels: new Set() }) + enterEdit() + } + } + + const placeAllUnplaced = (): void => placeAllUnplacedIn(view, true) + // .fzpz dropped on the canvas: convert → persist → place at the cursor const importFzpzFiles = async (files: File[], at: Pt): Promise => { for (const f of files) { @@ -284,10 +597,13 @@ export function CircuitViewV2({ await saveUserPart(def) bumpDefs() await addPartAt(def.type, at) - toast.success(`Imported ${def.label}`, { + // saveUserPart registers it, which is where naming/categorisation is + // applied — report the display name, not the raw Fritzing title. + const shown = getPart(def.type) ?? def + toast.success(`Imported ${shown.label}`, { description: warnings.length ? `${warnings.length} pin${warnings.length === 1 ? '' : 's'} could not be resolved` - : `${def.family} · now in the palette` + : `${shown.family} · now in the palette` }) } catch (err) { toast.error(`Couldn't import ${f.name}`, { @@ -316,8 +632,64 @@ export function CircuitViewV2({ if (def) setEditorPart(def) } + // ── image export ──────────────────────────────────────────────────────────── + // Render first, play the shutter/fly-to-corner animation against the real + // image, then hand the blob to the browser — so the download shows up right + // where the animation lands. + const savePng = React.useCallback(async (): Promise => { + if (exporting) return + setExporting(true) + try { + const shot = await renderPng(doc, view) + if (!shot) { + toast.error('Nothing to export — this view is empty.') + return + } + await playCaptureAnimation({ + stage: stageRef.current, + target: exportBtnRef.current, + imageUrl: shot.previewUrl + }) + saveImage(shot) + setSavedFile({ name: shot.name, id: ++saveSeq.current }) + } catch (err) { + console.error('circuit PNG export failed', err) + toast.error(`PNG export failed — ${(err as Error)?.message ?? String(err)}`) + } finally { + setExporting(false) + } + }, [doc, view, exporting]) + + const saveSvg = React.useCallback((): void => { + try { + const shot = exportSvg(doc, view) + if (!shot) { + toast.error('Nothing to export — this view is empty.') + return + } + setSavedFile({ name: shot.name, id: ++saveSeq.current }) + } catch (err) { + console.error('circuit SVG export failed', err) + toast.error(`SVG export failed — ${(err as Error)?.message ?? String(err)}`) + } + }, [doc, view]) + + // the download chip is transient + const savedId = savedFile?.id + React.useEffect(() => { + if (!savedId) return + const t = setTimeout(() => setSavedFile(null), 3600) + return () => clearTimeout(t) + }, [savedId]) + const tool = 'tactile-bordered h-8 px-2.5 flex items-center gap-1.5 rounded-md bg-surface-card text-text-muted text-xs hover:text-text-body active:translate-y-px' + // filled actions, matching the Upload button in the main toolbar: green for + // "run it", brand blue for "take it away with you" + const toolFilled = + 'tactile h-8 px-2.5 flex items-center gap-1.5 rounded-md text-white text-xs font-medium' + const toolGreen = `${toolFilled} bg-[var(--green)] [--_edge:var(--green-deep)]` + const toolBrand = `${toolFilled} bg-[var(--brand)] [--_edge:var(--brand-deep)]` return (
@@ -334,7 +706,7 @@ export function CircuitViewV2({ /> )} -
+
{/* left toolbar: edit toggle + undo/redo */}
{/* view toggle: Breadboard | Schematic */} @@ -397,6 +776,15 @@ export function CircuitViewV2({ {trayParts.length > 0 && (
unplaced here: + {trayParts.length > 1 && ( + + )} {trayParts.map((part) => ( + {/* right toolbar: simulate · export · code */} +
- - + + {/* one export control, two formats — the two icon buttons that used + to sit here read as unrelated actions */} +
+ + {exportOpen && ( + <> + {/* click-away catcher */} +
setExportOpen(false)} /> +
+ + +
+ + )} +
+ {onOpenCode && ( + )} {warnings.length > 0 && ( void importFzpzFiles(files, at)} annotations={simAnnotations} simVoltageForNet={simVoltageForNet} + pickNets={picking && showSim} + onPickNet={pickNetAt} + probes={probeTags} + onMoveProbe={moveProbeTo} + onDeleteProbe={(id) => store.dispatch(cmd.removeProbe(id))} onRequestEdit={enterEdit} /> - {showSim && ( - getPart(t)?.family} - onClose={() => setShowSim(false)} - onResult={setSim} - onSelectIssue={selectSimIssue} - /> - )} - {/* watermark — matches the header wordmark: thin 'tiny', bold 'Studio' */} -
+
tiny Studio
- {editable && ( - + {/* Right rail. Properties is an editing tool and Simulate is a + schematic tool, so the rail only exists when one of them applies — + the breadboard in view-only mode has no sidebar at all. */} + {railVisible && ( +
+ {showSim && showProps ? ( +
+ {( + [ + ['properties', 'Properties'], + ['simulate', 'Simulate'] + ] as [RailTab, string][] + ).map(([id, label]) => ( + + ))} +
+ ) : ( +
+ + {showSim ? 'Simulate' : 'Properties'} + + {showSim && ( + + )} +
+ )} + {rail === 'properties' ? ( + + ) : ( + getPart(t)?.simFamily ?? getPart(t)?.family} + onClose={() => { + setSimOpen(false) + setPicking(false) + }} + onResult={setSim} + onSelectIssue={selectSimIssue} + picking={picking} + onPickingChange={setPicking} + picked={pickedOutputs} + onToggleOutput={(ref) => toggleOutput(ref)} + onClearOutputs={() => store.dispatch(cmd.setProbes([]))} + /> + )} +
)} {editorPart !== undefined && ( @@ -612,6 +1100,36 @@ export function CircuitViewV2({ ) } +/** + * The little "saved" chip that pops in under the export button — the landing + * pad the capture animation flies into, so the download has somewhere to be. + */ +function DownloadChip({ name }: { name: string }): React.JSX.Element { + const ref = React.useRef(null) + React.useEffect(() => { + const el = ref.current + if (!el || typeof el.animate !== 'function') return + el.animate( + [ + { opacity: 0, transform: 'translateY(-8px) scale(0.9)' }, + { opacity: 1, transform: 'translateY(0) scale(1)', offset: 0.35 }, + { opacity: 1, transform: 'translateY(0) scale(1)', offset: 0.85 }, + { opacity: 0, transform: 'translateY(-4px) scale(0.98)' } + ], + { duration: 3600, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', fill: 'both' } + ) + }, [name]) + return ( +
+ + {name} +
+ ) +} + function ErcRow({ issue, onSelect diff --git a/src/renderer/src/circuit/views/canvas/Canvas.tsx b/src/renderer/src/circuit/views/canvas/Canvas.tsx index 3eda10d..3442cf8 100644 --- a/src/renderer/src/circuit/views/canvas/Canvas.tsx +++ b/src/renderer/src/circuit/views/canvas/Canvas.tsx @@ -11,7 +11,7 @@ * (camera, hover, armed wire, marquee, edit buffer) never touches the doc. */ -import { CircuitBoard, Zap } from 'lucide-react' +import { CircuitBoard, Crosshair, X, Zap } from 'lucide-react' import React from 'react' import { buildClipboard, materializePaste, parseClipboard } from '../../core/clipboard' import * as cmd from '../../core/commands' @@ -30,6 +30,8 @@ import { import { describeNet, type NetModel } from '../../core/nets' import { isBreadboard } from '../../parts/breadboard' import { netLabelVisualOf, snapNetLabel } from '../../parts/netLabels' +import { labelLayout, refdesOf, valueOf } from '../../parts/labels' +import { FONT_REFDES, FONT_VALUE, WIRE_STROKE } from '../../parts/style' import { calculateOrthogonalPath, clampOntoSegment, @@ -65,10 +67,15 @@ import { // wire look (identical to DiagramEditor / tinySchematic) const WIRE_W = 2.8 -const WIRE_SCH_W = 1 // schematic ink: thin single stroke (no outline/glow) +// schematic ink: one stroke, same weight as a symbol's pin lead so a wire +// reads as a continuation of the lead it lands on (parts/style.ts) +const WIRE_SCH_W = WIRE_STROKE const WIRE_OUTLINE_W = WIRE_W + 1.8 const WIRE_GLOW_W = WIRE_W + 5 const WIRE_CORNER = 4 +// schematics turn square: a filleted corner reads as a hand-drawn sketch, +// and every schematic convention (and CircuitLab) uses a hard 90° +const WIRE_CORNER_SCH = 0 const NET_GLOW = 'rgba(243, 203, 0, 0.30)' export interface Selection { @@ -89,6 +96,24 @@ export interface Cam { ty: number } +/** + * A placed measurement tag, already resolved to this view's world coordinates + * by the shell (which owns pin geometry). `ax/ay` is the node it reads — + * where the leader line starts; `x/y` is where the label sits, which is the + * part the user drags. + */ +export interface ProbeTag { + id: string + ax: number + ay: number + x: number + y: number + label: string + /** last measured value, when the run produced a single number */ + value?: string + kind: 'voltage' | 'current' | 'diff' +} + export interface CanvasHandle { fit: () => void zoomCenter: (factor: number) => void @@ -152,6 +177,11 @@ export function Canvas({ onImportFiles, annotations, simVoltageForNet, + pickNets = false, + onPickNet, + probes, + onMoveProbe, + onDeleteProbe, onRequestEdit }: { store: CircuitStore @@ -180,6 +210,22 @@ export function Canvas({ annotations?: { x: number; y: number; text: string }[] /** DC (.op) net voltage lookup, for the breadboard hole tooltip */ simVoltageForNet?: (netIdx: number) => string | undefined + /** + * Sim output picking (spec §10.4, CircuitLab-style): while on, a click on a + * wire or a pin toggles that net as an analysis output instead of selecting + * it. Works in view-only mode too — picking what to measure is not editing. + */ + pickNets?: boolean + /** a pick: the net that was hit, and where on the sheet it was clicked */ + onPickNet?: (netIndex: number, at: Pt) => void + /** placed measurement tags for this view, already resolved to world coords */ + probes?: ProbeTag[] + /** + * A tag was dragged. `at` is its new world position; `netIndex` is set only + * when the drop landed squarely on a wire or pin, which re-anchors it. + */ + onMoveProbe?: (id: string, at: Pt, netIndex?: number) => void + onDeleteProbe?: (id: string) => void /** tray "attach to cursor" placement: id being placed + drop callback */ /** double-clicking a component asks the shell to enter edit mode */ onRequestEdit: () => void @@ -631,10 +677,34 @@ export function Canvas({ setArmed(null) } + /** Output picking: resolve a click to its net and hand it to the shell, + * along with the point clicked — that is where the tag lands. */ + const pickNetOf = (key: string, from: 'pin' | 'wire', at: Pt): boolean => { + if (!pickNets) return false + const idx = from === 'pin' ? netModel.pinToNet.get(key) : netModel.wireToNet.get(key) + if (idx == null) return false + onPickNet?.(idx, at) + return true + } + + /** The net under a world point, if the point is on a wire or a pin. */ + const netAtWorld = (wx: number, wy: number): number | undefined => { + const pin = pinAtWorld(doc, wx, wy, 10 / scale, view) + if (pin) return netModel.pinToNet.get(`${pin.id}:${pin.pin}`) + const hit = hitWire(wx, wy, geomForHit, scale) + if (hit) return netModel.wireToNet.get(hit.id) + return undefined + } + const onPinClick = (e: React.MouseEvent, partId: string, pin: string): void => { + const ref = `${partId}:${pin}` + if (pickNets) { + e.stopPropagation() + pickNetOf(ref, 'pin', resolve(ref) ?? canvasPoint(e)) + return + } if (!editable) return e.stopPropagation() - const ref = `${partId}:${pin}` const pos = resolve(ref) if (pos) pinInteract(ref, pos) } @@ -779,6 +849,10 @@ export function Canvas({ const onWireClick = (e: React.MouseEvent, wireId: string): void => { e.stopPropagation() + if (pickNets) { + pickNetOf(wireId, 'wire', canvasPoint(e)) + return + } if (armed) { const cp = canvasPoint(e) setMouse(cp) @@ -1183,10 +1257,47 @@ export function Canvas({ return out }, [doc, resolve]) + // ── probe tags ────────────────────────────────────────────────────────────── + // A tag is dragged by its label. Dropping it on a wire or a pin re-anchors it + // to that node; dropping it anywhere else just moves the label. + + const probeDrag = React.useRef<{ id: string; offX: number; offY: number } | null>(null) + const [draggingProbe, setDraggingProbe] = React.useState(null) + + const onProbeDown = (e: React.PointerEvent, tag: ProbeTag): void => { + if (e.button !== 0) return + e.stopPropagation() + e.preventDefault() + const start = canvasPoint(e) + const d = { id: tag.id, offX: tag.x - start.x, offY: tag.y - start.y } + probeDrag.current = d + setDraggingProbe(tag.id) + const move = (ev: PointerEvent): void => { + if (!probeDrag.current) return + const p = canvasPoint(ev) + suppressClick.current = true + onMoveProbe?.(d.id, { x: p.x + d.offX, y: p.y + d.offY }) + } + const up = (ev: PointerEvent): void => { + probeDrag.current = null + setDraggingProbe(null) + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + const p = canvasPoint(ev) + onMoveProbe?.(d.id, { x: p.x + d.offX, y: p.y + d.offY }, netAtWorld(p.x, p.y)) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + // Contextual hint only while actively wiring/editing — no idle 'scroll to // zoom' / 'view-only' bubbles cluttering the canvas. - const hint: { icon: React.JSX.Element | null; text: string } | null = - editable && armed + const hint: { icon: React.JSX.Element | null; text: string } | null = pickNets + ? { + icon: , + text: 'Click a wire or a pin to drop a probe · drag a tag to move it · drop a tag on a wire to re-anchor it' + } + : editable && armed ? { icon: , text: `Click a pin or wire to connect · click for a bend${view === 'bb' ? ' · hold Space for straight' : ''} · Esc to cancel` @@ -1205,7 +1316,7 @@ export function Canvas({ className="size-full overflow-hidden" style={{ touchAction: 'none', - cursor: armed ? 'crosshair' : 'default', + cursor: armed || pickNets ? 'crosshair' : 'default', // schematic reads as paper (lighter surface, finer dot grid — spec §8.1) backgroundColor: view === 'sch' ? 'var(--bg)' : 'var(--bg-sunken)', backgroundImage: grid @@ -1270,11 +1381,14 @@ export function Canvas({ const outline = view === 'sch' ? 'rgba(0,0,0,0.45)' : darken(w.color || '#2fa46a') // schematic ink is a single thin stroke — no color outline, no glow. const coreW = view === 'sch' ? WIRE_SCH_W : WIRE_W - const d = roundedPath(pts) + const d = roundedPath(pts, view === 'sch' ? WIRE_CORNER_SCH : WIRE_CORNER) return ( setHoverWire(w.id)} onPointerLeave={() => setHoverWire((h) => (h === w.id ? null : h))} onClick={(e) => onWireClick(e, w.id)} @@ -1345,7 +1459,7 @@ export function Canvas({ {previewPts && ( onPartDown(e, part.id)} - onDoubleClick={(e) => { - e.stopPropagation() - if (!editable) onRequestEdit() - }} - onPointerMove={(e) => { - if (!editable || view !== 'bb' || !isBreadboard(part.type) || partDrag.current) - return - const cp = canvasPoint(e) - const hole = holeAt(doc, part.id, cp.x, cp.y, 6 / Math.min(scale, 1)) - setHoverHole(hole ? { id: part.id, pin: hole.pin, pos: hole.pos } : null) - }} - onPointerLeave={() => setHoverHole((h) => (h?.id === part.id ? null : h))} - onClick={(e) => e.stopPropagation()} - onContextMenu={(e) => { - if (!editable) return - e.preventDefault() - // breadboards rotate as a rigid assembly (seated parts + wires - // turn with the board); other parts rotate in place. - if (view === 'bb' && isBreadboard(part.type)) { - rotateBoardAssembly(part.id) - return - } - const cur = doc.parts.find((p) => p.id === part.id)?.[view] - if (!cur) return - const next = (((((cur.rotate ?? 0) + 90) % 360) + 360) % 360) as - | 0 - | 90 - | 180 - | 270 - const frozen = collectFrozen(doc, new Set([part.id]), view) - const placements = new Map([[part.id, { ...cur, rotate: next || undefined }]]) - store.dispatch( - cmd.placePart( - part.id, - view, - placements.get(part.id), - reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view) - ) - ) - }} - > +
- {editable && - Object.keys(vis.v.pins).length <= 60 && - Object.keys(vis.v.pins).map((pin) => { - const [restX, restY] = vis.v.pins[pin] - const isLeg = view === 'bb' && (vis.v.legs?.includes(pin) ?? false) - const legOff = isLeg ? pl.legs?.[pin] : undefined - const px = restX + (legOff?.[0] ?? 0) - const py = restY + (legOff?.[1] ?? 0) - const armedHere = armed?.from === `${part.id}:${pin}` - const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin - const netIdx = netModel.pinToNet.get(`${part.id}:${pin}`) - const onHotNet = highlightNet >= 0 && netIdx === highlightNet - // a pin sharing a net with anything else is connected — its - // "open lead" dot disappears (still clickable to re-wire). - const connected = - netIdx !== undefined && (netModel.nets[netIdx]?.length ?? 0) >= 2 - const on = armedHere || hovered - const showDot = on || onHotNet || !connected - return ( -
{ - e.stopPropagation() - if (editable && isLeg && !armed) - beginLegDrag(e, part.id, pin, legOff ?? [0, 0]) - }} - onDoubleClick={(e) => { - if (!editable || !isLeg || !legOff) return - e.stopPropagation() - const cur = doc.parts.find((p) => p.id === part.id)?.[view] - if (!cur?.legs?.[pin]) return - const legs = { ...cur.legs } - delete legs[pin] - store.dispatch( - cmd.placePart(part.id, view, { - ...cur, - legs: Object.keys(legs).length ? legs : undefined - }) - ) - }} - onPointerEnter={() => setHoverPin({ id: part.id, pin })} - onPointerLeave={() => - setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) - } - onClick={(e) => { - if (suppressClick.current) { - suppressClick.current = false - e.stopPropagation() - return - } - onPinClick(e, part.id, pin) - }} - > - - {onHotNet && !on && ( - - )} - {showDot && ( - - )} - -
+ style={{ + position: 'absolute', + left: pl.x, + top: pl.y, + width: vis.v.w, + height: vis.v.h, + // boards sit under every other part (and under the z5 wire layer) + zIndex: isBreadboard(part.type) ? 1 : 2, + transform: + pl.rotate || pl.flip + ? `${pl.rotate ? `rotate(${pl.rotate}deg)` : ''}${pl.flip ? ' scaleX(-1)' : ''}` + : undefined, + transformOrigin: 'center', + outline: selected ? '2px solid var(--brand)' : 'none', + outlineOffset: 4, + borderRadius: 6, + cursor: editable ? 'move' : 'default' + }} + onPointerDown={(e) => onPartDown(e, part.id)} + onDoubleClick={(e) => { + e.stopPropagation() + if (!editable) onRequestEdit() + }} + onPointerMove={(e) => { + if (!editable || view !== 'bb' || !isBreadboard(part.type) || partDrag.current) + return + const cp = canvasPoint(e) + const hole = holeAt(doc, part.id, cp.x, cp.y, 6 / Math.min(scale, 1)) + setHoverHole(hole ? { id: part.id, pin: hole.pin, pos: hole.pos } : null) + }} + onPointerLeave={() => setHoverHole((h) => (h?.id === part.id ? null : h))} + onClick={(e) => e.stopPropagation()} + onContextMenu={(e) => { + if (!editable) return + e.preventDefault() + // breadboards rotate as a rigid assembly (seated parts + wires + // turn with the board); other parts rotate in place. + if (view === 'bb' && isBreadboard(part.type)) { + rotateBoardAssembly(part.id) + return + } + const cur = doc.parts.find((p) => p.id === part.id)?.[view] + if (!cur) return + const next = (((((cur.rotate ?? 0) + 90) % 360) + 360) % 360) as + | 0 + | 90 + | 180 + | 270 + const frozen = collectFrozen(doc, new Set([part.id]), view) + const placements = new Map([[part.id, { ...cur, rotate: next || undefined }]]) + store.dispatch( + cmd.placePart( + part.id, + view, + placements.get(part.id), + reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view) + ) ) - })} - {view === 'bb' && vis.v.legs && vis.v.legs.length > 0 && ( - // bent-leg indicator: a simple ink line from the rest pin to - // the dragged tip, overlaid on the (static) Fritzing art — - // real per-instance leg-path warping is future work. - - {vis.v.legs.map((pin) => { - const off = pl.legs?.[pin] - if (!off || (off[0] === 0 && off[1] === 0)) return null - const [rx, ry] = vis.v.pins[pin] + }} + > +
+ {(editable || pickNets) && + !isBreadboard(part.type) && + Object.keys(vis.v.pins).length <= 256 && + Object.keys(vis.v.pins).map((pin) => { + const [restX, restY] = vis.v.pins[pin] + const isLeg = view === 'bb' && (vis.v.legs?.includes(pin) ?? false) + const legOff = isLeg ? pl.legs?.[pin] : undefined + const px = restX + (legOff?.[0] ?? 0) + const py = restY + (legOff?.[1] ?? 0) + const armedHere = armed?.from === `${part.id}:${pin}` + const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin + const netIdx = netModel.pinToNet.get(`${part.id}:${pin}`) + const onHotNet = highlightNet >= 0 && netIdx === highlightNet + // a pin sharing a net with anything else is connected — its + // "open lead" dot disappears (still clickable to re-wire). + const connected = + netIdx !== undefined && (netModel.nets[netIdx]?.length ?? 0) >= 2 + const on = armedHere || hovered + // a prototyping field (tinyProto) has too many holes to dot + // them all at rest — show one on hover, like a breadboard. + const dense = Object.keys(vis.v.pins).length > 60 + const showDot = on || onHotNet || (!connected && !dense) return ( - + className="pin-hit" + title={isLeg ? `${pin} (drag to bend the leg)` : pin} + style={{ + position: 'absolute', + left: px - 8, + top: py - 8, + width: 16, + height: 16, + zIndex: 3, + cursor: 'crosshair' + }} + onPointerDown={(e) => { + // in pick mode the pin is only a click target — let + // the press through so panning still works there + if (pickNets && !editable) return + e.stopPropagation() + if (editable && isLeg && !armed) + beginLegDrag(e, part.id, pin, legOff ?? [0, 0]) + }} + onDoubleClick={(e) => { + if (!editable || !isLeg || !legOff) return + e.stopPropagation() + const cur = doc.parts.find((p) => p.id === part.id)?.[view] + if (!cur?.legs?.[pin]) return + const legs = { ...cur.legs } + delete legs[pin] + store.dispatch( + cmd.placePart(part.id, view, { + ...cur, + legs: Object.keys(legs).length ? legs : undefined + }) + ) + }} + onPointerEnter={() => setHoverPin({ id: part.id, pin })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) + } + onClick={(e) => { + if (suppressClick.current) { + suppressClick.current = false + e.stopPropagation() + return + } + onPinClick(e, part.id, pin) + }} + > + + {onHotNet && !on && ( + + )} + {showDot && ( + + )} + +
) })} - - )} + {view === 'bb' && vis.v.legs && vis.v.legs.length > 0 && ( + // bent-leg indicator: a simple ink line from the rest pin to + // the dragged tip, overlaid on the (static) Fritzing art — + // real per-instance leg-path warping is future work. + + {vis.v.legs.map((pin) => { + const off = pl.legs?.[pin] + if (!off || (off[0] === 0 && off[1] === 0)) return null + const [rx, ry] = vis.v.pins[pin] + return ( + + ) + })} + + )} +
+ {/* Schematic text: refdes above the symbol, value below it + (spec §8). The breadboard keeps its single muted caption — + a value on a physical part would be noise. */}
onLabelDown(e, part.id)} > - {String(part.attrs?.label ?? part.id)} + {refdesOf(part)}
-
+ {valueText && ( +
+ {valueText} +
+ )} + ) })} @@ -1787,6 +1946,76 @@ export function Canvas({
))} + {/* probe tags: leader lines + anchor dots (labels follow, above) */} + {probes && probes.length > 0 && ( + + {probes.map((t) => ( + + + + + + ))} + + )} + + {probes?.map((t) => ( +
onProbeDown(e, t)} + onClick={(e) => e.stopPropagation()} + > + + {t.kind === 'current' ? 'A' : t.kind === 'diff' ? '\u0394' : 'V'} + + {t.label} + {t.value && {t.value}} + +
+ ))} + {doc.parts.length === 0 && (
{ + const { stage, target, imageUrl, duration = 1000 } = opts + if (!stage || typeof stage.animate !== 'function') return + if (prefersReducedMotion()) { + await flashOnly(stage) + return + } + + const stageBox = stage.getBoundingClientRect() + if (stageBox.width < 80 || stageBox.height < 80) return + + const layer = document.createElement('div') + layer.setAttribute('data-capture-layer', '') + layer.style.cssText = + 'position:absolute;inset:0;z-index:60;pointer-events:none;overflow:hidden;border-radius:inherit' + + // ── shutter flash ───────────────────────────────────────────────────────── + const flash = document.createElement('div') + flash.style.cssText = 'position:absolute;inset:0;background:#fff;opacity:0' + layer.appendChild(flash) + + // ── the "print": the exported scene, framed ─────────────────────────────── + const inset = 10 + const print = document.createElement('div') + print.style.cssText = [ + 'position:absolute', + `left:${inset}px`, + `top:${inset}px`, + `width:${Math.max(1, stageBox.width - inset * 2)}px`, + `height:${Math.max(1, stageBox.height - inset * 2)}px`, + 'border-radius:10px', + 'overflow:hidden', + 'opacity:0', + 'transform-origin:center center', + 'background:var(--bg-sunken, #1e1f22)', + 'box-shadow:0 18px 48px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.16) inset', + 'will-change:transform,opacity' + ].join(';') + + if (imageUrl) { + const img = document.createElement('img') + img.src = imageUrl + img.alt = '' + img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block' + print.appendChild(img) + } + layer.appendChild(print) + stage.appendChild(layer) + + // Where the print is headed: the export button, else the top-right corner. + const targetBox = target?.getBoundingClientRect() + const stageCx = stageBox.width / 2 + const stageCy = stageBox.height / 2 + const destCx = targetBox + ? targetBox.left - stageBox.left + targetBox.width / 2 + : stageBox.width - 26 + const destCy = targetBox ? targetBox.top - stageBox.top + targetBox.height / 2 : 26 + const destW = targetBox?.width || 32 + const destH = targetBox?.height || 32 + const scale = Math.max( + 0.02, + Math.min(destW / Math.max(1, stageBox.width), destH / Math.max(1, stageBox.height)) * 0.85 + ) + + const flashAnim = flash.animate( + [{ opacity: 0 }, { opacity: 0.72, offset: 0.3 }, { opacity: 0 }], + { duration: Math.min(280, duration), easing: 'ease-out' } + ) + + const flight = `translate(${destCx - stageCx}px, ${destCy - stageCy}px)` + const printAnim = print.animate( + [ + // 1 — the shutter is still white: nothing to see yet + { transform: 'scale(1)', opacity: 0, offset: 0, easing: 'ease-out' }, + // 2 — the print snaps in at full size, exactly over what was captured + { transform: 'scale(1)', opacity: 1, offset: 0.12, easing: 'cubic-bezier(0.3, 1.2, 0.5, 1)' }, + // 3 — it shrinks a bit and holds: this is the beat that reads "screenshot" + { transform: 'scale(0.86)', opacity: 1, offset: 0.34, easing: 'linear' }, + { + transform: 'scale(0.85)', + opacity: 1, + offset: 0.5, + easing: 'cubic-bezier(0.55, 0, 0.3, 1)' + }, + // 4 — off to the corner the download lands in, landing before it fades + { + transform: `${flight} scale(${scale}) rotate(-5deg)`, + opacity: 0.9, + offset: 0.92, + easing: 'ease-out' + }, + { transform: `${flight} scale(${scale * 0.65}) rotate(-5deg)`, opacity: 0, offset: 1 } + ], + { duration, fill: 'forwards' } + ) + + // the corner catches it + if (target && typeof target.animate === 'function') { + target.animate( + [ + { transform: 'scale(1)', offset: 0 }, + { transform: 'scale(1)', offset: 0.88 }, + { transform: 'scale(1.3)', offset: 0.95 }, + { transform: 'scale(1)', offset: 1 } + ], + { duration, easing: 'ease-out' } + ) + } + + try { + await Promise.all([safeFinish(flashAnim), safeFinish(printAnim)]) + } finally { + layer.remove() + } +} + +/** Reduced-motion path: a single quiet flash, no flight. */ +async function flashOnly(stage: HTMLElement): Promise { + const flash = document.createElement('div') + flash.style.cssText = + 'position:absolute;inset:0;z-index:60;pointer-events:none;background:#fff;opacity:0' + stage.appendChild(flash) + try { + await safeFinish(flash.animate([{ opacity: 0 }, { opacity: 0.5 }, { opacity: 0 }], 220)) + } finally { + flash.remove() + } +} + +async function safeFinish(a: Animation): Promise { + try { + await a.finished + } catch { + /* cancelled — nothing to do */ + } +} diff --git a/src/renderer/src/circuit/views/exportImage.ts b/src/renderer/src/circuit/views/exportImage.ts index 3d83fb4..95237e2 100644 --- a/src/renderer/src/circuit/views/exportImage.ts +++ b/src/renderer/src/circuit/views/exportImage.ts @@ -7,14 +7,16 @@ import { isBreadboard } from '../parts/breadboard' import { netLabelVisualOf } from '../parts/netLabels' +import { labelLayout, refdesOf, valueOf } from '../parts/labels' +import { FONT, FONT_REFDES, FONT_VALUE, INK, JUNCTION_R, WIRE_STROKE } from '../parts/style' import { escapeXml, namespaceSvgIds, prepareSvgForEmbed, svgNs } from '../parts/svg' import { isJunction, type CircuitDoc, type ViewId } from '../core/model' import { makeEndResolver, partArtFor, viewBounds, visualFor, wireGeometry } from './partsAdapter' const WIRE_W = 2.8 const WIRE_OUTLINE_W = WIRE_W + 1.8 -const WIRE_SCH_W = 1 -const INK = 'var(--text-strong)' + +const round2 = (n: number): number => Math.round(n * 100) / 100 function darken(hex: string, f = 0.55): string { const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) @@ -75,16 +77,20 @@ export function composeSceneSvg(doc: CircuitDoc, bg: string, view: ViewId = 'bb' const minX = bounds.minX - pad const minY = bounds.minY - pad const maxX = bounds.maxX + pad + 60 - const maxY = bounds.maxY + pad + 48 // room for the larger watermark + // the watermark is 46px type: it needs room for its descenders too, or the + // bottom of "tinyStudio" is sliced off by the image edge + const maxY = bounds.maxY + pad + 72 const W = Math.round(maxX - minX) const H = Math.round(maxY - minY) const wires = wireGeometry(doc, view) .map(({ w, pts }) => { if (pts.length < 2) return '' - const d = roundedPath(pts) + // square corners on the schematic, filleted on the breadboard (where + // wires are physical jumpers) + const d = roundedPath(pts, sch ? 0 : 4) if (sch) - return `` + return `` const core = w.color || '#2fa46a' return `` }) @@ -97,7 +103,7 @@ export function composeSceneSvg(doc: CircuitDoc, bg: string, view: ViewId = 'bb' [w.from, w.to].filter(isJunction).map((j) => { const p = resolve(j) return p - ? `` + ? `` : '' }) ) @@ -112,13 +118,31 @@ export function composeSceneSvg(doc: CircuitDoc, bg: string, view: ViewId = 'bb' const art = partArtFor(part, vis, view) const g = embed(art, part.id, pl.x, pl.y, vis.v.w, vis.v.h, pl.rotate, sch && pl.flip) const off = pl.labelOffset || [0, 0] - const labelText = String(part.attrs?.label ?? part.id) - const label = `${escapeXml(labelText)}` + // schematic: refdes above the symbol, value below it — same layout the + // canvas draws, so an export matches the screen (parts/labels.ts) + if (sch) { + // same anchoring the canvas uses, so an export matches the screen + const lay = labelLayout(vis.v.w, vis.v.h, vis.v.pins, pl.rotate) + // SVG text is baseline-positioned; the layout returns text-block tops + const refdes = `${escapeXml(refdesOf(part))}` + const value = valueOf(part, vis.def.simFamily ?? vis.def.family) + const valueText = value + ? `${escapeXml(value)}` + : '' + return `${g}${refdes}${valueText}` + } + const label = `${escapeXml(refdesOf(part))}` return `${g}${label}` } // boards paint first — under the wires and every other part - const boardsSvg = doc.parts.filter((p) => isBreadboard(p.type)).map(partSvg).join('') - const partsSvg = doc.parts.filter((p) => !isBreadboard(p.type)).map(partSvg).join('') + const boardsSvg = doc.parts + .filter((p) => isBreadboard(p.type)) + .map(partSvg) + .join('') + const partsSvg = doc.parts + .filter((p) => !isBreadboard(p.type)) + .map(partSvg) + .join('') const labelsSvg = sch ? (doc.netLabels ?? []) @@ -129,7 +153,18 @@ export function composeSceneSvg(doc: CircuitDoc, bg: string, view: ViewId = 'bb' .join('') : '' - const watermark = `tinyStudio` + // Two separate runs rather than weighted s inside one: tspans + // that share a text-anchor are positioned independently by some SVG + // rasterisers, which stacked "tiny" and "Studio" on top of each other in + // exported PNGs. Placed right-to-left off the same baseline instead. + const wmSize = 46 + const wmX = maxX - 14 + const wmY = maxY - 30 + const wmFont = `font-family="${FONT}" font-size="${wmSize}" letter-spacing="-1" fill="#79818c" fill-opacity="0.4"` + const studioW = 'Studio'.length * wmSize * 0.55 + const watermark = + `Studio` + + `tiny` return `${boardsSvg}${wires}${dots}${partsSvg}${labelsSvg}${watermark}` } @@ -145,13 +180,23 @@ function resolveCssVars(svg: string): string { }) } +/** + * Kick a Blob into the user's downloads. The anchor has to be in the document + * for Chromium/Electron to honour `download` reliably on every code path. + */ function download(blob: Blob, name: string): void { const a = document.createElement('a') const href = URL.createObjectURL(blob) a.href = href a.download = name + a.rel = 'noopener' + a.style.display = 'none' + document.body.appendChild(a) a.click() - setTimeout(() => URL.revokeObjectURL(href), 1000) + setTimeout(() => { + a.remove() + URL.revokeObjectURL(href) + }, 1000) } function bgFor(view: ViewId): string { @@ -159,42 +204,103 @@ function bgFor(view: ViewId): string { return (getComputedStyle(document.documentElement).getPropertyValue(token) || '#1e1f22').trim() } -export function exportSvg(doc: CircuitDoc, view: ViewId = 'bb'): void { - const svg = composeSceneSvg(doc, bgFor(view), view) - if (!svg) return - const name = view === 'sch' ? 'circuit-schematic.svg' : 'circuit.svg' - download(new Blob([resolveCssVars(svg)], { type: 'image/svg+xml;charset=utf-8' }), name) +function baseName(view: ViewId): string { + return view === 'sch' ? 'circuit-schematic' : 'circuit' +} + +/** + * UTF-8-safe `data:` URL for an SVG string. + * + * This is why PNG export used to do nothing: the app CSP is + * `img-src 'self' data: …` with no `blob:`, so pointing an at an object + * URL was blocked before it ever loaded — onerror fired, the canvas was never + * painted, and no file was written. `data:` is on the allowlist. + */ +export function svgDataUrl(svg: string): string { + const bytes = new TextEncoder().encode(svg) + let bin = '' + for (let i = 0; i < bytes.length; i += 0x8000) + bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)) + return `data:image/svg+xml;base64,${btoa(bin)}` +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('the SVG scene failed to rasterize')) + img.src = src + }) +} + +/** A rendered export: the file to save plus a preview URL for the UI. */ +export interface ExportedImage { + blob: Blob + /** data: URL of the composed scene — safe to drop straight into an . */ + previewUrl: string + name: string + width: number + height: number +} + +export function exportSvg(doc: CircuitDoc, view: ViewId = 'bb'): ExportedImage | null { + const raw = composeSceneSvg(doc, bgFor(view), view) + if (!raw) return null + const svg = resolveCssVars(raw) + const m = /width="(\d+)" height="(\d+)"/.exec(svg) + const shot: ExportedImage = { + blob: new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), + previewUrl: svgDataUrl(svg), + name: `${baseName(view)}.svg`, + width: m ? parseInt(m[1], 10) : 800, + height: m ? parseInt(m[2], 10) : 600 + } + download(shot.blob, shot.name) + return shot } -export function exportPng(doc: CircuitDoc, view: ViewId = 'bb'): void { +/** + * Compose + rasterize the scene at 2×, without saving it. Split out from + * `exportPng` so the view can play its capture animation against the real + * image and only then drop the file into the downloads. + */ +export async function renderPng( + doc: CircuitDoc, + view: ViewId = 'bb' +): Promise { const raw = composeSceneSvg(doc, bgFor(view), view) - if (!raw) return + if (!raw) return null const svg = resolveCssVars(raw) const m = /width="(\d+)" height="(\d+)"/.exec(svg) const W = m ? parseInt(m[1], 10) : 800 const H = m ? parseInt(m[2], 10) : 600 - const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })) - const img = new Image() - img.onload = () => { - const sf = 2 - const canvas = document.createElement('canvas') - canvas.width = W * sf - canvas.height = H * sf - const ctx = canvas.getContext('2d') - if (!ctx) { - URL.revokeObjectURL(url) - return - } - ctx.scale(sf, sf) - ctx.drawImage(img, 0, 0) - URL.revokeObjectURL(url) - canvas.toBlob((png) => { - if (png) download(png, view === 'sch' ? 'circuit-schematic.png' : 'circuit.png') - }, 'image/png') - } - img.onerror = () => { - URL.revokeObjectURL(url) - console.error('circuit PNG export: SVG failed to rasterize') - } - img.src = url + const src = svgDataUrl(svg) + const img = await loadImage(src) + + const sf = 2 + const canvas = document.createElement('canvas') + canvas.width = W * sf + canvas.height = H * sf + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('no 2D canvas context available') + ctx.scale(sf, sf) + ctx.drawImage(img, 0, 0, W, H) + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + if (!blob) throw new Error('the canvas could not be encoded as PNG') + return { blob, previewUrl: src, name: `${baseName(view)}.png`, width: W, height: H } +} + +/** Save an already-rendered export. */ +export function saveImage(shot: ExportedImage): void { + download(shot.blob, shot.name) +} + +export async function exportPng( + doc: CircuitDoc, + view: ViewId = 'bb' +): Promise { + const shot = await renderPng(doc, view) + if (shot) saveImage(shot) + return shot } diff --git a/src/renderer/src/circuit/views/inspector/Inspector.tsx b/src/renderer/src/circuit/views/inspector/Inspector.tsx index 73103b6..1bccdca 100644 --- a/src/renderer/src/circuit/views/inspector/Inspector.tsx +++ b/src/renderer/src/circuit/views/inspector/Inspector.tsx @@ -6,7 +6,15 @@ * frozen bends like canvas drags do. */ -import { CircuitBoard, FlipHorizontal2, Plus, RotateCw, Spline, Trash2 } from 'lucide-react' +import { + CircuitBoard, + FlipHorizontal2, + ListOrdered, + Plus, + RotateCw, + Spline, + Trash2 +} from 'lucide-react' import React from 'react' import { getPart } from '../../../lib/partsLibrary' import * as cmd from '../../core/commands' @@ -38,20 +46,21 @@ const field = 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' const rowLabel = 'text-[11px] text-text-muted' -function titleCase(s: string): string { - return s - .toLowerCase() - .replace(/\b([a-z])/g, (m) => m.toUpperCase()) - .replace(/\bIc\b/, 'IC') -} - +/** + * The Properties panel: everything about whatever is selected. + * + * Lives inside the shell's tabbed right rail (Properties | Simulate), so it + * renders as a plain column — the tab strip above it is the panel's header. + */ export function InspectorRail({ doc, store, sel, setSel, netModel, - view + view, + editable = true, + onRenumber }: { doc: CircuitDoc store: CircuitStore @@ -59,6 +68,9 @@ export function InspectorRail({ setSel: (s: Selection) => void netModel: NetModel view: ViewId + editable?: boolean + /** Rewrite every part id to a conventional refdes (offered when ids are slugs). */ + onRenumber?: () => void }): React.JSX.Element { const part = sel.parts.size === 1 && sel.wires.size === 0 @@ -75,10 +87,7 @@ export function InspectorRail({ : undefined return ( -
-
- Inspector -
+
{part ? ( ) : (
- Select a component to edit its refdes, location, rotation, and properties — or a wire to - recolor it. Shift-click or drag a marquee for multi-select. + {editable + ? 'Select a component to edit its refdes, location, rotation, and properties — or a wire to recolor it. Shift-click or drag a marquee for multi-select.' + : 'Switch on editing to change component properties. Select a component to inspect it.'}
)}
+ {onRenumber && ( +
+ +
+ )}
) } @@ -192,7 +213,7 @@ function PartInspector({ [w.from, w.to].some((e) => typeof e === 'string' && e.split(':')[0] === part.id) ).length - const simAttrs = simAttrsFor(part.type, def?.family) + const simAttrs = simAttrsFor(part.type, def?.simFamily ?? def?.family) const simKeys = new Set(simAttrs.map((a) => a.key)) const propRows = Object.entries(part.attrs || {}).filter( ([k]) => k !== 'label' && !simKeys.has(k) @@ -223,10 +244,12 @@ function PartInspector({ />
- Type - {part.type} - Family - {def?.family ? titleCase(def.family) : '—'} + Component + + {def?.label ?? part.type} + + Category + {def?.family ?? '—'} Wires {wiresTouching}
@@ -303,7 +326,11 @@ function PartInspector({ title={a.hint} onChange={(e) => store.dispatch( - cmd.setPartAttr(part.id, a.key, e.target.value === '' ? undefined : e.target.value) + cmd.setPartAttr( + part.id, + a.key, + e.target.value === '' ? undefined : e.target.value + ) ) } /> @@ -542,7 +569,7 @@ function LabelInspector({ let c: ReturnType = null let d = doc // rotateNetLabelCmd is a single 90° step; compose to reach target - const steps = (((target - (label.sch.rotate || 0)) / 90 + 4) % 4 + 4) % 4 + const steps = ((((target - (label.sch.rotate || 0)) / 90 + 4) % 4) + 4) % 4 const cmds: cmd.Command[] = [] for (let i = 0; i < steps; i++) { c = rotateNetLabelCmd(d, label.id) diff --git a/src/renderer/src/circuit/views/packs/PackManager.tsx b/src/renderer/src/circuit/views/packs/PackManager.tsx index ca2700b..c873864 100644 --- a/src/renderer/src/circuit/views/packs/PackManager.tsx +++ b/src/renderer/src/circuit/views/packs/PackManager.tsx @@ -51,7 +51,11 @@ export function PackManager({ .catch((err) => setEntries((e) => ({ ...e, - [url]: { loading: false, error: err instanceof Error ? err.message : String(err), packs: [] } + [url]: { + loading: false, + error: err instanceof Error ? err.message : String(err), + packs: [] + } })) ) }, []) @@ -87,16 +91,21 @@ export function PackManager({ setProgress(null) try { const manifest = await fetchManifest(pack.url) - const res = await installPack(manifest, pack.url, (done, total) => setProgress({ done, total })) + const res = await installPack(manifest, pack.url, (done, total) => + setProgress({ done, total }) + ) setInstalled(getInstalledPacks()) if (res.installed.length) onInstalled() if (res.failed.length) { - toast.error(`${pack.name}: ${res.installed.length} installed, ${res.failed.length} failed`, { - description: res.failed - .slice(0, 4) - .map((f) => `${f.type}: ${f.error}`) - .join('\n') - }) + toast.error( + `${pack.name}: ${res.installed.length} installed, ${res.failed.length} failed`, + { + description: res.failed + .slice(0, 4) + .map((f) => `${f.type}: ${f.error}`) + .join('\n') + } + ) } else { toast.success(`${pack.name} v${manifest.version} installed`, { description: `${res.installed.length} part${res.installed.length === 1 ? '' : 's'}` @@ -165,7 +174,10 @@ export function PackManager({ return (
- + {url} {isOpen(group.family) && ( diff --git a/src/renderer/src/circuit/views/partsAdapter.ts b/src/renderer/src/circuit/views/partsAdapter.ts index 5a240e3..baa44a8 100644 --- a/src/renderer/src/circuit/views/partsAdapter.ts +++ b/src/renderer/src/circuit/views/partsAdapter.ts @@ -12,7 +12,13 @@ * - frozen-bend reroutes for part moves (the collectFrozen behavior) */ -import { getPart, viewFor, type PartDef, type PartView } from '../../lib/partsLibrary' +import { + getPart, + TINYPROTO_BUSES, + viewFor, + type PartDef, + type PartView +} from '../../lib/partsLibrary' import { schematicVisual } from '../parts/symbols' import { netLabelPinWorld, netLabelVisualOf, snapNetLabel } from '../parts/netLabels' import { decorateResistor, hasResistorBands } from '../parts/resistorBands' @@ -328,8 +334,10 @@ import { breadboardBuses, isBreadboard } from '../parts/breadboard' /** Seat radius: half a hole pitch (spec §7.3). */ const SEAT_RADIUS = GRID_BB / 2 -/** buses resolver for buildNets — breadboards today, PartDef v2 packs later. */ +/** buses resolver for buildNets — breadboards, tinyProto's power rails, and + * PartDef v2 packs later. */ export function circuitBuses(type: string): string[][] | undefined { + if (type === 'tinyproto') return TINYPROTO_BUSES return breadboardBuses(type) } @@ -566,6 +574,33 @@ export function occupiedBoxes(doc: CircuitDoc, view: ViewId, exclude?: Set +): Placement { + const b = viewBounds(doc, view) + const vis = visualFor(type, view) + const w = vis?.v.w ?? 80 + const h = vis?.v.h ?? 40 + const preferred = b + ? { x: b.maxX + GRID_BB * 3 + w / 2, y: b.minY + h / 2 } + : { x: GRID_BB * 12, y: GRID_BB * 12 } + return findFreePlacement(doc, type, view, preferred, exclude) +} + /** Grid-snapped placement for a new `type` near `preferred` that clears the * existing scene. Spirals outward on the major grid until a free slot is found. */ export function findFreePlacement( diff --git a/src/renderer/src/circuit/views/sim/Plot.tsx b/src/renderer/src/circuit/views/sim/Plot.tsx index b713646..324ed06 100644 --- a/src/renderer/src/circuit/views/sim/Plot.tsx +++ b/src/renderer/src/circuit/views/sim/Plot.tsx @@ -7,9 +7,14 @@ * - ac: x = frequency (log), traces = magnitude in dB, plus dashed * phase traces on a right-hand degree axis * + * Traces are limited to the outputs the user picked (core/simOutputs) when + * they picked any; currents get their own right-hand axis so amps and volts + * don't share a scale. Long runs are decimated for drawing only — the CSV + * export and the underlying vectors keep every point. + * * uPlot gives cursors, drag-zoom (double-click resets), and a legend with * click-to-toggle series for free. Theme colors are read from the design - * tokens at mount. CSV export lives here too (visible vectors, full data). + * tokens at mount. */ import React from 'react' @@ -17,10 +22,24 @@ import uPlot from 'uplot' import 'uplot/dist/uPlot.min.css' import type { SimRun, SimVector } from '../../sim' -export const TRACES = ['#4f9cf9', '#f36e6e', '#54c08a', '#e5b567', '#b78be5', '#5bc8c8', '#e08fd0', '#9aa76b'] +export const TRACES = [ + '#4f9cf9', + '#f36e6e', + '#54c08a', + '#e5b567', + '#b78be5', + '#5bc8c8', + '#e08fd0', + '#9aa76b' +] export type PlotMode = 'tran' | 'dc' | 'ac' +/** Above this many points a trace is decimated for drawing (min/max preserved + * per bucket would be nicer; plain striding is enough to keep the canvas + * responsive and is what stops a long .tran from locking the UI). */ +const MAX_PLOT_POINTS = 8000 + function cssVar(name: string, fallback: string): string { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim() return v || fallback @@ -30,18 +49,33 @@ const mag = (re: number, im: number): number => Math.sqrt(re * re + im * im) const db = (m: number): number => 20 * Math.log10(Math.max(m, 1e-20)) const deg = (re: number, im: number): number => (Math.atan2(im, re) * 180) / Math.PI +const isCurrent = (name: string): boolean => /^i\(/i.test(name) +const isVoltage = (name: string): boolean => /^(v|vdiff)\(/i.test(name) + +function stride(values: number[], step: number): number[] { + if (step <= 1) return values + const out: number[] = [] + for (let i = 0; i < values.length; i += step) out.push(values[i]) + const last = values[values.length - 1] + if (out[out.length - 1] !== last) out.push(last) + return out +} + interface Prepared { data: uPlot.AlignedData series: uPlot.Series[] xLabel: string isLog: boolean hasPhase: boolean + hasCurrent: boolean + decimated: number } function prepare( run: SimRun, mode: PlotMode, - labelFor?: (name: string) => string | undefined + labelFor?: (name: string) => string | undefined, + pick?: (name: string) => boolean ): Prepared | null { const x = mode === 'ac' @@ -50,25 +84,38 @@ function prepare( ? (run.vectors.find((v) => v.name.toLowerCase() === 'v(v-sweep)') ?? run.vectors[0]) : (run.vectors.find((v) => v.type === 'time') ?? run.vectors[0]) if (!x) return null - const ys = run.vectors - .filter( - (v) => - v !== x && - v.name.toLowerCase() !== 'v(v-sweep)' && - (v.name.startsWith('v(') || v.name.startsWith('vdiff(')) - ) - .slice(0, TRACES.length) + + const plottable = (v: SimVector): boolean => + v !== x && v.name.toLowerCase() !== 'v(v-sweep)' && (isVoltage(v.name) || isCurrent(v.name)) + + const all = run.vectors.filter(plottable) + // an explicit pick wins; if it selects nothing resolvable, fall back to all + // rather than showing an empty chart + const picked = pick ? all.filter((v) => pick(v.name)) : all + let ys = (picked.length ? picked : all).slice(0, TRACES.length) + // AC plots magnitude/phase — currents don't belong on a dB axis + if (mode === 'ac') ys = ys.filter((v) => isVoltage(v.name)) if (!ys.length) return null + const step = Math.max(1, Math.ceil(x.values.length / MAX_PLOT_POINTS)) + const xs = stride(x.values, step) + const series: uPlot.Series[] = [{ label: mode === 'ac' ? 'Hz' : mode === 'dc' ? 'Vsweep' : 's' }] const cols: number[][] = [] const isAc = mode === 'ac' && ys.some((v) => v.imag) + const hasCurrent = !isAc && ys.some((v) => isCurrent(v.name)) + for (let i = 0; i < ys.length; i++) { const v = ys[i] const name = labelFor?.(v.name) ?? v.name if (isAc) { - cols.push(v.values.map((re, k) => db(mag(re, v.imag?.[k] ?? 0)))) + cols.push( + stride( + v.values.map((re, k) => db(mag(re, v.imag?.[k] ?? 0))), + step + ) + ) series.push({ label: `${name} dB`, stroke: TRACES[i], @@ -77,13 +124,15 @@ function prepare( value: (_u, val) => (val == null ? '' : `${val.toFixed(1)} dB`) }) } else { - cols.push(v.values) + const amps = isCurrent(v.name) + cols.push(stride(v.values, step)) series.push({ label: name, stroke: TRACES[i], width: 1.4, - scale: 'y', - value: (_u, val) => (val == null ? '' : `${fmtEng(val)}V`) + dash: amps ? [5, 3] : undefined, + scale: amps ? 'yi' : 'y', + value: (_u, val) => (val == null ? '' : `${fmtEng(val)}${amps ? 'A' : 'V'}`) }) } } @@ -91,7 +140,12 @@ function prepare( for (let i = 0; i < ys.length; i++) { const v = ys[i] const name = labelFor?.(v.name) ?? v.name - cols.push(v.values.map((re, k) => deg(re, v.imag?.[k] ?? 0))) + cols.push( + stride( + v.values.map((re, k) => deg(re, v.imag?.[k] ?? 0)), + step + ) + ) series.push({ label: `${name} °`, stroke: TRACES[i], @@ -103,11 +157,13 @@ function prepare( } } return { - data: [x.values, ...cols] as uPlot.AlignedData, + data: [xs, ...cols] as uPlot.AlignedData, series, xLabel: mode === 'ac' ? 'frequency (Hz)' : mode === 'dc' ? 'sweep (V)' : 'time (s)', isLog: mode === 'ac', - hasPhase: isAc + hasPhase: isAc, + hasCurrent, + decimated: step > 1 ? x.values.length : 0 } } @@ -125,16 +181,24 @@ export function fmtEng(v: number): string { export function SimPlot({ run, mode, - labelFor + labelFor, + pick, + height = 220 }: { run: SimRun mode: PlotMode /** override a vector's displayed legend name (e.g. a probe's label) */ labelFor?: (name: string) => string | undefined + /** restrict traces to the outputs the user picked */ + pick?: (name: string) => boolean + height?: number }): React.JSX.Element { const host = React.useRef(null) const plot = React.useRef(null) - const prepared = React.useMemo(() => prepare(run, mode, labelFor), [run, mode, labelFor]) + const prepared = React.useMemo( + () => prepare(run, mode, labelFor, pick), + [run, mode, labelFor, pick] + ) React.useEffect(() => { const el = host.current @@ -150,9 +214,7 @@ export function SimPlot({ labelSize: 14, grid: { stroke: gridInk, width: 0.5 }, ticks: { stroke: gridInk }, - values: prepared.isLog - ? (_u, splits) => splits.map((s) => fmtEng(s)) - : (_u, splits) => splits.map((s) => fmtEng(s)) + values: (_u, splits) => splits.map((s) => fmtEng(s)) }, { scale: 'y', @@ -171,15 +233,25 @@ export function SimPlot({ ticks: { stroke: gridInk }, values: (_u, splits) => splits.map((s) => `${s}°`) }) + if (prepared.hasCurrent) + axes.push({ + scale: 'yi', + side: 1, + stroke: axisInk, + grid: { show: false }, + ticks: { stroke: gridInk }, + values: (_u, splits) => splits.map((s) => `${fmtEng(s)}A`) + }) return new uPlot( { width, - height: 220, + height, series: prepared.series, scales: { x: prepared.isLog ? { distr: 3, log: 10 } : { time: false }, y: {}, - ...(prepared.hasPhase ? { deg: {} } : {}) + ...(prepared.hasPhase ? { deg: {} } : {}), + ...(prepared.hasCurrent ? { yi: {} } : {}) }, axes, legend: { live: true }, @@ -190,10 +262,10 @@ export function SimPlot({ ) } - plot.current = make(Math.max(el.clientWidth || 640, 320)) + plot.current = make(Math.max(el.clientWidth || 640, 240)) const ro = new ResizeObserver(() => { - const w = Math.max(el.clientWidth || 640, 320) - plot.current?.setSize({ width: w, height: 220 }) + const w = Math.max(el.clientWidth || 640, 240) + plot.current?.setSize({ width: w, height }) }) ro.observe(el) return () => { @@ -201,14 +273,17 @@ export function SimPlot({ plot.current?.destroy() plot.current = null } - }, [prepared]) + }, [prepared, height]) - if (!prepared) return
no voltage vectors to plot
+ if (!prepared) return
no vectors to plot for these outputs
return (
drag to zoom · double-click to reset · click legend entries to toggle traces + {prepared.decimated + ? ` · drawn from ${MAX_PLOT_POINTS.toLocaleString()} of ${prepared.decimated.toLocaleString()} points (CSV has all)` + : ''}
) diff --git a/src/renderer/src/circuit/views/sim/SimPanel.tsx b/src/renderer/src/circuit/views/sim/SimPanel.tsx index b8d7794..e18c0eb 100644 --- a/src/renderer/src/circuit/views/sim/SimPanel.tsx +++ b/src/renderer/src/circuit/views/sim/SimPanel.tsx @@ -1,25 +1,49 @@ /** - * circuit/views/sim/SimPanel — the M4 Simulate panel (spec §10.4, first cut). + * circuit/views/sim/SimPanel — the Simulate panel (spec §10.4). * - * Docked at the bottom of the circuit area: analysis tabs (DC op / DC sweep / - * Transient / AC), parameters, Run/Cancel, results. DC (.op) lists node - * voltages and source currents — and the shell mirrors them onto the canvas - * as annotations; sweeps render in a uPlot chart (cursors, drag-zoom, - * legend-toggle) with CSV export. + * Lives in the shell's right rail (schematic view only). Analysis tabs + * (DC op / DC sweep / Transient / AC), their parameters, the OUTPUTS the user + * wants reported, and Run/Cancel. DC (.op) lists node voltages and source + * currents — the shell mirrors them onto the canvas as annotations; sweeps + * render in a uPlot chart with CSV export. * - * Analysis config persists in doc.sim via Commands (undoable, serialized). + * Outputs work the way CircuitLab's do: nothing is plotted by name until you + * say what you want. Picking one drops a PROBE TAG on the sheet — a real, + * draggable, deletable label rather than a highlight — and doc.sim.probes is + * the single source of truth this list reads. Probes store stable references, + * not vector names; core/simOutputs says why. + * + * The engine's own lifecycle is surfaced separately from the analysis: a cold + * start is "loading the engine", not a run that timed out. And an analysis + * whose point count would exhaust memory is refused here, before it reaches + * the worker — that request used to take the whole app down. */ -import { CircuitBoard, Download, Loader2, Play, Square, X } from 'lucide-react' +import { Crosshair, Download, Loader2, Play, Square, X } from 'lucide-react' import React from 'react' import * as cmd from '../../core/commands' import type { Analysis, CircuitDoc } from '../../core/model' import { describeNet, type NetModel } from '../../core/nets' -import { generateNetlist, mapSimIssues, type NetlistResult, type SimIssueRef } from '../../core/netlist' +import { defaultAttrsFor } from '../../parts/naming' +import { + estimatePoints, + generateNetlist, + mapSimIssues, + MAX_SIM_POINTS, + type NetlistResult, + type SimIssueRef +} from '../../core/netlist' +import { + availableOutputs, + outputFilter, + outputLabelFor, + resolveOutputs, + type OutputRef +} from '../../core/simOutputs' import { diffProbeVectors, probeLabelFor } from '../../core/probes' import type { CircuitStore } from '../../core/store' import { getSimBackend, SimError } from '../../sim' -import type { SimRun } from '../../sim' +import type { EngineStatus, SimRun } from '../../sim' import { runToCsv, SimPlot, type PlotMode } from './Plot' const field = @@ -30,6 +54,11 @@ export interface SimState { netlist: NetlistResult | null } +/** Watchdog for the analysis itself — the engine load has its own, longer one. */ +function solveBudget(points: number): number { + return Math.min(120_000, 20_000 + points * 20) +} + export function SimPanel({ doc, netModel, @@ -37,7 +66,13 @@ export function SimPanel({ familyOf, onClose, onResult, - onSelectIssue + onSelectIssue, + picking = false, + onPickingChange, + picked, + onToggleOutput, + onClearOutputs, + variant = 'drawer' }: { doc: CircuitDoc netModel: NetModel @@ -48,6 +83,24 @@ export function SimPanel({ onResult: (s: SimState) => void /** clicking a part/net chip on an error asks the shell to select it */ onSelectIssue?: (refs: SimIssueRef) => void + /** "pick outputs by clicking the schematic" mode, owned by the shell */ + picking?: boolean + onPickingChange?: (on: boolean) => void + /** + * Outputs currently picked — one per measurement tag placed on the sheet. + * The shell owns them (they live in doc.sim.probes, which is what the canvas + * draws), so this panel reads the list and asks for changes rather than + * keeping a second copy that could disagree with the tags. + */ + picked: OutputRef[] + onToggleOutput: (ref: OutputRef) => void + onClearOutputs: () => void + /** + * 'drawer' — the original bottom panel across the canvas. + * 'rail' — a column inside the shell's right rail, so running an analysis + * no longer covers the circuit you're analysing. + */ + variant?: 'drawer' | 'rail' }): React.JSX.Element { const analysis: Analysis = doc.sim?.analyses?.[0] ?? { id: 'a1', kind: 'op' } const [running, setRunning] = React.useState(false) @@ -55,7 +108,16 @@ export function SimPanel({ const [gen, setGen] = React.useState(null) const [error, setError] = React.useState<{ message: string; details?: string[] } | null>(null) const [showNetlist, setShowNetlist] = React.useState(false) + const [showAllOutputs, setShowAllOutputs] = React.useState(false) const [autoRerun, setAutoRerun] = React.useState(false) + const [engine, setEngine] = React.useState(() => getSimBackend().status()) + + // engine lifecycle (loading / ready / failed) is independent of any one run + React.useEffect(() => { + const backend = getSimBackend() + setEngine(backend.status()) + return backend.subscribe(setEngine) + }, []) const setAnalysis = (patch: Partial): void => { store.dispatch(cmd.setAnalyses([{ ...analysis, ...patch }])) @@ -75,17 +137,50 @@ export function SimPanel({ [doc.parts, familyOf] ) + // ── outputs (what the user wants reported) ───────────────────────────────── + + const choices = React.useMemo( + () => availableOutputs(doc, netModel, familyOf), + [doc, netModel, familyOf] + ) + const resolved = React.useMemo( + () => resolveOutputs(picked, doc, netModel, gen, familyOf), + [picked, doc, netModel, gen, familyOf] + ) + const pickFilter = React.useMemo(() => outputFilter(resolved), [resolved]) + const pickLabel = React.useMemo(() => outputLabelFor(resolved), [resolved]) + const runningRef = React.useRef(false) + const points = estimatePoints(analysis) + const tooMany = points > MAX_SIM_POINTS const run = async (): Promise => { if (runningRef.current) return // one in-flight run at a time (esp. for auto-rerun) + if (tooMany) { + setError({ + message: `That analysis asks for about ${Math.round(points).toLocaleString()} points — more than the ${MAX_SIM_POINTS.toLocaleString()} this editor will hold in memory.`, + details: [ + analysis.kind === 'tran' + ? 'Raise the step, or shorten the stop time.' + : analysis.kind === 'dc' + ? 'Raise the step, or narrow the from/to range.' + : 'Lower the points-per-decade, or narrow the frequency range.' + ] + }) + return + } runningRef.current = true setRunning(true) setError(null) - const g = generateNetlist(doc, netModel, { familyOf, title: 'tinyStudio circuit' }) + const g = generateNetlist(doc, netModel, { + familyOf, + // keeps SPICE and the printed schematic values in step (parts/naming) + defaultAttrsOf: defaultAttrsFor, + title: 'tinyStudio circuit' + }) setGen(g) try { - const raw = await getSimBackend().run(g.netlist, 20000) + const raw = await getSimBackend().run(g.netlist, solveBudget(points)) // fold in synthetic diff-probe vectors (voltage/current probes need no // extra work — ngspice already reports every node and probe source) const diffs = diffProbeVectors(doc, netModel, g, raw) @@ -112,10 +207,9 @@ export function SimPanel({ setRunning(false) } - // auto-rerun (spec/M4 leftover): once enabled, every doc change re-runs the - // active analysis after a short debounce — same "Run" path, so results and - // canvas DC annotations refresh without a manual click. Skipped while a run - // is already in flight; the trailing edit still gets its own debounce timer. + // auto-rerun: once enabled, every doc change re-runs the active analysis + // after a short debounce — same "Run" path, so results and canvas DC + // annotations refresh without a manual click. const runRef = React.useRef(run) runRef.current = run const lastAutoDoc = React.useRef(doc) @@ -128,18 +222,17 @@ export function SimPanel({ const isOp = result != null && result.numPoints === 1 - // probe labels (M4 leftover): a placed sim-probe-v/-vdiff/-i part's - // attrs.label, if any, stands in for the raw v(node)/vdiff(id)/i(v) - // vector name in the table and plot legend. + // probe labels: a placed sim-probe part's attrs.label stands in for the raw + // v(node)/vdiff(id)/i(v) vector name; a picked output's own label wins. const labelFor = React.useCallback( (vecName: string): string | undefined => - gen ? probeLabelFor(vecName, doc, netModel, gen) : undefined, - [gen, doc, netModel] + pickLabel(vecName) ?? (gen ? probeLabelFor(vecName, doc, netModel, gen) : undefined), + [pickLabel, gen, doc, netModel] ) - // error → part/net highlight mapping (M4 leftover): scan the engine's raw - // message lines for the device/node names this run's netlist used, so the - // offending elements can be selected on the canvas straight from the error. + // error → part/net highlight mapping: scan the engine's raw message lines for + // the device/node names this run's netlist used, so the offending elements + // can be selected on the canvas straight from the error. const issueRefs: SimIssueRef | null = React.useMemo(() => { if (!error || !gen) return null const lines = error.details?.length ? error.details : [error.message] @@ -166,14 +259,29 @@ export function SimPanel({ [gen, netModel] ) - return ( -
- {/* header row: analysis config + run */} -
- - Simulate + const inRail = variant === 'rail' + const loading = engine.phase === 'loading' + const engineFailed = engine.phase === 'failed' -
+ return ( +
+ {/* controls: analysis kind and its parameters */} +
+
{( [ ['op', 'DC'], @@ -184,16 +292,14 @@ export function SimPanel({ ).map(([kind, label]) => (
{analysis.kind === 'tran' && ( -
+
step setAnalysis({ stop: e.target.value })} /> -