diff --git a/.claude/launch.json b/.claude/launch.json index 8ab4689..e59ab82 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,22 @@ "name": "web", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev:web"], - "port": 5173 + "port": 5173, + "autoPort": true + }, + { + "name": "web-preview", + "runtimeExecutable": "npx", + "runtimeArgs": [ + "vite", + "preview", + "--config", + "web.vite.config.mts", + "--port", + "4173", + "--strictPort" + ], + "port": 4173 } ] } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..80443ef --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +# Text files use LF everywhere; Windows checkouts included. +* text=auto eol=lf + +# Binary assets +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.icns binary +*.cur binary +*.ttf binary +*.woff binary +*.woff2 binary +*.mp3 binary +*.ogg binary +*.wav binary +*.wasm binary +*.exe binary +*.zip binary +*.fzpz binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cac959d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +# Typecheck, lint, test and build the web bundle on every push to main or a +# version branch, and on pull requests into main. Desktop installers are built +# by release.yml (see docs/releasing.md), not here. + +on: + push: + branches: [main, 'v*-dev'] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Install scripts download Electron and arduino-cli and rebuild app deps + # for Electron. None of the checks below need them. + - run: npm ci --ignore-scripts + + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm run build:web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0bb161d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,151 @@ +name: Release + +# Builds the desktop installers on Windows, macOS and Linux. +# +# - Pushing a tag like v0.4.0 builds all three and creates a DRAFT GitHub +# release with the installers attached. Check it, then click Publish. +# - Running it by hand (Actions → Release → Run workflow) builds the installers +# and keeps them as workflow artifacts, without making a release: a dry run. +# +# The installers are unsigned (macOS: ad-hoc signed), so Windows SmartScreen and +# macOS Gatekeeper warn on first launch. The release notes say how to get past +# that. See docs/releasing.md. + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-latest + target: win + vendor: windows-x64 + files: dist/*.exe + - name: macOS + os: macos-latest + target: mac + vendor: macos-arm64 macos-x64 + files: dist/*.dmg + - name: Linux + os: ubuntu-latest + target: linux + vendor: linux-x64 + files: | + dist/*.AppImage + dist/*.deb + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Check the tag matches package.json + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + version=$(node -p "require('./package.json').version") + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "::error::Tag $GITHUB_REF_NAME doesn't match package.json version $version" + exit 1 + fi + + - run: npm ci + + # Only this platform's binaries (npm run build's prebuild hook would fetch + # every platform, which can't unpack the Windows .zip on Linux). + - name: Fetch arduino-cli and the language server + shell: bash + run: | + node scripts/fetch-arduino-cli.mjs ${{ matrix.vendor }} + node scripts/fetch-language-server.mjs ${{ matrix.vendor }} + + - run: npm run typecheck + - run: npx electron-vite build + + - name: Package + run: npx electron-builder --${{ matrix.target }} --publish never + + - uses: actions/upload-artifact@v4 + with: + name: tinystudio-${{ matrix.target }} + path: ${{ matrix.files }} + if-no-files-found: error + + release: + name: Draft the GitHub release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Checksums + working-directory: dist + run: sha256sum * > SHA256SUMS.txt && cat SHA256SUMS.txt + + - name: Release notes + run: | + version="${GITHUB_REF_NAME#v}" + { + echo "## Download" + echo + echo "| System | File |" + echo "| --- | --- |" + row() { for f in dist/$2; do if [ -e "$f" ]; then echo "| $1 | \`$(basename "$f")\` |"; fi; done; } + row "Windows 10 and 11 (64-bit)" "*.exe" + row "macOS, Apple Silicon (M1 and later)" "*arm64.dmg" + row "macOS, Intel" "*x64.dmg" + row "Linux (64-bit), any distribution" "*.AppImage" + row "Debian, Ubuntu" "*.deb" + echo + echo "The installers aren't signed yet, so your system warns the first time:" + echo + echo "- **Windows:** on \"Windows protected your PC\", click **More info**, then **Run anyway**." + echo "- **macOS:** open the .dmg and drag tinyStudio to Applications. The first time you open it," + echo " macOS says it can't check it. Open **System Settings → Privacy & Security**, scroll down" + echo " and click **Open Anyway**." + echo "- **Linux AppImage:** make it executable (\`chmod +x tinystudio-*.AppImage\`), then run it." + echo + echo "Board support (ESP32, tinyCore, AVR) downloads on your first compile." + echo + # this version's section of the changelog, without its heading + awk -v v="$version" ' + $0 ~ "^## " v "( |$)" { on = 1; next } + on && /^## / { exit } + on { print } + ' CHANGELOG.md + } > notes.md + cat notes.md + + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* \ + --draft \ + --title "tinyStudio ${GITHUB_REF_NAME#v}" \ + --notes-file notes.md diff --git a/.gitignore b/.gitignore index 4b93837..ab0dfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,18 @@ out *.log* .npmrc -# Generated web exports in the bundled demo project -demo/**/index.html +# Local secrets, such as the dev server's GITHUB_CLIENT_SECRET (docs/github-auth.md) +.env.local +.env.*.local # arduino-cli binaries fetched by scripts/fetch-arduino-cli.mjs vendor/ -# Per-run diagnostic report written by scripts/fritzing-import.mjs -src/renderer/src/assets/parts/_report.json +# Staging output of scripts/fritzing-import.mjs (explode it into tinyparts) +tmp/ + +# Temp files left by FUSE-mounted sandboxes +.fuse_hidden* + +# TypeScript incremental build caches +*.tsbuildinfo diff --git a/.prettierignore b/.prettierignore index 9c6b791..014aafa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,8 @@ pnpm-lock.yaml LICENSE.md tsconfig.json tsconfig.*.json +dist-web +vendor +package-lock.json +# generated by npm run parts:sync; its file hashes must match tinyparts on GitHub +src/renderer/src/assets/tinyparts diff --git a/.vscode/settings.json b/.vscode/settings.json index debec75..4c05394 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,25 +7,5 @@ }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "workbench.colorCustomizations": { - "activityBar.activeBackground": "#93e6fc", - "activityBar.background": "#93e6fc", - "activityBar.foreground": "#15202b", - "activityBar.inactiveForeground": "#15202b99", - "activityBarBadge.background": "#fa45d4", - "activityBarBadge.foreground": "#15202b", - "commandCenter.border": "#15202b99", - "sash.hoverBorder": "#93e6fc", - "statusBar.background": "#61dafb", - "statusBar.foreground": "#15202b", - "statusBarItem.hoverBackground": "#2fcefa", - "statusBarItem.remoteBackground": "#61dafb", - "statusBarItem.remoteForeground": "#15202b", - "titleBar.activeBackground": "#61dafb", - "titleBar.activeForeground": "#15202b", - "titleBar.inactiveBackground": "#61dafb99", - "titleBar.inactiveForeground": "#15202b99" - }, - "peacock.color": "#61dafb" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c80a0db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,186 @@ +# Changelog + +What changed in each tinyStudio release. Dates are when the version was +finished; versions follow [semantic versioning](https://semver.org). + +## 0.4.0 (2026-09-19) + +0.3.0 was merged to `main` in July 2026 but never tagged or released, so 0.4.0 +is the first release since 0.2.0 and includes everything under 0.3.0 below. + +### Projects + +- A start screen with **Create new**, **Open existing**, **Try an example** and + your recent projects. +- **Open existing** takes a local folder, a pasted GitHub repo or link, one of + your own repos, or a recent project. +- The **New Project** dialog names the folder and sketch the way the Arduino IDE + does. +- A project menu in the header. +- In the browser, recent folders reopen with one permission prompt. Projects + that only live in the browser show a **Save to computer** banner, and Firefox + and Safari get scratch projects kept in browser storage. +- The browser build can compile and upload a real local folder. + +### GitHub + +- Desktop sign-in uses GitHub's device flow and keeps the token in the system + keychain instead of browser storage. A personal access token is still accepted + under **Advanced**. +- Sign-in asks only for access to public repositories. +- The browser build signs in through github.com and comes straight back, no + token to paste. The web app's address is `studio.tinycore.cc`; + `app.tinystudio.cc` forwards to it. +- **Make it mine** copies an example into a new public repo in your account, + images included, and keeps you editing there. +- Link, Push, Pull and Publish from the GitHub tab. A project opened from a repo + subfolder stays pinned to that folder. +- Push makes one commit with your message, deleted files included, and stops + instead of overwriting files someone changed on GitHub since your last sync. +- Pull brings in GitHub's changes and keeps your edits to files GitHub also + changed, naming them. +- A bar above the editor shows how many changes aren't pushed yet, with a Push + button. +- Opening a folder that is a git clone of a GitHub repo links it automatically. +- Whether you can push to a repo is checked with GitHub instead of guessed. + +### Parts + +- Parts now live in the [tinyparts](https://github.com/Mister-Industries/tinyparts) + repo as a folder per part: a `part.json` plus real SVG files you can edit in + Illustrator. Pins come from shapes named `pin-*`. +- The app ships a snapshot of tinyparts and, on launch, downloads only the files + that changed on GitHub (checked at most every 15 minutes). +- Parts are layered: your own edits on this computer, then a developer + checkout, then GitHub, then the built-in snapshot. +- The parts editor shows both views and can save a part on this computer or + reset it to the default. +- The Parts Packs panel is reorganised into Built in, Developer, This computer + and More packs. Installed packs stay linked to GitHub. +- Fritzing art ships with its CC-BY-SA attribution. +- The parts bin works like Fritzing's. A rail of tabs holds Search, Core, Mine, + and one tab for each installed pack; all the SparkFun packs share one tab. + Core opens with the tinyCore boards, then follows Fritzing's Core order + (Basic, Input, Output, Power, then the breadboards). Mine holds the parts you + import or make. Tiles are icons, with the name on hover. +- Search finds any installed part, including package variants, which no longer + get a tile of their own. Simulation sources and probes are found by search. +- New breadboard art for the tinyCore, tinyDisplay, tinyProto and tinySpeak. +- In the parts editor, arrow keys nudge the selected pin by one unit of the + art's own coordinates (Shift: 0.1 in). While pins come from `pin-*` shapes + the nudge moves the shape in the SVG, so the file stays the source of truth. + Ctrl+Z and Ctrl+Y undo and redo pin, size and art changes. + +### Circuit view + +- A new circuit editor replaces the old one, saving to `circuit.json`. Opening + the view doesn't write into a project that has no circuit: a **Create + circuit** button adds one, and projects with an old `diagram.json` get a + **Convert** button that writes `circuit.json` and keeps the original as + `diagram.json.bak`. +- **Breadboard:** zoom at the cursor, pan, fit, marquee select; draw, bend and + tap wires; rotate, flip, nudge, copy and paste parts; generated mini, half and + full breadboards that connect parts seated in their holes; bendable LED and + resistor legs; undo and redo up to 200 steps. +- **Schematic:** the same circuit as a schematic, with a hand-drawn US/IEEE + symbol set, ground, power and net labels, reference designators above each + symbol and simulated values below, ratsnest lines, and an electrical rule + check. +- **Simulate** is labelled Experimental, with a note that results may be + wrong. Circuits that contain a board read 0 V everywhere; the board's pins + aren't modelled yet. +- **Simulation:** ngspice runs in the app (downloaded on first use, about + 20 MB) with DC operating point, DC sweep, transient and AC analyses; charts + with cursors, zoom, log axes, dB and phase; CSV export; voltage and current + probes; node voltages drawn on the canvas. Pick what to plot by clicking the + schematic. +- Import a Fritzing `.fzpz` by dropping it on the canvas. +- SVG and PNG export of either view. +- The **Edit** button is labelled, each view remembers its own zoom and + position, and pins stay easy to click when zoomed out. + +### Code, build and serial + +- Renaming an open file updates its tab's name as well as its path. +- Boards are detected as they're plugged in and unplugged, without polling. +- A board options menu (PSRAM, partition scheme, CPU frequency) and + **Change board**. +- Compiler errors show as markers in the editor, and a failed build keeps the + Output tab open. +- Real upload progress, and ESP32 uploads report success correctly. +- The Serial Monitor has the full baud list, a line-ending picker, timestamps, + and remembers settings per port. It reports a port as open only when it is. +- Code completion, hover, signature help and live diagnostics from the Arduino + language server (desktop). +- The backend picks a free port starting at 3000, and the app connects to + whichever one it got. +- Requires tinyService 1.2.0. + +### Visual view + +- The Visual view no longer writes `visual.js` into a project that has none; + it offers to create one from the serial plotter template. +- Sketches run in a sandbox, so a project's `visual.js` can't reach your files. +- p5.js is bundled, so Visual works offline. +- A `theme` object lets sketches follow light and dark mode. New projects start + with a serial plotter. +- The exported web page has a theme toggle and a connection status. + +### Studio AI + +- Runs in the browser as well as the desktop app. +- New tools: read a built-in guide, inspect the circuit, find parts, read serial + output. Guides cover the app, the tinyCore pinout, `visual.js`, serial and + `circuit.json`. +- The conversation stays when you switch to another tab. +- Pick the model in the Studio AI settings: Opus 5 (the default), Sonnet 5 or + Haiku 4.5. Earlier builds were pinned to Opus 4.8. + +### Examples + +- Example tags are generated in the examples repo by its own workflow; the + app reads them from that repo's `examples.json` and no longer carries a copy + of the manifest. +- Examples come from the + [tinyStudio-examples](https://github.com/Mister-Industries/tinyStudio-examples) + repo, with tags, search and filters. Board tags use each board's colour. + +### App + +- Light theme by default, with coloured chrome and tabs. Diagrams in docs follow + the theme. +- **About tinyStudio** in the header menu shows the version, licence and credits. +- **Keyboard shortcuts** in the header menu (or Ctrl+/) lists every shortcut + the app defines. +- With no project open, the Files panel just says so; creating and opening + projects happens on the start screen and in the header menu, with the same + names in both: **Create new**, **Open existing**, **Try an example**. +- If tinyService stops while the desktop app is open, the app restarts it once. + If that fails, or it stops again within a minute, a notification says so and + offers **Restart**. A missing arduino-cli is reported the same way. +- Links in project READMEs open in your browser instead of replacing the app. +- The Windows installer has its own app identity and ships only the built app. +- Your theme choice carries over from earlier versions. +- The unfinished blocks editor (Blockly) is gone. It was bundled but could + never be switched on. + +### Security + +- tinyService 1.2.0 listens on this computer only and refuses browser origins + it doesn't know, so another website can't reach your boards through it. +- File access on desktop is limited to folders you've opened, plus downloaded + examples. Projects opened in an earlier version ask for their folder once. +- Only web and email links open outside the app, and only documents and images + open in other programs. +- Part art from packs and imports is sanitised before it's shown. +- A strict Content Security Policy in the built app. + +## 0.3.0 (not released) + +Merged to `main` between 3 and 22 July 2026 and included in 0.4.0: the new +circuit editor with breadboard, schematic and simulation; Arduino IDE parity for +board detection, board options, serial and the language server; the light theme. + +## 0.2.0 + +See the [GitHub release](https://github.com/Mister-Industries/tinyStudio/releases/tag/v0.2.0). diff --git a/INTEGRATION_GUIDE.md b/INTEGRATION_GUIDE.md index fa3053f..f8795c0 100644 --- a/INTEGRATION_GUIDE.md +++ b/INTEGRATION_GUIDE.md @@ -1,194 +1,101 @@ -# TinyService Integration Guide - -This document describes the integration of @mister-industries/tinyservice into the tinyStudio Electron application. - -## Overview - -TinyService is a WebSocket service that wraps arduino-cli for compiling and uploading Arduino sketches. It has been integrated into the Electron main process with the following features: - -- **Automatic startup** when the app initializes -- **Graceful shutdown** when the app quits -- **Platform-specific binary management** for arduino-cli -- **Development/Production mode handling** for different execution paths - -## Files Modified/Created - -### 1. [package.json](package.json#L29) - -Added dependency: - -```json -"@mister-industries/tinyservice": "^1.0.0" -``` - -### 2. Package registry - -`@mister-industries/tinyservice` and `@mister-industries/shared` are published to **public npm**, -so a plain `npm install` resolves them with no `.npmrc`, token, or registry configuration. (If you -ever fork the backend into a private registry, that's where a scoped `.npmrc` would go.) - -### 3. [electron-builder.yml](electron-builder.yml#L11-L35) - -Configured bundling of platform-specific arduino-cli binaries as `extraResources`: - -- **macOS Intel**: `node_modules/@mister-industries/tinyservice/binaries/macos-x64/arduino-cli` → `arduino-cli/darwin-x64/` -- **macOS ARM**: `node_modules/@mister-industries/tinyservice/binaries/macos-arm64/arduino-cli` → `arduino-cli/darwin-arm64/` -- **Linux x64**: `node_modules/@mister-industries/tinyservice/binaries/linux-x64/arduino-cli` → `arduino-cli/linux-x64/` -- **Linux ARM64**: `node_modules/@mister-industries/tinyservice/binaries/linux-arm64/arduino-cli` → `arduino-cli/linux-arm64/` -- **Windows**: `node_modules/@mister-industries/tinyservice/binaries/windows-x64/arduino-cli.exe` → `arduino-cli/win32-x64/` - -### 4. [src/main/ServiceManager.ts](src/main/ServiceManager.ts) - -New class that manages the TinyService lifecycle: - -**Key Features:** - -- Dynamically imports TinyService to handle ESM modules in CommonJS context -- Detects platform and architecture at runtime -- Resolves correct arduino-cli binary path: - - **Development mode** (`!app.isPackaged`): Uses system `arduino-cli` from PATH - - **Production mode** (`app.isPackaged`): Uses bundled binary from `process.resourcesPath` -- Provides `start()` and `stop()` methods with error handling -- Configuration: port 3000, allowedOrigins: ['*'] - -### 5. [src/main/index.ts](src/main/index.ts) - -Updated main process entry point: - -**Changes:** - -- Imports and instantiates `ServiceManager` -- Starts TinyService in `app.whenReady()` callback with error handling -- Stops TinyService gracefully in `app.on('before-quit')` event -- Ensures clean shutdown by calling `app.exit()` after stopping the service - -## Architecture - -### Service Lifecycle - -``` -app.whenReady() - ↓ -serviceManager.start() - ├─ Detect platform/arch - ├─ Resolve arduino-cli path - ├─ Create TinyService instance - └─ Start WebSocket server on ws://localhost:3000 - -app.beforeQuit - ↓ -serviceManager.stop() - ├─ Close WebSocket connections - ├─ Stop HTTP server - └─ Exit process -``` - -### Module Resolution - -The main process is compiled to CommonJS (`require`), but TinyService is an ESM module. This is handled via dynamic import: - -```typescript -async importTinyService() { - const module = await import('@mister-industries/tinyservice') - return module.TinyService -} -``` - -This allows the CommonJS main process to import ESM modules at runtime. - -## Running the Application - -### Development Mode - -```bash -npm run dev -``` - -The app will: - -1. Start the Electron app -2. Initialize TinyService with system `arduino-cli` -3. Listen on `ws://localhost:3000` - -### Production Build - -```bash -npm run build:mac # or build:linux, build:win -``` - -The app will: - -1. Bundle platform-specific arduino-cli binaries -2. On launch, resolve the correct binary path from `process.resourcesPath` -3. Initialize TinyService with bundled binary - -## Usage in Renderer Process - -Renderer processes can connect to TinyService using the WebSocket client from `@mister-industries/shared`: - -```typescript -import { TinyServiceClient } from '@mister-industries/shared' - -const client = new TinyServiceClient('ws://localhost:3000') - -// Connect and use the service -await client.connect() -const result = await client.compile(sketchPath, boardFqbn) -await client.disconnect() -``` - -## Environment Variables - -Optional environment variables that can be set to override defaults: - -- `PORT`: TinyService port (default: 3000) -- `ARDUINO_CLI_PATH`: Path to arduino-cli binary (development only) -- `NODE_ENV`: Set to 'development' for debug logging - -## Error Handling - -The integration includes comprehensive error handling: - -- **Startup errors**: Logged but don't prevent app from launching -- **Shutdown errors**: Logged with fallback to force exit -- **Runtime errors**: TinyService logs are visible in DevTools - -## Platform Support - -The integration supports: - -- ✅ macOS (Intel & ARM64) -- ✅ Linux (x64 & ARM64) -- ✅ Windows (x64) +# tinyService integration + +tinyService is the local backend that compiles, uploads and talks to boards. It is +a WebSocket and HTTP server around `arduino-cli`, published on npm as +[`@mister-industries/tinyservice`](https://www.npmjs.com/package/@mister-industries/tinyservice), +with its client and protocol types in `@mister-industries/shared`. tinyStudio +needs tinyService 1.2.0 or later. + +This guide covers how the desktop app runs it, how the renderer finds it, and how +the browser build uses it. + +## Desktop: the main process runs it + +[`src/main/ServiceManager.ts`](src/main/ServiceManager.ts) owns the backend. +[`src/main/index.ts`](src/main/index.ts) creates one `ServiceManager`, starts it +when the app is ready and stops it before the app quits (forcing exit after three +seconds if it hangs). + +`start()` does this: + +1. **Picks a port.** It tries `TINYSERVICE_DEFAULT_PORT` (3000, from + [`src/shared/tinyservice.ts`](src/shared/tinyservice.ts)) and the next nine, + and takes the first one free on 127.0.0.1. Port 3000 is a common dev-server + default, so it can't be assumed. +2. **Finds arduino-cli.** + - In development: `vendor/arduino-cli//`, fetched by + [`scripts/fetch-arduino-cli.mjs`](scripts/fetch-arduino-cli.mjs), falling back + to `arduino-cli` on `PATH`. + - In a packaged app: `resources/arduino-cli//`, copied there by + `extraResources` in [`electron-builder.yml`](electron-builder.yml). + - The folder names differ: `vendor/` uses `windows-x64` and `macos-arm64`, the + packaged app uses `win32-x64` and `darwin-arm64`. +3. **Finds the language server** (optional). `arduino-language-server` and + `clangd` from `vendor/language-server//` or + `resources/language-server//`, fetched by + `npm run fetch:language-server`. Without them the editor still works, just + without completion and diagnostics. +4. **Spawns tinyService as a child process.** It runs Electron's own binary in + Node mode (`ELECTRON_RUN_AS_NODE=1`) with a short launcher that imports the ESM + package and calls `new TinyService(options).start()`. A separate process is + used because Electron's main-process loader can't reliably import the ESM + package, and it keeps a backend crash out of the app. The child's working + directory is the app root, so it resolves the package from `node_modules`. + That is why `asar` is disabled in the packaged app. +5. **Waits for `/health`.** It polls `http://localhost:/health` up to ten + times, a second apart. If arduino-cli isn't available, or the service never + answers, it sends `service:error` to the renderer. + +tinyService's output is forwarded to the main-process console with a +`[tinyService]` prefix. + +## Renderer: finding the service + +[`WebSocketArduinoService`](src/renderer/src/services/arduino/WebSocketArduinoService.ts) +connects to the first of these that exists: + +1. `localStorage["tinyservice.url"]`, an explicit override +2. On desktop, the URL main reports over `service:get-url-sync`, which carries the + port tinyService actually got +3. `ws://localhost:3000` + +The Content Security Policy in +[`src/renderer/index.html`](src/renderer/index.html) allows `ws://` and `http://` +to any port on `localhost` and `127.0.0.1`, so a backend on 3001 connects too. + +## Browser build + +The browser can't start processes, so tinyService has to be running on the same +computer. When the app can't reach it, it offers the tinyService installer from +the [tinyService releases](https://github.com/Mister-Industries/tinyService/releases/latest). +Anyone developing tinyService itself can run it from that repo instead. + +Browser projects opened from GitHub live in memory (`mem://` paths) and can't be +compiled until they're saved to a real folder, because arduino-cli reads from +disk. + +## Security + +tinyService 1.2.0 listens on `127.0.0.1` only and refuses any browser request +whose `Origin` isn't allowed. `ServiceManager` passes `allowedOrigins`: the +packaged app (`file://`), the dev server on any port (`http://localhost:*`) and +the hosted web app (`https://studio.tinycore.cc`, and `https://app.tinystudio.cc` +until it forwards). Requests with no `Origin` header, such as the health check, +are accepted. A standalone tinyService (the tray app, or `npx`) uses the same +defaults. ## Troubleshooting -### "arduino-cli not found" in development - -Ensure arduino-cli is installed and available in your PATH: - -```bash -arduino-cli version -``` - -### Service fails to start - -1. Check port 3000 is not in use: `lsof -i :3000` -2. Verify arduino-cli binary path in ServiceManager logs -3. Ensure you have permission to execute the binary - -### WebSocket connection refused - -1. Verify TinyService started successfully (check console logs) -2. Confirm connecting from correct origin (add to `allowedOrigins` if needed) -3. Check if another service is using port 3000 - -## Future Enhancements +**"Arduino CLI is not available"** +Run `npm run fetch:arduino-cli` (development), or check that +`resources/arduino-cli//` exists in the packaged app. -Potential improvements: +**The app can't reach the backend** +Check the main-process console for `[ServiceManager]` and `[tinyService]` lines. +On desktop, ports 3000 to 3009 all being taken stops it from starting. In the +browser, make sure tinyService is running and, if it isn't on port 3000, set +`localStorage["tinyservice.url"]`. -- Make port configurable via config file or environment variable -- Add IPC channel to communicate service status to renderer -- Implement service health checks -- Add automatic service restart on failure -- Support for authenticated WebSocket connections +**No completion or diagnostics in the editor** +The language server isn't bundled by default. Run +`npm run fetch:language-server` and restart. diff --git a/README.md b/README.md index fcff6f6..2b312ef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ tinyStudio -**Write and flash embedded code, design circuits, and deploy apps all in one place** +**Write and flash embedded code, design circuits, and build visuals, all in one place** [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![Built with Electron](https://img.shields.io/badge/Electron-2f3242?logo=electron&logoColor=9feaf9)](https://www.electronjs.org/) @@ -13,43 +13,57 @@ --- -> ### ⚠️ WARNING - This is a ROUGH Alpha. Please read this first +> ### ⚠️ Alpha > -> 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. -> -> 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 -> [Roadmap](#roadmap) for where things stand. - -tinyStudio is an open-source IDE for makers. The idea was to be able to write an Arduino sketch, upload the code, see the wiring next to it, and watch a p5.js simulation react to your serial output, without jumping between apps. It can run as a local desktop app (Electron) and in the browser. - -It's built around the family of tinyBoards from -[MR.INDUSTRIES](https://github.com/Mister-Industries), starting with the **tinyCore** (a development board based on ESP32‑S3). -It also speaks plain Arduino, so most sketches and boards (an Arduino Uno, ESP32, etc) work too. - -## What it does today - -Keep the alpha warning above in mind, all of these things work, but they're... early. - -- **Three views, one project.** Switch between Code / Circuit / Visual for the same folder: - - **Code** is a Monaco-based editor for `.ino` sketches with some Arduino-aware tooling. - - **Circuit** is a drag-and-drop designer that reads and writes a - [Wokwi-compatible](https://docs.wokwi.com/diagram-format) `diagram.json`. - - **Visual** is a `visual.js` [p5.js](https://p5js.org/) sketch that runs live off your serial - output. You can export it to a standalone web page. -- **Build and flash for real.** Compile and upload over a bundled - [`arduino-cli`](https://arduino.github.io/arduino-cli/) through the tinyService backend, with a - built-in serial monitor. As Arduino gets tighter with their licensing, we plan to write our own service, but this makes it work for now. Please read the CLI's license to ensure you are okay with the terms. -- **A parts library.** Built-in tinyBoards plus parts imported from - [Fritzing](https://fritzing.org/), each with breadboard and schematic views. You can add your - own — see [Extending the library](#extending-the-library). -- **Board and library managers.** Install board packages and Arduino libraries from the UI. -- **Optional AI assistant.** Bring your own Anthropic API key for an in-app agent. Can read/write files with permission. -- **Web and GitHub Pages Project Export.** The same renderer runs in Electron and as a static web build. +> tinyStudio is in alpha. It works, but expect rough edges, and expect things to +> change between versions. See [Known issues](#known-issues) and +> [Roadmap](#roadmap) for where things stand, and [CHANGELOG.md](CHANGELOG.md) +> for what changed. + +tinyStudio is an open-source IDE for makers. Write an Arduino sketch, upload it, +see the wiring next to it, and watch a p5.js sketch react to the board's serial +output, without jumping between apps. It runs as a desktop app (Electron) and in +the browser. + +It's built around the tinyBoards from +[MR.INDUSTRIES](https://github.com/Mister-Industries), starting with the +**tinyCore** (an ESP32‑S3 development board). It also speaks plain Arduino, so +most sketches and boards (an Arduino Uno, an ESP32) work too. + +## What it does + +- **Three views of one project.** Switch between Code, Circuit and Visual: + - **Code** is a Monaco editor for `.ino` sketches, with completion, hover and + live diagnostics from the Arduino language server on desktop. + - **Circuit** edits `circuit.json`: a breadboard view, a schematic view of the + same circuit, an electrical rule check, and SPICE simulation (ngspice) with + plots and probes. + - **Visual** runs a [p5.js](https://p5js.org/) sketch (`visual.js`) live off the + board's serial output, and exports it as a standalone web page or publishes + it to GitHub Pages. +- **Build and flash.** Compile and upload with a bundled + [`arduino-cli`](https://arduino.github.io/arduino-cli/) through the tinyService + backend, with board options, upload progress and a Serial Monitor. As Arduino + tightens its licensing we plan to write our own service; until then, please + read arduino-cli's licence to make sure you're fine with its terms. +- **Parts.** tinyBoards and Fritzing parts from the + [tinyparts](https://github.com/Mister-Industries/tinyparts) repo, each with + breadboard and schematic art. The app ships a snapshot and picks up changes on + launch. You can edit parts or add your own. +- **GitHub.** Open any repo, copy an example into your own account with **Make it + mine**, and push, pull and publish from the app. +- **Examples.** A searchable library from + [tinyStudio-examples](https://github.com/Mister-Industries/tinyStudio-examples). +- **Studio AI (optional).** Bring your own Anthropic API key for an assistant that + can read your project, circuit and serial output, and edit files with your + permission. + +## Download + +Installers for Windows, macOS (Apple Silicon and Intel) and Linux (AppImage and +.deb) are on the [Releases](https://github.com/Mister-Industries/tinyStudio/releases) +page. They aren't signed yet, so Windows and macOS warn the first time you open +tinyStudio; the release notes say how to get past that. ## The tinyFamily @@ -60,143 +74,159 @@ 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)). +These come from the `tinyboards` pack in +[tinyparts](https://github.com/Mister-Industries/tinyparts) and show up in the +Circuit view's parts palette. ## Getting started ### Prerequisites -- **Node.js** 18+ and npm. +- **Node.js 22** and npm. -That's it. `npm install` pulls the backend packages (**`@mister-industries/tinyservice`** and -**`@mister-industries/shared`**) from public npm — no token or registry config needed — and -`arduino-cli` is fetched automatically the first time you run `npm run dev` (or `npm run build`) -by [`scripts/fetch-arduino-cli.mjs`](scripts/fetch-arduino-cli.mjs). See -[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for what tinyService does and how it's wired in. +`npm install` pulls the backend packages (`@mister-industries/tinyservice` and +`@mister-industries/shared`) from public npm. `arduino-cli` is downloaded into +`vendor/` the first time you run `npm run dev` or `npm run build`. -### Install +### Develop ```bash npm install +npm run dev # desktop app; starts tinyService for you +npm run dev:web # browser build at http://localhost:5173 ``` -### Develop +Compile, upload and serial all go through **tinyService**, a local WebSocket +server that wraps `arduino-cli`. The desktop app starts it on the first free port +from 3000. The browser build needs it running on the same computer: install it +from the +[tinyService releases](https://github.com/Mister-Industries/tinyService/releases/latest) +(the app offers the installer when it can't reach the backend). To use a +different address, set `localStorage["tinyservice.url"]`. More in +[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md). + +GitHub sign-in works out of the box on desktop (device flow) and in the browser +(web flow); the app's OAuth client id is built in. How it works, and what the +Netlify site needs for the browser flow: [docs/github-auth.md](docs/github-auth.md). + +### Check ```bash -npm run dev # Electron desktop app — starts the tinyService backend for you -npm run dev:web # browser-only renderer at http://localhost:5173 +npm run typecheck +npm run lint +npm test ``` -> **How the backend works:** all compile/upload/serial goes through **tinyService**, a small -> local WebSocket server (on `ws://localhost:3000`) that wraps `arduino-cli`. On the **desktop** -> app it starts automatically. For the **browser** build, run tinyService yourself -> (`npx @mister-industries/tinyservice`, or a standalone binary) and the page connects to it, -> so you can host the web build on GitHub Pages/Netlify and users just run the backend locally. -> Point the UI at a non-default backend by setting `localStorage["tinyservice.url"]`. +CI runs these, plus the web build, on every push to `main` and each version branch. ### Build ```bash npm run build:win # Windows installer npm run build:mac # macOS app -npm run build:linux # Linux package -npm run build:web # static web bundle (dist-web/) +npm run build:linux # AppImage and deb +npm run build:web # static web bundle in dist-web/ ``` -Windows packaging notes live in [docs/packaging-windows.md](docs/packaging-windows.md). +Releases for all three systems are built on GitHub: +[docs/releasing.md](docs/releasing.md). Windows packaging details: +[docs/packaging-windows.md](docs/packaging-windows.md). Hosting +the web build: [docs/web-deploy.md](docs/web-deploy.md). ## Example projects -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**. +Ready-to-open projects live in +[tinyStudio-examples](https://github.com/Mister-Industries/tinyStudio-examples) and +show up in the app's **Examples** tab. Open one, pick your board and port, and hit +**Verify** or **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 | -| [Joystick Example](demo/Joystick%20Example) | Read a Qwiic joystick and visualize the stick position | +These come with a circuit and a live visual: -Each project is a folder with the same structure: +| Project | What it shows | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [Basic Blink Program](https://github.com/Mister-Industries/tinyStudio-examples/tree/main/basics/blink-basic) | Blink an LED and mirror its state in the Visual view | +| [Smooth Breathing Effect](https://github.com/Mister-Industries/tinyStudio-examples/tree/main/basics/blink-breathing) | PWM-fade an LED and chart the brightness curve live | +| [Qwiic Joystick](https://github.com/Mister-Industries/tinyStudio-examples/tree/main/basics/qwiic-joystick) | Read a Qwiic joystick and play Asteroids or Pong in the Visual view | + +A project is a folder laid out the way the Arduino IDE expects: ``` -My Example/ - my_sketch/ - my_sketch.ino ← the Arduino sketch (its own folder, per Arduino convention) - diagram.json ← the circuit (Circuit view) - visual.js ← the p5 sketch (Visual view) - README.md ← how to run it +my-example/ + my-example.ino ← the sketch; it shares the folder's name + circuit.json ← the circuit (Circuit view) + visual.js ← the p5.js sketch (Visual view) + README.md ← shown in the Docs panel ``` -## Extending the library - -Creating your own **example projects** and **custom parts** is pretty straightforward. The full -walkthrough has been documented in: +Older projects with a `diagram.json` get a **Convert** button in the Circuit +view; it writes `circuit.json` and keeps the original as `diagram.json.bak`. -📖 **[docs/extending-the-library.md](docs/extending-the-library.md)** - -> Just a heads up though, we are not opening up CONTRIBUTING just yet, since a lot will be SUBJECT TO CHANGE. - -TL;DR: +## Extending the library -- **A custom part** is a JSON file in - [`src/renderer/src/assets/parts/`](src/renderer/src/assets/parts) plus one entry in - [`index.json`](src/renderer/src/assets/parts/index.json). You can hand-author it or generate - it from a Fritzing part using - [`scripts/fritzing-import.mjs`](scripts/fritzing-import.mjs). -- **An example project** is just a new folder under [`demo/`](demo) following the layout above. +- **Parts and their art** live in + [tinyparts](https://github.com/Mister-Industries/tinyparts), one folder per part + with real `.svg` files you can edit in Illustrator: + [docs/parts-and-art.md](docs/parts-and-art.md). +- **Example projects** are folders in tinyStudio-examples: + [docs/extending-the-library.md](docs/extending-the-library.md). ## Architecture ``` src/ - main/ Electron main process: app lifecycle, tinyService manager, - settings, the optional AI agent - preload/ typed IPC bridge between main and renderer - renderer/ the React UI (Code / Circuit / Visual), Redux store, - parts library, services, and the p5 visual runtime -scripts/ arduino-cli fetcher + Fritzing parts importer -demo/ bundled example projects -docs/ contributor & packaging docs + main/ Electron main process: windows, file access, tinyService, + GitHub sign-in, the desktop side of Studio AI + preload/ the typed bridge between main and the renderer + renderer/ the React app: Code, Circuit and Visual views, Redux store, + parts, the Arduino service client + shared/ code both processes use (the Studio AI agent, constants) +scripts/ arduino-cli and language-server fetchers, parts tools, tests +docs/ design notes and guides ``` -The Electron main process starts **tinyService**, a WebSocket wrapper around `arduino-cli`, on -launch; the renderer talks to it to compile, upload, and stream serial. See -[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for details. +Where to read more: -## Known bugs +| Topic | Doc | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| tinyService and the backend | [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) | +| The circuit editor | [docs/circuit-view-tech-spec.md](docs/circuit-view-tech-spec.md) | +| Parts, packs and art | [docs/parts-and-art.md](docs/parts-and-art.md), [docs/tinyparts-pack-setup.md](docs/tinyparts-pack-setup.md) | +| Where state lives | [docs/state-management.md](docs/state-management.md) | +| Code conventions | [docs/code-conventions.md](docs/code-conventions.md) | +| Opening and saving files | [docs/file-editing-flow.md](docs/file-editing-flow.md) | +| GitHub sign-in | [docs/github-auth.md](docs/github-auth.md) | +| Push, Pull and linked repos | [docs/github-sync.md](docs/github-sync.md) | +| What is planned for 0.4.0 and beta | [docs/release-plan.md](docs/release-plan.md), from [the September 2026 audit](docs/audit-2026-09.md) | -This is an alpha, so the list is short only because we haven't written everything down yet. -Known issues right now: +## Known issues -- **Chat sessions don't persist between tabbing.** Switch away from the AI assistant and back, -conversation is gone. +- Desktop projects opened in an earlier version ask you to choose their folder once. ## Roadmap -Nothing here is final, and priorities will shift. But this is roughly where we're headed. +Nothing here is final. **Up next** -- Circuit diagram overhaul and expansion (The wires suck right now, I'm aware) +- Circuit export to KiCad and Wokwi +- GitHub sign-in in the browser build without a pasted token +- Signed installers and automatic updates - Tutorials for using tinyStudio -- Built-in examples (and better examples in general) -- Better Markdown support (in the README viewer and the agent) -- General UI improvements **Further out** - CircuitPython support -- KiCad Schematic export ## Contributions -tinyStudio is in **early alpha** and is **not accepting external contributions** at this time. -Pull requests are closed automatically and issues may be closed without review. This will open up -as the project stabilizes, until then, feel free to **fork** and experiment. +tinyStudio is in alpha and **not accepting external contributions** yet. Pull +requests are closed automatically and issues may be closed without review. Feel +free to **fork** and experiment. ## License -tinyStudio is licensed under the **GNU General Public License v3.0 (or later)**. See -[LICENSE](LICENSE). +tinyStudio is licensed under the **GNU General Public License v3.0 (or later)**. +See [LICENSE](LICENSE), and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for the +work it includes. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f5ef9de --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,43 @@ +# Third-party notices + +tinyStudio is licensed under the GNU General Public License v3.0 or later (see +[LICENSE](LICENSE)). It includes, bundles or downloads the work below, each under +its own licence. The full licence text for each npm package ships in its folder +under `node_modules/`; the in-app **About tinyStudio** dialog lists the main ones. + +## Art and data + +| Work | Licence | Where it's used | +| ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Fritzing parts](https://github.com/fritzing/fritzing-parts) | CC-BY-SA 3.0 | Breadboard and schematic art for the Core parts pack. Attribution ships with the pack as `ATTRIBUTION.md` in `src/renderer/src/assets/tinyparts/packs/core/`. Changes to that art (restroked and recoloured symbols, snapped pins) are shared under the same licence in [tinyparts](https://github.com/Mister-Industries/tinyparts). | + +## Programs shipped with the desktop app + +| Program | Licence | Notes | +| ----------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| [arduino-cli](https://github.com/arduino/arduino-cli) | GPL-3.0 | Bundled binary; compiles and uploads sketches. Run as a separate program by tinyService. | +| [arduino-language-server](https://github.com/arduino/arduino-language-server) | AGPL-3.0 | Bundled only when fetched with `npm run fetch:language-server`; editor code intelligence. | +| [clangd](https://clangd.llvm.org) | Apache-2.0 with LLVM exception | Bundled alongside arduino-language-server. | +| [Electron](https://www.electronjs.org) | MIT | The app runtime. Chromium and its dependencies are listed in `LICENSES.chromium.html` in the install folder. | + +Board toolchains (ESP32, AVR and others) are not bundled. arduino-cli downloads +them on first compile under their own licences. + +## Libraries + +| Library | Licence | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| [tinyService](https://www.npmjs.com/package/@mister-industries/tinyservice) and [@mister-industries/shared](https://www.npmjs.com/package/@mister-industries/shared) | See the packages | +| [ngspice](https://ngspice.sourceforge.io) via [eecircuit-engine](https://github.com/eelab-dev/EEcircuit-engine) | BSD-3-Clause (ngspice), MIT (eecircuit-engine) | +| [p5.js](https://p5js.org) | LGPL-2.1 | +| [Monaco Editor](https://github.com/microsoft/monaco-editor) | MIT | +| [Mermaid](https://github.com/mermaid-js/mermaid) | MIT | +| [uPlot](https://github.com/leeoniya/uPlot) | MIT | +| [DOMPurify](https://github.com/cure53/DOMPurify) | MPL-2.0 or Apache-2.0 | +| [React](https://react.dev), [Redux Toolkit](https://redux-toolkit.js.org) | MIT | +| [Radix UI](https://www.radix-ui.com) | MIT | +| [Tailwind CSS](https://tailwindcss.com) | MIT | +| [Lucide icons](https://lucide.dev) | ISC | + +The complete list of npm dependencies and their licences can be produced with +`npx license-checker --production --summary`. diff --git a/build/icon.icns b/build/icon.icns index 816c212..e9cd6ff 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico index a6bc4d5..907ff6c 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index fe98f45..09bc54b 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/icon.svg b/build/icon.svg index c133ef6..524bbf4 100644 --- a/build/icon.svg +++ b/build/icon.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/demo/Blink Example/README.md b/demo/Blink Example/README.md deleted file mode 100644 index 28d837a..0000000 --- a/demo/Blink Example/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Blink Example - -Blink an LED and report its state over serial — the tinyStudio demo project. - -Built with **tinyStudio** for the **tinyCore** ESP32-S3 board by MR.INDUSTRIES -(also runs on an Arduino Uno). - -## Project layout - -``` -Blink Example/ - led_blink/ - led_blink.ino ← the Arduino sketch (its own folder, per Arduino convention) - diagram.json ← the circuit (Circuit view) - visual.js ← the p5 sketch (Visual view) - README.md ← this file - index.html ← created when you Export the Visual for the web -``` - -## Try it - -1. Open this folder in tinyStudio (Files → Open Folder → "Blink Example"). -2. Pick your board and port, then **Verify** and **Upload**. -3. Open the **Serial Monitor** (next to Output) at 9600 baud — `Hello World`, - then `On` / `Off` in step with the LED. -4. Use the **Code / Circuit / Visual** segment: - - **Circuit** — the wiring in `diagram.json`. - - **Visual** — `visual.js` runs live: a glowing LED that mirrors the board, - with a scrolling on/off timeline. Hit **Export** to publish it as a web page. - -## Pin map - -| Signal | Pin | Direction | -|--------|-----|-----------| -| LED | SIG / 13 | output | diff --git a/demo/Blink Example/diagram.json b/demo/Blink Example/diagram.json deleted file mode 100644 index d7daacf..0000000 --- a/demo/Blink Example/diagram.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "version": 1, - "editor": "tinystudio", - "author": "tinyStudio", - "parts": [ - { - "type": "tinycore", - "id": "tinycore", - "left": 150, - "top": 240 - }, - { - "type": "resistor", - "id": "resistor", - "left": 520, - "top": 230 - }, - { - "type": "led-generic-5mm", - "id": "led", - "left": 364.8, - "top": 144 - }, - { - "type": "battery-aa", - "id": "battery-aa_y90", - "left": 422.4, - "top": 345.59999999999997, - "rotate": 180 - } - ], - "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" - ] - ] - ] -} \ No newline at end of file diff --git a/demo/Blink Example/led_blink/led_blink.ino b/demo/Blink Example/led_blink/led_blink.ino deleted file mode 100644 index b5f5b6c..0000000 --- a/demo/Blink Example/led_blink/led_blink.ino +++ /dev/null @@ -1,21 +0,0 @@ -// led_blink — blink the onboard LED and report its state over serial. -// The tinyStudio demo: prints "Hello World" once, then "On"/"Off" in step with -// the LED so the Serial Monitor (and the Visual view) match the board. - -const uint8_t LED = 13; // Uno onboard LED (also fine on tinyCore) - -void setup() { - pinMode(LED, OUTPUT); - Serial.begin(9600); - Serial.println("Hello World"); -} - -void loop() { - digitalWrite(LED, HIGH); - Serial.println("ON"); - delay(1000); - - digitalWrite(LED, LOW); - Serial.println("OFF"); - delay(1000); -} \ No newline at end of file diff --git a/demo/Blink Example/visual.js b/demo/Blink Example/visual.js deleted file mode 100644 index 40a2074..0000000 --- a/demo/Blink Example/visual.js +++ /dev/null @@ -1,81 +0,0 @@ -// visual.js — mirrors the board's LED and charts its On/Off history. -// tinyStudio feeds each Serial.println() line to serialEvent(); this lights a -// glowing bulb on "On" / dims it on "Off", and scrolls a square-wave timeline -// of the recent state underneath. A little taste of what p5.js + serial can do. - -let on = false; -let history = []; // recent 1/0 samples -const MAX = 180; - -function setup() { - createCanvas(420, 420); - textFont('monospace'); -} - -function draw() { - background(7, 11, 34); - history.push(on ? 1 : 0); - if (history.length > MAX) history.shift(); - - // ── glowing LED bulb ── - const cx = width / 2; - const cy = 138; - if (on) { - noStroke(); - fill(255, 63, 140, 55); - circle(cx, cy, 210); - fill(255, 63, 140, 35); - circle(cx, cy, 150); - } - stroke(53, 60, 120); - strokeWeight(2); - fill(on ? color(255, 63, 140) : color(26, 31, 77)); - circle(cx, cy, 96); - noStroke(); - fill(on ? 255 : 120); - textAlign(CENTER); - textSize(22); - text(on ? 'ON' : 'OFF', cx, cy + 8); - - // latest serial line - fill(0, 240, 255); - textAlign(LEFT); - textSize(13); - const last = window.__tinySerial ? window.__tinySerial.last : '—'; - text('serial: ' + (last || '—'), 22, 28); - - // ── scrolling square-wave timeline ── - const top = 264; - const bot = 384; - const left = 22; - const right = width - 22; - stroke(26, 31, 77); - strokeWeight(1); - line(left, top, right, top); - line(left, bot, right, bot); - - noFill(); - stroke(0, 240, 255); - strokeWeight(2); - beginShape(); - for (let i = 0; i < history.length; i++) { - const x = map(i, 0, MAX - 1, left, right); - const y = history[i] ? top + 8 : bot - 8; - vertex(x, y); - } - endShape(); - - noStroke(); - fill(120, 130, 170); - textSize(11); - text('HIGH', left, top - 6); - text('LOW', left, bot + 16); -} - -// fired by tinyStudio for every serial line received -function serialEvent(line) { - if (!line) return; - const t = line.trim(); - if (/^on$/i.test(t)) on = true; - else if (/^off$/i.test(t)) on = false; -} \ No newline at end of file diff --git a/demo/Fade Example/README.md b/demo/Fade Example/README.md deleted file mode 100644 index 81b49b8..0000000 --- a/demo/Fade Example/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Fade Example - -Smoothly fade an LED with PWM and chart the brightness curve — the "analog" -companion to the Blink demo. - -Built with **tinyStudio** for the **tinyCore** ESP32-S3 board by MR.INDUSTRIES -(also runs on an Arduino Uno). - -## Project layout - -``` -Fade Example/ - fade/ - fade.ino ← the Arduino sketch (its own folder, per Arduino convention) - diagram.json ← the circuit (Circuit view) - visual.js ← the p5 sketch (Visual view) - README.md ← this file -``` - -## Try it - -1. Open this folder in tinyStudio (Files → Open Folder → "Fade Example"). -2. Pick your board and port, then **Verify** and **Upload**. -3. Open the **Serial Monitor** (next to Output) at 9600 baud — a stream of - numbers from `0` to `255` and back as the LED breathes. -4. Use the **Code / Circuit / Visual** segment: - - **Circuit** — the wiring in `diagram.json`: `SIG → resistor → LED → GND`. - - **Visual** — `visual.js` runs live: a bulb that glows in proportion to the - PWM value, with a scrolling brightness curve underneath. - -## Pin map - -| Signal | Pin | Direction | -| ------ | -------- | ----------- | -| LED | SIG / 13 | output (PWM) | - -## How it works - -`analogWrite(LED, brightness)` sets the PWM duty cycle, and `brightness` ramps -0 → 255 → 0 in steps of 5. Each loop prints the current value with -`Serial.println(brightness)` — one number per line — which is exactly what -`visual.js` parses in `serialEvent()` to drive the bulb and the chart. diff --git a/demo/Fade Example/diagram.json b/demo/Fade Example/diagram.json deleted file mode 100644 index 630a241..0000000 --- a/demo/Fade Example/diagram.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": 1, - "editor": "tinystudio", - "author": "tinyStudio", - "parts": [ - { - "type": "tinycore", - "id": "tinycore", - "left": 150, - "top": 240 - }, - { - "type": "resistor", - "id": "resistor", - "left": 470, - "top": 230 - }, - { - "type": "led-generic-5mm", - "id": "led", - "left": 360, - "top": 140 - } - ], - "connections": [ - ["tinycore:SIG", "resistor:Pin 0", "#36c46b"], - ["resistor:Pin 1", "led:anode", "#36c46b"], - ["led:cathode", "tinycore:GND", "#8b94c8"] - ] -} diff --git a/demo/Fade Example/fade/fade.ino b/demo/Fade Example/fade/fade.ino deleted file mode 100644 index d8c41ed..0000000 --- a/demo/Fade Example/fade/fade.ino +++ /dev/null @@ -1,25 +0,0 @@ -// fade — smoothly ramp an LED up and down with PWM, reporting brightness. -// The tinyStudio "analog" companion to Blink: instead of on/off, the LED -// breathes, and each loop prints the current 0–255 brightness on its own line -// so the Serial Monitor (and the Visual view) can chart the curve. - -const uint8_t LED = 13; // PWM-capable pin on tinyCore (also fine on an Uno) - -int brightness = 0; // current duty cycle, 0–255 -int step = 5; // how much to change each frame - -void setup() { - pinMode(LED, OUTPUT); - Serial.begin(9600); -} - -void loop() { - analogWrite(LED, brightness); - Serial.println(brightness); // one number per line → easy to parse in visual.js - - brightness += step; - if (brightness <= 0 || brightness >= 255) { - step = -step; // reverse direction at each end - } - delay(100); -} diff --git a/demo/Fade Example/visual.js b/demo/Fade Example/visual.js deleted file mode 100644 index 2015a72..0000000 --- a/demo/Fade Example/visual.js +++ /dev/null @@ -1,65 +0,0 @@ -// visual.js — a breathing LED that tracks the board's PWM brightness. -// tinyStudio feeds each Serial.println() line to serialEvent(); the sketch -// prints a 0–255 number per frame, so we parse it, glow a bulb in proportion, -// and scroll a brightness curve underneath. A small taste of p5.js + serial. - -let brightness = 0; // latest 0–255 value from the board -let history = []; // recent brightness samples -const MAX = 240; - -function setup() { - createCanvas(420, 420); - textFont('monospace'); -} - -function draw() { - background(7, 11, 34); - history.push(brightness); - if (history.length > MAX) history.shift(); - - const t = brightness / 255; // 0..1 - - // ── glowing LED bulb ── - const cx = width / 2; - const cy = 138; - noStroke(); - fill(255, 63, 140, 60 * t); - circle(cx, cy, 210); - fill(255, 63, 140, 40 * t); - circle(cx, cy, 150); - stroke(53, 60, 120); - strokeWeight(2); - fill(lerpColor(color(26, 31, 77), color(255, 63, 140), t)); - circle(cx, cy, 96); - noStroke(); - fill(255); - textAlign(CENTER); - textSize(20); - text(brightness, cx, cy + 7); - - // ── brightness curve ── - stroke(53, 60, 120); - line(20, 360, width - 20, 360); - noFill(); - stroke(255, 63, 140); - strokeWeight(2); - beginShape(); - for (let i = 0; i < history.length; i++) { - const x = map(i, 0, MAX, 20, width - 20); - const y = map(history[i], 0, 255, 360, 230); - vertex(x, y); - } - endShape(); - - noStroke(); - fill(123, 134, 184); - textAlign(LEFT); - textSize(12); - text('brightness', 20, 390); -} - -// Called by tinyStudio for every line the board prints over serial. -function serialEvent(line) { - const n = parseInt(line, 10); - if (!isNaN(n)) brightness = constrain(n, 0, 255); -} diff --git a/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino b/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino deleted file mode 100644 index 03061f5..0000000 --- a/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino +++ /dev/null @@ -1,34 +0,0 @@ -/* - IR Distance Sensor - - Created with tinyStudio - Date: 6/24/2026 - - Description: - A basic sketch template. Customize this code for your project needs. -*/ - -// Pin definitions -const int LED_PIN = 13; // Built-in LED pin - -void setup() { - // Initialize serial communication - Serial.begin(9600); - - // Initialize digital pin LED_PIN as an output - pinMode(LED_PIN, OUTPUT); - - Serial.println("IR Distance Sensor - Setup complete!"); -} - -void loop() { - // Turn the LED on - digitalWrite(LED_PIN, HIGH); - Serial.println("LED ON"); - delay(1000); // Wait for a second - - // Turn the LED off - digitalWrite(LED_PIN, LOW); - Serial.println("LED OFF"); - delay(1000); // Wait for a second -} diff --git a/demo/IR Distance Example/IR Distance Sensor/README.md b/demo/IR Distance Example/IR Distance Sensor/README.md deleted file mode 100644 index 5673a50..0000000 --- a/demo/IR Distance Example/IR Distance Sensor/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# IR Distance Sensor - -A tinyCore project created with tinyStudio. - -## Getting Started - -1. Open this project in tinyStudio -2. Connect your tinyCore -3. Upload the sketch to your board - -## Hardware Requirements - -- tinyCore (or compatible board) -- USB cable -- Additional components as needed - -## Circuit Diagram - -Add your circuit diagram and connections here. diff --git a/demo/IR Distance Example/IR Distance Sensor/diagram.json b/demo/IR Distance Example/IR Distance Sensor/diagram.json deleted file mode 100644 index 8766c34..0000000 --- a/demo/IR Distance Example/IR Distance Sensor/diagram.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "editor": "tinystudio", - "parts": [], - "connections": [] -} \ No newline at end of file diff --git a/demo/IR Distance Example/IR Distance Sensor/visual.js b/demo/IR Distance Example/IR Distance Sensor/visual.js deleted file mode 100644 index a170921..0000000 --- a/demo/IR Distance Example/IR Distance Sensor/visual.js +++ /dev/null @@ -1,54 +0,0 @@ -// visual.js — Serial Plotter -// Graphs the latest number printed over Serial (serialValue()) as a scrolling -// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the -// board. Switch to Code to edit this sketch; Visual to run it. - -let data = []; -const MAX = 240; // points kept on screen - -function setup() { - createCanvas(480, 280); - textFont('monospace'); -} - -function draw() { - background(7, 11, 34); - - // pull the most recent serial value each frame - data.push(serialValue()); - if (data.length > MAX) data.shift(); - - // auto-scale to the data range (with a little headroom) - let lo = Math.min(...data, 0); - let hi = Math.max(...data, 1); - if (hi === lo) hi = lo + 1; - - // grid - stroke(26, 31, 77); - strokeWeight(1); - for (let i = 0; i <= 4; i++) { - let y = map(i, 0, 4, 20, height - 24); - line(40, y, width - 12, y); - } - - // plotted line - noFill(); - stroke(0, 240, 255); - strokeWeight(2); - beginShape(); - for (let i = 0; i < data.length; i++) { - let x = map(i, 0, MAX - 1, 40, width - 12); - let y = map(data[i], lo, hi, height - 24, 20); - vertex(x, y); - } - endShape(); - - // readouts - noStroke(); - fill(235, 238, 255); - textSize(12); - text('value: ' + serialValue().toFixed(2), 44, 16); - fill(120, 130, 170); - text(hi.toFixed(0), 8, 24); - text(lo.toFixed(0), 8, height - 24); -} diff --git a/demo/Joystick Example/README.md b/demo/Joystick Example/README.md deleted file mode 100644 index c225373..0000000 --- a/demo/Joystick Example/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Qwiic Joystick Asteroids Example - -Stream the SparkFun Qwiic Joystick's X/Y position (and button) over serial, then **play a -full game of Asteroids** with it in the **Visual** tab. - -Built with **tinyStudio** for the **tinyCore** ESP32-S3 board by MR.INDUSTRIES. - ---- - -## What it does - -- [x] Talks to the Qwiic Joystick over I²C (default address `0x20`) -- [x] Reads horizontal + vertical as 10-bit values (`0..1023`, center `~512`) -- [x] Streams `x,y,button` over serial as a simple CSV line -- [x] `visual.js` parses that stream and lets you **fly a ship, rotate, thrust, and shoot rocks** - -```mermaid -flowchart TD - JOY[Qwiic Joystick] -->|I²C SDA/SCL| MCU[tinyCore] - MCU -->|"Serial.println x,y,button"| MON[Serial Stream] - MON --> VIS[visual.js · Asteroids] -``` - ---- - -## Hardware - -The SparkFun Qwiic Joystick ([COM-15168](https://www.sparkfun.com/products/15168)) is an -I²C analog thumbstick. Plug it into the tinyCore **Qwiic** port with a Qwiic cable — no -soldering, no breadboard. (Qwiic carries `3V3 · GND · SDA · SCL`.) - -Install the **SparkFun Qwiic Joystick Arduino Library** from the Library Manager. - ---- - -## Serial format - -One CSV line per sample, ~20 Hz: - -``` -512,498,0 -``` - -`x , y , button` — where `x`/`y` are `0..1023` (center `~512`) and `button` is `1` while pressed. - ---- - -## 📟 `qwiic_joystick.ino` — the firmware - -This is the code that runs on the tinyCore. Its only job is to read the joystick and print -a clean CSV line that the visualizer can parse. - -### Walkthrough - -| Stage | What happens | -|-------|--------------| -| **Includes** | `Wire.h` for I²C and `SparkFun_Qwiic_Joystick_Arduino_Library.h` for the driver. A global `JOYSTICK joystick;` object handles all register reads. | -| **`setup()`** | Starts serial at `9600` baud, then brings up the I²C bus with `Wire.begin(3, 4)` — pins **3 (SDA)** and **4 (SCL)** are the tinyCore's Qwiic bus. | -| **Self-check** | `joystick.begin()` returns `false` if the device isn't found. If so, it prints a hint to check the Qwiic cable and halts in a `while (1)` loop so you're not left guessing. | -| **`loop()`** | Reads the three values and prints them as `x,y,button`, then `delay(50)` paces the stream to roughly 20 Hz. | - -### Key lines - -```cpp -int x = joystick.getHorizontal(); // 0..1023, center ~512 -int y = joystick.getVertical(); // 0..1023, center ~512 -int b = (joystick.getButton() == 0) ? 1 : 0; // active-low → 1 = pressed -``` - -> **Note:** the joystick button is **active-low** (the library returns `0` when pressed), -> so the sketch inverts it to the more intuitive `1 = pressed` before sending. - ---- - -## 🎮 `visual.js` — the Asteroids game - -A p5.js sketch that turns the joystick stream into a playable game. It reads the **same CSV -format** the firmware emits, so the firmware and visualizer stay perfectly in sync. - -### Controls - -| Action | Joystick | Keyboard fallback* | -|--------|----------|--------------------| -| Rotate | Tilt **left / right** | ← / → arrows | -| Thrust | Push **up** | ↑ arrow | -| Fire | Press the **button** | Spacebar | -| Restart (after game over) | Press the **button** | Spacebar | - -> \*If the tinyStudio serial harness isn't present (e.g. a plain browser), `visual.js` -> automatically falls back to the keyboard so you can test the game before wiring anything up. - -### How the input is read - -```js -// parse "x,y,button" -> {x, y, b} (same regex the firmware feeds) -function parseLine(s) { ... } - -// pull the latest line from the harness, or null when not connected -function readJoystick() { - if (typeof serialRead !== 'function') return null; - return parseLine(serialRead()); -} -``` - -Each frame `handleInput()` grabs the newest joystick reading. A **deadzone** (`±90` around -center) ignores small wobble so the ship doesn't drift at rest, and a **rising-edge** check -on the button (`btn === 1 && lastBtn === 0`) means one press fires exactly one shot. - -### Game architecture - -| Piece | Role | -|-------|------| -| **`ship`** | Position, velocity, and angle. X tilt rotates it; pushing up adds thrust along its heading. Movement uses friction + a max-speed clamp, and the ship **wraps** around screen edges. | -| **`bullets`** | Spawned from the ship's nose, inherit ship velocity, and expire after `BULLET_LIFE` frames. | -| **`rocks`** | Lumpy procedurally-generated asteroids in three sizes. Large rocks split into two smaller ones when shot; small rocks just vanish. | -| **`checkCollisions()`** | Bullet-vs-rock (score + split) and ship-vs-rock (lose a life). Smaller rocks are worth more points. | -| **State** | `'play'` / `'over'`, with `lives`, `score`, a fire cooldown, and brief **invulnerability** (the blinking ship) after respawning. | -| **`nextWave()`** | Clearing all rocks spawns a new, slightly larger wave that scales with your score. | - -### Tuning knobs - -All the feel of the game lives in a block of constants near the top, so it's easy to tweak: - -```js -const DEADZONE = 90; // ignore small wobble around center -const TURN_SPEED = 0.07; // radians per frame at full tilt -const THRUST_ACCEL = 0.12; -const FRICTION = 0.99; -const MAX_SPEED = 7; -const BULLET_SPEED = 7; -const FIRE_COOLDOWN = 10; // frames between shots -const START_LIVES = 3; -``` - -The palette constants (`C_BG`, `C_SHIP`, `C_ROCK`, `C_BULLET`) keep the firmware demo and the -game looking consistent. - ---- - -## Build & play - -1. **Verify** to compile the sketch. -2. **Upload** to your connected tinyCore. -3. Open the **Visual** tab — tilt to steer, push up to thrust, and press the button to shoot. -4. Clear every rock to advance to the next wave. Good luck! 🚀 diff --git a/demo/Joystick Example/asteroids.js b/demo/Joystick Example/asteroids.js deleted file mode 100644 index e5d7702..0000000 --- a/demo/Joystick Example/asteroids.js +++ /dev/null @@ -1,343 +0,0 @@ -// asteroids.js — Qwiic Joystick Asteroids -// Classic Asteroids driven by the SparkFun Qwiic Joystick. -// Reads the same CSV stream as the visualizer: x,y,button (x,y in 0..1023) -// Tilt LEFT / RIGHT to rotate, push UP to thrust, press the button to fire. -// -// serialRead() -> latest raw line, e.g. "512,498,0" -// serialLines() -> recent raw lines (unused here, kept for parity) -// serialEvent() -> fires per new line (kept for parity) -// -// Built for tinyStudio's harness. If the serial functions aren't present -// (e.g. a plain browser / the p5 web editor), it falls back to the keyboard -// (arrows to steer/thrust, space to fire) so you can test before wiring up. - -const RANGE = 1023; // 10-bit ADC full scale -const CENTER = RANGE / 2; // joystick resting value -const DEADZONE = 90; // ignore small wobble around center -const START_LIVES = 3; - -// ---- palette (matches the visualizer) ---- -const C_BG = [0, 0, 0]; -const C_SHIP = [255, 255, 255]; -const C_ROCK = [150, 162, 196]; -const C_BULLET = [255, 215, 0]; // gold -const C_TEXT = [150, 162, 196]; - -// ---- tuning ---- -const TURN_SPEED = 0.07; // radians per frame at full tilt -const THRUST_ACCEL = 0.12; -const FRICTION = 0.99; -const MAX_SPEED = 7; -const BULLET_SPEED = 7; -const BULLET_LIFE = 60; // frames -const FIRE_COOLDOWN = 10; // frames between shots - -let ship, bullets, rocks; -let score = 0, lives = START_LIVES; -let state = 'play'; // 'play' | 'over' -let joyX = CENTER, joyY = CENTER, lastBtn = 0; -let fireTimer = 0, invuln = 0; - -function setup() { - createCanvas(640, 440); - textFont('monospace'); - resetGame(); -} - -function resetGame() { - score = 0; - lives = START_LIVES; - state = 'play'; - bullets = []; - spawnShip(); - rocks = []; - for (let i = 0; i < 4; i++) rocks.push(makeRock()); -} - -function spawnShip() { - ship = { x: width / 2, y: height / 2, vx: 0, vy: 0, angle: -PI / 2 }; - invuln = 90; -} - -// parse "x,y,button" -> {x,y,b} or null (same regex as the visualizer) -function parseLine(s) { - let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); - if (!m) return null; - return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; -} - -// read the joystick if the harness is present, else null -function readJoystick() { - if (typeof serialRead !== 'function') return null; - return parseLine(serialRead()); -} - -function makeRock(size, x, y) { - size = size || 3; // 3 = large, 2 = medium, 1 = small - // spawn at a random edge if no position given - if (x === undefined) { - if (random() < 0.5) { x = random() < 0.5 ? 0 : width; y = random(height); } - else { x = random(width); y = random() < 0.5 ? 0 : height; } - } - let r = size * 16; - let speed = map(size, 3, 1, 0.7, 2.2); - let a = random(TWO_PI); - // give each rock a lumpy outline - let verts = []; - let n = floor(random(8, 12)); - for (let i = 0; i < n; i++) { - verts.push(random(0.75, 1.15)); - } - return { - x, y, size, r, - vx: cos(a) * speed, - vy: sin(a) * speed, - rot: random(-0.03, 0.03), - spin: 0, - verts - }; -} - -function draw() { - background(C_BG); - handleInput(); - if (state === 'play') { - updateShip(); - updateBullets(); - updateRocks(); - checkCollisions(); - if (rocks.length === 0) nextWave(); - } - drawRocks(); - drawBullets(); - drawShip(); - drawHud(); -} - -function handleInput() { - let p = readJoystick(); - let btn = 0; - - if (p) { - joyX = p.x; - joyY = p.y; - btn = p.b; - } else if (typeof serialRead !== 'function') { - // no harness: keyboard fallback - joyX = CENTER + (keyIsDown(RIGHT_ARROW) ? 400 : 0) - (keyIsDown(LEFT_ARROW) ? 400 : 0); - joyY = CENTER + (keyIsDown(UP_ARROW) ? 400 : 0); - btn = keyIsDown(32) ? 1 : 0; // space - } - - // rising-edge button: fire, or restart when game over - if (btn === 1 && lastBtn === 0) onButton(); - lastBtn = btn; -} - -function onButton() { - if (state === 'over') { - resetGame(); - } else if (fireTimer <= 0) { - fire(); - } -} - -function fire() { - bullets.push({ - x: ship.x + cos(ship.angle) * 14, - y: ship.y + sin(ship.angle) * 14, - vx: cos(ship.angle) * BULLET_SPEED + ship.vx, - vy: sin(ship.angle) * BULLET_SPEED + ship.vy, - life: BULLET_LIFE - }); - fireTimer = FIRE_COOLDOWN; -} - -function updateShip() { - if (fireTimer > 0) fireTimer--; - if (invuln > 0) invuln--; - - // X axis -> rotate (deadzone keeps it steady at rest) - let dx = joyX - CENTER; - if (abs(dx) > DEADZONE) ship.angle += (dx / CENTER) * TURN_SPEED; - - // Y axis: push UP to thrust (joystick reads larger when pushed up) - let dy = joyY - CENTER; - if (dy > DEADZONE) { - let t = (dy / CENTER) * THRUST_ACCEL; - ship.vx += cos(ship.angle) * t; - ship.vy += sin(ship.angle) * t; - } - - // clamp speed + friction - let sp = mag(ship.vx, ship.vy); - if (sp > MAX_SPEED) { ship.vx *= MAX_SPEED / sp; ship.vy *= MAX_SPEED / sp; } - ship.vx *= FRICTION; - ship.vy *= FRICTION; - - ship.x = wrap(ship.x + ship.vx, width); - ship.y = wrap(ship.y + ship.vy, height); -} - -function updateBullets() { - for (let b of bullets) { - b.x = wrap(b.x + b.vx, width); - b.y = wrap(b.y + b.vy, height); - b.life--; - } - bullets = bullets.filter(b => b.life > 0); -} - -function updateRocks() { - for (let r of rocks) { - r.x = wrap(r.x + r.vx, width); - r.y = wrap(r.y + r.vy, height); - r.spin += r.rot; - } -} - -function checkCollisions() { - // bullets vs rocks - for (let i = rocks.length - 1; i >= 0; i--) { - let rock = rocks[i]; - for (let j = bullets.length - 1; j >= 0; j--) { - let b = bullets[j]; - if (dist(b.x, b.y, rock.x, rock.y) < rock.r) { - bullets.splice(j, 1); - rocks.splice(i, 1); - score += (4 - rock.size) * 10; // small rocks worth more - splitRock(rock); - break; - } - } - } - - // ship vs rocks (ignored while invulnerable) - if (invuln <= 0) { - for (let rock of rocks) { - if (dist(ship.x, ship.y, rock.x, rock.y) < rock.r + 8) { - loseLife(); - break; - } - } - } -} - -function splitRock(rock) { - if (rock.size <= 1) return; - for (let k = 0; k < 2; k++) { - rocks.push(makeRock(rock.size - 1, rock.x, rock.y)); - } -} - -function loseLife() { - lives--; - if (lives <= 0) { - state = 'over'; - } else { - spawnShip(); - } -} - -function nextWave() { - let count = 4 + floor(score / 200); - for (let i = 0; i < count; i++) rocks.push(makeRock()); -} - -function wrap(v, max) { - if (v < 0) return v + max; - if (v > max) return v - max; - return v; -} - -// ---------- drawing ---------- - -function drawShip() { - if (state !== 'play') return; - if (invuln > 0 && frameCount % 10 < 5) return; // blink while invulnerable - - push(); - translate(ship.x, ship.y); - rotate(ship.angle); - stroke(C_SHIP); - strokeWeight(2); - noFill(); - // triangle pointing along +x (angle 0) - beginShape(); - vertex(14, 0); - vertex(-10, -9); - vertex(-5, 0); - vertex(-10, 9); - endShape(CLOSE); - - // thrust flame when pushing up - if (joyY - CENTER > DEADZONE && frameCount % 6 < 3) { - stroke(C_BULLET); - line(-5, 0, -16, 0); - } - pop(); -} - -function drawBullets() { - noStroke(); - fill(C_BULLET); - for (let b of bullets) circle(b.x, b.y, 4); -} - -function drawRocks() { - stroke(C_ROCK); - strokeWeight(2); - noFill(); - for (let r of rocks) { - push(); - translate(r.x, r.y); - rotate(r.spin); - beginShape(); - let n = r.verts.length; - for (let i = 0; i < n; i++) { - let a = (i / n) * TWO_PI; - let rad = r.r * r.verts[i]; - vertex(cos(a) * rad, sin(a) * rad); - } - endShape(CLOSE); - pop(); - } -} - -function drawHud() { - noStroke(); - textAlign(LEFT, TOP); - textSize(20); - fill(C_SHIP); - text(score, 14, 12); - - // lives as little ships - for (let i = 0; i < lives; i++) { - push(); - translate(width - 20 - i * 22, 22); - rotate(-PI / 2); - stroke(C_SHIP); - strokeWeight(2); - noFill(); - beginShape(); - vertex(9, 0); - vertex(-6, -6); - vertex(-6, 6); - endShape(CLOSE); - pop(); - } - - if (state === 'over') { - noStroke(); - textAlign(CENTER, CENTER); - textSize(28); - fill(C_SHIP); - text('GAME OVER', width / 2, height / 2 - 16); - textSize(14); - fill(C_BULLET); - text('PRESS BUTTON TO RESTART', width / 2, height / 2 + 16); - } -} - -// fires per new serial line (kept for parity with the harness) -function serialEvent(line) {} \ No newline at end of file diff --git a/demo/Joystick Example/circuit.json b/demo/Joystick Example/circuit.json deleted file mode 100644 index 856ced8..0000000 --- a/demo/Joystick Example/circuit.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "format": "tinystudio-circuit", - "version": 2, - "meta": { - "created": "2026-07-03T17:07:26.415Z" - }, - "parts": [ - { - "id": "potentiometer-rotary-16mm-5_g9d", - "type": "potentiometer-rotary-16mm-5", - "attrs": { - "label": "Potentiometer" - }, - "bb": { - "x": 710.4, - "y": -124.8, - "labelOffset": [ - 65, - -68 - ] - } - }, - { - "id": "battery-aa_2dp", - "type": "battery-aa", - "bb": { - "x": 329, - "y": -27 - } - }, - { - "id": "led-generic-5mm_fsn", - "type": "led-generic-5mm", - "bb": { - "x": 637, - "y": -57.6, - "labelOffset": [ - -42, - -67 - ] - } - }, - { - "id": "resistor_hwt", - "type": "resistor", - "bb": { - "x": 643.2, - "y": 63 - } - } - ], - "wires": [ - { - "id": "w_5m58u96x", - "from": "battery-aa_2dp:+", - "to": "led-generic-5mm_fsn:cathode", - "view": "bb", - "color": "#e5544b", - "route": [ - "h98.21", - "v-41.56", - "h-0.16" - ] - }, - { - "id": "w_hw4el0vi", - "from": "led-generic-5mm_fsn:anode", - "to": "potentiometer-rotary-16mm-5_g9d:leg1", - "view": "bb", - "color": "#42a5f5", - "route": [ - "v6.07", - "h66.91" - ] - }, - { - "id": "w_1laeiu0i", - "from": "potentiometer-rotary-16mm-5_g9d:wiper", - "to": "resistor_hwt:Pin 1", - "view": "bb", - "color": "#42a5f5", - "route": [ - "v84.36", - "h-28.32", - "v-0.27", - "h-26.96" - ] - }, - { - "id": "w_oggo6jgz", - "from": "resistor_hwt:Pin 0", - "to": "battery-aa_2dp:-", - "view": "bb", - "color": "#79818c", - "route": [ - "h-39.36", - "v-30.35", - "h-59.81" - ] - } - ] -} diff --git a/demo/Joystick Example/pong.js b/demo/Joystick Example/pong.js deleted file mode 100644 index a4d7ad2..0000000 --- a/demo/Joystick Example/pong.js +++ /dev/null @@ -1,233 +0,0 @@ -// pong.js — Qwiic Joystick Pong -// Single-player Pong driven by the SparkFun Qwiic Joystick. -// Reads the same CSV stream as the visualizer: x,y,button (x,y in 0..1023) -// Push the stick UP / DOWN to move the left paddle. Press the button to serve. -// -// serialRead() -> latest raw line, e.g. "512,498,0" -// serialLines() -> recent raw lines (unused here, kept for parity) -// serialEvent() -> fires per new line (kept for parity) -// -// Built for tinyStudio's harness. If the serial functions aren't present -// (e.g. a plain browser / the p5 web editor), it falls back to the mouse so -// you can test the game logic before wiring up hardware. - -const RANGE = 1023; // 10-bit ADC full scale -const WIN_SCORE = 7; - -// ---- palette (matches the visualizer) ---- -const C_BG = [0, 0, 0]; -const C_NET = [26, 34, 70]; -const C_PLAYER = [255, 255, 255]; // cyan -const C_AI = [255, 255, 255]; // pink -const C_BALL = [255, 215, 0]; // gold -const C_TEXT = [150, 162, 196]; - -// ---- geometry ---- -const PADDLE_W = 12; -const PADDLE_H = 84; -const PADDLE_INSET = 26; // distance of the paddle face from the wall -const BALL_R = 9; -const AI_MAX_SPEED = 5.2; // lower = easier to beat - -let player, ai, ball; -let scoreP = 0, scoreA = 0; -let state = 'serve'; // 'serve' | 'play' | 'over' -let winner = ''; -let joyY = 512, lastBtn = 0; - -function setup() { - createCanvas(640, 440); - textFont('monospace'); - player = { x: PADDLE_INSET, y: height / 2 }; - ai = { x: width - PADDLE_INSET, y: height / 2 }; - resetBall(1); -} - -// parse "x,y,button" -> {x,y,b} or null (same regex as the visualizer) -function parseLine(s) { - let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); - if (!m) return null; - return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; -} - -// read the joystick if the harness is present, else null -function readJoystick() { - if (typeof serialRead !== 'function') return null; - return parseLine(serialRead()); -} - -function resetBall(dir) { - // dir: +1 serves right (toward CPU), -1 serves left (toward you) - ball = { - x: width / 2, - y: height / 2, - vx: 4.2 * dir, - vy: random(-2.2, 2.2), - speed: 4.6 - }; -} - -function draw() { - background(C_BG); - handleInput(); - if (state === 'play') updateBall(); - drawNet(); - drawPaddles(); - drawBall(); - drawHud(); -} - -function handleInput() { - let p = readJoystick(); - let btn = 0; - - if (p) { - joyY = p.y; // fresh joystick sample - btn = p.b; - } else if (typeof serialRead !== 'function') { - // no harness: mouse Y drives the paddle, click serves - joyY = map(mouseY, height, 0, 0, RANGE, true); - btn = mouseIsPressed ? 1 : 0; - } - // (harness present but no parseable line yet -> keep last joyY, btn stays 0) - - // stick UP -> paddle UP, matching the visualizer's inverted Y - player.y = map(joyY, 0, RANGE, height - PADDLE_H / 2, PADDLE_H / 2, true); - - // rising-edge button: serve or restart - if (btn === 1 && lastBtn === 0) onButton(); - lastBtn = btn; -} - -function onButton() { - if (state === 'serve') { - state = 'play'; - } else if (state === 'over') { - scoreP = 0; - scoreA = 0; - winner = ''; - state = 'serve'; - resetBall(random() < 0.5 ? 1 : -1); - } -} - -function updateBall() { - ball.x += ball.vx; - ball.y += ball.vy; - - // top / bottom walls - if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy *= -1; } - if (ball.y + BALL_R > height) { ball.y = height - BALL_R; ball.vy *= -1; } - - bouncePaddle(player, +1); // left paddle -> ball leaves rightward - bouncePaddle(ai, -1); // right paddle -> ball leaves leftward - - // CPU: chase only while the ball approaches, else drift back to center - let targetY = ball.vx > 0 ? ball.y : height / 2; - ai.y += constrain(targetY - ai.y, -AI_MAX_SPEED, AI_MAX_SPEED); - ai.y = constrain(ai.y, PADDLE_H / 2, height - PADDLE_H / 2); - - // scoring - if (ball.x + BALL_R < 0) { scoreA++; afterPoint(-1); } // CPU scored - if (ball.x - BALL_R > width) { scoreP++; afterPoint(+1); } // you scored -} - -function bouncePaddle(pad, dir) { - // dir: +1 = paddle on the left, -1 = paddle on the right - let halfW = PADDLE_W / 2; - let halfH = PADDLE_H / 2; - - // only register a hit when the ball is heading toward this paddle - if (dir > 0 && ball.vx >= 0) return; - if (dir < 0 && ball.vx <= 0) return; - - let withinX = abs(ball.x - pad.x) <= halfW + BALL_R; - let withinY = ball.y >= pad.y - halfH - BALL_R && - ball.y <= pad.y + halfH + BALL_R; - if (!(withinX && withinY)) return; - - // reflect with angle based on where it struck the paddle, speed up a touch - ball.speed = min(ball.speed + 0.35, 11); - let offset = constrain((ball.y - pad.y) / halfH, -1, 1); // -1 (top) .. 1 (bottom) - let angle = offset * (PI / 3.2); // up to ~56° - ball.vx = ball.speed * cos(angle) * dir; - ball.vy = ball.speed * sin(angle); - - // nudge clear so it can't stick inside the paddle - ball.x = pad.x + dir * (halfW + BALL_R + 0.5); -} - -function afterPoint(scorer) { - // scorer: +1 you scored, -1 CPU scored - if (scoreP >= WIN_SCORE || scoreA >= WIN_SCORE) { - state = 'over'; - winner = scoreP > scoreA ? 'PLAYER' : 'CPU'; - return; - } - state = 'serve'; - resetBall(scorer); // serve toward whoever just conceded the point -} - -function drawNet() { - stroke(C_NET); - strokeWeight(3); - for (let y = 12; y < height; y += 26) line(width / 2, y, width / 2, y + 14); -} - -function drawPaddles() { - noStroke(); - rectMode(CENTER); - // player (cyan) with glow - fill(C_PLAYER[0], C_PLAYER[1], C_PLAYER[2], 60); - rect(player.x, player.y, PADDLE_W + 8, PADDLE_H + 8, 6); - fill(C_PLAYER); - rect(player.x, player.y, PADDLE_W, PADDLE_H, 4); - // CPU (pink) with glow - fill(C_AI[0], C_AI[1], C_AI[2], 60); - rect(ai.x, ai.y, PADDLE_W + 8, PADDLE_H + 8, 6); - fill(C_AI); - rect(ai.x, ai.y, PADDLE_W, PADDLE_H, 4); - rectMode(CORNER); -} - -function drawBall() { - noStroke(); - fill(C_BALL[0], C_BALL[1], C_BALL[2], 60); - circle(ball.x, ball.y, BALL_R * 2 + 12); - fill(C_BALL); - circle(ball.x, ball.y, BALL_R * 2); -} - -function drawHud() { - noStroke(); - textAlign(CENTER, TOP); - textSize(34); - fill(C_PLAYER); - text(scoreP, width / 2 - 60, 12); - fill(C_AI); - text(scoreA, width / 2 + 60, 12); - - textSize(11); - fill(C_TEXT); - textAlign(LEFT, TOP); - text('YOU', 14, 16); - textAlign(RIGHT, TOP); - text('CPU', width - 14, 16); - - if (state === 'serve') { - banner('PRESS BUTTON TO SERVE'); - } else if (state === 'over') { - banner((winner === 'PLAYER' ? 'YOU WIN!' : 'CPU WINS') + ' PRESS TO RESTART'); - } -} - -function banner(msg) { - noStroke(); - textAlign(CENTER, CENTER); - textSize(16); - fill(C_BALL); - text(msg, width / 2, height - 28); -} - -// fires per new serial line (kept for parity with the harness) -function serialEvent(line) {} diff --git a/demo/Joystick Example/qwiic_joystick/qwiic_joystick.ino b/demo/Joystick Example/qwiic_joystick/qwiic_joystick.ino deleted file mode 100644 index 4649e3f..0000000 --- a/demo/Joystick Example/qwiic_joystick/qwiic_joystick.ino +++ /dev/null @@ -1,42 +0,0 @@ -// qwiic_joystick — stream the SparkFun Qwiic Joystick X/Y over serial -// generated & refined with Studio AI -// -// SparkFun Qwiic Joystick (COM-15168) is an I2C analog thumbstick. Plug it into -// the tinyCore Qwiic port with a Qwiic cable — no soldering, no breadboard. -// Install "SparkFun Qwiic Joystick Arduino Library" from the Library Manager. - -#include -#include "SparkFun_Qwiic_Joystick_Arduino_Library.h" - -JOYSTICK joystick; - -void setup() { - Serial.begin(9600); - Serial.println("Hello World"); - - //Wire.begin(); - Wire.begin(3, 4); // tinyCore Qwiic bus = I2C (SDA/SCL) - - delay(100); - - if (joystick.begin() == false) { - Serial.println("Joystick not found on I2C — check the Qwiic cable. Halting."); - while (1) delay(10); - } - Serial.println("# Qwiic Joystick ready — streaming x,y,button"); -} - -void loop() { - int x = joystick.getHorizontal(); // 0..1023, center ~512 - int y = joystick.getVertical(); // 0..1023, center ~512 - int b = (joystick.getButton() == 0) ? 1 : 0; // 1 = pressed - - // CSV line the Visual tab parses: x,y,button - Serial.print(x); - Serial.print(','); - Serial.print(y); - Serial.print(','); - Serial.println(b); - - delay(50); // ~20 Hz -} diff --git a/demo/Joystick Example/visual.js b/demo/Joystick Example/visual.js deleted file mode 100644 index a7b1b74..0000000 --- a/demo/Joystick Example/visual.js +++ /dev/null @@ -1,102 +0,0 @@ -// visual.js — Qwiic Joystick X/Y grid visualizer -// Plots the joystick's live position on a grid to match what the sketch streams. -// The sketch prints one CSV line per sample: x,y,button (x,y in 0..1023) -// -// serialRead() -> latest raw line, e.g. "512,498,0" -// serialLines() -> recent raw lines (used to draw the trail) - -const RANGE = 1023; // 10-bit ADC full scale -const PAD = 46; // grid margin -let px = 512, py = 512; // last good position (rest = centered) -let pressed = false; - -function setup() { - createCanvas(440, 440); - textFont('monospace'); -} - -// parse "x,y,button" -> {x,y,b} or null if the line has no numbers -function parseLine(s) { - let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); - if (!m) return null; - return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; -} - -// joystick -> screen. Physical stick grows LEFT in X and UP in Y -// (matching the SparkFun hookup directions), so X is inverted. -function sx(x) { return map(x, RANGE, 0, PAD, width - PAD); } -function sy(y) { return map(y, 0, RANGE, height - PAD, PAD); } - -function draw() { - background(0, 0, 0); - - let p = parseLine(serialRead()); - if (p) { px = p.x; py = p.y; pressed = p.b === 1; } - - drawGrid(); - - // trail of recent samples - let lines = serialLines(); - noFill(); - stroke(0, 240, 255, 90); - strokeWeight(2); - beginShape(); - for (let i = max(0, lines.length - 60); i < lines.length; i++) { - let q = parseLine(lines[i]); - if (q) vertex(sx(q.x), sy(q.y)); - } - endShape(); - - let gx = sx(px), gy = sy(py); - - // crosshair through the live point - stroke(38, 48, 92); - strokeWeight(1); - line(PAD, gy, width - PAD, gy); - line(gx, PAD, gx, height - PAD); - - // the joystick dot (glows gold while the button is pressed) - noStroke(); - let r = pressed ? 26 : 18; - fill(255, 63, 140, 60); - circle(gx, gy, r + 16); - fill(pressed ? color(255, 215, 0) : color(255, 63, 140)); - circle(gx, gy, r); - - drawReadout(gx, gy); -} - -function drawGrid() { - stroke(26, 34, 70); - strokeWeight(1); - const cells = 8; - for (let i = 0; i <= cells; i++) { - let x = map(i, 0, cells, PAD, width - PAD); - let y = map(i, 0, cells, PAD, height - PAD); - line(x, PAD, x, height - PAD); - line(PAD, y, width - PAD, y); - } - // center cross — the ~512,512 rest position - stroke(58, 70, 120); - strokeWeight(1.5); - let cx = sx(512), cy = sy(512); - line(cx - 8, cy, cx + 8, cy); - line(cx, cy - 8, cx, cy + 8); -} - -function drawReadout() { - noStroke(); - textSize(12); - fill(150, 162, 196); - textAlign(LEFT, TOP); - text('Qwiic Joystick', 12, 12); - fill(0, 240, 255); - textAlign(RIGHT, TOP); - text('X ' + nf(px, 4) + ' Y ' + nf(py, 4), width - 12, 12); - fill(pressed ? color(255, 215, 0) : color(90, 100, 140)); - textAlign(RIGHT, BOTTOM); - text(pressed ? '\u25CF BUTTON' : '\u25CB button', width - 12, height - 12); -} - -// fires on every new serial line (kept for parity with the harness) -function serialEvent(line) {} \ No newline at end of file diff --git a/dev-app-update.yml b/dev-app-update.yml deleted file mode 100644 index 2d67f7f..0000000 --- a/dev-app-update.yml +++ /dev/null @@ -1,3 +0,0 @@ -provider: generic -url: https://example.com/auto-updates -updaterCacheDirName: tinystudio-updater diff --git a/docs/accounts-and-domains.md b/docs/accounts-and-domains.md new file mode 100644 index 0000000..2fd78e6 --- /dev/null +++ b/docs/accounts-and-domains.md @@ -0,0 +1,353 @@ +# Accounts, domains and classes + +How tinycore.cc, tinyDocs and tinyStudio share one account: + +- which domain serves what +- how tinyStudio's GitHub sign-in is registered +- the class and project features planned for later + +Every decision here was made by the owner on 2026-09-14. Only **Change now** is 0.4.0 work. + +**For the agent on `v0.4-dev`:** + +- Read **Change now** first. +- Update step 6 of `docs/release-plan.md` to match it, and link this file from there. +- Commit this file with that change. + +## Decisions + +- **One account for everything.** It's the tinyCore account: Supabase Auth in the `tinycore-cc` repo, with sign-in through GitHub or Google. + - Its session cookie is set on `.tinycore.cc`. + - So every site that needs the login lives on a `tinycore.cc` subdomain. +- **The short domains stay forever.** `tinycore.cc`, `tinystudio.cc` and `tinydocs.cc` are on QR codes and links online. They forward to the real site and keep the path. +- **tinyStudio keeps its own GitHub OAuth app** for repo access (`public_repo`). It is never shared with the GitHub app inside Supabase. +- **Student work is saved on the student's computer.** There is no cloud storage for student copies. Public GitHub repos are fine for portfolio work. +- **Grades live in Canvas.** tinyStudio and tinyDocs never store grades. + +## Domains + +| Address | Serves | Netlify site | Signed in | +| ---------------------------------------- | ----------------------------- | ------------------ | ----------------------------------- | +| `tinycore.cc` | Home and store | tinycore-cc | Yes; sets the `.tinycore.cc` cookie | +| `studio.tinycore.cc` | tinyStudio web app | tinyStudio | Yes | +| `docs.tinycore.cc` | tinyDocs | tinydocs-astro | Yes, once sign-in is added | +| `tinystudio.cc` | tinyStudio landing page | tinydocs-astro | No | +| `deploy-preview-N.preview.tinystudio.cc` | tinyStudio deploy previews | tinyStudio | No | + +These addresses forward to another site: + +| Visited | Forwards to | +| ------------------------------------------------------ | --------------------------- | +| `app.tinystudio.cc/*` | `studio.tinycore.cc/:splat` | +| `tinystudio.cc/*`, except the landing page's own pages | `studio.tinycore.cc/:splat` | +| `tinydocs.cc/*` | `docs.tinycore.cc/:splat` | +| `docs.mr.industries/*` | `docs.tinycore.cc/:splat` | + +What's live today (checked 2026-09-14): + +- **DNS.** `tinycore.cc`, `tinystudio.cc`, `tinydocs.cc` and `mr.industries` all use Netlify DNS. New subdomains, HTTPS certificates and deploy subdomains are dashboard settings. +- **tinyStudio.** `tinystudio.cc` and `app.tinystudio.cc` serve the same app, so project links like `//` exist on both. +- **tinyDocs.** `tinydocs.cc` and `docs.mr.industries` serve the same Astro site. + - The live site matches the `FacioErgoSum/tinydocs-cc` repo. The older `tinydocs-astro` repo is out of date. + - `tinydocs.cc/get-started` returns 404; that page is at `/1_get-started/`. + - The header's "Sign In" button links to `https://tinycore.cc/store`. +- **tinycore.cc.** No `tinycore.cc` subdomain exists yet; the store is at `tinycore.cc/store`. + +Rules: + +- **User-written pages stay off `tinycore.cc`.** Shared Visual previews and embeds in the project gallery never go on a `tinycore.cc` subdomain, because code there can read the session cookie. Use GitHub Pages or a domain outside `tinycore.cc`. +- **Deploy previews stay off `tinycore.cc` too.** A preview runs code that isn't merged yet. +- **Moving a domain loses what the browser saved.** Browser storage belongs to one domain, so users lose recent projects, folder permissions, custom parts, saved keys, GitHub sign-in and settings. Make `studio.tinycore.cc` the main address before GitHub sign-in in the browser reaches users, so nobody signs in twice. +- **Keep order history and accounts safe:** + - Never create a new Supabase project. + - In Supabase, only add redirect URLs; leave Site URL unchanged. + - Keep `tinycore.cc` on its current Netlify site. Stripe webhooks call `tinycore.cc/api/webhooks/stripe`. + - Keep the cookie domain `.tinycore.cc`. + +### Moving the domains + +The owner does these steps in Netlify, in this order: + +1. Add `studio.tinycore.cc` to the tinyStudio site as a domain alias. Both addresses work. +2. Test the app on `studio.tinycore.cc`, including compile and upload through tinyService. +3. Make `studio.tinycore.cc` the primary domain and add the forwarding rule for `app.tinystudio.cc`. +4. At a quiet time, move `tinystudio.cc` from the tinyStudio site to the tinydocs site. That site serves the landing page (`/tinystudio/` in the tinydocs-cc repo) and already has the forwarding rules. The new certificate can take a few minutes. +5. For docs: + - Add `docs.tinycore.cc` to the tinydocs site and make it primary. + - Forward `tinydocs.cc` and `docs.mr.industries`. + - Add a redirect for every printed short URL that doesn't exist, such as `/get-started`. + +Forwarding rules in `netlify.toml`. Domain rules go above the site's other redirects, and `force` makes them apply even when a file exists at that path: + +```toml +# tinyStudio site +[[redirects]] + from = "https://app.tinystudio.cc/*" + to = "https://studio.tinycore.cc/:splat" + status = 301 + force = true + +# tinydocs site (repeat for https://docs.mr.industries/*) +[[redirects]] + from = "https://tinydocs.cc/*" + to = "https://docs.tinycore.cc/:splat" + status = 301 + force = true + +# tinydocs site: tinystudio.cc shows the landing page at its root, loads the +# page's own files, and forwards every other path to the app +[[redirects]] + from = "https://tinystudio.cc/" + to = "/tinystudio/" + status = 200 + force = true + +[[redirects]] + from = "https://tinystudio.cc/_astro/*" + to = "/_astro/:splat" + status = 200 + force = true + +[[redirects]] + from = "https://tinystudio.cc/*" + to = "https://studio.tinycore.cc/:splat" + status = 301 + force = true +``` + +The live rules, including `favicon.png` and the `/get-started` short URL, are in tinydocs-cc's `netlify.toml`. + +## GitHub OAuth apps + +There are two apps, and they are never shared. + +**tinyCore sign-in (exists).** + +- It lives inside Supabase, for tinycore.cc. +- Its callback is Supabase's: `https://.supabase.co/auth/v1/callback`. +- It asks only for `user:email`. +- Leave it as it is. + +**tinyStudio (create now).** Repo access for Make it mine and Push, on desktop and web. + +| Field | Value | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Owner | Mister-Industries organization: Settings → Developer settings → OAuth Apps | +| Application name | `tinyStudio` | +| Homepage URL | `https://tinystudio.cc` | +| Callback URLs | See the next table | +| Enable Device Flow | On; the desktop app signs in this way | +| Scope the app requests | `public_repo` | +| Client ID | Public. It's the default in `src/main/githubAuth.ts` and `electron.vite.config.ts` | +| Client secret | Only in the tinyStudio Netlify site's environment, as `GITHUB_CLIENT_SECRET`. Never in the repo, the built app or chat | + +GitHub allows up to 10 callback URLs per app. Matching subdomains is a wildcard setting you turn on per URL. + +| Callback URL | Wildcard | Used by | +| ---------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| `https://studio.tinycore.cc/auth/github/callback` | Off | Production | +| `https://app.tinystudio.cc/auth/github/callback` | Off | Until that domain forwards; remove after | +| `https://preview.tinystudio.cc/auth/github/callback` | On | Deploy previews at `deploy-preview-N.preview.tinystudio.cc` | +| `http://localhost:5173/auth/github/callback` | Off | Local development | +| `http://localhost:5174/auth/github/callback` | Off | Local development when port 5173 is taken, as on the owner's machine | + +Why the settings are these: + +- **The path is `/auth/github/callback`, not `/auth/callback`.** tinyCore sign-in uses `/auth/callback` on every tinycore.cc site, so the two must not collide. `docs/github-auth.md` still says `/auth/callback`. +- **The 5174 entry may be unnecessary.** GitHub lets a loopback redirect use any port, though it recommends `127.0.0.1` over `localhost`. If sign-in works on a second localhost port without it, drop that entry. +- **The apps are separate so no site can silently take repo access.** + - GitHub skips the approval screen for scopes someone has already granted. With one shared app, any script on any tinycore.cc site could get a repo-write token without the person seeing a prompt. + - Separate apps also keep the secret in one place. + - They keep GitHub's token limits apart too. The limit is 10 tokens per user, app and scope, and GitHub silently revokes the oldest one. +- **The web flow still needs a Netlify function.** GitHub requires the client secret to exchange the code for a token, even when PKCE (S256) is used. +- **Deploy previews get their own address.** + - Where: the tinyStudio Netlify site → Domain management → Automatic deploy subdomains → Deploy Previews. + - Settings: domain `tinystudio.cc`, subdomain `preview`. + - This needs Netlify DNS, which `tinystudio.cc` has. + +## Change now (0.4.0) + +### Owner + +1. Create the tinyStudio GitHub OAuth app with the settings above, and send the client ID. +2. Put the client secret in the tinyStudio Netlify site's environment as `GITHUB_CLIENT_SECRET`. +3. Add `studio.tinycore.cc` to the tinyStudio Netlify site as a domain alias. +4. Turn on automatic deploy subdomains for Deploy Previews: domain `tinystudio.cc`, subdomain `preview`. +5. When 0.4.0 ships: make `studio.tinycore.cc` the primary domain and forward `app.tinystudio.cc`. + +### Step 6: changes to the release plan + +- **Origins the Netlify function accepts** (`netlify/functions/github-token.ts`): + + - `https://studio.tinycore.cc` + - `https://app.tinystudio.cc`, until it forwards + - `https://deploy-preview-.preview.tinystudio.cc` + - `http://localhost:5173` and `http://localhost:5174` + + This replaces "only `https://app.tinystudio.cc` and `http://localhost:5173`". + +- **Redirect URI.** Build `redirect_uri` from `window.location.origin` plus `/auth/github/callback`, so every host returns to itself. +- **Local development.** An OAuth app allows up to 10 callback URLs, so no second app is needed. This replaces the bullet saying an app has one callback URL. +- **Done when** `studio.tinycore.cc` and a deploy preview at `deploy-preview-N.preview.tinystudio.cc` both sign in without a pasted token. +- **Docs to update:** + - `docs/github-auth.md`: callback path, domains, where the secret lives + - `docs/web-deploy.md` + - the checklist in `docs/packaging-windows.md` + - the README + +### Domain references + +Change `app.tinystudio.cc` to `studio.tinycore.cc` in: + +- the header comment in `netlify.toml` +- the header comment in `src/renderer/src/lib/projectRouting.ts` +- `src/shared/agentGuides/tinystudio.md`, which is what Studio AI tells users +- `docs/github-auth.md`, `INTEGRATION_GUIDE.md` and `docs/release-plan.md` + +Leave links to `https://tinystudio.cc` alone: the Visual export credit and the link tests. That address becomes the landing page. + +### tinyService 1.2.0 + +Published to npm on 2026-09-18 from tinyService commit `212d1bb` (branch `release/1.2.0`). Its default allowed origins include `https://studio.tinycore.cc` and `https://app.tinystudio.cc`. tinyStudio depends on `^1.2.0`. + +- **Port check.** It opens the port on both `127.0.0.1` and all interfaces (`src/main/freePort.ts`). + - This matters because the tray app runs 1.2.0 on `127.0.0.1:3000`. + - On Windows, a check on all interfaces alone passes while the tray holds that port. +- **Allowed origins.** `allowedOrigins` in `src/main/index.ts` is `['file://', 'http://localhost:*', 'https://studio.tinycore.cc', 'https://app.tinystudio.cc']`. + +Deploy previews aren't in tinyService's default list, so compile and upload can't reach a local tinyService from a preview. Test those on `studio.tinycore.cc` or localhost. + +### Not yet + +tinyCore sign-in, the Share menu, courses, profiles and Canvas LTI are all later work, described below. + +## Later: tinyCore sign-in on studio and docs + +### Docs: built + +On the tinydocs-cc branch `tinystudio-landing` (2026-09-14): + +- **Browser only.** The docs site stays static. + - `src/lib/supabase.ts` creates the browser client. + - `src/components/AccountButton.tsx`, in the header, shows Sign In (GitHub or Google) or the signed-in account. +- **Cookie domain by host.** On `*.tinycore.cc` the session cookie goes on `.tinycore.cc`, so docs and tinycore.cc share one sign-in. On any other host, such as localhost or tinydocs.cc, it stays on that host. +- **Callback.** `/auth/callback` is a static page. + - The browser client exchanges the `?code=` itself (PKCE). + - Then the page returns to where sign-in started, which was saved in `sessionStorage`. That keeps the redirect URL exactly the one on Supabase's allow list. +- **Settings.** + - Netlify environment: `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY`, the same values tinycore-cc uses. Without them the header keeps its old link to the store. + - Supabase redirect URLs: `https://docs.tinycore.cc/auth/callback` and `http://localhost:4321/auth/callback`. +- **To fix in tinycore-cc.** `src/lib/supabase/client.ts`, the browser client, sets no cookie domain. The server and middleware clients do. + - So when the browser refreshes the token or signs out on tinycore.cc, it writes or clears a host-only cookie, and signing out there may leave docs signed in. + - The fix: pass `cookieOptions: { domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN }` to `createBrowserClient`. + +### Studio, and what both follow + +- **Same Supabase project.** Use the one in tinycore-cc, with the same URL and public anon key. + - Put the session cookie on `.tinycore.cc`, the way `tinycore-cc/src/lib/supabase/server.ts` does. + - Every table needs Row Level Security, because the anon key is public. +- **Supabase settings.** In Authentication → URL Configuration, add: + + - `https://studio.tinycore.cc/auth/callback` + - `https://docs.tinycore.cc/auth/callback` + - the localhost equivalents + + Leave Site URL unchanged. + +- **Content Security Policy.** Add `https://.supabase.co` to `connect-src` in `src/renderer/index.html`. +- **The same sign-in buttons everywhere.** Someone who signs in with GitHub in one place and Google in another, with different emails, ends up with two accounts. +- **Connect GitHub stays a separate step,** using tinyStudio's own app. Ask for it the first time someone uses Make it mine or Push. +- **Desktop app.** It signs in through the browser and comes back through a `tinystudio://` link. + +## Later: the Share button + +1. **Publish to GitHub.** + - The existing Make it mine and Push, then copy the repo link. + - The link works for a portfolio, or for a Canvas assignment set to "Website URL". +2. **Send to Canvas.** + - What it does: + - zip the project and download it + - open the Canvas assignment in a new tab, if the project link carries `?canvas=` + - the student uploads the zip + - It works at every school with no setup. + - It needs a zip writer (tinyStudio only reads zips today, in `circuit/parts/zip.ts`) and an Open .zip import for teachers. + - Build it together with "Archive (export as .zip)", 1.0 beta step 7. +3. **Publish to tinyDocs.** Needs tinyCore sign-in. It adds the project to the person's profile (see profiles below). +4. **Canvas LTI 1.3.** Build this when a school wants to pilot it. + - What students see: + - a tinyStudio tab on Canvas's Submit Assignment page + - an Open in tinyStudio button on the assignment + - How it works: + - The tab uses the `homework_submission` placement. + - tinyStudio returns the zip to Canvas as a file item, through LTI deep linking. + - What it needs: + - each school's Canvas admin approves tinyStudio once + - Netlify functions for the LTI handshake + - a few minutes of hosting for the zip, which Canvas downloads + - Other learning management systems use the Send to Canvas download and upload. + +**Skip:** having tinyStudio submit straight into Canvas's API. It still needs approval from every school, plus Canvas sign-in inside tinyStudio, which is more access than LTI asks for. + +## Later: courses on tinyDocs + +How it works: + +1. **Teacher.** + - A teacher account is approved by an admin, the way student verification works today. + - The teacher creates a course on docs.tinycore.cc. + - Each assignment gets a title, a due date, a starter project link and, optionally, a Canvas assignment URL. + - The teacher shares a join link. +2. **Student: joining and opening work.** + - Open the join link and sign in. + - Go to My courses. Assignments are sorted by due date. + - Choose Open in tinyStudio, which opens `studio.tinycore.cc///?canvas=…`. +3. **Student: turning it in.** Save the project to the computer, work on it, and submit with Send to Canvas. The teacher grades in Canvas. + +Data, in the tinycore-cc Supabase project. The table names are a starting point: + +| Table | Holds | +| ------------------- | ------------------------------------------------------------------------------------------------------------- | +| `profiles` (exists) | Add `teacher_verified` next to `student_verified` | +| `courses` | Owner, title, slug, description, join code, Canvas course URL | +| `course_members` | Course, user, role (`teacher`, `ta`, `student`) | +| `assignments` | Course, title, instructions, due date (`timestamptz`), starter project link, Canvas assignment URL, published | + +- **Not stored:** grades, submissions and student files. +- **Rosters are still school records.** Collect only name and email, and let a teacher delete a course's data. +- **Access rules:** + - teachers of a course manage it + - members can read it + - students see only their own membership +- **Later:** import rosters and due dates from Canvas over LTI. + +## Later: profiles and user projects + +- **A project entry is metadata only.** Fields: + + - owner, title, description and cover image + - GitHub repo and path + - visibility (`private`, `unlisted` or `public`) + - tags and a featured flag + + The files stay in the person's public GitHub repo, so hosting costs stay near zero. + +- **Profile pages.** A profile on docs.tinycore.cc lists the person's projects. Public projects can be curated into the User Projects tab, like Instructables. Public listing needs moderation first. +- **Check repo ownership.** Before listing a project, confirm its repo belongs to the person's connected GitHub account. +- **Live previews.** Visual previews in the gallery run from GitHub Pages or another domain outside `tinycore.cc`, in a sandboxed iframe. They never run on a tinycore.cc page. + +## Storage and cost + +- **Project size.** Measured 2026-09-14: the 70 projects in tinyStudio-examples average 11 KB; the largest is 52 KB. Compiled `firmware.bin` files are about 1.5 MB each, so build output is never stored. +- **Supabase plans.** + + - Free: 1 GB of storage and 5 GB of egress; a project pauses after a week with no activity. + - Pro: $25 a month, with 100 GB of storage. + + The plans above store only small records, not files. + +## Open questions for the owner + +- Which Supabase plan is tinycore.cc on? +- Which short URLs are printed on QR codes or packaging? `tinydocs.cc/get-started` returns 404 today. +- Who moderates public projects, and what's the minimum age for an account? +- `studio.tinycore.cc` was chosen over `app.tinycore.cc`. If that changes, update tinyService's default allowed origins and the callback URLs. diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..61767ac --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,15 @@ +# Archive + +Working notes kept for history. They describe the code as it was when they were +written, so they don't match the current app; don't use them as a guide. + +| File | What it was | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `circuit-architecture-and-roadmap.md` | Research before the circuit editor rewrite: Fritzing internals, bugs in the old editor, file formats | +| `circuit-editor-progress.md` | The implementation log for the circuit editor milestones M0 to M4 | +| `arduino-ide-comparison.md` | A feature comparison with the Arduino IDE, written before the parity work | +| `parity-implementation-notes.md` | Notes from the Arduino IDE parity work that shipped with tinyService 1.1.0 | + +Current docs: [circuit-view-tech-spec.md](../circuit-view-tech-spec.md) for the +circuit editor, and [INTEGRATION_GUIDE.md](../../INTEGRATION_GUIDE.md) for the +backend. diff --git a/docs/arduino-ide-comparison.md b/docs/archive/arduino-ide-comparison.md similarity index 70% rename from docs/arduino-ide-comparison.md rename to docs/archive/arduino-ide-comparison.md index 4a54546..0eec7f9 100644 --- a/docs/arduino-ide-comparison.md +++ b/docs/archive/arduino-ide-comparison.md @@ -1,4 +1,4 @@ -# tinyStudio vs. Arduino IDE 2.x — Core Functionality Comparison +# tinyStudio vs. Arduino IDE 2.x: Core Functionality Comparison **Date:** July 2, 2026 **Scope:** Core IDE functionality only (editing, building, uploading, board/port handling, serial monitor, library/board management). The Visual and Circuit views are intentionally excluded. @@ -10,7 +10,7 @@ tinyStudio covers a solid slice of the Arduino IDE's core loop: verify, upload, serial monitor, connected-board detection, a Library Manager, and a Boards Manager with additional-URL support. The largest functional gaps are in **code intelligence** (Arduino IDE runs a full clangd-based language server; tinyStudio has a regex syntax highlighter), **board/port architecture** (Arduino IDE has an event-driven port watcher, per-port monitor services, board options, programmers, and manual board selection; tinyStudio has a polled snapshot with a combined board+port dropdown), and the **serial monitor pipeline** (Arduino IDE uses arduino-cli's gRPC monitor API with pause/resume around uploads and persisted per-port settings; tinyStudio spawns and kills `arduino-cli monitor` CLI processes and filters its text output). -The device-detection and monitor-switching behavior you've noticed is not a tuning issue — the two products use fundamentally different mechanisms. Arduino IDE talks to a long-lived `arduino-cli daemon` over gRPC and *subscribes* to port add/remove events (`BoardListWatch`), so plug/unplug appears within ~100 ms. tinyStudio shells out to `arduino-cli board list` on a timer that **stops polling once one board is found**, so unplugs and port changes go unnoticed until a manual refresh. Details in §3. +The device-detection and monitor-switching behavior you've noticed is not a tuning issue: the two products use fundamentally different mechanisms. Arduino IDE talks to a long-lived `arduino-cli daemon` over gRPC and *subscribes* to port add/remove events (`BoardListWatch`), so plug/unplug appears within ~100 ms. tinyStudio shells out to `arduino-cli board list` on a timer that **stops polling once one board is found**, so unplugs and port changes go unnoticed until a manual refresh. Details in §3. Several concrete state bugs were found in tinyStudio's serial path, including a false "connected" status sent before the port actually opens, a filter that swallows the port-busy error message, a stale process-close handler that can delete the *new* monitor's registration, and an auto tab-switch that hides compile errors 400 ms after a failed build. Details in §5. @@ -20,15 +20,15 @@ Several concrete state bugs were found in tinyStudio's serial path, including a This difference explains most of the behavioral gaps, so it's worth stating plainly. -**Arduino IDE 2.x** starts `arduino-cli daemon` once and keeps a persistent **gRPC** connection to it (`node/arduino-daemon-impl.ts`, `node/core-client-provider.ts`). Every operation — compile, upload, board list, monitor — is a gRPC call or a long-lived gRPC *stream* against that daemon. Streams matter: the daemon pushes port events and monitor data to the IDE the instant they happen, requests carry structured protobuf payloads (no text parsing), progress is reported natively, and operations are cancellable. +**Arduino IDE 2.x** starts `arduino-cli daemon` once and keeps a persistent **gRPC** connection to it (`node/arduino-daemon-impl.ts`, `node/core-client-provider.ts`). Every operation (compile, upload, board list, monitor) is a gRPC call or a long-lived gRPC *stream* against that daemon. Streams matter: the daemon pushes port events and monitor data to the IDE the instant they happen, requests carry structured protobuf payloads (no text parsing), progress is reported natively, and operations are cancellable. -**tinyStudio** spawns a fresh `arduino-cli ` child process per operation (`tinyservice/services/arduino-cli.service.js`) and parses its stdout — JSON for lists, raw text for everything else. The renderer talks to tinyService over a local WebSocket, and responses are correlated to requests **by action name only** — there are no request IDs (`WebSocketArduinoService.waitForResponse()`). The CLI-per-call model means every board scan pays process startup cost, there is no push channel for hardware events, upload progress must be faked (see Bug 9), and two in-flight requests with the same action name will cross-talk. +**tinyStudio** spawns a fresh `arduino-cli ` child process per operation (`tinyservice/services/arduino-cli.service.js`) and parses its stdout: JSON for lists, raw text for everything else. The renderer talks to tinyService over a local WebSocket, and responses are correlated to requests **by action name only**; there are no request IDs (`WebSocketArduinoService.waitForResponse()`). The CLI-per-call model means every board scan pays process startup cost, there is no push channel for hardware events, upload progress must be faked (see Bug 9), and two in-flight requests with the same action name will cross-talk. -This isn't a demand to rewrite on gRPC — arduino-cli's daemon mode is exactly what it exists for, and adopting it (or at least `board list --watch`, which streams JSON events over stdout **without** the daemon) would close the biggest gaps at moderate cost. +This isn't a demand to rewrite on gRPC; arduino-cli's daemon mode is exactly what it exists for, and adopting it (or at least `board list --watch`, which streams JSON events over stdout **without** the daemon) would close the biggest gaps at moderate cost. --- -## 3. Device Detection — Why Arduino IDE Feels Better +## 3. Device Detection: Why Arduino IDE Feels Better **Arduino IDE** (`node/board-discovery.ts`): a singleton backend service opens a `BoardListWatch` gRPC stream at startup. The CLI's pluggable discoveries (serial, mDNS, etc.) push `add`/`remove` events; the IDE debounces them for 100 ms, diffs against current state, and broadcasts to the frontend. Consequences: @@ -39,23 +39,23 @@ This isn't a demand to rewrite on gRPC — arduino-cli's daemon mode is exactly - If a detected board's platform isn't installed, the IDE prompts to install it (`boards-auto-installer.ts`). - Ports with no identifiable board can still be used: the "Select other board and port" dialog lets the user manually pair any FQBN with any port (`boards-config-dialog.tsx`), and that pairing is remembered. -**tinyStudio** (`useArduino.ts`, `arduino-cli.service.js#listBoards`): runs `arduino-cli board list` as a one-shot snapshot. Polling only runs **while zero boards are known** (every 8 s) and stops as soon as one is found — the comment in `useArduino.ts:585` says plug/unplug after that "is picked up by the manual Refresh or the next operation." On top of that, `WebSocketArduinoService.listBoards()` throttles to one real scan per 5 s and silently serves a cached list otherwise. Consequences, all of which match the symptoms you described: +**tinyStudio** (`useArduino.ts`, `arduino-cli.service.js#listBoards`): runs `arduino-cli board list` as a one-shot snapshot. Polling only runs **while zero boards are known** (every 8 s) and stops as soon as one is found; the comment in `useArduino.ts:585` says plug/unplug after that "is picked up by the manual Refresh or the next operation." On top of that, `WebSocketArduinoService.listBoards()` throttles to one real scan per 5 s and silently serves a cached list otherwise. Consequences, all of which match the symptoms you described: - Unplugging the selected board is never detected; the UI keeps showing it as connected until an upload fails. - Swapping boards, or a board re-enumerating on a different COM port after flash, is invisible until manual refresh. - Clicking Refresh within 5 s of the last scan animates the spinner but returns the stale cache. -- Unknown VID/PID devices are guessed by a hardcoded table (`identifyByUsb`) — any Espressif VID `303A` is labeled a tinyCore, and CP210x bridges produce a board with an **empty FQBN** that will fail to compile if selected. There is no manual board-to-port pairing to correct a wrong guess. +- Unknown VID/PID devices are guessed by a hardcoded table (`identifyByUsb`): any Espressif VID `303A` is labeled a tinyCore, and CP210x bridges produce a board with an **empty FQBN** that will fail to compile if selected. There is no manual board-to-port pairing to correct a wrong guess. - Nothing is persisted; board selection resets every launch. -**Recommendation:** replace the poll with `arduino-cli board list --watch --format json` (a long-lived child process that streams add/remove events as JSON lines — no gRPC required), keep the port list in tinyService as the single source of truth, and push changes to the renderer over the existing WebSocket. Remove the 5 s cache once events are push-based. Add a "choose board for this port" flow instead of the `identifyByUsb` guess table. +**Recommendation:** replace the poll with `arduino-cli board list --watch --format json` (a long-lived child process that streams add/remove events as JSON lines, no gRPC required), keep the port list in tinyService as the single source of truth, and push changes to the renderer over the existing WebSocket. Remove the 5 s cache once events are push-based. Add a "choose board for this port" flow instead of the `identifyByUsb` guess table. --- -## 4. Serial Monitor — Why Arduino IDE Switches Cleanly +## 4. Serial Monitor: Why Arduino IDE Switches Cleanly **Arduino IDE** (`node/monitor-manager.ts`, `node/monitor-service.ts`): each board+port combination gets its own `MonitorService` keyed by `fqbn-address-protocol`, using arduino-cli's **gRPC monitor stream** (not the `monitor` CLI command). Key behaviors: -- **Upload coordination is first-class.** `notifyUploadStarted()` *pauses* the monitor (releases the port, keeps the service and its buffer/state alive); `notifyUploadFinished()` resumes it — and if the board came back on a *different* port, it stops the old service and starts a queued one on the new port. Monitor open requests that arrive mid-upload are queued, then started only after verifying the board is still present. +- **Upload coordination is first-class.** `notifyUploadStarted()` *pauses* the monitor (releases the port, keeps the service and its buffer/state alive); `notifyUploadFinished()` resumes it, and if the board came back on a *different* port, it stops the old service and starts a queued one on the new port. Monitor open requests that arrive mid-upload are queued, then started only after verifying the board is still present. - **Settings are real and persisted.** Baud rates come from the board's pluggable monitor (`enumerate_port_settings`), so the list is board-appropriate rather than hardcoded. Baud, line ending (None/NL/CR/Both NL&CR), autoscroll, and timestamps are persisted per port (`node/monitor-settings/`, `browser/monitor-model.ts`). - **Raw data fidelity.** The stream delivers bytes as-is to the frontend; timestamps are applied at render time; nothing is trimmed or filtered. - There is also a **Serial Plotter** fed by the same monitor stream (`browser/serial/plotter/`). @@ -63,9 +63,9 @@ This isn't a demand to rewrite on gRPC — arduino-cli's daemon mode is exactly **tinyStudio** (`tinyservice/handlers/serial.handler.js`, `SerialContext.tsx`): opening the monitor spawns an `arduino-cli monitor -p ` child process; closing kills it (`taskkill /T /F` on Windows). The renderer keeps one app-level connection (`SerialProvider`) that auto-opens whenever a board is selected. Functional gaps against the IDE: - Only five hardcoded baud rates (9600–115200); missing 300–4800, 19200, 74880 (ESP boot messages), 230400, 460800, 921600, 2000000, and anything board-specific. -- No line-ending choice — every send unconditionally appends `\n` (`serial.handler.js:12`). +- No line-ending choice: every send unconditionally appends `\n` (`serial.handler.js:12`). - No timestamps option; no persistence of baud/autoscroll across restarts (baud resets to 9600). -- Output is line-ified and sanitized: every chunk is split on newlines, each line is trimmed, blank lines are dropped, and any line starting with `Connected to`, `Disconnected`, etc. is discarded — so sketches that print those words, print leading whitespace, use `\r`-based progress output, or use `Serial.print()` without newline get visibly distorted output. +- Output is line-ified and sanitized: every chunk is split on newlines, each line is trimmed, blank lines are dropped, and any line starting with `Connected to`, `Disconnected`, etc. is discarded, so sketches that print those words, print leading whitespace, use `\r`-based progress output, or use `Serial.print()` without newline get visibly distorted output. - No Serial Plotter. - The port is held continuously while a board is selected (auto-open on selection). This also DTR-resets the board every time the app starts or the port reopens. Arduino IDE only opens the port while the monitor is actually in use. - Because open/close is kill-and-respawn of a CLI process rather than pause/resume of a service, every upload/baud-change/reconnect pays process startup, reset, and the race conditions described in §5. @@ -77,37 +77,37 @@ This isn't a demand to rewrite on gRPC — arduino-cli's daemon mode is exactly These are concrete defects observed in the current code, roughly ordered by impact. Items 1–5 together explain the "weird COM port state" you've been seeing. **1. "Connected" is reported before the port actually opens.** -`serial.handler.js:70-74` sends `{opened: true}` ("Listening on PORT @ BAUD") immediately after `spawn()`, before arduino-cli has opened the port. If the port is busy or gone, the UI flashes connected, then flips to disconnected when the child exits — with no error shown, because of Bug 2. +`serial.handler.js:70-74` sends `{opened: true}` ("Listening on PORT @ BAUD") immediately after `spawn()`, before arduino-cli has opened the port. If the port is busy or gone, the UI flashes connected, then flips to disconnected when the child exits, with no error shown, because of Bug 2. **2. The output filter swallows the actual error messages.** -The same handler's `emit()` filter (lines 36–43) deliberately drops lines containing `Port monitor error` and `command 'open' failed`. Those are exactly the lines arduino-cli prints when the port is busy or missing. Net effect: a failed monitor open is completely silent — combined with Bug 1, the user sees "connected… disconnected" with no explanation. +The same handler's `emit()` filter (lines 36–43) deliberately drops lines containing `Port monitor error` and `command 'open' failed`. Those are exactly the lines arduino-cli prints when the port is busy or missing. Net effect: a failed monitor open is completely silent; combined with Bug 1, the user sees "connected… disconnected" with no explanation. **3. Stale close-handler can delete the *new* monitor's registration (Windows-biased race).** -`SerialHandler.close()` kills the old child and resolves on its `exit` event **or a 1.5 s fallback timeout** — on Windows the kill is an async `taskkill /T /F` whose result is ignored. If the old process outlives that window (slow taskkill, exclusive-port teardown), the new monitor is spawned and stored in `this.monitors` under the same connection ID; when the old child finally dies, its still-attached `close` handler runs `this.monitors.delete(connection.id)` — deleting the **new** child's entry — and pushes `{closed: true}` to the client. Result: UI shows disconnected while data still streams, `serial-write` silently no-ops (no child found), and the orphaned entry can't be closed cleanly. While the old process lingers it also still holds the COM port, so the new spawn hits Bug 1 + Bug 2. Fix: key handlers to the specific child (compare `this.monitors.get(id) === child` before deleting/emitting), and don't reuse the connection ID as the sole key. +`SerialHandler.close()` kills the old child and resolves on its `exit` event **or a 1.5 s fallback timeout**. On Windows the kill is an async `taskkill /T /F` whose result is ignored. If the old process outlives that window (slow taskkill, exclusive-port teardown), the new monitor is spawned and stored in `this.monitors` under the same connection ID; when the old child finally dies, its still-attached `close` handler runs `this.monitors.delete(connection.id)` (deleting the **new** child's entry) and pushes `{closed: true}` to the client. Result: UI shows disconnected while data still streams, `serial-write` silently no-ops (no child found), and the orphaned entry can't be closed cleanly. While the old process lingers it also still holds the COM port, so the new spawn hits Bug 1 + Bug 2. Fix: key handlers to the specific child (compare `this.monitors.get(id) === child` before deleting/emitting), and don't reuse the connection ID as the sole key. **4. Unplug/replug is undetected while a board is selected.** -`useArduino.ts:585-592` — the 8 s poll only runs when `boards.length === 0`. Once a board is listed, hardware changes are invisible (see §3). The stale selection then feeds uploads to a dead port and monitor opens to a dead port (which fail silently per Bugs 1–2). +`useArduino.ts:585-592`: the 8 s poll only runs when `boards.length === 0`. Once a board is listed, hardware changes are invisible (see §3). The stale selection then feeds uploads to a dead port and monitor opens to a dead port (which fail silently per Bugs 1–2). **5. Refresh within 5 s returns cached results while claiming a fresh scan.** `WebSocketArduinoService.listBoards()` throttle (5 s) returns `cachedBoardList` silently; `refreshBoards()` still logs "Scanning for connected boards… Found N board(s)". The user's replugged board doesn't appear even though they explicitly asked for a rescan. **6. Failed compile output is hidden 400 ms after the build finishes.** -`SerialMonitor.tsx:38-45` — when `isCompiling || isUploading` goes false, the panel switches back to the Serial tab after 400 ms **unconditionally**, including on failure. The user gets a red toast, but the Output pane with the actual compiler errors is yanked away. It also overrides the tab the user selected manually. Arduino IDE keeps the Output panel up and additionally renders errors inline in the editor. Fix: only auto-return to Serial on *success*, and never if the user manually switched tabs during the build. +`SerialMonitor.tsx:38-45`: when `isCompiling || isUploading` goes false, the panel switches back to the Serial tab after 400 ms **unconditionally**, including on failure. The user gets a red toast, but the Output pane with the actual compiler errors is yanked away. It also overrides the tab the user selected manually. Arduino IDE keeps the Output panel up and additionally renders errors inline in the editor. Fix: only auto-return to Serial on *success*, and never if the user manually switched tabs during the build. **7. Response cross-talk: no request IDs on the WebSocket protocol.** `waitForResponse()` matches replies by action string alone. Two overlapping `lib-search` calls (easy to trigger by typing in the Library Manager), or `list-boards` from two code paths, will interleave and resolve each other's promises with mixed output. Needs a per-request ID echoed by tinyService. **8. Every `tinyCore:*` FQBN is collapsed to one board variant.** -`normalizeTinyCoreFqbn()` (`WebSocketArduinoService.ts:80-85`) rewrites *any* detected tinyCore FQBN to `tiny_core_esp32s3_nopsram`, and `identifyByUsb()` claims every VID `303A` (all native-USB Espressif chips) as a tinyCore. Any future tinyCore variant — or any other ESP32-S2/S3/C3 board — gets compiled for the wrong target with no way to override. Related: CP210x devices are surfaced with `fqbn: ""`, which is selectable and then fails at compile time. +`normalizeTinyCoreFqbn()` (`WebSocketArduinoService.ts:80-85`) rewrites *any* detected tinyCore FQBN to `tiny_core_esp32s3_nopsram`, and `identifyByUsb()` claims every VID `303A` (all native-USB Espressif chips) as a tinyCore. Any future tinyCore variant (or any other ESP32-S2/S3/C3 board) gets compiled for the wrong target with no way to override. Related: CP210x devices are surfaced with `fqbn: ""`, which is selectable and then fails at compile time. **9. Upload progress bar is fake.** -`useArduino.ts:418-432` — a `setInterval` bumps the percentage by 10 every 200 ms up to 90%. It reflects wall-clock time, not the actual flash. arduino-cli reports real upload progress (the IDE consumes it via gRPC); at minimum, esptool's stdout percentages could be parsed from the streamed output. +`useArduino.ts:418-432`: a `setInterval` bumps the percentage by 10 every 200 ms up to 90%. It reflects wall-clock time, not the actual flash. arduino-cli reports real upload progress (the IDE consumes it via gRPC); at minimum, esptool's stdout percentages could be parsed from the streamed output. **10. Timeout "success sniffing" doesn't match your own boards.** -`waitForResponse()`'s timeout fallback (lines 195–211) infers a successful upload from `avrdude done` / `Upload complete` — AVR-only strings. ESP32 uploads (tinyCore's own target) end with esptool's `Hash of data verified` / `Hard resetting`, so a slow-but-successful ESP32 upload that hits the 90 s timeout is misreported as a failure. +`waitForResponse()`'s timeout fallback (lines 195–211) infers a successful upload from `avrdude done` / `Upload complete`: AVR-only strings. ESP32 uploads (tinyCore's own target) end with esptool's `Hash of data verified` / `Hard resetting`, so a slow-but-successful ESP32 upload that hits the 90 s timeout is misreported as a failure. **11. Stale-closure bugs in the timeout-recovery paths.** -`compileSketch` reads `lastCompileResult` (line 337) and `uploadSketch` reads `lastUploadResult` (line 501), but neither is in its `useCallback` dependency array — the recovery logic always sees the value from when the callback was created. Low impact (only affects the timeout paths), but worth fixing while in there. +`compileSketch` reads `lastCompileResult` (line 337) and `uploadSketch` reads `lastUploadResult` (line 501), but neither is in its `useCallback` dependency array; the recovery logic always sees the value from when the callback was created. Low impact (only affects the timeout paths), but worth fixing while in there. **12. tinyService binds port 3000 with no fallback.** `ServiceManager` hardcodes `ws://localhost:3000`. If any other dev server holds 3000, the backend fails and the app reports the agent as offline. A port scan + handoff of the chosen port to the renderer (it already supports a `tinyservice.url` override) would fix this. @@ -196,7 +196,7 @@ Legend: ✅ present · 🟡 partial · ❌ missing. "Ignoring" cloud accounts an | Index auto-update + progress UI | ✅ | 🟡 update on URL add only | | Open sketch in external editor / reveal in OS | ✅ | ❌ | -*(Not itemized: Theia platform niceties — command palette, keyboard shortcut editor, i18n, interface scaling. Real, but lower priority.)* +*(Not itemized: Theia platform niceties such as command palette, keyboard shortcut editor, i18n, interface scaling. Real, but lower priority.)* --- @@ -204,8 +204,8 @@ Legend: ✅ present · 🟡 partial · ❌ missing. "Ignoring" cloud accounts an 1. **Switch board detection to `arduino-cli board list --watch --format json`** streamed from tinyService, and delete the 8 s poll + 5 s cache. This single change fixes Bugs 4–5 and closes most of the perceived detection gap. (§3) 2. **Fix the serial monitor lifecycle** (Bugs 1–3): only report `opened` after arduino-cli confirms the port, stop filtering error lines (surface them as `error` messages instead), and bind child-process event handlers to the specific child instance. Consider arduino-cli's daemon/gRPC monitor as the longer-term replacement for kill-and-respawn. -3. **Stop hiding failed builds** (Bug 6): keep the Output tab up on failure; parse `file:line:col` from compiler output and set Monaco markers so errors appear in the editor — this is cheap with `monaco.editor.setModelMarkers` and gets you 70% of the IDE's inline-error value. -4. **Add request IDs to the tinyService protocol** (Bug 7) — prerequisite for everything else being reliable under concurrency. +3. **Stop hiding failed builds** (Bug 6): keep the Output tab up on failure; parse `file:line:col` from compiler output and set Monaco markers so errors appear in the editor; this is cheap with `monaco.editor.setModelMarkers` and gets you 70% of the IDE's inline-error value. +4. **Add request IDs to the tinyService protocol** (Bug 7): prerequisite for everything else being reliable under concurrency. 5. **Board options + manual board selection**: expose FQBN config options (arduino-cli reports them via `board details`) and a "choose board for this port" dialog; remove the `303A → tinyCore` assumption and the FQBN collapse (Bug 8). 6. **Monitor UX parity**: full/board-derived baud list, line-ending selector, timestamps, persist settings per port. All small, all frontend + one handler. 7. **Adopt the Arduino Language Server** (`arduino-language-server` + clangd, both distributed as binaries like arduino-cli): Monaco speaks LSP via `monaco-languageclient`. This is the single biggest step toward "everything Arduino IDE does" in the editor. diff --git a/docs/circuit-architecture-and-roadmap.md b/docs/archive/circuit-architecture-and-roadmap.md similarity index 54% rename from docs/circuit-architecture-and-roadmap.md rename to docs/archive/circuit-architecture-and-roadmap.md index 10af7be..7521494 100644 --- a/docs/circuit-architecture-and-roadmap.md +++ b/docs/archive/circuit-architecture-and-roadmap.md @@ -1,12 +1,12 @@ -# Circuit Design Document — Fritzing vs. tinyStudio, Fritzing Part Import, and the Road to KiCad +# Circuit Design Document: Fritzing vs. tinyStudio, Fritzing Part Import, and the Road to KiCad **Status:** Draft for review · **Date:** 2026-07-02 · **Scope:** `fritzing-app` (upstream reference) and `tinyStudio` Circuit view (`DiagramEditor.tsx`, `wireRouting.ts`, `circuitNets.ts`, `partsLibrary.ts`, `PartsEditor.tsx`, `scripts/fritzing-import.mjs`) -This document explains how Fritzing's breadboard/wiring/parts architecture works, how tinyStudio's circuit view works today, the exact differences between them, how a Fritzing part can be imported directly, the bugs and gaps in the current circuit designer, and a phased roadmap toward "the best breadboard view ever" with schematic generation and KiCad export — while keeping `diagram.json` Wokwi-compatible. +This document explains how Fritzing's breadboard/wiring/parts architecture works, how tinyStudio's circuit view works today, the exact differences between them, how a Fritzing part can be imported directly, the bugs and gaps in the current circuit designer, and a phased roadmap toward "the best breadboard view ever" with schematic generation and KiCad export, while keeping `diagram.json` Wokwi-compatible. --- -## Part I — How Fritzing works +## Part I: How Fritzing works Fritzing is a Qt/C++ desktop app built on `QGraphicsScene`. One electrical **model** drives four synchronized **views** (icon, breadboard, schematic, PCB). Everything below is verified against the `fritzing-app` source in this workspace. @@ -14,38 +14,38 @@ Fritzing is a Qt/C++ desktop app built on `QGraphicsScene`. One electrical **mod A Fritzing part is an XML file (`.fzp`) plus up to four SVG files (one per view). The `.fzp` contains: -- **Metadata** — `moduleId` (unique id), title, label, author, date, tags, `taxonomy`, description. -- **Properties** — a `family` (parts in the same family are hot-swappable in the Inspector) plus free-form key/value pairs (`resistance`, `package`, `voltage` …). -- **Views** — for each of `iconView` / `breadboardView` / `schematicView` / `pcbView`, an `image` path to an SVG and one or more `layer` ids (`breadboard`, `schematic`, `copper0`, `copper1`, `silkscreen` …). PCB parts reference multiple layers inside a single SVG. -- **Connectors** — each `` has an `id` (`connector0` …), a human `name` (`V+`, `GND`), a `type` (**male** or **female** — this drives breadboard behavior, see §1.3), optional ERC metadata (``, current/voltage hints), and per-view bindings: - - `svgId` — the id of the graphic element in that view's SVG that *is* the pin (e.g. `connector0pin`). - - `terminalId` (optional) — a smaller element marking the exact wire-attach point (used mostly in schematic view; without it the terminal defaults to the center of the `svgId` element). - - `legId` (optional) — for parts with bendable rubber-band legs (LEDs, resistors in breadboard view), the SVG element that is the flexible leg. -- **Buses** — `` declares internal connectivity: a `` lists `` pins that are permanently connected inside the part. **This is how a breadboard works in Fritzing**: the breadboard part has hundreds of female connectors, and each 5-hole row (and each power rail) is a bus. -- **Subparts** (schematic only) — multi-unit parts (e.g. quad op-amps) that can be placed as separate schematic symbols. +- **Metadata**: `moduleId` (unique id), title, label, author, date, tags, `taxonomy`, description. +- **Properties**: a `family` (parts in the same family are hot-swappable in the Inspector) plus free-form key/value pairs (`resistance`, `package`, `voltage` …). +- **Views**: for each of `iconView` / `breadboardView` / `schematicView` / `pcbView`, an `image` path to an SVG and one or more `layer` ids (`breadboard`, `schematic`, `copper0`, `copper1`, `silkscreen` …). PCB parts reference multiple layers inside a single SVG. +- **Connectors**: each `` has an `id` (`connector0` …), a human `name` (`V+`, `GND`), a `type` (**male** or **female**; this drives breadboard behavior, see §1.3), optional ERC metadata (``, current/voltage hints), and per-view bindings: + - `svgId`: the id of the graphic element in that view's SVG that *is* the pin (e.g. `connector0pin`). + - `terminalId` (optional): a smaller element marking the exact wire-attach point (used mostly in schematic view; without it the terminal defaults to the center of the `svgId` element). + - `legId` (optional): for parts with bendable rubber-band legs (LEDs, resistors in breadboard view), the SVG element that is the flexible leg. +- **Buses**: `` declares internal connectivity: a `` lists `` pins that are permanently connected inside the part. **This is how a breadboard works in Fritzing**: the breadboard part has hundreds of female connectors, and each 5-hole row (and each power rail) is a bus. +- **Subparts** (schematic only): multi-unit parts (e.g. quad op-amps) that can be placed as separate schematic symbols. -Key insight: **the `.fzp` stores no pin coordinates.** Geometry lives entirely in the SVGs; the `.fzp` binds connector ids to SVG element ids. Any importer must resolve those elements inside the SVG (including ancestor `transform`s) to get pin positions — which is exactly what `scripts/fritzing-import.mjs` does. +Key insight: **the `.fzp` stores no pin coordinates.** Geometry lives entirely in the SVGs; the `.fzp` binds connector ids to SVG element ids. Any importer must resolve those elements inside the SVG (including ancestor `transform`s) to get pin positions, which is exactly what `scripts/fritzing-import.mjs` does. Parts ship as `.fzpz` (a ZIP of the `.fzp` + its SVGs); sketches ship as `.fzz` (a ZIP containing a `.fz` XML plus any non-core parts used). ### 1.2 The scene/item architecture -- `ModelPart` / `ModelPartShared` — the in-memory part (shared data parsed once from the `.fzp`). -- `ItemBase` → `PaletteItem` → concrete classes (`Breadboard`, `Resistor`, `Wire`, `SymbolPaletteItem` for net labels/power symbols, `Dip`, `MysteryPart` …) — a `QGraphicsItem` per part *per view*. `PartFactory` picks the class from the moduleId/family. -- `ConnectorItem` — a `QGraphicsRectItem` child of each part for every connector in every view; handles hover highlight, hit testing, and holds the list of `connectedTo` ConnectorItems. -- `Bus` / `BusShared` — internal-connection groups from the `.fzp`. -- `SketchWidget` (≈10,800 lines) — the shared view controller; `BreadboardSketchWidget`, `SchematicSketchWidget`, `PcbSketchWidget` subclass it with per-view rules (`ignoreFemale()`, `canDropModelPart()`, trace widths, colors, grid defaults: 0.1 in breadboard/schematic, 0.05 in PCB). +- `ModelPart` / `ModelPartShared`: the in-memory part (shared data parsed once from the `.fzp`). +- `ItemBase` → `PaletteItem` → concrete classes (`Breadboard`, `Resistor`, `Wire`, `SymbolPaletteItem` for net labels/power symbols, `Dip`, `MysteryPart` …): a `QGraphicsItem` per part *per view*. `PartFactory` picks the class from the moduleId/family. +- `ConnectorItem`: a `QGraphicsRectItem` child of each part for every connector in every view; handles hover highlight, hit testing, and holds the list of `connectedTo` ConnectorItems. +- `Bus` / `BusShared`: internal-connection groups from the `.fzp`. +- `SketchWidget` (≈10,800 lines): the shared view controller; `BreadboardSketchWidget`, `SchematicSketchWidget`, `PcbSketchWidget` subclass it with per-view rules (`ignoreFemale()`, `canDropModelPart()`, trace widths, colors, grid defaults: 0.1 in breadboard/schematic, 0.05 in PCB). - Every mutation is a `QUndoCommand` on a shared undo stack, and commands carry a **CrossViewType** so an action in one view replays into the others. ### 1.3 Breadboard view mechanics (the part to learn from) -The breadboard view has no special "breadboard mode" — it emerges from a few composable rules: +The breadboard view has no special "breadboard mode": it emerges from a few composable rules: -1. **Male vs. female connectors.** Breadboard holes are *female*; component pins and wire ends are *male*. When a part is dropped or moved, `findConnectorsUnder()` hit-tests each of its male ConnectorItems against female ConnectorItems beneath it (`Breadboard::canFindConnectorsUnder()` returns false — the board itself never searches; things land *on it*). Overlap ⇒ an implicit connection is formed, with no wire drawn. Move the part away ⇒ the connection dissolves (`disconnectFromFemale`). +1. **Male vs. female connectors.** Breadboard holes are *female*; component pins and wire ends are *male*. When a part is dropped or moved, `findConnectorsUnder()` hit-tests each of its male ConnectorItems against female ConnectorItems beneath it (`Breadboard::canFindConnectorsUnder()` returns false: the board itself never searches; things land *on it*). Overlap ⇒ an implicit connection is formed, with no wire drawn. Move the part away ⇒ the connection dissolves (`disconnectFromFemale`). 2. **Buses make rows live.** Inserting a pin into one hole connects it to the whole 5-hole row because the row is a bus in the breadboard's `.fzp`. Power rails are long buses. 3. **Stickiness.** The breadboard is *sticky* (`ItemBase::m_sticky`): parts dropped on it are recorded in `m_stickyList` and move with it when the board is dragged. -4. **Wires with bendpoints.** A user wire is a chain of `Wire` items — each `Wire` is one straight `QGraphicsLineItem` segment with a ConnectorItem at each end; a bendpoint is where two segments share a position. `Wire::collectChained()` walks the chain to find true endpoints. Breadboard wires may also carry **Bézier curvature** per segment (`` control points in the sketch file) for the pretty curved-jumper look. Dragging from any male/female connector spawns a temp wire (`createTempWireForDragging`); dropping on a legal target commits it. -5. **Rubber-band legs.** Parts with `legId` connectors (LED, resistor) have draggable legs that stretch to reach holes — the leg is part geometry, not a wire. +4. **Wires with bendpoints.** A user wire is a chain of `Wire` items: each `Wire` is one straight `QGraphicsLineItem` segment with a ConnectorItem at each end; a bendpoint is where two segments share a position. `Wire::collectChained()` walks the chain to find true endpoints. Breadboard wires may also carry **Bézier curvature** per segment (`` control points in the sketch file) for the pretty curved-jumper look. Dragging from any male/female connector spawns a temp wire (`createTempWireForDragging`); dropping on a legal target commits it. +5. **Rubber-band legs.** Parts with `legId` connectors (LED, resistor) have draggable legs that stretch to reach holes; the leg is part geometry, not a wire. 6. **Ratsnest sync.** Every connect/disconnect calls `ratsnestConnect()`; other views receive **VirtualWire** ratsnest lines grouping equal-potential connectors, which the user then routes properly in that view (schematic wires, PCB traces). This is the bridge that turns a breadboard layout into a schematic to route. ### 1.4 Nets, ERC and routing @@ -53,8 +53,8 @@ The breadboard view has no special "breadboard mode" — it emerges from a few c - Equal-potential collection is a flood fill (`collectEqualPotential`) across: wire connections, female/male stacking, part-internal buses, and same-net symbols (net labels, power symbols via `symbolpaletteitem`). - `RoutingStatus` tracks nets total / routed / connectors-to-route per view. - ERC data (`erc.h`, connector `etype`, current/voltage) supports a basic electrical rule check and drives the newer simulation module (`src/simulation`, ngspice-based). -- PCB view has a full maze autorouter + DRC (`src/autoroute`), Gerber export (`svg2gerber`), and ground-plane generation. Schematic/breadboard have no autorouter — humans route; only the ratsnest guides them. -- `src/svg/kicad2svg` imports **KiCad → Fritzing** (modules and schematic symbols → part SVGs). There is no KiCad *export* in Fritzing — a gap tinyStudio can leapfrog. +- PCB view has a full maze autorouter + DRC (`src/autoroute`), Gerber export (`svg2gerber`), and ground-plane generation. Schematic/breadboard have no autorouter: humans route; only the ratsnest guides them. +- `src/svg/kicad2svg` imports **KiCad → Fritzing** (modules and schematic symbols → part SVGs). There is no KiCad *export* in Fritzing, a gap tinyStudio can leapfrog. ### 1.5 Sketch file format (`.fz` inside `.fzz`) @@ -62,7 +62,7 @@ XML `` with `` (per-view background/grid settings) and `` wrappers), then apply the **cumulative ancestor transform chain** and scale into canvas px. +3. For each connector, find the `svgId`/`terminalId` element (schematic prefers `terminalId`, the precise attach point; breadboard prefers `svgId`), compute its **local anchor** (rect center, circle center, path/polygon bbox center, line far-end heuristic, first-child for `` wrappers), then apply the **cumulative ancestor transform chain** and scale into canvas px. 4. Emit `PartDef` JSON + manifest entry + `_report.json` (ok / partial / failed / skipped). -So the conversion math (the genuinely hard part: FZP→pixels with transforms and units) is done and validated. What's missing is productization — see Part IV. +So the conversion math (the genuinely hard part: FZP→pixels with transforms and units) is done and validated. What's missing is productization; see Part IV. -### 2.4 Wiring engine (`wireRouting.ts` — ported from the tinySchematic prototype) +### 2.4 Wiring engine (`wireRouting.ts`, ported from the tinySchematic prototype) - **Absolute bendpoints.** A wire's stored instruction list is decoded into interior bend positions *fixed in world space*. When a part moves, only the leg(s) touching it re-anchor (`collectFrozen` → `buildWirePoints(source, bends, target)`); the wire body holds still. This mirrors Fritzing's behavior, where bendpoints are their own objects. - **Orthogonal elbow routing.** `calculateOrthogonalPath` builds L-paths; the final segment approaches the pin along the major travel axis so wires dock perpendicular. Hold Shift for straight/diagonal mode (`d` instructions). @@ -111,7 +111,7 @@ So the conversion math (the genuinely hard part: FZP→pixels with transforms an ### 2.5 Net model (`circuitNets.ts`) -Union-find (DSU) over endpoint keys with three edge types: physical connections, part-internal **buses** (via an optional `busesFor` callback — **currently never supplied by the editor**, see bug B8), and junction endpoints lying within 2 px of another wire's polyline. Output maps pins/connections → net index; drives the yellow equipotential hover glow and the "Nets: N" counter. This is a faithful miniature of Fritzing's `collectEqualPotential`. +Union-find (DSU) over endpoint keys with three edge types: physical connections, part-internal **buses** (via an optional `busesFor` callback, **currently never supplied by the editor**, see bug B8), and junction endpoints lying within 2 px of another wire's polyline. Output maps pins/connections → net index; drives the yellow equipotential hover glow and the "Nets: N" counter. This is a faithful miniature of Fritzing's `collectEqualPotential`. ### 2.6 Rendering & UX @@ -119,7 +119,7 @@ Parts are absolutely-positioned `
`s with `dangerouslySetInnerHTML` SVG bodi --- -## Part III — Architecture comparison +## Part III: Architecture comparison | Aspect | Fritzing | tinyStudio Circuit view | |---|---|---| @@ -135,13 +135,13 @@ Parts are absolutely-positioned `
`s with `dangerouslySetInnerHTML` SVG bodi | Undo | Full QUndoStack, cross-view | **None** (every drag frame rewrites the file buffer) | | ERC/simulation | ERC types per connector; ngspice simulation | None (Wokwi compat is the intended simulation path) | | PCB | Full view, maze autorouter, DRC, Gerber | Out of scope (KiCad export is the plan) | -| KiCad | Import only (KiCad→Fritzing SVG) | None yet — export is roadmap Phase 4 | +| KiCad | Import only (KiCad→Fritzing SVG) | None yet; export is roadmap Phase 4 | | Rubber-band legs | Yes (`legId`) | No | | Part swapping | Family-based Inspector swap | No | --- -## Part IV — Importing a Fritzing part directly into the Circuit view +## Part IV: Importing a Fritzing part directly into the Circuit view ### 4.1 Why it already (mostly) just works @@ -161,82 +161,82 @@ Because pin coordinates are resolved to Wokwi pixel space at import time, an imp ### 4.2 What's still missing for "no difficulty" in-app import 1. **`.fzpz` drag-and-drop in the app.** Today import is a build-time Node script against a repo checkout. Ship the same logic in the renderer (it's pure XML/string work; bundle `@xmldom/xmldom` or use the browser `DOMParser`, and `fflate` for the ZIP): drop a `.fzpz` onto the palette → unzip → parse `.fzp` → resolve pins from the bundled SVGs → `registerPart()` → **persist** (see B7). The only script feature needing care is path resolution (`.fzpz` bundles its SVGs with prefixed names like `svg.breadboard.foo.svg`, so section-searching isn't needed). -2. **Buses.** The importer drops `` entirely. Emit `buses: string[][]` (groups of pin names) into `PartDef` and pass a `busesFor` callback to `buildNets` (the parameter already exists). Without this, any imported part with internal common pins — most importantly *a breadboard* — nets wrongly. +2. **Buses.** The importer drops `` entirely. Emit `buses: string[][]` (groups of pin names) into `PartDef` and pass a `busesFor` callback to `buildNets` (the parameter already exists). Without this, any imported part with internal common pins (most importantly *a breadboard*) nets wrongly. 3. **Female connectors.** Add `pinType: 'male' | 'female'` per pin. Needed for drop-to-connect breadboard behavior (Phase 2) and for better wire-end semantics; harmless otherwise. 4. **Bendable legs (`legId`).** Currently ignored; leg pins resolve to the drawn leg end, which is acceptable. Real rubber-band legs are a Phase 2 renderer feature; importer just needs to tag `leg: true` on those pins. 5. **SVG hygiene at import time** (fixes live rendering *and* PNG export): - - **Namespace all `id`s** (`-`) — Fritzing SVGs freely reuse ids (`connector0pin`, gradient ids), and multiple inlined parts on one page currently collide (bug B6). + - **Namespace all `id`s** (`-`): Fritzing SVGs freely reuse ids (`connector0pin`, gradient ids), and multiple inlined parts on one page currently collide (bug B6). - Inline `class`-based styles (some parts carry ` - - - - - - - - - - - - - - ✓ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/resources/icon.png b/resources/icon.png index 40f90ae..744532f 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/scripts/README.md b/scripts/README.md index fd23917..3fb3749 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,15 +1,50 @@ +# Parts scripts + +Parts live in the [tinyparts](https://github.com/Mister-Industries/tinyparts) +repo, checked out next to this one (`../tinyparts`). The guide to where things are +and how to edit them is [docs/parts-and-art.md](../docs/parts-and-art.md). + +| Command | What it does | +| --- | --- | +| `npm run parts:check` | Validate every pack in `../tinyparts` with the app's own loaders (`--fix` repairs listings and JSON formatting, `--pack ` checks one) | +| `npm run parts:new -- --pack ` | Scaffold a folder part (`part.json` + starter `breadboard.svg`) | +| `node scripts/parts-tool.mjs explode --pack ` | Turn a pack's single-file JSON parts into editable folders (`--only a,b` for some) | +| `node scripts/parts-tool.mjs explode --from --pack --new "Name"` | Build a pack from a folder of PartDef JSON (e.g. the Fritzing importer's output) | +| `npm run parts:sync` | Copy the `"bundled": true` packs into `src/renderer/src/assets/tinyparts/` (do this before a release) | + +All of them take `--repo ` to use a different checkout. + +--- + +# Studio AI guide screenshots + +Studio AI's `read_guide` tool returns screenshots along with the guide text +(`src/shared/agentGuides/screens/`). Re-capture them when the UI changes enough +to make them misleading: + +``` +npm run dev:web +npx electron scripts/capture-agent-screens.mjs http://localhost:5173 +``` + +It drives the web build in an offscreen Electron window: the Code, Circuit and +Visual views on example projects, plus the `visual-js.md` reference sketch +rendered in the light and dark themes. Look at the images before committing; +the captions in `src/shared/agentGuides/index.ts` describe what's in each one. + # Fritzing → tinyStudio parts importer `fritzing-import.mjs` converts Fritzing parts (`.fzp` + SVGs) into tinyStudio's -per-part JSON library, with pin coordinates in pixels @ 96 DPI so the result is -**Wokwi `diagram.json` compatible**. +PartDef JSON, with pin coordinates in pixels @ 96 DPI so the result is +**Wokwi `diagram.json` compatible**. `parts-tool.mjs explode` then turns that +output into tinyparts folders. ## Prerequisites - The [`fritzing-parts`](https://github.com/fritzing/fritzing-parts) repo cloned locally. By default the script looks for it at `../fritzing-parts` (next to this repo). Use `--src` to point elsewhere. -- Run from the tinyStudio repo root. No install step — it uses `@xmldom/xmldom`, +- Run from the tinyStudio repo root. No install step: it uses `@xmldom/xmldom`, already a dependency. ## How it works @@ -24,38 +59,29 @@ units into pixels: pin_px = (coord_vb - viewBoxMin) * (realWidthPx / viewBoxWidth) ``` -Output lands in `src/renderer/src/assets/parts/`: +Output lands in `tmp/fritzing-import/` by default (git-ignored): ``` -.json one flat file per part (lazy-loaded by the editor) -index.json lightweight manifest: palette icons + family grouping +.json one file per part +index.json manifest: palette icons + family grouping _report.json per-part ok / partial / failed / skipped ``` -(Files are flat — family grouping lives in the manifest, because Vite's dynamic -imports can only vary one path segment.) - -The editor reads `index.json` for the palette and lazy-loads each part's JSON -only when it's placed — so importing the whole catalogue doesn't bloat startup. - ## Usage ```bash # See all options node scripts/fritzing-import.mjs --help -# Test a handful first (recommended) — exact filename match, no .fzp extension +# A handful first: exact filename match, no .fzp extension node scripts/fritzing-import.mjs --only resistor,LED-generic-5mm --views breadboard,schematic -# A curated starter set (what ships by default) -node scripts/fritzing-import.mjs --clean --views breadboard,schematic \ - --only "resistor,LED-generic-3mm,LED-generic-5mm,Pushbutton,capacitor_ceramic_100mil,capacitor_electrolytic_medium,potentiometer_rotary_16mm_5,diode_1N4001_300mil,transistor_signal_NPN_TO92_EBC,Buzzer-v15,servo,battery-AA" - # From a list file (one basename / moduleId per line) node scripts/fritzing-import.mjs --list my-parts.txt -# The entire core library (~1,800 parts) — both views -node scripts/fritzing-import.mjs --all --views breadboard,schematic +# Then into tinyparts, as editable folders +node scripts/parts-tool.mjs explode --from tmp/fritzing-import --pack core +npm run parts:check ``` ### Options @@ -63,23 +89,20 @@ node scripts/fritzing-import.mjs --all --views breadboard,schematic | Flag | Default | Meaning | |------|---------|---------| | `--src ` | `../fritzing-parts` | Path to the cloned fritzing-parts repo | -| `--out ` | `src/renderer/src/assets/parts` | Output directory | +| `--out ` | `tmp/fritzing-import` | Output directory | | `--views ` | `breadboard` | Views to extract: `breadboard`, `schematic` | -| `--only ` | — | Comma list of `.fzp` basenames / moduleIds | -| `--list ` | — | File with one basename / moduleId per line | -| `--all` | — | Import every `.fzp` in `/core` | +| `--only ` | - | Comma list of `.fzp` basenames / moduleIds | +| `--list ` | - | File with one basename / moduleId per line | +| `--all` | - | Import every `.fzp` in `/core` | | `--limit ` | ∞ | Stop after `n` parts (safety while testing) | | `--clean` | merge | Wipe the output dir first instead of merging | Notes: - `--only` matches an **exact** filename first; if none matches it falls back to substring (so `--only resistor` gives just `resistor.fzp`). -- Without `--clean`, runs **merge** into the existing manifest, so you can import - more parts incrementally. - Parts where a pin can't be resolved are marked `partial` in `_report.json` - (they still import — just check the flagged pins). - -## After importing - -Restart `npm run dev` (or rebuild). New parts appear in the Circuit view's -Components rail, grouped by family. + (they still import; just check the flagged pins). +- Fritzing parts keep **fixed** pin positions in `part.json` (their SVG ids + aren't `pin-*`). To make one follow its art, rename its pad shapes + `pin-` and switch `pins` to a list; see + [parts-and-art.md](../docs/parts-and-art.md#pins). diff --git a/scripts/capture-agent-screens.mjs b/scripts/capture-agent-screens.mjs new file mode 100644 index 0000000..51978aa --- /dev/null +++ b/scripts/capture-agent-screens.mjs @@ -0,0 +1,186 @@ +/** + * capture-agent-screens.mjs: refresh the screenshots bundled with Studio AI's + * guides (src/shared/agentGuides/screens/). Run after UI changes that would + * make them misleading. + * + * npm run dev:web (one terminal) + * npx electron scripts/capture-agent-screens.mjs [url] (default http://localhost:5173) + * + * Drives the web build in a hidden Electron window: + * app-code.jpg blink-basic example, Code view + * app-circuit.jpg same project, Circuit view + * app-visual.jpg an example without a visual.js, so the Visual view + * seeds the default sketch, fed with test serial data + * visual-reference.jpg the reference sketch from visual-js.md, rendered in + * the app's light and dark tokens side by side + * + * tinycore-pinout.png is the official render from Mister-Industries/tinyCore + * (assets/Renders/tinyCoreV2Pinout.png), not captured here. + */ + +import { app, BrowserWindow, nativeImage } from 'electron' +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outDir = join(root, 'src/shared/agentGuides/screens') +const base = (process.argv.find((a) => /^https?:\/\//.test(a)) ?? 'http://localhost:5173').replace( + /\/$/, + '' +) +const EXAMPLES = '/Mister-Industries/tinyStudio-examples/basics' +const WIDTH = 1280 +const HEIGHT = 800 + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function waitFor(win, expr, timeout = 45000) { + const end = Date.now() + timeout + while (Date.now() < end) { + if (await win.webContents.executeJavaScript(`!!(${expr})`).catch(() => false)) return + await sleep(250) + } + throw new Error(`Timed out waiting for: ${expr}`) +} + +function clickButton(win, label) { + return win.webContents.executeJavaScript(`(() => { + const el = [...document.querySelectorAll('button, [role="tab"]')] + .find((b) => b.textContent.trim() === ${JSON.stringify(label)}) + if (!el) throw new Error('No button labelled ' + ${JSON.stringify(label)}) + el.click() + })()`) +} + +async function save(image, file) { + const sized = + image.getSize().width > WIDTH ? image.resize({ width: WIDTH, quality: 'best' }) : image + writeFileSync(join(outDir, file), sized.toJPEG(85)) + console.log(`wrote ${file}`, sized.getSize()) +} + +async function openExample(win, name) { + await win.loadURL(`${base}${EXAMPLES}/${name}`) + await waitFor( + win, + `document.querySelector('.monaco-editor') && document.body.innerText.includes('${name}.ino')` + ) + await sleep(2000) +} + +/** Serial test data: a slow wave with some wobble, like a light sensor. */ +const FEED_SERIAL = `window.__feed = setInterval(() => { + const t = Date.now() / 700; + const v = Math.round(2048 + 1300 * Math.sin(t) + 280 * Math.sin(t * 3.7)); + const b = window.__tinySerial || (window.__tinySerial = { lines: [], values: [], last: '', value: 0 }); + b.lines = [...b.lines.slice(-300), String(v)]; + b.values = [...b.values.slice(-300), v]; + b.last = String(v); + b.value = v; +}, 50)` + +/** Render the visual-js guide's reference sketch in light and dark, side by side. */ +function referenceSketchScript() { + const guide = readFileSync(join(root, 'src/shared/agentGuides/visual-js.md'), 'utf8') + const code = /## Reference sketch[\s\S]*?```js\n([\s\S]*?)```/.exec(guide)?.[1] + if (!code) throw new Error('No reference sketch found in visual-js.md') + const themeSrc = readFileSync(join(root, 'src/renderer/src/lib/sketchTheme.ts'), 'utf8') + const tokens = Object.fromEntries( + [ + .../SKETCH_THEME_TOKENS = \{([\s\S]*?)\}/.exec(themeSrc)[1].matchAll(/(\w+): '(--[\w-]+)'/g) + ].map((m) => [m[1], m[2]]) + ) + + return `(async () => { + const root = document.documentElement; + const was = root.className; + const readTheme = (mode) => { + root.classList.remove('light', 'dark'); + root.classList.add(mode); + const css = getComputedStyle(root); + const theme = { font: 'Plus Jakarta Sans', mono: 'Fira Code', dark: mode === 'dark' }; + for (const [k, v] of Object.entries(${JSON.stringify(tokens)})) theme[k] = css.getPropertyValue(v).trim(); + theme.series = ['blue', 'green', 'purple', 'red', 'yellow'].map((k) => theme[k]); + return theme; + }; + await document.fonts.ready; + const render = (theme) => { + const host = document.createElement('div'); + host.style.cssText = 'position:fixed;left:-9999px;top:0'; + document.body.appendChild(host); + const factory = new Function('p', 'theme', 'with(p){ function serialAvailable(){ return true; }\\n' + + ${JSON.stringify(code)} + + '\\n p.setup = setup; p.draw = draw; p.serialEvent = serialEvent; }'); + const inst = new window.p5((p) => { + factory(p, theme); + const setup = p.setup; + p.setup = () => { p.pixelDensity(1); setup(); p.noLoop(); }; + }, host); + inst.serialEvent('state:on'); + for (let i = 0; i < 120; i++) { + inst.serialEvent(String(Math.round(2048 + 1300 * Math.sin(i / 9) + 280 * Math.sin(i / 2.4)))); + } + for (let i = 0; i < 90; i++) inst.redraw(); + const canvas = host.querySelector('canvas'); + return { canvas, done: () => { inst.remove(); host.remove(); } }; + }; + const light = render(readTheme('light')); + const dark = render(readTheme('dark')); + root.className = was; + const gap = 24; + const out = document.createElement('canvas'); + out.width = 480 * 2 + gap * 3; + out.height = 480 + gap * 2; + const ctx = out.getContext('2d'); + ctx.fillStyle = '#cfd5dc'; + ctx.fillRect(0, 0, out.width, out.height); + ctx.drawImage(light.canvas, gap, gap, 480, 480); + ctx.drawImage(dark.canvas, gap * 2 + 480, gap, 480, 480); + light.done(); + dark.done(); + return out.toDataURL('image/png'); + })()` +} + +async function main() { + // Offscreen rendering: a plain hidden window barely runs requestAnimationFrame, + // so p5 sketches would draw only a few frames before the capture. + const win = new BrowserWindow({ + width: WIDTH, + height: HEIGHT, + show: false, + webPreferences: { offscreen: true, backgroundThrottling: false } + }) + win.webContents.setFrameRate(60) + + // Light theme, as most users see it. + await win.loadURL(base) + await win.webContents.executeJavaScript(`localStorage.setItem('vite-ui-theme', 'light')`) + + await openExample(win, 'blink-basic') + await save(await win.webContents.capturePage(), 'app-code.jpg') + + await clickButton(win, 'Circuit') + await sleep(5000) + await save(await win.webContents.capturePage(), 'app-circuit.jpg') + + await openExample(win, 'blink-external-led') + await clickButton(win, 'Visual') + await waitFor(win, `document.querySelector('canvas')`) + await win.webContents.executeJavaScript(FEED_SERIAL) + await sleep(6000) + await save(await win.webContents.capturePage(), 'app-visual.jpg') + + const reference = await win.webContents.executeJavaScript(referenceSketchScript()) + await save(nativeImage.createFromDataURL(reference), 'visual-reference.jpg') +} + +app.whenReady().then(() => + main() + .then(() => app.quit()) + .catch((e) => { + console.error(e) + app.exit(1) + }) +) diff --git a/scripts/fetch-arduino-cli.mjs b/scripts/fetch-arduino-cli.mjs index ba9a1dd..d724bbe 100644 --- a/scripts/fetch-arduino-cli.mjs +++ b/scripts/fetch-arduino-cli.mjs @@ -16,11 +16,19 @@ // .tar.gz. On Windows we call System32\tar.exe explicitly so we don't // accidentally pick up Git Bash's GNU tar (which can't read .zip and mis-parses // drive-letter paths). On Linux, GNU tar can't read .zip, so building the -// Windows artifact there would need `unzip` — but Windows installers are built +// Windows artifact there would need `unzip`, but Windows installers are built // on Windows, where this works out of the box. import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, copyFileSync, chmodSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + copyFileSync, + chmodSync +} from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -62,7 +70,8 @@ async function download(url, dest) { async function fetchOne(platform) { const target = TARGETS[platform] - if (!target) throw new Error(`Unknown platform "${platform}". Valid: ${Object.keys(TARGETS).join(', ')}`) + if (!target) + throw new Error(`Unknown platform "${platform}". Valid: ${Object.keys(TARGETS).join(', ')}`) const outDir = join(vendorRoot, platform) const outBin = join(outDir, target.bin) @@ -111,7 +120,7 @@ function hostPlatform() { } async function main() { - // `current` (or `--current`) resolves to just this machine's platform — handy + // `current` (or `--current`) resolves to just this machine's platform, handy // for development, where you only need to run the app locally. With no args we // fetch every platform (what packaging needs; see electron-builder.yml). const requested = process.argv diff --git a/scripts/fetch-language-server.mjs b/scripts/fetch-language-server.mjs index 74ad4e2..ef8456d 100644 --- a/scripts/fetch-language-server.mjs +++ b/scripts/fetch-language-server.mjs @@ -148,7 +148,9 @@ async function main() { .slice(2) .map((a) => (a === 'current' || a === '--current' ? hostPlatform() : a)) const platforms = requested.length ? requested : Object.keys(TARGETS) - console.log(`Fetching arduino-language-server (nightly) + clangd ${CLANGD_VERSION} for: ${platforms.join(', ')}`) + console.log( + `Fetching arduino-language-server (nightly) + clangd ${CLANGD_VERSION} for: ${platforms.join(', ')}` + ) for (const p of platforms) { await fetchOne(p) } diff --git a/scripts/fritzing-import.mjs b/scripts/fritzing-import.mjs index ada66fe..0620def 100644 --- a/scripts/fritzing-import.mjs +++ b/scripts/fritzing-import.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * fritzing-import.mjs — convert Fritzing parts (.fzp + SVGs) into tinyStudio's + * fritzing-import.mjs: convert Fritzing parts (.fzp + SVGs) into tinyStudio's * per-part JSON library (Wokwi-pixel compatible). * * What it does * ------------ * For each Fritzing part it reads the .fzp (metadata + connector list) and the - * referenced view SVG(s). Fritzing does NOT store pin coordinates in the .fzp — + * referenced view SVG(s). Fritzing does NOT store pin coordinates in the .fzp; * it only names an svgId / terminalId. We resolve the real X/Y *inside the SVG*, * accumulating ancestor transforms, then scale viewBox units into pixels at * 96 DPI (the Wokwi convention) so the output drops straight into diagram.json. @@ -14,12 +14,13 @@ * pin_px = (coord_vb - viewBoxMin) * (realWidthPx / viewBoxWidth) * realWidthPx = toPx(svg width attr): in*96 · mm*3.7795 · cm*37.795 · pt*1.333 · px*1 * - * Output (default → src/renderer/src/assets/parts): + * Output (default → tmp/fritzing-import; then `node scripts/parts-tool.mjs + * explode --from tmp/fritzing-import --pack ` turns it into tinyparts folders): * //.json one file per part (lazy-loaded by the editor) * /index.json lightweight manifest (palette + grouping) * /_report.json per-part ok / partial / failed / skipped * - * Usage — see `node scripts/fritzing-import.mjs --help`. + * Usage: see `node scripts/fritzing-import.mjs --help`. */ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync } from 'node:fs' @@ -30,7 +31,7 @@ import { DOMParser } from '@xmldom/xmldom' const __dirname = dirname(fileURLToPath(import.meta.url)) const REPO = resolve(__dirname, '..') -const PX_PER_MM = 96 / 25.4 // 3.7795 — 96 DPI +const PX_PER_MM = 96 / 25.4 // 3.7795, 96 DPI // ── CLI ───────────────────────────────────────────────────────────────────── @@ -62,7 +63,7 @@ function parseArgs(argv) { } const HELP = ` -fritzing-import — Fritzing parts → tinyStudio JSON library +fritzing-import: Fritzing parts → tinyStudio JSON library node scripts/fritzing-import.mjs [options] @@ -70,7 +71,7 @@ Options --src Path to the cloned fritzing-parts repo (default: ../fritzing-parts next to tinyStudio) --out Output directory - (default: src/renderer/src/assets/parts) + (default: tmp/fritzing-import) --views Comma list of views to extract: breadboard,schematic (default: breadboard) --only Comma list of .fzp basenames or moduleIds to import @@ -349,7 +350,7 @@ function extractView(fzp, view, srcRoot) { const rel = fzp.viewImage[tag] if (!rel) return { skip: 'no view image' } - // image path is like "breadboard/foo.svg" — resolve under svg/
/ + // image path is like "breadboard/foo.svg"; resolve under svg/
/ // Try core first, then user/contrib/obsolete sections. const candidates = ['core', 'user', 'contrib', 'obsolete'].map((sec) => join(srcRoot, 'svg', sec, rel) @@ -432,7 +433,7 @@ function extractView(fzp, view, srcRoot) { usedNames.add(n) pins[n] = [round(canvas.x), round(canvas.y)] // bendable rubber-band leg (LED/resistor class parts, breadboard view - // only — cv.legId is only ever set there): tag the pin so the editor + // only; cv.legId is only ever set there): tag the pin so the editor // knows it can be dragged independent of the part body (Placement.legs). if (cv.legId) legPins.push(n) } @@ -529,7 +530,8 @@ function main() { } const srcRoot = resolve(args.src || resolve(REPO, '..', 'fritzing-parts')) - const outRoot = resolve(args.out || join(REPO, 'src', 'renderer', 'src', 'assets', 'parts')) + // a staging folder: explode it into tinyparts with scripts/parts-tool.mjs + const outRoot = resolve(args.out || join(REPO, 'tmp', 'fritzing-import')) const coreDir = join(srcRoot, 'core') if (!existsSync(coreDir)) { @@ -612,7 +614,7 @@ function main() { const bb = def.views.breadboard?.svg || def.views.schematic?.svg || '' def.icon = extractIcon(fzp, srcRoot) || (bb.length < 4000 ? bb : null) || undefined - // write per-part file — flat directory (Vite dynamic-import variables only + // write per-part file: flat directory (Vite dynamic-import variables only // support a single path segment, so family lives in the manifest, not dirs) writeFileSync(join(outRoot, `${type}.json`), JSON.stringify(def, null, 2)) @@ -642,7 +644,7 @@ function main() { console.log('\n') console.log(`✓ ok ${report.ok.length}`) - console.log(`~ partial ${report.partial.length} (some pins unresolved — see _report.json)`) + console.log(`~ partial ${report.partial.length} (some pins unresolved; see _report.json)`) console.log(`✗ failed ${report.failed.length}`) console.log(`· skipped ${report.skipped.length}`) console.log(`\nManifest : ${indexPath} (${parts.length} parts total)`) diff --git a/scripts/gen-icon.mjs b/scripts/gen-icon.mjs index bf4c77a..56a90fe 100644 --- a/scripts/gen-icon.mjs +++ b/scripts/gen-icon.mjs @@ -1,7 +1,7 @@ // Regenerates the tinyStudio app icons. // // The monogram letterforms are the real Plus Jakarta Sans (ExtraBold) glyph -// outlines — the app's UI font — converted to vector paths so the icon has no +// outlines (the app's UI font) converted to vector paths so the icon has no // runtime font dependency. The composed master is written to build/icon.svg, // then rasterized to the platform icons electron-builder consumes from build/. // @@ -12,7 +12,7 @@ // (download once from github.com/google/fonts ofl/plusjakartasans). import { createRequire } from 'node:module' -import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { writeFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' const req = createRequire(import.meta.url) @@ -26,7 +26,8 @@ try { process.exit(1) } -const FONT = process.env.JAKARTA_TTF || 'C:/Users/Geoff McIntyre/AppData/Local/Temp/icongen/jakarta.ttf' +const FONT = + process.env.JAKARTA_TTF || 'C:/Users/Geoff McIntyre/AppData/Local/Temp/icongen/jakarta.ttf' const buildDir = resolve(process.cwd(), 'build') // --- letterforms: real Plus Jakarta Sans ExtraBold glyph outlines --------- @@ -35,7 +36,6 @@ if (!existsSync(FONT)) { process.exit(1) } const font = fontkit.openSync(FONT).getVariation({ wght: 800 }) -const upm = font.unitsPerEm function glyph(ch) { const run = font.layout(ch) @@ -102,7 +102,10 @@ writeFileSync(resolve(buildDir, 'icon.svg'), svg + '\n') // --- rasterize ------------------------------------------------------------- const png1024 = await sharp(Buffer.from(svg)).resize(1024, 1024).png().toBuffer() writeFileSync(resolve(buildDir, 'icon.png'), png1024) -writeFileSync(resolve(buildDir, 'icon.ico'), png2icons.createICO(png1024, png2icons.BICUBIC, 0, false)) +writeFileSync( + resolve(buildDir, 'icon.ico'), + png2icons.createICO(png1024, png2icons.BICUBIC, 0, false) +) writeFileSync(resolve(buildDir, 'icon.icns'), png2icons.createICNS(png1024, png2icons.BICUBIC, 0)) console.log('wrote build/icon.svg, icon.png, icon.ico, icon.icns') diff --git a/scripts/make-pack-index.mjs b/scripts/make-pack-index.mjs index 1352d5d..0c4f06c 100644 --- a/scripts/make-pack-index.mjs +++ b/scripts/make-pack-index.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * make-pack-index.mjs — wrap a scripts/fritzing-import.mjs output directory + * make-pack-index.mjs: wrap a scripts/fritzing-import.mjs output directory * into a tinyStudio parts pack (pack.json + parts/*.json) plus a repo-level * index.json, ready to publish as the raw contents of a GitHub repo. See * docs/tinyparts-pack-setup.md for the full walkthrough. * * fritzing-import.mjs already writes flat per-part JSON + its own index.json - * manifest ({ parts: [{ type, file, ... }] }) — this script just re-shapes + * manifest ({ parts: [{ type, file, ... }] }); this script just re-shapes * that manifest into circuit/parts/packs.ts's pack.json format and copies * the part files alongside it, merging into (or creating) the repo's * top-level index.json so multiple packs can coexist. @@ -54,7 +54,7 @@ if (args.help || !args.parts || !args.out || !args.id || !args.name || !args.ver const manifestPath = join(args.parts, 'index.json') if (!existsSync(manifestPath)) { - console.error(`No index.json in ${args.parts} — run scripts/fritzing-import.mjs first.`) + console.error(`No index.json in ${args.parts}. Run scripts/fritzing-import.mjs first.`) process.exit(1) } const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) @@ -95,4 +95,6 @@ else index.packs.push(entry) writeFileSync(indexPath, JSON.stringify(index, null, 2)) console.log(`Wrote ${packDir}/pack.json (${parts.length} parts)`) -console.log(`Updated ${indexPath} (${index.packs.length} pack${index.packs.length === 1 ? '' : 's'} listed)`) +console.log( + `Updated ${indexPath} (${index.packs.length} pack${index.packs.length === 1 ? '' : 's'} listed)` +) diff --git a/scripts/parts-tool.mjs b/scripts/parts-tool.mjs new file mode 100644 index 0000000..8774525 --- /dev/null +++ b/scripts/parts-tool.mjs @@ -0,0 +1,485 @@ +#!/usr/bin/env node +/** + * parts-tool.mjs: maintenance commands for a tinyparts checkout. + * The author-facing guide is docs/parts-and-art.md. + * + * npm run parts:check validate every pack (art, pins, listings) + * npm run parts:check -- --fix …and repair pack.json / index.json listings + * npm run parts:new -- my-sensor --pack core [--label "My Sensor"] + * scaffold a new folder part + * node scripts/parts-tool.mjs explode --pack sparkfun-led + * turn a pack's single-file JSON parts into + * editable folders (part.json + .svg files) + * node scripts/parts-tool.mjs explode --from src/.../parts --pack core --new "Core" + * …or build a new pack from a folder of PartDef JSON + * + * Common flags: --repo the tinyparts checkout (default ../tinyparts) + * + * `check` reuses the app's own loaders (circuit/parts/folderPart.ts), bundled + * on the fly with esbuild, so "check passes" means "the app will load it". + */ +import { build } from 'esbuild' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const SRC = join(ROOT, 'src', 'renderer', 'src') + +// ── args ───────────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const a = { _: [] } + for (let i = 0; i < argv.length; i++) { + const t = argv[i] + if (t.startsWith('--')) { + const key = t.slice(2) + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) a[key] = true + else a[key] = argv[++i] + } else a._.push(t) + } + return a +} + +const args = parseArgs(process.argv.slice(2)) +const command = args._[0] +const repo = resolve(ROOT, typeof args.repo === 'string' ? args.repo : '../tinyparts') + +const readJson = (p) => JSON.parse(readFileSync(p, 'utf8').trimStart()) + +/** + * JSON as a person would write it: arrays of plain values on one line, wrapped + * at `width`. Same as formatJson in circuit/parts/folderPart.ts; keep in step. + */ +function formatJson(value, width = 100) { + const text = JSON.stringify(value, null, 2) + return ( + text.replace(/^( *)(.*)\[\n((?:\1 {2}[^[\]{}\n]*\n)+)\1\]/gm, (_m, indent, head, body) => { + const items = body + .split('\n') + .filter(Boolean) + .map((l) => l.trim().replace(/,$/, '')) + const oneLine = `${indent}${head}[${items.join(', ')}]` + if (oneLine.length <= width) return oneLine + const lines = [] + let cur = '' + for (const item of items) { + if (cur && indent.length + 2 + cur.length + item.length + 2 > width) { + lines.push(`${indent} ${cur.trimEnd()}`) + cur = '' + } + cur += `${item}, ` + } + lines.push(`${indent} ${cur.replace(/, $/, '')}`) + return `${indent}${head}[\n${lines.join('\n')}\n${indent}]` + }) + '\n' + ) +} + +const writeJson = (p, v) => { + mkdirSync(dirname(p), { recursive: true }) + writeFileSync(p, formatJson(v)) +} +const writeText = (p, s) => { + mkdirSync(dirname(p), { recursive: true }) + writeFileSync(p, s.endsWith('\n') ? s : s + '\n') +} +const isDir = (p) => existsSync(p) && statSync(p).isDirectory() + +function usage(code = 0) { + console.log( + readFileSync(fileURLToPath(import.meta.url), 'utf8') + .split('*/')[0] + .replace(/^[\s\S]*?\/\*\*/, '') + ) + process.exit(code) +} + +if (!command || args.help || args.h) usage(command ? 0 : 1) +if (!isDir(repo) && command !== 'help') { + console.error(`tinyparts checkout not found at ${repo} (pass --repo )`) + process.exit(1) +} + +// ── index.json bookkeeping ─────────────────────────────────────────────────── + +/** Add or refresh a pack's row in the repo index (other rows untouched). */ +function upsertIndexEntry(pack) { + const indexPath = join(repo, 'index.json') + const index = existsSync(indexPath) ? readJson(indexPath) : { schema: 1, groups: [], packs: [] } + const row = { + id: pack.id, + name: pack.name, + version: pack.version, + ...(pack.group ? { group: pack.group } : {}), + ...(pack.icon ? { icon: pack.icon } : {}), + ...(pack.bundled ? { bundled: true } : {}), + count: pack.parts.length, + files: pack.parts.length, + ...(pack.description ? { description: pack.description } : {}), + url: `packs/${pack.id}/pack.json` + } + const at = index.packs.findIndex((p) => p.id === pack.id) + if (at >= 0) index.packs[at] = { ...index.packs[at], ...row } + else if (pack.bundled) index.packs.unshift(row) + else index.packs.push(row) + if (pack.group && Array.isArray(index.groups) && !index.groups.includes(pack.group)) + pack.bundled ? index.groups.unshift(pack.group) : index.groups.push(pack.group) + writeJson(indexPath, index) +} + +// ── explode: single-file PartDef JSON → folder part ────────────────────────── + +const VIEWS = ['breadboard', 'schematic'] + +/** Split one PartDef into part.json + real .svg files. */ +function explodeDef(def, iconSvg) { + const files = {} + const views = {} + for (const kind of VIEWS) { + const v = def.views?.[kind] + if (!v?.svg) continue + files[`${kind}.svg`] = v.svg + views[kind] = { + svg: `${kind}.svg`, + width: v.w, + height: v.h, + pins: v.pins, + ...(v.legs?.length ? { legs: v.legs } : {}) + } + } + const { type, label, family, icon: ownIcon, ...rest } = def + delete rest.views + const icon = iconSvg ?? ownIcon + let iconRef + if (icon && icon !== def.views?.breadboard?.svg) { + if (icon === def.views?.schematic?.svg) iconRef = 'schematic.svg' + else { + files['icon.svg'] = icon + iconRef = 'icon.svg' + } + } + const partJson = { type, label, family, ...rest, ...(iconRef ? { icon: iconRef } : {}), views } + return { partJson, files } +} + +function writeFolderPart(packDir, def, iconSvg) { + const { partJson, files } = explodeDef(def, iconSvg) + const dir = join(packDir, 'parts', def.type) + writeJson(join(dir, 'part.json'), partJson) + for (const [name, text] of Object.entries(files)) writeText(join(dir, name), text) + return `parts/${def.type}` +} + +function explode() { + const packId = args.pack + if (typeof packId !== 'string') { + console.error('explode needs --pack ') + process.exit(1) + } + const packDir = join(repo, 'packs', packId) + + if (typeof args.from === 'string') { + // a folder of PartDef JSON (+ optional index.json manifest with icons) → a new pack + const from = resolve(ROOT, args.from) + const manifest = existsSync(join(from, 'index.json')) + ? readJson(join(from, 'index.json')) + : null + const entries = manifest?.parts?.length + ? manifest.parts.filter((m) => m.file) + : readdirSync(from) + .filter((f) => f.endsWith('.json') && f !== 'index.json' && !f.startsWith('_')) + .map((file) => ({ file })) + const parts = [] + for (const m of entries) { + const def = readJson(join(from, m.file)) + parts.push({ type: def.type, dir: writeFolderPart(packDir, def, m.icon) }) + } + const existing = existsSync(join(packDir, 'pack.json')) + ? readJson(join(packDir, 'pack.json')) + : {} + const pack = { + schema: 1, + id: packId, + name: typeof args.new === 'string' ? args.new : existing.name || packId, + version: typeof args.version === 'string' ? args.version : existing.version || '1.0.0', + ...(typeof args.group === 'string' + ? { group: args.group } + : existing.group + ? { group: existing.group } + : {}), + ...(typeof args.icon === 'string' + ? { icon: args.icon } + : existing.icon + ? { icon: existing.icon } + : {}), + ...(args.bundled || existing.bundled ? { bundled: true } : {}), + ...(typeof args.description === 'string' + ? { description: args.description } + : existing.description + ? { description: existing.description } + : {}), + parts + } + writeJson(join(packDir, 'pack.json'), pack) + upsertIndexEntry(pack) + console.log(`explode: wrote ${parts.length} folder parts to packs/${packId}`) + return + } + + // in place: a tinyparts pack whose parts are single JSON files + const packPath = join(packDir, 'pack.json') + if (!existsSync(packPath)) { + console.error(`no pack at ${packPath}`) + process.exit(1) + } + const pack = readJson(packPath) + const only = typeof args.only === 'string' ? new Set(args.only.split(',')) : null + let n = 0 + pack.parts = pack.parts.map((ref) => { + if (!ref.file || (only && !only.has(ref.type))) return ref + const jsonPath = join(packDir, ref.file) + const def = readJson(jsonPath) + const dir = writeFolderPart(packDir, def) + rmSync(jsonPath) + n++ + return { ...ref, file: undefined, dir } + }) + pack.parts = pack.parts.map(({ file, ...r }) => (file ? { ...r, file } : r)) + writeJson(packPath, pack) + console.log(`explode: ${n} part(s) in packs/${pack.id} are now folders`) +} + +// ── new: scaffold a folder part ────────────────────────────────────────────── + +function scaffold() { + const type = args._[1] + const packId = args.pack + if (!type || typeof packId !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(type)) { + console.error( + 'usage: parts-tool.mjs new --pack [--label "Name"] [--family "Sensor"]\n' + + ' is lower-case letters, digits and dashes (it becomes the folder name)' + ) + process.exit(1) + } + const packDir = join(repo, 'packs', packId) + const packPath = join(packDir, 'pack.json') + if (!existsSync(packPath)) { + console.error(`no pack at ${packPath}`) + process.exit(1) + } + const dir = join(packDir, 'parts', type) + if (existsSync(dir)) { + console.error(`${dir} already exists`) + process.exit(1) + } + const label = typeof args.label === 'string' ? args.label : type + writeJson(join(dir, 'part.json'), { + type, + label, + family: typeof args.family === 'string' ? args.family : 'Custom', + views: { breadboard: { svg: 'breadboard.svg', width: '0.8in', height: '0.4in' } } + }) + // 0.8in × 0.4in at 72 units/in, two pads on the 0.1in grid + writeText( + join(dir, 'breadboard.svg'), + ` + + + ${label} + + +` + ) + const pack = readJson(packPath) + pack.parts.push({ type, dir: `parts/${type}` }) + writeJson(packPath, pack) + if (existsSync(join(repo, 'index.json'))) { + const index = readJson(join(repo, 'index.json')) + if (index.packs.some((p) => p.id === pack.id)) upsertIndexEntry(pack) + } + console.log( + `new: packs/${packId}/parts/${type}/ (part.json + breadboard.svg), added to pack.json` + ) +} + +// ── check ──────────────────────────────────────────────────────────────────── + +async function loadLoaders() { + const out = await build({ + stdin: { + contents: `export * from ${JSON.stringify(join(SRC, 'circuit/parts/folderPart.ts'))} +export * from ${JSON.stringify(join(SRC, 'circuit/parts/svgArt.ts'))}`, + resolveDir: SRC, + loader: 'ts' + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'error' + }) + const tmp = mkdtempSync(join(tmpdir(), 'parts-tool-')) + const file = join(tmp, 'loaders.mjs') + writeFileSync(file, out.outputFiles[0].text) + try { + return await import(pathToFileURL(file).href) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +async function check() { + const lib = await loadLoaders() + const fix = !!args.fix + const packsRoot = join(repo, 'packs') + const ids = + typeof args.pack === 'string' + ? [args.pack] + : readdirSync(packsRoot).filter((d) => isDir(join(packsRoot, d))) + const index = existsSync(join(repo, 'index.json')) ? readJson(join(repo, 'index.json')) : null + let errors = 0 + let warnings = 0 + let checked = 0 + const err = (m) => { + errors++ + console.log(` ✖ ${m}`) + } + const warn = (m) => { + warnings++ + console.log(` ⚠ ${m}`) + } + + for (const id of ids) { + const packDir = join(packsRoot, id) + const packPath = join(packDir, 'pack.json') + if (!existsSync(packPath)) { + console.log(`packs/${id}`) + err('no pack.json') + continue + } + let pack + try { + pack = lib.parsePackJson(readFileSync(packPath, 'utf8'), `packs/${id}/pack.json`) + } catch (e) { + console.log(`packs/${id}`) + err(e.message) + continue + } + const folderRefs = pack.parts.filter((r) => r.dir) + // single-file packs are generated by the Fritzing pipeline; only their listing is checked + // unless --legacy asks for every JSON to be parsed too + const onDisk = isDir(join(packDir, 'parts')) + ? readdirSync(join(packDir, 'parts')).filter((d) => isDir(join(packDir, 'parts', d))) + : [] + if (!folderRefs.length && !onDisk.length && !args.legacy) continue + console.log(`packs/${id} (${pack.parts.length} parts)`) + if (index && !index.packs.some((p) => p.id === id)) { + if (fix) upsertIndexEntry(pack) + else err('not listed in index.json (run with --fix)') + } + + const listed = new Set(folderRefs.map((r) => lib.joinPath(r.dir))) + const unlisted = onDisk.filter( + (d) => !listed.has(`parts/${d}`) && existsSync(join(packDir, 'parts', d, 'part.json')) + ) + let changed = false + for (const d of unlisted) { + if (fix) { + const json = readJson(join(packDir, 'parts', d, 'part.json')) + pack.parts.push({ type: json.type || d, dir: `parts/${d}` }) + changed = true + console.log(` + listed parts/${d} in pack.json`) + } else err(`parts/${d} has a part.json but pack.json doesn't list it (run with --fix)`) + } + for (const ref of folderRefs) { + if (!existsSync(join(packDir, ref.dir, 'part.json'))) { + if (fix) { + pack.parts = pack.parts.filter((r) => r !== ref) + changed = true + console.log(` - removed ${ref.dir} from pack.json (folder is gone)`) + } else err(`${ref.dir}/part.json is missing (run with --fix to unlist it)`) + } + } + if (changed) { + writeJson(packPath, pack) + if (index?.packs.some((p) => p.id === id)) upsertIndexEntry(pack) + } + // --fix also normalises formatting of hand-edited JSON (never its content) + if (fix) { + for (const p of [packPath, ...folderRefs.map((r) => join(packDir, r.dir, 'part.json'))]) { + if (!existsSync(p)) continue + const text = readFileSync(p, 'utf8') + try { + const pretty = formatJson(JSON.parse(text.trimStart())) + if (pretty !== text.replace(/\r\n/g, '\n')) writeFileSync(p, pretty) + } catch { + /* invalid JSON is reported below */ + } + } + } + + const read = async (rel) => readFileSync(join(packDir, rel), 'utf8') + const seen = new Set() + for (const ref of pack.parts) { + if (seen.has(ref.type)) err(`type "${ref.type}" is listed twice`) + seen.add(ref.type) + if (ref.file && !args.legacy) continue + checked++ + try { + if (ref.file) { + const def = JSON.parse(await read(ref.file)) + if (!lib.isPartDef(def)) err(`${ref.file} is not a valid part definition`) + continue + } + const where = `${lib.joinPath(ref.dir)}/part.json` + const json = lib.parsePartJson(await read(where), where) + if (json.type !== ref.type) + err(`${where}: type "${json.type}" but pack.json lists "${ref.type}"`) + if (ref.dir.split('/').pop() !== json.type) + warn(`${where}: folder name differs from type "${json.type}"`) + const def = await lib.buildFolderPart(json, read, { + layer: 'dev', + pack: id, + dir: lib.joinPath(ref.dir) + }) + for (const w of def.source.warnings) warn(`${json.type}: ${w}`) + const names = new Set(Object.keys((def.views.breadboard ?? def.views.schematic).pins)) + for (const bus of json.buses ?? []) + for (const p of bus) + if (!names.has(p)) err(`${json.type}: bus references unknown pin "${p}"`) + if (def.views.breadboard && def.views.schematic) { + const bb = Object.keys(def.views.breadboard.pins).sort().join() + const sch = Object.keys(def.views.schematic.pins).sort().join() + if (bb !== sch) warn(`${json.type}: breadboard and schematic pin names differ`) + } + } catch (e) { + err(e.message) + } + } + } + console.log( + `\n${checked} part(s) checked: ${errors} error(s), ${warnings} warning(s)${errors && !fix ? '\n(some listing problems can be repaired with --fix)' : ''}` + ) + process.exit(errors ? 1 : 0) +} + +// ── dispatch ───────────────────────────────────────────────────────────────── + +if (command === 'explode') explode() +else if (command === 'new') scaffold() +else if (command === 'check') await check() +else usage(1) diff --git a/scripts/render-circuit-preview.mjs b/scripts/render-circuit-preview.mjs new file mode 100644 index 0000000..d7b3dbb --- /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/sync-tinyparts.mjs b/scripts/sync-tinyparts.mjs new file mode 100644 index 0000000..eda5713 --- /dev/null +++ b/scripts/sync-tinyparts.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * sync-tinyparts.mjs: copy the BUNDLED tinyparts packs into the app. + * + * npm run parts:sync from ../tinyparts (the working tree) + * npm run parts:sync -- --repo from another checkout + * + * Every pack whose index.json row says `"bundled": true` is copied to + * src/renderer/src/assets/tinyparts/packs//: just pack.json, each part's + * part.json, the art files those reference and the pack's ATTRIBUTION.md + * (stray .ai files, READMEs and the like stay behind), and snapshot.gen.ts is + * regenerated to index them. + * + * Each copied file is recorded with its git blob sha. At launch the app asks + * GitHub for the current tree and only downloads files whose sha differs, so + * a released build that already matches GitHub downloads nothing. + * + * Run this before cutting a release, after pushing tinyparts. You don't need it + * while editing art: `npm run dev` can read your tinyparts checkout directly + * (Parts Packs → Developer). See docs/parts-and-art.md. + */ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const OUT = join(ROOT, 'src', 'renderer', 'src', 'assets', 'tinyparts') + +const argv = process.argv.slice(2) +const flag = (name) => { + const i = argv.indexOf(`--${name}`) + return i >= 0 ? argv[i + 1] : undefined +} +if (argv.includes('--help') || argv.includes('-h')) { + console.log( + readFileSync(fileURLToPath(import.meta.url), 'utf8') + .split('*/')[0] + .replace(/^[\s\S]*?\/\*\*/, '') + ) + process.exit(0) +} +const repo = resolve(ROOT, flag('repo') ?? '../tinyparts') +if (!existsSync(join(repo, 'index.json'))) { + console.error(`No tinyparts checkout at ${repo} (expected index.json). Pass --repo .`) + process.exit(1) +} + +const git = (...args) => { + const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' }) + return r.status === 0 ? r.stdout.trim() : null +} + +/** GitHub stores text with LF endings; hash what GitHub will hash. */ +const TEXT = /\.(json|svg|md|txt)$/i +function blob(path) { + let buf = readFileSync(path) + if (TEXT.test(path)) buf = Buffer.from(buf.toString('utf8').replace(/\r\n/g, '\n'), 'utf8') + const sha = createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex') + return { buf, sha } +} + +const readJson = (p) => JSON.parse(readFileSync(p, 'utf8').trimStart()) +const posix = (...parts) => + parts + .join('/') + .replace(/\\/g, '/') + .replace(/\/{2,}/g, '/') + .replace(/(^|\/)\.\//g, '$1') + +/** pack.json + part.json + the files each part references (pack-relative). */ +function packFiles(packDir) { + const pack = readJson(join(packDir, 'pack.json')) + const files = new Set(['pack.json']) + for (const ref of pack.parts ?? []) { + if (ref.file) files.add(posix(ref.file)) + if (!ref.dir) continue + const partPath = posix(ref.dir, 'part.json') + files.add(partPath) + const part = readJson(join(packDir, partPath)) + for (const kind of ['breadboard', 'schematic']) { + const svg = part.views?.[kind]?.svg + if (svg) files.add(posix(ref.dir, svg)) + } + if (part.icon) files.add(posix(ref.dir, part.icon)) + } + return { pack, files: [...files] } +} + +/** The file the palette draws first (eagerly imported). */ +function isEager(rel, packDir) { + if (rel === 'pack.json' || rel.endsWith('/part.json')) return true + const dir = rel.slice(0, rel.lastIndexOf('/')) + const partPath = join(packDir, dir, 'part.json') + if (!existsSync(partPath)) return false + const part = readJson(partPath) + const icon = part.icon ?? part.views?.breadboard?.svg ?? part.views?.schematic?.svg + return icon && posix(dir, icon) === rel +} + +// ── which packs, from where ────────────────────────────────────────────────── + +const index = readJson(join(repo, 'index.json')) +const bundled = index.packs.filter((p) => p.bundled) +if (!bundled.length) { + console.error('index.json lists no packs with "bundled": true; nothing to sync.') + process.exit(1) +} + +const remoteUrl = git('remote', 'get-url', 'origin') ?? '' +const gh = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/.exec(remoteUrl) +const repoName = gh ? `${gh[1]}/${gh[2]}` : 'Mister-Industries/tinyparts' +const commit = git('rev-parse', 'HEAD') ?? '' +const dirty = git('status', '--porcelain', '--', ...bundled.map((p) => `packs/${p.id}`)) ?? '' +const unpushed = git('rev-list', '--count', '@{u}..HEAD') + +// ── copy ───────────────────────────────────────────────────────────────────── + +rmSync(join(OUT, 'packs'), { recursive: true, force: true }) +const snapshotPacks = [] +const eager = [] +const lazy = [] +let bytes = 0 + +for (const row of bundled) { + const packDir = join(repo, 'packs', row.id) + const { pack, files } = packFiles(packDir) + const shas = {} + for (const rel of files.sort()) { + const src = join(packDir, rel) + if (!existsSync(src)) { + console.error(`✖ packs/${row.id}/${rel} is referenced but missing; run npm run parts:check`) + process.exit(1) + } + const { buf, sha } = blob(src) + const dest = join(OUT, 'packs', row.id, rel) + mkdirSync(dirname(dest), { recursive: true }) + writeFileSync(dest, buf) + bytes += buf.length + shas[rel] = sha + ;(isEager(rel, packDir) ? eager : lazy).push(posix('packs', row.id, rel)) + } + // The licence notice ships with the art (Fritzing-derived packs are CC-BY-SA), + // but stays out of the hash map: the launch-time update check compares only + // the files a pack loads, so listing it would make the pack look changed. + const attribution = join(packDir, 'ATTRIBUTION.md') + if (existsSync(attribution)) { + const dest = join(OUT, 'packs', row.id, 'ATTRIBUTION.md') + mkdirSync(dirname(dest), { recursive: true }) + writeFileSync(dest, blob(attribution).buf) + } + snapshotPacks.push({ id: pack.id, files: shas }) + console.log( + ` ${pack.id.padEnd(12)} ${String(pack.parts.length).padStart(3)} parts ${String(files.length).padStart(4)} files` + ) +} + +// ── snapshot.gen.ts ────────────────────────────────────────────────────────── + +const q = (s) => JSON.stringify(s) +const lines = [ + '// GENERATED by scripts/sync-tinyparts.mjs; do not edit.', + `// A copy of the bundled packs from github.com/${repoName}.`, + '// To change a part, edit it in tinyparts and run `npm run parts:sync`.', + '/* eslint-disable */', + '', + ...eager.map((p, i) => `import f${i} from ${q(`./${p}?raw`)}`), + '', + 'export interface SnapshotPack {', + ' id: string', + ' /** pack-relative path → git blob sha */', + ' files: Record', + '}', + '', + 'export interface TinypartsSnapshot {', + ' /** GitHub owner/repo the packs came from */', + ' repo: string', + ' /** tinyparts HEAD when synced */', + ' commit: string', + ' /** true if the checkout had uncommitted changes in these packs */', + ' dirty: boolean', + ' packs: SnapshotPack[]', + '}', + '', + `export const SNAPSHOT: TinypartsSnapshot = ${JSON.stringify( + { repo: repoName, commit, dirty: !!dirty, packs: snapshotPacks }, + null, + 2 + )}`, + '', + '/** repo-relative path → file text (part.json, pack.json and palette icons) */', + 'export const EAGER: Record = {', + ...eager.map((p, i) => ` ${q(p)}: f${i},`), + '}', + '', + '/** repo-relative path → lazy loader for everything else */', + 'export const LAZY: Record Promise> = {', + ...lazy.map((p) => ` ${q(p)}: () => import(${q(`./${p}?raw`)}).then((m) => m.default),`), + '}', + '' +] +writeFileSync(join(OUT, 'snapshot.gen.ts'), lines.join('\n')) +writeFileSync( + join(OUT, 'README.md'), + `# Bundled tinyparts snapshot: don't edit here + +Everything in this folder is copied from the [tinyparts](https://github.com/${repoName}) +repo by \`npm run parts:sync\` and will be overwritten the next time it runs. + +To change a part's art or pins, edit it in your tinyparts checkout +(\`packs//parts//\`), then run \`npm run parts:sync\` here. +See [docs/parts-and-art.md](../../../../../docs/parts-and-art.md). +` +) + +console.log( + `\nsynced ${snapshotPacks.length} pack(s), ${eager.length + lazy.length} files (${(bytes / 1024).toFixed(0)} KB) from ${repoName}@${commit.slice(0, 7) || '?'}` +) +if (dirty) + console.warn( + `\n⚠ tinyparts has UNCOMMITTED changes in these packs: they're in the snapshot, but not on GitHub yet.\n` + + ` Commit and push tinyparts, or installed apps will "update" back to what GitHub has.\n` + + dirty + .split('\n') + .slice(0, 8) + .map((l) => ` ${l}`) + .join('\n') + ) +else if (unpushed && unpushed !== '0') + console.warn(`\n⚠ tinyparts has ${unpushed} unpushed commit(s); push before releasing.`) diff --git a/scripts/sza-wrap/wrap.cs b/scripts/sza-wrap/wrap.cs deleted file mode 100644 index e41c716..0000000 --- a/scripts/sza-wrap/wrap.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Reflection; - -class SzaWrap { - // Strip the first token (this exe) from a Windows command line, honoring quotes. - static string StripFirstToken(string cmd) { - int i = 0; - if (i < cmd.Length && cmd[i] == '"') { - i++; - while (i < cmd.Length && cmd[i] != '"') i++; - if (i < cmd.Length) i++; // closing quote - } else { - while (i < cmd.Length && cmd[i] != ' ' && cmd[i] != '\t') i++; - } - return cmd.Substring(Math.Min(i, cmd.Length)).TrimStart(); - } - - static int Main() { - string baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - string real = Path.Combine(baseDir, "7za-real.exe"); - string args = StripFirstToken(Environment.CommandLine); - // -snl- => extract symlink entries as regular files instead of creating - // links, so extraction needs no symlink-creation privilege on Windows. - string newArgs = (args.Length > 0 ? args + " " : "") + "-snl-"; - var psi = new ProcessStartInfo { - FileName = real, - Arguments = newArgs, - UseShellExecute = false - }; - var p = Process.Start(psi); - p.WaitForExit(); - return p.ExitCode; - } -} diff --git a/scripts/test-circuit.mjs b/scripts/test-circuit.mjs index 7f4c7df..5acc828 100644 --- a/scripts/test-circuit.mjs +++ b/scripts/test-circuit.mjs @@ -1,48 +1,132 @@ #!/usr/bin/env node /** - * test-circuit.mjs — zero-extra-dependency test runner for the Circuit v2 core. + * 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/**\/__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 * - * Why not vitest? Nothing against it — adopt it whenever it lands in the repo; + * Why not vitest? Nothing against it. Adopt it whenever it lands in the repo; * these test files are plain node:test + assert and will port in minutes. */ 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, extname, 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') + +// Every `__tests__/*.test.ts` under src (renderer and main alike) so a new +// suite (lib/__tests__, main/__tests__) is picked up by dropping the file in, +// with no change here. Main-process tests must not import `electron`; keep the +// logic under test in plain modules. `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' + })) + } +} + +/** Vite's `?inline` suffix: the Studio AI guide screenshots, as base64 data URLs. */ +const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' } +const inlineImports = { + name: 'inline-imports', + setup(b) { + b.onResolve({ filter: /\?inline$/ }, (args) => ({ + path: resolve(args.resolveDir, args.path.slice(0, -7)), + namespace: 'inline' + })) + b.onLoad({ filter: /.*/, namespace: 'inline' }, (args) => ({ + contents: `data:${MIME[extname(args.path)]};base64,${readFileSync(args.path).toString('base64')}`, + loader: 'text' + })) + } +} + +/** + * Main-process modules import `electron`. Tests never have one, so the import + * resolves to a stub whose `app.getPath` answers from environment variables the + * test sets (TINYSTUDIO_TEST_USERDATA, TINYSTUDIO_TEST_DOCUMENTS) and whose + * other members are inert. + */ +const electronStub = { + name: 'electron-stub', + setup(b) { + b.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'electron-stub' })) + b.onLoad({ filter: /.*/, namespace: 'electron-stub' }, () => ({ + contents: ` + export const app = { + getPath: (name) => + name === 'userData' + ? process.env.TINYSTUDIO_TEST_USERDATA ?? '/tmp/tinystudio-test-userdata' + : process.env.TINYSTUDIO_TEST_DOCUMENTS ?? '/tmp/tinystudio-test-documents', + isPackaged: false, + getAppPath: () => process.cwd() + } + export const shell = { openExternal: async () => {}, openPath: async () => '', showItemInFolder: () => {} } + export const ipcMain = { handle: () => {}, on: () => {} } + export const dialog = {} + export class BrowserWindow { static fromWebContents() { return null } } + export const safeStorage = { isEncryptionAvailable: () => false } + export const contextBridge = { exposeInMainWorld: () => {} } + export const ipcRenderer = {} + `, + loader: 'js' + })) + } +} + 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', target: 'node20', sourcemap: 'inline', external: ['node:*'], + // The renderer's `@renderer/*` import alias (tsconfig.web.json paths). + alias: { '@renderer': join(srcRoot, 'renderer', 'src') }, outExtension: { '.js': '.mjs' }, - logLevel: 'error' + logLevel: 'error', + plugins: [rawImports, inlineImports, electronStub] }) - 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/AgentService.ts b/src/main/AgentService.ts index 04b25f8..ef97879 100644 --- a/src/main/AgentService.ts +++ b/src/main/AgentService.ts @@ -1,504 +1,172 @@ /** - * AgentService — the "brain" behind the Studio AI tab. + * AgentService: the desktop host for the Studio AI agent. * - * Runs entirely in the main (Node) process so the API key never reaches the - * renderer bundle. It holds the conversation, runs the agentic tool loop against - * Claude, executes a small set of workspace-scoped filesystem tools, and gates - * every mutating tool (write/edit/delete) behind a renderer permission prompt. + * The agent itself (conversation, tool loop, tool definitions) lives in + * src/shared/agentCore.ts so the web build can run it too. This file supplies + * the desktop pieces: it runs in the main (Node) process so the API key never + * reaches the renderer bundle, and gives the agent Node filesystem access scoped + * to the open workspace. * * Communication with the renderer: * - main → renderer 'agent:event' streamed text / tool activity / done / error - * - main → renderer 'agent:permission-request' { id, ... } — awaits a response - * - main → renderer 'agent:file-changed' { path } — so open editors can refresh + * - main → renderer 'agent:permission-request' { id, ... }, awaits a response + * - main → renderer 'agent:file-changed' { path }, so open editors can refresh * - renderer → main agent:send / agent:abort / agent:reset / agent:permission-response */ import Anthropic from '@anthropic-ai/sdk' -import type { - ContentBlockParam, - MessageParam, - Tool, - ToolUseBlock -} from '@anthropic-ai/sdk/resources/messages' import { BrowserWindow } from 'electron' import { promises as fs } from 'fs' import path from 'path' -import { randomUUID } from 'crypto' -import { getApiKey } from './settings' +import { + AgentSession, + IGNORED_DIRS, + type AgentSendArgs, + type AgentWorkspace, + type StudioMethod +} from '../shared/agentCore' +import { hasAccess } from './folderAccess' +import { getApiKey, getModel } from './settings' -const MODEL = 'claude-opus-4-8' -const MAX_TOKENS = 16000 -const MAX_TURNS = 50 // hard stop on the tool loop, in case the model never settles -const MAX_TOOL_OUTPUT = 60000 // chars returned to the model from a single tool +export type { AgentSendArgs } -export interface AgentSendArgs { - text: string - workspaceRoot: string | null - context?: { board?: string; openFile?: string; lastError?: string } -} - -/** Events streamed to the renderer over 'agent:event'. */ -export type AgentEvent = - | { type: 'text_delta'; text: string } - | { type: 'tool_use'; id: string; name: string; input: unknown } - | { type: 'tool_result'; id: string; name: string; ok: boolean; summary: string } - | { type: 'done'; stopReason: string } - | { type: 'error'; message: string } - -type ToolResult = { content: string; isError: boolean; summary: string } +/** How long to wait for the renderer to answer a live-state request. */ +const STUDIO_TIMEOUT_MS = 20000 -const SYSTEM_BASE = `You are Studio AI, the built-in coding agent for tinyStudio — an IDE for the tinyCore (ESP32-S3) board, used with Arduino sketches (.ino), p5/JS visualizations, and circuit diagrams. - -You can read, search, and edit files in the user's open workspace using your tools. Be a capable pair-programmer: -- Read before you write. Inspect the relevant files first; never guess at file contents. -- Make the smallest change that satisfies the request. Don't refactor or add abstractions that weren't asked for. -- Prefer edit_file (a targeted replacement) over rewriting a whole file with write_file. -- All paths you pass to tools are relative to the workspace root. -- Writes, edits, and deletes require the user's approval — a prompt appears for each. If the user denies one, adapt rather than retrying the same change. -- Keep chat replies concise. Explain what you changed and why, not every step.` +type StudioAnswer = { ok: boolean; value: string } export class AgentService { private window: BrowserWindow | null = null - private conversation: MessageParam[] = [] - private pendingPermissions = new Map void>() - private aborted = false - private running = false + private studioRequests = new Map void>() + private nextStudioId = 1 + private session = new AgentSession({ + getApiKey, + getModel, + createClient: (apiKey) => new Anthropic({ apiKey }), + // Only folders the user opened: the renderer names the root, main decides. + openWorkspace: (root) => (root && hasAccess(root) ? nodeWorkspace(root) : null), + emit: (evt) => this.window?.webContents.send('agent:event', evt), + requestPermission: (req) => { + if (!this.window) return false + this.window.webContents.send('agent:permission-request', req) + return true + }, + fileChanged: (absolutePath) => + this.window?.webContents.send('agent:file-changed', { path: absolutePath }), + // The parts registry and serial buffer live in the renderer. + studio: { + inspectCircuit: (text) => this.askRenderer('inspectCircuit', text), + findParts: (query) => this.askRenderer('findParts', query), + readSerial: (count) => this.askRenderer('readSerial', count) + } + }) setWindow(win: BrowserWindow): void { this.window = win } + /** The renderer's answer to an askRenderer() request. */ + resolveStudio(id: string, answer: StudioAnswer): void { + const resolve = this.studioRequests.get(id) + if (resolve) { + this.studioRequests.delete(id) + resolve(answer) + } + } + + /** Round-trip a live-state request to the renderer (lib/studioBridge.ts). */ + private askRenderer(method: StudioMethod, arg: string | number): Promise { + const win = this.window + if (!win) return Promise.reject(new Error('The app window is not available.')) + const id = `studio-${this.nextStudioId++}` + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.studioRequests.delete(id) + reject(new Error('The app did not answer in time.')) + }, STUDIO_TIMEOUT_MS) + this.studioRequests.set(id, ({ ok, value }) => { + clearTimeout(timer) + if (ok) resolve(value) + else reject(new Error(value)) + }) + win.webContents.send('agent:studio-request', { id, method, arg }) + }) + } + reset(): void { - this.conversation = [] - this.aborted = false + this.session.reset() } abort(): void { - this.aborted = true - // Auto-deny anything currently waiting on the user so the loop can unwind. - for (const resolve of this.pendingPermissions.values()) resolve(false) - this.pendingPermissions.clear() + this.session.abort() } resolvePermission(id: string, allow: boolean): void { - const resolve = this.pendingPermissions.get(id) - if (resolve) { - this.pendingPermissions.delete(id) - resolve(allow) - } + this.session.resolvePermission(id, allow) } - private emit(evt: AgentEvent): void { - this.window?.webContents.send('agent:event', evt) + send(args: AgentSendArgs): Promise { + return this.session.send(args) } +} - async send(args: AgentSendArgs): Promise { - if (this.running) { - this.emit({ type: 'error', message: 'The agent is already working on a request.' }) - return - } - const apiKey = await getApiKey() - if (!apiKey) { - this.emit({ - type: 'error', - message: 'No Anthropic API key configured. Add one in the AI settings.' - }) - return - } - - this.running = true - this.aborted = false - const client = new Anthropic({ apiKey }) - this.conversation.push({ role: 'user', content: args.text }) - - const system = this.buildSystem(args) - - try { - for (let turn = 0; turn < MAX_TURNS; turn++) { - if (this.aborted) { - this.emit({ type: 'done', stopReason: 'aborted' }) - return - } - - const stream = client.messages.stream({ - model: MODEL, - max_tokens: MAX_TOKENS, - system, - messages: this.conversation, - tools: TOOLS, - thinking: { type: 'adaptive' }, - output_config: { effort: 'high' } - }) - - stream.on('text', (delta) => { - if (!this.aborted) this.emit({ type: 'text_delta', text: delta }) - }) - - const message = await stream.finalMessage() - this.conversation.push({ role: 'assistant', content: message.content }) - - if (message.stop_reason !== 'tool_use') { - this.emit({ type: 'done', stopReason: message.stop_reason ?? 'end_turn' }) - return - } - - const toolUses = message.content.filter((b): b is ToolUseBlock => b.type === 'tool_use') - const results: ContentBlockParam[] = [] - for (const tu of toolUses) { - if (this.aborted) { - results.push({ - type: 'tool_result', - tool_use_id: tu.id, - content: 'Cancelled by the user.', - is_error: true - }) - continue - } - this.emit({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input }) - const r = await this.execTool(tu.name, tu.input, args.workspaceRoot) - this.emit({ - type: 'tool_result', - id: tu.id, - name: tu.name, - ok: !r.isError, - summary: r.summary - }) - results.push({ - type: 'tool_result', - tool_use_id: tu.id, - content: r.content, - is_error: r.isError - }) - } - this.conversation.push({ role: 'user', content: results }) - } - this.emit({ type: 'done', stopReason: 'max_turns' }) - } catch (err) { - this.emit({ type: 'error', message: err instanceof Error ? err.message : String(err) }) - } finally { - this.running = false - } - } - - private buildSystem(args: AgentSendArgs): string { - const lines = [SYSTEM_BASE, '', '## Current context'] - lines.push(`Workspace root: ${args.workspaceRoot ?? '(no workspace open)'}`) - if (args.context?.board) lines.push(`Selected board: ${args.context.board}`) - if (args.context?.openFile) lines.push(`File the user is viewing: ${args.context.openFile}`) - if (args.context?.lastError) { - lines.push(`Last build error:\n${args.context.lastError.split('\n').slice(0, 12).join('\n')}`) - } - if (!args.workspaceRoot) { - lines.push( - 'No workspace is open, so file tools are unavailable — answer from knowledge only.' - ) - } - return lines.join('\n') - } - - // --- Tool execution ------------------------------------------------------- - - private async execTool( - name: string, - rawInput: unknown, - root: string | null - ): Promise { - const input = (rawInput ?? {}) as Record - try { - if (!root) return err('No workspace is open, so file tools cannot run.') - switch (name) { - case 'list_dir': - return await this.listDir(root, str(input.path, '.')) - case 'read_file': - return await this.readFile(root, reqStr(input.path, 'path')) - case 'grep': - return await this.grep(root, reqStr(input.pattern, 'pattern'), str(input.path, '.')) - case 'write_file': - return await this.writeFile( - root, - reqStr(input.path, 'path'), - reqStr(input.content, 'content') - ) - case 'edit_file': - return await this.editFile( - root, - reqStr(input.path, 'path'), - reqStr(input.old_string, 'old_string'), - reqStr(input.new_string, 'new_string') - ) - case 'delete_file': - return await this.deleteFile(root, reqStr(input.path, 'path')) - default: - return err(`Unknown tool: ${name}`) - } - } catch (e) { - return err(e instanceof Error ? e.message : String(e)) - } - } +/** Node filesystem access confined to one workspace folder. */ +function nodeWorkspace(root: string): AgentWorkspace { + const normRoot = path.resolve(root) /** Resolve a workspace-relative path, refusing anything that escapes the root. */ - private resolve(root: string, rel: string): string { - const normRoot = path.resolve(root) - const abs = path.resolve(normRoot, rel) + const resolve = (p: string): string => { + const abs = path.resolve(normRoot, p) if (abs !== normRoot && !abs.startsWith(normRoot + path.sep)) { - throw new Error(`Path "${rel}" is outside the workspace and was blocked.`) + throw new Error(`Path "${p}" is outside the workspace and was blocked.`) } return abs } - - private rel(root: string, abs: string): string { - return path.relative(path.resolve(root), abs).split(path.sep).join('/') || '.' - } - - private async listDir(root: string, rel: string): Promise { - const abs = this.resolve(root, rel) - const entries = await fs.readdir(abs, { withFileTypes: true }) - const lines = entries - .filter((e) => e.name !== 'node_modules' && e.name !== '.git') - .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)) - .sort() - return ok(lines.join('\n') || '(empty directory)', `listed ${lines.length} items in ${rel}`) - } - - private async readFile(root: string, rel: string): Promise { - const abs = this.resolve(root, rel) - let content = await fs.readFile(abs, 'utf-8') - let note = '' - if (content.length > MAX_TOOL_OUTPUT) { - content = content.slice(0, MAX_TOOL_OUTPUT) - note = `\n\n[truncated to ${MAX_TOOL_OUTPUT} characters]` - } - return ok(content + note, `read ${rel}`) - } - - private async grep(root: string, pattern: string, rel: string): Promise { - const abs = this.resolve(root, rel) - let re: RegExp - try { - re = new RegExp(pattern, 'i') - } catch { - return err(`Invalid regex: ${pattern}`) - } - const hits: string[] = [] - const MAX_HITS = 100 - const walk = async (dir: string): Promise => { - if (hits.length >= MAX_HITS) return - const entries = await fs.readdir(dir, { withFileTypes: true }) - for (const e of entries) { - if (e.name === 'node_modules' || e.name === '.git') continue - const full = path.join(dir, e.name) - if (e.isDirectory()) { - await walk(full) - } else if (e.isFile()) { - let text: string - try { - text = await fs.readFile(full, 'utf-8') - } catch { - continue // skip binary / unreadable - } - const fileRel = this.rel(root, full) - text.split('\n').forEach((line, i) => { - if (hits.length < MAX_HITS && re.test(line)) { - hits.push(`${fileRel}:${i + 1}: ${line.trim().slice(0, 200)}`) - } - }) + const rel = (abs: string): string => path.relative(normRoot, abs).split(path.sep).join('/') || '.' + + return { + label: root, + absolute: resolve, + list: async (p) => + (await fs.readdir(resolve(p), { withFileTypes: true })).map((e) => ({ + name: e.name, + isDirectory: e.isDirectory() + })), + read: (p) => fs.readFile(resolve(p), 'utf-8'), + listFilesRecursive: async (p) => { + const start = resolve(p) + const files: string[] = [] + const walk = async (dir: string): Promise => { + for (const e of await fs.readdir(dir, { withFileTypes: true })) { + if (IGNORED_DIRS.has(e.name)) continue + const full = path.join(dir, e.name) + if (e.isDirectory()) await walk(full) + else if (e.isFile()) files.push(rel(full)) } - if (hits.length >= MAX_HITS) return } - } - const stat = await fs.stat(abs) - await (stat.isDirectory() ? walk(abs) : Promise.resolve()) - return ok( - hits.join('\n') || `No matches for /${pattern}/`, - `${hits.length} match${hits.length === 1 ? '' : 'es'} for "${pattern}"` - ) - } - - private async writeFile(root: string, rel: string, content: string): Promise { - const abs = this.resolve(root, rel) - const exists = await fileExists(abs) - const preview = content.length > 1200 ? content.slice(0, 1200) + '\n…' : content - const allowed = await this.requestPermission({ - tool: 'write_file', - action: exists ? 'Overwrite file' : 'Create file', - path: rel, - preview - }) - if (!allowed) return err(`User denied writing ${rel}.`) - await fs.mkdir(path.dirname(abs), { recursive: true }) - await fs.writeFile(abs, content, 'utf-8') - this.window?.webContents.send('agent:file-changed', { path: abs }) - return ok(`Wrote ${content.length} characters to ${rel}.`, `wrote ${rel}`) - } - - private async editFile( - root: string, - rel: string, - oldStr: string, - newStr: string - ): Promise { - const abs = this.resolve(root, rel) - const original = await fs.readFile(abs, 'utf-8') - const occurrences = original.split(oldStr).length - 1 - if (occurrences === 0) { - return err(`old_string not found in ${rel}. Read the file again and match exactly.`) - } - if (occurrences > 1) { - return err( - `old_string appears ${occurrences} times in ${rel}; include more surrounding context so it is unique.` - ) - } - const allowed = await this.requestPermission({ - tool: 'edit_file', - action: 'Edit file', - path: rel, - preview: `- ${truncate(oldStr, 600)}\n+ ${truncate(newStr, 600)}` - }) - if (!allowed) return err(`User denied editing ${rel}.`) - await fs.writeFile(abs, original.replace(oldStr, newStr), 'utf-8') - this.window?.webContents.send('agent:file-changed', { path: abs }) - return ok(`Applied edit to ${rel}.`, `edited ${rel}`) - } - - private async deleteFile(root: string, rel: string): Promise { - const abs = this.resolve(root, rel) - const allowed = await this.requestPermission({ - tool: 'delete_file', - action: 'Delete', - path: rel, - preview: `This will permanently delete ${rel}.` - }) - if (!allowed) return err(`User denied deleting ${rel}.`) - const stat = await fs.stat(abs) - if (stat.isDirectory()) await fs.rm(abs, { recursive: true }) - else await fs.unlink(abs) - this.window?.webContents.send('agent:file-changed', { path: abs }) - return ok(`Deleted ${rel}.`, `deleted ${rel}`) - } - - private requestPermission(details: { - tool: string - action: string - path: string - preview: string - }): Promise { - if (!this.window) return Promise.resolve(false) - const id = randomUUID() - return new Promise((resolve) => { - this.pendingPermissions.set(id, resolve) - this.window!.webContents.send('agent:permission-request', { id, ...details }) - }) - } -} - -// --- Tool definitions sent to the model ------------------------------------- - -const TOOLS: Tool[] = [ - { - name: 'list_dir', - description: - 'List the files and folders in a workspace directory. Use this to explore the project.', - input_schema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Directory path relative to the workspace root. Defaults to "."' - } + if ((await fs.stat(start)).isDirectory()) await walk(start) + else files.push(rel(start)) + return files + }, + exists: async (p) => { + try { + await fs.access(resolve(p)) + return true + } catch { + return false } + }, + write: async (p, content) => { + const abs = resolve(p) + await fs.mkdir(path.dirname(abs), { recursive: true }) + await fs.writeFile(abs, content, 'utf-8') + }, + remove: async (p) => { + const abs = resolve(p) + if (abs === normRoot) throw new Error('Refusing to delete the workspace root.') + const stat = await fs.stat(abs) + if (stat.isDirectory()) await fs.rm(abs, { recursive: true }) + else await fs.unlink(abs) } - }, - { - name: 'read_file', - description: - 'Read the full contents of a file in the workspace. Always read a file before editing it.', - input_schema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path relative to the workspace root.' } - }, - required: ['path'] - } - }, - { - name: 'grep', - description: 'Search the workspace for lines matching a (case-insensitive) regular expression.', - input_schema: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Regular expression to search for.' }, - path: { - type: 'string', - description: 'Directory to search under, relative to root. Defaults to "."' - } - }, - required: ['pattern'] - } - }, - { - name: 'write_file', - description: - 'Create a new file or overwrite an existing one with the given content. Requires user approval. Prefer edit_file for small changes to existing files.', - input_schema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path relative to the workspace root.' }, - content: { type: 'string', description: 'Full file content to write.' } - }, - required: ['path', 'content'] - } - }, - { - name: 'edit_file', - description: - 'Replace exactly one occurrence of old_string with new_string in a file. old_string must match the file exactly (including whitespace) and be unique. Requires user approval.', - input_schema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path relative to the workspace root.' }, - old_string: { - type: 'string', - description: 'The exact text to replace (must be unique in the file).' - }, - new_string: { type: 'string', description: 'The replacement text.' } - }, - required: ['path', 'old_string', 'new_string'] - } - }, - { - name: 'delete_file', - description: 'Delete a file or folder from the workspace. Requires user approval.', - input_schema: { - type: 'object', - properties: { path: { type: 'string', description: 'Path relative to the workspace root.' } }, - required: ['path'] - } - } -] - -// --- small helpers ---------------------------------------------------------- - -function ok(content: string, summary: string): ToolResult { - return { content, isError: false, summary } -} -function err(message: string): ToolResult { - return { content: message, isError: true, summary: message } -} -function str(v: unknown, fallback: string): string { - return typeof v === 'string' && v.length > 0 ? v : fallback -} -function reqStr(v: unknown, field: string): string { - if (typeof v !== 'string') throw new Error(`Missing or invalid "${field}".`) - return v -} -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + '…' : s -} -async function fileExists(p: string): Promise { - try { - await fs.access(p) - return true - } catch { - return false } } diff --git a/src/main/ServiceManager.ts b/src/main/ServiceManager.ts index ee539b3..8475b03 100644 --- a/src/main/ServiceManager.ts +++ b/src/main/ServiceManager.ts @@ -1,9 +1,10 @@ import { spawn, type ChildProcess } from 'child_process' import { app, BrowserWindow } from 'electron' import { existsSync } from 'fs' -import net from 'net' import os from 'os' import path from 'path' +import { findFreePort } from './freePort' +import { decideAfterExit } from './serviceRestart' interface ServiceConfig { port: number @@ -26,8 +27,19 @@ export class ServiceManager { private config: ServiceConfig private isRunning = false private mainWindow: BrowserWindow | null = null - - constructor(config: ServiceConfig = { port: 3000, allowedOrigins: ['*'] }) { + // start() in progress, so a second caller waits for it instead of spawning twice. + private starting: Promise | null = null + // stop() asked the child to exit, so its exit isn't a crash. + private stopping = false + // When the last automatic restart happened (serviceRestart.ts). + private lastRestartAt: number | null = null + // The port is chosen once. The renderer builds its WebSocket URL at startup + // and never re-reads it, so every restart must reuse the same port. + private portChosen = false + // Why the service isn't running, for a renderer that mounts after the fact. + private lastError: string | null = null + + constructor(config: ServiceConfig) { this.config = config } @@ -39,20 +51,29 @@ export class ServiceManager { } /** - * Send error to renderer process + * Send an error to the renderer (BackendPrompt listens). `stopped` means the + * service isn't running and won't come back on its own; the renderer then + * offers a Restart button. */ - private sendErrorToRenderer(message: string, error: unknown): void { + private sendErrorToRenderer(message: string, error: unknown, opts?: { stopped: boolean }): void { const errorMessage = error instanceof Error ? error.message : String(error) console.error(`[ServiceManager] ${message}:`, errorMessage) - + if (opts?.stopped) this.lastError = errorMessage + if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('service:error', { message, - error: errorMessage + error: errorMessage, + stopped: opts?.stopped ?? false }) } } + /** Report a start failure to the renderer; the caller decides the wording. */ + reportStartFailure(message: string, error: unknown): void { + this.sendErrorToRenderer(message, error, { stopped: true }) + } + /** * Build the tiny Node program that boots TinyService in the child process. * We pass config explicitly (port, arduino-cli path) so we don't depend on the @@ -64,7 +85,7 @@ export class ServiceManager { port: this.config.port, arduinoCliPath, allowedOrigins: this.config.allowedOrigins, - // Language-server integration (optional — the service degrades to + // Language-server integration (optional; the service degrades to // "lsp-unavailable" when the binaries aren't present). lspServerPath: lsPaths?.lspServerPath, clangdPath: lsPaths?.clangdPath, @@ -146,7 +167,7 @@ export class ServiceManager { /** * Resolve the arduino-language-server + clangd binaries fetched by * scripts/fetch-language-server.mjs into vendor/language-server// - * (dev) or bundled resources (packaged). Returns null when missing — the + * (dev) or bundled resources (packaged). Returns null when missing; the * backend then reports the LSP as unavailable and the editor degrades to * syntax-highlighting only. */ @@ -184,7 +205,7 @@ export class ServiceManager { /** * Default arduino-cli.yaml location (the language server wants the config * file arduino-cli itself uses). Returns undefined when the file doesn't - * exist yet — arduino-cli works on defaults without one. + * exist yet; arduino-cli works on defaults without one. */ private resolveArduinoCliConfigPath(): string | undefined { let candidate: string @@ -203,39 +224,19 @@ export class ServiceManager { } /** - * Find a free TCP port, starting at the preferred one. Port 3000 is a very - * popular dev-server default, so never assume it's ours — a foreign process - * on 3000 previously made the backend fail to bind and the app report - * "agent offline" with no explanation. + * ws:// URL of the running service (port may differ from the default 3000). + * 127.0.0.1 rather than localhost: tinyService 1.2 binds IPv4 loopback only, + * and a client that resolves localhost to ::1 first would be refused. */ - private async findFreePort(preferred: number, attempts = 10): Promise { - for (let port = preferred; port < preferred + attempts; port++) { - const free = await new Promise((resolve) => { - const probe = net - .createServer() - .once('error', () => resolve(false)) - .once('listening', () => { - probe.close(() => resolve(true)) - }) - probe.listen(port, '127.0.0.1') - }) - if (free) return port - } - throw new Error( - `No free port found in ${preferred}-${preferred + attempts - 1} for TinyService` - ) - } - - /** ws:// URL of the running service (port may differ from the default 3000). */ getServiceUrl(): string { - return `ws://localhost:${this.config.port}` + return `ws://127.0.0.1:${this.config.port}` } /** * Check service health via HTTP endpoint */ private async checkServiceHealth(): Promise { - const response = await fetch(`http://localhost:${this.config.port}/health`) + const response = await fetch(`http://127.0.0.1:${this.config.port}/health`) if (!response.ok) { throw new Error(`Health check failed with status ${response.status}`) } @@ -250,7 +251,7 @@ export class ServiceManager { try { console.log(`[ServiceManager] Health check attempt ${attempt}/${maxRetries}`) const health = await this.checkServiceHealth() - + if (health.status !== 'ok') { throw new Error(`Service health check returned status: ${health.status}`) } @@ -262,19 +263,24 @@ export class ServiceManager { if (!health.arduinoCli.available) { const warningMessage = 'Arduino CLI is not available on the service' console.warn(`[ServiceManager] WARNING: ${warningMessage}`) - this.sendErrorToRenderer(warningMessage, new Error('Arduino CLI not found at configured path')) + this.sendErrorToRenderer( + warningMessage, + new Error('Arduino CLI not found at configured path') + ) } return } catch (error) { console.warn(`[ServiceManager] Health check attempt ${attempt} failed:`, error) - + if (attempt === maxRetries) { - throw new Error(`Service health check failed after ${maxRetries} attempts: ${error instanceof Error ? error.message : String(error)}`) + throw new Error( + `Service health check failed after ${maxRetries} attempts: ${error instanceof Error ? error.message : String(error)}` + ) } - + // Wait before retrying - await new Promise(resolve => setTimeout(resolve, retryDelayMs)) + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) } } } @@ -286,19 +292,32 @@ export class ServiceManager { * an ESM package, and Electron's main-process ESM loader can't reliably import * ESM that's bundled with the app. Running it under Electron's own binary in * Node mode (ELECTRON_RUN_AS_NODE) uses the plain Node ESM loader, which loads - * it fine — and isolates the backend in its own process. `cwd` points at the + * it fine, and isolates the backend in its own process. `cwd` points at the * app root so the child resolves @mister-industries/tinyservice from - * node_modules (which is why asar must stay disabled — see electron-builder.yml). + * node_modules (which is why asar must stay disabled; see electron-builder.yml). */ async start(): Promise { if (this.isRunning) { console.log('[ServiceManager] TinyService is already running') return } + if (!this.starting) { + this.starting = this.spawnAndVerify().finally(() => { + this.starting = null + }) + } + return this.starting + } + private async spawnAndVerify(): Promise { + this.stopping = false try { - // Bind to a free port — 3000 may be taken by another dev server. - this.config.port = await this.findFreePort(this.config.port) + // Bind to a free port: 3000 may be taken by another dev server. Only on + // the first start: restarts keep the port (see portChosen). + if (!this.portChosen) { + this.config.port = await findFreePort(this.config.port) + this.portChosen = true + } const arduinoCliPath = this.resolveArduinoCliPath() // out/main → app root (two levels up); node_modules lives here in dev and @@ -309,31 +328,34 @@ export class ServiceManager { console.log(`[ServiceManager] Arduino CLI path: ${arduinoCliPath}`) console.log(`[ServiceManager] Service cwd: ${cwd}`) - this.child = spawn(process.execPath, ['-e', this.buildLauncherCode(arduinoCliPath)], { + const child = spawn(process.execPath, ['-e', this.buildLauncherCode(arduinoCliPath)], { cwd, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) - - this.child.stdout?.on('data', (d: Buffer) => console.log(`[tinyService] ${d.toString().trim()}`)) - this.child.stderr?.on('data', (d: Buffer) => - console.error(`[tinyService] ${d.toString().trim()}`) - ) - this.child.on('error', (err) => this.sendErrorToRenderer('TinyService process error', err)) - this.child.on('exit', (code) => { - if (code) console.warn(`[ServiceManager] TinyService process exited with code ${code}`) + this.child = child + + child.stdout?.on('data', (d: Buffer) => console.log(`[tinyService] ${d.toString().trim()}`)) + child.stderr?.on('data', (d: Buffer) => console.error(`[tinyService] ${d.toString().trim()}`)) + // A spawn failure also fails the health check below, which reports it. + child.on('error', (err) => console.error('[ServiceManager] TinyService process error:', err)) + child.on('exit', (code, signal) => { + // A child we've already replaced must not touch the current state. + if (this.child !== child) return this.isRunning = false this.child = null + void this.handleExit(code, signal) }) - // Give the spawned process time to boot, then confirm it's serving. - await this.verifyServiceHealth(10, 1000) + // Give the spawned process time to boot, then confirm it's serving. A + // cold start on a busy or slow machine can take well over ten seconds. + await this.verifyServiceHealth(30, 1000) this.isRunning = true - console.log(`[ServiceManager] TinyService started on ws://localhost:${this.config.port}`) + this.lastError = null + console.log(`[ServiceManager] TinyService started on ${this.getServiceUrl()}`) } catch (error) { - this.sendErrorToRenderer('Failed to start TinyService', error) if (this.child) { this.child.kill() this.child = null @@ -342,26 +364,83 @@ export class ServiceManager { } } + /** + * The child exited without stop() asking. Restart it once on the same port + * so the renderer's URL stays valid. If the restart fails, or the service + * dies again within a minute, tell the renderer instead (it shows the error + * with a Restart button). + */ + private async handleExit(code: number | null, signal: NodeJS.Signals | null): Promise { + const reason = signal ? `was killed by ${signal}` : `exited with code ${code}` + const decision = decideAfterExit({ + stopping: this.stopping, + lastRestartAt: this.lastRestartAt, + now: Date.now() + }) + if (decision === 'ignore') return + console.warn(`[ServiceManager] TinyService ${reason}`) + if (decision === 'give-up') { + this.sendErrorToRenderer( + 'tinyService stopped', + new Error(`It ${reason} less than a minute after being restarted.`), + { stopped: true } + ) + return + } + this.lastRestartAt = Date.now() + console.log('[ServiceManager] Restarting TinyService') + try { + await this.start() + console.log('[ServiceManager] TinyService restarted') + } catch (error) { + this.sendErrorToRenderer( + 'tinyService stopped', + new Error( + `It ${reason} and couldn't be restarted: ${error instanceof Error ? error.message : String(error)}` + ), + { stopped: true } + ) + } + } + + /** Restart on request from the renderer. Resets the automatic-restart budget. */ + async restart(): Promise { + await this.stop() + this.lastRestartAt = null + await this.start() + } + + /** For a renderer that mounts after the service already failed. */ + getStatus(): { running: boolean; error: string | null } { + return { running: this.isRunning, error: this.lastError } + } + /** * Stop the TinyService child process. */ async stop(): Promise { - if (!this.child) { + const child = this.child + if (!child) { console.log('[ServiceManager] TinyService is not running') this.isRunning = false return } - try { - console.log('[ServiceManager] Stopping TinyService...') - this.child.kill() - this.child = null - this.isRunning = false - console.log('[ServiceManager] TinyService stopped') - } catch (error) { - this.sendErrorToRenderer('Failed to stop TinyService', error) - throw error - } + console.log('[ServiceManager] Stopping TinyService...') + // Mark the exit as ours before it happens, then wait for it (briefly) so + // a restart doesn't race the old child's exit handler. + this.stopping = true + await new Promise((resolve) => { + const timer = setTimeout(resolve, 2000) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + child.kill() + }) + if (this.child === child) this.child = null + this.isRunning = false + console.log('[ServiceManager] TinyService stopped') } /** diff --git a/src/main/__tests__/externalLinks.test.ts b/src/main/__tests__/externalLinks.test.ts new file mode 100644 index 0000000..cccffba --- /dev/null +++ b/src/main/__tests__/externalLinks.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { isSafeExternalUrl } from '../externalLinks' +import { canOpenFile, OPENABLE_EXTENSIONS } from '../ipc/files' + +test('only web and mail links may leave the app', () => { + assert.equal(isSafeExternalUrl('https://tinystudio.cc/docs'), true) + assert.equal(isSafeExternalUrl('http://localhost:5173/'), true) + assert.equal(isSafeExternalUrl('mailto:hello@example.com'), true) + assert.equal(isSafeExternalUrl('file:///C:/Windows/System32/cmd.exe'), false) + assert.equal(isSafeExternalUrl('javascript:alert(1)'), false) + assert.equal(isSafeExternalUrl('data:text/html,'), false) + assert.equal(isSafeExternalUrl('ms-settings:display'), false) + assert.equal(isSafeExternalUrl('not a url'), false) + assert.equal(isSafeExternalUrl(''), false) + assert.equal(isSafeExternalUrl(null), false) + assert.equal(isSafeExternalUrl(undefined), false) +}) + +test('open-path hands only documents and images to the OS', () => { + for (const ext of OPENABLE_EXTENSIONS) assert.ok(ext.startsWith('.'), ext) + assert.equal(canOpenFile('C:/proj/index.html'), true) + assert.equal(canOpenFile('/home/me/proj/wiring.PNG'), true, 'extension case is ignored') + assert.equal(canOpenFile('/tmp/notes.md'), true) + assert.equal(canOpenFile('C:/proj/setup.exe'), false) + assert.equal(canOpenFile('C:/proj/run.bat'), false) + assert.equal(canOpenFile('/usr/bin/script.sh'), false) + assert.equal(canOpenFile('/proj/Makefile'), false, 'no extension') + assert.equal(canOpenFile('/proj/archive.html.zip'), false, 'only the last extension counts') +}) diff --git a/src/main/__tests__/folderAccess.test.ts b/src/main/__tests__/folderAccess.test.ts new file mode 100644 index 0000000..20d22ae --- /dev/null +++ b/src/main/__tests__/folderAccess.test.ts @@ -0,0 +1,79 @@ +/** + * folderAccess decides which paths the renderer may touch. `electron` is + * stubbed by the test runner: app.getPath answers from the environment set + * below, so each run gets its own userData and Documents folders. + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +// The module reads its paths lazily, on the first call, so the environment set +// below is in place by the time any test runs. +import { examplesDir, grantFolder, hasAccess, requireAccess } from '../folderAccess' + +const base = mkdtempSync(path.join(tmpdir(), 'tinystudio-access-')) +const userData = path.join(base, 'userdata') +const documents = path.join(base, 'docs') +const granted = path.join(base, 'projects', 'blinky') +const sibling = path.join(base, 'projects', 'other') +mkdirSync(userData, { recursive: true }) +mkdirSync(granted, { recursive: true }) +writeFileSync(path.join(userData, 'folder-access.json'), JSON.stringify({ folders: [granted] })) +process.env.TINYSTUDIO_TEST_USERDATA = userData +process.env.TINYSTUDIO_TEST_DOCUMENTS = documents + +test('a granted folder and everything inside it is accessible; siblings are not', () => { + assert.equal(hasAccess(granted), true) + assert.equal(hasAccess(path.join(granted, 'blinky.ino')), true) + assert.equal(hasAccess(path.join(granted, 'src', 'deep', 'file.h')), true) + assert.equal(hasAccess(sibling), false) + assert.equal(hasAccess(path.join(base, 'projects')), false, 'the parent is not granted') + assert.equal(hasAccess(granted + '-suffix'), false, 'a longer sibling name is not a subfolder') +}) + +test('nothing else counts as a path', () => { + assert.equal(hasAccess(''), false) + assert.equal(hasAccess(undefined), false) + assert.equal(hasAccess(42), false) + assert.equal(hasAccess(null), false) +}) + +test('the examples folder under Documents is always accessible', () => { + assert.equal(examplesDir(), path.join(documents, 'tinyStudio Examples')) + assert.equal(hasAccess(path.join(examplesDir(), 'owner', 'repo', 'blink', 'blink.ino')), true) + assert.equal(hasAccess(path.join(documents, 'elsewhere')), false) +}) + +test('case is ignored except on Linux', () => { + const upper = granted.toUpperCase() + const original = Object.getOwnPropertyDescriptor(process, 'platform')! + try { + Object.defineProperty(process, 'platform', { value: 'win32' }) + assert.equal(hasAccess(upper), true) + Object.defineProperty(process, 'platform', { value: 'darwin' }) + assert.equal(hasAccess(upper), true) + Object.defineProperty(process, 'platform', { value: 'linux' }) + assert.equal(hasAccess(upper), upper === granted, 'Linux file systems are case-sensitive') + assert.equal(hasAccess(granted), true) + } finally { + Object.defineProperty(process, 'platform', original) + } +}) + +test('requireAccess returns the absolute path or explains what to do', () => { + assert.equal(requireAccess(granted), path.resolve(granted)) + assert.throws(() => requireAccess(sibling), /Open its folder first/) +}) + +test('granting a folder makes it accessible now and after a restart', async () => { + assert.equal(hasAccess(sibling), false) + await grantFolder(sibling) + assert.equal(hasAccess(path.join(sibling, 'a.ino')), true) + const saved = JSON.parse(readFileSync(path.join(userData, 'folder-access.json'), 'utf-8')) + assert.deepEqual(saved.folders, [granted, path.resolve(sibling)]) + // granting again is a no-op + await grantFolder(sibling) + const again = JSON.parse(readFileSync(path.join(userData, 'folder-access.json'), 'utf-8')) + assert.equal(again.folders.length, 2) +}) diff --git a/src/main/__tests__/freePort.test.ts b/src/main/__tests__/freePort.test.ts new file mode 100644 index 0000000..daecac6 --- /dev/null +++ b/src/main/__tests__/freePort.test.ts @@ -0,0 +1,58 @@ +/** + * A port counts as free only when nothing holds it on 127.0.0.1 or on the + * wildcard address, since Windows lets one bind sit beside the other. + */ +import assert from 'node:assert/strict' +import net from 'node:net' +import { test } from 'node:test' +import { canBind, findFreePort } from '../freePort' + +/** Listen on `host` (or every interface) on an OS-chosen port; resolves the port. */ +function hold(host?: string): Promise<{ port: number; close: () => Promise }> { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.once('error', reject) + srv.once('listening', () => { + const { port } = srv.address() as net.AddressInfo + resolve({ port, close: () => new Promise((r) => srv.close(() => r())) }) + }) + if (host) srv.listen(0, host) + else srv.listen(0) + }) +} + +test('a port held on 127.0.0.1 is skipped', async () => { + const held = await hold('127.0.0.1') + try { + assert.equal(await canBind(held.port, '127.0.0.1'), false) + const got = await findFreePort(held.port, 5) + assert.notEqual(got, held.port) + assert.ok(got > held.port && got < held.port + 5) + } finally { + await held.close() + } +}) + +test('a port held on every interface is skipped', async () => { + const held = await hold() + try { + assert.equal(await canBind(held.port), false) + assert.notEqual(await findFreePort(held.port, 5), held.port) + } finally { + await held.close() + } +}) + +test('a free port is returned as is, and none in range throws', async () => { + const probe = await hold('127.0.0.1') + const port = probe.port + await probe.close() + assert.equal(await findFreePort(port, 3), port) + + const busy = await hold('127.0.0.1') + try { + await assert.rejects(findFreePort(busy.port, 1), /No free port/) + } finally { + await busy.close() + } +}) diff --git a/src/main/__tests__/serviceRestart.test.ts b/src/main/__tests__/serviceRestart.test.ts new file mode 100644 index 0000000..3504761 --- /dev/null +++ b/src/main/__tests__/serviceRestart.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { decideAfterExit, RESTART_WINDOW_MS } from '../serviceRestart' + +test('an exit during stop() is ignored', () => { + assert.equal(decideAfterExit({ stopping: true, lastRestartAt: null, now: 1000 }), 'ignore') + assert.equal(decideAfterExit({ stopping: true, lastRestartAt: 900, now: 1000 }), 'ignore') +}) + +test('the first unexpected exit gets a restart', () => { + assert.equal(decideAfterExit({ stopping: false, lastRestartAt: null, now: 1000 }), 'restart') +}) + +test('dying again within the window gives up', () => { + const lastRestartAt = 10_000 + assert.equal( + decideAfterExit({ stopping: false, lastRestartAt, now: lastRestartAt + 1 }), + 'give-up' + ) + assert.equal( + decideAfterExit({ stopping: false, lastRestartAt, now: lastRestartAt + RESTART_WINDOW_MS - 1 }), + 'give-up' + ) +}) + +test('a service that outlived the window earns another restart', () => { + const lastRestartAt = 10_000 + assert.equal( + decideAfterExit({ stopping: false, lastRestartAt, now: lastRestartAt + RESTART_WINDOW_MS }), + 'restart' + ) +}) diff --git a/src/main/externalLinks.ts b/src/main/externalLinks.ts new file mode 100644 index 0000000..2ce5948 --- /dev/null +++ b/src/main/externalLinks.ts @@ -0,0 +1,18 @@ +import { shell } from 'electron' + +const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']) + +/** True for web and mail links: the only things tinyStudio hands to the OS to open. */ +export function isSafeExternalUrl(url: unknown): boolean { + try { + return SAFE_PROTOCOLS.has(new URL(String(url)).protocol) + } catch { + return false + } +} + +/** Open a web or mail link in the user's default app; anything else is ignored. */ +export async function openExternalSafely(url: unknown): Promise { + if (!isSafeExternalUrl(url)) return + await shell.openExternal(new URL(String(url)).toString()) +} diff --git a/src/main/folderAccess.ts b/src/main/folderAccess.ts new file mode 100644 index 0000000..c70246a --- /dev/null +++ b/src/main/folderAccess.ts @@ -0,0 +1,70 @@ +/** + * folderAccess: which folders the renderer may read and write. + * + * The renderer shows project content from anywhere (README links, part art, + * sketches), so it isn't trusted with the whole disk. It gets the folders the + * user chose in a folder picker, remembered across launches in + * userData/folder-access.json, plus the folder examples download into. + * Every file operation main performs for the renderer checks this first. + */ + +import { app } from 'electron' +import { readFileSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' + +const storeFile = (): string => path.join(app.getPath('userData'), 'folder-access.json') + +let granted: string[] | null = null + +function load(): string[] { + if (granted) return granted + granted = [] + try { + const parsed = JSON.parse(readFileSync(storeFile(), 'utf-8')) as { folders?: unknown } + if (Array.isArray(parsed.folders)) { + granted = parsed.folders.filter((f): f is string => typeof f === 'string') + } + } catch { + // first launch, or an unreadable file: nothing granted yet + } + return granted +} + +/** Paths compare case-insensitively where the file system usually is (Windows, macOS). */ +const comparable = (p: string): string => { + const abs = path.resolve(p) + return process.platform === 'linux' ? abs : abs.toLowerCase() +} + +/** Where "Open example" writes projects on desktop. Always accessible. */ +export function examplesDir(): string { + return path.join(app.getPath('documents'), 'tinyStudio Examples') +} + +/** Remember a folder the user picked, so it stays reachable after a restart. */ +export async function grantFolder(folder: string): Promise { + const list = load() + if (list.some((f) => comparable(f) === comparable(folder))) return + list.push(path.resolve(folder)) + await mkdir(path.dirname(storeFile()), { recursive: true }) + await writeFile(storeFile(), JSON.stringify({ folders: list }, null, 2), 'utf-8') +} + +/** Is `target` a granted folder, or inside one? */ +export function hasAccess(target: unknown): target is string { + if (typeof target !== 'string' || target === '') return false + const t = comparable(target) + return [...load(), examplesDir()].some((root) => { + const r = comparable(root) + return t === r || t.startsWith(r.endsWith(path.sep) ? r : r + path.sep) + }) +} + +/** The absolute path for `target`, or an error the renderer can show. */ +export function requireAccess(target: unknown): string { + if (!hasAccess(target)) { + throw new Error(`tinyStudio doesn't have access to ${String(target)}. Open its folder first.`) + } + return path.resolve(target) +} diff --git a/src/main/freePort.ts b/src/main/freePort.ts new file mode 100644 index 0000000..db0457e --- /dev/null +++ b/src/main/freePort.ts @@ -0,0 +1,39 @@ +/** + * freePort: find a TCP port tinyService can bind. Kept free of Electron so + * it can be unit-tested; ServiceManager supplies the preferred port. + * + * A port is probed twice, on 127.0.0.1 (where tinyService 1.2 binds) and on + * the wildcard address (where 1.1 and most dev servers bind). Windows allows a + * specific-address bind next to a wildcard one and the other way round, so a + * single probe misses one kind of listener: the installed tray tinyService is + * exactly that case, and the app's child then died with EADDRINUSE while the + * health check reached the tray service and passed. + */ + +import net from 'net' + +/** True when nothing holds `port` on `host` (or on every interface when omitted). */ +export function canBind(port: number, host?: string): Promise { + return new Promise((resolve) => { + const probe = net + .createServer() + .once('error', () => resolve(false)) + .once('listening', () => { + probe.close(() => resolve(true)) + }) + if (host) probe.listen(port, host) + else probe.listen(port) + }) +} + +/** + * The first port from `preferred` that is free both on 127.0.0.1 and on the + * wildcard address. Port 3000 is a very popular dev-server default, so never + * assume it's ours. + */ +export async function findFreePort(preferred: number, attempts = 10): Promise { + for (let port = preferred; port < preferred + attempts; port++) { + if ((await canBind(port, '127.0.0.1')) && (await canBind(port))) return port + } + throw new Error(`No free port found in ${preferred}-${preferred + attempts - 1} for TinyService`) +} diff --git a/src/main/githubAuth.ts b/src/main/githubAuth.ts new file mode 100644 index 0000000..93f6b0a --- /dev/null +++ b/src/main/githubAuth.ts @@ -0,0 +1,232 @@ +/** + * 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 is. A token in the renderer's localStorage + * would be 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 a full-`repo` personal + * access token. + */ + +import { safeStorage } from 'electron' +import { promises as fs } from 'fs' +import { join } from 'path' +import { app } from 'electron' +import { GITHUB_CLIENT_ID_DEFAULT, GITHUB_SCOPE as SCOPE } from '../shared/githubApp' + +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. The tinyStudio app's id + * is built in (shared/githubApp); 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 || GITHUB_CLIENT_ID_DEFAULT + +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..c64e246 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,21 +1,62 @@ import { electronApp, is, optimizer } from '@electron-toolkit/utils' -import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron' -import { constants, promises as fs } from 'fs' -import path, { join } from 'path' +import { app, BrowserWindow, ipcMain, Menu, screen } from 'electron' +import { join } from 'path' +import { pathToFileURL } from 'url' import icon from '../../resources/icon.png?asset' import { AgentService, type AgentSendArgs } from './AgentService' +import { openExternalSafely } from './externalLinks' +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 { registerFileIpc } from './ipc/files' +import { registerPartsIpc } from './ipc/parts' +import { registerWindowIpc } from './ipc/window' import { ServiceManager } from './ServiceManager' -import { clearApiKey, getStatus, setApiKey } from './settings' +import { clearApiKey, getModel, getStatus, setApiKey, setModel } from './settings' +import { TINYSERVICE_DEFAULT_PORT } from '../shared/tinyservice' -// Initialize ServiceManager const serviceManager = new ServiceManager({ - port: 3000, - allowedOrigins: ['*'] + port: TINYSERVICE_DEFAULT_PORT, + // Browser origins tinyService accepts: the packaged renderer, the dev server + // (any port: 5173 is often taken) and the hosted web app on both of its + // addresses. Requests with no Origin header, such as the health check, are + // always accepted. + allowedOrigins: [ + 'file://', + 'http://localhost:*', + 'https://studio.tinycore.cc', + 'https://app.tinystudio.cc' + ] }) -// Studio AI agent — one instance, bound to the main window. +// Studio AI agent: one instance, bound to the main window. const agentService = new AgentService() +/** The URL the renderer is served from, so navigation elsewhere can be refused. */ +const rendererUrl = + is.dev && process.env['ELECTRON_RENDERER_URL'] + ? process.env['ELECTRON_RENDERER_URL'] + : pathToFileURL(join(__dirname, '../renderer/index.html')).href + +function isAppUrl(url: string): boolean { + try { + const target = new URL(url) + const appUrl = new URL(rendererUrl) + if (target.protocol !== appUrl.protocol) return false + return target.protocol === 'file:' + ? target.pathname === appUrl.pathname + : target.host === appUrl.host + } catch { + return false + } +} + function createWindow(): void { // Size to fit the monitor: cap the window to the available work area so the // bottom is never cut off on smaller / lower-resolution screens. @@ -23,7 +64,6 @@ function createWindow(): void { const winW = Math.min(1280, screenW - 40) const winH = Math.min(800, screenH - 40) - // Create the browser window. const mainWindow = new BrowserWindow({ width: winW, height: winH, @@ -40,32 +80,26 @@ function createWindow(): void { } }) - // Set the main window for ServiceManager error reporting serviceManager.setMainWindow(mainWindow) - - // Bind the Studio AI agent to this window for streaming + permission prompts. agentService.setWindow(mainWindow) - mainWindow.on('ready-to-show', () => { - mainWindow.show() - }) - - // Emit maximize/unmaximize events for renderer - mainWindow.on('maximize', () => { - mainWindow.webContents.send('window:maximized') - }) - - mainWindow.on('unmaximize', () => { - mainWindow.webContents.send('window:unmaximized') - }) + mainWindow.on('ready-to-show', () => mainWindow.show()) + mainWindow.on('maximize', () => mainWindow.webContents.send('window:maximized')) + mainWindow.on('unmaximize', () => mainWindow.webContents.send('window:unmaximized')) - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) + // Links open in the user's browser. The app window only ever shows the app: + // a plain link click in a README must not replace it with a website that has + // no way back (the window is frameless). + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void openExternalSafely(url) return { action: 'deny' } }) + mainWindow.webContents.on('will-navigate', (event, url) => { + if (isAppUrl(url)) return + event.preventDefault() + void openExternalSafely(url) + }) - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -73,48 +107,55 @@ function createWindow(): void { } } -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - // Set app user model id for windows - electronApp.setAppUserModelId('com.electron') + // Matches appId in electron-builder.yml; Windows groups taskbar icons and + // notifications by it. + electronApp.setAppUserModelId('cc.tinystudio.app') // Register standard edit/view accelerators (undo/redo/cut/copy/paste/select-all, // reload, devtools). The window is frameless so this menu stays hidden, but - // without it those shortcuts never bind — e.g. Ctrl+Z wouldn't work in inputs. + // without it those shortcuts never bind (e.g. Ctrl+Z wouldn't work in inputs). Menu.setApplicationMenu( Menu.buildFromTemplate([{ role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }]) ) - // Start TinyService try { await serviceManager.start() } catch (error) { - console.error('Failed to start TinyService during app initialization:', error) + // The window doesn't exist yet; the renderer asks for the status on mount. + serviceManager.reportStartFailure('tinyService failed to start', error) } - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + // F12 toggles DevTools in development; Ctrl/Cmd+R is ignored in production. app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) - // IPC test - ipcMain.handle('ping', () => 'pong') - // The backend may bind to a non-default port (3000 can be taken by another // dev server); the renderer asks for the real URL instead of assuming. ipcMain.handle('service:get-url', () => serviceManager.getServiceUrl()) ipcMain.on('service:get-url-sync', (event) => { event.returnValue = serviceManager.getServiceUrl() }) + ipcMain.handle('service:get-status', () => serviceManager.getStatus()) + // The Restart button in BackendPrompt. Returns the failure as data so the + // renderer gets a clean message rather than an "Error invoking remote + // method" wrapper. + ipcMain.handle('service:restart', async () => { + try { + await serviceManager.restart() + return { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) // --- Studio AI agent + settings --- ipcMain.handle('settings:status', () => getStatus()) ipcMain.handle('settings:set-key', (_, key: string) => setApiKey(key)) ipcMain.handle('settings:clear-key', () => clearApiKey()) + ipcMain.handle('settings:get-model', () => getModel()) + ipcMain.handle('settings:set-model', (_, model: string) => setModel(model)) // Fire-and-forget: the agent streams its work back over 'agent:event'. ipcMain.handle('agent:send', (_, args: AgentSendArgs) => { @@ -125,183 +166,36 @@ app.whenReady().then(async () => { ipcMain.handle('agent:permission-response', (_, id: string, allow: boolean) => { agentService.resolvePermission(id, allow) }) - - // Window control handlers - ipcMain.on('window:minimize', (event) => { - const window = BrowserWindow.fromWebContents(event.sender) - window?.minimize() - }) - - ipcMain.on('window:maximize', (event) => { - const window = BrowserWindow.fromWebContents(event.sender) - if (window?.isMaximized()) { - window.unmaximize() - } else { - window?.maximize() + ipcMain.handle( + 'agent:studio-response', + (_, id: string, answer: { ok: boolean; value: string }) => { + agentService.resolveStudio(id, answer) } - }) - - ipcMain.on('window:close', (event) => { - const window = BrowserWindow.fromWebContents(event.sender) - window?.close() - }) - - // Open a local file with the OS default app (e.g. the exported index.html in - // the browser). Returns '' on success or an error string. - ipcMain.handle('open-path', async (_, targetPath: string) => { - return shell.openPath(targetPath) - }) - - // Open an external URL (e.g. the GitHub Pages site) in the default browser. - ipcMain.handle('open-external', async (_, url: string) => { - await shell.openExternal(url) - }) - - // Default location for downloaded example projects (first-run onboarding). - ipcMain.handle('app:get-examples-dir', () => { - return path.join(app.getPath('documents'), 'tinyStudio Examples') - }) - - // Save a generated file (e.g. the Visual web export) via a Save dialog. - ipcMain.handle('save-file-as', async (event, defaultName: string, content: string) => { - const window = BrowserWindow.fromWebContents(event.sender) - const result = await dialog.showSaveDialog(window!, { - defaultPath: defaultName, - filters: [ - { name: 'HTML', extensions: ['html'] }, - { name: 'All Files', extensions: ['*'] } - ] - }) - if (result.canceled || !result.filePath) return null - await fs.writeFile(result.filePath, content, 'utf-8') - return result.filePath - }) - - // File system handlers - ipcMain.handle('select-folder', async () => { - const result = await dialog.showOpenDialog({ - properties: ['openDirectory'] - }) - return result.canceled ? null : result.filePaths[0] - }) - - ipcMain.handle('read-directory', async (_, dirPath: string, recursive = false) => { - try { - const result: Array<{ - name: string - path: string - isDirectory: boolean - size?: number - lastModified: number - }> = [] - - async function readDirRecursive(currentPath: string): Promise { - const items = await fs.readdir(currentPath, { withFileTypes: true }) - - for (const item of items) { - const itemPath = join(currentPath, item.name) - const stats = await fs.stat(itemPath) - - result.push({ - name: item.name, - path: itemPath, - isDirectory: item.isDirectory(), - size: item.isFile() ? stats.size : undefined, - lastModified: stats.mtime.getTime() - }) - - if (recursive && item.isDirectory()) { - await readDirRecursive(itemPath) - } - } - } - - await readDirRecursive(dirPath) - - return result - } catch (error) { - throw new Error(`Failed to read directory: ${error}`) - } - }) - - ipcMain.handle('read-file', async (_, filePath: string) => { - try { - return await fs.readFile(filePath, 'utf-8') - } catch (error) { - throw new Error(`Failed to read file: ${error}`) - } - }) - - ipcMain.handle('write-file', async (_, filePath: string, content: string) => { - try { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, content, 'utf-8') - } catch (error) { - throw new Error(`Failed to write file: ${error}`) - } - }) - - ipcMain.handle('create-file', async (_, filePath: string, content = '') => { - try { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, content, 'utf-8') - } catch (error) { - throw new Error(`Failed to create file: ${error}`) - } - }) - - ipcMain.handle('rename-file', async (_, oldPath: string, newPath: string) => { - try { - await fs.rename(oldPath, newPath) - } catch (error) { - throw new Error(`Failed to rename file: ${error}`) - } - }) - - ipcMain.handle('create-folder', async (_, folderPath: string) => { - try { - await fs.mkdir(folderPath, { recursive: true }) - } catch (error) { - throw new Error(`Failed to create folder: ${error}`) - } - }) + ) - ipcMain.handle('delete-file', async (_, targetPath: string) => { - try { - const stats = await fs.stat(targetPath) - if (stats.isDirectory()) { - await fs.rmdir(targetPath, { recursive: true }) - } else { - await fs.unlink(targetPath) - } - } catch (error) { - throw new Error(`Failed to delete: ${error}`) - } - }) + // --- 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)) - ipcMain.handle('path-exists', async (_, targetPath: string) => { - try { - await fs.access(targetPath, constants.F_OK) - return true - } catch { - return false - } + // Unpackaged (npm run dev / npm start): unlocks parts-development features. + ipcMain.on('app:is-dev', (event) => { + event.returnValue = is.dev }) - ipcMain.handle('get-file-stats', async (_, filePath: string) => { - try { - const stats = await fs.stat(filePath) - return { - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - size: stats.size, - lastModified: stats.mtime.getTime(), - created: stats.birthtime.getTime() - } - } catch (error) { - throw new Error(`Failed to get file stats: ${error}`) - } - }) + registerWindowIpc() + registerFileIpc() + registerPartsIpc() createWindow() @@ -312,9 +206,8 @@ app.whenReady().then(async () => { }) }) -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. +// Quit when all windows are closed, except on macOS, where apps stay active +// until the user quits with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) @@ -331,10 +224,8 @@ app.on('before-quit', async (event) => { }, 3000) try { - console.log('Stopping TinyService...') await serviceManager.stop() clearTimeout(cleanupTimeout) - console.log('TinyService stopped, exiting...') app.exit() } catch (error) { console.error('Error stopping TinyService during app quit:', error) @@ -343,6 +234,3 @@ app.on('before-quit', async (event) => { } } }) - -// In this file you can include the rest of your app"s main process -// code. You can also put them in separate files and require them here. diff --git a/src/main/ipc/files.ts b/src/main/ipc/files.ts new file mode 100644 index 0000000..89787d6 --- /dev/null +++ b/src/main/ipc/files.ts @@ -0,0 +1,191 @@ +/** + * File-system IPC for the renderer. Every path is checked against the folders + * the user granted (folderAccess) before main touches the disk. + */ + +import { BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from 'electron' +import { constants, promises as fs } from 'fs' +import path from 'path' +import { openExternalSafely } from '../externalLinks' +import { examplesDir, grantFolder, hasAccess, requireAccess } from '../folderAccess' + +interface FileSystemItem { + name: string + path: string + isDirectory: boolean + size?: number + lastModified: number +} + +/** What "open with the default app" may open: documents and images, never programs. */ +export const OPENABLE_EXTENSIONS = new Set([ + '.html', + '.htm', + '.svg', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.pdf', + '.txt', + '.md', + '.csv', + '.json' +]) + +/** True when `open-path` may hand this file to the OS: its extension is on the list above. */ +export function canOpenFile(filePath: string): boolean { + return OPENABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()) +} + +/** Run a file operation, prefixing failures so the renderer can tell what went wrong. */ +async function attempt(what: string, run: () => Promise): Promise { + try { + return await run() + } catch (error) { + throw new Error(`${what}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +export function registerFileIpc(): void { + // Picking a folder is what grants access to it. + ipcMain.handle('select-folder', async (event, defaultPath?: string) => { + const options: OpenDialogOptions = { + properties: ['openDirectory', 'createDirectory'], + defaultPath: typeof defaultPath === 'string' ? defaultPath : undefined + } + const window = BrowserWindow.fromWebContents(event.sender) + const result = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options) + const folder = result.canceled ? undefined : result.filePaths[0] + if (!folder) return null + await grantFolder(folder) + return folder + }) + + ipcMain.handle('has-access', (_, targetPath: string) => hasAccess(targetPath)) + + ipcMain.handle('app:get-examples-dir', () => examplesDir()) + + ipcMain.handle('read-directory', async (_, dirPath: string, recursive = false) => { + const root = requireAccess(dirPath) + return attempt('Failed to read directory', async () => { + const result: FileSystemItem[] = [] + const walk = async (current: string): Promise => { + for (const item of await fs.readdir(current, { withFileTypes: true })) { + const itemPath = path.join(current, item.name) + const stats = await fs.stat(itemPath) + result.push({ + name: item.name, + path: itemPath, + isDirectory: item.isDirectory(), + size: item.isFile() ? stats.size : undefined, + lastModified: stats.mtime.getTime() + }) + if (recursive && item.isDirectory()) await walk(itemPath) + } + } + await walk(root) + return result + }) + }) + + ipcMain.handle('read-file', async (_, filePath: string) => { + const target = requireAccess(filePath) + return attempt('Failed to read file', () => fs.readFile(target, 'utf-8')) + }) + + const write = async (filePath: string, content: string, what: string): Promise => { + const target = requireAccess(filePath) + await attempt(what, async () => { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content, 'utf-8') + }) + } + ipcMain.handle('write-file', (_, filePath: string, content: string) => + write(filePath, content, 'Failed to write file') + ) + ipcMain.handle('create-file', (_, filePath: string, content = '') => + write(filePath, content, 'Failed to create file') + ) + + ipcMain.handle('rename-file', async (_, oldPath: string, newPath: string) => { + const from = requireAccess(oldPath) + const to = requireAccess(newPath) + await attempt('Failed to rename file', () => fs.rename(from, to)) + }) + + ipcMain.handle('create-folder', async (_, folderPath: string) => { + const target = requireAccess(folderPath) + await attempt('Failed to create folder', () => fs.mkdir(target, { recursive: true })) + }) + + ipcMain.handle('delete-file', async (_, targetPath: string) => { + const target = requireAccess(targetPath) + await attempt('Failed to delete', () => fs.rm(target, { recursive: true })) + }) + + // Existence alone reveals nothing about contents, and the app checks remembered + // folders before it knows whether they're still granted. + ipcMain.handle('path-exists', async (_, targetPath: string) => { + try { + await fs.access(targetPath, constants.F_OK) + return true + } catch { + return false + } + }) + + ipcMain.handle('get-file-stats', async (_, filePath: string) => { + const target = requireAccess(filePath) + return attempt('Failed to get file stats', async () => { + const stats = await fs.stat(target) + return { + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + size: stats.size, + lastModified: stats.mtime.getTime(), + created: stats.birthtime.getTime() + } + }) + }) + + // Reveal a file or folder in Explorer / Finder (e.g. a part's art in tinyparts). + ipcMain.handle('show-item-in-folder', (_, targetPath: string) => { + shell.showItemInFolder(requireAccess(targetPath)) + }) + + // Open a document with the OS default app (e.g. the Visual export in a browser). + // Resolves '' on success or a message to show. + ipcMain.handle('open-path', async (_, targetPath: string) => { + const target = requireAccess(targetPath) + const stats = await fs.stat(target).catch(() => null) + if (!stats) return `${target} doesn't exist.` + if (stats.isFile() && !canOpenFile(target)) { + const ext = path.extname(target).toLowerCase() + return `tinyStudio only opens documents and images, not ${ext || 'files without an extension'}.` + } + return shell.openPath(target) + }) + + ipcMain.handle('open-external', (_, url: string) => openExternalSafely(url)) + + // Save a generated file (e.g. the Visual web export) wherever the user picks. + ipcMain.handle('save-file-as', async (event, defaultName: string, content: string) => { + const window = BrowserWindow.fromWebContents(event.sender) + const options = { + defaultPath: defaultName, + filters: [ + { name: 'HTML', extensions: ['html'] }, + { name: 'All Files', extensions: ['*'] } + ] + } + const result = window + ? await dialog.showSaveDialog(window, options) + : await dialog.showSaveDialog(options) + if (result.canceled || !result.filePath) return null + await fs.writeFile(result.filePath, content, 'utf-8') + return result.filePath + }) +} diff --git a/src/main/ipc/parts.ts b/src/main/ipc/parts.ts new file mode 100644 index 0000000..9502de2 --- /dev/null +++ b/src/main/ipc/parts.ts @@ -0,0 +1,47 @@ +/** + * Parts development IPC: watch a local tinyparts checkout (a granted folder). + * + * One recursive watcher per window. Changes are batched (editors like + * Illustrator save through temp files and renames) and sent as paths relative + * to the watched folder; the renderer decides what to reload. + */ + +import { ipcMain } from 'electron' +import { watch, type FSWatcher } from 'fs' +import { requireAccess } from '../folderAccess' + +export function registerPartsIpc(): void { + const watchers = new Map() + + ipcMain.handle('parts:watch', (event, dir: string) => { + const root = requireAccess(dir) + const sender = event.sender + watchers.get(sender.id)?.close() + let pending = new Set() + let timer: NodeJS.Timeout | null = null + const watcher = watch(root, { recursive: true }, (_evt, file) => { + if (!file) return + const rel = file.toString().replace(/\\/g, '/') + if (rel === '.git' || rel.startsWith('.git/')) return + pending.add(rel) + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + if (!sender.isDestroyed()) sender.send('parts:changed', { dir, paths: [...pending] }) + pending = new Set() + }, 250) + }) + watcher.on('error', () => watchers.delete(sender.id)) + if (!watchers.has(sender.id)) { + sender.once('destroyed', () => { + watchers.get(sender.id)?.close() + watchers.delete(sender.id) + }) + } + watchers.set(sender.id, watcher) + }) + + ipcMain.handle('parts:unwatch', (event) => { + watchers.get(event.sender.id)?.close() + watchers.delete(event.sender.id) + }) +} diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts new file mode 100644 index 0000000..c6cfc4f --- /dev/null +++ b/src/main/ipc/window.ts @@ -0,0 +1,19 @@ +/** Title-bar controls for the frameless window. */ + +import { BrowserWindow, ipcMain } from 'electron' + +export function registerWindowIpc(): void { + ipcMain.on('window:minimize', (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize() + }) + + ipcMain.on('window:maximize', (event) => { + const window = BrowserWindow.fromWebContents(event.sender) + if (window?.isMaximized()) window.unmaximize() + else window?.maximize() + }) + + ipcMain.on('window:close', (event) => { + BrowserWindow.fromWebContents(event.sender)?.close() + }) +} diff --git a/src/main/serviceRestart.ts b/src/main/serviceRestart.ts new file mode 100644 index 0000000..bc5fbc6 --- /dev/null +++ b/src/main/serviceRestart.ts @@ -0,0 +1,28 @@ +/** + * What to do when the tinyService child process exits. Kept free of Electron + * so it can be unit-tested; ServiceManager supplies the timestamps. + * + * The policy: an exit we asked for is ignored. An unexpected exit gets one + * automatic restart on the same port. If the restarted service dies again + * within RESTART_WINDOW_MS, restarting clearly isn't helping, so give up and + * tell the user. A service that ran longer than the window before dying earns + * a fresh restart. + */ + +export const RESTART_WINDOW_MS = 60_000 + +export type ExitDecision = 'ignore' | 'restart' | 'give-up' + +export function decideAfterExit(input: { + /** stop() was called: the exit is expected. */ + stopping: boolean + /** When the last automatic restart happened, or null if there was none. */ + lastRestartAt: number | null + now: number +}): ExitDecision { + if (input.stopping) return 'ignore' + if (input.lastRestartAt !== null && input.now - input.lastRestartAt < RESTART_WINDOW_MS) { + return 'give-up' + } + return 'restart' +} diff --git a/src/main/settings.ts b/src/main/settings.ts index da05969..d498430 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,21 +1,24 @@ /** - * settings — persistent app settings for the main process. + * settings: persistent app settings for the main process. * - * Right now this only stores the Anthropic API key used by the Studio AI agent. + * Stores the Studio AI settings: the Anthropic API key and the model to use. * The key is encrypted at rest with Electron's safeStorage (OS keychain / DPAPI) - * and written to userData. We never expose the key to the renderer — the renderer + * and written to userData. We never expose the key to the renderer; the renderer * only ever learns whether a key is configured, never its value. */ import { app, safeStorage } from 'electron' import { promises as fs } from 'fs' import { join } from 'path' +import { isAgentModel, resolveAgentModel, type AgentModelId } from '../shared/agentModels' interface StoredSettings { /** base64 of the encrypted (or, if encryption is unavailable, plaintext) key */ apiKeyEnc?: string /** true when apiKeyEnc was produced by safeStorage; false = plaintext fallback */ apiKeyEncrypted?: boolean + /** Studio AI model id (shared/agentModels.ts); unset means the default */ + model?: string } function settingsPath(): string { @@ -73,6 +76,18 @@ export async function getApiKey(): Promise { return process.env.ANTHROPIC_API_KEY ?? null } +/** The chosen Studio AI model, or the default when none is stored or it's no longer offered. */ +export async function getModel(): Promise { + return resolveAgentModel((await read()).model) +} + +export async function setModel(model: string): Promise { + if (!isAgentModel(model)) throw new Error(`Unknown model: ${model}`) + const s = await read() + s.model = model + await write(s) +} + export type ApiKeySource = 'stored' | 'env' | 'none' export async function getStatus(): Promise<{ configured: boolean; source: ApiKeySource }> { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 79dc726..ecc3234 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -18,20 +18,31 @@ interface FileStats { } interface FileSystemAPI { - selectFolder: () => Promise + /** Folder picker. The chosen folder joins the folders tinyStudio may read and write. */ + selectFolder: (defaultPath?: string) => Promise + /** Whether file access is allowed at this path (a chosen folder or the examples folder). */ + hasAccess: (targetPath: string) => Promise readDirectory: (dirPath: string, recursive?: boolean) => Promise readFile: (filePath: string) => Promise writeFile: (filePath: string, content: string) => Promise createFile: (filePath: string, content?: string) => Promise renameFile: (oldPath: string, newPath: string) => Promise createFolder: (folderPath: string) => Promise - deleteFolder: (folderPath: string) => Promise deleteFile: (targetPath: string) => Promise pathExists: (targetPath: string) => Promise getFileStats: (filePath: string) => Promise saveFileAs: (defaultName: string, content: string) => Promise openPath: (targetPath: string) => Promise openExternal: (url: string) => Promise + showInFolder: (targetPath: string) => Promise +} + +/** Parts development: watch a local tinyparts checkout (npm run dev). */ +interface PartsDevAPI { + watch: (dir: string) => Promise + unwatch: () => Promise + /** batched file changes, as paths relative to the watched folder */ + onChanged: (cb: (info: { dir: string; paths: string[] }) => void) => () => void } // Arduino API types @@ -126,10 +137,52 @@ interface SettingsAPI { getStatus: () => Promise setApiKey: (key: string) => Promise clearApiKey: () => Promise + /** The Studio AI model id (shared/agentModels.ts), the default when none was chosen. */ + getModel: () => Promise + /** Rejects an id that isn't in the offered list. */ + setModel: (model: string) => Promise } interface AppAPI { getExamplesDir: () => Promise + /** running unpackaged (npm run dev) */ + isDev: () => boolean +} + +/** 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 +} + +export interface ServiceError { + message: string + error: string + /** The backend isn't running and won't restart on its own. */ + stopped: boolean } interface ServiceAPI { @@ -137,6 +190,12 @@ interface ServiceAPI { getUrl: () => Promise /** Synchronous variant for construction-time use. */ getUrlSync: () => string + /** Whether the backend is running, and the last fatal error if it isn't. */ + getStatus: () => Promise<{ running: boolean; error: string | null }> + /** Stop and start the backend again on the same port. */ + restart: () => Promise<{ ok: boolean; error?: string }> + /** Backend problems found by the main process. */ + onError: (cb: (info: ServiceError) => void) => () => void } export type AgentEvent = @@ -157,7 +216,18 @@ export interface AgentPermissionRequest { interface AgentSendArgs { text: string workspaceRoot: string | null - context?: { board?: string; openFile?: string; lastError?: string } + context?: { + board?: string + openFile?: string + lastError?: string + view?: 'code' | 'circuit' | 'visual' + } +} + +export interface AgentStudioRequest { + id: string + method: 'inspectCircuit' | 'findParts' | 'readSerial' + arg: string | number } interface AgentAPI { @@ -168,6 +238,9 @@ interface AgentAPI { onEvent: (cb: (evt: AgentEvent) => void) => () => void onPermissionRequest: (cb: (req: AgentPermissionRequest) => void) => () => void onFileChanged: (cb: (info: { path: string }) => void) => () => void + /** Desktop only: the main-process agent asking the renderer for live app state. */ + onStudioRequest?: (cb: (req: AgentStudioRequest) => void) => () => void + respondStudio?: (id: string, answer: { ok: boolean; value: string }) => Promise } declare global { @@ -177,9 +250,11 @@ declare global { fs: FileSystemAPI arduino: ArduinoAPI settings: SettingsAPI + github: GitHubAuthAPI agent: AgentAPI app: AppAPI service: ServiceAPI + parts: PartsDevAPI } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 4f501e6..fedc6b6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,7 +22,11 @@ interface FileStats { const api = { // File system operations fs: { - selectFolder: (): Promise => ipcRenderer.invoke('select-folder'), + // Picking a folder grants tinyStudio access to it (main/folderAccess). + selectFolder: (defaultPath?: string): Promise => + ipcRenderer.invoke('select-folder', defaultPath), + hasAccess: (targetPath: string): Promise => + ipcRenderer.invoke('has-access', targetPath), readDirectory: (dirPath: string, recursive = false): Promise => ipcRenderer.invoke('read-directory', dirPath, recursive), readFile: (filePath: string): Promise => ipcRenderer.invoke('read-file', filePath), @@ -46,32 +50,100 @@ const api = { // Open a local file with the OS default app (e.g. exported HTML in a browser). openPath: (targetPath: string): Promise => ipcRenderer.invoke('open-path', targetPath), // Open an external URL in the default browser. - openExternal: (url: string): Promise => ipcRenderer.invoke('open-external', url) + openExternal: (url: string): Promise => ipcRenderer.invoke('open-external', url), + // Reveal a file or folder in Explorer / Finder. + showInFolder: (targetPath: string): Promise => + ipcRenderer.invoke('show-item-in-folder', targetPath) + }, + + // Parts development (npm run dev): watch a local tinyparts checkout. + parts: { + watch: (dir: string): Promise => ipcRenderer.invoke('parts:watch', dir), + unwatch: (): Promise => ipcRenderer.invoke('parts:unwatch'), + onChanged: (cb: (info: { dir: string; paths: string[] }) => void): (() => void) => { + const handler = (_e: unknown, info: { dir: string; paths: string[] }): void => cb(info) + ipcRenderer.on('parts:changed', handler) + return () => ipcRenderer.removeListener('parts:changed', handler) + } }, // App-level paths/info. app: { // Default folder for downloaded example projects (Documents/tinyStudio Examples). - getExamplesDir: (): Promise => ipcRenderer.invoke('app:get-examples-dir') + getExamplesDir: (): Promise => ipcRenderer.invoke('app:get-examples-dir'), + // True when running unpackaged (npm run dev): parts-development features. + isDev: (): boolean => ipcRenderer.sendSync('app:is-dev') }, // Backend (tinyService) info. service: { - // Real ws:// URL of the spawned backend — its port may differ from 3000. + // Real ws:// URL of the spawned backend; its port may differ from 3000. getUrl: (): Promise => ipcRenderer.invoke('service:get-url'), // Synchronous variant for construction-time use: the WebSocket service // client is created synchronously at renderer startup, and the backend is // already running by then (main starts it before creating the window). - getUrlSync: (): string => ipcRenderer.sendSync('service:get-url-sync') + getUrlSync: (): string => ipcRenderer.sendSync('service:get-url-sync'), + // Whether the backend is running, and why not if it isn't. + getStatus: (): Promise<{ running: boolean; error: string | null }> => + ipcRenderer.invoke('service:get-status'), + // Stop and start the backend again on the same port. + restart: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('service:restart'), + // Problems main found with the backend: a crash it couldn't recover from + // (`stopped`), or a warning such as a missing arduino-cli. + onError: ( + cb: (info: { message: string; error: string; stopped: boolean }) => void + ): (() => void) => { + const handler = ( + _e: unknown, + info: { message: string; error: string; stopped: boolean } + ): void => cb(info) + ipcRenderer.on('service:error', handler) + return () => ipcRenderer.removeListener('service:error', handler) + } }, - // App settings (Studio AI). The renderer never sees the API key value — + // App settings (Studio AI). The renderer never sees the API key value, // only whether one is configured. settings: { getStatus: (): Promise<{ configured: boolean; source: 'stored' | 'env' | 'none' }> => ipcRenderer.invoke('settings:status'), setApiKey: (key: string): Promise => ipcRenderer.invoke('settings:set-key', key), - clearApiKey: (): Promise => ipcRenderer.invoke('settings:clear-key') + clearApiKey: (): Promise => ipcRenderer.invoke('settings:clear-key'), + // The Claude model Studio AI uses (shared/agentModels.ts). + getModel: (): Promise => ipcRenderer.invoke('settings:get-model'), + setModel: (model: string): Promise => ipcRenderer.invoke('settings:set-model', model) + }, + + // 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(). @@ -79,7 +151,12 @@ const api = { send: (args: { text: string workspaceRoot: string | null - context?: { board?: string; openFile?: string; lastError?: string } + context?: { + board?: string + openFile?: string + lastError?: string + view?: 'code' | 'circuit' | 'visual' + } }): Promise => ipcRenderer.invoke('agent:send', args), abort: (): Promise => ipcRenderer.invoke('agent:abort'), reset: (): Promise => ipcRenderer.invoke('agent:reset'), @@ -99,23 +176,22 @@ const api = { const handler = (_e: unknown, info: { path: string }): void => cb(info) ipcRenderer.on('agent:file-changed', handler) return () => ipcRenderer.removeListener('agent:file-changed', handler) - } + }, + // The agent asking the renderer for live app state (lib/studioBridge.ts). + onStudioRequest: (cb: (req: unknown) => void): (() => void) => { + const handler = (_e: unknown, req: unknown): void => cb(req) + ipcRenderer.on('agent:studio-request', handler) + return () => ipcRenderer.removeListener('agent:studio-request', handler) + }, + respondStudio: (id: string, answer: { ok: boolean; value: string }): Promise => + ipcRenderer.invoke('agent:studio-response', id, answer) } } -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('api', api) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = api +// The window is always context-isolated, so the bridge is the renderer's only way in. +try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) +} catch (error) { + console.error(error) } diff --git a/src/renderer/index.html b/src/renderer/index.html index d138fef..98aebb7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,13 +3,18 @@ tinyStudio - + + - - diff --git a/src/renderer/public/favicon.svg b/src/renderer/public/favicon.svg new file mode 100644 index 0000000..524bbf4 --- /dev/null +++ b/src/renderer/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/public/sketch-runner/index.html b/src/renderer/public/sketch-runner/index.html new file mode 100644 index 0000000..2b803c5 --- /dev/null +++ b/src/renderer/public/sketch-runner/index.html @@ -0,0 +1,47 @@ + + + + + tinyStudio sketch + + + + + + + +
+ + + + + diff --git a/src/renderer/public/sketch-runner/runner.js b/src/renderer/public/sketch-runner/runner.js new file mode 100644 index 0000000..fc9164f --- /dev/null +++ b/src/renderer/public/sketch-runner/runner.js @@ -0,0 +1,175 @@ +/* global p5 */ +/** + * tinyStudio sketch runner: runs a visual.js p5 sketch inside a sandboxed + * iframe. The parent (components/VisualPreview) sends: + * + * { type: 'run', code, theme, serial } start (or restart) a sketch + * { type: 'theme', theme } the app switched light/dark + * { type: 'serial', line } a line the board printed + * { type: 'pause' } / { type: 'resume' } / { type: 'stop' } + * + * and receives { type: 'ready' } once, then { type: 'error', message } for + * anything the sketch throws. + */ +;(() => { + const CALLBACKS = [ + 'preload', + 'setup', + 'draw', + 'mousePressed', + 'mouseReleased', + 'mouseClicked', + 'mouseMoved', + 'mouseDragged', + 'mouseWheel', + 'doubleClicked', + 'keyPressed', + 'keyReleased', + 'keyTyped', + 'touchStarted', + 'touchMoved', + 'touchEnded', + 'windowResized', + 'serialEvent' + ] + const MAX_LINES = 300 + const stage = document.getElementById('stage') + + let serial = { lines: [], values: [], last: '', value: 0 } + /** Filled in place, because sketches keep a reference to it. */ + const theme = {} + let instance = null + /** Lines received since the last frame, handed to serialEvent() before draw(). */ + let pending = [] + + const post = (message) => window.parent.postMessage(message, '*') + const report = (error) => + post({ type: 'error', message: String(error && error.message ? error.message : error) }) + + const numberIn = (line) => { + const match = String(line).match(/-?\d+(?:\.\d+)?/) + if (match) return parseFloat(match[0]) + return /(HIGH|\bON\b|true)/i.test(line) ? 1 : 0 + } + + const pushLine = (line) => { + const value = numberIn(line) + serial.lines.push(line) + serial.values.push(value) + if (serial.lines.length > MAX_LINES) serial.lines.shift() + if (serial.values.length > MAX_LINES) serial.values.shift() + serial.last = line + serial.value = value + pending.push(line) + if (pending.length > MAX_LINES) pending.shift() + } + + // Processing-style serial helpers, in scope for the sketch. + const helpers = { + serialRead: () => serial.last, + serialReadLine: () => serial.last, + serialAvailable: () => serial.lines.length > 0, + serialValue: () => serial.value, + serialValues: () => serial.values.slice(), + serialLines: () => serial.lines.slice() + } + const helperNames = Object.keys(helpers) + + const stop = () => { + if (instance) instance.remove() + instance = null + pending = [] + } + + const run = (code) => { + stop() + let factory + try { + // Global-style sketches: `with (p)` puts p5's API in scope, and any + // callback the sketch defines is handed to the instance. + const hookup = CALLBACKS.map( + (name) => `if (typeof ${name} === 'function') p.${name} = ${name};` + ).join('\n') + factory = new Function('p', 'theme', ...helperNames, `with (p) {\n${code}\n${hookup}\n}`) + } catch (error) { + report(error) + return + } + try { + instance = new p5((p) => { + try { + factory(p, theme, ...helperNames.map((name) => helpers[name])) + } catch (error) { + report(error) + } + const setup = p.setup + p.setup = function () { + try { + if (setup) setup.call(p) + } catch (error) { + report(error) + } + } + const draw = p.draw + p.draw = function () { + const lines = pending + pending = [] + if (typeof p.serialEvent === 'function') { + for (const line of lines) { + try { + p.serialEvent(line) + } catch { + // a bad line shouldn't stop the sketch + } + } + } + if (!draw) return + try { + draw.call(p) + } catch (error) { + report(error) + p.noLoop() + } + } + }, stage) + } catch (error) { + report(error) + } + } + + window.addEventListener('message', (event) => { + if (event.source !== window.parent) return + const message = event.data || {} + switch (message.type) { + case 'run': + Object.assign(theme, message.theme || {}) + if (message.serial && Array.isArray(message.serial.lines)) { + serial = { + lines: message.serial.lines.slice(-MAX_LINES), + values: (message.serial.values || []).slice(-MAX_LINES), + last: message.serial.last || '', + value: message.serial.value || 0 + } + } + run(String(message.code || '')) + break + case 'theme': + Object.assign(theme, message.theme || {}) + break + case 'serial': + pushLine(String(message.line)) + break + case 'pause': + if (instance) instance.noLoop() + break + case 'resume': + if (instance) instance.loop() + break + case 'stop': + stop() + break + } + }) + + post({ type: 'ready' }) +})() diff --git a/src/renderer/src/.fuse_hidden0000001200000001 b/src/renderer/src/.fuse_hidden0000001200000001 deleted file mode 100644 index 7ee192b..0000000 --- a/src/renderer/src/.fuse_hidden0000001200000001 +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { notify as toast } from './lib/notify' -import { BackendPrompt } from './components/BackendPrompt' -import { WelcomeDialog } from './components/WelcomeDialog' -import { LoadGitHubProjectCommand, OpenWorkspaceCommand } from './commands/fileCommands' -import { parseProjectRoute } from './lib/projectRouting' -import { DocsPanel } from './components/DocsPanel' -import { EditorPanel } from './components/EditorPanel' -import { ErrorBoundary } from './components/ErrorBoundary' -import { FileExplorer } from './components/FileExplorer' -import { Header } from './components/Header' -import { SerialMonitor } from './components/SerialMonitor' -import { StatusBar } from './components/StatusBar' -import { Toolbar } from './components/Toolbar' -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './components/ui/Resizable' -import { ArduinoProvider } from './contexts/ArduinoContext' -import { SerialProvider } from './contexts/SerialContext' -import { fileSystem } from './lib/fileSystem' -import { - selectEditorView, - selectPanelState, - setPanelOpen, - useAppDispatch, - useAppSelector -} from './redux' -import { ArduinoServiceFactory } from './services/arduino/ArduinoServiceFactory' - -export default function App(): React.JSX.Element { - const { isFileExplorerOpen, isSerialMonitorOpen, isDocsPanelOpen } = - useAppSelector(selectPanelState) - const editorView = useAppSelector(selectEditorView) - const dispatch = useAppDispatch() - const [editorSize, setEditorSize] = useState(50) - - // The serial monitor / output dock only makes sense while coding — close it - // when switching to the full-window Circuit or Visual views, reopen on Code. - useEffect(() => { - dispatch(setPanelOpen({ panel: 'monitor', isOpen: editorView === 'code' })) - }, [editorView, dispatch]) - - // Cleanup Arduino service on unmount - useEffect(() => { - return () => { - console.log('Cleaning up Arduino service on app unmount') - ArduinoServiceFactory.cleanup() - } - }, []) - - // The "backend not reachable" messaging now lives in (a - // persistent, actionable banner with the start command) rather than a transient - // toast, so compile/upload/serial don't appear to fail silently. - - // On launch, a `///` deep link opens that GitHub project and - // takes precedence over reopening the last local workspace. Otherwise, reopen - // the last workspace (if it still exists on disk). - // - // Guard against running twice — StrictMode double-invokes effects in dev, and a - // second open rebuilds the tree with new ids, which previously opened a - // duplicate tab for the auto-opened sketch. - const reopenedRef = useRef(false) - useEffect(() => { - if (reopenedRef.current) return - reopenedRef.current = true - - const route = parseProjectRoute() - if (route) { - new LoadGitHubProjectCommand(route.owner, route.repo, route.path).execute().catch((e) => { - console.error('Failed to load project from URL:', e) - toast.error('Could not open that project', { - description: e instanceof Error ? e.message : String(e) - }) - }) - return - } - - const last = localStorage.getItem('tinystudio.lastWorkspace') - if (!last) return - fileSystem - .pathExists(last) - .then((exists) => { - if (exists) { - void new OpenWorkspaceCommand(last).execute() - } else { - localStorage.removeItem('tinystudio.lastWorkspace') - } - }) - .catch((e) => console.error('Failed to reopen last workspace:', e)) - }, []) - - // Honor browser back/forward between projects. - useEffect(() => { - const onPop = (): void => { - const route = parseProjectRoute() - if (route) { - new LoadGitHubProjectCommand(route.owner, route.repo, route.path) - .execute() - .catch((e) => console.error('Failed to load project on navigation:', e)) - } - } - window.addEventListener('popstate', onPop) - return () => window.removeEventListener('popstate', onPop) - }, []) - - return ( - - -
-
- - - {isFileExplorerOpen && ( - <> - - - - - - )} - - - setEditorSize(size)} - > - - - {isSerialMonitorOpen && ( - <> - - - - - - )} - - - {isDocsPanelOpen && ( - <> - - - - - - - - )} - - - - -
-
-
- ) -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3fdfcdf..e4a5199 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,20 +1,25 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { notify as toast } from './lib/notify' +import { notify as toast, reportError } from './lib/notify' +import { completeWebSignIn, isCallbackUrl } from './lib/githubWebAuth' import { BackendPrompt } from './components/BackendPrompt' -import { LoadGitHubProjectCommand, OpenWorkspaceCommand } from './commands/fileCommands' +import { loadGitHubProject, openRecentFolder, openFolder } from './commands/fileCommands' +import { listRecentProjects } from './lib/projectStore' +import { STORAGE_KEYS } from './lib/storageKeys' import { parseProjectRoute } from './lib/projectRouting' +import { serveStudioRequests } from './lib/studioBridge' import { DocsPanel } from './components/DocsPanel' import { EditorPanel } from './components/EditorPanel' import { ErrorBoundary } from './components/ErrorBoundary' import { FileExplorer } from './components/FileExplorer' import { Header } from './components/Header' +import { ProjectDialogs } from './components/ProjectDialogs' import { SerialMonitor } from './components/SerialMonitor' import { StatusBar } from './components/StatusBar' import { Toolbar } from './components/Toolbar' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './components/ui/Resizable' import { getPanelGroupElement, type ImperativePanelHandle } from 'react-resizable-panels' -import { ArduinoProvider } from './contexts/ArduinoContext' -import { SerialProvider } from './contexts/SerialContext' +import { ArduinoProvider } from './contexts/ArduinoProvider' +import { SerialProvider } from './contexts/SerialProvider' import { fileSystem } from './lib/fileSystem' import { selectEditorView, @@ -29,6 +34,9 @@ export default function App(): React.JSX.Element { const { isFileExplorerOpen, isSerialMonitorOpen, isDocsPanelOpen } = useAppSelector(selectPanelState) const editorView = useAppSelector(selectEditorView) + // A boolean, not the workspace itself: a file-tree refresh swaps the object, + // and that mustn't re-open a monitor the user closed. + const hasWorkspace = useAppSelector((state) => state.file.workspace !== null) const dispatch = useAppDispatch() const [editorSize, setEditorSize] = useState(50) @@ -74,16 +82,21 @@ export default function App(): React.JSX.Element { return () => cancelAnimationFrame(id) }, [isDocsPanelOpen, isSerialMonitorOpen, isFileExplorerOpen, applyFilePanelWidth]) - // The serial monitor / output dock only makes sense while coding — close it - // when switching to the full-window Circuit or Visual views, reopen on Code. + // The serial monitor / output dock only makes sense while coding a project; + // close it for the full-window Circuit or Visual views and when nothing is + // open (the start screen gets the whole column), reopen on Code once a + // project is. useEffect(() => { - dispatch(setPanelOpen({ panel: 'monitor', isOpen: editorView === 'code' })) - }, [editorView, dispatch]) + dispatch(setPanelOpen({ panel: 'monitor', isOpen: editorView === 'code' && hasWorkspace })) + }, [editorView, hasWorkspace, dispatch]) + + // Desktop: Studio AI runs in the main process, but the parts registry and the + // serial buffer its tools read live here (lib/studioBridge). + useEffect(() => serveStudioRequests(), []) // Cleanup Arduino service on unmount useEffect(() => { return () => { - console.log('Cleaning up Arduino service on app unmount') ArduinoServiceFactory.cleanup() } }, []) @@ -96,37 +109,60 @@ export default function App(): React.JSX.Element { // takes precedence over reopening the last local workspace. Otherwise, reopen // the last workspace (if it still exists on disk). // - // Guard against running twice — StrictMode double-invokes effects in dev, and a - // second open rebuilds the tree with new ids, which previously opened a - // duplicate tab for the auto-opened sketch. + // Guard against running twice: StrictMode double-invokes effects in dev, and a + // second open rebuilds the tree with new ids, which would open a duplicate + // tab for the auto-opened sketch. const reopenedRef = useRef(false) useEffect(() => { if (reopenedRef.current) return reopenedRef.current = true + // Back from github.com (web build): finish signing in, then carry on from + // the page the user was on, which may itself be a project deep link. + if (!fileSystem.isElectron() && isCallbackUrl()) { + void completeWebSignIn().then((result) => { + window.history.replaceState(null, '', result.returnTo) + if (result.account) toast.success(`Signed in as ${result.account.login}`) + else if (result.error) toast.error('GitHub sign-in failed', { description: result.error }) + const back = parseProjectRoute(new URL(result.returnTo, window.location.origin).pathname) + if (back) { + loadGitHubProject(back.owner, back.repo, back.path).catch((e) => + reportError('Could not open that project', e) + ) + } + }) + return + } + const route = parseProjectRoute() if (route) { - new LoadGitHubProjectCommand(route.owner, route.repo, route.path).execute().catch((e) => { - console.error('Failed to load project from URL:', e) - toast.error('Could not open that project', { - description: e instanceof Error ? e.message : String(e) - }) - }) + loadGitHubProject(route.owner, route.repo, route.path).catch((e) => + reportError('Could not open that project', e) + ) return } - const last = localStorage.getItem('tinystudio.lastWorkspace') + const last = localStorage.getItem(STORAGE_KEYS.lastWorkspace) if (!last) return - fileSystem - .pathExists(last) - .then((exists) => { - if (exists) { - void new OpenWorkspaceCommand(last).execute() - } else { - localStorage.removeItem('tinystudio.lastWorkspace') - } + if (!fileSystem.isElectron()) { + // Browser: a folder only comes back through its stored handle, and only + // while access is still granted. Asking again needs a click, so otherwise + // it waits under Recent on the start screen. + const entry = listRecentProjects().find((r) => r.kind === 'folder' && r.location === last) + if (entry) { + openRecentFolder(entry, { prompt: false }).catch((e) => + console.warn('Could not reopen the last folder at launch:', e) + ) + } + return + } + Promise.all([fileSystem.pathExists(last), window.api.fs.hasAccess(last)]) + .then(([exists, allowed]) => { + if (!exists) localStorage.removeItem(STORAGE_KEYS.lastWorkspace) + // A folder from before access was tracked waits under Recent until it's chosen again. + else if (allowed) void openFolder(last) }) - .catch((e) => console.error('Failed to reopen last workspace:', e)) + .catch((e) => console.warn('Could not reopen the last workspace at launch:', e)) }, []) // Honor browser back/forward between projects. @@ -134,9 +170,9 @@ export default function App(): React.JSX.Element { const onPop = (): void => { const route = parseProjectRoute() if (route) { - new LoadGitHubProjectCommand(route.owner, route.repo, route.path) - .execute() - .catch((e) => console.error('Failed to load project on navigation:', e)) + loadGitHubProject(route.owner, route.repo, route.path).catch((e) => + reportError('Could not open that project', e) + ) } } window.addEventListener('popstate', onPop) @@ -202,6 +238,7 @@ export default function App(): React.JSX.Element { +
diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 9620b94..3ca8243 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -1,5 +1,5 @@ /* ============================================================================ - tinyStudio / MR.INDUSTRIES — application theme + tinyStudio / MR.INDUSTRIES: application theme ---------------------------------------------------------------------------- tinyStudio design system: a clean digital workbench. Cold neutral greys (near-white in light, Discord-charcoal in dark) form a calm ground; four @@ -21,6 +21,12 @@ @import 'tailwindcss'; +/* The bundled tinyparts snapshot (assets/tinyparts, ~1 MB of part art + JSON) + has no class names in it. Keep Tailwind from scanning it: every scanned file + becomes a CSS dependency, so when `npm run parts:sync` removes one the + running dev server fails to rebuild the stylesheet (ENOENT). */ +@source not './tinyparts'; + @import 'tw-animate-css'; @import './ds-components.css'; @@ -31,45 +37,45 @@ @custom-variant dark (&:where(.dark, .dark *)); :root { - /* ── RAW INK RAMPS — the four PCB solder-mask colors ─────────────────────── + /* ── RAW INK RAMPS: the four PCB solder-mask colors ─────────────────────── *-soft = light tint surface · *-deep = press/border shade · *-on = accessible text on the soft tint. (LIGHT values; dark brightens.) */ - /* RED — error · disconnected · stop */ + /* RED: error · disconnected · stop */ --red-soft: #fbe4e1; --red: #e5544b; --red-deep: #b23a33; --red-on: #8c2a25; - /* GREEN — success · connected · run */ + /* GREEN: success · connected · run */ --green-soft: #daf1e5; --green: #2fa46a; --green-deep: #1e7a4d; --green-on: #155c3a; - /* BLUE — primary action · info · links (the brand) */ + /* BLUE: primary action · info · links (the brand) */ --blue-soft: #dceefd; --blue: #42a5f5; --blue-deep: #1e88e5; --blue-on: #0d5b9a; - /* YELLOW — warning · uploading · highlight (gold) */ + /* YELLOW: warning · uploading · highlight (gold) */ --yellow-soft: #fbf1c2; --yellow: #f3cb00; --yellow-deep: #c6a500; --yellow-on: #6b5800; - /* text on the solid --yellow swatch specifically (button fills, badges) — + /* text on the solid --yellow swatch specifically (button fills, badges): always dark since the gold stays bright in both themes, unlike --yellow-on which is tuned for the soft tinted surface and flips in dark mode */ --yellow-contrast: #3d2f00; - /* PURPLE — Studio AI accent (chat bubbles, send button) */ + /* PURPLE: Studio AI accent (chat bubbles, send button) */ --purple-soft: #efe3fb; --purple: #9d5fd6; --purple-deep: #7c3fb0; --purple-on: #5a2a82; - /* ── COLD NEUTRAL RAMP — white → slate ───────────────────────────────────── */ + /* ── COLD NEUTRAL RAMP: white → slate ───────────────────────────────────── */ --warm-50: #fbfcfd; --warm-100: #f5f7f9; --warm-150: #edf0f3; @@ -83,9 +89,9 @@ --warm-900: #181a1e; --warm-950: #101113; - /* ── SEMANTIC ALIASES — LIGHT (default) ──────────────────────────────────── */ + /* ── SEMANTIC ALIASES: LIGHT (default) ──────────────────────────────────── */ - /* surfaces — near-white canvas, light-grey wells */ + /* surfaces: near-white canvas, light-grey wells */ --bg: #fafbfc; --bg-sunken: #edf0f3; --bg-raised: #ffffff; @@ -112,7 +118,7 @@ --brand-on: var(--blue-on); --brand-contrast: #ffffff; - /* top/bottom bar accent — deliberately independent of --brand so the + /* top/bottom bar accent: deliberately independent of --brand so the window chrome can be recolored without touching buttons/selection/focus */ --bar-accent: var(--blue); --bar-accent-deep: var(--blue-deep); @@ -164,7 +170,7 @@ --control-md: 38px; --control-lg: 46px; - /* ── EFFECTS — radius, shadows, motion, dot-grid ─────────────────────────── */ + /* ── EFFECTS: radius, shadows, motion, dot-grid ─────────────────────────── */ --radius-xs: 3px; --radius-sm: 5px; --radius-md: 8px; @@ -182,7 +188,7 @@ --highlight-top: inset 0 1px 0 0 rgba(255, 255, 255, 0.45); --highlight-top-strong: inset 0 1.5px 0 0 rgba(255, 255, 255, 0.6); - /* SOFT elevation — floating UI only */ + /* SOFT elevation: floating UI only */ --shadow-soft-sm: 0 2px 6px rgba(20, 19, 16, 0.1); --shadow-soft: 0 8px 24px rgba(20, 19, 16, 0.14); --shadow-soft-lg: 0 20px 48px rgba(20, 19, 16, 0.2); @@ -200,7 +206,7 @@ --dot-gap: 18px; --dots: radial-gradient(var(--dot-color) 1.1px, transparent 1.1px); - /* ── EDITOR SYNTAX — derived from the ink palette ────────────────────────── */ + /* ── EDITOR SYNTAX: derived from the ink palette ────────────────────────── */ --syn-bg: var(--bg-sunken); --syn-text: var(--text-body); --syn-comment: var(--text-faint); @@ -212,7 +218,7 @@ --syn-const: var(--red-deep); --syn-punc: var(--text-muted); - /* ── SHADCN SEMANTIC MAPPING — points the component system at design tokens. + /* ── SHADCN SEMANTIC MAPPING: points the component system at design tokens. These reference the semantic aliases above, so they flip with the theme automatically; no need to redefine them in the dark block. ──────────────── */ --radius: var(--radius-md); @@ -247,70 +253,93 @@ --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); - - /* ── LEGACY BACK-COMPAT — old tinyForge token names re-pointed at the new - palette so chrome that still references them stays on-brand and theme-aware - until each component is restyled. Remove once nothing references them. ──── */ - --navy-1000: var(--bg-sunken); - --navy-900: var(--bg-raised); - --navy-800: var(--bg); - --navy-700: var(--bg-raised); - --navy-600: var(--border-default); - --navy-500: var(--bg-sunken); - --navy-400: var(--border-default); - --navy-300: var(--border-soft); - - --cyan-bright: var(--brand); - --cyan: var(--brand); - --cyan-deep: var(--brand-deep); - --cyan-soft: var(--brand-soft); - --cyan-line: var(--border-interactive); - - --pink-bright: var(--brand); - --pink: var(--brand); - --pink-deep: var(--brand-deep); - --pink-soft: var(--brand-soft); - --pink-line: var(--border-interactive); - - --signal-success: var(--status-ok); - --signal-warning: var(--status-warn); - --signal-error: var(--status-error); - --signal-info: var(--status-info); - --signal-success-soft: var(--green-soft); - --signal-warning-soft: var(--yellow-soft); - --signal-error-soft: var(--red-soft); - - --fg-1: var(--text-strong); - --fg-2: var(--text-body); - --fg-3: var(--text-muted); - --fg-4: var(--text-faint); - --fg-on-cyan: var(--brand-contrast); - --fg-on-pink: var(--brand-contrast); - - --grad-brand: var(--brand); - --grad-brand-r: var(--brand); - --grad-ai: var(--brand); - --grad-panel: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0)); - - --r-sm: var(--radius-sm); - --r-md: var(--radius-md); - --r-lg: var(--radius-lg); - --r-pill: var(--radius-pill); - --shadow-panel: var(--shadow-soft); - --shadow-pop: var(--shadow-soft-lg); - --glow-cyan: 0 0 0 1.5px var(--brand-soft); - --glow-pink: 0 0 0 1.5px var(--brand-soft); } -/* ── DARK THEME — Discord-style cold charcoal ─────────────────────────────── +/* ── DARK THEME: Discord-style cold charcoal ─────────────────────────────── Inks brighten so they sit on dark grey without vibrating. Neutrals are cold dark grey (never pure black). Everything referencing the semantic aliases (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; @@ -342,14 +371,14 @@ --bar-accent-deep: var(--blue-deep); --bar-accent-contrast: #06243b; - /* surfaces — Discord-ish cold grey ladder */ + /* surfaces: Discord-ish cold grey ladder */ --bg: #2b2d31; --bg-sunken: #1e1f22; --bg-raised: #313338; --surface-card: #383a40; --surface-overlay: #3e4046; - /* text — cool soft whites */ + /* text: cool soft whites */ --text-strong: #f2f3f5; --text-body: #dbdee1; --text-muted: #969ba3; @@ -456,28 +485,6 @@ --color-border-default: var(--border-default); --color-border-strong: var(--border-strong); --color-border-interactive: var(--border-interactive); - - /* legacy ramps still referenced by un-migrated chrome */ - --color-navy-1000: var(--navy-1000); - --color-navy-900: var(--navy-900); - --color-navy-800: var(--navy-800); - --color-navy-700: var(--navy-700); - --color-navy-600: var(--navy-600); - --color-navy-500: var(--navy-500); - --color-navy-400: var(--navy-400); - --color-navy-300: var(--navy-300); - --color-cyan: var(--cyan); - --color-cyan-bright: var(--cyan-bright); - --color-cyan-deep: var(--cyan-deep); - --color-pink: var(--pink); - --color-pink-bright: var(--pink-bright); - --color-signal-success: var(--signal-success); - --color-signal-warning: var(--signal-warning); - --color-signal-error: var(--signal-error); - --color-fg-1: var(--fg-1); - --color-fg-2: var(--fg-2); - --color-fg-3: var(--fg-3); - --color-fg-4: var(--fg-4); } @layer base { @@ -511,7 +518,7 @@ } } -/* ── mono eyebrow / label — the maker-signature microtype ──────────────────── +/* ── mono eyebrow / label: the maker-signature microtype ──────────────────── e.g. SERIAL MONITOR, ▸ HARDWARE */ .ts-eyebrow { font-family: var(--font-mono); @@ -535,30 +542,3 @@ .dot-grid-loose { --dot-gap: 26px; } - -/* ── RISO registration offset — colorful decorative graphics ONLY ─────────── */ -.ts-riso { - position: relative; - display: inline-block; - color: var(--text-strong); - isolation: isolate; -} -.ts-riso::before { - content: attr(data-riso-glyph); - position: absolute; - inset: 0; - transform: translate(var(--riso-x, 2px), var(--riso-y, 2px)); - color: var(--riso-ink, var(--red)); - mix-blend-mode: multiply; - z-index: -1; - pointer-events: none; -} -.dark .ts-riso::before { - mix-blend-mode: screen; -} - -/* Legacy wordmark hook — now a flat brand treatment (no neon gradient). - The Header wordmark is restyled in the chrome phase. */ -.tf-gradient-text { - color: var(--brand); -} diff --git a/src/renderer/src/assets/circuitdiagram.png b/src/renderer/src/assets/circuitdiagram.png deleted file mode 100644 index 792b6a1..0000000 Binary files a/src/renderer/src/assets/circuitdiagram.png and /dev/null differ diff --git a/src/renderer/src/assets/ds-components.css b/src/renderer/src/assets/ds-components.css index a6e7c2c..a0a56d5 100644 --- a/src/renderer/src/assets/ds-components.css +++ b/src/renderer/src/assets/ds-components.css @@ -1,5 +1,5 @@ /* ============================================================================ - tinyStudio — component layer + tinyStudio: component layer ---------------------------------------------------------------------------- Tactile "pixel-art" primitives + additive display tokens, ported from the tinyStudio Design System. Everything here is token-driven (see base.css), so @@ -7,7 +7,7 @@ be composed with utilities on the app's Radix-based components. ============================================================================ */ -/* ── TACTILE depth helper — shared by Button (filled variants) ──────────────── +/* ── TACTILE depth helper: shared by Button (filled variants) ──────────────── A filled control = inset top highlight + a solid colored bottom edge + a soft ambient shadow. Lifts on hover, presses DOWN on click. Variants set --_edge. */ .tactile { @@ -53,7 +53,7 @@ font-family: var(--font-sans); } -/* Bordered tactile (secondary / outline / icon) — adds a 1.5px inset ring that +/* Bordered tactile (secondary / outline / icon): adds a 1.5px inset ring that the press collapses along with the bottom edge. */ .tactile-bordered { --_edge: var(--border-default); @@ -91,7 +91,37 @@ 0 2px 0 0 var(--_edge); } -/* ── CHECKBOX — tactile, hard-edged ──────────────────────────────────────── */ +/* Outline tactile (icon-only neutral keys: Save, undo/redo…): the same real + 1.5px border as the board/port pickers, in the darker interactive grey, over + a 1.5px ledge. (A sub-pixel 0.5px ledge vanished at 100% scaling and left the + key looking flat.) Lifts on hover, presses flat on click. */ +.tactile-outline { + border: 1.5px solid var(--border-interactive); + box-shadow: + 0 1.5px 0 0 var(--border-interactive), + 0 4px 10px rgba(20, 19, 16, 0.16); + transition: + transform var(--dur-fast) var(--ease-snap), + box-shadow var(--dur-fast) var(--ease-snap), + background-color var(--dur-fast) var(--ease-snap); +} +.tactile-outline:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 2.5px 0 0 var(--border-interactive), + 0 7px 16px rgba(20, 19, 16, 0.2); +} +.tactile-outline:active:not(:disabled) { + transform: translateY(1.5px); + box-shadow: + 0 0 0 0 var(--border-interactive), + 0 1px 3px rgba(20, 19, 16, 0.18); +} +.tactile-outline:disabled { + box-shadow: 0 1.5px 0 0 var(--border-interactive); +} + +/* ── CHECKBOX: tactile, hard-edged ──────────────────────────────────────── */ .ts-check { display: inline-flex; align-items: flex-start; @@ -231,7 +261,7 @@ gap: var(--space-5); } -/* ── BADGE — compact status/label token (mono) ───────────────────────────── */ +/* ── BADGE: compact status/label token (mono) ───────────────────────────── */ .ts-badge { display: inline-flex; align-items: center; @@ -317,22 +347,39 @@ border-color: var(--yellow); } -/* ── TAG / CHIP — libraries, board capabilities, filters ─────────────────── */ +/* ── 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 +387,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; @@ -373,7 +435,7 @@ color: var(--text-strong); } -/* ── STATUS PILL — the IDE's board / connection indicator ────────────────── */ +/* ── STATUS PILL: the IDE's board / connection indicator ────────────────── */ .ts-status { display: inline-flex; align-items: center; @@ -435,23 +497,65 @@ opacity: 0; } } + +/* One-shot "over here" flash: an overlay with a brand ring + tint that pulses + once and fades. Drop it inside a positioned container; the Examples panel + mounts one when the start screen's "Try an example" card sends you there. + Re-trigger by remounting it (a React key). */ +.ts-attention-flash { + position: absolute; + inset: 0; + z-index: 50; + pointer-events: none; + border-radius: inherit; + background: var(--brand-soft); + box-shadow: inset 0 0 0 2.5px var(--brand); + opacity: 0; + animation: ts-attention-flash 1.1s var(--ease-out) forwards; +} +@keyframes ts-attention-flash { + 0% { + opacity: 0; + } + 20% { + opacity: 0.9; + } + 100% { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .ts-attention-flash { + animation-duration: 1.6s; + animation-timing-function: linear; + } +} .ts-status__label b { font-weight: var(--fw-semibold); color: var(--text-strong); } -/* ── STATUS BAR chrome — icon buttons, bell + history, panel menu ────────── - The status bar (like the top bar) is brand-blue in light, raised-grey in - dark; these classes flip via .ts-statusbar / .dark .ts-statusbar. */ +/* ── STATUS BAR chrome: icon buttons, bell + history, panel menu ────────── + The status bar is the raised surface: white in light, grey in dark. */ +.ts-statusbar { + --sb-bg: var(--bg-raised); + --sb-border: var(--border-default); + --sb-text: var(--text-muted); + --sb-faint: var(--text-faint); + --sb-body: var(--text-body); + --sb-strong: var(--text-strong); + --sb-hover: var(--bg-sunken); + --sb-on: #e2e6eb; + --sb-link: var(--bar-accent); +} +.dark .ts-statusbar { + --sb-on: var(--surface-card); +} -/* StatusPill label sits on the colored bar */ +/* StatusPill label sits on the bar */ .ts-statusbar .ts-status__label, .ts-statusbar .ts-status__label b { - color: #fff; -} -.dark .ts-statusbar .ts-status__label, -.dark .ts-statusbar .ts-status__label b { - color: var(--text-body); + color: var(--sb-body); } /* status-bar icon buttons (panel menu + bell triggers) */ @@ -477,26 +581,15 @@ color: var(--brand); } .ts-statusbar .st-iconbtn { - color: #fff; + color: var(--sb-text); } .ts-statusbar .st-iconbtn:hover { - background: rgba(255, 255, 255, 0.18); - color: #fff; + background: var(--sb-hover); + color: var(--sb-body); } .ts-statusbar .st-iconbtn--on { - background: rgba(255, 255, 255, 0.24); - color: #fff; -} -.dark .ts-statusbar .st-iconbtn { - color: var(--text-muted); -} -.dark .ts-statusbar .st-iconbtn:hover { - background: var(--bg-sunken); - color: var(--text-body); -} -.dark .ts-statusbar .st-iconbtn--on { - background: var(--surface-card); - color: var(--text-strong); + background: var(--sb-on); + color: var(--sb-strong); } .st-bell-badge { @@ -517,10 +610,7 @@ box-shadow: 0 0 0 1.5px var(--bg-raised); } .ts-statusbar .st-bell-badge { - box-shadow: 0 0 0 1.5px var(--brand); -} -.dark .ts-statusbar .st-bell-badge { - box-shadow: 0 0 0 1.5px var(--bg-raised); + box-shadow: 0 0 0 1.5px var(--sb-bg); } /* ── notification bell + history popover (anchored bottom-right) ──────────── */ @@ -732,3 +822,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/electron.svg b/src/renderer/src/assets/electron.svg deleted file mode 100644 index 45ef09c..0000000 --- a/src/renderer/src/assets/electron.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/renderer/src/assets/parts/7segment-100-cat.json b/src/renderer/src/assets/parts/7segment-100-cat.json deleted file mode 100644 index 710dd05..0000000 --- a/src/renderer/src/assets/parts/7segment-100-cat.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "type": "7segment-100-cat", - "label": "7 Segment Display", - "family": "Character Display", - "description": "7 Segment Display with common cathode.", - "source": { - "fzp": "7segment_100_cat.fzp", - "moduleId": "7segment100cat" - }, - "views": { - "breadboard": { - "svg": "", - "w": 256, - "h": 347, - "pins": { - "Segment E": [ - 98.76, - 324.73 - ], - "Segment D": [ - 113.16, - 324.73 - ], - "common cathode": [ - 127.56, - 324.73 - ], - "Segment C": [ - 141.96, - 324.73 - ], - "Segment DP": [ - 156.36, - 324.73 - ], - "Segment B": [ - 156.36, - 21.43 - ], - "Segment A": [ - 141.96, - 21.43 - ], - "common cathode.2": [ - 127.56, - 21.43 - ], - "Segment F": [ - 113.16, - 21.43 - ], - "Segment G": [ - 98.76, - 21.43 - ] - } - }, - "schematic": { - "svg": "5103, 8912467", - "w": 221.13, - "h": 115.26, - "pins": { - "Segment E": [ - 125.92, - 114.74 - ], - "Segment D": [ - 97.05, - 114.77 - ], - "common cathode": [ - 111.57, - 0.49 - ], - "Segment C": [ - 68.35, - 114.77 - ], - "Segment DP": [ - 212.32, - 114.77 - ], - "Segment B": [ - 39.6, - 114.77 - ], - "Segment A": [ - 10.68, - 114.77 - ], - "common cathode.2": [ - 111.57, - 0.49 - ], - "Segment F": [ - 154.79, - 114.77 - ], - "Segment G": [ - 183.57, - 114.77 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/battery-aa.json b/src/renderer/src/assets/parts/battery-aa.json deleted file mode 100644 index 60f4283..0000000 --- a/src/renderer/src/assets/parts/battery-aa.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "battery-aa", - "label": "Battery", - "family": "Battery", - "description": "Your standard 2x AA battery pack (3 Volts)", - "source": { - "fzp": "battery-AA.fzp", - "moduleId": "1000AFDF10011leg" - }, - "views": { - "breadboard": { - "svg": "", - "w": 222.88, - "h": 116.64, - "pins": { - "-": [ - 215.99, - 63.58 - ], - "+": [ - 215.99, - 45.33 - ] - } - }, - "schematic": { - "svg": "12", - "w": 39.33, - "h": 30.34, - "pins": { - "-": [ - 38.87, - 15.17 - ], - "+": [ - 0.47, - 15.17 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/buzzer-v15.json b/src/renderer/src/assets/parts/buzzer-v15.json deleted file mode 100644 index 3573c4f..0000000 --- a/src/renderer/src/assets/parts/buzzer-v15.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "buzzer-v15", - "label": "LilyPad Buzzer", - "family": "microcontroller board (lilypad)", - "description": "This is a small buzzer for the LilyPad system. Use 2 I/O pins on the LilyPad main board and create different noises based on the different frequency of I/O toggling. Loud enough to hear inside a pocket but not obtrusively loud.\n\nPlease note: This is an inductive buzzer meaning that is will act as a short to ground if you are not actively driving it. We recommend you put both I/O pins to low (0V) when the buzzer is not used. Also, it's come to our attention that washing these buzzers will damage them. Until we've figured out a solution to this, avoid washing any portion of your project that contains one of these buzzers.\n\nLilyPad is a wearable e-textile technology developed by Leah Buechley and cooperatively designed by Leah and SparkFun. Each LilyPad was creatively designed to have large connecting pads to allow them to be sewn into clothing. Various input, output, power, and sensor boards are available. They're even washable!\n\nNote: A portion of this sale is given back to Dr. Leah Buechley for continued development and education of e-textiles.\n\nDimensions:\n\n20mm outer diameter\nThin 0.8mm PCB", - "source": { - "fzp": "Buzzer-v15.fzp", - "moduleId": "Buzzer-v15" - }, - "views": { - "breadboard": { - "svg": "Fritzing 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 G4510", - "w": 56.69, - "h": 56.69, - "pins": { - "-": [ - 9.92, - 17.72 - ], - "+": [ - 46.74, - 38.98 - ] - } - }, - "schematic": { - "svg": "-+LilyPadBuzzer", - "w": 28.8, - "h": 76.8, - "pins": { - "-": [ - 9.6, - 76.8 - ], - "+": [ - 9.6, - 0 - ] - } - } - }, - "icon": "Fritzing 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 G4510" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/capacitor-ceramic-100mil.json b/src/renderer/src/assets/parts/capacitor-ceramic-100mil.json deleted file mode 100644 index e638a33..0000000 --- a/src/renderer/src/assets/parts/capacitor-ceramic-100mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "capacitor-ceramic-100mil", - "label": "Ceramic Capacitor", - "family": "Capacitor [bidirectional]", - "description": "100 nF multilayer Ceramic Capacitor", - "source": { - "fzp": "capacitor_ceramic_100mil.fzp", - "moduleId": "100milCeramicCapacitorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 18.08, - "h": 23.83, - "pins": { - "0": [ - 4.24, - 14.71 - ], - "1": [ - 13.84, - 14.71 - ] - } - }, - "schematic": { - "svg": "12", - "w": 15.36, - "h": 29.73, - "pins": { - "0": [ - 7.68, - 29.27 - ], - "1": [ - 7.68, - 0.47 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/capacitor-ceramic-200mil.json b/src/renderer/src/assets/parts/capacitor-ceramic-200mil.json deleted file mode 100644 index 59a8719..0000000 --- a/src/renderer/src/assets/parts/capacitor-ceramic-200mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "capacitor-ceramic-200mil", - "label": "Ceramic Capacitor", - "family": "Capacitor [bidirectional]", - "description": "100 nF multilayer Ceramic Capacitor", - "source": { - "fzp": "capacitor_ceramic_200mil.fzp", - "moduleId": "200milCeramicCapacitorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 18.08, - "h": 23.83, - "pins": { - "0": [ - 4.24, - 14.71 - ], - "1": [ - 13.84, - 14.71 - ] - } - }, - "schematic": { - "svg": "12", - "w": 15.36, - "h": 29.73, - "pins": { - "0": [ - 7.68, - 29.27 - ], - "1": [ - 7.68, - 0.47 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/capacitor-electrolytic-medium.json b/src/renderer/src/assets/parts/capacitor-electrolytic-medium.json deleted file mode 100644 index f54c219..0000000 --- a/src/renderer/src/assets/parts/capacitor-electrolytic-medium.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "capacitor-electrolytic-medium", - "label": "Electrolytic Capacitor", - "family": "Capacitor [unidirectional]", - "description": "electrolytic capacitor", - "source": { - "fzp": "capacitor_electrolytic_medium.fzp", - "moduleId": "MediumElectrolyticCapacitorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 30.24, - "h": 57.5, - "pins": { - "-": [ - 10.36, - 50.3 - ], - "+": [ - 19.96, - 50.3 - ] - } - }, - "schematic": { - "svg": "21", - "w": 20.16, - "h": 29.73, - "pins": { - "-": [ - 10.08, - 29.27 - ], - "+": [ - 10.08, - 0.47 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/diode-1n4001-300mil.json b/src/renderer/src/assets/parts/diode-1n4001-300mil.json deleted file mode 100644 index 3a3b396..0000000 --- a/src/renderer/src/assets/parts/diode-1n4001-300mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "diode-1n4001-300mil", - "label": "Rectifier Diode", - "family": "Diode", - "description": "A standard rectifier diode", - "source": { - "fzp": "diode_1N4001_300mil.fzp", - "moduleId": "3254CBFC44diode" - }, - "views": { - "breadboard": { - "svg": "", - "w": 39.84, - "h": 9.6, - "pins": { - "cathode": [ - 0.68, - 4.79 - ], - "anode": [ - 39.16, - 4.94 - ] - } - }, - "schematic": { - "svg": "21", - "w": 8.38, - "h": 22.02, - "pins": { - "cathode": [ - 4.19, - 21.88 - ], - "anode": [ - 4.19, - 0.28 - ] - } - } - }, - "icon": "basic-diode.svg" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/diode-zener-0-5w-3-6v-300mil.json b/src/renderer/src/assets/parts/diode-zener-0-5w-3-6v-300mil.json deleted file mode 100644 index 9f79cd8..0000000 --- a/src/renderer/src/assets/parts/diode-zener-0-5w-3-6v-300mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "diode-zener-0-5w-3-6v-300mil", - "label": "Zener Diode", - "family": "Diode", - "description": "A standard zener diode", - "source": { - "fzp": "diode_zener_0_5w_3_6v_300mil.fzp", - "moduleId": "xc307sadf951b6bdd0d67c0d2ad67eabZenerDiodeModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 39.84, - "h": 9.6, - "pins": { - "cathode": [ - 0.68, - 4.79 - ], - "anode": [ - 39.16, - 4.94 - ] - } - }, - "schematic": { - "svg": "12", - "w": 21.6, - "h": 9.36, - "pins": { - "cathode": [ - 21.6, - 4.94 - ], - "anode": [ - 0, - 5.1 - ] - } - } - }, - "icon": "basic-diode.svg" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/index.json b/src/renderer/src/assets/parts/index.json deleted file mode 100644 index 62d5cc0..0000000 --- a/src/renderer/src/assets/parts/index.json +++ /dev/null @@ -1,382 +0,0 @@ -{ - "version": 1, - "generated": "2026-06-22T18:53:35.395Z", - "parts": [ - { - "type": "switch-spst", - "label": "Slide Switch (SPST)", - "family": "Switch", - "familySlug": "switch", - "views": [ - "breadboard" - ], - "pins": 2, - "icon": "SPST", - "file": "switch-spst.json" - }, - { - "type": "smd-inductor-0805", - "label": "Inductor", - "family": "air-core inductor", - "familySlug": "air-core-inductor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "smd-inductor-0805.json" - }, - { - "type": "battery-aa", - "label": "Battery", - "family": "Battery", - "familySlug": "battery", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "battery-aa.json" - }, - { - "type": "transistor-signal-npn-to92-ebc", - "label": "NPN-Transistor", - "family": "Bipolar Transistor", - "familySlug": "bipolar-transistor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "transistor-signal-npn-to92-ebc.json" - }, - { - "type": "transistor-signal-pnp-to92-ebc", - "label": "PNP-Transistor", - "family": "Bipolar Transistor", - "familySlug": "bipolar-transistor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "transistor-signal-pnp-to92-ebc.json" - }, - { - "type": "capacitor-ceramic-100mil", - "label": "Ceramic Capacitor", - "family": "Capacitor [bidirectional]", - "familySlug": "capacitor-bidirectional", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "capacitor-ceramic-100mil.json" - }, - { - "type": "capacitor-ceramic-200mil", - "label": "Ceramic Capacitor", - "family": "Capacitor [bidirectional]", - "familySlug": "capacitor-bidirectional", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "capacitor-ceramic-200mil.json" - }, - { - "type": "capacitor-electrolytic-medium", - "label": "Electrolytic Capacitor", - "family": "Capacitor [unidirectional]", - "familySlug": "capacitor-unidirectional", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "capacitor-electrolytic-medium.json" - }, - { - "type": "7segment-100-cat", - "label": "7 Segment Display", - "family": "Character Display", - "familySlug": "character-display", - "views": [ - "breadboard", - "schematic" - ], - "pins": 10, - "icon": "", - "file": "7segment-100-cat.json" - }, - { - "type": "diode-1n4001-300mil", - "label": "Rectifier Diode", - "family": "Diode", - "familySlug": "diode", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "basic-diode.svg", - "file": "diode-1n4001-300mil.json" - }, - { - "type": "diode-zener-0-5w-3-6v-300mil", - "label": "Zener Diode", - "family": "Diode", - "familySlug": "diode", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "basic-diode.svg", - "file": "diode-zener-0-5w-3-6v-300mil.json" - }, - { - "type": "ir-receiver-v14", - "label": "IR Receiver Breakout", - "family": "IR", - "familySlug": "ir", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "Fritzing 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:TSOP8523", - "file": "ir-receiver-v14.json" - }, - { - "type": "led-generic-3mm", - "label": "Red LED - 3mm", - "family": "LED", - "familySlug": "led", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "led-generic-3mm.json" - }, - { - "type": "led-generic-5mm", - "label": "Red LED - 5mm", - "family": "LED", - "familySlug": "led", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "led-generic-5mm.json" - }, - { - "type": "buzzer-v15", - "label": "LilyPad Buzzer", - "family": "microcontroller board (lilypad)", - "familySlug": "microcontroller-board-lilypad", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "Fritzing 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 G4510", - "file": "buzzer-v15.json" - }, - { - "type": "ldr-photocell-300mil-v5", - "label": "Photocell (LDR)", - "family": "Photo-Resistor", - "familySlug": "photo-resistor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "ldr-photocell-300mil-v5.json" - }, - { - "type": "piezo-sensor", - "label": "Piezo Speaker", - "family": "Piezo", - "familySlug": "piezo", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "piezo-sensor.json" - }, - { - "type": "potentiometer-trimmer-6mm-5", - "label": "6mm Trimmer Potentiometer", - "family": "Potentiometer", - "familySlug": "potentiometer", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "potentiometer-trimmer-6mm-5.json" - }, - { - "type": "potentiometer-rotary-16mm-5", - "label": "Rotary Potentiometer (Large)", - "family": "Potentiometer", - "familySlug": "potentiometer", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "potentiometer-rotary-16mm-5.json" - }, - { - "type": "reedswitch-500mil", - "label": "Reed switch", - "family": "Reed Switch", - "familySlug": "reed-switch", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "reedswitch-500mil.json" - }, - { - "type": "resistor", - "label": "220 Ω Resistor", - "family": "Resistor", - "familySlug": "resistor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "resistor.json" - }, - { - "type": "servo", - "label": "Basic Servo", - "family": "Servo", - "familySlug": "servo", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "servo.json" - }, - { - "type": "sparkfun-sensors-mic-electret-smd", - "label": "MIC", - "family": "sparkfun Electret Mic", - "familySlug": "sparkfun-electret-mic", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "sparkfun-sensors-mic-electret-smd.json" - }, - { - "type": "sparkfun-passives-fuse-x20mm", - "label": "FUSE", - "family": "sparkfun Fuse", - "familySlug": "sparkfun-fuse", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "sparkfun-passives-fuse-x20mm.json" - }, - { - "type": "sparkfun-discretesemi-mosfet-nchannel-pth", - "label": "MOSFET-NCHANNEL", - "family": "sparkfun Mosfet (N Channel)", - "familySlug": "sparkfun-mosfet-n-channel", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "sparkfun-discretesemi-mosfet-nchannel-pth.json" - }, - { - "type": "pushbutton", - "label": "Round Pushbutton", - "family": "Switch - 2 pin", - "familySlug": "switch-2-pin", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "pushbutton.json" - }, - { - "type": "te-relay", - "label": "RELAY", - "family": "TE General Purpose Relays", - "familySlug": "te-general-purpose-relays", - "views": [ - "breadboard", - "schematic" - ], - "pins": 4, - "icon": "sparkfun-electromechanical_relay-g5q_breadboard.svgVDE", - "file": "te-relay.json" - }, - { - "type": "thermistor-300mil", - "label": "Temperature Sensor (Thermistor)", - "family": "temperature sensor", - "familySlug": "temperature-sensor", - "views": [ - "breadboard", - "schematic" - ], - "pins": 2, - "icon": "", - "file": "thermistor-300mil.json" - }, - { - "type": "voltage-regulator-7805", - "label": "Voltage Regulator - 5V", - "family": "Voltage Regulator", - "familySlug": "voltage-regulator", - "views": [ - "breadboard", - "schematic" - ], - "pins": 3, - "icon": "", - "file": "voltage-regulator-7805.json" - } - ] -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/ir-receiver-v14.json b/src/renderer/src/assets/parts/ir-receiver-v14.json deleted file mode 100644 index e73444f..0000000 --- a/src/renderer/src/assets/parts/ir-receiver-v14.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "ir-receiver-v14", - "label": "IR Receiver Breakout", - "family": "IR", - "description": "

\n\n\n\tDescription: This is a very small infrared receiver based on the TSOP85 receiver from Vishay. This receiver has all the filtering and 38kHz demodulation built into the unit. Simply point a IR remote at the receiver, hit a button, and you'll see a stream of 1s and 0s out of the data pin.
\n\n\n\t
\n\n\n\tDocuments:

\n\n\n", - "source": { - "fzp": "IR_Receiver_v14.fzp", - "moduleId": "IR_Receiver_v14" - }, - "views": { - "breadboard": { - "svg": "Fritzing 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:TSOP8523", - "w": 52.8, - "h": 38.4, - "pins": { - "GND": [ - 43.2, - 28.8 - ], - "OUT": [ - 43.2, - 19.2 - ], - "VCC": [ - 43.2, - 9.6 - ] - } - }, - "schematic": { - "svg": "OUTGNDVCCIRReceiverBreakout", - "w": 76.8, - "h": 86.4, - "pins": { - "GND": [ - 28.8, - 86.4 - ], - "OUT": [ - 76.8, - 38.4 - ], - "VCC": [ - 28.8, - 0 - ] - } - } - }, - "icon": "Fritzing 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:TSOP8523" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/ldr-photocell-300mil-v5.json b/src/renderer/src/assets/parts/ldr-photocell-300mil-v5.json deleted file mode 100644 index d7fae62..0000000 --- a/src/renderer/src/assets/parts/ldr-photocell-300mil-v5.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "ldr-photocell-300mil-v5", - "label": "Photocell (LDR)", - "family": "Photo-Resistor", - "description": "A generic light dependent resistor (LDR)", - "source": { - "fzp": "LDR_photocell_300mil_v5.fzp", - "moduleId": "2010BBCD20113PhotocellModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 32.44, - "h": 14.58, - "pins": { - "pin 0": [ - 1.05, - 6.38 - ], - "pin 1": [ - 29.85, - 6.38 - ] - } - }, - "schematic": { - "svg": "12", - "w": 43.9, - "h": 15.12, - "pins": { - "pin 0": [ - 0.35, - 7.56 - ], - "pin 1": [ - 43.55, - 7.56 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/led-generic-3mm.json b/src/renderer/src/assets/parts/led-generic-3mm.json deleted file mode 100644 index 1149804..0000000 --- a/src/renderer/src/assets/parts/led-generic-3mm.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "led-generic-3mm", - "label": "Red LED - 3mm", - "family": "LED", - "description": "A generic red LED (~1.8V)", - "source": { - "fzp": "LED-generic-3mm.fzp", - "moduleId": "3mmColorLEDModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 15.46, - "h": 29.21, - "pins": { - "cathode": [ - 4.53, - 25.78 - ], - "anode": [ - 11.73, - 25.78 - ] - } - }, - "schematic": { - "svg": "", - "w": 13.68, - "h": 22.03, - "pins": { - "cathode": [ - 9.9, - 21.82 - ], - "anode": [ - 9.9, - 0.22 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/led-generic-5mm.json b/src/renderer/src/assets/parts/led-generic-5mm.json deleted file mode 100644 index 804368c..0000000 --- a/src/renderer/src/assets/parts/led-generic-5mm.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "led-generic-5mm", - "label": "Red LED - 5mm", - "family": "LED", - "description": "A generic red LED (~1.8V)", - "source": { - "fzp": "LED-generic-5mm.fzp", - "moduleId": "5mmColorLEDModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 20.61, - "h": 38.94, - "pins": { - "cathode": [ - 6.04, - 34.37 - ], - "anode": [ - 15.64, - 34.37 - ] - } - }, - "schematic": { - "svg": "", - "w": 13.68, - "h": 22.03, - "pins": { - "cathode": [ - 9.9, - 21.82 - ], - "anode": [ - 9.9, - 0.22 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/piezo-sensor.json b/src/renderer/src/assets/parts/piezo-sensor.json deleted file mode 100644 index 05c9722..0000000 --- a/src/renderer/src/assets/parts/piezo-sensor.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "piezo-sensor", - "label": "Piezo Speaker", - "family": "Piezo", - "description": "Piezo", - "source": { - "fzp": "piezo sensor.fzp", - "moduleId": "8abf6496b5d466c7a1893c17a296a676" - }, - "views": { - "breadboard": { - "svg": "", - "w": 104.64, - "h": 90.21, - "pins": { - "pin1": [ - 46.2, - 88.55 - ], - "pin2": [ - 55.78, - 88.55 - ] - } - }, - "schematic": { - "svg": "21", - "w": 25.92, - "h": 20.51, - "pins": { - "pin1": [ - 9.37, - 20.17 - ], - "pin2": [ - 16.55, - 20.17 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/potentiometer-rotary-16mm-5.json b/src/renderer/src/assets/parts/potentiometer-rotary-16mm-5.json deleted file mode 100644 index 5492e4e..0000000 --- a/src/renderer/src/assets/parts/potentiometer-rotary-16mm-5.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "potentiometer-rotary-16mm-5", - "label": "Rotary Potentiometer (Large)", - "family": "Potentiometer", - "description": "A rotary adjustable resistor (potentiometer).", - "source": { - "fzp": "potentiometer_rotary_16mm_5.fzp", - "moduleId": "potentiometer_rotary_16mm_5_PotentiometerModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 56.64, - "h": 113.38, - "pins": { - "leg1": [ - 9.15, - 107.64 - ], - "wiper": [ - 28.32, - 107.64 - ], - "leg2": [ - 47.49, - 107.64 - ] - } - }, - "schematic": { - "svg": "132", - "w": 24.02, - "h": 39.38, - "pins": { - "leg1": [ - 4.35, - 38.88 - ], - "wiper": [ - 23.54, - 19.68 - ], - "leg2": [ - 4.35, - 0.48 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/potentiometer-trimmer-6mm-5.json b/src/renderer/src/assets/parts/potentiometer-trimmer-6mm-5.json deleted file mode 100644 index 827e628..0000000 --- a/src/renderer/src/assets/parts/potentiometer-trimmer-6mm-5.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "potentiometer-trimmer-6mm-5", - "label": "6mm Trimmer Potentiometer", - "family": "Potentiometer", - "description": "A standard 6mm trimmer potentiometer.", - "source": { - "fzp": "potentiometer_trimmer_6mm_5.fzp", - "moduleId": "potentiometer_trimmer_6mm_5_PotentiometerModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 23.73, - "h": 31.68, - "pins": { - "leg1": [ - 2.27, - 30.24 - ], - "wiper": [ - 11.87, - 1.44 - ], - "leg2": [ - 21.47, - 30.24 - ] - } - }, - "schematic": { - "svg": "132", - "w": 24.02, - "h": 39.38, - "pins": { - "leg1": [ - 4.35, - 38.88 - ], - "wiper": [ - 23.54, - 19.68 - ], - "leg2": [ - 4.35, - 0.48 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/pushbutton.json b/src/renderer/src/assets/parts/pushbutton.json deleted file mode 100644 index 8235411..0000000 --- a/src/renderer/src/assets/parts/pushbutton.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "pushbutton", - "label": "Round Pushbutton", - "family": "Switch - 2 pin", - "description": "A generic pushbutton.", - "source": { - "fzp": "Pushbutton.fzp", - "moduleId": "33b94ebdb1ef4e7cf0f8425956cfca60" - }, - "views": { - "breadboard": { - "svg": "", - "w": 67.42, - "h": 74.89, - "pins": { - "leg0": [ - 28.19, - 72.79 - ], - "leg1": [ - 37.83, - 72.79 - ] - } - }, - "schematic": { - "svg": "12", - "w": 6.62, - "h": 29.5, - "pins": { - "leg0": [ - 4.74, - 29.14 - ], - "leg1": [ - 4.74, - 0.3 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/reedswitch-500mil.json b/src/renderer/src/assets/parts/reedswitch-500mil.json deleted file mode 100644 index 19392f0..0000000 --- a/src/renderer/src/assets/parts/reedswitch-500mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "reedswitch-500mil", - "label": "Reed switch", - "family": "Reed Switch", - "description": "Reed switch", - "source": { - "fzp": "reedSwitch_500mil.fzp", - "moduleId": "c0e65b2d0c9003507cf4a3222e995a45" - }, - "views": { - "breadboard": { - "svg": "", - "w": 60.48, - "h": 10.56, - "pins": { - "pin 1": [ - 0.67, - 4.53 - ], - "pin 2": [ - 55.29, - 4.53 - ] - } - }, - "schematic": { - "svg": "12", - "w": 36.02, - "h": 19.29, - "pins": { - "pin 1": [ - 35.92, - 10.11 - ], - "pin 2": [ - 0.16, - 10.11 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/resistor.json b/src/renderer/src/assets/parts/resistor.json deleted file mode 100644 index 18d5a31..0000000 --- a/src/renderer/src/assets/parts/resistor.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "resistor", - "label": "220 Ω Resistor", - "family": "Resistor", - "description": "A generic resistor (0.25W)", - "source": { - "fzp": "resistor.fzp", - "moduleId": "ResistorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 41.2, - "h": 9.32, - "pins": { - "Pin 0": [ - 0.96, - 3.93 - ], - "Pin 1": [ - 40.24, - 3.93 - ] - } - }, - "schematic": { - "svg": "21", - "w": 39.33, - "h": 8.26, - "pins": { - "Pin 0": [ - 0.47, - 4.13 - ], - "Pin 1": [ - 38.87, - 4.13 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/servo.json b/src/renderer/src/assets/parts/servo.json deleted file mode 100644 index 7554843..0000000 --- a/src/renderer/src/assets/parts/servo.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "servo", - "label": "Basic Servo", - "family": "Servo", - "description": "A generic servo.", - "source": { - "fzp": "servo.fzp", - "moduleId": "3234DBDC80leg" - }, - "views": { - "breadboard": { - "svg": "", - "w": 109.42, - "h": 88.03, - "pins": { - "gnd": [ - 97.34, - 70.58 - ], - "vcc": [ - 97.45, - 67.87 - ], - "pulse": [ - 97.34, - 65.2 - ] - } - }, - "schematic": { - "svg": "123+pulse-Servo", - "w": 47.1, - "h": 43.2, - "pins": { - "gnd": [ - 0.17, - 28.8 - ], - "vcc": [ - 0.17, - 14.4 - ], - "pulse": [ - 0.17, - 21.6 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/smd-inductor-0805.json b/src/renderer/src/assets/parts/smd-inductor-0805.json deleted file mode 100644 index fe7cebb..0000000 --- a/src/renderer/src/assets/parts/smd-inductor-0805.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "smd-inductor-0805", - "label": "Inductor", - "family": "air-core inductor", - "description": "Inductor", - "source": { - "fzp": "SMD_inductor_0805.fzp", - "moduleId": "805InductorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 41.12, - "h": 16.92, - "pins": { - "pin 0": [ - 2.37, - 10.82 - ], - "pin 1": [ - 38.46, - 10.85 - ] - } - }, - "schematic": { - "svg": "12", - "w": 5.76, - "h": 58.53, - "pins": { - "pin 0": [ - 0.48, - 0.47 - ], - "pin 1": [ - 0.48, - 58.07 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/sparkfun-discretesemi-mosfet-nchannel-pth.json b/src/renderer/src/assets/parts/sparkfun-discretesemi-mosfet-nchannel-pth.json deleted file mode 100644 index ddfa006..0000000 --- a/src/renderer/src/assets/parts/sparkfun-discretesemi-mosfet-nchannel-pth.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "sparkfun-discretesemi-mosfet-nchannel-pth", - "label": "MOSFET-NCHANNEL", - "family": "sparkfun Mosfet (N Channel)", - "description": "Generic NMOS footprint", - "source": { - "fzp": "sparkfun-discretesemi-mosfet-nchannel-pth.fzp", - "moduleId": "SparkFun-DiscreteSemi-MOSFET-NCHANNEL-PTH" - }, - "views": { - "breadboard": { - "svg": "", - "w": 22.04, - "h": 32.1, - "pins": { - "D": [ - 1.44, - 31.33 - ], - "G": [ - 20.6, - 31.33 - ], - "S": [ - 11.01, - 31.33 - ] - } - }, - "schematic": { - "svg": "312", - "w": 42.51, - "h": 39.33, - "pins": { - "D": [ - 29.27, - 0.47 - ], - "G": [ - 0.47, - 19.67 - ], - "S": [ - 29.27, - 38.87 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/sparkfun-passives-fuse-x20mm.json b/src/renderer/src/assets/parts/sparkfun-passives-fuse-x20mm.json deleted file mode 100644 index 28daee5..0000000 --- a/src/renderer/src/assets/parts/sparkfun-passives-fuse-x20mm.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "type": "sparkfun-passives-fuse-x20mm", - "label": "FUSE", - "family": "sparkfun Fuse", - "source": { - "fzp": "sparkfun-passives-fuse-x20mm.fzp", - "moduleId": "SparkFun-Passives-FUSE-X20MM" - }, - "views": { - "breadboard": { - "svg": "", - "w": 66.77, - "h": 20.59, - "pins": { - "1": [ - 5.04, - 10.29 - ], - "2": [ - 45.17, - 10.29 - ] - } - }, - "schematic": { - "svg": "13", - "w": 77.73, - "h": 20.16, - "pins": { - "1": [ - 0.47, - 10.08 - ], - "2": [ - 77.27, - 10.08 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/sparkfun-sensors-mic-electret-smd.json b/src/renderer/src/assets/parts/sparkfun-sensors-mic-electret-smd.json deleted file mode 100644 index 7cde49b..0000000 --- a/src/renderer/src/assets/parts/sparkfun-sensors-mic-electret-smd.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "sparkfun-sensors-mic-electret-smd", - "label": "MIC", - "family": "sparkfun Electret Mic", - "description": "Omni-directional electret microphone. 9.7mm standard diameter.", - "source": { - "fzp": "sparkfun-sensors-mic-electret_smd.fzp", - "moduleId": "SparkFun-Sensors-MIC-ELECTRET_SMD" - }, - "views": { - "breadboard": { - "svg": "Fritzing breadboard generated by brd2svg", - "w": 44.14, - "h": 57.6, - "pins": { - "1": [ - 22.07, - 52.8 - ], - "2": [ - 22.07, - 4.8 - ] - } - }, - "schematic": { - "svg": "21", - "w": 25.92, - "h": 20.51, - "pins": { - "1": [ - 9.36, - 19.95 - ], - "2": [ - 16.55, - 19.98 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/switch-spst.json b/src/renderer/src/assets/parts/switch-spst.json deleted file mode 100644 index 328e191..0000000 --- a/src/renderer/src/assets/parts/switch-spst.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "switch-spst", - "label": "Slide Switch (SPST)", - "family": "Switch", - "description": "A hand-authored example part: a single-pole, single-throw slide switch. Copy this file as a starting point for your own parts.", - "views": { - "breadboard": { - "svg": "SPST", - "w": 60, - "h": 40, - "pins": { - "1": [12, 38], - "2": [48, 38] - } - } - } -} diff --git a/src/renderer/src/assets/parts/te-relay.json b/src/renderer/src/assets/parts/te-relay.json deleted file mode 100644 index a41322b..0000000 --- a/src/renderer/src/assets/parts/te-relay.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "type": "te-relay", - "label": "RELAY", - "family": "TE General Purpose Relays", - "description": "\n\n

SPST relay

", - "source": { - "fzp": "TE_RELAY.fzp", - "moduleId": "TE_RELAY" - }, - "views": { - "breadboard": { - "svg": "sparkfun-electromechanical_relay-g5q_breadboard.svgVDE", - "w": 77.57, - "h": 84.4, - "pins": { - "COIL1": [ - 72.38, - 83.98 - ], - "COIL2": [ - 72.38, - 55.18 - ], - "MAIN": [ - 14.78, - 83.98 - ], - "NO": [ - 5.18, - 55.18 - ] - } - }, - "schematic": { - "svg": "sparkfun-electromechanical_relay-spst_schematic.svg123 4", - "w": 68.13, - "h": 58.53, - "pins": { - "COIL1": [ - 0.67, - 10.07 - ], - "COIL2": [ - 0.67, - 48.47 - ], - "MAIN": [ - 67.4, - 29.27 - ], - "NO": [ - 67.4, - 48.47 - ] - } - } - }, - "icon": "sparkfun-electromechanical_relay-g5q_breadboard.svgVDE" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/thermistor-300mil.json b/src/renderer/src/assets/parts/thermistor-300mil.json deleted file mode 100644 index 4e8c899..0000000 --- a/src/renderer/src/assets/parts/thermistor-300mil.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "type": "thermistor-300mil", - "label": "Temperature Sensor (Thermistor)", - "family": "temperature sensor", - "description": "A 10k NTC Thermistor (Temperature Sensor)", - "source": { - "fzp": "thermistor_300mil.fzp", - "moduleId": "2012BCEC1FBThermistorModuleID" - }, - "views": { - "breadboard": { - "svg": "", - "w": 22.08, - "h": 22.08, - "pins": { - "pin 0": [ - 1.44, - 12.72 - ], - "pin 1": [ - 20.64, - 12.72 - ] - } - }, - "schematic": { - "svg": "12", - "w": 17, - "h": 43.2, - "pins": { - "pin 0": [ - 10.31, - 42.86 - ], - "pin 1": [ - 10.31, - 0.34 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/transistor-signal-npn-to92-ebc.json b/src/renderer/src/assets/parts/transistor-signal-npn-to92-ebc.json deleted file mode 100644 index cc54de0..0000000 --- a/src/renderer/src/assets/parts/transistor-signal-npn-to92-ebc.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "transistor-signal-npn-to92-ebc", - "label": "NPN-Transistor", - "family": "Bipolar Transistor", - "description": "A standard NPN-transistor", - "source": { - "fzp": "transistor_signal_NPN_TO92_EBC.fzp", - "moduleId": "NPN-to92-ebc" - }, - "views": { - "breadboard": { - "svg": "", - "w": 22.04, - "h": 32.11, - "pins": { - "E": [ - 1.44, - 30.11 - ], - "B": [ - 11.01, - 30.11 - ], - "C": [ - 20.6, - 30.11 - ] - } - }, - "schematic": { - "svg": "123", - "w": 20.13, - "h": 39.33, - "pins": { - "E": [ - 19.67, - 38.87 - ], - "B": [ - 0.47, - 19.67 - ], - "C": [ - 19.67, - 0.47 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/transistor-signal-pnp-to92-ebc.json b/src/renderer/src/assets/parts/transistor-signal-pnp-to92-ebc.json deleted file mode 100644 index 6b8721f..0000000 --- a/src/renderer/src/assets/parts/transistor-signal-pnp-to92-ebc.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "transistor-signal-pnp-to92-ebc", - "label": "PNP-Transistor", - "family": "Bipolar Transistor", - "description": "A standard PNP signal transistor", - "source": { - "fzp": "transistor_signal_PNP_TO92_EBC.fzp", - "moduleId": "2244EBEC21e" - }, - "views": { - "breadboard": { - "svg": "", - "w": 22.04, - "h": 32.1, - "pins": { - "E": [ - 1.44, - 31.33 - ], - "B": [ - 11.01, - 31.33 - ], - "C": [ - 20.6, - 31.33 - ] - } - }, - "schematic": { - "svg": "132", - "w": 20.13, - "h": 39.33, - "pins": { - "E": [ - 19.67, - 0.47 - ], - "B": [ - 0.47, - 19.67 - ], - "C": [ - 19.67, - 38.87 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/parts/voltage-regulator-7805.json b/src/renderer/src/assets/parts/voltage-regulator-7805.json deleted file mode 100644 index cb480b7..0000000 --- a/src/renderer/src/assets/parts/voltage-regulator-7805.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "voltage-regulator-7805", - "label": "Voltage Regulator - 5V", - "family": "Voltage Regulator", - "description": "5V DC Voltage Regulator", - "source": { - "fzp": "voltage_regulator_7805.fzp", - "moduleId": "1238DBDC00-voltage-reg_7805" - }, - "views": { - "breadboard": { - "svg": "", - "w": 35.52, - "h": 59.82, - "pins": { - "input voltage": [ - 8.64, - 58.08 - ], - "GND": [ - 18.24, - 58.08 - ], - "output voltage": [ - 27.84, - 58.08 - ] - } - }, - "schematic": { - "svg": "3output voltage1input voltage2GNDVoltageRegulator5V", - "w": 134.4, - "h": 57.6, - "pins": { - "input voltage": [ - 0, - 9.6 - ], - "GND": [ - 67.2, - 57.6 - ], - "output voltage": [ - 134.4, - 9.6 - ] - } - } - }, - "icon": "" -} \ No newline at end of file diff --git a/src/renderer/src/assets/tinyLogo.png b/src/renderer/src/assets/tinyLogo.png deleted file mode 100644 index 7227cfb..0000000 Binary files a/src/renderer/src/assets/tinyLogo.png and /dev/null differ diff --git a/src/renderer/src/assets/tinyparts/README.md b/src/renderer/src/assets/tinyparts/README.md new file mode 100644 index 0000000..9405cd5 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/README.md @@ -0,0 +1,8 @@ +# Bundled tinyparts snapshot: don't edit here + +Everything in this folder is copied from the [tinyparts](https://github.com/Mister-Industries/tinyparts) +repo by `npm run parts:sync` and will be overwritten the next time it runs. + +To change a part's art or pins, edit it in your tinyparts checkout +(`packs//parts//`), then run `npm run parts:sync` here. +See [docs/parts-and-art.md](../../../../../docs/parts-and-art.md). diff --git a/src/renderer/src/assets/tinyparts/packs/core/ATTRIBUTION.md b/src/renderer/src/assets/tinyparts/packs/core/ATTRIBUTION.md new file mode 100644 index 0000000..d4031cb --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/ATTRIBUTION.md @@ -0,0 +1,11 @@ +# Attribution + +The parts in this pack are derived from the +[Fritzing parts library](https://github.com/fritzing/fritzing-parts) (the `core` +bin): breadboard and schematic artwork and connector definitions, converted for +tinyStudio. Each part's `part.json` records its source `.fzp` under `source`. + +Fritzing parts are licensed under the +[Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/) +(CC-BY-SA 3.0). Changes made here (conversion to separate SVG files, pin +coordinates, renamed ids) are shared under the same license. diff --git a/src/renderer/src/assets/tinyparts/packs/core/pack.json b/src/renderer/src/assets/tinyparts/packs/core/pack.json new file mode 100644 index 0000000..b06a82c --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/pack.json @@ -0,0 +1,161 @@ +{ + "schema": 1, + "id": "core", + "name": "Core", + "version": "2.0.0", + "group": "tinyStudio", + "icon": "Co", + "bundled": true, + "description": "Everyday components that ship with tinyStudio: resistors, LEDs, switches, sensors and more.", + "sections": [ + "Basic", "Input", "Output", "Textile", "ICs", "Power", "Microcontroller", "Computer", + "Connection", "Breadboard View" + ], + "parts": [ + { + "type": "resistor", + "dir": "parts/resistor", + "section": "Basic" + }, + { + "type": "capacitor-ceramic-100mil", + "dir": "parts/capacitor-ceramic-100mil", + "section": "Basic" + }, + { + "type": "capacitor-ceramic-200mil", + "dir": "parts/capacitor-ceramic-200mil", + "section": "Basic" + }, + { + "type": "capacitor-electrolytic-medium", + "dir": "parts/capacitor-electrolytic-medium", + "section": "Basic" + }, + { + "type": "smd-inductor-0805", + "dir": "parts/smd-inductor-0805", + "section": "Basic" + }, + { + "type": "diode-1n4001-300mil", + "dir": "parts/diode-1n4001-300mil", + "section": "Basic" + }, + { + "type": "diode-zener-0-5w-3-6v-300mil", + "dir": "parts/diode-zener-0-5w-3-6v-300mil", + "section": "Basic" + }, + { + "type": "transistor-signal-pnp-to92-ebc", + "dir": "parts/transistor-signal-pnp-to92-ebc", + "section": "Basic" + }, + { + "type": "transistor-signal-npn-to92-ebc", + "dir": "parts/transistor-signal-npn-to92-ebc", + "section": "Basic" + }, + { + "type": "sparkfun-discretesemi-mosfet-nchannel-pth", + "dir": "parts/sparkfun-discretesemi-mosfet-nchannel-pth", + "section": "Basic" + }, + { + "type": "potentiometer-trimmer-6mm-5", + "dir": "parts/potentiometer-trimmer-6mm-5", + "section": "Input" + }, + { + "type": "potentiometer-rotary-16mm-5", + "dir": "parts/potentiometer-rotary-16mm-5", + "section": "Input" + }, + { + "type": "pushbutton", + "dir": "parts/pushbutton", + "section": "Input" + }, + { + "type": "switch-spst", + "dir": "parts/switch-spst", + "section": "Input" + }, + { + "type": "ldr-photocell-300mil-v5", + "dir": "parts/ldr-photocell-300mil-v5", + "section": "Input" + }, + { + "type": "ir-receiver-v14", + "dir": "parts/ir-receiver-v14", + "section": "Input" + }, + { + "type": "thermistor-300mil", + "dir": "parts/thermistor-300mil", + "section": "Input" + }, + { + "type": "reedswitch-500mil", + "dir": "parts/reedswitch-500mil", + "section": "Input" + }, + { + "type": "sparkfun-sensors-mic-electret-smd", + "dir": "parts/sparkfun-sensors-mic-electret-smd", + "section": "Input" + }, + { + "type": "led-generic-5mm", + "dir": "parts/led-generic-5mm", + "section": "Output" + }, + { + "type": "led-generic-3mm", + "dir": "parts/led-generic-3mm", + "section": "Output" + }, + { + "type": "7segment-100-cat", + "dir": "parts/7segment-100-cat", + "section": "Output" + }, + { + "type": "piezo-sensor", + "dir": "parts/piezo-sensor", + "section": "Output" + }, + { + "type": "buzzer-v15", + "dir": "parts/buzzer-v15", + "section": "Output" + }, + { + "type": "servo", + "dir": "parts/servo", + "section": "Output" + }, + { + "type": "te-relay", + "dir": "parts/te-relay", + "section": "Output" + }, + { + "type": "battery-aa", + "dir": "parts/battery-aa", + "section": "Power" + }, + { + "type": "voltage-regulator-7805", + "dir": "parts/voltage-regulator-7805", + "section": "Power" + }, + { + "type": "sparkfun-passives-fuse-x20mm", + "dir": "parts/sparkfun-passives-fuse-x20mm", + "section": "Power" + } + ] +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/breadboard.svg new file mode 100644 index 0000000..5491679 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/icon.svg new file mode 100644 index 0000000..1e49653 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/part.json new file mode 100644 index 0000000..e9b66ed --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/part.json @@ -0,0 +1,47 @@ +{ + "type": "7segment-100-cat", + "label": "7 Segment Display", + "family": "Character Display", + "description": "7 Segment Display with common cathode.", + "source": { + "fzp": "7segment_100_cat.fzp", + "moduleId": "7segment100cat" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 256, + "height": 347, + "pins": { + "Segment E": [98.76, 324.73], + "Segment D": [113.16, 324.73], + "common cathode": [127.56, 324.73], + "Segment C": [141.96, 324.73], + "Segment DP": [156.36, 324.73], + "Segment B": [156.36, 21.43], + "Segment A": [141.96, 21.43], + "common cathode.2": [127.56, 21.43], + "Segment F": [113.16, 21.43], + "Segment G": [98.76, 21.43] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 221.13, + "height": 115.26, + "pins": { + "Segment E": [125.92, 114.74], + "Segment D": [97.05, 114.77], + "common cathode": [111.57, 0.49], + "Segment C": [68.35, 114.77], + "Segment DP": [212.32, 114.77], + "Segment B": [39.6, 114.77], + "Segment A": [10.68, 114.77], + "common cathode.2": [111.57, 0.49], + "Segment F": [154.79, 114.77], + "Segment G": [183.57, 114.77] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/schematic.svg new file mode 100644 index 0000000..f19d338 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/7segment-100-cat/schematic.svg @@ -0,0 +1 @@ +5103, 8912467 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/breadboard.svg new file mode 100644 index 0000000..22342cf --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/icon.svg new file mode 100644 index 0000000..56c623e --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/part.json new file mode 100644 index 0000000..6db446a --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/part.json @@ -0,0 +1,31 @@ +{ + "type": "battery-aa", + "label": "Battery", + "family": "Battery", + "description": "Your standard 2x AA battery pack (3 Volts)", + "source": { + "fzp": "battery-AA.fzp", + "moduleId": "1000AFDF10011leg" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 222.88, + "height": 116.64, + "pins": { + "-": [215.99, 63.58], + "+": [215.99, 45.33] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 39.33, + "height": 30.34, + "pins": { + "-": [38.87, 15.17], + "+": [0.47, 15.17] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/schematic.svg new file mode 100644 index 0000000..25b94ba --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/battery-aa/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/breadboard.svg new file mode 100644 index 0000000..2812ed4 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/breadboard.svg @@ -0,0 +1 @@ +Fritzing 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 G4510 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/part.json new file mode 100644 index 0000000..44d2567 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/part.json @@ -0,0 +1,30 @@ +{ + "type": "buzzer-v15", + "label": "LilyPad Buzzer", + "family": "microcontroller board (lilypad)", + "description": "This is a small buzzer for the LilyPad system. Use 2 I/O pins on the LilyPad main board and create different noises based on the different frequency of I/O toggling. Loud enough to hear inside a pocket but not obtrusively loud.\n\nPlease note: This is an inductive buzzer meaning that is will act as a short to ground if you are not actively driving it. We recommend you put both I/O pins to low (0V) when the buzzer is not used. Also, it's come to our attention that washing these buzzers will damage them. Until we've figured out a solution to this, avoid washing any portion of your project that contains one of these buzzers.\n\nLilyPad is a wearable e-textile technology developed by Leah Buechley and cooperatively designed by Leah and SparkFun. Each LilyPad was creatively designed to have large connecting pads to allow them to be sewn into clothing. Various input, output, power, and sensor boards are available. They're even washable!\n\nNote: A portion of this sale is given back to Dr. Leah Buechley for continued development and education of e-textiles.\n\nDimensions:\n\n20mm outer diameter\nThin 0.8mm PCB", + "source": { + "fzp": "Buzzer-v15.fzp", + "moduleId": "Buzzer-v15" + }, + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 56.69, + "height": 56.69, + "pins": { + "-": [9.92, 17.72], + "+": [46.74, 38.98] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 28.8, + "height": 76.8, + "pins": { + "-": [9.6, 76.8], + "+": [9.6, 0] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/schematic.svg new file mode 100644 index 0000000..e5b51c5 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/buzzer-v15/schematic.svg @@ -0,0 +1 @@ +-+LilyPadBuzzer diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/breadboard.svg new file mode 100644 index 0000000..02062b2 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/icon.svg new file mode 100644 index 0000000..e7be3c6 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/part.json new file mode 100644 index 0000000..b7261f7 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "capacitor-ceramic-100mil", + "label": "Ceramic Capacitor", + "family": "Capacitor [bidirectional]", + "description": "100 nF multilayer Ceramic Capacitor", + "source": { + "fzp": "capacitor_ceramic_100mil.fzp", + "moduleId": "100milCeramicCapacitorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 18.08, + "height": 23.83, + "pins": { + "0": [4.24, 14.71], + "1": [13.84, 14.71] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 15.36, + "height": 29.73, + "pins": { + "0": [7.68, 29.27], + "1": [7.68, 0.47] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/schematic.svg new file mode 100644 index 0000000..d3c4d03 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-100mil/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/breadboard.svg new file mode 100644 index 0000000..02062b2 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/icon.svg new file mode 100644 index 0000000..e7be3c6 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/part.json new file mode 100644 index 0000000..f5e85ba --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "capacitor-ceramic-200mil", + "label": "Ceramic Capacitor", + "family": "Capacitor [bidirectional]", + "description": "100 nF multilayer Ceramic Capacitor", + "source": { + "fzp": "capacitor_ceramic_200mil.fzp", + "moduleId": "200milCeramicCapacitorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 18.08, + "height": 23.83, + "pins": { + "0": [4.24, 14.71], + "1": [13.84, 14.71] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 15.36, + "height": 29.73, + "pins": { + "0": [7.68, 29.27], + "1": [7.68, 0.47] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/schematic.svg new file mode 100644 index 0000000..d3c4d03 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-ceramic-200mil/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/breadboard.svg new file mode 100644 index 0000000..ea972ca --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/icon.svg new file mode 100644 index 0000000..1690784 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/part.json new file mode 100644 index 0000000..635c972 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/part.json @@ -0,0 +1,31 @@ +{ + "type": "capacitor-electrolytic-medium", + "label": "Electrolytic Capacitor", + "family": "Capacitor [unidirectional]", + "description": "electrolytic capacitor", + "source": { + "fzp": "capacitor_electrolytic_medium.fzp", + "moduleId": "MediumElectrolyticCapacitorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 30.24, + "height": 57.5, + "pins": { + "-": [10.36, 50.3], + "+": [19.96, 50.3] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 20.16, + "height": 29.73, + "pins": { + "-": [10.08, 29.27], + "+": [10.08, 0.47] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/schematic.svg new file mode 100644 index 0000000..e41974e --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/capacitor-electrolytic-medium/schematic.svg @@ -0,0 +1 @@ +21 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/breadboard.svg new file mode 100644 index 0000000..51ec146 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/icon.svg new file mode 100644 index 0000000..9991913 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/icon.svg @@ -0,0 +1 @@ +basic-diode.svg diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/part.json new file mode 100644 index 0000000..814f19c --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "diode-1n4001-300mil", + "label": "Rectifier Diode", + "family": "Diode", + "description": "A standard rectifier diode", + "source": { + "fzp": "diode_1N4001_300mil.fzp", + "moduleId": "3254CBFC44diode" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 39.84, + "height": 9.6, + "pins": { + "cathode": [0.68, 4.79], + "anode": [39.16, 4.94] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 8.38, + "height": 22.02, + "pins": { + "cathode": [4.19, 21.88], + "anode": [4.19, 0.28] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/schematic.svg new file mode 100644 index 0000000..80c4d6c --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-1n4001-300mil/schematic.svg @@ -0,0 +1 @@ +21 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg new file mode 100644 index 0000000..51ec146 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/icon.svg new file mode 100644 index 0000000..9991913 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/icon.svg @@ -0,0 +1 @@ +basic-diode.svg diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/part.json new file mode 100644 index 0000000..5762173 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "diode-zener-0-5w-3-6v-300mil", + "label": "Zener Diode", + "family": "Diode", + "description": "A standard zener diode", + "source": { + "fzp": "diode_zener_0_5w_3_6v_300mil.fzp", + "moduleId": "xc307sadf951b6bdd0d67c0d2ad67eabZenerDiodeModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 39.84, + "height": 9.6, + "pins": { + "cathode": [0.68, 4.79], + "anode": [39.16, 4.94] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 21.6, + "height": 9.36, + "pins": { + "cathode": [21.6, 4.94], + "anode": [0, 5.1] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/schematic.svg new file mode 100644 index 0000000..09811f9 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/diode-zener-0-5w-3-6v-300mil/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/breadboard.svg new file mode 100644 index 0000000..de3ac1d --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/breadboard.svg @@ -0,0 +1 @@ +Fritzing 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:TSOP8523 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/part.json new file mode 100644 index 0000000..ac23a62 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/part.json @@ -0,0 +1,32 @@ +{ + "type": "ir-receiver-v14", + "label": "IR Receiver Breakout", + "family": "IR", + "description": "

\n\n\n\tDescription: This is a very small infrared receiver based on the TSOP85 receiver from Vishay. This receiver has all the filtering and 38kHz demodulation built into the unit. Simply point a IR remote at the receiver, hit a button, and you'll see a stream of 1s and 0s out of the data pin.
\n\n\n\t
\n\n\n\tDocuments:

\n\n\n", + "source": { + "fzp": "IR_Receiver_v14.fzp", + "moduleId": "IR_Receiver_v14" + }, + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 52.8, + "height": 38.4, + "pins": { + "GND": [43.2, 28.8], + "OUT": [43.2, 19.2], + "VCC": [43.2, 9.6] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 76.8, + "height": 86.4, + "pins": { + "GND": [28.8, 86.4], + "OUT": [76.8, 38.4], + "VCC": [28.8, 0] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/schematic.svg new file mode 100644 index 0000000..4ebaef4 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ir-receiver-v14/schematic.svg @@ -0,0 +1 @@ +OUTGNDVCCIRReceiverBreakout diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/breadboard.svg new file mode 100644 index 0000000..284910b --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/icon.svg new file mode 100644 index 0000000..27a0dc9 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/part.json new file mode 100644 index 0000000..18ef5a1 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/part.json @@ -0,0 +1,31 @@ +{ + "type": "ldr-photocell-300mil-v5", + "label": "Photocell (LDR)", + "family": "Photo-Resistor", + "description": "A generic light dependent resistor (LDR)", + "source": { + "fzp": "LDR_photocell_300mil_v5.fzp", + "moduleId": "2010BBCD20113PhotocellModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 32.44, + "height": 14.58, + "pins": { + "pin 0": [1.05, 6.38], + "pin 1": [29.85, 6.38] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 43.9, + "height": 15.12, + "pins": { + "pin 0": [0.35, 7.56], + "pin 1": [43.55, 7.56] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/schematic.svg new file mode 100644 index 0000000..6660b56 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/ldr-photocell-300mil-v5/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/breadboard.svg new file mode 100644 index 0000000..dc70fef --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/icon.svg new file mode 100644 index 0000000..c44612f --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/part.json new file mode 100644 index 0000000..b747876 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/part.json @@ -0,0 +1,31 @@ +{ + "type": "led-generic-3mm", + "label": "Red LED - 3mm", + "family": "LED", + "description": "A generic red LED (~1.8V)", + "source": { + "fzp": "LED-generic-3mm.fzp", + "moduleId": "3mmColorLEDModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 15.46, + "height": 29.21, + "pins": { + "cathode": [4.53, 25.78], + "anode": [11.73, 25.78] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 13.68, + "height": 22.03, + "pins": { + "cathode": [9.9, 21.82], + "anode": [9.9, 0.22] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/schematic.svg new file mode 100644 index 0000000..2a877c8 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-3mm/schematic.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/breadboard.svg new file mode 100644 index 0000000..5cb64cd --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/icon.svg new file mode 100644 index 0000000..c44612f --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/part.json new file mode 100644 index 0000000..bc7557f --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/part.json @@ -0,0 +1,31 @@ +{ + "type": "led-generic-5mm", + "label": "Red LED - 5mm", + "family": "LED", + "description": "A generic red LED (~1.8V)", + "source": { + "fzp": "LED-generic-5mm.fzp", + "moduleId": "5mmColorLEDModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 20.61, + "height": 38.94, + "pins": { + "cathode": [6.04, 34.37], + "anode": [15.64, 34.37] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 13.68, + "height": 22.03, + "pins": { + "cathode": [9.9, 21.82], + "anode": [9.9, 0.22] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/schematic.svg new file mode 100644 index 0000000..2a877c8 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/led-generic-5mm/schematic.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/breadboard.svg new file mode 100644 index 0000000..7d128d7 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/icon.svg new file mode 100644 index 0000000..ceba2eb --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/part.json new file mode 100644 index 0000000..c5e8712 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/part.json @@ -0,0 +1,31 @@ +{ + "type": "piezo-sensor", + "label": "Piezo Speaker", + "family": "Piezo", + "description": "Piezo", + "source": { + "fzp": "piezo sensor.fzp", + "moduleId": "8abf6496b5d466c7a1893c17a296a676" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 104.64, + "height": 90.21, + "pins": { + "pin1": [46.2, 88.55], + "pin2": [55.78, 88.55] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 25.92, + "height": 20.51, + "pins": { + "pin1": [9.37, 20.17], + "pin2": [16.55, 20.17] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/schematic.svg new file mode 100644 index 0000000..e0c2fc9 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/piezo-sensor/schematic.svg @@ -0,0 +1 @@ +21 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/breadboard.svg new file mode 100644 index 0000000..f5dbc44 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/icon.svg new file mode 100644 index 0000000..5c7a529 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/part.json new file mode 100644 index 0000000..adbf141 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/part.json @@ -0,0 +1,33 @@ +{ + "type": "potentiometer-rotary-16mm-5", + "label": "Rotary Potentiometer (Large)", + "family": "Potentiometer", + "description": "A rotary adjustable resistor (potentiometer).", + "source": { + "fzp": "potentiometer_rotary_16mm_5.fzp", + "moduleId": "potentiometer_rotary_16mm_5_PotentiometerModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 56.64, + "height": 113.38, + "pins": { + "leg1": [9.15, 107.64], + "wiper": [28.32, 107.64], + "leg2": [47.49, 107.64] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 24.02, + "height": 39.38, + "pins": { + "leg1": [4.35, 38.88], + "wiper": [23.54, 19.68], + "leg2": [4.35, 0.48] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/schematic.svg new file mode 100644 index 0000000..9004e01 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-rotary-16mm-5/schematic.svg @@ -0,0 +1 @@ +132 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/breadboard.svg new file mode 100644 index 0000000..2ad4b3b --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/icon.svg new file mode 100644 index 0000000..05597df --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/part.json new file mode 100644 index 0000000..aff61cc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/part.json @@ -0,0 +1,33 @@ +{ + "type": "potentiometer-trimmer-6mm-5", + "label": "6mm Trimmer Potentiometer", + "family": "Potentiometer", + "description": "A standard 6mm trimmer potentiometer.", + "source": { + "fzp": "potentiometer_trimmer_6mm_5.fzp", + "moduleId": "potentiometer_trimmer_6mm_5_PotentiometerModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 23.73, + "height": 31.68, + "pins": { + "leg1": [2.27, 30.24], + "wiper": [11.87, 1.44], + "leg2": [21.47, 30.24] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 24.02, + "height": 39.38, + "pins": { + "leg1": [4.35, 38.88], + "wiper": [23.54, 19.68], + "leg2": [4.35, 0.48] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/schematic.svg new file mode 100644 index 0000000..9004e01 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/potentiometer-trimmer-6mm-5/schematic.svg @@ -0,0 +1 @@ +132 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/breadboard.svg new file mode 100644 index 0000000..542c3f0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/icon.svg new file mode 100644 index 0000000..4fe6b42 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/part.json new file mode 100644 index 0000000..8fdd248 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/part.json @@ -0,0 +1,31 @@ +{ + "type": "pushbutton", + "label": "Round Pushbutton", + "family": "Switch - 2 pin", + "description": "A generic pushbutton.", + "source": { + "fzp": "Pushbutton.fzp", + "moduleId": "33b94ebdb1ef4e7cf0f8425956cfca60" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 67.42, + "height": 74.89, + "pins": { + "leg0": [28.19, 72.79], + "leg1": [37.83, 72.79] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 6.62, + "height": 29.5, + "pins": { + "leg0": [4.74, 29.14], + "leg1": [4.74, 0.3] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/schematic.svg new file mode 100644 index 0000000..78ca753 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/pushbutton/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/breadboard.svg new file mode 100644 index 0000000..b406d2c --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/icon.svg new file mode 100644 index 0000000..70bcc95 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/part.json new file mode 100644 index 0000000..cc2f3dc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "reedswitch-500mil", + "label": "Reed switch", + "family": "Reed Switch", + "description": "Reed switch", + "source": { + "fzp": "reedSwitch_500mil.fzp", + "moduleId": "c0e65b2d0c9003507cf4a3222e995a45" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 60.48, + "height": 10.56, + "pins": { + "pin 1": [0.67, 4.53], + "pin 2": [55.29, 4.53] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 36.02, + "height": 19.29, + "pins": { + "pin 1": [35.92, 10.11], + "pin 2": [0.16, 10.11] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/schematic.svg new file mode 100644 index 0000000..bc98dc6 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/reedswitch-500mil/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/breadboard.svg new file mode 100644 index 0000000..0b736e0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/icon.svg new file mode 100644 index 0000000..d7daecb --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/part.json new file mode 100644 index 0000000..b2c5cbc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/part.json @@ -0,0 +1,31 @@ +{ + "type": "resistor", + "label": "220 Ω Resistor", + "family": "Resistor", + "description": "A generic resistor (0.25W)", + "source": { + "fzp": "resistor.fzp", + "moduleId": "ResistorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 41.2, + "height": 9.32, + "pins": { + "Pin 0": [0.96, 3.93], + "Pin 1": [40.24, 3.93] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 39.33, + "height": 8.26, + "pins": { + "Pin 0": [0.47, 4.13], + "Pin 1": [38.87, 4.13] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/schematic.svg new file mode 100644 index 0000000..a65b548 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/resistor/schematic.svg @@ -0,0 +1 @@ +21 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/servo/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/breadboard.svg new file mode 100644 index 0000000..2e576b3 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/servo/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/icon.svg new file mode 100644 index 0000000..5147953 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/servo/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/part.json new file mode 100644 index 0000000..bc9154b --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/part.json @@ -0,0 +1,33 @@ +{ + "type": "servo", + "label": "Basic Servo", + "family": "Servo", + "description": "A generic servo.", + "source": { + "fzp": "servo.fzp", + "moduleId": "3234DBDC80leg" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 109.42, + "height": 88.03, + "pins": { + "gnd": [97.34, 70.58], + "vcc": [97.45, 67.87], + "pulse": [97.34, 65.2] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 47.1, + "height": 43.2, + "pins": { + "gnd": [0.17, 28.8], + "vcc": [0.17, 14.4], + "pulse": [0.17, 21.6] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/servo/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/schematic.svg new file mode 100644 index 0000000..7a861f3 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/servo/schematic.svg @@ -0,0 +1 @@ +123+pulse-Servo diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/breadboard.svg new file mode 100644 index 0000000..b29f48f --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/icon.svg new file mode 100644 index 0000000..7221c0a --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/part.json new file mode 100644 index 0000000..5bb4d36 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/part.json @@ -0,0 +1,31 @@ +{ + "type": "smd-inductor-0805", + "label": "Inductor", + "family": "air-core inductor", + "description": "Inductor", + "source": { + "fzp": "SMD_inductor_0805.fzp", + "moduleId": "805InductorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 41.12, + "height": 16.92, + "pins": { + "pin 0": [2.37, 10.82], + "pin 1": [38.46, 10.85] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 5.76, + "height": 58.53, + "pins": { + "pin 0": [0.48, 0.47], + "pin 1": [0.48, 58.07] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/schematic.svg new file mode 100644 index 0000000..7d4d33d --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/smd-inductor-0805/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg new file mode 100644 index 0000000..df12961 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json new file mode 100644 index 0000000..4af45bc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json @@ -0,0 +1,32 @@ +{ + "type": "sparkfun-discretesemi-mosfet-nchannel-pth", + "label": "MOSFET-NCHANNEL", + "family": "sparkfun Mosfet (N Channel)", + "description": "Generic NMOS footprint", + "source": { + "fzp": "sparkfun-discretesemi-mosfet-nchannel-pth.fzp", + "moduleId": "SparkFun-DiscreteSemi-MOSFET-NCHANNEL-PTH" + }, + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 22.04, + "height": 32.1, + "pins": { + "D": [1.44, 31.33], + "G": [20.6, 31.33], + "S": [11.01, 31.33] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 42.51, + "height": 39.33, + "pins": { + "D": [29.27, 0.47], + "G": [0.47, 19.67], + "S": [29.27, 38.87] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg new file mode 100644 index 0000000..7c35521 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg @@ -0,0 +1 @@ +312 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/breadboard.svg new file mode 100644 index 0000000..33b6286 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/part.json new file mode 100644 index 0000000..a3b5db0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/part.json @@ -0,0 +1,29 @@ +{ + "type": "sparkfun-passives-fuse-x20mm", + "label": "FUSE", + "family": "sparkfun Fuse", + "source": { + "fzp": "sparkfun-passives-fuse-x20mm.fzp", + "moduleId": "SparkFun-Passives-FUSE-X20MM" + }, + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 66.77, + "height": 20.59, + "pins": { + "1": [5.04, 10.29], + "2": [45.17, 10.29] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 77.73, + "height": 20.16, + "pins": { + "1": [0.47, 10.08], + "2": [77.27, 10.08] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/schematic.svg new file mode 100644 index 0000000..44486fa --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-passives-fuse-x20mm/schematic.svg @@ -0,0 +1 @@ +13 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/breadboard.svg new file mode 100644 index 0000000..1b44774 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/breadboard.svg @@ -0,0 +1 @@ +Fritzing breadboard generated by brd2svg diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/icon.svg new file mode 100644 index 0000000..659febc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/part.json new file mode 100644 index 0000000..428ea09 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/part.json @@ -0,0 +1,31 @@ +{ + "type": "sparkfun-sensors-mic-electret-smd", + "label": "MIC", + "family": "sparkfun Electret Mic", + "description": "Omni-directional electret microphone. 9.7mm standard diameter.", + "source": { + "fzp": "sparkfun-sensors-mic-electret_smd.fzp", + "moduleId": "SparkFun-Sensors-MIC-ELECTRET_SMD" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 44.14, + "height": 57.6, + "pins": { + "1": [22.07, 52.8], + "2": [22.07, 4.8] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 25.92, + "height": 20.51, + "pins": { + "1": [9.36, 19.95], + "2": [16.55, 19.98] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/schematic.svg new file mode 100644 index 0000000..f354a4e --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/sparkfun-sensors-mic-electret-smd/schematic.svg @@ -0,0 +1 @@ +21 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/breadboard.svg new file mode 100644 index 0000000..65b5504 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/breadboard.svg @@ -0,0 +1 @@ +SPST diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/part.json new file mode 100644 index 0000000..fa69bfc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/switch-spst/part.json @@ -0,0 +1,17 @@ +{ + "type": "switch-spst", + "label": "Slide Switch (SPST)", + "family": "Switch", + "description": "A hand-authored example part: a single-pole, single-throw slide switch. Copy this file as a starting point for your own parts.", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 60, + "height": 40, + "pins": { + "1": [12, 38], + "2": [48, 38] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/breadboard.svg new file mode 100644 index 0000000..8cd7f43 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/breadboard.svg @@ -0,0 +1 @@ +sparkfun-electromechanical_relay-g5q_breadboard.svgVDE diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/icon.svg new file mode 100644 index 0000000..97ac95d --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/icon.svg @@ -0,0 +1 @@ +sparkfun-electromechanical_relay-g5q_breadboard.svgVDE diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/part.json new file mode 100644 index 0000000..e86b2da --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/part.json @@ -0,0 +1,35 @@ +{ + "type": "te-relay", + "label": "RELAY", + "family": "TE General Purpose Relays", + "description": "\n\n

SPST relay

", + "source": { + "fzp": "TE_RELAY.fzp", + "moduleId": "TE_RELAY" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 77.57, + "height": 84.4, + "pins": { + "COIL1": [72.38, 83.98], + "COIL2": [72.38, 55.18], + "MAIN": [14.78, 83.98], + "NO": [5.18, 55.18] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 68.13, + "height": 58.53, + "pins": { + "COIL1": [0.67, 10.07], + "COIL2": [0.67, 48.47], + "MAIN": [67.4, 29.27], + "NO": [67.4, 48.47] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/schematic.svg new file mode 100644 index 0000000..2f39b0b --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/te-relay/schematic.svg @@ -0,0 +1 @@ +sparkfun-electromechanical_relay-spst_schematic.svg123 4 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/breadboard.svg new file mode 100644 index 0000000..12cf0b0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/icon.svg new file mode 100644 index 0000000..fe22ce0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/part.json new file mode 100644 index 0000000..4e663f7 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/part.json @@ -0,0 +1,31 @@ +{ + "type": "thermistor-300mil", + "label": "Temperature Sensor (Thermistor)", + "family": "temperature sensor", + "description": "A 10k NTC Thermistor (Temperature Sensor)", + "source": { + "fzp": "thermistor_300mil.fzp", + "moduleId": "2012BCEC1FBThermistorModuleID" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 22.08, + "height": 22.08, + "pins": { + "pin 0": [1.44, 12.72], + "pin 1": [20.64, 12.72] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 17, + "height": 43.2, + "pins": { + "pin 0": [10.31, 42.86], + "pin 1": [10.31, 0.34] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/schematic.svg new file mode 100644 index 0000000..29df2e7 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/thermistor-300mil/schematic.svg @@ -0,0 +1 @@ +12 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/breadboard.svg new file mode 100644 index 0000000..b71e6d3 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/icon.svg new file mode 100644 index 0000000..5fc7ba3 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/part.json new file mode 100644 index 0000000..85565d6 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/part.json @@ -0,0 +1,33 @@ +{ + "type": "transistor-signal-npn-to92-ebc", + "label": "NPN-Transistor", + "family": "Bipolar Transistor", + "description": "A standard NPN-transistor", + "source": { + "fzp": "transistor_signal_NPN_TO92_EBC.fzp", + "moduleId": "NPN-to92-ebc" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 22.04, + "height": 32.11, + "pins": { + "E": [1.44, 30.11], + "B": [11.01, 30.11], + "C": [20.6, 30.11] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 20.13, + "height": 39.33, + "pins": { + "E": [19.67, 38.87], + "B": [0.47, 19.67], + "C": [19.67, 0.47] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/schematic.svg new file mode 100644 index 0000000..7e3e757 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-npn-to92-ebc/schematic.svg @@ -0,0 +1 @@ +123 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/breadboard.svg new file mode 100644 index 0000000..df12961 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/icon.svg new file mode 100644 index 0000000..36f80c8 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/part.json new file mode 100644 index 0000000..8327cd9 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/part.json @@ -0,0 +1,33 @@ +{ + "type": "transistor-signal-pnp-to92-ebc", + "label": "PNP-Transistor", + "family": "Bipolar Transistor", + "description": "A standard PNP signal transistor", + "source": { + "fzp": "transistor_signal_PNP_TO92_EBC.fzp", + "moduleId": "2244EBEC21e" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 22.04, + "height": 32.1, + "pins": { + "E": [1.44, 31.33], + "B": [11.01, 31.33], + "C": [20.6, 31.33] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 20.13, + "height": 39.33, + "pins": { + "E": [19.67, 0.47], + "B": [0.47, 19.67], + "C": [19.67, 38.87] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/schematic.svg new file mode 100644 index 0000000..b993b93 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/transistor-signal-pnp-to92-ebc/schematic.svg @@ -0,0 +1 @@ +132 diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/breadboard.svg new file mode 100644 index 0000000..3fa48ac --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/breadboard.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/icon.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/icon.svg new file mode 100644 index 0000000..b2dcab0 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/icon.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/part.json b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/part.json new file mode 100644 index 0000000..f20dc8d --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/part.json @@ -0,0 +1,33 @@ +{ + "type": "voltage-regulator-7805", + "label": "Voltage Regulator - 5V", + "family": "Voltage Regulator", + "description": "5V DC Voltage Regulator", + "source": { + "fzp": "voltage_regulator_7805.fzp", + "moduleId": "1238DBDC00-voltage-reg_7805" + }, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": 35.52, + "height": 59.82, + "pins": { + "input voltage": [8.64, 58.08], + "GND": [18.24, 58.08], + "output voltage": [27.84, 58.08] + } + }, + "schematic": { + "svg": "schematic.svg", + "width": 134.4, + "height": 57.6, + "pins": { + "input voltage": [0, 9.6], + "GND": [67.2, 57.6], + "output voltage": [134.4, 9.6] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/schematic.svg b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/schematic.svg new file mode 100644 index 0000000..826c969 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/core/parts/voltage-regulator-7805/schematic.svg @@ -0,0 +1 @@ +3output voltage1input voltage2GNDVoltageRegulator5V diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/pack.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/pack.json new file mode 100644 index 0000000..326f843 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/pack.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "id": "tinyboards", + "name": "tinyBoards", + "version": "2.0.0", + "group": "tinyStudio", + "icon": "tB", + "bundled": true, + "description": "The MR.INDUSTRIES tinyBoard family: tinyCore and its stackable HATs.", + "sections": ["tinyBoards"], + "parts": [ + { + "type": "tinycore", + "dir": "parts/tinycore", + "section": "tinyBoards" + }, + { + "type": "tinydisplay", + "dir": "parts/tinydisplay", + "section": "tinyBoards" + }, + { + "type": "tinyglow", + "dir": "parts/tinyglow", + "section": "tinyBoards" + }, + { + "type": "tinyproto", + "dir": "parts/tinyproto", + "section": "tinyBoards" + }, + { + "type": "tinysniff", + "dir": "parts/tinysniff", + "section": "tinyBoards" + }, + { + "type": "tinyspeak", + "dir": "parts/tinyspeak", + "section": "tinyBoards" + } + ] +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/breadboard.svg new file mode 100644 index 0000000..1779cf1 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/breadboard.svg @@ -0,0 +1,2111 @@ + + + + + + + + + + + + + + + BOOT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RST + + + 8 + + + 9 + + + 10 + + + 11 + + + 12 + + + 13 + + + DIGITAL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SERIAL + + + ANALOG + + + GND + + + 3V3 + + + A5 + + + A4 + + + A3 + + + A2 + + + 3V3 + + + GND + + + A1 + + + A0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SCK + + + MO + + + MI + + + RX + + + TX + + + SDA + + + SCL + + + PWR + + + GND + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PWR + + + BOOT + + + CRG + + + SIG + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5V + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/icon.svg new file mode 100644 index 0000000..ef63f0f --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/icon.svg @@ -0,0 +1,1998 @@ + + + + + + + + + + + + + + BOOT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RST + 8 + 9 + 10 + 11 + 12 + 13 + DIGITAL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SERIAL + ANALOG + GND + 3V3 + A5 + A4 + A3 + A2 + 3V3 + GND + A1 + A0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SCK + MO + MI + RX + TX + SDA + SCL + PWR + GND + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PWR + BOOT + CRG + SIG + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5V + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/part.json new file mode 100644 index 0000000..4168f74 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinycore/part.json @@ -0,0 +1,20 @@ +{ + "type": "tinycore", + "label": "tinyCore", + "family": "tinyStudio", + "sub": "ESP32-S3", + "accent": "var(--cyan)", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": [ + "GND", "3V3", "A5", "A4", "A3", "A2", "A1", "A0", "D8", "D9", "D10", "D11", "D12", "D13", + "3V3.2", "GND.2", "SCK", "MO", "MI", "RX", "TX", "SDA", "SCL", "PWR", "GND.3" + ] + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/breadboard.svg new file mode 100644 index 0000000..4112111 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/breadboard.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SDA + 8 + 9 + 10 + 12 + GND + 3V3 + A5 + A4 + A3 + A2 + 3V3 + GND + A1 + A0 + SCK + MO + MI + RX + TX + SCL + PWR + GND + + + 11 + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tinyDisplay + BOARD DESIGN BY: + GEOFF McINTYRE + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/icon.svg new file mode 100644 index 0000000..5c090fc --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/icon.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SDA + 8 + 9 + 10 + 12 + GND + 3V3 + A5 + A4 + A3 + A2 + 3V3 + GND + A1 + A0 + SCK + MO + MI + RX + TX + SCL + PWR + GND + + + 11 + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tinyDisplay + BOARD DESIGN BY: + GEOFF McINTYRE + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/part.json new file mode 100644 index 0000000..2fc0bf7 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinydisplay/part.json @@ -0,0 +1,20 @@ +{ + "type": "tinydisplay", + "label": "tinyDisplay", + "family": "tinyStudio", + "sub": "Round LCD module", + "accent": "#2fbfa6", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": [ + "GND", "3V3", "A5", "A4", "A3", "A2", "A1", "A0", "D8", "D9", "D10", "D11", "D12", "D13", + "3V3.2", "GND.2", "SCK", "MO", "MI", "RX", "TX", "SDA", "SCL", "PWR", "GND.3" + ] + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/breadboard.svg new file mode 100644 index 0000000..7422edf --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/breadboard.svg @@ -0,0 +1,1645 @@ + + + + + + + + + + + + + + 8 + + + 9 + + + 10 + + + 12 + + + GND + + + 3V3 + + + A5 + + + A4 + + + A3 + + + A2 + + + 3V3 + + + G + + + A1 + + + A0 + + + + + 11 + + + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5V + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ON + + + ON + + + ON + + + CH2 + + + CH1 + + + CH3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OFF + + + OFF + + + OFF + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/icon.svg new file mode 100644 index 0000000..7422edf --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/icon.svg @@ -0,0 +1,1645 @@ + + + + + + + + + + + + + + 8 + + + 9 + + + 10 + + + 12 + + + GND + + + 3V3 + + + A5 + + + A4 + + + A3 + + + A2 + + + 3V3 + + + G + + + A1 + + + A0 + + + + + 11 + + + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5V + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ON + + + ON + + + ON + + + CH2 + + + CH1 + + + CH3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OFF + + + OFF + + + OFF + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/part.json new file mode 100644 index 0000000..cc0d361 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyglow/part.json @@ -0,0 +1,55 @@ +{ + "type": "tinyglow", + "label": "tinyGlow", + "family": "tinyStudio", + "sub": "3-channel LED driver", + "accent": "var(--pink)", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": { + "GND": [7.2, 57.6], + "3V3": [7.2, 67.2], + "A5": [7.2, 76.8], + "A4": [7.2, 86.4], + "A3": [7.2, 96], + "A2": [7.2, 105.6], + "A1": [7.2, 115.2], + "A0": [7.2, 124.8], + "D8": [175.2, 57.6], + "D9": [175.2, 67.2], + "D10": [175.2, 76.8], + "D11": [175.2, 86.4], + "D12": [175.2, 96], + "D13": [175.2, 105.6], + "3V3.2": [175.2, 115.2], + "GND.2": [175.2, 124.8], + "SCK": [52.8, 170.4], + "MO": [62.4, 170.4], + "MI": [72, 170.4], + "RX": [81.6, 170.4], + "TX": [91.2, 170.4], + "SDA": [100.8, 170.4], + "SCL": [110.4, 170.4], + "PWR": [120, 170.4], + "GND.3": [129.6, 170.4], + "5V": [160, 141.2], + "GND.21": [153.2, 148], + "5V.1": [138.45, 2.62], + "CH1": [145.78, 9.95], + "GND.6": [152.78, 16.95], + "GND.5": [101.46, 0.95], + "CH2": [91.13, 1.6], + "5V.2": [81.46, 1.28], + "GND.4.1": [46.14, 2.95], + "CH3": [38.13, 10.27], + "5V.3": [29.81, 17.95], + "5V.4": [128.13, 125.27] + } + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/breadboard.svg new file mode 100644 index 0000000..3772b77 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/breadboard.svg @@ -0,0 +1,612 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + 3V3 + A5 + A4 + A3 + A2 + A1 + A0 + 8 + 9 + 10 + 11 + 12 + 13 + 3V3 + GND + SCK + MO + MI + RX + TX + SDA + SCL + PWR + GND + tinyProto + BOARD DESIGN BY: + GEOFF McINTYRE + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/icon.svg new file mode 100644 index 0000000..3772b77 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/icon.svg @@ -0,0 +1,612 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + 3V3 + A5 + A4 + A3 + A2 + A1 + A0 + 8 + 9 + 10 + 11 + 12 + 13 + 3V3 + GND + SCK + MO + MI + RX + TX + SDA + SCL + PWR + GND + tinyProto + BOARD DESIGN BY: + GEOFF McINTYRE + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/part.json new file mode 100644 index 0000000..5f8707d --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyproto/part.json @@ -0,0 +1,43 @@ +{ + "type": "tinyproto", + "label": "tinyProto", + "family": "tinyStudio", + "sub": "Prototyping / breakout", + "accent": "#3fc47a", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": [ + "GND", "3V3", "A5", "A4", "A3", "A2", "A1", "A0", "D8", "D9", "D10", "D11", "D12", "D13", + "3V3.2", "GND.2", "SCK", "MO", "MI", "RX", "TX", "SDA", "SCL", "PWR", "GND.3", "A.5", + "A.6", "A.7", "A.8", "A.9", "A.10", "A.11", "A.12", "B.5", "B.6", "B.7", "B.8", "B.9", + "B.10", "B.11", "B.12", "C.5", "C.6", "C.7", "C.8", "C.9", "C.10", "C.11", "C.12", "D.1", + "D.2", "D.3", "D.4", "D.5", "D.6", "D.7", "D.8", "D.9", "D.10", "D.11", "D.12", "D.13", + "D.14", "D.15", "E.1", "E.2", "E.3", "E.4", "E.5", "E.6", "E.7", "E.8", "E.9", "E.10", + "E.11", "E.12", "E.13", "E.14", "E.15", "F.1", "F.2", "F.3", "F.4", "F.5", "F.6", "F.7", + "F.8", "F.9", "F.10", "F.11", "F.12", "F.13", "F.14", "F.15", "G.1", "G.2", "G.3", "G.4", + "G.5", "G.6", "G.7", "G.8", "G.9", "G.10", "G.11", "G.12", "G.13", "G.14", "G.15", "H.1", + "H.2", "H.3", "H.4", "H.5", "H.6", "H.7", "H.8", "H.9", "H.10", "H.11", "H.12", "H.13", + "H.14", "H.15", "I.1", "I.2", "I.3", "I.4", "I.5", "I.6", "I.7", "I.8", "I.9", "I.10", + "I.11", "I.12", "I.13", "I.14", "I.15", "J.1", "J.2", "J.3", "J.4", "J.5", "J.6", "J.7", + "J.8", "J.9", "J.10", "J.11", "J.12", "J.13", "J.14", "J.15", "K.1", "K.2", "K.3", "K.4", + "K.5", "K.6", "K.7", "K.8", "K.9", "K.10", "K.11", "K.12", "K.13", "K.14", "K.15", "L.1", + "L.2", "L.3", "L.4", "L.5", "L.6", "L.7", "L.8", "L.9", "L.10", "L.11", "L.12", "L.13", + "L.14", "L.15", "M.5", "M.6", "M.7", "M.8", "M.9", "M.10", "M.11", "M.12", "N.5", "N.6", + "N.7", "N.8", "N.9", "N.10", "N.11", "N.12", "O.5", "O.6", "O.7", "O.8", "O.9", "O.10", + "O.11", "O.12" + ] + } + }, + "buses": [ + ["A.5", "B.5", "C.5", "D.2", "D.3", "D.4"], + ["A.6", "B.6", "C.6"], + ["M.11", "N.11", "O.11"], + ["M.12", "N.12", "O.12", "L.13", "L.14", "L.15"], + ["K.13", "K.14", "K.15"] + ] +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/breadboard.svg new file mode 100644 index 0000000..50e7244 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/breadboard.svg @@ -0,0 +1,951 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8 + + + 9 + + + 10 + + + 12 + + + A2-CH4 + + + A1-CO + + + A0-H2S + + + + + 11 + + + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tinySniff + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + METHANE + + + CARBON MONO. + + + HYDROGEN SULFIDE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BOARD DESIGN BY: + + + GEOFF McINTYRE + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/icon.svg new file mode 100644 index 0000000..d2b4c97 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/icon.svg @@ -0,0 +1,951 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8 + + + 9 + + + 10 + + + 12 + + + A2-CH4 + + + A1-CO + + + A0-H2S + + + + + 11 + + + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tinySniff + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + METHANE + + + CARBON MONO. + + + HYDROGEN SULFIDE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BOARD DESIGN BY: + + + GEOFF McINTYRE + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/part.json new file mode 100644 index 0000000..8ceeef6 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinysniff/part.json @@ -0,0 +1,20 @@ +{ + "type": "tinysniff", + "label": "tinySniff", + "family": "tinyStudio", + "sub": "MEMS gas sensor array", + "accent": "#e0a90d", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": [ + "GND", "3V3", "A5", "A4", "A3", "A2", "A1", "A0", "D8", "D9", "D10", "D11", "D12", "D13", + "3V3.2", "GND.2", "SCK", "MO", "MI", "RX", "TX", "SDA", "SCL", "PWR", "GND.3" + ] + } + } +} diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/breadboard.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/breadboard.svg new file mode 100644 index 0000000..e9dcf76 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/breadboard.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + 3V3 + A5 + A4 + A3 + A2 + A1 + A0 + DIN + BCLK + LRC + WS + DOUT + SCLK + 3V3 + SCK + MO + MI + RX + TX + SDA + SCL + PWR + G + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + tinySpeak + BOARD DESIGN BY: + JOHN LETTANG + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/icon.svg b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/icon.svg new file mode 100644 index 0000000..e9dcf76 --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/icon.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + 3V3 + A5 + A4 + A3 + A2 + A1 + A0 + DIN + BCLK + LRC + WS + DOUT + SCLK + 3V3 + SCK + MO + MI + RX + TX + SDA + SCL + PWR + G + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GND + tinySpeak + BOARD DESIGN BY: + JOHN LETTANG + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/part.json b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/part.json new file mode 100644 index 0000000..8a33d2a --- /dev/null +++ b/src/renderer/src/assets/tinyparts/packs/tinyboards/parts/tinyspeak/part.json @@ -0,0 +1,20 @@ +{ + "type": "tinyspeak", + "label": "tinySpeak", + "family": "tinyStudio", + "sub": "Microphone + speaker", + "accent": "#f0655c", + "builtin": true, + "icon": "icon.svg", + "views": { + "breadboard": { + "svg": "breadboard.svg", + "width": "1.9in", + "height": "1.9in", + "pins": [ + "GND", "3V3", "A5", "A4", "A3", "A2", "A1", "A0", "D8", "D9", "D10", "D11", "D12", "D13", + "3V3.2", "GND.2", "SCK", "MO", "MI", "RX", "TX", "SDA", "SCL", "PWR", "GND.3" + ] + } + } +} diff --git a/src/renderer/src/assets/tinyparts/snapshot.gen.ts b/src/renderer/src/assets/tinyparts/snapshot.gen.ts new file mode 100644 index 0000000..6bdeeed --- /dev/null +++ b/src/renderer/src/assets/tinyparts/snapshot.gen.ts @@ -0,0 +1,379 @@ +// GENERATED by scripts/sync-tinyparts.mjs; do not edit. +// A copy of the bundled packs from github.com/Mister-Industries/tinyparts. +// To change a part, edit it in tinyparts and run `npm run parts:sync`. +/* eslint-disable */ + +import f0 from "./packs/tinyboards/pack.json?raw" +import f1 from "./packs/tinyboards/parts/tinycore/icon.svg?raw" +import f2 from "./packs/tinyboards/parts/tinycore/part.json?raw" +import f3 from "./packs/tinyboards/parts/tinydisplay/icon.svg?raw" +import f4 from "./packs/tinyboards/parts/tinydisplay/part.json?raw" +import f5 from "./packs/tinyboards/parts/tinyglow/icon.svg?raw" +import f6 from "./packs/tinyboards/parts/tinyglow/part.json?raw" +import f7 from "./packs/tinyboards/parts/tinyproto/icon.svg?raw" +import f8 from "./packs/tinyboards/parts/tinyproto/part.json?raw" +import f9 from "./packs/tinyboards/parts/tinysniff/icon.svg?raw" +import f10 from "./packs/tinyboards/parts/tinysniff/part.json?raw" +import f11 from "./packs/tinyboards/parts/tinyspeak/icon.svg?raw" +import f12 from "./packs/tinyboards/parts/tinyspeak/part.json?raw" +import f13 from "./packs/core/pack.json?raw" +import f14 from "./packs/core/parts/7segment-100-cat/icon.svg?raw" +import f15 from "./packs/core/parts/7segment-100-cat/part.json?raw" +import f16 from "./packs/core/parts/battery-aa/icon.svg?raw" +import f17 from "./packs/core/parts/battery-aa/part.json?raw" +import f18 from "./packs/core/parts/buzzer-v15/breadboard.svg?raw" +import f19 from "./packs/core/parts/buzzer-v15/part.json?raw" +import f20 from "./packs/core/parts/capacitor-ceramic-100mil/icon.svg?raw" +import f21 from "./packs/core/parts/capacitor-ceramic-100mil/part.json?raw" +import f22 from "./packs/core/parts/capacitor-ceramic-200mil/icon.svg?raw" +import f23 from "./packs/core/parts/capacitor-ceramic-200mil/part.json?raw" +import f24 from "./packs/core/parts/capacitor-electrolytic-medium/icon.svg?raw" +import f25 from "./packs/core/parts/capacitor-electrolytic-medium/part.json?raw" +import f26 from "./packs/core/parts/diode-1n4001-300mil/icon.svg?raw" +import f27 from "./packs/core/parts/diode-1n4001-300mil/part.json?raw" +import f28 from "./packs/core/parts/diode-zener-0-5w-3-6v-300mil/icon.svg?raw" +import f29 from "./packs/core/parts/diode-zener-0-5w-3-6v-300mil/part.json?raw" +import f30 from "./packs/core/parts/ir-receiver-v14/breadboard.svg?raw" +import f31 from "./packs/core/parts/ir-receiver-v14/part.json?raw" +import f32 from "./packs/core/parts/ldr-photocell-300mil-v5/icon.svg?raw" +import f33 from "./packs/core/parts/ldr-photocell-300mil-v5/part.json?raw" +import f34 from "./packs/core/parts/led-generic-3mm/icon.svg?raw" +import f35 from "./packs/core/parts/led-generic-3mm/part.json?raw" +import f36 from "./packs/core/parts/led-generic-5mm/icon.svg?raw" +import f37 from "./packs/core/parts/led-generic-5mm/part.json?raw" +import f38 from "./packs/core/parts/piezo-sensor/icon.svg?raw" +import f39 from "./packs/core/parts/piezo-sensor/part.json?raw" +import f40 from "./packs/core/parts/potentiometer-rotary-16mm-5/icon.svg?raw" +import f41 from "./packs/core/parts/potentiometer-rotary-16mm-5/part.json?raw" +import f42 from "./packs/core/parts/potentiometer-trimmer-6mm-5/icon.svg?raw" +import f43 from "./packs/core/parts/potentiometer-trimmer-6mm-5/part.json?raw" +import f44 from "./packs/core/parts/pushbutton/icon.svg?raw" +import f45 from "./packs/core/parts/pushbutton/part.json?raw" +import f46 from "./packs/core/parts/reedswitch-500mil/icon.svg?raw" +import f47 from "./packs/core/parts/reedswitch-500mil/part.json?raw" +import f48 from "./packs/core/parts/resistor/icon.svg?raw" +import f49 from "./packs/core/parts/resistor/part.json?raw" +import f50 from "./packs/core/parts/servo/icon.svg?raw" +import f51 from "./packs/core/parts/servo/part.json?raw" +import f52 from "./packs/core/parts/smd-inductor-0805/icon.svg?raw" +import f53 from "./packs/core/parts/smd-inductor-0805/part.json?raw" +import f54 from "./packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg?raw" +import f55 from "./packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json?raw" +import f56 from "./packs/core/parts/sparkfun-passives-fuse-x20mm/breadboard.svg?raw" +import f57 from "./packs/core/parts/sparkfun-passives-fuse-x20mm/part.json?raw" +import f58 from "./packs/core/parts/sparkfun-sensors-mic-electret-smd/icon.svg?raw" +import f59 from "./packs/core/parts/sparkfun-sensors-mic-electret-smd/part.json?raw" +import f60 from "./packs/core/parts/switch-spst/breadboard.svg?raw" +import f61 from "./packs/core/parts/switch-spst/part.json?raw" +import f62 from "./packs/core/parts/te-relay/icon.svg?raw" +import f63 from "./packs/core/parts/te-relay/part.json?raw" +import f64 from "./packs/core/parts/thermistor-300mil/icon.svg?raw" +import f65 from "./packs/core/parts/thermistor-300mil/part.json?raw" +import f66 from "./packs/core/parts/transistor-signal-npn-to92-ebc/icon.svg?raw" +import f67 from "./packs/core/parts/transistor-signal-npn-to92-ebc/part.json?raw" +import f68 from "./packs/core/parts/transistor-signal-pnp-to92-ebc/icon.svg?raw" +import f69 from "./packs/core/parts/transistor-signal-pnp-to92-ebc/part.json?raw" +import f70 from "./packs/core/parts/voltage-regulator-7805/icon.svg?raw" +import f71 from "./packs/core/parts/voltage-regulator-7805/part.json?raw" + +export interface SnapshotPack { + id: string + /** pack-relative path → git blob sha */ + files: Record +} + +export interface TinypartsSnapshot { + /** GitHub owner/repo the packs came from */ + repo: string + /** tinyparts HEAD when synced */ + commit: string + /** true if the checkout had uncommitted changes in these packs */ + dirty: boolean + packs: SnapshotPack[] +} + +export const SNAPSHOT: TinypartsSnapshot = { + "repo": "Mister-Industries/tinyparts", + "commit": "b72b53b5ddbbea6ca4ecb64e951633fa477117a6", + "dirty": false, + "packs": [ + { + "id": "tinyboards", + "files": { + "pack.json": "326f843ade0dbd0bbee0b8319fe5cf7525331653", + "parts/tinycore/breadboard.svg": "1779cf14d778fc1a9344ff7a79fd2c4ecc3c968f", + "parts/tinycore/icon.svg": "ef63f0f404c31716a0a2258304aaa77628644073", + "parts/tinycore/part.json": "4168f74b75e81176d4d09a3bbc36cc0782e5322b", + "parts/tinydisplay/breadboard.svg": "411211170cdd786ed66bf34ce09190a928661c5c", + "parts/tinydisplay/icon.svg": "5c090fc0fe43c4250087c233f68feab4a616c1e9", + "parts/tinydisplay/part.json": "2fc0bf7f33de43eb5d2069fade89b327a0414a1c", + "parts/tinyglow/breadboard.svg": "7422edffb825837211d63e4a6a98252badcf9118", + "parts/tinyglow/icon.svg": "7422edffb825837211d63e4a6a98252badcf9118", + "parts/tinyglow/part.json": "cc0d3615fb4d7484da25a71689c7a9a995394e90", + "parts/tinyproto/breadboard.svg": "3772b77d543b004105083515e9de76bb49dc0b9c", + "parts/tinyproto/icon.svg": "3772b77d543b004105083515e9de76bb49dc0b9c", + "parts/tinyproto/part.json": "5f8707d4a2f3d2344443b7638eb0863104615af9", + "parts/tinysniff/breadboard.svg": "50e7244c365f9038d3dcc01d046bbe774c4b12c3", + "parts/tinysniff/icon.svg": "d2b4c97d4a80841707da10a850090e652faf8bb7", + "parts/tinysniff/part.json": "8ceeef62e1b245133d19a76694fd24deaf0810ce", + "parts/tinyspeak/breadboard.svg": "e9dcf76268ab87f0f3f7b7e9a601b62ab8c69221", + "parts/tinyspeak/icon.svg": "e9dcf76268ab87f0f3f7b7e9a601b62ab8c69221", + "parts/tinyspeak/part.json": "8a33d2ae8283ecd4de172154f79bc673d0a36e54" + } + }, + { + "id": "core", + "files": { + "pack.json": "b06a82ce18ab4b3615873fbd04273c763916c997", + "parts/7segment-100-cat/breadboard.svg": "549167978aff3849ac58e839a9cace3a5b1b5b9a", + "parts/7segment-100-cat/icon.svg": "1e49653dea165e4387fc708c6f1f09584d42d4ff", + "parts/7segment-100-cat/part.json": "e9b66edd52a8255306ac0b62d3514914adc51eef", + "parts/7segment-100-cat/schematic.svg": "f19d3385a7482fafc874f1be4ead4b94ddc565ab", + "parts/battery-aa/breadboard.svg": "22342cfb0e45063ed77760cac2ed69f18c2c2b1b", + "parts/battery-aa/icon.svg": "56c623ef0d2acddb02ace41ec98866d618632da9", + "parts/battery-aa/part.json": "6db446ada25fdce4df10c7156fc875c345e02904", + "parts/battery-aa/schematic.svg": "25b94ba93d9371332bb59041889e5879af675fe0", + "parts/buzzer-v15/breadboard.svg": "2812ed474e704a2182894c0f81d596ea77490785", + "parts/buzzer-v15/part.json": "44d2567d8399861a40706368177b5b3b0b07751c", + "parts/buzzer-v15/schematic.svg": "e5b51c5023a3e02afb23c41f53e700a4e103c248", + "parts/capacitor-ceramic-100mil/breadboard.svg": "02062b2f9ff00e9eeeed6de9048dd45e21297946", + "parts/capacitor-ceramic-100mil/icon.svg": "e7be3c610c13009b7279b9b518880507a1c7c44c", + "parts/capacitor-ceramic-100mil/part.json": "b7261f798e9cbdbe4d800158b57c3ded507135fc", + "parts/capacitor-ceramic-100mil/schematic.svg": "d3c4d03fad2b4db0e7346d279f1360f3f4aa0688", + "parts/capacitor-ceramic-200mil/breadboard.svg": "02062b2f9ff00e9eeeed6de9048dd45e21297946", + "parts/capacitor-ceramic-200mil/icon.svg": "e7be3c610c13009b7279b9b518880507a1c7c44c", + "parts/capacitor-ceramic-200mil/part.json": "f5e85ba21b4968abac2a1506520956e8e6664bcb", + "parts/capacitor-ceramic-200mil/schematic.svg": "d3c4d03fad2b4db0e7346d279f1360f3f4aa0688", + "parts/capacitor-electrolytic-medium/breadboard.svg": "ea972cac6303eda46ff71251a60c0f088b1f3a92", + "parts/capacitor-electrolytic-medium/icon.svg": "169078426d45dc367ac32117c2e8cd52a09f6b80", + "parts/capacitor-electrolytic-medium/part.json": "635c972a1257ac5ec7d89d1eb39958dbd67933ea", + "parts/capacitor-electrolytic-medium/schematic.svg": "e41974ea66b785eaf71e1a370dc5f961e32a411c", + "parts/diode-1n4001-300mil/breadboard.svg": "51ec146c16817c12b77822408e6ce305d2b892fc", + "parts/diode-1n4001-300mil/icon.svg": "99919136a3a31140a0859dca98cfe2da87997a6d", + "parts/diode-1n4001-300mil/part.json": "814f19c8345f11443be57c5595a6c3b1e99ba290", + "parts/diode-1n4001-300mil/schematic.svg": "80c4d6c13d7797784a3642bcc5eeab70913aae88", + "parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg": "51ec146c16817c12b77822408e6ce305d2b892fc", + "parts/diode-zener-0-5w-3-6v-300mil/icon.svg": "99919136a3a31140a0859dca98cfe2da87997a6d", + "parts/diode-zener-0-5w-3-6v-300mil/part.json": "5762173d65ffd26e7eff918436d9d88c700ca2d7", + "parts/diode-zener-0-5w-3-6v-300mil/schematic.svg": "09811f92bec361481c131a8f61c67da1403e994a", + "parts/ir-receiver-v14/breadboard.svg": "de3ac1dc191c090f1a9981ea4581d3a9ee8cbc44", + "parts/ir-receiver-v14/part.json": "ac23a62480de5de506790ecd2596b6490bd2a5d5", + "parts/ir-receiver-v14/schematic.svg": "4ebaef490ada9008220e4b53ed2e8434aed8200f", + "parts/ldr-photocell-300mil-v5/breadboard.svg": "284910b8672ac440de9d4e7691480834f2033ffa", + "parts/ldr-photocell-300mil-v5/icon.svg": "27a0dc98adc0b63497314387f4e693b0da81cd3d", + "parts/ldr-photocell-300mil-v5/part.json": "18ef5a1a2f645719d1dee7e329e5614fc46ae648", + "parts/ldr-photocell-300mil-v5/schematic.svg": "6660b5610395757ccf14a36dc190d800ff4013ba", + "parts/led-generic-3mm/breadboard.svg": "dc70fef7340cd3ab0450755a6d67eb194919c378", + "parts/led-generic-3mm/icon.svg": "c44612f7d0be61ab81a7afac049679b49f397a83", + "parts/led-generic-3mm/part.json": "b747876a98de5aaf9a1a706abad2c40942825007", + "parts/led-generic-3mm/schematic.svg": "2a877c8184018980eea99b1e2d414097798b03b6", + "parts/led-generic-5mm/breadboard.svg": "5cb64cd51cc0309085f100e21434850f116f173f", + "parts/led-generic-5mm/icon.svg": "c44612f7d0be61ab81a7afac049679b49f397a83", + "parts/led-generic-5mm/part.json": "bc7557fa9cb28e0069b27f478972b583f6857f77", + "parts/led-generic-5mm/schematic.svg": "2a877c8184018980eea99b1e2d414097798b03b6", + "parts/piezo-sensor/breadboard.svg": "7d128d79612d4f0b50a9773ff13cbbc79d088dea", + "parts/piezo-sensor/icon.svg": "ceba2eb508701d6070c4d385f7659a2db73f719a", + "parts/piezo-sensor/part.json": "c5e871263b4dbce52ea3563abcf98ff092d5b2bb", + "parts/piezo-sensor/schematic.svg": "e0c2fc9588750fdd57caaeed41654af0cf36e2e6", + "parts/potentiometer-rotary-16mm-5/breadboard.svg": "f5dbc44ccb248abd19d61a4e84599db934dfe8f2", + "parts/potentiometer-rotary-16mm-5/icon.svg": "5c7a529bcacff30ca84d03d53d4cff77a9192a60", + "parts/potentiometer-rotary-16mm-5/part.json": "adbf1410be85969943861260886a1b4b568b58f6", + "parts/potentiometer-rotary-16mm-5/schematic.svg": "9004e013768755bce1bb6a5ef6cf09d823cab9a9", + "parts/potentiometer-trimmer-6mm-5/breadboard.svg": "2ad4b3b88117fc96ea77c057229eddd5a1140b87", + "parts/potentiometer-trimmer-6mm-5/icon.svg": "05597dfffb400bdb2d0d10d8a321bdce0edc655b", + "parts/potentiometer-trimmer-6mm-5/part.json": "aff61cc68384f9d0eb14788ad37f073e6c75b935", + "parts/potentiometer-trimmer-6mm-5/schematic.svg": "9004e013768755bce1bb6a5ef6cf09d823cab9a9", + "parts/pushbutton/breadboard.svg": "542c3f0b6ba67104271630e77577380283255736", + "parts/pushbutton/icon.svg": "4fe6b429af4ff654eb299b8b33c72f497254f757", + "parts/pushbutton/part.json": "8fdd248cdbb31646db904cca3f53330fc362fb22", + "parts/pushbutton/schematic.svg": "78ca75365d07c68223fc618b2fe16128bb123f33", + "parts/reedswitch-500mil/breadboard.svg": "b406d2c3ec035017488fce5e5328e2c12c3b0f58", + "parts/reedswitch-500mil/icon.svg": "70bcc955c5874458f0bdb924923fc3a29ab651b9", + "parts/reedswitch-500mil/part.json": "cc2f3dc9bf2a39b826e76ce84e038461fc1105d7", + "parts/reedswitch-500mil/schematic.svg": "bc98dc6b6e0affadb4921107327c2471df8438dc", + "parts/resistor/breadboard.svg": "0b736e0f7918684500fde5cebbe7fe7329e59648", + "parts/resistor/icon.svg": "d7daecb5944342363d205b756c64b9a712ce4f6d", + "parts/resistor/part.json": "b2c5cbc7e163f05f02db8f401227d0d6945211cc", + "parts/resistor/schematic.svg": "a65b548dc01d66e92f09eca6d40e1d8cd0850726", + "parts/servo/breadboard.svg": "2e576b33d7f6f6f1ec8ee324443d119e91084df6", + "parts/servo/icon.svg": "51479534c0f8fdcd4d88149c41777d28c07a411d", + "parts/servo/part.json": "bc9154bc15186020d9d8190983a8dfd8aeccd5c2", + "parts/servo/schematic.svg": "7a861f3ac394ff6bfb898bb5c6fc4b5acd20fa64", + "parts/smd-inductor-0805/breadboard.svg": "b29f48f7034ab4fd98399fdc03deab2d39398f02", + "parts/smd-inductor-0805/icon.svg": "7221c0a29eaae9b357378e4d6c8f4980c65f60b7", + "parts/smd-inductor-0805/part.json": "5bb4d3653ca1ac06a85d97fceb2998b4b9d52cd8", + "parts/smd-inductor-0805/schematic.svg": "7d4d33d90136afe4f440dc2e3644c80404de4e32", + "parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg": "df12961a1728fc4dc8f3b4bbe6cec9911c869fe5", + "parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json": "4af45bc914333fd252d27ff84bbd04caae278eeb", + "parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg": "7c3552169c311b0b31f6790affca783053446b76", + "parts/sparkfun-passives-fuse-x20mm/breadboard.svg": "33b62863e197f81109a6046583bcc66f7713513f", + "parts/sparkfun-passives-fuse-x20mm/part.json": "a3b5db0655083f16f60ff96e2e333857073ffdfc", + "parts/sparkfun-passives-fuse-x20mm/schematic.svg": "44486fa7609d8cb3dec2d6b377259ac20668c9c5", + "parts/sparkfun-sensors-mic-electret-smd/breadboard.svg": "1b447742b02fcbc735ac85e0a0edf328d5d5e257", + "parts/sparkfun-sensors-mic-electret-smd/icon.svg": "659febcd8df0c34bee22ca5defd021c2aac08c0e", + "parts/sparkfun-sensors-mic-electret-smd/part.json": "428ea09ae870a8c1198362b8adc218fcf450aac1", + "parts/sparkfun-sensors-mic-electret-smd/schematic.svg": "f354a4ebdfdb99bdcc2cee8c22fe2664d1d0392e", + "parts/switch-spst/breadboard.svg": "65b55040aa3fa0142449296e9f8dd43298c035ce", + "parts/switch-spst/part.json": "fa69bfca28dbafa33cfa8309541b9b644874e25e", + "parts/te-relay/breadboard.svg": "8cd7f4358b8345e2307e87e1e83e0c4a188188ee", + "parts/te-relay/icon.svg": "97ac95d7ccc2d2fca31ce077299c3a74bf310f23", + "parts/te-relay/part.json": "e86b2da7360d240d7d6a0e3b5b804867cc49f884", + "parts/te-relay/schematic.svg": "2f39b0b17253dedb02b0699927aa71741a13749e", + "parts/thermistor-300mil/breadboard.svg": "12cf0b00d4fc40c48852aec520b4498a98a06854", + "parts/thermistor-300mil/icon.svg": "fe22ce0e02f5d69c563f30cea162394913c7e056", + "parts/thermistor-300mil/part.json": "4e663f7f163c37c2fba93831d94b00bd0541deed", + "parts/thermistor-300mil/schematic.svg": "29df2e7f6e09dc4c0087b60b9cbd0bdff36308de", + "parts/transistor-signal-npn-to92-ebc/breadboard.svg": "b71e6d3965cb6fd3bfad97ffc3f06a6c8a670096", + "parts/transistor-signal-npn-to92-ebc/icon.svg": "5fc7ba3e62e7b759477519ec2a631b8b7577c23e", + "parts/transistor-signal-npn-to92-ebc/part.json": "85565d68a7815ce2dca0ff7c11ae0b1b66eeaea4", + "parts/transistor-signal-npn-to92-ebc/schematic.svg": "7e3e757e9f0646582f816864bbe21d258857104e", + "parts/transistor-signal-pnp-to92-ebc/breadboard.svg": "df12961a1728fc4dc8f3b4bbe6cec9911c869fe5", + "parts/transistor-signal-pnp-to92-ebc/icon.svg": "36f80c8bed181c4ee02de7cec866479ab4c2594c", + "parts/transistor-signal-pnp-to92-ebc/part.json": "8327cd9b44588aa2fc98d82025676a684ad5ed7a", + "parts/transistor-signal-pnp-to92-ebc/schematic.svg": "b993b93762ee30164a6c5a83dd9171f8a146c832", + "parts/voltage-regulator-7805/breadboard.svg": "3fa48ac7a68dcfaed5e784ef15427dc8cd39bede", + "parts/voltage-regulator-7805/icon.svg": "b2dcab03a257aa83aa49e87d0b0a3faa2f4c22d9", + "parts/voltage-regulator-7805/part.json": "f20dc8dd96688a8c6caf5bf2bfd337622ebe66d8", + "parts/voltage-regulator-7805/schematic.svg": "826c96941f90e26d931c27d63f3a93f9c2953f06" + } + } + ] +} + +/** repo-relative path → file text (part.json, pack.json and palette icons) */ +export const EAGER: Record = { + "packs/tinyboards/pack.json": f0, + "packs/tinyboards/parts/tinycore/icon.svg": f1, + "packs/tinyboards/parts/tinycore/part.json": f2, + "packs/tinyboards/parts/tinydisplay/icon.svg": f3, + "packs/tinyboards/parts/tinydisplay/part.json": f4, + "packs/tinyboards/parts/tinyglow/icon.svg": f5, + "packs/tinyboards/parts/tinyglow/part.json": f6, + "packs/tinyboards/parts/tinyproto/icon.svg": f7, + "packs/tinyboards/parts/tinyproto/part.json": f8, + "packs/tinyboards/parts/tinysniff/icon.svg": f9, + "packs/tinyboards/parts/tinysniff/part.json": f10, + "packs/tinyboards/parts/tinyspeak/icon.svg": f11, + "packs/tinyboards/parts/tinyspeak/part.json": f12, + "packs/core/pack.json": f13, + "packs/core/parts/7segment-100-cat/icon.svg": f14, + "packs/core/parts/7segment-100-cat/part.json": f15, + "packs/core/parts/battery-aa/icon.svg": f16, + "packs/core/parts/battery-aa/part.json": f17, + "packs/core/parts/buzzer-v15/breadboard.svg": f18, + "packs/core/parts/buzzer-v15/part.json": f19, + "packs/core/parts/capacitor-ceramic-100mil/icon.svg": f20, + "packs/core/parts/capacitor-ceramic-100mil/part.json": f21, + "packs/core/parts/capacitor-ceramic-200mil/icon.svg": f22, + "packs/core/parts/capacitor-ceramic-200mil/part.json": f23, + "packs/core/parts/capacitor-electrolytic-medium/icon.svg": f24, + "packs/core/parts/capacitor-electrolytic-medium/part.json": f25, + "packs/core/parts/diode-1n4001-300mil/icon.svg": f26, + "packs/core/parts/diode-1n4001-300mil/part.json": f27, + "packs/core/parts/diode-zener-0-5w-3-6v-300mil/icon.svg": f28, + "packs/core/parts/diode-zener-0-5w-3-6v-300mil/part.json": f29, + "packs/core/parts/ir-receiver-v14/breadboard.svg": f30, + "packs/core/parts/ir-receiver-v14/part.json": f31, + "packs/core/parts/ldr-photocell-300mil-v5/icon.svg": f32, + "packs/core/parts/ldr-photocell-300mil-v5/part.json": f33, + "packs/core/parts/led-generic-3mm/icon.svg": f34, + "packs/core/parts/led-generic-3mm/part.json": f35, + "packs/core/parts/led-generic-5mm/icon.svg": f36, + "packs/core/parts/led-generic-5mm/part.json": f37, + "packs/core/parts/piezo-sensor/icon.svg": f38, + "packs/core/parts/piezo-sensor/part.json": f39, + "packs/core/parts/potentiometer-rotary-16mm-5/icon.svg": f40, + "packs/core/parts/potentiometer-rotary-16mm-5/part.json": f41, + "packs/core/parts/potentiometer-trimmer-6mm-5/icon.svg": f42, + "packs/core/parts/potentiometer-trimmer-6mm-5/part.json": f43, + "packs/core/parts/pushbutton/icon.svg": f44, + "packs/core/parts/pushbutton/part.json": f45, + "packs/core/parts/reedswitch-500mil/icon.svg": f46, + "packs/core/parts/reedswitch-500mil/part.json": f47, + "packs/core/parts/resistor/icon.svg": f48, + "packs/core/parts/resistor/part.json": f49, + "packs/core/parts/servo/icon.svg": f50, + "packs/core/parts/servo/part.json": f51, + "packs/core/parts/smd-inductor-0805/icon.svg": f52, + "packs/core/parts/smd-inductor-0805/part.json": f53, + "packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/breadboard.svg": f54, + "packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/part.json": f55, + "packs/core/parts/sparkfun-passives-fuse-x20mm/breadboard.svg": f56, + "packs/core/parts/sparkfun-passives-fuse-x20mm/part.json": f57, + "packs/core/parts/sparkfun-sensors-mic-electret-smd/icon.svg": f58, + "packs/core/parts/sparkfun-sensors-mic-electret-smd/part.json": f59, + "packs/core/parts/switch-spst/breadboard.svg": f60, + "packs/core/parts/switch-spst/part.json": f61, + "packs/core/parts/te-relay/icon.svg": f62, + "packs/core/parts/te-relay/part.json": f63, + "packs/core/parts/thermistor-300mil/icon.svg": f64, + "packs/core/parts/thermistor-300mil/part.json": f65, + "packs/core/parts/transistor-signal-npn-to92-ebc/icon.svg": f66, + "packs/core/parts/transistor-signal-npn-to92-ebc/part.json": f67, + "packs/core/parts/transistor-signal-pnp-to92-ebc/icon.svg": f68, + "packs/core/parts/transistor-signal-pnp-to92-ebc/part.json": f69, + "packs/core/parts/voltage-regulator-7805/icon.svg": f70, + "packs/core/parts/voltage-regulator-7805/part.json": f71, +} + +/** repo-relative path → lazy loader for everything else */ +export const LAZY: Record Promise> = { + "packs/tinyboards/parts/tinycore/breadboard.svg": () => import("./packs/tinyboards/parts/tinycore/breadboard.svg?raw").then((m) => m.default), + "packs/tinyboards/parts/tinydisplay/breadboard.svg": () => import("./packs/tinyboards/parts/tinydisplay/breadboard.svg?raw").then((m) => m.default), + "packs/tinyboards/parts/tinyglow/breadboard.svg": () => import("./packs/tinyboards/parts/tinyglow/breadboard.svg?raw").then((m) => m.default), + "packs/tinyboards/parts/tinyproto/breadboard.svg": () => import("./packs/tinyboards/parts/tinyproto/breadboard.svg?raw").then((m) => m.default), + "packs/tinyboards/parts/tinysniff/breadboard.svg": () => import("./packs/tinyboards/parts/tinysniff/breadboard.svg?raw").then((m) => m.default), + "packs/tinyboards/parts/tinyspeak/breadboard.svg": () => import("./packs/tinyboards/parts/tinyspeak/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/7segment-100-cat/breadboard.svg": () => import("./packs/core/parts/7segment-100-cat/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/7segment-100-cat/schematic.svg": () => import("./packs/core/parts/7segment-100-cat/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/battery-aa/breadboard.svg": () => import("./packs/core/parts/battery-aa/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/battery-aa/schematic.svg": () => import("./packs/core/parts/battery-aa/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/buzzer-v15/schematic.svg": () => import("./packs/core/parts/buzzer-v15/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-ceramic-100mil/breadboard.svg": () => import("./packs/core/parts/capacitor-ceramic-100mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-ceramic-100mil/schematic.svg": () => import("./packs/core/parts/capacitor-ceramic-100mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-ceramic-200mil/breadboard.svg": () => import("./packs/core/parts/capacitor-ceramic-200mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-ceramic-200mil/schematic.svg": () => import("./packs/core/parts/capacitor-ceramic-200mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-electrolytic-medium/breadboard.svg": () => import("./packs/core/parts/capacitor-electrolytic-medium/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/capacitor-electrolytic-medium/schematic.svg": () => import("./packs/core/parts/capacitor-electrolytic-medium/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/diode-1n4001-300mil/breadboard.svg": () => import("./packs/core/parts/diode-1n4001-300mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/diode-1n4001-300mil/schematic.svg": () => import("./packs/core/parts/diode-1n4001-300mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg": () => import("./packs/core/parts/diode-zener-0-5w-3-6v-300mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/diode-zener-0-5w-3-6v-300mil/schematic.svg": () => import("./packs/core/parts/diode-zener-0-5w-3-6v-300mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/ir-receiver-v14/schematic.svg": () => import("./packs/core/parts/ir-receiver-v14/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/ldr-photocell-300mil-v5/breadboard.svg": () => import("./packs/core/parts/ldr-photocell-300mil-v5/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/ldr-photocell-300mil-v5/schematic.svg": () => import("./packs/core/parts/ldr-photocell-300mil-v5/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/led-generic-3mm/breadboard.svg": () => import("./packs/core/parts/led-generic-3mm/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/led-generic-3mm/schematic.svg": () => import("./packs/core/parts/led-generic-3mm/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/led-generic-5mm/breadboard.svg": () => import("./packs/core/parts/led-generic-5mm/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/led-generic-5mm/schematic.svg": () => import("./packs/core/parts/led-generic-5mm/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/piezo-sensor/breadboard.svg": () => import("./packs/core/parts/piezo-sensor/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/piezo-sensor/schematic.svg": () => import("./packs/core/parts/piezo-sensor/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/potentiometer-rotary-16mm-5/breadboard.svg": () => import("./packs/core/parts/potentiometer-rotary-16mm-5/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/potentiometer-rotary-16mm-5/schematic.svg": () => import("./packs/core/parts/potentiometer-rotary-16mm-5/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/potentiometer-trimmer-6mm-5/breadboard.svg": () => import("./packs/core/parts/potentiometer-trimmer-6mm-5/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/potentiometer-trimmer-6mm-5/schematic.svg": () => import("./packs/core/parts/potentiometer-trimmer-6mm-5/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/pushbutton/breadboard.svg": () => import("./packs/core/parts/pushbutton/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/pushbutton/schematic.svg": () => import("./packs/core/parts/pushbutton/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/reedswitch-500mil/breadboard.svg": () => import("./packs/core/parts/reedswitch-500mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/reedswitch-500mil/schematic.svg": () => import("./packs/core/parts/reedswitch-500mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/resistor/breadboard.svg": () => import("./packs/core/parts/resistor/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/resistor/schematic.svg": () => import("./packs/core/parts/resistor/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/servo/breadboard.svg": () => import("./packs/core/parts/servo/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/servo/schematic.svg": () => import("./packs/core/parts/servo/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/smd-inductor-0805/breadboard.svg": () => import("./packs/core/parts/smd-inductor-0805/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/smd-inductor-0805/schematic.svg": () => import("./packs/core/parts/smd-inductor-0805/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg": () => import("./packs/core/parts/sparkfun-discretesemi-mosfet-nchannel-pth/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/sparkfun-passives-fuse-x20mm/schematic.svg": () => import("./packs/core/parts/sparkfun-passives-fuse-x20mm/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/sparkfun-sensors-mic-electret-smd/breadboard.svg": () => import("./packs/core/parts/sparkfun-sensors-mic-electret-smd/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/sparkfun-sensors-mic-electret-smd/schematic.svg": () => import("./packs/core/parts/sparkfun-sensors-mic-electret-smd/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/te-relay/breadboard.svg": () => import("./packs/core/parts/te-relay/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/te-relay/schematic.svg": () => import("./packs/core/parts/te-relay/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/thermistor-300mil/breadboard.svg": () => import("./packs/core/parts/thermistor-300mil/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/thermistor-300mil/schematic.svg": () => import("./packs/core/parts/thermistor-300mil/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/transistor-signal-npn-to92-ebc/breadboard.svg": () => import("./packs/core/parts/transistor-signal-npn-to92-ebc/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/transistor-signal-npn-to92-ebc/schematic.svg": () => import("./packs/core/parts/transistor-signal-npn-to92-ebc/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/transistor-signal-pnp-to92-ebc/breadboard.svg": () => import("./packs/core/parts/transistor-signal-pnp-to92-ebc/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/transistor-signal-pnp-to92-ebc/schematic.svg": () => import("./packs/core/parts/transistor-signal-pnp-to92-ebc/schematic.svg?raw").then((m) => m.default), + "packs/core/parts/voltage-regulator-7805/breadboard.svg": () => import("./packs/core/parts/voltage-regulator-7805/breadboard.svg?raw").then((m) => m.default), + "packs/core/parts/voltage-regulator-7805/schematic.svg": () => import("./packs/core/parts/voltage-regulator-7805/schematic.svg?raw").then((m) => m.default), +} diff --git a/src/renderer/src/assets/wavy-lines.svg b/src/renderer/src/assets/wavy-lines.svg deleted file mode 100644 index d08c611..0000000 --- a/src/renderer/src/assets/wavy-lines.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/src/circuit/__tests__/breadboard.test.ts b/src/renderer/src/circuit/__tests__/breadboard.test.ts index 949b416..2d63f1b 100644 --- a/src/renderer/src/circuit/__tests__/breadboard.test.ts +++ b/src/renderer/src/circuit/__tests__/breadboard.test.ts @@ -1,4 +1,4 @@ -/** Tests for parts/breadboard — generator geometry, buses, net integration. */ +/** Tests for parts/breadboard: generator geometry, buses, net integration. */ import assert from 'node:assert/strict' import { test } from 'node:test' @@ -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..91d9d34 100644 --- a/src/renderer/src/circuit/__tests__/clipboard.test.ts +++ b/src/renderer/src/circuit/__tests__/clipboard.test.ts @@ -1,8 +1,13 @@ -/** Tests for core/clipboard — copy payload rules + paste re-iding. */ +/** Tests for core/clipboard: copy payload rules + paste re-iding. */ 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', () => { @@ -37,7 +45,7 @@ test('buildClipboard keeps junction riders when the host and both parts are in', test('buildClipboard drops junction riders whose host wire is dropped (fixpoint)', () => { const doc = fixture() - // select all parts but explicitly only wire w3 — its host w1 is a candidate + // select all parts but explicitly only wire w3; its host w1 is a candidate // via both-pins-selected, so w3 survives; then remove R2 → w1 dies → w3 dies. const p1 = buildClipboard(doc, ['R1', 'R2', 'LED1'], ['w3'])! assert.ok(p1.wires.some((w) => w.id === 'w3')) @@ -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__/erc.test.ts b/src/renderer/src/circuit/__tests__/erc.test.ts index 9d4744b..3510ad9 100644 --- a/src/renderer/src/circuit/__tests__/erc.test.ts +++ b/src/renderer/src/circuit/__tests__/erc.test.ts @@ -1,4 +1,4 @@ -/** Tests for core/erc — net-model rule checks. */ +/** Tests for core/erc: net-model rule checks. */ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { runErc } from '../core/erc' diff --git a/src/renderer/src/circuit/__tests__/exportSch.test.ts b/src/renderer/src/circuit/__tests__/exportSch.test.ts index 4eaa160..d9074ac 100644 --- a/src/renderer/src/circuit/__tests__/exportSch.test.ts +++ b/src/renderer/src/circuit/__tests__/exportSch.test.ts @@ -1,8 +1,9 @@ -/** Tests for schematic image export — ink wires + net-label glyphs, valid XML shape. */ +/** Tests for schematic image export: ink wires + net-label glyphs, valid XML shape. */ 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..0332819 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) @@ -88,7 +122,7 @@ test('unzip round-trips stored and deflated entries', async () => { test('unzip skips directory entries and rejects non-zip data', async () => { const zip = buildZip([{ name: 'a/', data: new Uint8Array(0), store: true }]) assert.equal((await unzip(zip)).length, 0) - await assert.rejects(() => unzip(new TextEncoder().encode('not a zip at all — plain text'))) + await assert.rejects(() => unzip(new TextEncoder().encode('not a zip at all; plain text'))) }) test('unzip survives a trailing comment after the EOCD', async () => { 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..fceb258 100644 --- a/src/renderer/src/circuit/__tests__/model.test.ts +++ b/src/renderer/src/circuit/__tests__/model.test.ts @@ -1,5 +1,5 @@ /** - * circuit/core/model tests — v2 parse/serialize round-trip, v1/Wokwi + * circuit/core/model tests: v2 parse/serialize round-trip, v1/Wokwi * migration, unknown-key preservation (B3), duplicate handling. */ import { strict as assert } from 'node:assert' @@ -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..13b89b1 --- /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__/netLabels.test.ts b/src/renderer/src/circuit/__tests__/netLabels.test.ts index 46d0137..a6600da 100644 --- a/src/renderer/src/circuit/__tests__/netLabels.test.ts +++ b/src/renderer/src/circuit/__tests__/netLabels.test.ts @@ -1,4 +1,4 @@ -/** Tests for parts/netLabels — glyph geometry, single pin, grid snapping. */ +/** Tests for parts/netLabels: glyph geometry, single pin, grid snapping. */ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { GRID_BB, type NetLabel } from '../core/model' diff --git a/src/renderer/src/circuit/__tests__/netlist.test.ts b/src/renderer/src/circuit/__tests__/netlist.test.ts index 8da0a6b..d96a7fc 100644 --- a/src/renderer/src/circuit/__tests__/netlist.test.ts +++ b/src/renderer/src/circuit/__tests__/netlist.test.ts @@ -1,4 +1,4 @@ -/** Golden tests for core/netlist — SPICE generation from the net model. */ +/** Golden tests for core/netlist: SPICE generation from the net model. */ import assert from 'node:assert/strict' import { test } from 'node:test' @@ -20,7 +20,7 @@ const part = (id: string, type: string, attrs?: CircuitPart['attrs']): CircuitPa sch: { x: 0, y: 0 } }) -/** V1 5V across R1(10k) → R2(4.7kΩ) to ground — the RC-divider golden. */ +/** V1 5V across R1(10k) → R2(4.7kΩ) to ground: the RC-divider golden. */ function divider(): CircuitDoc { const doc = emptyDoc() doc.parts = [ @@ -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) }) @@ -191,7 +194,7 @@ test('mapSimIssue finds the part behind an ngspice device-name mention', () => { test('mapSimIssue finds the net behind a node-name mention, skips ground', () => { const doc = divider() const res = generateNetlist(doc, buildNets(doc)) - // R1/R2 share a node — find its synthetic name and confirm it round-trips + // R1/R2 share a node; find its synthetic name and confirm it round-trips const midIdx = res.nodeOfNet.findIndex((n) => n !== '0' && n.startsWith('n')) assert.ok(midIdx >= 0) const hit = mapSimIssue(`doAnalyses: node ${res.nodeOfNet[midIdx]} is floating`, res) diff --git a/src/renderer/src/circuit/__tests__/nets.test.ts b/src/renderer/src/circuit/__tests__/nets.test.ts index f790818..bce61ff 100644 --- a/src/renderer/src/circuit/__tests__/nets.test.ts +++ b/src/renderer/src/circuit/__tests__/nets.test.ts @@ -1,5 +1,5 @@ /** - * circuit/core/nets tests — DSU nets across views, buses, junction identity + * circuit/core/nets tests: DSU nets across views, buses, junction identity * (B9), net labels, dangling detection. */ import { strict as assert } from 'node:assert' diff --git a/src/renderer/src/circuit/__tests__/packs.test.ts b/src/renderer/src/circuit/__tests__/packs.test.ts index c4133bd..2cfb8b7 100644 --- a/src/renderer/src/circuit/__tests__/packs.test.ts +++ b/src/renderer/src/circuit/__tests__/packs.test.ts @@ -1,21 +1,24 @@ -/** Tests for parts/packs — index/manifest fetch+validate, install, settings. - * Network (fetch) and localStorage are stubbed; registerPart/getPart are the - * real in-memory registry (no DOM needed), so installPack is exercised - * end-to-end against it. */ +/** Tests for parts/packs: index/manifest fetch+validate, install into the + * parts cache (served as the registry's remote layer), settings. Network + * (fetch) and localStorage are stubbed; the cache falls back to memory under + * node, and the registry is the real one. */ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' -import { getPart } from '../../lib/partsLibrary' +import { PART_MANIFEST, getPart, loadPart } from '../../lib/partsLibrary' import { DEFAULT_INDEX_URL, fetchIndex, fetchManifest, getIndexUrls, getInstalledPacks, + githubOrigin, installPack, setIndexUrls, + uninstallPack, type PackManifest } from '../parts/packs' +import { getCachedPack } from '../parts/partsCache' // ── in-memory localStorage stub (Node has no browser globals) ────────────── @@ -42,17 +45,26 @@ beforeEach(() => { // ── fetch stub ─────────────────────────────────────────────────────────────── -function stubFetch(routes: Record unknown) | { status: number }>): void { +type Route = unknown | { status: number } + +function stubFetch(routes: Record): void { ;(globalThis as unknown as { fetch: typeof fetch }).fetch = (async ( url: string ): Promise => { 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 - } - const body = typeof hit === 'function' ? (hit as () => unknown)() : hit - return { ok: true, status: 200, json: async () => body } as Response + const fail = (status: number): Response => + ({ ok: false, status, json: async () => ({}), text: async () => '' }) as unknown as Response + if (hit === undefined) return fail(404) + if (typeof hit === 'object' && hit !== null && 'status' in hit) + return fail((hit as { status: number }).status) + const text = typeof hit === 'string' ? hit : JSON.stringify(hit) + return { + ok: true, + status: 200, + json: async () => JSON.parse(text), + text: async () => text, + arrayBuffer: async () => new TextEncoder().encode(text).buffer + } as unknown as Response }) as typeof fetch } @@ -93,7 +105,22 @@ 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/ + ) +}) + +test('githubOrigin recognises a tinyparts-shaped raw URL and nothing else', () => { + assert.deepEqual( + githubOrigin('https://raw.githubusercontent.com/me/tinyparts/dev/packs/leds/pack.json', 'leds'), + { kind: 'github', repo: 'me/tinyparts', ref: 'dev', commit: '' } + ) + assert.equal( + githubOrigin('https://raw.githubusercontent.com/me/r/main/other/pack.json', 'leds'), + null + ) + assert.equal(githubOrigin('https://example.com/packs/leds/pack.json', 'leds'), null) }) // ── installPack ────────────────────────────────────────────────────────────── @@ -105,42 +132,63 @@ const RESISTOR_DEF = { views: { breadboard: { svg: '', w: 10, h: 10, pins: { '1': [0, 5], '2': [10, 5] } } } } -test('installPack registers every valid part via saveUserPart, continues past one bad part', async () => { +const FOLDER_ART = + '' + +test('installPack caches every valid part (single-file and folder), continues past bad ones', async () => { const manifest: PackManifest = { schema: 1, - id: 'core', - name: 'Core', + id: 'pack-test', + name: 'Pack Test', version: '2.0.0', parts: [ { type: 'pack-test-resistor', file: 'parts/resistor.json' }, + { type: 'pack-test-folder', dir: 'parts/pack-test-folder' }, { type: 'pack-test-missing', file: 'parts/missing.json' }, // 404 { type: 'pack-test-malformed', file: 'parts/malformed.json' } // not a PartDef ] } stubFetch({ 'https://example.com/parts/resistor.json': RESISTOR_DEF, + 'https://example.com/parts/pack-test-folder/part.json': { + type: 'pack-test-folder', + label: 'Folder Part', + family: 'Passive', + views: { breadboard: { svg: 'breadboard.svg', width: 20, height: 10 } } + }, + 'https://example.com/parts/pack-test-folder/breadboard.svg': FOLDER_ART, 'https://example.com/parts/malformed.json': { not: 'a part' } - // parts/missing.json intentionally absent -> 404 }) const progress: [number, number][] = [] const res = await installPack(manifest, 'https://example.com/pack.json', (done, total) => progress.push([done, total]) ) - assert.deepEqual(res.installed, ['pack-test-resistor']) + assert.deepEqual(res.installed, ['pack-test-resistor', 'pack-test-folder']) assert.equal(res.failed.length, 2) assert.ok(res.failed.some((f) => f.type === 'pack-test-missing' && /HTTP 404/.test(f.error))) assert.ok(res.failed.some((f) => f.type === 'pack-test-malformed')) - assert.equal(progress.length, 3) - assert.deepEqual(progress[2], [3, 3]) - - // the successfully-installed part is live in the registry - assert.ok(getPart('pack-test-resistor')) - - // a pack with at least one success is marked installed at its version - assert.equal(getInstalledPacks().core, '2.0.0') + assert.deepEqual(progress[3], [4, 4]) + + // live in the registry, from the remote layer + assert.ok(getPart('pack-test-resistor'), 'single-file parts are ready immediately') + const folder = await loadPart('pack-test-folder') + assert.deepEqual(folder?.views.breadboard?.pins, { A: [5, 5], B: [15, 5] }) + assert.equal(PART_MANIFEST.find((m) => m.type === 'pack-test-folder')?.layer, 'remote') + + // stored with blob shas, and the stored manifest lists only what landed + const rec = await getCachedPack('pack-test') + assert.ok(rec) + assert.match(rec!.files['parts/pack-test-folder/breadboard.svg'], /^[0-9a-f]{40}$/) + assert.deepEqual(rec!.origin, { kind: 'url', url: 'https://example.com/pack.json' }) + assert.equal(getInstalledPacks()['pack-test'], '2.0.0') + + await uninstallPack('pack-test') + assert.equal(getPart('pack-test-resistor'), undefined) + assert.equal(await getCachedPack('pack-test'), undefined) + assert.equal(getInstalledPacks()['pack-test'], undefined) }) -test('installPack does not mark a pack installed when every part fails', async () => { +test('installPack does not store or mark a pack when every part fails', async () => { const manifest: PackManifest = { schema: 1, id: 'all-bad', @@ -152,6 +200,7 @@ test('installPack does not mark a pack installed when every part fails', async ( const res = await installPack(manifest, 'https://example.com/pack.json') assert.equal(res.installed.length, 0) assert.equal(getInstalledPacks()['all-bad'], undefined) + assert.equal(await getCachedPack('all-bad'), undefined) }) // ── settings (index URL list) ─────────────────────────────────────────────── diff --git a/src/renderer/src/circuit/__tests__/paletteLayout.test.ts b/src/renderer/src/circuit/__tests__/paletteLayout.test.ts new file mode 100644 index 0000000..d497adc --- /dev/null +++ b/src/renderer/src/circuit/__tests__/paletteLayout.test.ts @@ -0,0 +1,197 @@ +/** Tests for views/palette/paletteLayout: Fritzing-style tabs and sections. */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + PART_MANIFEST, + getPackInfo, + registerPart, + unregisterPart, + type PackInfo, + type PartMeta +} from '../../lib/partsLibrary' +import { BREADBOARDS, generateBreadboard } from '../parts/breadboard' +import { + CORE_TAB, + MINE_TAB, + paletteTabs, + searchParts, + tabIdFor +} from '../views/palette/paletteLayout' + +const part = (type: string, extra: Partial = {}): PartMeta => ({ + type, + label: type, + family: '', + views: ['breadboard'], + pins: 2, + ...extra +}) + +const PACKS: Record = { + core: { id: 'core', name: 'Core', group: 'tinyStudio', sections: ['Basic', 'Input'] }, + tinyboards: { + id: 'tinyboards', + name: 'tinyBoards', + group: 'tinyStudio', + sections: ['tinyBoards'] + }, + 'sparkfun-sensors': { id: 'sparkfun-sensors', name: 'SparkFun · Sensors', group: 'SparkFun' }, + 'sparkfun-rf': { id: 'sparkfun-rf', name: 'SparkFun · RF', group: 'SparkFun' }, + arduino: { id: 'arduino', name: 'Arduino', group: 'Vendors', sections: ['Boards', 'Shields'] }, + seeed: { id: 'seeed', name: 'Seeed Studio', group: 'Vendors' } +} +const lookup = (id: string): PackInfo | undefined => PACKS[id] + +test('tabs: Core and Mine first, then Arduino and SparkFun, SparkFun packs sharing one tab', () => { + const tabs = paletteTabs( + [ + part('seeeduino', { bin: 'seeed' }), + part('mic', { bin: 'sparkfun-sensors' }), + part('radio', { bin: 'sparkfun-rf' }), + part('uno', { bin: 'arduino' }), + part('resistor', { bin: 'core', section: 'Basic' }) + ], + lookup + ) + assert.deepEqual( + tabs.map((t) => t.id), + [CORE_TAB, MINE_TAB, 'arduino', 'sparkfun', 'seeed'] + ) + const sparkfun = tabs.find((t) => t.id === 'sparkfun')! + // a pack without sections becomes a section named after it inside a shared tab + assert.deepEqual( + sparkfun.sections.map((s) => s.title), + ['RF', 'Sensors'] + ) +}) + +test('sections follow the pack.json order, parts their listing position', () => { + const tabs = paletteTabs( + [ + part('ldr', { bin: 'core', section: 'Input', position: 3 }), + part('capacitor', { bin: 'core', section: 'Basic', position: 1 }), + part('resistor', { bin: 'core', section: 'Basic', position: 0 }), + part('tinycore', { bin: 'tinyboards', section: 'tinyBoards', position: 0 }), + part('breadboard', { bin: 'core', section: 'Breadboard View' }) + ], + lookup + ) + const core = tabs.find((t) => t.id === CORE_TAB)! + assert.deepEqual( + core.sections.map((s) => [s.title, s.parts.map((p) => p.type)]), + [ + // tinyBoards sorts before Core, so its declared section comes first + ['tinyBoards', ['tinycore']], + ['Basic', ['resistor', 'capacitor']], + ['Input', ['ldr']], + // not declared by any pack: after the declared ones + ['Breadboard View', ['breadboard']] + ] + ) +}) + +test('a tinyBoards part with no section still sits in the top tinyBoards section', () => { + const core = paletteTabs( + [ + part('resistor', { bin: 'core', section: 'Basic', position: 0 }), + part('tinyglow', { bin: 'tinyboards', position: 2 }), + part('tinycore', { bin: 'tinyboards', section: 'tinyBoards', position: 0 }) + ], + lookup + ).find((t) => t.id === CORE_TAB)! + assert.deepEqual( + core.sections.map((s) => [s.title, s.parts.map((p) => p.type)]), + [ + ['tinyBoards', ['tinycore', 'tinyglow']], + ['Basic', ['resistor']] + ] + ) +}) + +test('parts without a pack go to Mine; variants get no tile but search finds them', () => { + const manifest = [ + part('my-sensor', { label: 'My Sensor' }), + part('cap', { bin: 'core', label: 'Capacitor' }), + part('cap-0805', { bin: 'core', label: 'Capacitor (0805)', variantOf: 'cap' }) + ] + const tabs = paletteTabs(manifest, lookup) + assert.deepEqual( + tabs.find((t) => t.id === MINE_TAB)!.sections[0].parts.map((p) => p.type), + ['my-sensor'] + ) + const coreTypes = tabs.find((t) => t.id === CORE_TAB)!.sections.flatMap((s) => s.parts) + assert.deepEqual( + coreTypes.map((p) => p.type), + ['cap'] + ) + assert.deepEqual( + searchParts(manifest, 'capacitor 0805', lookup).map((p) => p.type), + ['cap-0805'] + ) + assert.deepEqual( + searchParts(manifest, 'cap', lookup).map((p) => p.type), + ['cap', 'cap-0805'] + ) +}) + +test('search-only parts (simulation sources, probes) get no tile but search finds them', () => { + const manifest = [ + part('resistor', { bin: 'core', section: 'Basic' }), + part('sim-probe-v', { bin: 'core', label: 'Voltage Probe', searchOnly: true }) + ] + const core = paletteTabs(manifest, lookup).find((t) => t.id === CORE_TAB)! + assert.deepEqual( + core.sections.flatMap((s) => s.parts).map((p) => p.type), + ['resistor'] + ) + assert.deepEqual( + searchParts(manifest, 'probe', lookup).map((p) => p.type), + ['sim-probe-v'] + ) +}) + +test('tabIdFor: packs in the tinyStudio group share Core, unknown packs get their own tab', () => { + assert.equal(tabIdFor(undefined, undefined), MINE_TAB) + assert.equal(tabIdFor('core', undefined), CORE_TAB) + assert.equal( + tabIdFor('my-pack', { id: 'my-pack', name: 'Mine too', group: 'Vendors' }), + 'my-pack' + ) +}) + +test('bundled packs: Core opens with the tinyBoards, then Fritzing Core order', () => { + const { def } = generateBreadboard(BREADBOARDS[0]) + registerPart(def) + try { + const core = paletteTabs(PART_MANIFEST, getPackInfo).find((t) => t.id === CORE_TAB)! + const titles = core.sections.map((s) => s.title) + assert.deepEqual(titles.slice(0, 5), ['tinyBoards', 'Basic', 'Input', 'Output', 'Power']) + assert.ok(titles.includes('Breadboard View'), 'generated breadboards sit in Breadboard View') + assert.equal(core.sections[0].parts[0].type, 'tinycore') + assert.equal(core.sections[1].parts[0].type, 'resistor') + assert.ok(!titles.includes('Measuring Tools'), 'probes are search-only') + // every bundled part has a place + const placed = core.sections.flatMap((s) => s.parts).filter((p) => p.layer === 'bundled') + assert.equal(placed.length, PART_MANIFEST.filter((p) => p.layer === 'bundled').length) + } finally { + unregisterPart(def.type) + } +}) + +test('a local edit of a shipped part stays in its tab and section', () => { + registerPart({ + type: 'resistor', + label: 'Resistor (edited)', + origin: 'edit', + views: { breadboard: { svg: '', w: 10, h: 10, pins: { a: [0, 0], b: [10, 0] } } } + }) + try { + const meta = PART_MANIFEST.find((p) => p.type === 'resistor')! + assert.equal(meta.layer, 'user') + assert.equal(meta.bin, 'core') + assert.equal(meta.section, 'Basic') + } finally { + unregisterPart('resistor') + } +}) 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__/probes.test.ts b/src/renderer/src/circuit/__tests__/probes.test.ts index 6ecb848..d48abb6 100644 --- a/src/renderer/src/circuit/__tests__/probes.test.ts +++ b/src/renderer/src/circuit/__tests__/probes.test.ts @@ -1,4 +1,4 @@ -/** core/probes — sim probe recognition + diff-probe subtraction + labeling. */ +/** core/probes: sim probe recognition + diff-probe subtraction + labeling. */ import assert from 'node:assert/strict' import { test } from 'node:test' @@ -97,7 +97,7 @@ test('diffProbeVector subtracts real and imaginary parts when the run is complex part('R2', 'resistor', { resistance: '1k' }), part('VD1', 'sim-probe-vdiff') ] - // two disjoint nets, no GND — so neither resolves to node '0' + // two disjoint nets, no GND, so neither resolves to node '0' doc.wires = [wire('VD1:+', 'R1:Pin 1'), wire('VD1:-', 'R2:Pin 1')] const net = buildNets(doc) const gen = generateNetlist(doc, net) 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..f85c7b7 --- /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__/resistorBands.test.ts b/src/renderer/src/circuit/__tests__/resistorBands.test.ts index a028ff6..02af877 100644 --- a/src/renderer/src/circuit/__tests__/resistorBands.test.ts +++ b/src/renderer/src/circuit/__tests__/resistorBands.test.ts @@ -1,4 +1,4 @@ -/** Tests for parts/resistorBands — value parsing, 4-band color code, SVG recolor. */ +/** Tests for parts/resistorBands: value parsing, 4-band color code, SVG recolor. */ import assert from 'node:assert/strict' import { test } from 'node:test' diff --git a/src/renderer/src/circuit/__tests__/routing.test.ts b/src/renderer/src/circuit/__tests__/routing.test.ts index 8d631ad..abcee44 100644 --- a/src/renderer/src/circuit/__tests__/routing.test.ts +++ b/src/renderer/src/circuit/__tests__/routing.test.ts @@ -1,5 +1,5 @@ /** - * circuit/core/routing tests — journey decode/encode incl. the Wokwi "*" + * circuit/core/routing tests: journey decode/encode incl. the Wokwi "*" * semantics (B2 fix), bend extraction, simplification, junction parametrics. * Runner: `npm run test:circuit` (esbuild bundle → node --test). */ @@ -43,7 +43,7 @@ test('decodeJourney: empty journey auto-completes orthogonally', () => { } }) -test('decodeJourney: Wokwi "*" — post-star applies in reverse from target (B2)', () => { +test('decodeJourney: Wokwi "*"; post-star applies in reverse from target (B2)', () => { // Wokwi docs example: ["v10","h5","*","v-15","h10"] // v10,h5 from source; from TARGET walk back: h10 → 10px left of target, // then v-15 → 15px below target; gap auto-completed. 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..ffa0e7e --- /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__/store.test.ts b/src/renderer/src/circuit/__tests__/store.test.ts index 7404328..2a7f3ad 100644 --- a/src/renderer/src/circuit/__tests__/store.test.ts +++ b/src/renderer/src/circuit/__tests__/store.test.ts @@ -1,5 +1,5 @@ /** - * circuit/core store + commands tests — undo/redo, drag merging, cascades, + * circuit/core store + commands tests: undo/redo, drag merging, cascades, * duplicate-wire guard (B5), rename rewrites (B4-adjacent), echo guard. */ import { strict as assert } from 'node:assert' @@ -90,7 +90,7 @@ test('deleteParts cascades wires and repairs junction riders', () => { s.dispatch(addPart({ id: 'C', type: 'x' })) s.dispatch(addWire({ id: 'host', from: 'A:1', to: 'B:1', view: 'bb' })) s.dispatch(addWire({ id: 'rider', from: 'C:1', to: { wire: 'host', t: 0.5 }, view: 'bb' })) - // deleting B removes host (touches B) — rider re-anchors to host.from = A:1 + // deleting B removes host (touches B); rider re-anchors to host.from = A:1 s.dispatch(deleteParts(['B'])) const wires = s.getDoc().wires assert.equal(wires.length, 1) diff --git a/src/renderer/src/circuit/__tests__/svg.test.ts b/src/renderer/src/circuit/__tests__/svg.test.ts index b3afcba..c8d9b5a 100644 --- a/src/renderer/src/circuit/__tests__/svg.test.ts +++ b/src/renderer/src/circuit/__tests__/svg.test.ts @@ -1,4 +1,4 @@ -/** Tests for parts/svg — id namespacing (B6) and helpers. */ +/** Tests for parts/svg: id namespacing (B6) and helpers. */ import assert from 'node:assert/strict' import { test } from 'node:test' @@ -19,7 +19,7 @@ test('namespaceSvgIds prefixes defined ids and their references', () => { test('namespaceSvgIds leaves external references and unknown ids alone', () => { const svg = '' const out = namespaceSvgIds(svg, 'ns') - assert.ok(out.includes('url(#other)')) // not defined here — untouched + assert.ok(out.includes('url(#other)')) // not defined here, untouched assert.ok(out.includes('href="https://x/#frag"')) assert.ok(out.includes('id="ns-mine"')) }) @@ -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('')) }) @@ -75,6 +82,6 @@ test('namespaceSvgIds rewrites #id selectors inside style blocks', () => { const svg = '' const out = namespaceSvgIds(svg, 'pR1') assert.ok(out.includes('#pR1-body{fill:blue}')) - assert.ok(out.includes('#other{}')) // not defined as an id here — untouched + assert.ok(out.includes('#other{}')) // not defined as an id here, untouched assert.ok(out.includes('id="pR1-body"')) }) diff --git a/src/renderer/src/circuit/__tests__/svgArt.test.ts b/src/renderer/src/circuit/__tests__/svgArt.test.ts new file mode 100644 index 0000000..2c6514b --- /dev/null +++ b/src/renderer/src/circuit/__tests__/svgArt.test.ts @@ -0,0 +1,358 @@ +/** + * Tests for parts/svgArt + parts/folderPart: reading pins out of hand-edited + * SVG files, surviving what Illustrator does to them, keeping every part's + * gradients and classes to itself, and turning a part folder into a PartDef. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + artPrefix, + decodeIllustratorId, + movePinInArt, + namespaceSvg, + pinNameFromId, + prepareArt, + scanPins +} from '../parts/svgArt' +import { + buildFolderPart, + folderPartMeta, + loadPack, + parsePartJson, + type PartJson, + type ReadText +} from '../parts/folderPart' + +const svg = (body: string, root = 'viewBox="0 0 100 100"'): string => + `${body}` + +const pinsOf = (s: string, w?: number, h?: number): Record => + Object.fromEntries(scanPins(s, w, h).pins.map((p) => [p.name, p.at])) + +// ── pin discovery ──────────────────────────────────────────────────────────── + +test('a pin is the centre of the shape named pin-, scaled into the part box', () => { + // 72 units/in art in a 1in × 0.5in box → 96/72 px per unit + const s = svg( + '' + + '' + + '', + 'width="1in" height="0.5in" viewBox="0 0 72 36"' + ) + const pins = pinsOf(s) + assert.deepEqual(pins.VCC, [9.6, 9.6]) + assert.deepEqual(pins.GND, [82.67, 42.67]) + // the box from part.json beats whatever the root claims + assert.deepEqual(pinsOf(s, 144, 72).VCC, [14.4, 14.4]) +}) + +test('ancestor and own transforms are applied', () => { + const pins = pinsOf( + svg( + '' + + '' + + '' + + '' + ) + ) + assert.deepEqual(pins.A, [14, 4]) + assert.deepEqual(pins.R, [0, 10]) + assert.deepEqual(pins.L, [55, 50]) +}) + +test('paths (relative commands, arcs) and named groups resolve to their centre', () => { + const pins = pinsOf( + svg( + '' + + '' + + '' + ) + ) + assert.deepEqual(pins.P, [10, 10]) + assert.deepEqual(pins.Q, [25, 25]) + assert.deepEqual(pins.G, [45, 2]) +}) + +test('art is fitted into a box of a different aspect the way the canvas draws it', () => { + // 10×10 art in a 20×10 box: uniform scale 1, centred with a 5px margin + assert.deepEqual( + pinsOf(svg('', 'viewBox="0 0 10 10"'), 20, 10).C, + [10, 5] + ) +}) + +test('ids Illustrator mangled still name the right pin; duplicates are reported', () => { + assert.equal(decodeIllustratorId('pin-3V3_x2E_2'), 'pin-3V3.2') + assert.equal(pinNameFromId('pin-3V3_x2E_2'), '3V3.2') + assert.equal(pinNameFromId('pin-GND_1_'), 'GND') + assert.equal(pinNameFromId('pin_x3A_TX'), 'TX') + assert.equal(pinNameFromId('pin-GND_00000094586231542387468840000009823571904523165_'), 'GND') + assert.equal(pinNameFromId('connector0pin'), null) + assert.equal(pinNameFromId('pin_header_outline'), null) + + const scan = scanPins( + svg( + '' + ) + ) + assert.deepEqual(scan.duplicates, ['GND']) + assert.deepEqual(scan.empty, ['X']) + assert.deepEqual(scan.pins.find((p) => p.name === 'GND')!.at, [1, 1]) +}) + +test('shapes inside , etc. are never pins', () => { + const pins = pinsOf( + svg( + '' + ) + ) + assert.deepEqual(Object.keys(pins), []) +}) + +test('a legacy Illustrator file (prolog, DOCTYPE subset, comments, CDATA) parses', () => { + const file = ` + + +]> + + + + + +` + // tinyBoards: 1.9in box, 136.8-unit viewBox + assert.deepEqual(pinsOf(file, 182.4, 182.4).SCK, [52.8, 170.4]) +}) + +// ── moving pins in the art ─────────────────────────────────────────────────── + +test('movePinInArt shifts a circle pad by cx/cy and the pin follows, in box px', () => { + // 100-unit viewBox in a 200 px box: 1 px = 0.5 units + const s = svg( + '' + ) + const moved = movePinInArt(s, 'GND', 9.6, -4.8, 200, 200) + assert.ok(moved) + assert.deepEqual(moved!.at, [29.6, 35.2]) + assert.match(moved!.svg, //) + assert.deepEqual(pinsOf(moved!.svg, 200, 200)['A0'], [84, 84], 'other pins untouched') +}) + +test('movePinInArt moves rects by x/y and paths by a leading translate', () => { + const s = svg( + '' + + '' + ) + const a = movePinInArt(s, 'A', 1, 2, 100, 100)! + assert.match(a.svg, / { + const s = svg( + '' + ) + assert.deepEqual(pinsOf(s)['X'], [20, 20]) + const m = movePinInArt(s, 'X', 4, 6, 100, 100)! + // 4 root units under scale(2) is 2 local units + assert.match(m.svg, /cx="7" cy="8"/) + assert.deepEqual(m.at, [24, 26]) +}) + +test('movePinInArt returns null for an unknown pin and leaves the file alone', () => { + const s = svg('') + assert.equal(movePinInArt(s, 'NOPE', 1, 1, 100, 100), null) +}) + +// ── preparing + namespacing ────────────────────────────────────────────────── + +test('prepareArt drops the prolog and root size but keeps ') : 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/packs.ts b/src/renderer/src/circuit/parts/packs.ts index e3a1479..1a2abb9 100644 --- a/src/renderer/src/circuit/parts/packs.ts +++ b/src/renderer/src/circuit/parts/packs.ts @@ -1,27 +1,48 @@ /** - * circuit/parts/packs — GitHub-distributed parts packs (M2 leftover, spec - * §5.3/§5.4, "Boards-Manager pattern"). + * circuit/parts/packs: installing parts packs from a pack index (the tinyparts + * repo on GitHub by default, or any URL serving the same files). * - * This targets the app's ACTUAL current parts pipeline — the legacy - * `lib/partsLibrary` PartDef (v1) persisted via `lib/userParts.ts` (B7) — - * not the aspirational v2 `registry.ts`/pack.json-with-zips design in the - * spec, which needs the still-unbuilt M2 parts registry migration first. A - * pack here is just a manifest of hosted PartDef v1 JSON files (exactly - * what `scripts/fritzing-import.mjs` / the in-app `.fzpz` importer already - * emit); installing one calls the same `saveUserPart` every other part - * source uses. See docs/tinyparts-pack-setup.md for how to host one. + * An installed pack is downloaded into the parts cache (parts/partsCache.ts) + * and served as the REMOTE layer of the registry, not copied into the user's + * own parts, so parts/tinypartsSync.ts can keep it current and a local edit + * still shadows it. * - * Formats: - * index.json { schema: 1, packs: [{ id, name, version, description?, url }] } - * pack.json { schema: 1, id, name, version, parts: [{ type, file }] } - * parts/*.json a plain PartDef (registerPart-compatible) + * Formats (docs/parts-and-art.md has the full picture): + * index.json { schema: 1, packs: [{ id, name, version, url, bundled?, … }] } + * pack.json { schema: 1, id, name, version, parts: [{ type, dir } | { type, file }] } + * parts//part.json + .svg files (folder part, editable) + * parts/.json (single-file PartDef, SVG embedded) * - * `file`/`url` may be relative — resolved against the manifest/index's own - * URL, so a pack can ship as a self-contained folder of relative paths. + * `file`/`dir`/`url` may be relative, resolved against the manifest/index's + * own URL, so a pack can ship as a self-contained folder of relative paths. */ -import type { PartDef } from '../../lib/partsLibrary' -import { saveUserPart } from '../../lib/userParts' +import { setLayerParts, setPackInfo } from '../../lib/partsLibrary' +import { SNAPSHOT } from './bundled' +import { + formatJson, + isPartDef, + joinPath, + loadPack, + parsePartJson, + referencedFiles, + type PackJson, + type PackPartRef, + type ReadText +} from './folderPart' +import { + deleteCachedPack, + getCachedPack, + gitBlobSha, + listCachedPacks, + putCachedPack, + readCachedFile, + type PackOrigin +} from './partsCache' +import { STORAGE_KEYS } from '../../lib/storageKeys' + +export type { PackPartRef } +export type PackManifest = PackJson export interface PackIndexEntry { id: string @@ -29,6 +50,11 @@ export interface PackIndexEntry { version: string description?: string url: string + group?: string + icon?: string + /** ships inside the app; nothing to install */ + bundled?: boolean + count?: number } export interface PackIndex { @@ -36,28 +62,11 @@ export interface PackIndex { packs: PackIndexEntry[] } -export interface PackPartRef { - type: string - file: string -} - -export interface PackManifest { - schema: number - id: string - name: string - version: string - description?: string - parts: PackPartRef[] -} - -/** Seeded per the M2 decision log ("new tinyparts repo under - * Mister-Industries"); harmless to fetch — a 404 just surfaces as an error - * row in the UI until the repo/index is published. */ export const DEFAULT_INDEX_URL = 'https://raw.githubusercontent.com/Mister-Industries/tinyparts/main/index.json' -const LS_INDEX_URLS = 'tinystudio.packs.indexUrls' -const LS_INSTALLED = 'tinystudio.packs.installed' +const LS_INDEX_URLS = STORAGE_KEYS.packIndexUrls +const LS_INSTALLED = STORAGE_KEYS.installedPacks function resolveUrl(file: string, base: string): string { try { @@ -67,7 +76,7 @@ function resolveUrl(file: string, base: string): string { } } -async function fetchJson(url: string): Promise { +async function fetchResponse(url: string): Promise { let res: Response try { res = await fetch(url, { cache: 'no-store' }) @@ -75,6 +84,11 @@ async function fetchJson(url: string): Promise { throw new Error(`network error fetching ${url}: ${e instanceof Error ? e.message : String(e)}`) } if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`) + return res +} + +async function fetchJson(url: string): Promise { + const res = await fetchResponse(url) try { return await res.json() } catch { @@ -82,12 +96,18 @@ async function fetchJson(url: string): Promise { } } +/** A file's text plus its git blob sha (so the update check can compare it to GitHub). */ +export async function fetchText(url: string): Promise<{ text: string; sha: string }> { + const res = await fetchResponse(url) + const bytes = new Uint8Array(await res.arrayBuffer()) + return { text: new TextDecoder().decode(bytes), sha: await gitBlobSha(bytes) } +} + export async function fetchIndex(url: string): Promise { const json = await fetchJson(url) if (!json || typeof json !== 'object' || !Array.isArray((json as PackIndex).packs)) throw new Error(`${url} is not a valid pack index (expected { packs: [...] })`) const idx = json as PackIndex - // resolve each pack's manifest url relative to the index itself return { ...idx, packs: idx.packs.map((p) => ({ ...p, url: resolveUrl(p.url, url) })) } } @@ -103,15 +123,49 @@ export async function fetchManifest(url: string): Promise { return json as PackManifest } -function isPartDef(v: unknown): v is PartDef { - return ( - !!v && - typeof v === 'object' && - typeof (v as PartDef).type === 'string' && - typeof (v as PartDef).label === 'string' && - !!(v as PartDef).views && - typeof (v as PartDef).views === 'object' +/** `raw.githubusercontent.com////packs//pack.json` → GitHub coordinates. */ +export function githubOrigin(manifestUrl: string, packId: string): PackOrigin | null { + const m = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/.exec( + manifestUrl ) + if (!m || m[4] !== `packs/${packId}/pack.json`) return null + return { kind: 'github', repo: `${m[1]}/${m[2]}`, ref: m[3], commit: '' } +} + +export const isBundledPack = (id: string): boolean => SNAPSHOT.packs.some((p) => p.id === id) + +// ── the remote layer ───────────────────────────────────────────────────────── + +/** Serve a cached pack as the registry's remote layer (or withdraw it if gone). */ +export async function registerCachedPack(id: string): Promise { + const rec = await getCachedPack(id) + if (!rec) { + setLayerParts('remote', id, []) + return [] + } + const prefix = `packs/${id}/` + const read: ReadText = async (path) => { + const rel = path.startsWith(prefix) ? path.slice(prefix.length) : path + const text = await readCachedFile(id, rel) + if (text === undefined) throw new Error(`${path} is missing from the parts cache`) + return text + } + const { pack, providers, errors } = await loadPack(`packs/${id}`, read, 'remote') + setPackInfo(pack) + setLayerParts('remote', id, providers) + return errors +} + +/** Register every cached pack; call once at startup, before user parts. */ +export async function initRemoteLayer(): Promise { + for (const rec of await listCachedPacks()) { + try { + const errors = await registerCachedPack(rec.id) + for (const e of errors) console.warn(`[parts] cached pack ${rec.id}: ${e}`) + } catch (e) { + console.warn(`[parts] couldn't load cached pack ${rec.id}:`, e) + } + } } export interface InstallResult { @@ -119,9 +173,11 @@ export interface InstallResult { failed: { type: string; error: string }[] } -/** Install every part in a manifest via the existing saveUserPart path - * (registers + persists to IndexedDB, same as the Parts Editor / .fzpz - * import). Continues past individual part failures. */ +/** + * Download every part in a manifest into the parts cache and make the pack + * live. Continues past individual part failures; a pack with no successful + * part isn't stored or marked installed. + */ export async function installPack( manifest: PackManifest, manifestUrl: string, @@ -129,13 +185,34 @@ export async function installPack( ): Promise { const installed: string[] = [] const failed: { type: string; error: string }[] = [] + const files: Record = {} let done = 0 + for (const ref of manifest.parts) { try { - const url = resolveUrl(ref.file, manifestUrl) - const json = await fetchJson(url) - if (!isPartDef(json)) throw new Error(`${url} is not a valid part definition`) - await saveUserPart(json) + const got: Record = {} + if (ref.dir) { + const partPath = joinPath(ref.dir, 'part.json') + const pj = await fetchText(resolveUrl(partPath, manifestUrl)) + const json = parsePartJson(pj.text, resolveUrl(partPath, manifestUrl)) + got[partPath] = pj + for (const f of referencedFiles(json)) { + const rel = joinPath(ref.dir, f) + got[rel] = await fetchText(resolveUrl(rel, manifestUrl)) + } + } else if (ref.file) { + const url = resolveUrl(ref.file, manifestUrl) + const f = await fetchText(url) + let json: unknown + try { + json = JSON.parse(f.text) + } catch { + throw new Error(`${url} did not return valid JSON`) + } + if (!isPartDef(json)) throw new Error(`${url} is not a valid part definition`) + got[joinPath(ref.file)] = f + } else throw new Error('pack entry has neither "dir" nor "file"') + Object.assign(files, got) installed.push(ref.type) } catch (e) { failed.push({ type: ref.type, error: e instanceof Error ? e.message : String(e) }) @@ -144,12 +221,56 @@ export async function installPack( onProgress?.(done, manifest.parts.length) } } - if (installed.length) markInstalled(manifest.id, manifest.version) + if (!installed.length) return { installed, failed } + + // the manifest byte-for-byte when everything landed (so its sha matches + // GitHub's); otherwise just the parts that made it, which the next update + // check will see as changed and retry + if (failed.length) { + const kept = { ...manifest, parts: manifest.parts.filter((r) => installed.includes(r.type)) } + files['pack.json'] = { text: formatJson(kept), sha: '' } + } else { + files['pack.json'] = await fetchText(manifestUrl).catch(() => ({ + text: formatJson(manifest), + sha: '' + })) + } + + await putCachedPack( + { + id: manifest.id, + name: manifest.name, + version: manifest.version, + origin: githubOrigin(manifestUrl, manifest.id) ?? { kind: 'url', url: manifestUrl }, + files: Object.fromEntries(Object.entries(files).map(([rel, f]) => [rel, f.sha])), + updatedAt: Date.now() + }, + files + ) + await registerCachedPack(manifest.id) + markInstalled(manifest.id, manifest.version) return { installed, failed } } +/** Remove a downloaded pack; its parts disappear unless another layer has them. */ +export async function uninstallPack(id: string): Promise { + await deleteCachedPack(id) + setLayerParts('remote', id, []) + const map = getInstalledPacks() + delete map[id] + writeLs(LS_INSTALLED, map) +} + // ── settings (index URL list + installed-version tracking) ───────────────── +function writeLs(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* quota / privacy mode: session-only */ + } +} + export function getIndexUrls(): string[] { try { const raw = localStorage.getItem(LS_INDEX_URLS) @@ -164,11 +285,7 @@ export function getIndexUrls(): string[] { } export function setIndexUrls(urls: string[]): void { - try { - localStorage.setItem(LS_INDEX_URLS, JSON.stringify(urls)) - } catch { - /* quota / privacy mode — session-only */ - } + writeLs(LS_INDEX_URLS, urls) } export function getInstalledPacks(): Record { @@ -181,11 +298,5 @@ export function getInstalledPacks(): Record { } function markInstalled(id: string, version: string): void { - const map = getInstalledPacks() - map[id] = version - try { - localStorage.setItem(LS_INSTALLED, JSON.stringify(map)) - } catch { - /* quota / privacy mode */ - } + writeLs(LS_INSTALLED, { ...getInstalledPacks(), [id]: version }) } diff --git a/src/renderer/src/circuit/parts/partsBoot.ts b/src/renderer/src/circuit/parts/partsBoot.ts new file mode 100644 index 0000000..36a79aa --- /dev/null +++ b/src/renderer/src/circuit/parts/partsBoot.ts @@ -0,0 +1,32 @@ +/** + * circuit/parts/partsBoot: bring every parts layer up, in order, once per run. + * + * bundled registered as soon as lib/partsLibrary loads (always there) + * remote packs cached from GitHub on an earlier run (parts/packs.ts) + * user this computer's saved parts and local edits (lib/userParts.ts) + * dev a local tinyparts checkout, dev servers only (parts/devFolder.ts) + * + * …then, in the background, ask GitHub whether tinyparts changed since the + * last check (parts/tinypartsSync.ts). The editor is usable before that answer. + */ + +import { adoptLegacyCopies, initUserParts } from '../../lib/userParts' +import { startDevParts } from './devFolder' +import { initRemoteLayer } from './packs' +import { syncTinyparts } from './tinypartsSync' + +let boot: Promise | null = null + +export function initPartsLibrary(): Promise { + if (!boot) { + boot = (async () => { + await initRemoteLayer().catch((e) => + console.warn('[parts] reading the parts cache failed', e) + ) + await initUserParts().catch((e) => console.warn('[parts] reading saved parts failed', e)) + await startDevParts().catch((e) => console.warn('[parts] live tinyparts failed', e)) + void syncTinyparts().then(() => adoptLegacyCopies()) + })() + } + return boot +} diff --git a/src/renderer/src/circuit/parts/partsCache.ts b/src/renderer/src/circuit/parts/partsCache.ts new file mode 100644 index 0000000..b71b500 --- /dev/null +++ b/src/renderer/src/circuit/parts/partsCache.ts @@ -0,0 +1,178 @@ +/** + * circuit/parts/partsCache: packs downloaded from tinyparts, kept on this + * computer so they load offline and only changed files are ever re-fetched. + * + * Stored in IndexedDB (`tinystudio-parts-cache`), which in the desktop app sits + * inside Electron's userData folder; the web build uses the browser's. Falls + * back to memory where IndexedDB doesn't exist (tests, locked-down WebViews). + * Nothing here is user-authored; clearing it just means a re-download. + */ + +export type PackOrigin = + | { kind: 'github'; repo: string; ref: string; commit: string } + | { kind: 'url'; url: string } + +export interface CachedPack { + id: string + name: string + version: string + origin: PackOrigin + /** pack-relative path → git blob sha of the stored text */ + files: Record + updatedAt: number +} + +interface CachedFile { + /** `${packId}/${packRelativePath}` */ + key: string + text: string + sha: string +} + +const DB_NAME = 'tinystudio-parts-cache' +const PACKS = 'packs' +const FILES = 'files' + +// ── memory fallback ────────────────────────────────────────────────────────── + +const memPacks = new Map() +const memFiles = new Map() + +function idbAvailable(): boolean { + try { + return typeof indexedDB !== 'undefined' + } catch { + return false + } +} + +let dbPromise: Promise | null = null + +function openDb(): Promise { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(PACKS)) db.createObjectStore(PACKS, { keyPath: 'id' }) + if (!db.objectStoreNames.contains(FILES)) db.createObjectStore(FILES, { keyPath: 'key' }) + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error ?? new Error('indexedDB open failed')) + }) + dbPromise.catch(() => { + dbPromise = null + }) + } + return dbPromise +} + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error ?? new Error('indexedDB request failed')) + }) +} + +async function withStore( + store: string, + mode: IDBTransactionMode, + fn: (s: IDBObjectStore) => IDBRequest +): Promise { + const db = await openDb() + return request(fn(db.transaction(store, mode).objectStore(store))) +} + +// ── public API ─────────────────────────────────────────────────────────────── + +export async function listCachedPacks(): Promise { + if (!idbAvailable()) return [...memPacks.values()] + try { + return (await withStore(PACKS, 'readonly', (s) => s.getAll())) as CachedPack[] + } catch { + return [...memPacks.values()] + } +} + +export async function getCachedPack(id: string): Promise { + return (await listCachedPacks()).find((p) => p.id === id) +} + +export async function readCachedFile(packId: string, rel: string): Promise { + const key = `${packId}/${rel}` + if (!idbAvailable()) return memFiles.get(key)?.text + try { + const hit = (await withStore(FILES, 'readonly', (s) => s.get(key))) as CachedFile | undefined + return hit?.text + } catch { + return memFiles.get(key)?.text + } +} + +/** + * Store a pack atomically: its record plus exactly `files` (any file the pack + * used to have that isn't in `files` is removed). + */ +export async function putCachedPack( + record: CachedPack, + files: Record +): Promise { + const prev = await getCachedPack(record.id) + const stale = Object.keys(prev?.files ?? {}).filter((rel) => !(rel in files)) + if (!idbAvailable()) { + for (const rel of stale) memFiles.delete(`${record.id}/${rel}`) + for (const [rel, f] of Object.entries(files)) + memFiles.set(`${record.id}/${rel}`, { key: `${record.id}/${rel}`, ...f }) + memPacks.set(record.id, record) + return + } + const db = await openDb() + await new Promise((resolve, reject) => { + const tx = db.transaction([PACKS, FILES], 'readwrite') + const fileStore = tx.objectStore(FILES) + for (const rel of stale) fileStore.delete(`${record.id}/${rel}`) + for (const [rel, f] of Object.entries(files)) + fileStore.put({ key: `${record.id}/${rel}`, text: f.text, sha: f.sha } satisfies CachedFile) + tx.objectStore(PACKS).put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('cache write failed')) + tx.onabort = () => reject(tx.error ?? new Error('cache write aborted')) + }) +} + +/** Update a pack's record without touching its files (e.g. a newer commit, same content). */ +export async function touchCachedPack(record: CachedPack): Promise { + if (!idbAvailable()) { + memPacks.set(record.id, record) + return + } + await withStore(PACKS, 'readwrite', (s) => s.put(record)) +} + +export async function deleteCachedPack(id: string): Promise { + const prev = await getCachedPack(id) + if (!prev) return + if (!idbAvailable()) { + for (const rel of Object.keys(prev.files)) memFiles.delete(`${id}/${rel}`) + memPacks.delete(id) + return + } + const db = await openDb() + await new Promise((resolve, reject) => { + const tx = db.transaction([PACKS, FILES], 'readwrite') + for (const rel of Object.keys(prev.files)) tx.objectStore(FILES).delete(`${id}/${rel}`) + tx.objectStore(PACKS).delete(id) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('cache delete failed')) + }) +} + +/** Git's blob id for some bytes; lets a download be compared with GitHub's tree. */ +export async function gitBlobSha(bytes: Uint8Array): Promise { + const header = new TextEncoder().encode(`blob ${bytes.length}\0`) + const buf = new Uint8Array(header.length + bytes.length) + buf.set(header) + buf.set(bytes, header.length) + const digest = await crypto.subtle.digest('SHA-1', buf) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/src/renderer/src/circuit/parts/resistorBands.ts b/src/renderer/src/circuit/parts/resistorBands.ts index 2cc5001..b3e787d 100644 --- a/src/renderer/src/circuit/parts/resistorBands.ts +++ b/src/renderer/src/circuit/parts/resistorBands.ts @@ -1,5 +1,5 @@ /** - * circuit/parts/resistorBands — resistor color-code decoration. + * circuit/parts/resistorBands: resistor color-code decoration. * * The Fritzing resistor art carries clean band ids (`band_1_st`, `band_2_nd`, * `band_rd_multiplier`; `gold_band` is tolerance and stays gold). When a part @@ -64,7 +64,7 @@ export function bandColorsFor(ohms: number): BandColors | null { exp += 1 } if (sig < 10) { - // values < 10 Ω land here (e.g. 4.7 → sig 47, exp −1 — already handled); + // values < 10 Ω land here (e.g. 4.7 → sig 47, exp −1, already handled); // a degenerate rounding can still under-run, renormalize sig *= 10 exp -= 1 diff --git a/src/renderer/src/circuit/parts/simParts.ts b/src/renderer/src/circuit/parts/simParts.ts index 2051d4b..e5307e0 100644 --- a/src/renderer/src/circuit/parts/simParts.ts +++ b/src/renderer/src/circuit/parts/simParts.ts @@ -1,5 +1,5 @@ /** - * circuit/parts/simParts — builtin simulation sources (M4, spec §10.3). + * circuit/parts/simParts: builtin simulation sources (M4, spec §10.3). * * Generated two-pin source symbols (DC voltage, sine, DC current) in the * classic circle style. Both views get the same art so a source is placeable @@ -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, @@ -85,6 +86,8 @@ export function generateSimSource(spec: SimSourceSpec): PartDef { label: spec.label, family: 'Source', builtin: true, + bin: 'core', + searchOnly: true, views: { breadboard: view, schematic: view } } cache.set(spec.type, def) diff --git a/src/renderer/src/circuit/parts/simProbes.ts b/src/renderer/src/circuit/parts/simProbes.ts index e8fbe2a..cdc8673 100644 --- a/src/renderer/src/circuit/parts/simProbes.ts +++ b/src/renderer/src/circuit/parts/simProbes.ts @@ -1,26 +1,35 @@ /** - * circuit/parts/simProbes — builtin sim probes (M4 leftover, spec §10.3). + * circuit/parts/simProbes: builtin sim probes (M4 leftover, spec §10.3). * * Three placeable 1/2-pin builtins, CircuitLab-flag style, same art both * views (seatable on a breadboard like any part): * - sim-probe-v voltage probe (1 pin: `+`) * - sim-probe-vdiff diff. voltage (2 pins: `+`, `-`) - * - sim-probe-i current probe (2 pins: `in`, `out` — wired in + * - sim-probe-i current probe (2 pins: `in`, `out`; wired in * series with the branch to measure) * * Voltage/diff probes need no SPICE element (ngspice already reports every - * node's voltage under `.op`/`.tran`/`.ac`) — core/netlist.ts marks them + * node's voltage under `.op`/`.tran`/`.ac`); core/netlist.ts marks them * `transparent`. The current probe DOES need a real element: it emits a 0V * series voltage source so ngspice reports `i(v)` through it (an ideal * ammeter). Diff-probe subtraction and probe labeling live in core/probes.ts. */ 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] } } } @@ -91,6 +101,8 @@ export function generateSimProbe(spec: ProbeSpec): PartDef { label: spec.label, family: 'Probe', builtin: true, + bin: 'core', + searchOnly: true, views: { breadboard: view, schematic: view } } cache.set(spec.type, def) diff --git a/src/renderer/src/circuit/parts/style.ts b/src/renderer/src/circuit/parts/style.ts new file mode 100644 index 0000000..d3a6488 --- /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. One source is what keeps symbols from looking like + * they came from four different programs, which is what module-by-module + * choices of stroke, font and grid produce. + * + * 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/svg.ts b/src/renderer/src/circuit/parts/svg.ts index e6d2daa..a43beb1 100644 --- a/src/renderer/src/circuit/parts/svg.ts +++ b/src/renderer/src/circuit/parts/svg.ts @@ -1,12 +1,11 @@ /** - * circuit/parts/svg — SVG utilities for part artwork. + * circuit/parts/svg: SVG utilities for part artwork. * - * M1 ships the pieces the image exporter needs (B6 fix): id-namespacing so - * two parts whose SVGs both define e.g. `id="g"` don't corrupt each other in - * a composed export, plus small size/escape helpers. The full sanitizer and - * the procedural breadboard generator land in M2 (spec §5). + * Id-namespacing so two parts whose SVGs both define e.g. `id="g"` don't + * corrupt each other in a composed export, plus small size/escape helpers. + * Sanitizing markup before it's inserted into the page is lib/sanitizeSvg. * - * Pure string transforms — no DOM — so they run in tests, workers, exports. + * Pure string transforms (no DOM) so they run in tests, workers, exports. */ /** @@ -56,14 +55,14 @@ export function stripSvgSize(svg: string): string { /** * Prepare a part's SVG for embedding as a NESTED element in a composed - * scene: strip the XML prolog / doctype (illegal mid-document — they killed + * scene: strip the XML prolog / doctype (illegal mid-document; they killed * the whole exported file's parse), and remove any root-level x/y/width/height * attributes (Fritzing exports carry x="0px" y="0px", which collided with the * composer's placement attributes → duplicate-attribute XML errors). The - * root's viewBox is kept — it's what makes the injected width/height scale. + * root's viewBox is kept; it's what makes the injected width/height scale. */ export function prepareSvgForEmbed(svg: string): string { - let s = svg + const s = svg .replace(/<\?xml[\s\S]*?\?>/g, '') .replace(/]*>/gi, '') .trim() diff --git a/src/renderer/src/circuit/parts/svgArt.ts b/src/renderer/src/circuit/parts/svgArt.ts new file mode 100644 index 0000000..54141aa --- /dev/null +++ b/src/renderer/src/circuit/parts/svgArt.ts @@ -0,0 +1,636 @@ +/** + * circuit/parts/svgArt: reading and preparing part artwork that lives as real, + * hand-editable .svg files (tinyparts folder parts; see docs/parts-and-art.md). + * + * Three jobs, all DOM-free so they behave identically in the app, under + * `node --test`, and in scripts/parts-tool.mjs: + * + * 1. PIN DISCOVERY. A pin is any element whose id is `pin-`; name the + * object "pin-GND" in Illustrator's Layers panel and it becomes pin GND. + * Its position is the centre of that shape (or group), through every + * ancestor transform, mapped from viewBox units into the part's pixel box. + * Move the pad in Illustrator and the pin moves with it. + * 2. ILLUSTRATOR TOLERANCE. Illustrator escapes characters in exported ids + * (`pin-3V3.2` can come back as `pin-3V3_x2E_2`), suffixes duplicate names + * (`pin-GND_1_`), and rewrites the root width/height. Ids are decoded, and + * the part box always comes from part.json when it says so. + * 3. NAMESPACING. Every part is inlined into ONE page (canvas + palette), so + * two SVGs that both define `SVGID_1_` or a `.st0` class paint each other. + * Referenced ids and
@@ -334,20 +796,23 @@ export function CircuitViewV2({ /> )} -
+
{/* left toolbar: edit toggle + undo/redo */} -
+
{editable && ( <> @@ -355,7 +820,7 @@ export function CircuitViewV2({ className={`${tool} w-8 justify-center px-0 disabled:opacity-40`} disabled={!store.canUndo()} onClick={() => store.undo()} - title={store.undoLabel() ? `Undo ${store.undoLabel()} (Ctrl+Z)` : 'Undo (Ctrl+Z)'} + title={`${store.undoLabel() ? `Undo ${store.undoLabel()}` : 'Undo'} (${keysOf('circuit.undo')})`} > @@ -363,32 +828,42 @@ export function CircuitViewV2({ className={`${tool} w-8 justify-center px-0 disabled:opacity-40`} disabled={!store.canRedo()} onClick={() => store.redo()} - title="Redo (Ctrl+Y)" + title={`Redo (${keysOf('circuit.redo')})`} > )} +
{/* view toggle: Breadboard | Schematic */}
{( [ - ['bb', 'Breadboard'], - ['sch', 'Schematic'] - ] as [ViewId, string][] - ).map(([v, label]) => ( + ['bb', 'Breadboard', CircuitBoard], + ['sch', 'Schematic', Cpu] + ] as [ViewId, string, typeof Cpu][] + ).map(([v, label, Icon]) => ( ))}
@@ -397,11 +872,20 @@ export function CircuitViewV2({ {trayParts.length > 0 && (
unplaced here: + {trayParts.length > 1 && ( + + )} {trayParts.map((part) => (
)} - {/* right toolbar: export / grid / code */} -
- + {/* 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 addPartAt(type, at)} onDropNetLabel={(kind, name, at) => addNetLabel(kind, name, at)} onImportFiles={(files, at) => 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' */} -
+ {/* 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 && ( { + await resetUserPart(editorPart.type) + setEditorPart(undefined) + toast.success(`${editorPart.label} is back to the shipped version`) + } + : undefined + } + folderPacks={devPacks} onClose={() => setEditorPart(undefined)} - onSave={(def: PartDef) => { - void saveUserPart(def) + onSave={async (def: PartDef) => { + await saveUserPart(def) bumpDefs() setEditorPart(undefined) + toast.success(`Saved ${def.label} on this computer`, { + description: isLocalEdit(def.type) + ? 'Only this computer sees this change. Reset it any time from the Parts editor or Parts Packs.' + : 'It’s in your components rail.' + }) }} + onSaveToFolder={ + devFolderActive() + ? async (def, info, pack) => { + try { + const dir = await savePartToFolder({ pack, def, ...info }) + bumpDefs() + setEditorPart(undefined) + toast.success(`Saved ${def.label} to tinyparts`, { + description: `${dir}: commit and push tinyparts to share it`, + // revealing a folder needs the desktop app + action: window.api?.fs + ? { label: 'Show', onClick: () => void window.api.fs.showInFolder(dir) } + : undefined + }) + } catch (e) { + toast.error('Couldn’t save to tinyparts', { + description: e instanceof Error ? e.message : String(e) + }) + } + } + : undefined + } /> )} @@ -612,6 +1238,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..abc030d 100644 --- a/src/renderer/src/circuit/views/canvas/Canvas.tsx +++ b/src/renderer/src/circuit/views/canvas/Canvas.tsx @@ -1,18 +1,19 @@ /** - * circuit/views/canvas/Canvas — the interactive breadboard scene (M1). + * circuit/views/canvas/Canvas: the interactive breadboard scene (M1). * * Owns: camera (zoom/pan/fit), pointer routing, selection + marquee, wire * drawing/editing state machines, part drag with frozen-bend reroutes, and * rendering (parts, glossy-tube wires, junction dots, handles, preview). * The gesture set is a 1:1 port of DiagramEditor's editor behaviors, re-said - * as Commands against the CircuitStore — same editor, now with undo. + * as Commands against the CircuitStore: same editor, now with undo. * * Document mutations ONLY go through store.dispatch(command). Ephemeral state * (camera, hover, armed wire, marquee, edit buffer) never touches the doc. */ -import { CircuitBoard, Zap } from 'lucide-react' +import { Crosshair, X, Zap } from 'lucide-react' import React from 'react' +import { matches } from '../../../lib/shortcuts' import { buildClipboard, materializePaste, parseClipboard } from '../../core/clipboard' import * as cmd from '../../core/commands' import { @@ -30,6 +31,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, @@ -62,26 +65,23 @@ import { type RatsnestSegment, type Seat } from '../partsAdapter' +import { emptySel, type Selection } from './selection' +import { sanitizeSvg } from '../../../lib/sanitizeSvg' // 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 { - parts: Set - wires: Set - /** selected net-label ids (schematic) */ - labels?: Set -} -export const emptySel = (): Selection => ({ - parts: new Set(), - wires: new Set(), - labels: new Set() -}) +export type { Selection } from './selection' export interface Cam { scale: number @@ -89,6 +89,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 @@ -99,7 +117,7 @@ export interface CanvasHandle { type HandleKind = 'vertex' | 'edge' | 'endpoint' interface Armed { - from: string // "part:pin" — drawing always starts on a pin + from: string // "part:pin"; drawing always starts on a pin points: Pt[] straight: boolean } @@ -146,12 +164,18 @@ export function Canvas({ defsTick, cam, setCam, + fitOnMount = true, handleRef, onDropPart, onDropNetLabel, onImportFiles, annotations, simVoltageForNet, + pickNets = false, + onPickNet, + probes, + onMoveProbe, + onDeleteProbe, onRequestEdit }: { store: CircuitStore @@ -171,6 +195,8 @@ export function Canvas({ defsTick: number cam: Cam setCam: React.Dispatch> + /** fit the scene on first render; false when a remembered camera is restored */ + fitOnMount?: boolean handleRef: React.Ref onDropPart: (type: string, at: Pt) => void onDropNetLabel: (kind: NetLabelKind, name: string, at: Pt) => void @@ -180,11 +206,29 @@ 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 }): React.JSX.Element { const scale = cam.scale + // Pin targets stay at least 12 screen px wide however far the view zooms out. + const pinHit = Math.max(16, 12 / scale) // schematic wires bend on the fine grid; parts still snap pins to major const wireGrid = view === 'sch' ? GRID_SCH : GRID_BB const snap = (v: number): number => Math.round(v / wireGrid) * wireGrid @@ -213,7 +257,7 @@ export function Canvas({ offY: number orig: Map frozen: FrozenWire[] - /** set once the pointer actually displaces — distinguishes board-hole clicks */ + /** set once the pointer actually displaces; distinguishes board-hole clicks */ moved: boolean } | null>(null) const handleDrag = React.useRef<{ kind: HandleKind } | null>(null) @@ -350,7 +394,7 @@ export function Canvas({ [fit, cam, scale] ) - const didFit = React.useRef(false) + const didFit = React.useRef(!fitOnMount) React.useEffect(() => { if (didFit.current) return if (doc.parts.length === 0 || doc.parts.every((p) => !p[view] || visualFor(p.type, view))) { @@ -486,11 +530,11 @@ export function Canvas({ window.addEventListener('pointerup', up) } - // drag a bendable leg tip (LED/resistor class parts, breadboard view only — + // drag a bendable leg tip (LED/resistor class parts, breadboard view only; // M2 leftover, spec §6.2/§7.4). `base` is the pin's leg offset at grab time // ([0,0] if it was resting). Live-dispatches like onLabelDown (merged // commands per move) so wires re-anchor to the tip in real time without a - // separate dragOverrides path — nothing else needs to know this isn't a + // separate dragOverrides path; nothing else needs to know this isn't a // normal edit. A real (>=3px) drag suppresses the trailing click so it // doesn't also arm/complete a wire from this pin. const beginLegDrag = ( @@ -509,11 +553,11 @@ export function Canvas({ const cur = doc.parts.find((p) => p.id === partId)?.[view] if (!cur) return // legOffset is added to the local pin coord BEFORE rotate/flip - // (core/geometry pinWorld) — invert that so dragging the tip on + // (core/geometry pinWorld); invert that so dragging the tip on // screen moves it the direction the user is actually dragging. const rad = (-(cur.rotate ?? 0) * Math.PI) / 180 let ldx = dxs * Math.cos(rad) - dys * Math.sin(rad) - let ldy = dxs * Math.sin(rad) + dys * Math.cos(rad) + const ldy = dxs * Math.sin(rad) + dys * Math.cos(rad) if (cur.flip) ldx = -ldx const next: [number, number] = [ Math.round((base[0] + ldx) * 100) / 100, @@ -631,10 +675,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) } @@ -690,7 +758,7 @@ export function Canvas({ const jp = clampOntoSegment(hit.p1, hit.p2, snap(wx), snap(wy)) return { wire: hit.id, t: tAtPoint(host.points, jp) } } - return null // v2 has no free-point endpoints — caller reverts + return null // v2 has no free-point endpoints; caller reverts } const startHandleDrag = ( @@ -779,6 +847,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) @@ -840,7 +912,7 @@ export function Canvas({ window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', up) setMarquee(null) - if (!rect) return // plain click — onCanvasClick handles deselect + if (!rect) return // plain click; onCanvasClick handles deselect const x0 = Math.min(rect.a.x, rect.b.x) const y0 = Math.min(rect.a.y, rect.b.y) const x1 = Math.max(rect.a.x, rect.b.x) @@ -1025,62 +1097,62 @@ export function Canvas({ const down = (e: KeyboardEvent): void => { if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return - // Space = straight-wire modifier (Shift is reserved for shift-select). - if (e.key === ' ') { + // Bindings live in lib/shortcuts (the Keyboard Shortcuts dialog reads + // the same list). Space = straight-wire modifier (Shift is reserved for + // shift-select). + if (matches(e, 'circuit.straightWire')) { setStraight(true) e.preventDefault() return } - if (e.key === 'Escape') { + if (matches(e, 'circuit.cancel')) { setArmed(null) setSel(emptySel()) return } if (!editable) return - const mod = e.ctrlKey || e.metaKey - if (mod && (e.key === 'z' || e.key === 'Z')) { + if (matches(e, 'circuit.undo')) { e.preventDefault() - if (e.shiftKey) store.redo() - else store.undo() + store.undo() return } - if (mod && (e.key === 'y' || e.key === 'Y')) { + if (matches(e, 'circuit.redo') || matches(e, 'circuit.redoAlt')) { e.preventDefault() store.redo() return } - if (mod && (e.key === 'c' || e.key === 'C')) { + if (matches(e, 'circuit.copy')) { void copySelection() return } - if (mod && (e.key === 'x' || e.key === 'X')) { + if (matches(e, 'circuit.cut')) { void copySelection().then((ok) => { if (ok) deleteSelection() }) return } - if (mod && (e.key === 'v' || e.key === 'V')) { + if (matches(e, 'circuit.paste')) { void navigator.clipboard.readText().then((text) => { const payload = parseClipboard(text) if (payload) pastePayload(payload) }) return } - if (mod && (e.key === 'd' || e.key === 'D')) { + if (matches(e, 'circuit.duplicate')) { e.preventDefault() const payload = buildClipboard(doc, sel.parts, sel.wires) if (payload) pastePayload(payload) return } - if (e.key === 'r' || e.key === 'R') { + if (matches(e, 'circuit.rotate')) { if (sel.parts.size) { e.preventDefault() rotateSelection() } return } - if ((e.key === 'f' || e.key === 'F') && view === 'sch' && sel.parts.size) { - // horizontal mirror — schematic only (spec §6.3 / B13) + if (matches(e, 'circuit.flip') && view === 'sch' && sel.parts.size) { + // horizontal mirror, schematic only (spec §6.3 / B13) e.preventDefault() const ids = [...sel.parts] const frozen = collectFrozen(doc, new Set(ids), view) @@ -1099,13 +1171,14 @@ export function Canvas({ if (cmds.length) store.dispatch(cmd.composite('Flip', cmds)) return } - if (e.key === 'Delete' || e.key === 'Backspace') { + if (matches(e, 'circuit.delete')) { deleteSelection() return } - if (e.key.startsWith('Arrow') && sel.parts.size) { + const big = matches(e, 'circuit.nudgeBig') + if ((big || matches(e, 'circuit.nudge')) && sel.parts.size) { e.preventDefault() - const step = (view === 'sch' ? GRID_SCH : GRID_BB) * (e.shiftKey ? 5 : 1) + const step = (view === 'sch' ? GRID_SCH : GRID_BB) * (big ? 5 : 1) const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], @@ -1183,10 +1256,47 @@ export function Canvas({ return out }, [doc, resolve]) - // Contextual hint only while actively wiring/editing — no idle 'scroll to + // ── 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,8 +1315,8 @@ export function Canvas({ className="size-full overflow-hidden" style={{ touchAction: 'none', - cursor: armed ? 'crosshair' : 'default', - // schematic reads as paper (lighter surface, finer dot grid — spec §8.1) + 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 ? `radial-gradient(var(--dot-color) ${view === 'sch' ? 0.9 : 1.1}px, transparent ${view === 'sch' ? 0.9 : 1.1}px)` @@ -1268,13 +1378,16 @@ export function Canvas({ const onHotNet = highlightNet >= 0 && netModel.wireToNet.get(w.id) === highlightNet const core = view === 'sch' ? ink : w.color || '#2fa46a' 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. + // 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 +1458,7 @@ export function Canvas({ {previewPts && ( { const pl = placementOf(part.id) if (!pl) return null - // breadboards are transparent on the schematic — their row/rail + // breadboards are transparent on the schematic; their row/rail // buses still merge nets globally, but they render as no part here. if (view === 'sch' && isBreadboard(part.type)) return null const vis = visualFor(part.type, view) @@ -1474,202 +1587,255 @@ export function Canvas({ } const selected = sel.parts.has(part.id) const labelOff = pl.labelOffset || [0, 0] + // labels live OUTSIDE the rotated container, anchored to the box + // the symbol visibly occupies (parts/labels.ts); inside it they + // would inherit the rotation and need a counter-transform that + // slides them off the part + const lay = labelLayout(vis.v.w, vis.v.h, vis.v.pins, pl.rotate) + const valueText = + view === 'sch' ? valueOf(part, vis.def.simFamily ?? vis.def.family) : '' return ( -
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 - pinHit / 2, + top: py - pinHit / 2, + width: pinHit, + height: pinHit, + 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} +
+ )} + ) })} @@ -1712,7 +1878,7 @@ export function Canvas({ >
{editable && (
onPinClick(e, label.id, '1')} > - + {showDot && ( ))} - {doc.parts.length === 0 && ( + {/* 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()} > - -
- {editable - ? 'Drag parts from the components rail — or double-click one to drop it here.' - : 'Empty circuit — click edit to start placing parts.'} -
+ + {t.kind === 'current' ? 'A' : t.kind === 'diff' ? '\u0394' : 'V'} + + {t.label} + {t.value && {t.value}} +
- )} + ))}
+ {/* Empty sheet: a note fixed to the middle of the window (not the + panned/zoomed world), gone once the first part is placed. */} + {doc.parts.length === 0 && ( +
+

+ {editable + ? 'Drag parts from the components rail, or double-click one to drop it here.' + : 'Empty circuit. Click Edit to start placing parts.'} +

+
+ )} + {/* transient action hint (only while wiring/reshaping) */} {hint && (
diff --git a/src/renderer/src/circuit/views/canvas/selection.ts b/src/renderer/src/circuit/views/canvas/selection.ts new file mode 100644 index 0000000..7a0014d --- /dev/null +++ b/src/renderer/src/circuit/views/canvas/selection.ts @@ -0,0 +1,13 @@ +/** What the circuit canvas has selected. */ +export interface Selection { + parts: Set + wires: Set + /** selected net-label ids (schematic) */ + labels?: Set +} + +export const emptySel = (): Selection => ({ + parts: new Set(), + wires: new Set(), + labels: new Set() +}) diff --git a/src/renderer/src/circuit/views/captureAnimation.ts b/src/renderer/src/circuit/views/captureAnimation.ts new file mode 100644 index 0000000..8948256 --- /dev/null +++ b/src/renderer/src/circuit/views/captureAnimation.ts @@ -0,0 +1,167 @@ +/** + * circuit/views/captureAnimation: the "screenshot" flourish that plays when + * the circuit view exports an image. + * + * Sequence: a shutter flash over the stage, the freshly-composed scene snaps + * in as a framed print, shrinks a touch, then flies up to the export button in + * the top-right corner where the download lands. Pure DOM + the Web Animations + * API, so it owns no React state and cleans itself up when it settles. + */ + +const REDUCED = '(prefers-reduced-motion: reduce)' + +export interface CaptureAnimationOpts { + /** The element that was "photographed"; the animation is drawn over it. */ + stage: HTMLElement | null + /** Where the download lands (the export button). Falls back to the corner. */ + target?: HTMLElement | null + /** data: URL of the exported scene. Omit for an untextured card. */ + imageUrl?: string + /** Total run time in ms (default 1000). */ + duration?: number +} + +function prefersReducedMotion(): boolean { + return typeof matchMedia === 'function' && matchMedia(REDUCED).matches +} + +/** + * Play the capture animation. Always resolves; a failure to animate must + * never stop the file from being saved. + */ +export async function playCaptureAnimation(opts: CaptureAnimationOpts): Promise { + 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..91bb089 100644 --- a/src/renderer/src/circuit/views/exportImage.ts +++ b/src/renderer/src/circuit/views/exportImage.ts @@ -1,5 +1,5 @@ /** - * circuit/views/exportImage — compose a scene (breadboard OR schematic) into a + * circuit/views/exportImage: compose a scene (breadboard OR schematic) into a * standalone SVG and download it as .svg or rasterized .png @2×. Part SVG ids * are namespaced per instance (fixes B6). Schematic wires are single ink * strokes; net labels are rendered from their glyphs. @@ -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('') + // 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 labelsSvg = sch ? (doc.netLabels ?? []) @@ -129,12 +153,23 @@ 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}` } /** - * Inline every var(--token) with its computed value — builtin art and schematic + * Inline every var(--token) with its computed value; builtin art and schematic * ink use design-system variables that only resolve inside the app stylesheet. */ function resolveCssVars(svg: string): string { @@ -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. + * + * A `data:` URL rather than an object URL: the app CSP is + * `img-src 'self' data: …` with no `blob:`, so an pointed at an object + * URL is blocked before it loads: onerror fires, the canvas is never painted, + * and no file is 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..bd299fc 100644 --- a/src/renderer/src/circuit/views/inspector/Inspector.tsx +++ b/src/renderer/src/circuit/views/inspector/Inspector.tsx @@ -1,12 +1,20 @@ /** - * circuit/views/inspector/Inspector — right-rail editing for the selection. + * circuit/views/inspector/Inspector: right-rail editing for the selection. * Part: refdes (rename with uniqueness check), label, location, rotation, * typed attrs. Wire: color, net info. Multi-select: summary + delete. * All edits dispatch Commands (undoable); moves/rotations reroute wires with * 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' @@ -31,27 +39,28 @@ import { rotateBoardAssemblyCmd, rotateNetLabelCmd } from '../partsAdapter' -import { WIRE_COLORS } from '../palette/Palette' -import type { Selection } from '../canvas/Canvas' +import { WIRE_COLORS } from '../palette/wireColors' +import type { Selection } from '../canvas/selection' 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 && ( +
+ +
+ )}
) } @@ -163,7 +184,7 @@ function PartInspector({ if (!pv) return const next = (((deg % 360) + 360) % 360) as 0 | 90 | 180 | 270 // Breadboards rotate as a rigid assembly (board + seated parts + wires - // between them) — same path as the canvas R / right-click gesture. + // between them): same path as the canvas R / right-click gesture. if (view === 'bb' && isBreadboard(part.type)) { const steps = ((next - (pv.rotate ?? 0)) / 90 + 4) % 4 const c = rotateBoardAssemblyCmd(doc, part.id, implicitSeats(doc), steps) @@ -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}
@@ -268,7 +291,7 @@ function PartInspector({ ))} {view === 'sch' && (

- Labels sharing a name join the same net — a clean way to wire power and ground without long + Labels sharing a name join the same net: a clean way to wire power and ground without long wires.

-
- setNewUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addUrl()} - /> - + } > - Add index - -
+ {builtIn.map((p) => { + const c = cached.find((x) => x.id === p.id) + const from = devLive.has(p.id) + ? 'from your tinyparts folder' + : c && c.origin.kind === 'github' + ? `updated from GitHub @ ${short(c.origin.commit)}` + : `as shipped @ ${short(SNAPSHOT.commit)}${SNAPSHOT.dirty ? ' (+ local changes)' : ''}` + return ( +
+
+
+ {p.name}{' '} + · {p.parts} parts +
+
{from}
+
+
+ ) + })} + {sync.errors.length > 0 && sync.state === 'ok' && ( +
+ {sync.errors.slice(0, 3).join(' · ')} +
+ )} + -
- {urls.length === 0 && ( -
- No index URLs configured. Add one above to browse installable parts packs. -
- )} - {urls.map((url) => { - const state = entries[url] - return ( -
-
- - {url} - + {/* ── developer ────────────────────────────────────────────── */} + {(canChooseDevFolder() || dev.serverRoot || dev.state !== 'off') && ( +
void chooseFolder()}> + {dev.mode === 'folder' ? 'Change…' : 'Choose folder…'} + + ) : undefined + } + > + {!dev.folder && ( +
+
+ Off: parts come from the app and GitHub as usual. +
+ {dev.serverRoot && ( + + )} +
+ )} + {dev.folder && ( +
+
+
+ {dev.folder} +
+
+ {dev.state === 'loading' + ? 'loading…' + : dev.state === 'error' + ? dev.error + : `${dev.mode === 'server' ? 'served by the dev server' : 'chosen folder'} · watching · ${dev.packs.map((p) => `${p.name} (${p.parts})`).join(', ')}`} +
+
+ {window.api?.fs && ( + + )}
-
- {!state &&
loading…
} - {state?.error && ( -
- - {state.error} -
- )} - {state && !state.error && !state.loading && state.packs.length === 0 && ( -
no packs listed
- )} - {state?.packs.map((pack) => { - const curVersion = installed[pack.id] - const isInstalling = installing === pack.id - const upToDate = curVersion === pack.version - return ( -
-
-
- {pack.name} v{pack.version} -
- {pack.description && ( -
{pack.description}
- )} -
- {curVersion && ( - - {upToDate ? 'installed' : `v${curVersion} installed`} - - )} - -
- ) - })} + )} + {devIssues.length > 0 && ( +
+ + {devIssues.length} issue{devIssues.length === 1 ? '' : 's'} in the folder + +
+ {devIssues.join('\n')} +
+
+ )} +
+ )} + + {/* ── local edits ──────────────────────────────────────────── */} + {edits.length > 0 && ( +
+ {edits.map((type) => ( +
+
+ {PART_MANIFEST.find((m) => m.type === type)?.label ?? type}{' '} + {type} +
+
+ ))} +
+ )} + + {/* ── more packs ───────────────────────────────────────────── */} +
+
+ setNewUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addUrl()} + /> + +
+ {urls.length === 0 && ( +
+ No index URLs configured. Add one above to browse installable parts packs.
- ) - })} + )} + {urls.map((url) => { + const state = entries[url] + return ( +
+
+ + {url} + + + +
+
+ {!state &&
loading…
} + {state?.error && ( +
+ + {state.error} +
+ )} + {state?.packs + .filter((p) => !p.bundled && !isBundledPack(p.id)) + .map((pack) => { + const curVersion = installed[pack.id] + const c = cached.find((x) => x.id === pack.id) + const isInstalling = installing === pack.id + const upToDate = curVersion === pack.version + return ( +
+
+
+ {pack.name}{' '} + v{pack.version} +
+ {pack.description && ( +
+ {pack.description} +
+ )} +
+ {curVersion && ( + + {upToDate ? 'installed' : `v${curVersion} installed`} + + )} + {(c || curVersion) && ( + + )} + +
+ ) + })} +
+
+ ) + })} +
diff --git a/src/renderer/src/circuit/views/palette/Palette.tsx b/src/renderer/src/circuit/views/palette/Palette.tsx index 6f022fc..7c39863 100644 --- a/src/renderer/src/circuit/views/palette/Palette.tsx +++ b/src/renderer/src/circuit/views/palette/Palette.tsx @@ -1,17 +1,34 @@ /** - * circuit/views/palette/Palette — the Fritzing-style components rail (M1). - * Reads the LEGACY partsLibrary manifest (replaced by the pack registry in - * M2); drag a tile onto the canvas or double-click to drop it at centre. + * circuit/views/palette/Palette: the parts bin, laid out like Fritzing's: a + * rail of tabs (Search, Core, Mine, then one per installed pack) beside a grid + * of part icons in sections. Which part goes where: ./paletteLayout. + * Drag a tile onto the canvas, or double-click it to drop it at the centre. */ -import { ChevronDown, ChevronRight, CircuitBoard, Package, Pencil, Plus } from 'lucide-react' +import { CircuitBoard, Package, Pencil, Plus, Search } from 'lucide-react' import React from 'react' -import { getPart, partsByFamily, type PartMeta } from '../../../lib/partsLibrary' +import { + getPackInfo, + getPart, + onPartsChanged, + PART_MANIFEST, + type PartMeta +} from '../../../lib/partsLibrary' +import { STORAGE_KEYS } from '../../../lib/storageKeys' import type { ViewId } from '../../core/model' import { NET_LABEL_KINDS, netLabelView } from '../../parts/netLabels' import { schematicVisual } from '../../parts/symbols' - -export const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] +import { WIRE_COLORS } from './wireColors' +import { sanitizeSvg } from '../../../lib/sanitizeSvg' +import { + CORE_TAB, + MINE_TAB, + paletteTabs, + searchParts, + SEARCH_TAB, + type PaletteSection, + type PaletteTab +} from './paletteLayout' function iconFor(meta: PartMeta, view: ViewId): string | undefined { if (view === 'sch') { @@ -21,29 +38,132 @@ function iconFor(meta: PartMeta, view: ViewId): string | undefined { return meta.icon } -function titleCase(s: string): string { - return s - .toLowerCase() - .replace(/\b([a-z])/g, (m) => m.toUpperCase()) - .replace(/\bIc\b/, 'IC') +function readSavedTab(): string { + try { + return localStorage.getItem(STORAGE_KEYS.paletteTab) || CORE_TAB + } catch { + return CORE_TAB // storage blocked: start on Core + } +} + +function saveTab(id: string): void { + try { + localStorage.setItem(STORAGE_KEYS.paletteTab, id) + } catch { + /* storage blocked: the tab just isn't remembered */ + } } -function Thumb({ svg, size = 40 }: { svg?: string; size?: number }): React.JSX.Element { - if (!svg) +const Thumb = React.memo(function Thumb({ svg }: { svg?: string }): React.JSX.Element { + const html = React.useMemo(() => (svg ? sanitizeSvg(svg) : ''), [svg]) + if (!html) return + return ( +
+ ) +}) + +/** A tab's icon: the pack's own image, its SVG, or its short text code. */ +function TabIcon({ tab }: { tab: PaletteTab }): React.JSX.Element { + const icon = tab.icon + if (tab.id === CORE_TAB || tab.id === MINE_TAB || !icon) return ( -
- -
+ + {tab.id === CORE_TAB || tab.id === MINE_TAB + ? tab.title.toUpperCase() + : tab.title.slice(0, 2)} + ) + if (icon.startsWith('data:image/')) + return + if (icon.trimStart().startsWith('<')) + return ( + + ) + return {icon.slice(0, 3)} +} + +const tileClass = + 'group relative size-9 p-0.5 rounded-md border border-border-default bg-surface-card hover:bg-bg-sunken hover:border-brand hover:-translate-y-px transition cursor-grab active:cursor-grabbing' + +function PartTile({ + part, + view, + onAdd, + onEdit +}: { + part: PartMeta + view: ViewId + onAdd: (type: string) => void + onEdit: (type: string) => void +}): React.JSX.Element { return (
+ draggable + onDragStart={(e) => e.dataTransfer.setData('text/tinystudio-part', part.type)} + onDoubleClick={() => onAdd(part.type)} + className={tileClass} + title={`${part.label} · ${part.pins} pins\nDrag onto the canvas, or double-click`} + > + + +
+ ) +} + +function NetLabelTiles({ + onAddNetLabel +}: { + onAddNetLabel: (kind: string, name: string) => void +}): React.JSX.Element { + return ( + <> + {NET_LABEL_KINDS.map((k) => ( +
+ e.dataTransfer.setData('text/tinystudio-netlabel', `${k.kind}:${k.name}`) + } + onDoubleClick={() => onAddNetLabel(k.kind, k.name)} + className={tileClass} + title={`${k.label}\nDrag onto the schematic, or double-click`} + > + +
+ ))} + + ) +} + +function SectionGrid({ + section, + children +}: { + section: Pick + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ {section.title && ( +
{section.title}
+ )} +
{children}
+
) } @@ -66,113 +186,146 @@ export function Palette({ onNewPart: () => void onOpenPacks: () => void }): React.JSX.Element { - const [collapsed, setCollapsed] = React.useState>(new Set()) - const families = partsByFamily() - const isOpen = (fam: string): boolean => !collapsed.has(fam) - const toggleFam = (fam: string): void => - setCollapsed((s) => { - const n = new Set(s) - if (n.has(fam)) n.delete(fam) - else n.add(fam) - return n - }) + const [rev, setRev] = React.useState(0) + React.useEffect(() => onPartsChanged(() => setRev((r) => r + 1)), []) + const [tabId, setTabId] = React.useState(readSavedTab) + const [query, setQuery] = React.useState('') + + // eslint-disable-next-line react-hooks/exhaustive-deps -- rev: the manifest mutates in place + const tabs = React.useMemo(() => paletteTabs(PART_MANIFEST, getPackInfo), [rev]) + const results = React.useMemo( + () => (tabId === SEARCH_TAB ? searchParts(PART_MANIFEST, query, getPackInfo) : []), + // eslint-disable-next-line react-hooks/exhaustive-deps -- rev: the manifest mutates in place + [tabId, query, rev] + ) + // an uninstalled pack's tab falls back to Core + const tab = tabId === SEARCH_TAB ? undefined : (tabs.find((t) => t.id === tabId) ?? tabs[0]) + const pick = (id: string): void => { + setTabId(id) + saveTab(id) + } + + const sections: PaletteSection[] = tab?.sections ?? [] + + const railButton = (id: string, title: string, content: React.ReactNode): React.JSX.Element => { + const active = (tab?.id ?? SEARCH_TAB) === id + return ( + + ) + } + + const tile = (p: PartMeta): React.JSX.Element => ( + + ) return ( -
-
- Components +
+
-
- Wire - {WIRE_COLORS.map((c) => ( + + +
+
+ + {tab?.title ?? 'Search'} +
-
- {view === 'sch' && ( -
-
Net Labels
-
- {NET_LABEL_KINDS.map((k) => ( -
- e.dataTransfer.setData('text/tinystudio-netlabel', `${k.kind}:${k.name}`) - } - onDoubleClick={() => onAddNetLabel(k.kind, k.name)} - className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" - title={`${k.label} — drag onto the schematic (or double-click)`} - > - -
- {k.label} -
-
- ))} -
-
- )} - {families.map((group) => ( -
+ className="text-text-muted hover:text-brand" + title="Parts packs: install more components" + onClick={onOpenPacks} + > + + + +
+
+ Wire + {WIRE_COLORS.map((c) => ( - {isOpen(group.family) && ( -
- {group.parts.map((c: PartMeta) => ( -
e.dataTransfer.setData('text/tinystudio-part', c.type)} - onDoubleClick={() => onAdd(c.type)} - className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" - title={`${c.label} · ${c.pins} pins — drag onto the canvas (or double-click)`} - > - -
- {c.label} -
- -
- ))} -
- )} -
- ))} + key={c} + onClick={() => onPickColor(c)} + className="w-4 h-4 rounded-full border-2 transition-transform hover:scale-110" + style={{ + background: c, + borderColor: wireColor === c ? 'var(--brand)' : 'rgba(255,255,255,0.18)' + }} + title="Set wire color (recolors the selected wire)" + /> + ))} +
+ +
+ {tabId === SEARCH_TAB && ( + <> + setQuery(e.target.value)} + placeholder="Search installed parts" + className="w-full h-8 px-2 rounded-md border border-border-default bg-surface-card text-[12px] text-text-body placeholder:text-text-faint outline-none focus:border-brand" + /> + {query.trim() && !results.length && ( +

+ No installed part matches. More parts come in packs (+ in the bar on the left). +

+ )} + {results.length > 0 && {results.map(tile)}} + + )} + + {tab?.id === MINE_TAB && !sections.length && ( +

+ Parts you import (drop a .fzpz on the canvas) or make with New part appear + here. +

+ )} + + {tab?.id === CORE_TAB && view === 'sch' && ( + + + + )} + {sections.map((s) => ( + + {s.parts.map(tile)} + + ))} +
) diff --git a/src/renderer/src/circuit/views/palette/paletteLayout.ts b/src/renderer/src/circuit/views/palette/paletteLayout.ts new file mode 100644 index 0000000..28da235 --- /dev/null +++ b/src/renderer/src/circuit/views/palette/paletteLayout.ts @@ -0,0 +1,214 @@ +/** + * circuit/views/palette/paletteLayout: which tab and section each part sits in, + * laid out the way Fritzing's parts bins are. + * + * Core the bundled tinyBoards and Core packs, and the parts generated in + * code (breadboards, sources, probes), in Fritzing's Core bin order + * Mine the user's own imported and new parts + * one tab per installed pack; every SparkFun pack shares one tab + * + * A pack's pack.json decides its layout: `sections` gives the section order and + * the `parts` list gives the order within each section. Package variants + * (`variantOf`) and search-only parts (simulation sources and probes) get no + * tile; search still finds them. + * + * Pure (no React, no registry): the palette passes in the manifest and a pack + * lookup. + */ + +import type { PackInfo, PartMeta } from '../../../lib/partsLibrary' + +export const SEARCH_TAB = 'search' +export const CORE_TAB = 'core' +export const MINE_TAB = 'mine' +const SPARKFUN_TAB = 'sparkfun' + +export interface PaletteSection { + /** undefined for a tab with a single, untitled section */ + title?: string + parts: PartMeta[] +} + +export interface PaletteTab { + id: string + title: string + /** a data: URL, SVG markup or a short text code, from the pack */ + icon?: string + sections: PaletteSection[] +} + +/** Tabs after Core and Mine: Arduino and SparkFun first, as in Fritzing, then the tinyparts index order. */ +const TAB_ORDER = [ + 'arduino', + SPARKFUN_TAB, + 'seeed', + 'intel', + 'lilypad', + 'picaxe', + 'wemos', + 'analog-devices', + 'atlas-scientific', + 'infineon', + 'spresense', + 'dagu', + 'frc', + 'raspberry-pi', + 'chipkit', + 'calliope', + 'esp', + 'adafruit', + 'voltage-regulators', + 'simulator' +] + +/** Pack order inside a shared tab (the tinyparts index order). */ +const PACK_ORDER = [ + 'tinyboards', + 'core', + 'sparkfun-analogic', + 'sparkfun-boards', + 'sparkfun-connectors', + 'sparkfun-digitalic', + 'sparkfun-discretesemi', + 'sparkfun-displays', + 'sparkfun-electromechanical', + 'sparkfun-freqctrl', + 'sparkfun-led', + 'sparkfun-passives', + 'sparkfun-poweric', + 'sparkfun-rf', + 'sparkfun-sensors', + 'sparkfun-etc' +] + +type PackLookup = (id: string) => PackInfo | undefined + +/** The tab a part's pack (its `bin`) belongs in. */ +export function tabIdFor(bin: string | undefined, info: PackInfo | undefined): string { + if (!bin) return MINE_TAB + if (bin === 'core' || bin === 'tinyboards' || info?.group === 'tinyStudio') return CORE_TAB + if (info?.group === 'SparkFun') return SPARKFUN_TAB + return bin +} + +/** "SparkFun · Sensors" → "Sensors": a pack's name as a section inside a shared tab. */ +function shortPackName(info: PackInfo | undefined, bin: string): string { + const name = info?.name ?? bin + const dot = name.indexOf('·') + return dot >= 0 ? name.slice(dot + 1).trim() : name +} + +function rank(list: string[], id: string): number { + const i = list.indexOf(id) + return i < 0 ? list.length : i +} + +const byPackOrder = (a: string, b: string): number => + rank(PACK_ORDER, a) - rank(PACK_ORDER, b) || a.localeCompare(b) + +function tabMeta(id: string, bins: string[], packInfo: PackLookup): Omit { + if (id === CORE_TAB) return { id, title: 'Core' } + if (id === MINE_TAB) return { id, title: 'Mine' } + if (id === SPARKFUN_TAB) { + const icon = bins.map((b) => packInfo(b)?.icon).find((i) => i?.startsWith('data:')) + return { id, title: 'SparkFun', icon } + } + const info = packInfo(id) + return { id, title: info?.name ?? id, icon: info?.icon } +} + +function sectionsFor( + parts: PartMeta[], + bins: string[], + packInfo: PackLookup, + shared: boolean +): PaletteSection[] { + const titleOf = (p: PartMeta): string | undefined => + p.section ?? (shared ? shortPackName(packInfo(p.bin!), p.bin!) : undefined) + + // declared order first (pack by pack), then any other section as it turns up + const order: (string | undefined)[] = [] + const add = (t: string | undefined): void => { + if (!order.includes(t)) order.push(t) + } + for (const bin of bins) for (const s of packInfo(bin)?.sections ?? []) add(s) + const sorted = [...parts].sort( + (a, b) => + byPackOrder(a.bin!, b.bin!) || + (a.position ?? Infinity) - (b.position ?? Infinity) || + a.label.localeCompare(b.label) + ) + for (const p of sorted) add(titleOf(p)) + + return order + .map((title) => ({ title, parts: sorted.filter((p) => titleOf(p) === title) })) + .filter((s) => s.parts.length > 0) +} + +/** + * The palette's tabs: Core and Mine always, then one per installed pack in + * Fritzing's order. Search is not a tab here; see searchParts. + */ +export function paletteTabs(manifest: PartMeta[], packInfo: PackLookup): PaletteTab[] { + const types = new Set(manifest.map((p) => p.type)) + const shown = manifest.filter((p) => !p.searchOnly && !(p.variantOf && types.has(p.variantOf))) + + const byTab = new Map([ + [CORE_TAB, []], + [MINE_TAB, []] + ]) + for (const p of shown) { + const id = tabIdFor(p.bin, p.bin ? packInfo(p.bin) : undefined) + if (!byTab.has(id)) byTab.set(id, []) + byTab.get(id)!.push(p) + } + + const tabs: PaletteTab[] = [] + for (const [id, parts] of byTab) { + if (id === MINE_TAB) { + const sorted = [...parts].sort((a, b) => a.label.localeCompare(b.label)) + tabs.push({ + ...tabMeta(id, [], packInfo), + sections: sorted.length ? [{ parts: sorted }] : [] + }) + continue + } + const bins = [...new Set(parts.map((p) => p.bin!))].sort(byPackOrder) + const shared = id === CORE_TAB || bins.length > 1 + tabs.push({ + ...tabMeta(id, bins, packInfo), + sections: sectionsFor(parts, bins, packInfo, shared) + }) + } + + const fixed = [CORE_TAB, MINE_TAB] + return tabs.sort((a, b) => { + const fa = fixed.indexOf(a.id) + const fb = fixed.indexOf(b.id) + if (fa >= 0 || fb >= 0) return (fa < 0 ? 99 : fa) - (fb < 0 ? 99 : fb) + return rank(TAB_ORDER, a.id) - rank(TAB_ORDER, b.id) || a.title.localeCompare(b.title) + }) +} + +/** + * Every part whose name, type, category, section or pack matches all the words + * in `query`, package variants included. Names starting with the query come first. + */ +export function searchParts( + manifest: PartMeta[], + query: string, + packInfo: PackLookup, + limit = 200 +): PartMeta[] { + const words = query.toLowerCase().split(/\s+/).filter(Boolean) + if (!words.length) return [] + const hits = manifest.filter((p) => { + const pack = p.bin ? (packInfo(p.bin)?.name ?? p.bin) : 'mine' + const text = `${p.label} ${p.type} ${p.family} ${p.section ?? ''} ${pack}`.toLowerCase() + return words.every((w) => text.includes(w)) + }) + const starts = (p: PartMeta): number => (p.label.toLowerCase().startsWith(words[0]) ? 0 : 1) + return hits + .sort((a, b) => starts(a) - starts(b) || a.label.localeCompare(b.label)) + .slice(0, limit) +} diff --git a/src/renderer/src/circuit/views/palette/wireColors.ts b/src/renderer/src/circuit/views/palette/wireColors.ts new file mode 100644 index 0000000..724dbec --- /dev/null +++ b/src/renderer/src/circuit/views/palette/wireColors.ts @@ -0,0 +1,2 @@ +/** Wire colours offered by the components rail and the Inspector, in menu order. */ +export const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] diff --git a/src/renderer/src/circuit/views/partsAdapter.ts b/src/renderer/src/circuit/views/partsAdapter.ts index 5a240e3..3e54a23 100644 --- a/src/renderer/src/circuit/views/partsAdapter.ts +++ b/src/renderer/src/circuit/views/partsAdapter.ts @@ -1,5 +1,5 @@ /** - * circuit/views/partsAdapter — geometry glue between the v2 document and the + * circuit/views/partsAdapter: geometry glue between the v2 document and the * LEGACY partsLibrary (breadboard view only). Temporary by design: the M2 * parts registry (`circuit/parts/registry.ts`) replaces every import of * `lib/partsLibrary` in here without touching the canvas. @@ -88,7 +88,7 @@ export function firstPinLocal(type: string, view: ViewId = 'bb'): [number, numbe return Object.values(vis.v.pins)[0] } -/** Snap a placement so its PINS land on the 9.6 px major grid (both views — +/** Snap a placement so its PINS land on the 9.6 px major grid (both views: * spec §4 pin-on-grid contract; Fritzing behavior). */ export function snapBB(type: string, placement: Placement, view: ViewId = 'bb'): Placement { const vis = visualFor(type, view) @@ -239,7 +239,7 @@ export interface FrozenWire { id: string bends: Pt[] straight: boolean - /** Both endpoints sit on moved parts — bends translate with the move. */ + /** Both endpoints sit on moved parts; bends translate with the move. */ both: boolean } @@ -320,7 +320,7 @@ export function bbBounds( return viewBounds(doc, 'bb') } -// ── M2: breadboard seating (drop-to-connect, derived — never stored) ───────── +// ── M2: breadboard seating (drop-to-connect, derived, never stored) ───────── import { SpatialHash } from '../core/geometry' import { breadboardBuses, isBreadboard } from '../parts/breadboard' @@ -328,9 +328,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: a part's own `buses` (tinyProto's power + * rails, from its part.json), else the generated breadboards'. */ export function circuitBuses(type: string): string[][] | undefined { - return breadboardBuses(type) + return getPart(type)?.buses ?? breadboardBuses(type) } export interface Seat { @@ -342,7 +343,7 @@ export interface Seat { } /** - * Derive implicit pin-in-hole connections: a part seats as a rigid body — + * Derive implicit pin-in-hole connections: a part seats as a rigid body; * ALL of its pins must land within SEAT_RADIUS of a breadboard hole at the * part's current placement/rotation, or NONE of them seat (Fritzing rule: * a component either fully plugs in or it's just resting on top). This @@ -432,14 +433,14 @@ export interface RatsnestSegment { } /** - * Dashed helper lines for nets that are electrically connected (globally — + * Dashed helper lines for nets that are electrically connected (globally: * either view, buses, seating) but not yet drawn in `view`: for each global * net, group its placed pins by this-view-only connectivity, then greedily * bridge groups between their nearest pins. */ export function ratsnest(doc: CircuitDoc, view: ViewId, global: NetModel): RatsnestSegment[] { // this-view connectivity: only this view's wires (+ physical buses; bb also - // gets derived seating — a seated pin needs no wire) + // gets derived seating; a seated pin needs no wire) const viewDoc: CircuitDoc = { ...doc, wires: doc.wires.filter((w) => w.view === view) } const viewNets = buildNets(viewDoc, { busesFor: circuitBuses, @@ -490,7 +491,7 @@ import type { ErcIssue } from '../core/erc' /** * Info-level floating-pin findings that need pin geometry (hence view-side). - * Only parts that are PARTIALLY connected are reported — a fully-unwired part + * Only parts that are PARTIALLY connected are reported; a fully-unwired part * is just not placed yet and would only add noise. */ export function ercFloatingPins(doc: CircuitDoc, net: NetModel, view: ViewId): ErcIssue[] { @@ -566,6 +567,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..1a9db91 100644 --- a/src/renderer/src/circuit/views/sim/Plot.tsx +++ b/src/renderer/src/circuit/views/sim/Plot.tsx @@ -1,5 +1,5 @@ /** - * circuit/views/sim/Plot — uPlot-backed waveform display (M4, spec §10.4). + * circuit/views/sim/Plot: uPlot-backed waveform display (M4, spec §10.4). * * One component for the three sweep shapes: * - transient: x = time (linear) @@ -7,28 +7,48 @@ * - 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' 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'] +import { fmtEng } from './format' +import { deg, mag, TRACES } from './plotData' 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 } -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 @@ -36,12 +56,15 @@ interface Prepared { 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 +73,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 +113,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 +129,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,38 +146,37 @@ 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 } } -export function fmtEng(v: number): string { - const a = Math.abs(v) - if (a >= 1e6) return `${(v / 1e6).toFixed(2)}M` - if (a >= 1e3) return `${(v / 1e3).toFixed(2)}k` - if (a >= 1) return v.toFixed(2) - if (a >= 1e-3) return `${(v * 1e3).toFixed(2)}m` - if (a >= 1e-6) return `${(v * 1e6).toFixed(2)}µ` - if (a === 0) return '0' - return `${(v * 1e9).toFixed(2)}n` -} - 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 +192,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 +211,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 +240,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,32 +251,18 @@ 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)` + : ''}
) } - -/** CSV of every vector in the run (x first), raw numbers. */ -export function runToCsv(run: SimRun): string { - const cols: { name: string; values: number[] }[] = [] - for (const v of run.vectors as SimVector[]) { - if (v.imag) { - cols.push({ name: `${v.name} (mag)`, values: v.values.map((re, k) => mag(re, v.imag![k])) }) - cols.push({ name: `${v.name} (deg)`, values: v.values.map((re, k) => deg(re, v.imag![k])) }) - } else { - cols.push({ name: v.name, values: v.values }) - } - } - const n = Math.max(...cols.map((c) => c.values.length)) - const lines = [cols.map((c) => JSON.stringify(c.name)).join(',')] - for (let i = 0; i < n; i++) lines.push(cols.map((c) => c.values[i] ?? '').join(',')) - return lines.join('\n') + '\n' -} diff --git a/src/renderer/src/circuit/views/sim/SimPanel.tsx b/src/renderer/src/circuit/views/sim/SimPanel.tsx index b8d7794..88378e8 100644 --- a/src/renderer/src/circuit/views/sim/SimPanel.tsx +++ b/src/renderer/src/circuit/views/sim/SimPanel.tsx @@ -1,26 +1,53 @@ /** - * 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; such a request would 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 { Badge } from '../../../components/ui/Badge' 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 { runToCsv, SimPlot, type PlotMode } from './Plot' +import type { EngineStatus, SimRun } from '../../sim' +import { fmtSI } from './format' +import { SimPlot, type PlotMode } from './Plot' +import { runToCsv } from './plotData' const field = 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-20 text-xs' @@ -30,6 +57,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 +69,13 @@ export function SimPanel({ familyOf, onClose, onResult, - onSelectIssue + onSelectIssue, + picking = false, + onPickingChange, + picked, + onToggleOutput, + onClearOutputs, + variant = 'drawer' }: { doc: CircuitDoc netModel: NetModel @@ -48,6 +86,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 +111,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,19 +140,52 @@ 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) + // extra work; ngspice already reports every node and probe source) const diffs = diffProbeVectors(doc, netModel, g, raw) const r: SimRun = diffs.length ? { ...raw, vectors: [...raw.vectors, ...diffs] } : raw setResult(r) @@ -112,10 +210,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 +225,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 +262,34 @@ 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 ( +
+ {/* The simulator is shipped as-is in 0.4: say so wherever the panel opens. */} +
+ Experimental + Simulation is experimental and results may be wrong. +
+ {/* controls: analysis kind and its parameters */} +
+
{( [ ['op', 'DC'], @@ -184,16 +300,14 @@ export function SimPanel({ ).map(([kind, label]) => (
{analysis.kind === 'tran' && ( -
+
step setAnalysis({ stop: e.target.value })} /> -