Commit Graph

177 Commits

Author SHA1 Message Date
David Montero Crespo ae70595239 fix(compile): user_libs_all gets the -Werror relaxations too
The main component had them; the merged user-libraries component did not, so
a warning INSIDE a vendor library was still fatal. M5Stack's MahonyAHRS.cpp
(fast inverse square root, a float read through a long*) fails under
-Werror=uninitialized, which made installing the vendor's own library kill
every build using it. Library code the user cannot edit must not stop their
build; the diagnostics still print as warnings.
2026-08-12 14:38:49 +02:00
David Montero Crespo 1980d70942 feat(serial): hardware-serial seams for the pro Web Serial monitor
appendHardwareSerial pushes real-UART bytes through the existing
per-frame serial batcher (no SerialMonitor changes), and
lib/proHardwareSerial lets the overlay intercept a board's serial
input while a physical monitor is attached — TX and RX switch to the
hardware together, detaching hands the tab back to the simulator.
Also adds @types/w3c-web-serial (esptool-js references it but only
dev-depends on it).
2026-08-11 21:23:56 +02:00
David Montero Crespo f03f80d1b3 fix(micropython): sleepy isconnected()/status() — busy-waits starved idleSkip
The canonical MicroPython connect idiom is `while not wlan.isconnected():
pass` — a pure Python busy-wait, and most gallery WiFi examples use it.
The emulated CPU only fast-forwards through WAITI idles, so a busy-wait
pins the sim at real execution speed (~2% of the chip in a browser tab).
Measured on the classic-ESP32 engine with a marker-bracketed harness: the
connect+DHCP phase costs ~1.5M busy instructions under a sleepy poll loop
and ~194M under the busy-wait — same firmware, same radio, 130x apart.
At browser speed that is close to a minute of wall clock for the connect
alone, which reads as "WiFi hangs" and got diagnosed as exactly that.

The proxy now sleeps 20ms per unanswered isconnected()/status() poll,
turning the user's busy loop into an idle loop without touching their
code. Re-measured end-to-end with the real prelude: 226.8M -> 64.3M busy
steps, and the made-up-SSID example connects with a 10.13.37.x lease on
the in-browser engine.
2026-08-10 22:58:30 +02:00
David Montero Crespo d527b1839c fix(micropython): real WiFi driver only where the backend can serve it
Deploy verification of the #262 fix caught a backend split the issue
could not see. The reporter runs the OSS Docker image, where MicroPython
executes under the QEMU worker — there the real driver works and their
fix verification (DHCP on 192.168.4.x, DNS, TCP) holds. But velxio.dev
routes a plain ESP32 MicroPython run to the in-browser esp32js engine,
and the engines' P4 WiFi (which serves Arduino fine) does not yet cover
MicroPython's v1.20 / esp-idf-4.2 vintage esp_wifi blob: handing it the
real module stalls esp_wifi_init with NO output — observed live as four
minutes of dead serial after the raw-REPL paste, strictly worse than the
stub's fake-but-responsive answers.

So the prelude now asks the bridge: an overlay bridge that keeps the
MicroPython run in-browser declares micropythonWifiSupported=false and
keeps the stub; the QEMU path (every OSS install, and ?esp32sim=qemu in
the overlay) gets the real driver + SSID-rewrite proxy. When an engine
gains MicroPython WiFi it flips its declaration and this file needs no
further change.

Property is duck-typed and optional; the plain OSS Esp32Bridge does not
define it, and undefined means supported — OSS behavior is exactly the
reporter's verified environment.
2026-08-10 22:34:49 +02:00
David Montero Crespo f96d239a1d fix(micropython): route unknown SSIDs to the emulated AP instead of hanging
Follow-up to the #262 fix. Handing MicroPython the real driver exposed a
population the stub had been quietly carrying: sketches that connect to
an SSID that simply does not exist in the emulated radio. The AP tables
(QEMU esp32_wifi_ap.c and the esp32*js engines — same four networks in
both) associate by MAC/channel, so an unknown SSID scans to nothing and
`while not isconnected():` spins forever. Most 100-days gallery examples
ship their author's home SSID, so "real driver, no shim" would have
frozen them on the connect loop.

So when a NIC is attached the prelude now injects a thin proxy around
the REAL network module: connect() to any SSID outside the emulated set
is redirected to Velxio-GUEST (announced on serial, so nobody debugs a
connection they didn't make); known SSIDs pass through untouched; every
other attribute delegates to the native module. Result: made-up-SSID
sketches get genuine DHCP/DNS/TCP through SLIRP instead of the old fake
answers, and the reporter's PICSimLabWifi repro is never rewritten.

Wrapper semantics (rewrite, passthrough, module-attr delegation, and the
`from network import WLAN` form) are exercised against an instrumented
fake module; both generated preludes are syntax-checked.
2026-08-10 21:52:30 +02:00
David Montero Crespo 1800f57225 fix(micropython): stop shadowing the real WiFi driver when a NIC exists
Reported in #262: on an emulated ESP32, MicroPython's WLAN.scan() always
returned [], ifconfig() reported 10.0.2.15/10.0.2.2/10.0.2.3, and
outbound sockets raised EHOSTUNREACH — while Arduino WiFi.h on the same
board and container scanned, connected on 192.168.4.x and completed real
HTTP requests. Two independent faults, both needed for the failure.

1. The MicroPython prelude injected a stub `network` module into
   sys.modules unconditionally, so user code never reached the firmware
   driver. The stub's hardcoded ifconfig() happens to be QEMU's SLIRP
   defaults, which is why the symptom read so convincingly as a routing
   bug.

2. hasWifi survived a language switch. It is a property of the compiled
   sketch, set only by arduino-cli, but setBoardLanguageMode kept it — so
   a board compiled once as Arduino carried that verdict into a later
   MicroPython run. The source-scanning fallback only fires when hasWifi
   is undefined, so it never got a chance, and the backend attaches the
   `-nic` device only when wifi_enabled is true.

The stub is NOT removed, which is where this departs from the patch in
the issue. It exists for a reason the report didn't have: without a NIC,
`network.WLAN(STA_IF)` hangs forever waiting on peripheral status bits
QEMU never sets, tripping the FreeRTOS watchdog after ~26s. Deleting it
would trade a fake-but-working network for a hung board. It is now
conditional — real driver when a NIC is attached, stub when none is —
so both paths are honest.

Also kept: the ntptime and urequests stubs, which the issue's diff would
have dropped along with `network` (it removed the whole prelude block).
Those back 29 call sites in the 100-days gallery examples, which have no
emulated internet to reach regardless of the NIC.

The detector is now one shared function instead of two copies that could
disagree — the stub decision and the -nic decision MUST match. Folded in
`from network import WLAN`, which matched neither previous pattern: such
a sketch was judged WiFi-less and hung on the missing peripheral. The
existing tests re-implemented the detection inline, so they only ever
proved that copy self-consistent; the new ones call the shipped function.
2026-08-10 21:40:58 +02:00
David Montero Crespo 27f712444b feat(store): hydrationSeq — bulk canvas loads identify themselves
setComponents/setWires (project load, example load, .vlx/wokwi import,
clear) bump a counter so observers that diff components/wires between
updates can tell hydration from a user deliberately adding one part.
Canvas edits (addComponent/addWire) don't touch it.
2026-08-09 22:28:16 +02:00
David Montero Crespo 93314b074b fix(esp32sim): re-sync the flat simulator field after a bridge rebuild
Second half of the deep-link race: an overlay board kind added BEFORE
the overlay registers makes isEsp32Kind() false, so addBoard wires the
board as a generic AVR — and the parts on the canvas attach their I2C
device models to THAT simulator (measured live: the gesture's device
sat on an AVRSimulator while the C6 engine booted with an empty bus).
The overlay's rebuild then swapped simulatorMap but left the flat
`simulator` field stale, so DynamicComponent's [simulator] effect never
re-attached anything. Re-syncing the flat field when the rebuilt board
is the active one makes every part re-attach against the real shim —
sensors, displays, mic sources, the lot.
2026-08-09 22:27:57 +02:00
David Montero Crespo 77e947ef98 fix(esp32sim): a rebuilt board adopts the I2C devices its parts attached
The pro overlay loads async, so a deep-linked example attaches its part
device models to the shim wired around the STOCK QEMU bridge — whose
attachSyncI2cDevice is a silent optional no-op — and only then does the
overlay's factory rebuild the board with the js-engine bridge. The
rebuild made a brand-new shim; the parts on the canvas hold the old one
in their attach closures and never re-attach. Result: the engine booted
with an EMPTY I2C bus and arduino's i2c-ng driver failed every
transaction with ESP_ERR_INVALID_STATE (the seated C6 gesture example —
measured live with the bus device table at Map(0)). Whether it broke
was a chunk-load race; the pro bundle's growth turned it into a sure
loss.

The shim now records every sync-attached device and a rebuilt shim
adopts its predecessor's — through addI2CDevice, so the new Delegating
bridge buffers them pre-connect and its start() replays them into the
engine after reset, as it always did for attaches it actually heard.
2026-08-09 22:06:51 +02:00
David Montero Crespo ede49e2e78 feat(sim): duck-type the browser MicroPython path so pro simulators plug in
The in-browser MicroPython run path required `sim instanceof RP2040Simulator`,
so an overlay-registered pro simulator (e.g. the RP2350's ARM MicroPython)
could never use it. Accept any simulator exposing loadMicroPython(files)
instead - generic plumbing, no board-specific reference; a sim without the
method is a no-op exactly as before. RP2040 behavior is unchanged.
2026-08-06 00:54:54 +02:00
David Montero Crespo c9fe6f199d feat(editor): folders - create/delete folder trees in the file explorer
File names may now carry '/' paths; the explorer renders each board group
as a collapsible folder tree (folders first, files in group order, CSS-var
indentation because .fe-file-item pins padding with !important). New-folder
buttons on the board header and per-folder hover actions (new file here,
new subfolder, delete with confirm when files are inside; a folder holding
every file of the group cannot be deleted - groups keep >= 1 file).

Model: names are the source of truth; only EMPTY folders are tracked
(folderGroups per group) so they survive until a file lands in them, then
materialise into the name prefix. Round-trips through .vlx (optional
folderGroups field, laxly validated), the auth stash draft, and
loadProjectState. Examples gain boards[].files so a single board can ship
a full multi-file tree (first entry = main sketch).

Backend: the arduino-cli sketch writer now creates subdirectories for
'/'-pathed names and REJECTS names that resolve outside the sketch dir
('..' traversal) - previously it crashed on the former and wrote outside
the tmp dir on the latter.
2026-08-05 06:48:38 +02:00
David Montero Crespo cfa03aeaf9 feat(sd): boards with a built-in microSD slot take user uploads
A ProBoardDef can declare builtInSdCsPin, and the store already built a FAT
card from the project files for it - but there was no way to put YOUR files
on that card: the upload panel only existed for the microsd-card component
and for parts with metadata.sdSlot, and boards have no properties bag.

Boards now carry sdFiles (same shape the SD panel persists everywhere else):

- BoardInstance.sdFiles, consumed by the store's Run path (card component
  still wins when one is on the canvas);
- the board inspector shows the SD Card panel whenever the board kind
  declares builtInSdCsPin, and routes the panel's onChange to updateBoard;
- .vlx round-trips it (serialisableBoard + payload type), and gallery
  examples can pre-load the slot via ExampleBoard.sdFiles, applied by
  loadExample the way the panel would.

First user: the XIAO ESP32S3 Sense (CS=GPIO21), whose board def and example
land in the pro overlay.
2026-08-04 05:55:21 +02:00
David Montero Crespo 13a8403d6d fix(sockets): a board a hair off the seat is still plugged in 2026-08-01 06:14:43 +02:00
David Montero Crespo 540c3081db fix(canvas): a seated board stays above the socket it is plugged into 2026-08-01 04:49:02 +02:00
David Montero Crespo cd2dc2f54b Merge remote-tracking branch 'origin/master' into v3.2-worktree 2026-08-01 04:23:55 +02:00
David Montero Crespo 5f8a3e171c feat(sim): a speaker-sink gate on the shim — audio out follows the part 2026-08-01 00:22:57 +02:00
David Montero Crespo ab7949502d feat(sim): seated sockets are electrical, and capability is probed
Three generic seams, none naming any board:

The pin tracer gains a socket hop: a component that declares boardSocket
with a board actually seated on it resolves a pad to the SAME-NAMED pin
of that board — seating IS the connection, like a XIAO pushed into a
shield header or a HAT on the 40-pin. Until now a seated shield's
buttons and LEDs were dead unless the user drew wires the real stack
does not have. Wires keep working exactly as before for every other
host (Uno, Mega, Pi, STM32, ...).

setMicrophoneSource now RETURNS whether a bridge actually took the
source, so a part can state that this host has no I2S instead of
pretending to stream into a void — capability reported, never invented.

createNeopixelDecoder is exported: boards are not the only things
carrying an addressable LED, and a part with one on board needs the
same decode rather than a drifting copy.
2026-07-31 22:32:47 +02:00
David Montero eb3b04efe3 spice: seed INPUT_PULLUP pins to their resting level on pull enable
Deterministic repro (emulation-gaps F4): after pinMode(2, INPUT_PULLUP)
with a correctly-wired button-to-GND, all 8 setup()-time digitalReads
and the first two loop() passes returned 0 — the input register only
got its level from the first SPICE solve (~400 ms). That window is what
fired phantom emergency-stop latches and auto-started state machines in
audited sessions (and an unwired INPUT_PULLUP pin on the qemu bridges
never got driven at all: no net in pinNetMap, connector skips it, guest
read 0 forever).

Fix: PinManager.onPullChange fires on pull transitions; AVRSimulator
seeds its port input to the resting level instantly (real silicon does
this in ns), and makePinPullHandler does the same for the qemu-bridge
boards via the shim's setPinState. Sourced nets are still overridden by
the very next solve, so the 'mis-wired button still works' regression
guarded by the June work cannot return.
2026-07-31 20:51:42 +02:00
David Montero 1ad1fd4137 feat(components): DS3231 RTC module + GPS NEO-6M NMEA talker
DS3231 (I2C 0x68):
- New velxio-ds3231 Web Component (4-pin module: GND/VCC/SDA/SCL) with
  pinInfo per CLAUDE.md 6a; registered in elements-register + main.tsx.
- Catalog entry in scripts/component-overrides.json (_customComponents)
  mirrored into components-metadata.json exactly as the generator emits it
  (verified by cloning wokwi-elements and running generate:metadata: zero drift).
- The existing VirtualDS3231 device sim (DS1307-compatible time regs
  0x00-0x06 + control 0x0E / status 0x0F / temperature 0x11-0x12 at 0.25 C
  steps) is now user-reachable from the picker; the AVR/RP2040 attach path
  gains live temperature updates via the SensorControlPanel (new ds3231
  slider in sensorControlConfig).

GPS NEO-6M (UART talker, 9600 baud):
- New velxio-gps-neo6m Web Component (VCC/RX/TX/GND, pulsing PPS LED).
- New part sim GpsParts.ts: emits a valid GPGGA+GPRMC cycle per second
  (correct XOR checksums, ddmm.mmmmm coords, UTC clock advancing 1 s/cycle)
  with lat/lng/altitude/speed configurable via property dialog and live
  via a new SensorControlPanel entry. Defaults: Madrid 40.4168 N 3.7038 W.
- Injection routes, chosen by classifying the wired board pin:
  1) hardware UART RX -> new uniform sim.feedUart(uart, data) seam
     (AVR USART0, RP2040 uart0/1 via rp2040js feedByte, ESP32 uart0/2 and
     STM32 USART1/2 via their bridge shims); byte feed is paced in 8-byte
     wall-clock chunks so 32-byte RX FIFOs never overflow;
  2) any other digital pin on a cycle-accurate sim -> real 8N1 bit-banged
     waveform via schedulePinChange, so SoftwareSerial decodes it;
  3) feedUart rejection (e.g. Mega USART1-3, not modelled by avr8js)
     degrades permanently to the bit-banged path.
- Interconnect already probed sim.feedUart; implementing it also upgrades
  cross-board UART byte shortcuts for RP2040 uart1.

Tests: new gps-neo6m.test.ts (16 tests: NMEA builders, byte injection on
Uno RX0 / ESP32 RX2, Mega RX1 fallback, waveform decode back to $GPGGA,
live position updates, PPS pulse) + 3 new ds3231 AVR-path tests in
protocol-parts.test.ts. Full frontend suite: 2294 passed, 2 pre-existing
Galaksija Z80 timeouts that pass in isolation. npx tsc --noEmit clean.
2026-07-31 20:38:06 +02:00
David Montero a18964b401 spice: unify silkscreen->GPIO mapping on boardPinToNumber (fixes STM32 outputs, nano-esp32 buttons, esp32-family SPICE analog)
Three parallel half-implementations of the pin-name mapping each broke a
different board family (2026-07 emulation-gaps audit):

- collectPinStates.pinNameToArduinoPin returned -1 for STM32 'PC13' (no
  V source stamped: LED dark with correct firmware) and for nano-esp32
  'D2'/'A0' (no pull resistor stamped for INPUT_PULLUP buttons).
- connectDigitalInputsToMcu.gpioFromPinName only knew digits/GPIO/GP, so
  labeled pins were never driven from the solve (buttons stuck LOW).
- connectAnalogInputsToMcu's ADC_PIN_MAP used 'GPIO32'-style lookup keys
  that never matched the real bare-number wire pin names, so the
  SPICE->ADC path skipped every esp32-family pin; its per-kind GPIO
  converters also mapped nano-esp32 A-pins with the AVR convention.

All three now delegate to utils/boardPinMapping.boardPinToNumber (with
the previous behavior as fallback for unknown names). Also adds the
ESP32-C3 branch to the store's adcChannelForPin (GPIO0-5 -> CH0-5,
verified against the qemu SARADC channel layout).
2026-07-31 19:20:59 +02:00
David Montero cd5cbb14f3 esp32: map S3-family GPIO pins to ADC channels in setAdcVoltage/setAdcWaveform
The GPIO->channel map only covered the classic ESP32 (GPIO 36-39/32-35),
so on esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32 every SPICE-driven
analog value was dropped and analogRead saw 0 (when it didn't hang —
fixed machine-side in libqemu 1.2.5's SENS stub). S3 family: ADC1 =
GPIO 1-10 -> CH0-9, ADC2 = GPIO 11-20 -> channel index 10-19, matching
the machine's channel layout.
2026-07-31 18:38:04 +02:00
David Montero Crespo 8ff8fdb292 feat(sim): board-agnostic speaker seam — audio out plays on the host
setSpeakerMonitor mirrors setMicrophoneSource: a part that models a
board with an on-board speaker/jack (reSpeaker Lite) gets the peak of
each block the guest played, so it can light a meter, while the audio
itself goes to the host's sound card via the engine bridge. Nothing is
wired for it on the canvas — that is the point: a real board plugged
into your speakers has no wire to draw. No-op on bridges without an
audio path, which reads as a silent speaker.
2026-07-31 17:29:52 +02:00
David Montero Crespo cb26e36b28 feat(canvas): drag-to-front — whatever you dragged last paints on top
An unseated board dropped over a component was painted underneath it
(boards z 0, components z 1) and became impossible to grab back; the
earlier blanket z bump for boards broke the opposite case, hiding LEDs
and transistors behind every board. Neither static order can win both.

The rule is now the user's own gesture: the FIRST movement of a drag
raises that item (board or part, symmetrically) to the top of a
monotonic dragged-stack, so a board dropped over an LED sits visibly on
top — and dragging the LED afterwards wins the stack right back.
Click-select alone never raises: selection must not reshuffle a scene
you arranged. Untouched items keep the static layering (components
above unseated boards, seated boards above their socket), and the rank
map is ephemeral — never saved with the project.
2026-07-31 16:02:26 +02:00
David Montero Crespo 08c5626b79 feat(sim): board-agnostic microphone seam + reSpeaker gallery glyph
setMicrophoneSource on the simulator shim mirrors addI2CDevice: a part
that produces audio hands one 16-bit sample per call to whatever bridge
implements the method (the in-browser JS-engine bridges); everywhere
else the optional chain makes it a silent mic. No board-kind checks in
components. Plus the flat reSpeaker Lite glyph for gallery cards.
2026-07-31 03:30:13 +02:00
David Montero Crespo a0ba1fbd28 fix(uart): el enganche de serie sobrevive a recrear el simulador
El fan-out del Interconnect envuelve el onSerialData de la INSTANCIA y
la marca con un flag. Compilar, resetear o cambiar de motor crea una
instancia nueva: sin flag y sin envoltorio, asi que la placa dejaba de
oirse por el cable a mitad de sesion. Sintoma real: la Pi en modo Linux
encendia el LED del Arduino (Pi -> Uno), pero la respuesta del Arduino
no volvia nunca (Uno -> Pi), porque el Uno habia recreado su simulador
al compilar despues de que se construyeran las rutas.

Se vuelve a enganchar en cada simulatorMap.set (siete puntos: AVR,
RP2040, RISC-V, ESP32, STM32 y los shims).
2026-07-29 20:55:05 +02:00
David Montero Crespo 3f867f7234 chore(pi): traza de que motor toma cada arranque
Una linea por start con el motor elegido, si estaba fijado y el motivo.
Sin ella, una placa que no arranca NADA (motor equivocado, bridge
ausente) es indistinguible de una que arranco bien: no hay error, no hay
proceso y la barra dice lo mismo. El toolbar ya deja su traza
[handleRun]; esta cubre el camino del boton de modo Linux.
2026-07-29 20:33:36 +02:00
David Montero Crespo d98617f1d2 fix(pi): arrancar sin bridge deja de fallar en silencio + test del reinicio
startBoard llamaba `getBoardBridge(id)?.connect()`: si la placa no tenia
bridge, el encadenamiento opcional se lo tragaba y el usuario se quedaba
sin guest, sin error y con la barra mostrando Stop. Ahora avisa por
consola y baja el flag de running, que es lo unico honesto que se puede
hacer ahi.

Test nuevo del camino que usa el boton de modo Linux: fijar el modo,
parar y arrancar tiene que abrir el WebSocket del guest, dejar
engineMode en linux y running en true; y un socket en CLOSING no puede
impedir la reconexion.
2026-07-29 20:10:57 +02:00
David Montero Crespo 6bfeaf1b15 fix(pi): reiniciar en modo Linux vuelve a arrancar el guest
connect() se rendia si el socket no estaba CLOSED, y uno en CLOSING pasa
esa prueba: pulsar "Linux terminal" justo despues de una ejecucion
cerraba el socket y el arranque siguiente no hacia nada -- ni guest, ni
error, y la barra seguia mostrando Stop. Ahora solo se rinde con OPEN o
CONNECTING y descarta el que se esta cerrando.

startBoard marca ademas running en la rama Pi: el boton de Linux
reinicia la placa por su cuenta, sin pasar por la barra, asi que el flag
se quedaba con lo que hubiera dejado la ejecucion anterior.
2026-07-29 19:33:14 +02:00
David Montero Crespo c55e399055 fix(uart): el hook de TX de la Pi se reinstala cuando nace el bridge
Las rutas serie se construyen al cargar la pagina, pero el bridge de una
placa QEMU-Linux nace al pulsar Run: ensureSerialHook encontraba bridge
nulo, hacia no-op y nadie volvia a intentarlo — los bytes que el guest
transmitia por el header salian del backend (uart_tx) y morian en un
onUartTx sin instalar. reensureSerialHooks(boardId) repite el enganche
(idempotente por el flag) y el store lo llama al crear el bridge.
2026-07-29 17:26:02 +02:00
David Montero Crespo 93388c675a fix(pi): perifericos completos en la familia Pi — entradas, buses y pines
Auditoria de "la Pi tiene todo lo de la placa real" con cuatro huecos
encontrados y cerrados:

1) GPIO de entrada en modo Linux: GPIO_IN respondia VAL 0 fijo (stub de
   la fase 2), asi que GPIO.input() leia 0 eternamente aunque el canvas
   empujara el nivel. El backend guarda ahora el ultimo nivel por pin
   (set_pin_state lo escribe) y GPIO_IN contesta de ahi. Los flancos
   (SET) siguen llegando al guest como antes.

2) UART del header hacia otra placa: el shim del rootfs ya hablaba
   `UART <port> TX <hex>` / RX_REQ, pero sin modelo de esclavo el
   backend tragaba los bytes. Ahora TX sin esclavo se emite al canvas
   (uart_tx) y RX_REQ sin esclavo drena la cola que llena pi_uart_rx —
   el mismo protocolo de siempre, sin ops nuevas.

3) El escaner de esclavos I2C/SPI/UART estaba doblemente muerto:
   clasificaba por numero fisico de pin ('3','5','19'...) cuando el
   elemento expone GPIOxx, y su unico llamador era RaspberryPiWorkspace,
   que el terminal unificado reemplazo. Acepta ambos nombres y corre en
   onBooted del store.

4) boardPinToNumber solo mapeaba los pines de la 3/4/5; la Zero, 1B+ y
   2B (mismo header de 40 pines, mismo elemento) se quedaban sin mapa.
2026-07-29 17:05:39 +02:00
David Montero Crespo 8e1001740a fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.

1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
   dibuja un elemento del overlay. El overlay registraba un def minimo
   para las seis Pi solo para llevar una linea de setup del guest, y eso
   basto para cambiarles el render: en vez de la ilustracion
   Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
   coordenadas de pines, y los cables quedaban colgando en la esquina.
   Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
   lleva la cadena y nada mas; getGuestSetup() la resuelve dando
   prioridad al def del overlay si existe.

2) Las partes de entrada (PIR, botones, sensores) avisan con
   simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
   simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
   en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
   nada. traceDetailed devuelve ahora tambien la placa a la que llega el
   pin, y si es de la familia Pi la parte recibe un simulador que empuja
   el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
   los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 07:34:01 +02:00
David Montero Crespo 43ef6c9f1d feat(seams): instant-engine registry for QEMU-Linux boards
Generic seam only — no board, OS or runtime specifics in the OSS tree:
an overlay may register an engine that decides, per board, whether a run
needs the Linux guest or can happen in the browser. The decision is data
(engine + reason + where) so the UI can explain a 90 s boot instead of
just taking it, and BoardInstance carries engineMode / enginePinned so
the user's choice is predictable and the terminal panel knows whether an
interactive shell exists. Nothing registers in OSS: the QEMU path is
untouched.
2026-07-29 04:20:07 +02:00
David Montero Crespo 330b9ed1b2 feat(pi-family): quietBoot — hide the shared rootfs' branded boot chatter
The generic arm64 image prints another product's banner/motd/login line
during boot, before guestSetup can re-brand the guest. Boards with
quietBoot show a neutral '[Velxio] Booting <label> (Linux guest)...'
progress line (dots every 4 s) while boot detection and the prompt-gated
upload still run underneath; the shell is revealed (already re-branded)
right before the auto-run command, so the user's first visible output is
their own program.
2026-07-28 21:07:21 +02:00
David Montero Crespo bbafe66ee7 feat(pi-family): unified editor UX — Monaco + explorer + bottom xterm, one file surface
QEMU-Linux boards used three competing file surfaces (workspace group
with a meaningless sketch.ino/libraries.json, the VFS panel with its
Upload button, and the Pi workspace's own editor). Now they behave like
every other board:

- the editor file group defaults to script.py for ANY Pi-family kind
  (kind-based check via isPiBoardKind, not the old raspberry-pi- string)
- the libraries.json manifest row is hidden for Pi boards
- EditorPage always renders Monaco; the RaspberryPiWorkspace swap is
  gone
- the bottom serial panel renders the interactive xterm (PiTerminal,
  now seeded with session history) for running Pi boards
- example vfsFiles load into the editor group (single source of truth);
  the run path uploads the group into the guest home
- Run on a booted guest re-runs without the 45 s reboot (Ctrl-C +
  re-upload + run); starting a Pi board pops the terminal open
- piSyncAndRunScript/piRerunScript exported from the store; auto-run
  now applies to the whole family (guestHome/autoRun overridable)
2026-07-28 19:43:20 +02:00
David Montero Crespo dd86020343 feat(pi-family): one-click run UX — autoRun + guestHome + clean serial mirror
- ProBoardDef.autoRun: after boot (+guestSetup) the VFS uploads itself
  and the command runs, so a single Run click boots, uploads and starts
  the user's script (same UX as compiled boards)
- ProBoardDef.guestHome: VFS home dir override ('/root' for guests that
  log in as root); those boards drop the historic hello.sh sample
- upload sequence extracted to utils/piUpload (shared by the VFS panel
  button and autoRun)
- serial monitor strips DEL/C0 control echoes (backspace showed tofu)
2026-07-28 16:28:02 +02:00
David Montero Crespo 55dd25eeba feat(pi-family): guestSetup seam + board-branded workspace texts
Overlay QEMU-Linux boards are not Raspberry Pis: ProBoardDef.guestSetup
lets a board send one shell line at the boot prompt (hostname/PS1/clear)
to de-brand the generic image, sent before piBooted flips so uploads
cannot interleave; the workspace start button and power-on title carry
the board's own label for non raspberry-pi kinds; the compile console
line uses the board label instead of 'Raspberry Pi 3B'.
2026-07-28 14:36:13 +02:00
David Montero Crespo a419b13cb1 fix(esp32): el mapa GPIO->canal ADC del shim pregunta al puente
setAdcVoltage/setAdcWaveform solo conocian el mapa del ESP32 clasico
(GPIO32..39): en un S3 devolvian false para TODOS los pines y el part no
podia alimentar el ADC. Ahora el shim consulta bridge.adcChannelForGpio si
existe (los puentes js exponen el mapa de su chip) y cae al mapa clasico
solo si no.
2026-07-28 04:15:04 +02:00
David Montero Crespo 0b7c9e1dd6 fix(esp32): completeTransfer del shim SPI ya no es un no-op — el MISO de los parts llega al guest
El adaptador SPI del Esp32BridgeShim descartaba el MISO de los parts ('el worker
lo lleva por _spi_response'), cierto solo en la era QEMU: cualquier part SPI que
RESPONDE (una SD contestando CMD0) hablaba con nadie en modo js — medido como
SD.begin()=0 con sd_diskio reintentando CMD0 para siempre en el Round Display.

Ahora reenvia a bridge.setSpiResponse, que todos los puentes tienen: el de QEMU
lo manda al worker, y los motores js fijan el byte que su SpiForwarder devuelve
para ESTA transferencia (toda la cadena onByte corre sincrona dentro del
transfer del motor). El reposo lo restaura el decodificador compartido, que ya
completa cada byte ajeno con 0xff.
2026-07-28 02:01:15 +02:00
David Montero Crespo fd6829f645 merge: master (produccion) dentro de v3.2
Las dos ramas habian divergido: master llevaba el modo lenguaje ESP-IDF puro
(#139) y v3.2 la ruta de compilacion IDF v5.5 para toda la familia ESP32 mas
los arreglos de venv/toolchain. Ambas tocaban espidf_compiler.py.

Los dos lados son ejes ORTOGONALES y se conservan enteros:

  - use_idf5 / arduino_mode (v3.2): que arbol IDF usa el build (5.5 vs 4.4) y
    si cabe Arduino-como-componente.
  - pure_idf (master): el modo LENGUAJE que elige el usuario; sus ficheros son
    las fuentes del componente main con su propio app_main().

Resolucion:
  - _build_env acepta los tres. Un build IDF puro fuerza arduino_mode a falso:
    la plantilla CMake mete el componente arduino-esp32 en cuanto existe
    ARDUINO_ESP32_PATH, asi que dejarlo puesto compilaba el core de Arduino en
    un build que no tiene sketch. Lo cazaron los tests de master.
  - VELXIO_PURE_SKETCH solo con pure_idf, nunca con arduino_mode a falso a
    secas: un target sin core arduino-esp32 sigue entregando un SKETCH al
    traductor legacy y no debe tomar la rama del glob puro.
  - La identidad del build-dir suma los dos tokens (|idf:N|ard:N y |lang:pure):
    ningun par de esas combinaciones puede compartir un build/ configurado.
  - La cadena de escritura de fuentes queda pure_idf -> arduino_mode -> legacy.
  - sdkconfig: render de v3.2 (con target/use_idf5) mas el filtrado de simbolos
    CONFIG_ARDUINO* de master cuando el build es puro.

test/backend/unit/test_espidf_compiler.py: los 7 tests que ya estaban rotos en
v3.2 (AttributeError: idf5_path, fixture sin actualizar desde que se anadio la
seleccion de IDF) vuelven a pasar.

Verificado: backend 293 pasan / 0 fallan (v3.2 traia 7 rotos); frontend 2268
pasan / 0 fallan en los dos shards.
2026-07-25 21:52:00 +02:00
David Montero 734b7d0487 feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.

Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
  compile paths and folded into the dedup job key (language='arduino'
  and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
  as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
  resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
  and VELXIO_PURE_SKETCH raised so the template CMake compiles the
  user's own sources via a glob branch. Pure builds get their own
  persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
  detection sets has_wifi, and literal #define SSID/PASS plus
  wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
  mode (the symbols don't exist without the arduino component).

Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
  family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
  driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
  (the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).

Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
2026-07-24 06:37:01 +02:00
David Montero Crespo 116afcab98 feat(sensors): runtime seam for overlay-registered sensor controls
registerSensorControls() lets a private build add SensorControlDef entries for
sensors it ships outside the OSS tree (e.g. the DFRobot Gravity analog family)
so they get the live slider panel; every SENSOR_CONTROLS lookup now goes
through getSensorControl(id) which falls back to the registered map. Dead code
in a pure OSS build, same contract as proBoardRegistry / registerComponentDoc.
2026-07-23 21:32:14 +02:00
David Montero Crespo 32958640a3 feat(boards): runtime board-registration seam for private overlays
registerProBoards() lets a hosted overlay ship boards outside the OSS tree as
data-only definitions: registration patches the exported BoardKind maps
(labels / FQBN / MicroPython) so every existing read site keeps working, and
the sites a map can't cover consult the registry — canvas render (custom
element or overlay render fn), BOARD_SIZE, pin-name mapping, picker list +
descriptions + tag, ESP32 family routing, in-browser simulator construction
and firmware load (structural ProBoardSimulator contract, duck-typed PIO
attach/detach), built-in bridge sensors, and a CS-gated built-in microSD
(sdCsPin -> sd_card.cs_pin worker config). Esp32Bridge additionally gains the
esp32-c6 machine type + TX pin (public chip knowledge — the C6 compile path
already ships) and a generic sendKey() for built-in matrix keyboards.
registerProExamples() appends gallery examples at runtime; the board ONLINE
ads recompute at render so registration hides them. OSS behavior without an
overlay is unchanged — the registry is dead code, same as the other seams.
2026-07-22 21:21:21 +02:00
David Montero Crespo c02547049d feat: ESP32 bridge seams, picker datasheets, S3/C3 examples + online-only board showcase
Generic platform work ported from the internal line:
- Esp32BridgeFactory seam + rebuildEsp32Bridge + sync-I2C seam: a
  substitute simulation bridge (e.g. the hosted editor's in-browser JS
  emulators) can be installed without touching OSS code
- Component datasheets: hover popover (ComponentInfoPanel) + markdown
  docs for common parts
- Per-chip S3/C3 basics examples for the gallery
- .gitignore: never allow pro emulator mask ROMs into the OSS repo

New: online-only board showcase. Boards implemented by the hosted editor
(ESP32-C6, M5Stack Core, Cardputer ADV, Pimoroni RP2350 family) appear
in the picker as advertisement cards with an ONLINE badge linking to
velxio.com, where they are free to use. Ads auto-hide in any build that
registers the real BoardKind.
2026-07-21 00:22:19 -03:00
David Montero Crespo 701042fa22 fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:

updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).

CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.

Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).

wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.

Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.

Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:15:59 +02:00
David Montero Crespo 0635e15e7a fix(router): escape corridors for endpoint-in-obstacle + checked-elbow parity
Three router bugs found by replaying a real agent session (reloj_3333) where
wires ran straight across a seated 4-digit display. Each fix is covered by a
regression test built from the failing geometry.

Endpoint inside an obstacle no longer drops the whole obstacle
--------------------------------------------------------------
Breadboard strips under a seated display start INSIDE its inflated bbox, so
the "rects containing an endpoint are dropped" rule deleted the display as
an obstacle for every wire leaving those strips — 15 wires crossed it end to
end. The rect is now carved instead: an escape corridor (ROUTE_MARGIN wide)
from the endpoint to the chosen edge, with the rest of the body still
blocking. Side blocks overlap the endpoint's row by 1px, or the strict
segment-hit test leaves the row as a free seam straight across the body.

Overlapping rects escape in ONE shared direction
------------------------------------------------
Seated resistors overlap heavily (19px pitch, ~66px inflated boxes). When
each containing rect picked its own nearest edge, the corridors pointed
different ways and walled each other off — A* found no exit, fell back to
the direct elbow, and the wire crossed the display anyway. The escape
direction is now chosen once against the UNION of containing rects and
every carve uses it, so the corridors chain into a continuous exit.

Null route materialises the CHECKED elbow
------------------------------------------
routeAroundObstacles returns null when the PREVIEW elbow (longer-axis-first)
is clear — but the re-route pass stored empty waypoints, which the renderer
expands as the horizontal-first corner: a DIFFERENT elbow the router never
validated. Three wires shipped crossing a display whose checked route was
clean. The pass now materialises previewElbow explicitly, exactly like
finishWireCreation always did.

Verified E2E: the same agent prompt that produced 15 crossings now builds
the ESP32 clock with ZERO wire segments crossing the display body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:35:58 +02:00
David Montero Crespo 651161559a feat(wires): avoid other wires + live routed preview + system-owned shapes
Extends the existing component-avoiding A* (wireAutoRoute.ts) into the full
auto-router the canvas was missing. Three pieces:

Wire avoidance with soft costs
------------------------------
Component bodies stay hard-blocked, but wires get graded costs: running
parallel on top of another wire (within an 8px corridor) is charged per px,
a perpendicular crossing costs a small fixed amount, and bends keep their
existing penalty. Crossings must stay possible — hard-blocking wires makes
dense boards unroutable and everything would degrade to the default elbow.

The compressed grid gains "corridor" coordinates 8px to each side of every
wire segment, so the router actually has a lane to run BESIDE a wire; that
is also what lays multi-wire runs out as a tidy side-by-side bus, since
each new wire routes seeing the previous ones. Wires sharing an endpoint
with the route are exempt (wires meeting on a pin must touch there), and
only wires within 120px of the route's bbox participate, keeping the grid
under the coordinate cap on dense canvases.

autoRouted: the system owns the shape until the user takes it
-------------------------------------------------------------
New Wire flag, set by pin-to-pin creation and by agent add_wire. Every
shape-editing gesture (segment drag, waypoint drag, waypoint insert — five
call sites) clears it: from that moment the wire is hand-authored and is
NEVER re-shaped, exactly where the user put it. Wires from older projects
have no flag and are treated as hand-authored.

recalculateAllWirePositions re-routes flagged wires after endpoints move
(component drag end, agent batches, mount settle — never per drag frame).
This is also what routes agent wires at all: they are created before their
elements mount and before pin coords are final, so creation-time routing
is impossible; the settle-timer recalc routes them once geometry is real.

Live routed preview
-------------------
updateWireInProgress routes start->cursor (throttled to 40ms) and the
preview renders that path, so the wire dodges components and wires AS THE
MOUSE MOVES instead of snapping into shape on the final click. Hand-guided
previews (user-placed waypoints) keep the classic path untouched.

Verified in the live app: an agent-built breadboard circuit shows 0 wire
overlap px and 0 body crossings across all wires, and a hand-started wire
aimed collinear with an existing run previews 21px beside it, overlap 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:08:25 +02:00
David Montero Crespo 9e5ae8baf6 fix(breadboard): derive seating at element mount — closes run-before-seating race
A part can land in the store at its FINAL position before its element
mounts: the agent streams add_component and the seating move in one batch,
and updateComponent's reseat then finds no DOM (computeSeating null) and
keeps the empty seating. Nothing re-derived it afterwards — the agent-side
seat correction skips when the position needs no nudge, and 'pininfo-change'
only fires on pin-SET swaps, not on plain init. Meanwhile run_simulation
executes right after the SSE round, before the correction's animation frame.

Net effect, reported by a user as a suspicion that turned out exactly right:
a clock the agent built and ran in one turn showed a dead display, while
reloading the project and running it worked — bb seating wires are persisted,
so on reload they exist before Run is pressed.

DynamicComponent now reseats once the element's pinInfo first becomes
measurable (same polling cadence as the pinInfo-ready effect), which closes
the hole for every path that stores a final position before mount: agent
batches, project load, undo. To keep that free on load,
reseatComponentOnBreadboard skips the store write when there is nothing
seated and nothing to clear — otherwise every off-board part would churn the
wires array identity once per mount.

Verified live end-to-end: agent adds + seats + wires + compiles + RUNS in a
single turn; the seated LED blinks immediately (4 transitions sampled), with
all 4 seated-pin markers present — no reload needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:39:29 +02:00
David Montero 206cda78af fix(components): type-coerce string properties + reseat on pininfo-change
Root cause of the 'digits=4 display seated with the 1-digit COM pinout'
bug: property values arrive as STRINGS (agent set_component_property,
the property dialog's text inputs) and were assigned to the web
component verbatim — wokwi's 7segment does switch(this.digits) with
numeric cases, so el.digits='4' silently fell back to the 1-digit
pinout (and 'false' stayed truthy for boolean props like colon).

- DynamicComponent now coerces string values to the TYPE of the
  metadata default for that key (number/boolean) before assigning.
- New pininfo-change listener: when a property swaps the element's pin
  set (digits, flip, pins edge), the elements announce it — re-derive
  the breadboard seating then, with the fresh pinout, instead of never.
2026-07-18 19:18:27 +02:00
David Montero f98c3268fa feat(breadboard): Wokwi-style parts-on-breadboard — hole snapping + invisible seating wires
Parts now plug INTO the breadboard instead of using it as a junction box:

- Drag magnetism: while dragging, the part's anchor pin snaps to the
  nearest hole center (9 px range, 9.6 px grid) so parts land perfectly
  aligned, like Wokwi.
- Seating: every pin within 4 px of a hole gets an invisible zero-length
  wire (Wire.bb) from pin to hole — the exact model Wokwi persists as
  ["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary
  wires, so the netlist builder, digital trace and SPICE need zero
  changes; they are simply not rendered and not hit-testable. Seating
  re-computes on every move/rotation (updateComponent), and moving the
  breadboard carries its seated parts along.
- Resistors auto-rotate to vertical when dragged over a breadboard
  (their 58.8 px pin span bridges the center trench rows b-f exactly).
- Seat tolerance 4 px: absorbs the worst element pin-spacing residual
  (~1.6 px) while staying under half the hole pitch, so a pin is never
  ambiguous between holes.

Wokwi interchange fixes that fell out of the diagram.json research:
- import maps the top-level rotate attr onto properties.rotation
  (previously every rotated part imported flat) and export emits it
  back as rotate instead of leaking it into attrs;
- $bb / empty-color connections import as bb seating wires and export
  back as ["$bb"] entries, so parts-on-breadboard projects round-trip;
- wokwi-breadboard-half aliases to the full breadboard (hole names are
  a strict superset, so every connection stays valid).

Breadboard elements now export their pure hole grids and import cleanly
without a DOM (node tests); geometry + store seating covered by
breadboard-snap.test.ts and breadboard-seating.test.ts.
2026-07-18 08:47:25 +02:00
David Montero 7ed9c51bd3 feat(wires): first-time auto-routing around components
Creating a wire with a direct pin-to-pin click (no user waypoints) now
routes around other components' bounding boxes instead of crossing
them. Routing happens exactly once, at creation: the routed corners are
stored as ordinary waypoints, so every later manual edit stays where
the user puts it — never re-routed.

Router (utils/wireAutoRoute.ts):
- tries the preview elbow first (clear -> keep existing behavior and
  the WYSIWYG shape), then the opposite elbow, then A* over the
  compressed grid spanned by pin coordinates and obstacle edges
  inflated by an 8 px clearance, with a 40 px per-bend penalty so
  straighter routes win
- obstacles are component boxes only (never boards — pins sit on both
  board edges and detouring around a board produces absurd routes),
  excluding the wire's own endpoint components, measured from the
  rendered DOM; rects containing an endpoint are dropped
- any failure (walled-off target, oversized grid, no DOM) falls back
  to the previous direct-elbow behavior
2026-07-18 05:59:45 +02:00