From ef01b8d2b75f9538fd47c3eccdb19dfde2b2628d Mon Sep 17 00:00:00 2001 From: bertona88 Date: Mon, 27 Jul 2026 20:51:12 +0200 Subject: [PATCH 1/3] feat: add Paper Figure Studio and OpticalSetup bridge --- .github/workflows/validate.yml | 34 + .gitignore | 1 + ACCEPTANCE_TESTS.md | 71 + CLAIMS_AND_VALIDATION.md | 51 + INTERFACE_CONTRACT.md | 66 + PHOTONICS_MODEL_CONTRACT.md | 77 + README.md | 25 +- app/.gitignore | 3 + app/CHANGELOG_PAPER_STUDIO.md | 113 + app/README.md | 233 ++ app/build_single.py | 51 + app/docs/PAPER_FIGURE_STUDIO.md | 34 + app/icon.svg | 6 + app/index.html | 304 +++ app/integrations/opticalsetup/README.md | 35 + .../opticalsetup/receiver-adapter.js | 101 + app/manifest.webmanifest | 20 + app/src/analysis.js | 661 ++++++ app/src/app.js | 1894 +++++++++++++++++ app/src/bridge.js | 115 + app/src/circuit.js | 598 ++++++ app/src/complex.js | 93 + app/src/coupler-view.js | 468 ++++ app/src/export.js | 293 +++ app/src/geometry.js | 139 ++ app/src/models.js | 773 +++++++ app/src/physics.js | 736 +++++++ app/src/plot.js | 271 +++ app/styles.css | 956 +++++++++ app/sw.js | 50 + app/tests/analysis.test.mjs | 93 + app/tests/export.test.mjs | 79 + app/tests/geometry-models.test.mjs | 67 + app/tests/paper-bridge.test.mjs | 123 ++ app/tests/physics.test.mjs | 39 + package.json | 6 + 36 files changed, 8676 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 ACCEPTANCE_TESTS.md create mode 100644 CLAIMS_AND_VALIDATION.md create mode 100644 INTERFACE_CONTRACT.md create mode 100644 PHOTONICS_MODEL_CONTRACT.md create mode 100644 app/.gitignore create mode 100644 app/CHANGELOG_PAPER_STUDIO.md create mode 100644 app/README.md create mode 100644 app/build_single.py create mode 100644 app/docs/PAPER_FIGURE_STUDIO.md create mode 100644 app/icon.svg create mode 100644 app/index.html create mode 100644 app/integrations/opticalsetup/README.md create mode 100644 app/integrations/opticalsetup/receiver-adapter.js create mode 100644 app/manifest.webmanifest create mode 100644 app/src/analysis.js create mode 100644 app/src/app.js create mode 100644 app/src/bridge.js create mode 100644 app/src/circuit.js create mode 100644 app/src/complex.js create mode 100644 app/src/coupler-view.js create mode 100644 app/src/export.js create mode 100644 app/src/geometry.js create mode 100644 app/src/models.js create mode 100644 app/src/physics.js create mode 100644 app/src/plot.js create mode 100644 app/styles.css create mode 100644 app/sw.js create mode 100644 app/tests/analysis.test.mjs create mode 100644 app/tests/export.test.mjs create mode 100644 app/tests/geometry-models.test.mjs create mode 100644 app/tests/paper-bridge.test.mjs create mode 100644 app/tests/physics.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f77b30f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,34 @@ +name: Validate successor candidate + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate application + run: npm run app:validate + + - name: Ensure generated build stays untracked + run: test -z "$(git status --short -- app/dist)" diff --git a/.gitignore b/.gitignore index 2f083fe..6a15c50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store node_modules/ prototype/node_modules/ +app/dist/ diff --git a/ACCEPTANCE_TESTS.md b/ACCEPTANCE_TESTS.md new file mode 100644 index 0000000..61d36c5 --- /dev/null +++ b/ACCEPTANCE_TESTS.md @@ -0,0 +1,71 @@ +# Acceptance tests + +## Automated gate + +From the repository root: + +```sh +npm run app:validate +``` + +The gate must: + +1. parse every `app/src/*.js` runtime module and the OpticalSetup receiver adapter; +2. pass the complete Node regression suite with no failures; +3. build `app/dist/PicSetup-Lab-10x-preview.html`; +4. leave generated `app/dist/` output untracked. + +The existing prototype remains independently checkable: + +```sh +npm run prototype:test +npm run prototype:check +``` + +The `prototype/` tree object must be identical to the base commit. + +## Required model and graph cases + +The automated suite must cover: + +- lossless coherent-network conservation and finite residuals; +- Mach–Zehnder and resonator response over declared sweeps; +- physical and schematic route-length contracts; +- hierarchy collapse/expand response preservation; +- deterministic tuning, tolerance, and pulse fixtures; +- semantic, SAX, gdsfactory, and S-parameter workflows; +- typed-domain compatibility and exclusion of non-optical links from the coherent solve; +- the hybrid paper template's component vocabulary and optical/electrical/RF/control graph; +- `setup-port/1` encoding, URL, manifest, and receiver round trips; +- explicit unresolved spatial state at the OpticalSetup boundary. + +## Browser acceptance journey + +Serve `app/` over HTTP and verify in a current browser: + +1. The application loads without runtime errors. +2. A user can create, connect, move, inspect, save, and reopen a photonic circuit. +3. Wavelength or phase changes produce complementary MZI output behavior. +4. Unsupported or disconnected state produces an explicit warning rather than a fabricated result. +5. Paper Figure Studio opens the included hybrid template. +6. The template contains a chip figure, OpticalSetup boundaries, RF/electrical/control wiring, instrumentation, plot, image placeholder, panel label, and operating-condition annotation. +7. Component label placement, metadata visibility, connection labels, offsets, and arrow direction are editable. +8. SVG export is content cropped and opens as a valid vector document. +9. PNG export has a white or transparent background as selected and a publication-scale resolution. +10. Bridge JSON and the bridge manifest identify `setup-port/1`, units, both power references, omissions, source identity, and target URL. +11. Opening a bridge retains `incomingBridge` and `bridgeSchema=setup-port/1` in the OpticalSetup URL. +12. No action implies that the external OpticalSetup receiver is already deployed. + +## Pull-request acceptance + +Before review: + +- CI is green on the proposed commit. +- The diff is confined to the successor application, governing contracts, documentation, scripts, and validation workflow. +- `prototype/` is unchanged. +- No generated `dist/`, dependency directory, credential, or machine-specific `/mnt/data` path is committed. +- The PR description separates implemented behavior from manual browser evidence, external receiver work, merge, and deployment. + +## Production acceptance + +Merge does not by itself authorize replacement of the public prototype. A production release requires explicit approval, a scoped deployment plan, a live URL/version check, browser smoke on the deployed artifact, and a recorded rollback path. diff --git a/CLAIMS_AND_VALIDATION.md b/CLAIMS_AND_VALIDATION.md new file mode 100644 index 0000000..744b914 --- /dev/null +++ b/CLAIMS_AND_VALIDATION.md @@ -0,0 +1,51 @@ +# Claims and validation + +## Candidate status + +The application under `app/` is a greenfield successor candidate. The current public site continues to use the immutable `prototype/` snapshot unless a separately authorized release replaces it. + +## Supported claims + +Subject to the automated checks and the declared model contract, the candidate may be described as: + +- a browser-based semantic PIC and publication-figure workbench; +- an interactive reduced-order coherent scattering-network simulator; +- a typed hybrid figure editor whose non-optical domains remain outside the optical solve; +- a Paper Figure Studio with content-cropped SVG and high-resolution PNG export; +- a sender and neutral receiver-adapter proposal for the versioned `setup-port/1` OpticalSetup boundary; +- a dependency-light local application that can produce a standalone HTML build. + +The included component models are analytical, imported, pedagogical, or reduced order as their definitions state. Output is suitable for topology exploration, system-level reasoning, teaching, and figure composition within those assumptions. + +## Prohibited claims + +Without additional primary validation, the candidate must not be described as: + +- fabrication ready, mask-layout ready, DRC clean, or foundry qualified; +- a calibrated predictor of device, thermal, electrical, RF, control, biological, behavioral, operational, or performance outcomes; +- a full-vector, multimode, polarization-resolved, nonlinear, or full-wave electromagnetic solver; +- proof that imported S-parameters are passive, causal, correctly referenced, or process valid; +- a deployed OpticalSetup receiver, live synchronization layer, or coupled PicSetup/OpticalSetup solver; +- the current production site merely because local tests or a standalone build pass. + +## Validation matrix + +| Boundary | Evidence | What it establishes | What it does not establish | +| --- | --- | --- | --- | +| JavaScript syntax | `npm run app:check` | Runtime and receiver modules parse in Node | Browser behavior or model correctness | +| Model and interface regression | `npm run app:test` | Declared invariants, exports, typed-domain isolation, template, and bridge round trips | Full browser interaction or external receiver integration | +| Standalone packaging | `npm run app:build` | Multi-file source can be bundled into one local HTML artifact | Public deployment | +| Browser smoke | Serve `app/` and exercise the acceptance journey | Current browser loads and primary UI/export actions work | Cross-browser completeness or production acceptance | +| Prototype preservation | Compare the `prototype/` tree against the base commit | Prior-art snapshot was not modified | Production availability | +| Pull-request CI | GitHub Actions validation on the proposed commit | Clean-run reproducibility in the hosted runner | Merge, release, DNS, or live-site replacement | +| Live verification | Inspect the deployed URL and release identity after an authorized deployment | Public artifact and selected smoke behavior are live | Scientific calibration or user acceptance | + +## Export claims + +Semantic JSON preserves the hybrid graph and bridge manifest. SAX YAML and gdsfactory Python are topology scaffolds restricted to physical optical content; they are not PDK-qualified layouts or substitutes for foundry compact models. + +SVG/PNG output is publication-oriented composition. A stylized plot panel is diagram content unless replaced by exported numerical data. Image panels preserve user-supplied imagery but do not validate its provenance or quantitative meaning. + +## Reporting results + +Validation reports must give exact pass/fail counts, record warnings, and identify skipped manual or external checks. Tests, browser smoke, deployment, OpticalSetup receiver adoption, and public acceptance are separate completion boundaries. diff --git a/INTERFACE_CONTRACT.md b/INTERFACE_CONTRACT.md new file mode 100644 index 0000000..5e335f1 --- /dev/null +++ b/INTERFACE_CONTRACT.md @@ -0,0 +1,66 @@ +# Interface contract + +## Internal semantic graph + +The saved PicSetup document is the source of truth for circuit topology, figure composition, component parameters, units, model versions, and provenance. + +Components expose typed ports. Connections declare one of: + +- `optical` +- `electrical` +- `rf` +- `control` +- `annotation` + +The editor must reject incompatible port/domain combinations. Semantic JSON preserves the complete hybrid graph. SAX- and gdsfactory-oriented handoffs include only physical optical topology and must state that figure-only objects and non-optical links were omitted. + +## OpticalSetup bridge: `setup-port/1` + +The first cross-setup interface is a deterministic, versioned document handoff between a PicSetup optical boundary and OpticalSetup. + +Every payload contains: + +- `schema`: exactly `setup-port/1`; +- a stable bridge ID; +- source application, document, component ID, and component name; +- target application and URL; +- `domain`: exactly `optical`; +- interface kind and direction; +- a declared reference frame and orientation; +- wavelength in nanometres; +- PicSetup-side and OpticalSetup-side power in milliwatts; +- an explicit power reference and coupling efficiency; +- polarization, guided-mode label, boundary phase, and CW/pulsed timing; +- supported capabilities and explicit omissions; +- an optional return URL; +- a creation timestamp. + +Allowed directions are `input`, `output`, and `bidirectional`. Supported interface kinds distinguish fiber mode, chip-edge mode, grating/free-space mode, and free-space beam boundaries. + +The sender encodes JSON as base64url and opens the target with: + +```text +?incomingBridge=&bridgeSchema=setup-port/1 +``` + +Base64url is transport encoding, not encryption, authentication, or authorization. Bridge payloads must not contain secrets. + +## Power and state boundary + +The payload carries both interface-side powers and the coupling efficiency so the reference is inspectable. The receiver must not silently reinterpret one side's power as the other. + +PicSetup exports a scalar guided-mode boundary. It does not invent or transfer beam waist, numerical aperture, sampled transverse field, wavefront, or laboratory coordinates. Those properties remain unresolved until OpticalSetup supplies them. + +Phase is a declared boundary value in radians. CW/pulsed state includes repetition rate in megahertz and pulse duration in picoseconds only when pulsed. `createdAt` records document handoff time; it is not a shared simulation clock. + +## Receiver and ownership + +`app/integrations/opticalsetup/receiver-adapter.js` validates and normalizes the payload into a neutral boundary descriptor. It deliberately stops before scene creation. + +OpticalSetup must map that descriptor into its own component and scene APIs, choose unresolved spatial state, and review/deploy the receiver in Luca Genchi's repository. PicSetup must not copy OpticalSetup private state or claim receiver deployment based on the sender adapter. + +The initial interface is not live synchronization, co-simulation, or a distributed multi-physics solve. Stable bridge identity and a return URL allow later workflows without implying real-time authority. + +## Evolution + +Incompatible changes require a new schema identifier. Receivers must reject unsupported schema or domain values rather than guessing. Optional extensions must preserve the meaning and units of existing fields and keep unknown state explicit. diff --git a/PHOTONICS_MODEL_CONTRACT.md b/PHOTONICS_MODEL_CONTRACT.md new file mode 100644 index 0000000..e4194d1 --- /dev/null +++ b/PHOTONICS_MODEL_CONTRACT.md @@ -0,0 +1,77 @@ +# Photonics model contract + +## Status and ownership + +This contract governs the greenfield candidate under `app/`. It does not describe or modify the immutable production snapshot under `prototype/`. + +PicSetup owns guided photonic topology and reduced-order circuit behavior. OpticalSetup owns free-space optical geometry and propagation. ElectricalSetup owns electrical drive and readout dynamics. A typed boundary may exchange declared state between those domains; it does not transfer model ownership. + +## State and governing equation + +Each modeled optical port carries an incoming complex amplitude `a` and outgoing complex amplitude `b`. Amplitudes are power normalized, so `|a|²` and `|b|²` are optical powers in milliwatts. + +Every compact component contributes a wavelength-dependent scattering relation: + +```text +b = S a + s +``` + +Waveguide connections contribute the propagation operator `C`. The complete connected optical network is solved simultaneously: + +```text +(I - S C) b = s +``` + +The reported linear-system residual and the optical power budget are separate diagnostics. A small algebraic residual is not evidence of a physically complete loss model. + +## Units and conventions + +- Vacuum wavelength is expressed in nanometres. +- Geometric and optical path lengths are expressed in micrometres. +- Optical powers are expressed in milliwatts. +- Phase values in solver state are radians. Display controls may expose degrees when labeled. +- A port represents one declared scalar guided mode, TE0-like by default. +- Component port reference planes are the component connection points represented in the semantic graph. +- Connection direction and component port roles determine the sign and source/sink interpretation; visual arrow direction alone has no solver authority. + +Physical routing numerically integrates the rendered path to obtain geometric length. Schematic routing uses the connection's explicit optical length. The interface must identify which mode is active. + +## Solver admission + +Only connections whose declared domain is `optical` and whose ports are optically compatible enter `C`. + +`electrical`, `rf`, `control`, and `annotation` connections remain part of the semantic document and publication figure, but they do not enter the coherent optical solve. Diagram-only objects never acquire a compact optical model merely because they are visually connected. + +The modeled registry includes analytical or imported reduced-order components such as sources, couplers, splitters, phase and amplitude elements, resonators, filters, detectors, crossings, terminations, grating/edge interfaces, delay lines, imported S-parameter blocks, and reducible passive hierarchy. Each definition must declare its ports, parameters, active-model scope, provenance, assumptions, and rendering metadata. + +## Power accounting + +The solver reports launched, detected, terminated, component-loss, waveguide-loss, and unaccounted power where the selected models expose enough state. Conservation tests use passive, lossless fixtures with declared matched boundaries. Lossy or incomplete circuits must not be described as conservative merely because the numerical residual is small. + +## Validity domain + +The candidate is an interactive compact-model workbench. It is not: + +- a cross-section or full-chip Maxwell solver; +- a fabrication-ready mask-layout or DRC tool; +- a calibrated foundry model library; +- a polarization-resolved or multimode solver unless an imported model explicitly supplies those channels; +- a nonlinear, thermal-crosstalk, RF, SPICE, PCB, control-system, or full-wave time-domain simulator; +- proof of passivity, causality, reference-plane correctness, or fabrication validity for user-imported S-parameters. + +Pulse analysis reconstructs a linear response from sampled frequency-domain compact models. Detector bandwidth may shape the electrical pulse readout but does not alter the CW optical network. + +## Required validation + +Changes to the model layer must retain: + +- finite complex amplitudes and bounded numerical residuals for supported connected fixtures; +- lossless passive power conservation within the tolerance recorded in the test; +- complementary Mach–Zehnder outputs under wavelength or phase variation; +- physical/schematic route-length semantics; +- hierarchy collapse/expand response preservation; +- deterministic seeded tolerance results; +- isolation of non-optical connection domains from the coherent solve; +- explicit warnings for invalid, disconnected, unsupported, or numerically problematic states. + +Passing these checks validates the declared reduced-order behavior only. It does not establish fabrication accuracy, live deployment, or public acceptance. diff --git a/README.md b/README.md index 6e9d381..9edc6ba 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - **Live prototype:** https://picsetup.com/ - **Prototype release verified:** 2026-07-26 (`20260726T002235Z-478235af2650`); check the URL for current availability - **Field:** Integrated photonics -- **Status:** Greenfield planning wrapper with a preserved prototype snapshot +- **Status:** Greenfield successor candidate with a preserved production prototype ## Vision @@ -23,7 +23,26 @@ PicSetup is part of the **Setup Universe**: independently deployed scientific an Read [AGENTS.md](./AGENTS.md) before planning or implementing work. -The present browser demo should not constrain the next architecture. Before substantial implementation, this repository expects `VISION.md`, `PHOTONICS_MODEL_CONTRACT.md`, `INTERFACE_CONTRACT.md`, `CLAIMS_AND_VALIDATION.md`, and `ACCEPTANCE_TESTS.md`. +The present browser demo should not constrain the next architecture. The candidate successor is governed by `VISION.md`, `PHOTONICS_MODEL_CONTRACT.md`, `INTERFACE_CONTRACT.md`, `CLAIMS_AND_VALIDATION.md`, and `ACCEPTANCE_TESTS.md`. + +## Successor candidate + +`app/` contains the dependency-light greenfield workbench candidate. It combines an editable semantic PIC canvas, compact coherent-network analysis, typed cross-domain figure connections, publication SVG/PNG export, and versioned OpticalSetup bridge ports. + +Paper Figure Studio can combine simulated photonic objects with diagram-only instruments, RF/electrical/control wiring, plots, image panels, annotations, and chip boundaries without admitting non-optical links into the coherent solve. Its included hybrid template demonstrates the complete figure workflow. + +The `setup-port/1` bridge is a deterministic document handoff. PicSetup emits an explicit scalar guided-mode boundary; OpticalSetup retains authority over free-space geometry, beam waist, numerical aperture, wavefront, and receiver-side scene construction. The neutral receiver adapter under `app/integrations/opticalsetup/` is a focused integration proposal, not a deployment to Luca Genchi's repository. + +To validate or serve the candidate locally: + +```sh +npm run app:validate +npm run app:serve +``` + +Then open http://127.0.0.1:4174/. + +The candidate is not deployed by the current Pages workflow. Acceptance, merge, and replacement of the live prototype remain separate decisions. ## Prototype model boundary @@ -52,7 +71,7 @@ npm run prototype:serve Then open http://127.0.0.1:4173/?setup=pic. -These commands validate only the legacy prototype. This wrapper intentionally has no future-product test suite until the greenfield implementation begins. +These commands validate only the legacy prototype. The successor has its own tests and build under `app/`; neither suite demonstrates a production deployment. ## Setup Universe diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..68a8c3e --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +dist/ diff --git a/app/CHANGELOG_PAPER_STUDIO.md b/app/CHANGELOG_PAPER_STUDIO.md new file mode 100644 index 0000000..cd42964 --- /dev/null +++ b/app/CHANGELOG_PAPER_STUDIO.md @@ -0,0 +1,113 @@ +# PicSetup Paper Figure Studio — implementation notes + +## What changed + +The uploaded PicSetup Lab source was extended from a compact PIC analysis canvas into a hybrid analysis-and-publication workbench. + +### Paper Figure Studio + +- Dedicated **Paper** entry point in the top bar. +- White or transparent paper preview, optional export grid and port markers. +- Content-cropped SVG export with embedded styles. +- High-resolution PNG export; the included example exports at 2400 × 1796 px. +- Editable component-label placement, metadata visibility, X/Y label offsets, and rotation. +- Editable connection labels, X/Y offsets, and start/end/bidirectional arrows. +- Background chip boundary, panel letters, annotations, image/micrograph panels, and stylized plot panels. +- Included **Hybrid PIC paper figure** template combining a PIC, OpticalSetup boundaries, RF/electrical/control wiring, instrumentation, plot, micrograph placeholder, and operating-condition callout. + +### New photonic and system vocabulary + +New compact or interface elements include: + +- edge coupler; +- OpticalSetup bridge port; +- spiral delay line; +- four-channel AWG; +- thermo-optic heater with an electrical control terminal. + +New publication/system elements include: + +- fiber array; +- polarization controller; +- optical instrument; +- RF source; +- electrical amplifier/TIA; +- oscilloscope; +- controller; +- generic laser/receiver/driver/processor/sample system block; +- chip boundary; +- image panel; +- plot panel; +- text annotation; +- panel label. + +### Typed graph and solver boundary + +Connections now have explicit `optical`, `electrical`, `rf`, `control`, or `annotation` domains. Ports are checked for compatibility. Only optical links enter the coherent scattering solve; the other domains remain editable and exportable without being misinterpreted as waveguides. + +The semantic netlist preserves the full hybrid figure graph. SAX and gdsfactory handoffs intentionally contain only physical optical topology. + +### OpticalSetup bridge + +PicSetup now emits the versioned `setup-port/1` contract. A bridge records: + +- stable bridge identity; +- source document/component and target application; +- interface direction and physical kind; +- wavelength; +- PicSetup-side and OpticalSetup-side powers with an explicit power reference; +- coupling efficiency; +- polarization and guided mode; +- boundary phase; +- CW/pulsed state, repetition rate, and pulse duration; +- capabilities, omissions, reference frame, and return URL. + +Opening a bridge launches: + +```text +https://opticalsetup.com/sketch/?incomingBridge=&bridgeSchema=setup-port/1 +``` + +The source also includes a framework-independent receiver parser at: + +```text +integrations/opticalsetup/receiver-adapter.js +``` + +That adapter validates and normalizes the handoff without inventing beam waist, NA, wavefront, or laboratory coordinates. OpticalSetup still needs to map the normalized descriptor into its own scene/component creation API and deploy that receiver. The current integration is a deterministic document handoff, not live synchronization or a distributed multi-physics solve. + +## Validation + +- JavaScript syntax checks passed for every runtime and receiver-adapter module. +- Full Node regression suite: **19/19 passed**. +- Browser smoke test passed for: + - loading the 19-object / 14-link hybrid template; + - connection figure controls; + - component figure controls; + - OpticalSetup bridge actions; + - Paper Figure Studio; + - SVG export; + - PNG export; + - bridge-manifest export; + - generated OpticalSetup URL and query payload. +- No browser runtime errors were produced in the smoke run. The only warnings came from intentionally loading the standalone file in a null-origin test page, where `localStorage` is unavailable; normal HTTP serving avoids that condition. + +## Run + +```bash +python -m http.server 8000 +``` + +Open `http://localhost:8000`. + +Rebuild the portable one-file version with: + +```bash +python build_single.py +``` + +Run tests with: + +```bash +node --test tests/*.test.mjs +``` diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..fa7530a --- /dev/null +++ b/app/README.md @@ -0,0 +1,233 @@ +# PicSetup — PIC analysis and publication-figure workbench + +PicSetup is a dependency-light, mobile-first environment for integrated photonics. It combines an editable semantic PIC canvas, a simultaneous complex scattering solver, an interactive analysis Lab, reusable hierarchy, and a **Paper Figure Studio** for building the system-level figures commonly used in papers: a chip schematic connected to external optics, RF/electrical instrumentation, control blocks, plots, micrographs, labels, and operating-condition callouts. + +The product boundary is deliberate. PicSetup owns guided-wave photonic topology and compact models. Free-space laboratory optics remain the domain of [OpticalSetup](https://opticalsetup.com/sketch/), connected through explicit, versioned optical bridge ports rather than by pretending that every domain uses one solver. + +Everything runs in the browser. A backend is not required. + +## Run locally + +Serve this directory with any static web server: + +```bash +python -m http.server 8000 +``` + +Open `http://localhost:8000`. + +Build the completely self-contained preview: + +```bash +python build_single.py +``` + +The portable build is written to `dist/PicSetup-Lab-10x-preview.html`. The multi-file application remains the preferred form for development and PWA installation. + +## Primary workflows + +### Circuit analysis + +1. Build or open a PIC on the canvas. +2. Pin a detector, optical probe, port, waveguide, or circuit-level measurement. +3. Open **PicSetup Lab**. +4. Sweep an active optical parameter, tune selected knobs toward a target, run tolerance analysis, or reconstruct a linear pulse response. +5. Apply an operating point back to the live circuit, preserve a baseline, or export the semantic state for another tool. + +### Paper figures + +1. Open **Paper** and load the included **Hybrid PIC paper figure** template, or add figure elements to an existing circuit. +2. Combine simulated PIC components with diagram-only instruments, system blocks, typed links, panel letters, text, plots, and image panels. +3. Edit component-label placement, metadata visibility, connection labels, direction arrows, panel dimensions, and the figure background. +4. Export a cropped publication SVG or high-resolution PNG. Semantic JSON remains available when the figure must stay editable. + +## What is implemented + +### Semantic circuit canvas + +- Touch, pen, mouse, keyboard, and mobile-first interaction. +- Component placement, direct dragging, freehand waveguide routing, undo/redo, deletion, local save, autosave, JSON import/export, and URL sharing. +- Pinch/wheel zoom, pan, fit, component focus, larger invisible touch targets, and semantic zoom that reveals detail progressively. +- Explicit **Physical** and **Schematic** routing modes. + - Physical: the rendered cubic curve is numerically integrated and used as optical length. + - Schematic: the drawing is visual and each waveguide owns an explicit optical length. +- Optional physical snap grid, micrometre scale, exact route length, phase, minimum-curvature radius, and user-declared bend-radius validation. +- Solid single-color CW illumination, optional nonphysical direction tracers, and pulse-envelope packets whose length and spacing are controlled separately. + +### Compact-model circuit engine + +Every optical port carries incoming and outgoing complex, power-normalized modal amplitudes: + +```text +b = S a + s +``` + +Waveguides form the wavelength-dependent connection operator `C`, and the complete optical circuit is solved simultaneously: + +```text +(I - S C) b = s +``` + +The coherent model registry includes: + +- laser source, photodetector, non-perturbing optical probe, and matched termination; +- phase shifter, electro-optic modulator, thermo-optic heater, and attenuator; +- directional coupler, 2×2 MMI, Y splitter, and waveguide crossing; +- add-drop ring, Bragg filter, arrayed waveguide grating, and spiral delay line; +- grating coupler, edge coupler, and OpticalSetup bridge boundary; +- wavelength-indexed generic complex S-parameter block; +- hierarchical block reduced live from a stored passive subcircuit. + +Each simulated definition owns its ports, parameters, active-model scope, provenance, assumptions, compact scattering model, rendering metadata, and inspector contract. + +### Paper Figure Studio + +Paper Figure Studio adds a publication-composition layer without weakening the physics boundary. + +- White or transparent paper canvas, optional grid and port visibility, content-aware export crop, SVG export, and high-resolution PNG export. +- Resizable chip boundary with background layering. +- Panel letters, free text annotations, operating-condition callouts, and movable/hideable component metadata. +- Configurable connection labels, label offsets, and start/end/bidirectional arrows. +- Image panels for local optical micrographs, SEMs, photographs, or renderings, with contain/crop controls. +- Stylized plot panels for figure composition; real numerical traces should still be exported from PicSetup Lab. +- External figure vocabulary including fiber arrays, polarization controllers, optical instruments, RF generators, amplifiers/TIAs, oscilloscopes, controllers, and generic laser/receiver/driver/processor/sample blocks. +- Included hybrid template combining free-space/fiber handoffs, a PIC transmitter/receiver, RF drive, thermal bias, photocurrent readout, control, a plot, a micrograph placeholder, and paper annotations. + +Diagram-only figure objects are declared as such, excluded from hierarchy reduction, and never silently substituted for a compact optical model. + +### Typed cross-domain graph + +Connections carry a declared domain: + +- `optical` — guided/fiber/free-space optical boundary links; +- `electrical` — voltage/current/readout wiring; +- `rf` — RF/coax drive links; +- `control` — logical control and automation; +- `annotation` — explanatory figure relationships. + +Port compatibility is checked while wiring. Only optical links enter the coherent network solve. Electrical, RF, control, and annotation links remain editable, inspectable, and exportable, but cannot accidentally become optical waveguides. + +### OpticalSetup bridge ports + +The **OpticalSetup port** is a real typed boundary, not a decorative hyperlink. It supports: + +- OpticalSetup → PicSetup, PicSetup → OpticalSetup, or bidirectional direction; +- free-space, fiber, chip-edge, or vertical-grating interface kind; +- wavelength, explicitly referenced power on both sides of the interface, coupling efficiency, polarization, guided mode, boundary phase, and CW/pulsed timing; +- bridge ID, target OpticalSetup URL, return URL, capabilities, and explicit omissions. + +PicSetup serializes the handoff as the versioned `setup-port/1` contract and opens the target editor with: + +```text +?incomingBridge=&bridgeSchema=setup-port/1 +``` + +It can also copy or download the bridge JSON and export a manifest containing every OpticalSetup bridge in the figure. + +**Integration boundary:** this source completes the PicSetup sender/exporter side. The live OpticalSetup editor must add an `incomingBridge` adapter before the URL automatically creates a corresponding fiber/free-space boundary object. Until that receiver is deployed, the target opens with the complete payload preserved in the URL; this is not live synchronization or a distributed multi-physics solve. + +### Trust and diagnostics + +- Solver residual is reported separately from optical power accounting. +- Power budget distinguishes launched, detected, terminated, component loss, waveguide loss, and unaccounted power. +- Model warnings identify invalid parameters, unsupported operating conditions, and optional bend-radius violations. +- Inspector fields state whether a parameter is active in CW, pulse, electrical, global, visual, or diagram-only scope. +- Parameter links keep compatible knobs synchronized during direct edits, sweeps, tuning, and tolerance runs. +- No hidden geometry substitution: grouping, rendering, and solving preserve declared optical-length contracts. + +### PicSetup Lab + +#### Sweep + +- Sweep wavelength or any active CW compact-model/waveguide parameter. +- Plot multiple pinned measurements simultaneously. +- Inspect a live operating-point cursor and apply it back to the circuit. +- Save baseline traces and export CSV. +- Derive maximum/minimum transmission, insertion loss, extinction ratio, 3 dB bandwidth, free spectral range, phase excursion, and group delay where the data support them. +- Compare paired outputs through imbalance and contrast metrics. + +#### Tune + +- Maximize, minimize, or target a pinned measurement. +- Select one to four compatible design knobs. +- Add a wavelength robustness window so tuning optimizes more than a single nominal point. +- Preview the proposed circuit and explicitly apply it to the canvas. + +#### Tolerance + +- Seeded Monte Carlo analysis with absolute or relative Gaussian variation. +- Yield against a threshold and criterion. +- Distribution histogram, best/worst sampled results, deterministic ±3σ corner search, and correlation-based sensitivity ranking. +- Click a sensitivity result to highlight the responsible component or route. + +#### Pulse + +For a linear circuit, PicSetup builds a transform-limited Gaussian source spectrum, solves the complex transfer function over frequency, reconstructs the temporal optical envelope, and applies detector bandwidth to produce an electrical response. It reports delay, output pulse width, peak transmission, and repetition period. + +This is spectral compact-model reconstruction—not nonlinear propagation or a full-wave time-domain field solve. + +### Hierarchy and reuse + +- Multi-select passive optical components and collapse them into a reusable external scattering block. +- Expand the block back into its internal graph. +- Preserve circuit response across collapse and expansion, including boundary optical lengths. +- Export a block as semantic JSON. +- Non-optical links and publication-only objects are rejected from compact optical hierarchy rather than being dropped silently. +- Included templates: hybrid PIC paper figure, Mach–Zehnder interferometer, add-drop ring, delay interferometer, and filter bank. + +### Workflow exports + +- Portable PicSetup experiment JSON. +- Semantic netlist JSON with model provenance, assumptions, parameters, typed ports, endpoints, hierarchy, links, domain counts, figure-object counts, and bridge manifest. +- SAX-oriented YAML scaffold containing only physical optical topology. +- Starter gdsfactory Python topology containing only physical optical topology. +- Complex S-parameter import from JSON or CSV. +- Auto-layout for imported semantic netlists. + +The SAX and gdsfactory outputs deliberately omit figure-only objects and non-optical wiring. They are handoff scaffolds, not a PDK-qualified layout, DRC-clean routing solution, or substitute for foundry compact models. + +## Architecture + +```text +src/models.js simulated and diagram-only component/port registry +src/physics.js typed network assembly, route contracts, templates, hierarchy +src/bridge.js setup-port/1 OpticalSetup payloads, URLs, and manifests +src/geometry.js shared cubic paths, adaptive length, curvature/radius +src/analysis.js measurements, sweeps, metrics, tuning, tolerances, pulses +src/plot.js interactive Canvas Lab plots +src/export.js semantic/SAX/gdsfactory/S-parameter workflow exports +src/circuit.js SVG renderer, paper layers, typed links, semantic zoom +src/coupler-view.js progressive local directional-coupler physics views +src/app.js interaction state, inspectors, Paper Studio, persistence/PWA +integrations/opticalsetup/ receiver parser and neutral boundary adapter +``` + +The application has no runtime package dependency. Browser modules are kept separate during development and converted to Blob-backed modules by `build_single.py` for the one-file preview. + +## Declared physics and integration limits + +- One scalar TE₀-like optical channel per declared port unless an imported model defines otherwise. +- Compact circuit models, not a cross-section or full-chip Maxwell solve. +- Directional-coupler local field views use compact transverse profiles and visibly magnify the carrier oscillation. +- Default compact models are analytical approximations intended for topology and system-level reasoning; provenance is shown in the inspector. +- Bend radius is validated only when the user supplies a minimum. PicSetup does not invent bend loss from an unspecified process stack. +- Detector bandwidth acts in pulse/electrical analysis; it does not alter the CW optical network. +- Pulse reconstruction assumes a linear time-invariant circuit over the sampled spectrum. +- Imported S-parameter quality, passivity, causality, and reference-plane correctness remain the user’s responsibility. +- Figure-only electrical/RF/control blocks are not SPICE, RF, PCB, or control-system simulations. +- The OpticalSetup bridge exchanges a typed boundary state; it does not transfer a transverse field, automatically infer beam waist/NA/wavefront, or synchronize two solvers in real time. +- Large embedded images are best shared through JSON/SVG or the source file rather than very long URL fragments. + +## Tests + +Run the complete test suite: + +```bash +node --test tests/*.test.mjs +``` + +The suite covers complex-network conservation and residuals, exact rendered/model route length, curvature, physical/schematic routing, component registry dimensions, hierarchy response preservation, MZI and ring sweeps, phase/group-delay metrics, linked parameters, target tuning, deterministic Monte Carlo and corners, pulse reconstruction, semantic/SAX/gdsfactory exports, S-parameter import, auto-layout, typed-domain solver isolation, the hybrid paper template, and `setup-port/1` bridge round trips. + +## PWA + +When served over HTTP(S), the service worker caches the application shell and all source modules—including `src/bridge.js`—for offline reuse. Installability depends on normal browser PWA requirements and origin security rules. diff --git a/app/build_single.py b/app/build_single.py new file mode 100644 index 0000000..c79c8f4 --- /dev/null +++ b/app/build_single.py @@ -0,0 +1,51 @@ +from pathlib import Path +import json, re + +root = Path(__file__).resolve().parent +html = (root / 'index.html').read_text() +css = (root / 'styles.css').read_text() +order = ['complex', 'geometry', 'models', 'physics', 'bridge', 'analysis', 'plot', 'export', 'circuit', 'coupler-view', 'app'] +mods = {name: (root / 'src' / f'{name}.js').read_text() for name in order} + +html = re.sub(r'\s*]*>', '', html) +html = re.sub(r'\s*]*>', '', html) +html = html.replace(' ', f' ') +html = html.replace(' ', '') +source_json = json.dumps(mods, ensure_ascii=False).replace('', '<\\/script>') + +# Create module Blob URLs in dependency order and replace each static relative import +# with the generated URL before the dependent Blob is created. +bootstrap = f''' ''' +html = html.replace('', bootstrap + '\n') +dist = root / 'dist' +dist.mkdir(exist_ok=True) +out = dist / 'PicSetup-Lab-10x-preview.html' +out.write_text(html) + +print(out, out.stat().st_size) diff --git a/app/docs/PAPER_FIGURE_STUDIO.md b/app/docs/PAPER_FIGURE_STUDIO.md new file mode 100644 index 0000000..4380ba6 --- /dev/null +++ b/app/docs/PAPER_FIGURE_STUDIO.md @@ -0,0 +1,34 @@ +# Paper Figure Studio and OpticalSetup bridges + +## Why this layer exists + +PIC papers often combine several visual domains in one figure: the on-chip circuit, free-space or fiber coupling, electrical/RF drive and readout, control software, plots, and microscope/SEM panels. PicSetup now represents that complete composition while preserving a strict boundary between simulation and illustration. + +## Simulated versus diagram-only objects + +Simulated photonic objects participate in the coherent compact-model network. Diagram-only objects carry typed ports and publication metadata, render and export normally, but do not enter the optical solver. This prevents an RF cable, controller, image panel, or generic instrument from silently becoming a waveguide. + +## Figure workflow + +1. Open **Paper** from the top bar. +2. Load **Hybrid PIC paper figure**, or add elements from the component library. +3. Select a component to edit its figure label, label placement, metadata visibility, offsets, and rotation. +4. Select a typed connection to edit its label, direction arrow, and label offsets. +5. Attach a local image to a micrograph/image panel from its inspector. +6. Export a cropped SVG or high-resolution PNG. + +The plot panel is a composition placeholder. Quantitative plots should be exported from PicSetup Lab and inserted as images or reconstructed downstream. + +## OpticalSetup handoff + +An OpticalSetup port emits `setup-port/1`, containing: + +- stable bridge ID and source document/component identity; +- target application and return URL; +- direction and physical interface kind; +- wavelength, explicit PicSetup-side and OpticalSetup-side powers, coupling efficiency, polarization, guided mode, phase, and CW/pulsed timing; +- supported capabilities and unsupported properties. + +The URL uses `incomingBridge` plus `bridgeSchema`. The companion receiver parser is in `integrations/opticalsetup/receiver-adapter.js`. + +The contract is intentionally narrower than a universal simulator. PicSetup owns the scalar guided-mode boundary. OpticalSetup owns beam waist, numerical aperture, wavefront, spatial coordinates, and free-space propagation. diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..f9afcd2 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..b8e9eed --- /dev/null +++ b/app/index.html @@ -0,0 +1,304 @@ + + + + + + + + + + + PicSetup Lab — Integrated Photonics Workbench + + +
+
+ +
+ PicSetup + Untitled experiment +
+
+ + + + + +
+
+ +
+
+
+ + + +
+ Converged0.0 ms +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Launched1.000 mW + Detected1.000 mW + + Solver residual1.0e−12 +
+ + + +
+ + + +
+
+ + + + +
+ + + + +
+ +
+
+ +
CW steady stateControl changes visualization speed, not optical carrier time.
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ +
Directional couplerCircuit → local component physics
+
+ 10× · power flow + +
+
+ +
+
+
+ + + + + +
+ +
IN1.000 mW
+
THRU0.500 mW
+
CROSS0.500 mW
+
Normalized power 0 → 1
+
+ + Current component +
+
+ +
Pinch or scroll to inspect deeperZoom level changes the visible model transparently.
+
+
+ + +
+
+
+ + +
+
+
ProjectExperiment actions
+
+
+
+ +
+ + + + diff --git a/app/integrations/opticalsetup/README.md b/app/integrations/opticalsetup/README.md new file mode 100644 index 0000000..161ce2d --- /dev/null +++ b/app/integrations/opticalsetup/README.md @@ -0,0 +1,35 @@ +# OpticalSetup receiver adapter + +PicSetup opens OpticalSetup with a versioned optical boundary in the query string: + +```text +https://opticalsetup.com/sketch/?incomingBridge=…&bridgeSchema=setup-port/1 +``` + +`receiver-adapter.js` is a framework-agnostic parser for that handoff. It validates the schema and optical domain, then converts the payload into a neutral OpticalSetup-side boundary descriptor. + +A minimal integration in OpticalSetup is: + +```js +import { consumeIncomingBridge } from './receiver-adapter.js'; + +consumeIncomingBridge((descriptor, originalPayload) => { + // Replace this with OpticalSetup's real scene/component creation API. + const element = createExternalOpticalBoundary({ + name: descriptor.name, + role: descriptor.role, + wavelengthNm: descriptor.wavelengthNm, + powerMw: descriptor.opticalPowerMw, + polarization: descriptor.polarization, + sourceMode: descriptor.temporal.mode + }); + + element.setupBridge = descriptor.bridge; + selectElement(element.id); + return element; +}); +``` + +The receiver intentionally leaves beam waist, numerical aperture, wavefront, and laboratory coordinates unresolved. Those properties belong to OpticalSetup and cannot be reconstructed honestly from PicSetup's scalar guided-mode boundary. + +The adapter does not implement live synchronization. The first integration slice is a deterministic, inspectable document handoff with a return URL and stable bridge ID. diff --git a/app/integrations/opticalsetup/receiver-adapter.js b/app/integrations/opticalsetup/receiver-adapter.js new file mode 100644 index 0000000..5dd1821 --- /dev/null +++ b/app/integrations/opticalsetup/receiver-adapter.js @@ -0,0 +1,101 @@ +/** + * Framework-agnostic receiving adapter for PicSetup's setup-port/1 URL handoff. + * + * OpticalSetup can import this module, call readIncomingBridge() on startup, + * convert the payload with opticalSetupBoundaryFromBridge(), and then map the + * returned descriptor into its own scene/component API. + */ + +export const SETUP_PORT_SCHEMA = 'setup-port/1'; + +function decodeBase64UrlText(value) { + const source = String(value ?? '').trim(); + if (!source) throw new Error('Missing incomingBridge payload.'); + const padded = source.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((source.length + 3) % 4); + const binary = atob(padded); + return new TextDecoder().decode(Uint8Array.from(binary, character => character.charCodeAt(0))); +} + +function finite(value, fallback = null) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; +} + +export function decodeIncomingBridge(encoded) { + const payload = JSON.parse(decodeBase64UrlText(encoded)); + if (!payload || payload.schema !== SETUP_PORT_SCHEMA) { + throw new Error(`Unsupported setup bridge schema: ${payload?.schema ?? 'missing'}`); + } + if (payload.domain !== 'optical') throw new Error(`Unsupported bridge domain: ${payload.domain ?? 'missing'}`); + if (!payload.bridgeId) throw new Error('Bridge payload has no bridgeId.'); + return payload; +} + +export function readIncomingBridge(url = globalThis.location?.href) { + if (!url) return null; + const parsed = new URL(String(url), 'https://opticalsetup.com/sketch/'); + const encoded = parsed.searchParams.get('incomingBridge'); + if (!encoded) return null; + const requestedSchema = parsed.searchParams.get('bridgeSchema'); + if (requestedSchema && requestedSchema !== SETUP_PORT_SCHEMA) { + throw new Error(`URL requests unsupported bridge schema: ${requestedSchema}`); + } + return decodeIncomingBridge(encoded); +} + +/** + * Turn the cross-product payload into a neutral OpticalSetup-side descriptor. + * This function intentionally does not invent beam waist, NA, wavefront, or + * laboratory coordinates because PicSetup exports a scalar guided-mode boundary. + */ +export function opticalSetupBoundaryFromBridge(payload) { + if (!payload || payload.schema !== SETUP_PORT_SCHEMA) throw new Error('Expected a setup-port/1 payload.'); + const state = payload.state ?? {}; + const direction = ['input', 'output', 'bidirectional'].includes(payload.direction) ? payload.direction : 'output'; + const role = direction === 'output' ? 'source' : direction === 'input' ? 'sink' : 'bidirectional'; + const kind = String(payload.kind ?? 'free-space-beam'); + + return { + id: `picsetup:${String(payload.bridgeId)}`, + name: String(payload.source?.componentName || 'PicSetup optical boundary'), + type: kind === 'fiber-mode' ? 'fiber-interface' : 'free-space-interface', + role, + wavelengthNm: finite(state.wavelengthNm, 1550), + opticalPowerMw: Math.max(0, finite(state.opticalSetupSidePowerMw, finite(state.opticalPowerMw, 0))), + polarization: String(state.polarization || 'unspecified'), + guidedMode: String(state.guidedMode || 'unspecified'), + temporal: { + mode: state.sourceMode === 'pulsed' ? 'pulsed' : 'cw', + repetitionRateMHz: state.sourceMode === 'pulsed' ? finite(state.repetitionRateMHz) : null, + pulseDurationPs: state.sourceMode === 'pulsed' ? finite(state.pulseDurationPs) : null + }, + referenceFrame: payload.referenceFrame ?? null, + unresolvedSpatialState: { + beamWaistUm: null, + numericalAperture: null, + wavefront: 'unspecified', + laboratoryCoordinates: 'unresolved' + }, + bridge: { + schema: payload.schema, + bridgeId: String(payload.bridgeId), + sourceApplication: String(payload.source?.application || 'PicSetup'), + sourceDocument: String(payload.source?.documentName || ''), + sourceComponentId: String(payload.source?.componentId || ''), + returnUrl: payload.returnUrl || null, + capabilities: payload.capabilities ?? {}, + omissions: Array.isArray(payload.omissions) ? [...payload.omissions] : [] + } + }; +} + +/** + * Minimal startup helper for OpticalSetup. `createBoundary` should be the one + * application-specific function that places/selects the corresponding object. + */ +export function consumeIncomingBridge(createBoundary, { url = globalThis.location?.href } = {}) { + if (typeof createBoundary !== 'function') throw new TypeError('createBoundary must be a function.'); + const payload = readIncomingBridge(url); + if (!payload) return null; + const descriptor = opticalSetupBoundaryFromBridge(payload); + return createBoundary(descriptor, payload); +} diff --git a/app/manifest.webmanifest b/app/manifest.webmanifest new file mode 100644 index 0000000..84646a5 --- /dev/null +++ b/app/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "PicSetup Lab — Integrated Photonics Workbench", + "short_name": "PicSetup", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#030607", + "theme_color": "#05090a", + "description": "Think through, inspect, tune, explain, share, and hand off compact-model integrated photonic circuits.", + "categories": ["education", "productivity", "utilities"], + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/app/src/analysis.js b/app/src/analysis.js new file mode 100644 index 0000000..ad53ffa --- /dev/null +++ b/app/src/analysis.js @@ -0,0 +1,661 @@ +import { C, abs, abs2, arg, mul, expi } from './complex.js'; +import { defaultParams, getDefinition, listDefinitions, parameterDefinition } from './models.js'; +import { connectionLengthUm, solveCircuit, isOpticalConnection } from './physics.js'; + +const clone = value => structuredClone(value); +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const EPS = 1e-15; + +export function defaultMeasurementSpecs(circuit) { + const saved = Array.isArray(circuit.lab?.measurements) ? circuit.lab.measurements : []; + if (saved.length) return clone(saved); + const detectors = circuit.components.filter(component => getDefinition(component.type).detector || getDefinition(component.type).probe); + if (detectors.length) return detectors.map((component, index) => ({ + id: `m-${component.id}-${index}`, + kind: 'component', componentId: component.id, metric: 'power', + label: `${component.name} power` + })); + return [{ id: 'm-total-detected', kind: 'circuit', metric: 'detectedPower', label: 'Detected power' }]; +} + +export function measurementLabel(circuit, spec) { + if (spec.label) return spec.label; + if (spec.kind === 'component') { + const component = circuit.components.find(item => item.id === spec.componentId); + return `${component?.name ?? spec.componentId} ${spec.metric ?? 'power'}`; + } + if (spec.kind === 'port') { + const component = circuit.components.find(item => item.id === spec.componentId); + return `${component?.name ?? spec.componentId}:${spec.portId} ${spec.direction ?? 'incoming'} ${spec.metric ?? 'power'}`; + } + if (spec.kind === 'connection') return `${spec.connectionId} ${spec.metric ?? 'power'}`; + return spec.metric ?? 'Measurement'; +} + +export function readMeasurement(circuit, solve, spec) { + const fallback = { value: 0, complex: C(), unit: '', label: measurementLabel(circuit, spec) }; + if (!solve?.ok) return fallback; + if (spec.kind === 'circuit') { + if (spec.metric === 'sourcePower') return { ...fallback, value: solve.sourcePowerMw, unit: 'mW' }; + if (spec.metric === 'solverResidual') return { ...fallback, value: solve.residual, unit: '' }; + if (spec.metric === 'balanceError') return { ...fallback, value: solve.powerBudget?.balanceErrorFraction ?? 0, unit: '' }; + return { ...fallback, value: solve.detectedPowerMw, unit: 'mW' }; + } + if (spec.kind === 'connection') { + const connection = solve.connections?.get(spec.connectionId); + if (!connection) return fallback; + if (spec.metric === 'phase') return { ...fallback, value: arg(connection.deliveredToB ?? C()), complex: connection.deliveredToB ?? C(), unit: 'rad' }; + if (spec.metric === 'loss') return { ...fallback, value: connection.lossMw ?? 0, unit: 'mW' }; + const direction = spec.direction === 'reverse' ? 'B' : 'A'; + const complex = direction === 'A' ? (connection.deliveredToB ?? C()) : (connection.deliveredToA ?? C()); + return { ...fallback, value: direction === 'A' ? connection.deliveredToBMw : connection.deliveredToAMw, complex, unit: 'mW' }; + } + const result = solve.components?.get(spec.componentId); + if (!result) return fallback; + if (spec.kind === 'port') { + const port = result.ports?.[spec.portId]; + if (!port) return fallback; + const direction = spec.direction === 'outgoing' ? 'outgoing' : 'incoming'; + const complex = port[direction] ?? C(); + if (spec.metric === 'phase') return { ...fallback, value: arg(complex), complex, unit: 'rad' }; + if (spec.metric === 'amplitude') return { ...fallback, value: abs(complex), complex, unit: '√mW' }; + return { ...fallback, value: abs2(complex), complex, unit: 'mW' }; + } + + const ports = Object.values(result.ports ?? {}); + const preferredPort = result.ports?.in ?? ports[0]; + const complex = preferredPort?.incoming ?? C(); + if (spec.metric === 'phase') return { ...fallback, value: arg(complex), complex, unit: 'rad' }; + if (spec.metric === 'amplitude') return { ...fallback, value: abs(complex), complex, unit: '√mW' }; + if (spec.metric === 'current') return { ...fallback, value: result.photocurrentMa ?? 0, complex, unit: 'mA' }; + return { ...fallback, value: result.measurementMw ?? result.opticalPowerMw ?? 0, complex, unit: 'mW' }; +} + +export function pinMeasurement(circuit, spec) { + circuit.lab ??= {}; + circuit.lab.measurements ??= []; + const key = `${spec.kind}:${spec.componentId ?? spec.connectionId ?? ''}:${spec.portId ?? ''}:${spec.metric ?? ''}:${spec.direction ?? ''}`; + const exists = circuit.lab.measurements.some(item => `${item.kind}:${item.componentId ?? item.connectionId ?? ''}:${item.portId ?? ''}:${item.metric ?? ''}:${item.direction ?? ''}` === key); + if (!exists) circuit.lab.measurements.push({ id: spec.id ?? `m-${Math.random().toString(36).slice(2, 8)}`, ...clone(spec) }); + return circuit.lab.measurements; +} + +function globalParameter(circuit) { + return { + id: 'global:wavelengthNm', kind: 'global', key: 'wavelengthNm', label: 'Global wavelength λ', unit: 'nm', + min: 1200, max: 1700, step: 0.1, tolerance: 0.02, + get: target => Number(target.settings?.wavelengthNm ?? 1550), + set: (target, value) => { + target.settings ??= {}; + target.settings.wavelengthNm = value; + target.components.filter(component => component.type === 'source').forEach(source => { source.params.wavelengthNm = value; }); + } + }; +} + +function rawSweepParameters(circuit) { + const parameters = [globalParameter(circuit)]; + for (const component of circuit.components) { + const definition = getDefinition(component.type); + for (const parameter of definition.parameters ?? []) { + if (parameter.sweepable === false || parameter.scope === 'visual' || parameter.target === 'global') continue; + parameters.push({ + id: `component:${component.id}:${parameter.key}`, + kind: 'component', componentId: component.id, componentType: component.type, key: parameter.key, + label: `${component.name} · ${parameter.label}`, unit: parameter.unit ?? '', + min: Number(parameter.min), max: Number(parameter.max), step: Number(parameter.step), + tolerance: Number(parameter.tolerance ?? 0.01), scope: parameter.scope ?? 'cw', + get: target => Number(target.components.find(item => item.id === component.id)?.params?.[parameter.key] ?? parameter.default), + set: (target, value) => { + const item = target.components.find(candidate => candidate.id === component.id); + if (item) item.params[parameter.key] = value; + if (component.type === 'source' && parameter.key === 'wavelengthNm') globalParameter(target).set(target, value); + } + }); + } + } + for (const connection of circuit.connections) { + if (!isOpticalConnection(connection, circuit.components)) continue; + parameters.push({ + id: `connection:${connection.id}:neff`, kind: 'connection', connectionId: connection.id, key: 'neff', + label: `${connection.id} · effective index`, unit: '', min: 1, max: 4.5, step: 0.001, tolerance: 0.002, + get: target => Number(target.connections.find(item => item.id === connection.id)?.params?.neff ?? 2.42), + set: (target, value) => { const item = target.connections.find(candidate => candidate.id === connection.id); if (item) item.params.neff = value; } + }); + parameters.push({ + id: `connection:${connection.id}:lossDbPerCm`, kind: 'connection', connectionId: connection.id, key: 'lossDbPerCm', + label: `${connection.id} · propagation loss`, unit: 'dB/cm', min: 0, max: 50, step: 0.05, tolerance: 0.05, + get: target => Number(target.connections.find(item => item.id === connection.id)?.params?.lossDbPerCm ?? 2), + set: (target, value) => { const item = target.connections.find(candidate => candidate.id === connection.id); if (item) item.params.lossDbPerCm = value; } + }); + parameters.push({ + id: `connection:${connection.id}:length`, kind: 'connection', connectionId: connection.id, key: 'schematicLengthUm', + label: `${connection.id} · optical length`, unit: 'µm', min: 0, max: 10000, step: 0.1, tolerance: 0.01, + get: target => { + const item = target.connections.find(candidate => candidate.id === connection.id); + return connectionLengthUm(item, target.components, target.settings); + }, + set: (target, value) => { + const item = target.connections.find(candidate => candidate.id === connection.id); + if (item) { item.params.routingMode = 'schematic'; item.params.schematicLengthUm = value; } + } + }); + } + return parameters; +} + +export function parameterLinkFor(circuit, parameterId) { + return (circuit.parameterLinks ?? []).find(link => (link.members ?? []).includes(parameterId)) ?? null; +} + +function directParameter(circuit, parameterId) { + return rawSweepParameters(circuit).find(parameter => parameter.id === parameterId) ?? globalParameter(circuit); +} + +function setParameterWithLinks(circuit, parameterId, value) { + const descriptors = new Map(rawSweepParameters(circuit).map(parameter => [parameter.id, parameter])); + const parameter = descriptors.get(parameterId) ?? globalParameter(circuit); + const numeric = Number(value); + const bounded = Number.isFinite(numeric) ? clamp(numeric, Number.isFinite(parameter.min) ? parameter.min : numeric, Number.isFinite(parameter.max) ? parameter.max : numeric) : numeric; + parameter.set(circuit, bounded); + const link = parameterLinkFor(circuit, parameterId); + if (!link) return; + for (const memberId of link.members ?? []) { + if (memberId === parameterId) continue; + const member = descriptors.get(memberId); + if (!member) continue; + const mapped = clamp(bounded, Number.isFinite(member.min) ? member.min : bounded, Number.isFinite(member.max) ? member.max : bounded); + member.set(circuit, mapped); + } +} + +export function createParameterLink(circuit, memberIds, { name = null } = {}) { + const unique = [...new Set(memberIds ?? [])]; + if (unique.length < 2) throw new Error('Choose at least two parameters to link.'); + const descriptors = new Map(rawSweepParameters(circuit).map(parameter => [parameter.id, parameter])); + const members = unique.map(id => descriptors.get(id)); + if (members.some(parameter => !parameter)) throw new Error('One linked parameter is no longer available.'); + const unit = members[0].unit ?? ''; + const scope = members[0].scope ?? 'cw'; + if (members.some(parameter => (parameter.unit ?? '') !== unit || (parameter.scope ?? 'cw') !== scope)) throw new Error('Linked parameters must share the same unit and active model scope.'); + circuit.parameterLinks ??= []; + circuit.parameterLinks = circuit.parameterLinks.map(link => ({ ...link, members: (link.members ?? []).filter(id => !unique.includes(id)) })).filter(link => link.members.length >= 2); + const id = `link-${Math.random().toString(36).slice(2, 8)}`; + const link = { id, name: name ?? `Linked ${members[0].label}`, unit, scope, members: unique }; + circuit.parameterLinks.push(link); + setParameterWithLinks(circuit, unique[0], members[0].get(circuit)); + return link; +} + +export function unlinkParameter(circuit, parameterId) { + circuit.parameterLinks = (circuit.parameterLinks ?? []).map(link => ({ ...link, members: (link.members ?? []).filter(id => id !== parameterId) })).filter(link => link.members.length >= 2); +} + +export function listSweepParameters(circuit) { + return rawSweepParameters(circuit).map(parameter => { + const link = parameterLinkFor(circuit, parameter.id); + return { + ...parameter, + linkId: link?.id ?? null, + linkName: link?.name ?? null, + set: (target, value) => setParameterWithLinks(target, parameter.id, value) + }; + }); +} + +export function resolveParameter(circuit, parameterId) { + return listSweepParameters(circuit).find(parameter => parameter.id === parameterId) ?? globalParameter(circuit); +} + +function axisValues(start, stop, points, scaleMode = 'linear') { + const n = clamp(Math.round(points), 2, 5001); + if (scaleMode === 'log' && start > 0 && stop > 0) { + const a = Math.log(start), b = Math.log(stop); + return Array.from({ length: n }, (_, index) => Math.exp(a + (b - a) * index / (n - 1))); + } + return Array.from({ length: n }, (_, index) => start + (stop - start) * index / (n - 1)); +} + +export function sweepCircuit(circuit, sweep, measurements = defaultMeasurementSpecs(circuit)) { + const started = performance.now(); + const parameter = resolveParameter(circuit, sweep.parameterId); + const values = axisValues(Number(sweep.start ?? parameter.min), Number(sweep.stop ?? parameter.max), Number(sweep.points ?? 301), sweep.scale ?? 'linear'); + const working = clone(circuit); + const traces = measurements.map(spec => ({ spec: clone(spec), label: measurementLabel(circuit, spec), unit: '', values: [], complex: [] })); + const solveTimes = []; + let failures = 0; + for (const value of values) { + parameter.set(working, value); + const solve = solveCircuit(working); + solveTimes.push(solve.elapsedMs ?? 0); + if (!solve.ok) failures += 1; + traces.forEach(trace => { + const measurement = readMeasurement(working, solve, trace.spec); + trace.values.push(measurement.value); + trace.complex.push(measurement.complex); + trace.unit = measurement.unit; + }); + } + const currentValue = parameter.get(circuit); + return { + kind: 'sweep', parameter: { ...parameter, get: undefined, set: undefined }, x: values, traces, + currentValue, currentIndex: values.reduce((best, value, index) => Math.abs(value - currentValue) < Math.abs(values[best] - currentValue) ? index : best, 0), + metrics: traces.map(trace => analyzeTrace(values, trace.values, { sourcePowerMw: solveCircuit(circuit).sourcePowerMw, unit: trace.unit, complex: trace.complex, xParameterId: parameter.id })), + comparison: traces.length >= 2 ? analyzePair(values, traces[0].values, traces[1].values) : null, + failures, elapsedMs: performance.now() - started, solveTimeMeanMs: solveTimes.reduce((sum, value) => sum + value, 0) / Math.max(1, solveTimes.length) + }; +} + +export async function sweepCircuitAsync(circuit, sweep, measurements = defaultMeasurementSpecs(circuit), onProgress = null) { + const started = performance.now(); + const parameter = resolveParameter(circuit, sweep.parameterId); + const values = axisValues(Number(sweep.start ?? parameter.min), Number(sweep.stop ?? parameter.max), Number(sweep.points ?? 301), sweep.scale ?? 'linear'); + const working = clone(circuit); + const traces = measurements.map(spec => ({ spec: clone(spec), label: measurementLabel(circuit, spec), unit: '', values: [], complex: [] })); + let failures = 0; + let solveTime = 0; + for (let index = 0; index < values.length; index += 1) { + parameter.set(working, values[index]); + const solve = solveCircuit(working); + solveTime += solve.elapsedMs ?? 0; + if (!solve.ok) failures += 1; + traces.forEach(trace => { + const measurement = readMeasurement(working, solve, trace.spec); + trace.values.push(measurement.value); + trace.complex.push(measurement.complex); + trace.unit = measurement.unit; + }); + if (index % 32 === 31) { + onProgress?.((index + 1) / values.length); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + const baseSolve = solveCircuit(circuit); + const currentValue = parameter.get(circuit); + const result = { + kind: 'sweep', parameter: { ...parameter, get: undefined, set: undefined }, x: values, traces, + currentValue, currentIndex: values.reduce((best, value, index) => Math.abs(value - currentValue) < Math.abs(values[best] - currentValue) ? index : best, 0), + metrics: traces.map(trace => analyzeTrace(values, trace.values, { sourcePowerMw: baseSolve.sourcePowerMw, unit: trace.unit, complex: trace.complex, xParameterId: parameter.id })), + comparison: traces.length >= 2 ? analyzePair(values, traces[0].values, traces[1].values) : null, + failures, elapsedMs: performance.now() - started, solveTimeMeanMs: solveTime / Math.max(1, values.length) + }; + onProgress?.(1); + return result; +} + +function peakIndices(values) { + const peaks = []; + for (let index = 1; index < values.length - 1; index += 1) { + if (values[index] > values[index - 1] && values[index] >= values[index + 1]) peaks.push(index); + } + return peaks; +} + +function crossingX(x0, y0, x1, y1, threshold) { + if (Math.abs(y1 - y0) < EPS) return (x0 + x1) / 2; + return x0 + (threshold - y0) * (x1 - x0) / (y1 - y0); +} + +function unwrapPhase(values) { + if (!values.length) return []; + const out = [values[0]]; + for (let index = 1; index < values.length; index += 1) { + let delta = values[index] - values[index - 1]; + while (delta > Math.PI) delta -= 2 * Math.PI; + while (delta < -Math.PI) delta += 2 * Math.PI; + out.push(out[index - 1] + delta); + } + return out; +} + +function derivative(x, y) { + return y.map((_value, index) => { + const left = Math.max(0, index - 1); + const right = Math.min(y.length - 1, index + 1); + if (left === right) return 0; + const dx = x[right] - x[left]; + return Math.abs(dx) < EPS ? 0 : (y[right] - y[left]) / dx; + }); +} + +export function analyzeTrace(x, y, { sourcePowerMw = 1, unit = 'mW', complex = null, xParameterId = '' } = {}) { + if (!x.length || x.length !== y.length) return {}; + let maxIndex = 0, minIndex = 0; + for (let index = 1; index < y.length; index += 1) { + if (y[index] > y[maxIndex]) maxIndex = index; + if (y[index] < y[minIndex]) minIndex = index; + } + const max = y[maxIndex], min = y[minIndex]; + const threshold = max / 2; + let left = x[0], right = x.at(-1); + for (let index = maxIndex; index > 0; index -= 1) { + if ((y[index] - threshold) * (y[index - 1] - threshold) <= 0) { left = crossingX(x[index], y[index], x[index - 1], y[index - 1], threshold); break; } + } + for (let index = maxIndex; index < y.length - 1; index += 1) { + if ((y[index] - threshold) * (y[index + 1] - threshold) <= 0) { right = crossingX(x[index], y[index], x[index + 1], y[index + 1], threshold); break; } + } + const peaks = peakIndices(y).filter(index => y[index] > min + 0.25 * (max - min)); + const spacings = peaks.slice(1).map((index, i) => x[index] - x[peaks[i]]).filter(Number.isFinite); + const fsr = spacings.length ? spacings.sort((a, b) => a - b)[Math.floor(spacings.length / 2)] : null; + const extinctionDb = unit === 'mW' ? 10 * Math.log10(Math.max(max, EPS) / Math.max(min, EPS)) : null; + const insertionLossDb = unit === 'mW' ? -10 * Math.log10(Math.max(max, EPS) / Math.max(sourcePowerMw, EPS)) : null; + const hasComplex = Array.isArray(complex) && complex.length === x.length; + const phaseRad = hasComplex ? unwrapPhase(complex.map(value => arg(value ?? C()))) : []; + let groupDelayPs = null; + if (phaseRad.length && /wavelengthNm$/.test(xParameterId)) { + const speedOfLight = 299792458; + const omega = x.map(wavelengthNm => 2 * Math.PI * speedOfLight / Math.max(1e-18, wavelengthNm * 1e-9)); + groupDelayPs = derivative(omega, phaseRad).map(value => -value * 1e12); + } + const finiteGroupDelay = groupDelayPs?.filter(Number.isFinite) ?? []; + return { + max, min, maxX: x[maxIndex], minX: x[minIndex], maxIndex, minIndex, + extinctionDb, insertionLossDb, bandwidth3dB: right - left, bandwidthLeft: left, bandwidthRight: right, + fsr, peakCount: peaks.length, + mean: y.reduce((sum, value) => sum + value, 0) / y.length, + rms: Math.sqrt(y.reduce((sum, value) => sum + value * value, 0) / y.length), + phaseRad, + phaseAtMaxRad: phaseRad[maxIndex] ?? null, + phaseExcursionRad: phaseRad.length ? Math.max(...phaseRad) - Math.min(...phaseRad) : null, + groupDelayPs, + groupDelayAtMaxPs: groupDelayPs?.[maxIndex] ?? null, + groupDelayMeanPs: finiteGroupDelay.length ? finiteGroupDelay.reduce((sum, value) => sum + value, 0) / finiteGroupDelay.length : null, + groupDelayMinPs: finiteGroupDelay.length ? Math.min(...finiteGroupDelay) : null, + groupDelayMaxPs: finiteGroupDelay.length ? Math.max(...finiteGroupDelay) : null + }; +} + +export function analyzePair(x, first, second) { + const total = first.map((value, index) => value + (second[index] ?? 0)); + const imbalance = first.map((value, index) => (value - (second[index] ?? 0)) / Math.max(total[index], EPS)); + const contrast = first.map((value, index) => 10 * Math.log10((value + EPS) / ((second[index] ?? 0) + EPS))); + return { + totalMin: Math.min(...total), totalMax: Math.max(...total), + imbalanceMax: Math.max(...imbalance.map(Math.abs)), + contrastMaxDb: Math.max(...contrast), contrastMinDb: Math.min(...contrast), + crossoverX: x[imbalance.reduce((best, value, index) => Math.abs(value) < Math.abs(imbalance[best]) ? index : best, 0)] + }; +} + +function objectiveAt(circuit, config) { + const wavelengths = []; + const center = Number(circuit.settings?.wavelengthNm ?? 1550); + const window = Math.max(0, Number(config.robustWindowNm ?? 0)); + const count = window > 0 ? 7 : 1; + for (let index = 0; index < count; index += 1) wavelengths.push(center + (count === 1 ? 0 : -window + 2 * window * index / (count - 1))); + const primary = []; + const secondary = []; + for (const wavelengthNm of wavelengths) { + const solve = solveCircuit(circuit, { wavelengthNm }); + primary.push(readMeasurement(circuit, solve, config.measurement).value); + if (config.secondaryMeasurement) secondary.push(readMeasurement(circuit, solve, config.secondaryMeasurement).value); + } + if (config.goal === 'minimize') return { score: -Math.max(...primary), value: primary.reduce((sum, value) => sum + value, 0) / primary.length }; + if (config.goal === 'target') { + const error = primary.reduce((sum, value) => sum + Math.abs(value - Number(config.targetValue ?? 0)), 0) / primary.length; + return { score: -error, value: primary.reduce((sum, value) => sum + value, 0) / primary.length }; + } + if (config.goal === 'contrast' && secondary.length) { + const contrasts = primary.map((value, index) => 10 * Math.log10((value + EPS) / (secondary[index] + EPS))); + return { score: Math.min(...contrasts), value: contrasts.reduce((sum, value) => sum + value, 0) / contrasts.length }; + } + return { score: Math.min(...primary), value: primary.reduce((sum, value) => sum + value, 0) / primary.length }; +} + +export async function optimizeCircuit(circuit, config, onProgress = null) { + const working = clone(circuit); + const knobs = (config.parameterIds ?? []).slice(0, 4).map(id => resolveParameter(working, id)); + if (!knobs.length) throw new Error('Choose at least one tunable parameter.'); + const before = objectiveAt(working, config); + let best = before; + let evaluations = 1; + const passes = clamp(Number(config.passes ?? 4), 1, 8); + const samples = clamp(Number(config.samplesPerPass ?? 33), 9, 101); + + for (let pass = 0; pass < passes; pass += 1) { + for (let knobIndex = 0; knobIndex < knobs.length; knobIndex += 1) { + const knob = knobs[knobIndex]; + const current = knob.get(working); + const fullRange = knob.max - knob.min; + const localHalf = pass === 0 ? fullRange / 2 : fullRange / (2 * 3 ** pass); + const start = pass === 0 ? knob.min : clamp(current - localHalf, knob.min, knob.max); + const stop = pass === 0 ? knob.max : clamp(current + localHalf, knob.min, knob.max); + let localBest = { ...best, knobValue: current }; + for (let index = 0; index < samples; index += 1) { + const value = start + (stop - start) * index / (samples - 1); + knob.set(working, value); + const candidate = objectiveAt(working, config); + evaluations += 1; + if (candidate.score > localBest.score) localBest = { ...candidate, knobValue: value }; + if (evaluations % 24 === 0) { + onProgress?.((pass * knobs.length + knobIndex + index / samples) / (passes * knobs.length)); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + knob.set(working, localBest.knobValue); + best = localBest; + } + } + const after = objectiveAt(working, config); + onProgress?.(1); + return { + circuit: working, + before, after, + settings: knobs.map(knob => ({ id: knob.id, label: knob.label, value: knob.get(working), unit: knob.unit })), + evaluations + }; +} + +function mulberry32(seed) { + let value = seed >>> 0; + return () => { + value += 0x6D2B79F5; + let t = value; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} +function normal(random) { + const u = Math.max(EPS, random()); + const v = random(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); +} +function correlation(x, y) { + const mx = x.reduce((sum, value) => sum + value, 0) / x.length; + const my = y.reduce((sum, value) => sum + value, 0) / y.length; + let xy = 0, xx = 0, yy = 0; + for (let index = 0; index < x.length; index += 1) { + const dx = x[index] - mx, dy = y[index] - my; + xy += dx * dy; xx += dx * dx; yy += dy * dy; + } + return xy / Math.sqrt(Math.max(EPS, xx * yy)); +} +function quantile(values, q) { + const sorted = [...values].sort((a, b) => a - b); + const index = (sorted.length - 1) * q; + const lo = Math.floor(index), hi = Math.ceil(index); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (index - lo); +} +function histogram(values, binCount = 24) { + const min = Math.min(...values), max = Math.max(...values); + const width = Math.max(EPS, (max - min) / binCount); + const bins = Array.from({ length: binCount }, (_, index) => ({ x0: min + index * width, x1: min + (index + 1) * width, count: 0 })); + values.forEach(value => bins[Math.min(binCount - 1, Math.floor((value - min) / width))].count += 1); + return bins; +} + +export async function runTolerance(circuit, config, onProgress = null) { + const parameters = (config.parameters ?? []).slice(0, 8).map(item => { + const descriptor = resolveParameter(circuit, item.id ?? item.parameterId); + return { descriptor, sigma: Number(item.sigma ?? descriptor.tolerance ?? 0.01), relative: item.relative !== false }; + }); + if (!parameters.length) throw new Error('Choose at least one tolerance parameter.'); + const samples = clamp(Number(config.samples ?? 500), 20, 10000); + const random = mulberry32(Number(config.seed ?? 24681357)); + const values = []; + const deviations = parameters.map(() => []); + const nominalSolve = solveCircuit(circuit); + const nominal = readMeasurement(circuit, nominalSolve, config.measurement).value; + + for (let sample = 0; sample < samples; sample += 1) { + const working = clone(circuit); + parameters.forEach((item, index) => { + const base = item.descriptor.get(circuit); + const z = normal(random); + const delta = item.relative ? base * item.sigma * z : item.sigma * z; + item.descriptor.set(working, clamp(base + delta, item.descriptor.min, item.descriptor.max)); + deviations[index].push(delta); + }); + const solve = solveCircuit(working); + values.push(readMeasurement(working, solve, config.measurement).value); + if (sample % 40 === 39) { + onProgress?.((sample + 1) / samples); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + + const sensitivities = parameters.map((item, index) => ({ + id: item.descriptor.id, + label: item.descriptor.label, + correlation: correlation(deviations[index], values), + sigma: item.sigma, + relative: item.relative + })).sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation)); + + const threshold = Number(config.threshold ?? nominal * 0.9); + const pass = config.criterion === 'max' ? values.filter(value => value <= threshold).length : values.filter(value => value >= threshold).length; + + const cornerParameters = parameters.slice(0, 7); + const corners = []; + const cornerCount = 2 ** cornerParameters.length; + for (let mask = 0; mask < cornerCount; mask += 1) { + const working = clone(circuit); + const corner = []; + cornerParameters.forEach((item, index) => { + const base = item.descriptor.get(circuit); + const sign = mask & (1 << index) ? 1 : -1; + const delta = item.relative ? base * item.sigma * 3 * sign : item.sigma * 3 * sign; + const value = clamp(base + delta, item.descriptor.min, item.descriptor.max); + item.descriptor.set(working, value); + corner.push({ id: item.descriptor.id, value }); + }); + const solve = solveCircuit(working); + corners.push({ value: readMeasurement(working, solve, config.measurement).value, settings: corner }); + } + corners.sort((a, b) => a.value - b.value); + onProgress?.(1); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const std = Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, values.length - 1)); + return { + kind: 'tolerance', nominal, values, mean, std, + p05: quantile(values, 0.05), p50: quantile(values, 0.5), p95: quantile(values, 0.95), + min: Math.min(...values), max: Math.max(...values), + threshold, criterion: config.criterion ?? 'min', yield: pass / values.length, + sensitivities, histogram: histogram(values, Number(config.bins ?? 24)), + worstCorner: corners[0], bestCorner: corners.at(-1), samples + }; +} + +function dftAt(spectrum, frequencies, timePs) { + let re = 0, im = 0; + for (let index = 0; index < spectrum.length; index += 1) { + const phase = 2 * Math.PI * frequencies[index] * timePs; + const c = Math.cos(phase), s = Math.sin(phase); + re += spectrum[index].re * c - spectrum[index].im * s; + im += spectrum[index].re * s + spectrum[index].im * c; + } + return C(re / spectrum.length, im / spectrum.length); +} +function centroid(time, intensity) { + const total = intensity.reduce((sum, value) => sum + value, 0); + return time.reduce((sum, value, index) => sum + value * intensity[index], 0) / Math.max(EPS, total); +} +function fwhm(time, intensity) { + const max = Math.max(...intensity); + const threshold = max / 2; + const indices = intensity.map((value, index) => value >= threshold ? index : -1).filter(index => index >= 0); + if (indices.length < 2) return 0; + return time[indices.at(-1)] - time[indices[0]]; +} +function lowPassIntensity(intensity, dtPs, bandwidthGhz) { + const bandwidthPerPs = Math.max(0, bandwidthGhz) * 0.001; + if (bandwidthPerPs <= 0) return intensity.map(() => 0); + const alpha = 1 - Math.exp(-2 * Math.PI * bandwidthPerPs * dtPs); + const out = []; + let state = 0; + for (const value of intensity) { state += alpha * (value - state); out.push(state); } + return out; +} + +export async function simulatePulse(circuit, measurement, options = {}, onProgress = null) { + const source = circuit.components.find(component => component.type === 'source'); + if (!source) throw new Error('A source is required for pulse analysis.'); + const durationPs = Math.max(0.05, Number(options.durationPs ?? source.params?.pulseDurationPs ?? 12)); + const repetitionRateMHz = Math.max(0.001, Number(options.repetitionRateMHz ?? source.params?.repetitionRateMHz ?? 80)); + const centerWavelengthNm = Number(circuit.settings?.wavelengthNm ?? source.params?.wavelengthNm ?? 1550); + const samples = clamp(Math.round(Number(options.samples ?? source.params?.spectralSamples ?? 192)), 64, 512); + const cUmPerPs = 299.792458; + const centerFrequencyThz = cUmPerPs / (centerWavelengthNm / 1000); + const spanThz = Math.max(0.08, Math.min(centerFrequencyThz * 0.2, Number(options.spanThz ?? 8 / durationPs))); + const df = spanThz / samples; + const offsets = Array.from({ length: samples }, (_, index) => (index - samples / 2) * df); + const inputSpectrum = []; + const outputSpectrum = []; + + for (let index = 0; index < samples; index += 1) { + const offset = offsets[index]; + const frequency = centerFrequencyThz + offset; + const wavelengthNm = cUmPerPs / frequency * 1000; + const solve = solveCircuit(circuit, { wavelengthNm }); + const transfer = readMeasurement(circuit, solve, measurement).complex; + const envelope = Math.exp(-(Math.PI ** 2) * durationPs ** 2 * offset ** 2 / (2 * Math.log(2))); + const input = C(envelope, 0); + inputSpectrum.push(input); + outputSpectrum.push(mul(input, transfer)); + if (index % 24 === 23) { + onProgress?.((index + 1) / (samples * 2)); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + + const timeWindowPs = 1 / df; + const dtPs = timeWindowPs / samples; + const timePs = Array.from({ length: samples }, (_, index) => (index - samples / 2) * dtPs); + const inputField = []; + const outputField = []; + for (let index = 0; index < samples; index += 1) { + inputField.push(dftAt(inputSpectrum, offsets, timePs[index])); + outputField.push(dftAt(outputSpectrum, offsets, timePs[index])); + if (index % 32 === 31) { + onProgress?.(0.5 + (index + 1) / (samples * 2)); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + const inputIntensityRaw = inputField.map(abs2); + const normalization = Math.max(EPS, Math.max(...inputIntensityRaw)); + const inputIntensity = inputIntensityRaw.map(value => value / normalization); + const outputIntensity = outputField.map(abs2).map(value => value / normalization); + const detector = measurement.kind === 'component' ? circuit.components.find(component => component.id === measurement.componentId && getDefinition(component.type).detector) : null; + const electricalIntensity = detector ? lowPassIntensity(outputIntensity, dtPs, Number(detector.params?.bandwidthGhz ?? 20)) : [...outputIntensity]; + const inputCenter = centroid(timePs, inputIntensity); + const outputCenter = centroid(timePs, outputIntensity); + const inputFwhm = fwhm(timePs, inputIntensity); + const outputFwhm = fwhm(timePs, outputIntensity); + onProgress?.(1); + return { + kind: 'pulse', timePs, inputIntensity, outputIntensity, electricalIntensity, + offsetsThz: offsets, inputSpectrum: inputSpectrum.map(abs2), outputSpectrum: outputSpectrum.map(abs2), + metrics: { + delayPs: outputCenter - inputCenter, + inputFwhmPs: inputFwhm, + outputFwhmPs: outputFwhm, + broadening: inputFwhm > 0 ? outputFwhm / inputFwhm : null, + peakTransmission: Math.max(...outputIntensity), + electricalPeak: Math.max(...electricalIntensity), + repetitionPeriodPs: 1e6 / repetitionRateMHz, + spectralSpanThz: spanThz + }, + centerWavelengthNm, durationPs, repetitionRateMHz, samples, detectorBandwidthGhz: detector ? Number(detector.params?.bandwidthGhz ?? 20) : null + }; +} diff --git a/app/src/app.js b/app/src/app.js new file mode 100644 index 0000000..9cbaa81 --- /dev/null +++ b/app/src/app.js @@ -0,0 +1,1894 @@ +import { CircuitRenderer, componentBounds } from './circuit.js'; +import { + DEFAULT_WORLD_TO_UM, solveCircuit, makeDemoCircuit, makeBlankCircuit, makeTemplate, CIRCUIT_TEMPLATES, + validateConnection, nearestPort, componentPorts, portPosition, getConnectionPoints, + connectionLengthUm, waveguideTransmission, couplerCoefficients, localCouplerState, + createHierarchicalBlock, expandHierarchicalBlock, connectionDomain, isOpticalConnection +} from './physics.js'; +import { + getDefinition, listDefinitions, getPorts, defaultParams, formatParameterValue +} from './models.js'; +import { abs2, formatPhase } from './complex.js'; +import { CouplerView, couplerViewMeta } from './coupler-view.js'; +import { + defaultMeasurementSpecs, measurementLabel, readMeasurement, pinMeasurement, + listSweepParameters, resolveParameter, parameterLinkFor, createParameterLink, unlinkParameter, + sweepCircuitAsync, optimizeCircuit, runTolerance, simulatePulse +} from './analysis.js'; +import { LabPlot } from './plot.js'; +import { + semanticNetlist, toSaxYAML, toGdsfactoryPython, downloadText, + parseSParameterData, makeSParameterComponent, autoLayoutCircuit, importSemanticNetlist +} from './export.js'; +import { buildOpticalBridgePayload, opticalSetupBridgeUrl, bridgeManifest } from './bridge.js'; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const deepClone = value => structuredClone(value); +const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback; +const escapeHTML = value => String(value).replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); +const humanNumber = (value, digits = 3) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '—'; +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + +const dom = { + app: $('#app'), svg: $('#circuitSvg'), viewport: $('#viewport'), projectName: $('#projectName'), + undoButton: $('#undoButton'), redoButton: $('#redoButton'), shareButton: $('#shareButton'), menuButton: $('#menuButton'), moreButton: $('#moreButton'), + labButton: $('#labButton'), labPinCount: $('#labPinCount'), + modelBadge: $('#modelBadge'), routingButton: $('#routingButton'), routingBadge: $('#routingBadge'), routingDetail: $('#routingDetail'), figureButton: $('#figureButton'), + solverHealth: $('#solverHealth'), solverMeta: $('#solverMeta'), sourceReadout: $('#sourceReadout'), detectedReadout: $('#detectedReadout'), + balanceReadout: $('#balanceReadout'), powerBudgetReadout: $('#powerBudgetReadout'), powerBudgetButton: $('#powerBudgetButton'), + transportModeLabel: $('#transportModeLabel'), transportModeDetail: $('#transportModeDetail'), + emptyHint: $('#emptyHint'), loadDemoInline: $('#loadDemoInline'), inspector: $('#inspector'), inspectorEmpty: $('#inspectorEmpty'), inspectorContent: $('#inspectorContent'), + componentTray: $('#componentTray'), componentCatalog: $('#componentCatalog'), zoomOut: $('#zoomOut'), zoomIn: $('#zoomIn'), zoomReset: $('#zoomReset'), + zoomReadout: $('#zoomReadout'), fitButton: $('#fitButton'), pauseButton: $('#pauseButton'), timeKnob: $('#timeKnob'), speedReadout: $('#speedReadout'), + transportIcon: $('#transportIcon'), scaleRuler: $('#scaleRuler'), scaleReadout: $('#scaleReadout'), toast: $('#toast'), + physicsDialog: $('#physicsDialog'), closePhysics: $('#closePhysics'), couplerCanvas: $('#couplerCanvas'), miniMapSvg: $('#miniMapSvg'), depthBadge: $('#depthBadge'), + fieldScaleLabel: $('#fieldScaleLabel'), couplerModelName: $('#couplerModelName'), couplerInputLabel: $('#couplerInputLabel'), couplerThroughLabel: $('#couplerThroughLabel'), + couplerCrossLabel: $('#couplerCrossLabel'), gapRange: $('#gapRange'), gapOutput: $('#gapOutput'), lengthRange: $('#lengthRange'), lengthOutput: $('#lengthOutput'), + wavelengthRange: $('#wavelengthRange'), wavelengthOutput: $('#wavelengthOutput'), couplingAngleMetric: $('#couplingAngleMetric'), crossPowerMetric: $('#crossPowerMetric'), + throughPhaseMetric: $('#throughPhaseMetric'), crossPhaseMetric: $('#crossPhaseMetric'), physicsInfoButton: $('#physicsInfoButton'), equationCard: $('#equationCard'), + sheetDialog: $('#sheetDialog'), sheetTitle: $('#sheetTitle'), sheetSubtitle: $('#sheetSubtitle'), sheetContent: $('#sheetContent'), + labPanel: $('#labPanel'), labClose: $('#labClose'), labMinimize: $('#labMinimize'), labTabs: $('#labTabs'), labSubtitle: $('#labSubtitle'), labPlot: $('#labPlot'), labPlotToolbar: $('#labPlotToolbar'), + labControls: $('#labControls'), labStatus: $('#labStatus'), labProgressText: $('#labProgressText'), labProgress: $('#labProgress'), labProgressBar: $('#labProgressBar'), + labStatusDot: $('#labStatusDot'), labMetrics: $('#labMetrics') +}; + +const renderer = new CircuitRenderer(dom.svg); +const couplerView = new CouplerView(dom.couplerCanvas, { onViewChange: view => setPhysicsView(view, false) }); + +const runtime = { + circuit: null, + solve: null, + selection: null, + tool: 'select', + placeType: 'source', + connectStart: null, + previewPoints: [], + previewValid: null, + interaction: null, + pointers: new Map(), + pinch: null, + history: [], + future: [], + continuousSnapshot: null, + toastTimer: null, + paused: false, + speed: 1, + animationStart: performance.now(), + selectedCouplerId: null, + physicsView: 'power', + dirty: false, + lastSavedAt: 0, + pendingAutosave: null, + operationToken: 0, + highlightIds: [], + lab: { + open: false, + compact: false, + tab: 'sweep', + busy: false, + result: null, + resultTab: null, + baselines: [], + cursorIndex: null, + selectedMeasurementId: null, + sweep: { parameterId: 'global:wavelengthNm', start: 1500, stop: 1600, points: 301, scale: 'linear' }, + tune: { goal: 'maximize', targetValue: 0.5, robustWindowNm: 0, parameterIds: [], secondaryMeasurementId: null }, + tolerance: { samples: 500, threshold: null, criterion: 'min', seed: 24681357, parameters: [] }, + pulse: { durationPs: 12, repetitionRateMHz: 80, samples: 192 }, + optimizationResult: null, + toleranceResult: null, + pulseResult: null + } +}; + +const labPlot = new LabPlot(dom.labPlot, { + onCursor(index, pinned) { + runtime.lab.cursorIndex = index; + if (!pinned || runtime.lab.result?.kind !== 'sweep') return; + applySweepPoint(index); + } +}); + +function labelForType(type) { return getDefinition(type).label ?? type; } +function iconForType(type) { return getDefinition(type).glyph ?? '•'; } +function primarySource() { return runtime.circuit?.components?.find(component => component.type === 'source' || (getDefinition(component.type).sourceBoundary && ['input', 'bidirectional'].includes(component.params?.direction))) ?? null; } +function currentSourceMode(source = primarySource()) { return source?.params?.sourceMode === 'pulsed' ? 'pulsed' : 'cw'; } +function currentCwVisualization(source = primarySource()) { return source?.params?.cwVisualization === 'tracers' ? 'tracers' : 'solid'; } +function snapWorldPoint(point, circuit = runtime.circuit) { + const settings = circuit?.settings ?? {}; + if (settings.routingMode !== 'physical' || !settings.snapToGrid) return { x: point.x, y: point.y }; + const spacing = Math.max(1e-6, finite(settings.gridUm, 5) / Math.max(1e-6, finite(settings.worldToUm, DEFAULT_WORLD_TO_UM))); + return { x: Math.round(point.x / spacing) * spacing, y: Math.round(point.y / spacing) * spacing }; +} + +function formatPower(value) { + if (!Number.isFinite(value)) return '—'; + if (value >= .1) return `${value.toFixed(3)} mW`; + if (value >= .001) return `${(value * 1000).toFixed(2)} µW`; + return `${(value * 1e6).toFixed(1)} nW`; +} +function formatResidual(value) { + if (!Number.isFinite(value)) return '—'; + if (value === 0) return '0'; + return value.toExponential(1).replace('e-', 'e−').replace('e+', 'e+'); +} +function modeSummary(source = primarySource()) { + if (!source) return { label: 'No source', detail: 'Place a laser to launch a coherent field.' }; + if (currentSourceMode(source) === 'pulsed') { + return { + label: 'Pulse-envelope view', + detail: `${finite(source.params?.pulseDurationPs, 12).toFixed(1)} ps envelope · ${finite(source.params?.repetitionRateMHz, 80).toFixed(0)} MHz train · animation timing compressed` + }; + } + if (currentCwVisualization(source) === 'tracers') return { label: 'CW steady state + tracers', detail: 'Tracers show direction only; they are not physical pulses.' }; + return { label: 'CW steady state', detail: 'Continuous single-color field. Use Lab → Pulse for spectral temporal reconstruction.' }; +} + +function sanitizedSettings(input = {}) { + const routingMode = input.routingMode === 'schematic' ? 'schematic' : 'physical'; + return { + wavelengthNm: clamp(finite(input.wavelengthNm, 1550), 200, 10000), + canvasZoom: clamp(finite(input.canvasZoom, 1), .35, 4), + canvasPanX: clamp(finite(input.canvasPanX, 0), -4000, 4000), + canvasPanY: clamp(finite(input.canvasPanY, 0), -4000, 4000), + routingMode, + worldToUm: clamp(finite(input.worldToUm, DEFAULT_WORLD_TO_UM), 1e-4, 100), + gridUm: clamp(finite(input.gridUm, 5), .01, 10000), + snapToGrid: Boolean(input.snapToGrid), + minBendRadiusUm: clamp(finite(input.minBendRadiusUm, 0), 0, 100000), + figureMode: input.figureMode === 'paper' ? 'paper' : 'workbench', + paperBackground: input.paperBackground === 'transparent' ? 'transparent' : 'white', + showPortsInFigure: input.showPortsInFigure !== false, + showGridInFigure: Boolean(input.showGridInFigure), + figurePadding: clamp(finite(input.figurePadding, 36), 0, 300) + }; +} + +function safeCircuit(input) { + if (!input || typeof input !== 'object') return makeDemoCircuit(); + const definitions = new Set(listDefinitions().map(definition => definition.type)); + const components = []; + const usedIds = new Set(); + for (const [index, raw] of (Array.isArray(input.components) ? input.components.slice(0, 500) : []).entries()) { + const type = definitions.has(raw?.type) ? raw.type : 'generic'; + let id = String(raw?.id ?? `${getDefinition(type).prefix ?? type}-${index + 1}`).slice(0, 100); + while (usedIds.has(id)) id = `${id}-${index + 1}`; + usedIds.add(id); + let params; + try { params = { ...defaultParams(type), ...deepClone(raw?.params ?? {}) }; } + catch { params = { ...defaultParams(type) }; } + components.push({ + id, type, + name: String(raw?.name ?? labelForType(type)).slice(0, 120), + x: clamp(finite(raw?.x, 200), -5000, 5000), + y: clamp(finite(raw?.y, 200), -5000, 5000), + rotation: clamp(finite(raw?.rotation, 0), -3600, 3600), + params + }); + } + const byId = new Map(components.map(component => [component.id, component])); + const occupied = new Set(); + const connections = []; + for (const [index, raw] of (Array.isArray(input.connections) ? input.connections.slice(0, 1000) : []).entries()) { + const a = { component: String(raw?.a?.component ?? ''), port: String(raw?.a?.port ?? '') }; + const b = { component: String(raw?.b?.component ?? ''), port: String(raw?.b?.port ?? '') }; + const aComponent = byId.get(a.component), bComponent = byId.get(b.component); + if (!aComponent || !bComponent) continue; + const aPort = getPorts(aComponent).find(port => port.id === a.port); + const bPort = getPorts(bComponent).find(port => port.id === b.port); + if (!aPort || !bPort || (aPort.role ?? 'optical') !== (bPort.role ?? 'optical')) continue; + const domain = ['optical', 'electrical', 'rf', 'control', 'annotation'].includes(raw?.domain) ? raw.domain : (aPort.role ?? 'optical'); + const keyA = `${a.component}:${a.port}`, keyB = `${b.component}:${b.port}`; + if (occupied.has(keyA) || occupied.has(keyB) || keyA === keyB) continue; + occupied.add(keyA); occupied.add(keyB); + let params; + const defaults = domain === 'optical' ? { lossDbPerCm: 2, neff: 2.42 } : {}; + try { params = { ...defaults, ...deepClone(raw?.params ?? {}) }; } + catch { params = { ...defaults }; } + const waypoints = Array.isArray(raw?.waypoints) ? raw.waypoints.slice(0, 500).map(point => ({ x: clamp(finite(point?.x), -5000, 5000), y: clamp(finite(point?.y), -5000, 5000) })) : []; + const prefix = domain === 'optical' ? 'wg' : domain === 'electrical' ? 'wire' : domain === 'rf' ? 'rf' : domain === 'control' ? 'ctrl' : 'link'; + connections.push({ id: String(raw?.id ?? `${prefix}-${index + 1}`).slice(0, 100), domain, label: String(raw?.label ?? '').slice(0, 120), a, b, waypoints, params }); + } + let lab; + try { lab = deepClone(input.lab ?? { measurements: [] }); } + catch { lab = { measurements: [] }; } + if (!Array.isArray(lab.measurements)) lab.measurements = []; + const circuit = { + version: 2, + name: String(input.name ?? 'Imported experiment').slice(0, 160), + settings: sanitizedSettings(input.settings), + components, connections, lab, + parameterLinks: [] + }; + let links = []; + try { links = deepClone(Array.isArray(input.parameterLinks) ? input.parameterLinks : []); } catch { links = []; } + const validParameterIds = new Set(listSweepParameters(circuit).map(parameter => parameter.id)); + circuit.parameterLinks = links.slice(0, 200).map((link, index) => ({ + id: String(link?.id ?? `link-${index + 1}`).slice(0, 100), + name: String(link?.name ?? `Parameter link ${index + 1}`).slice(0, 120), + unit: String(link?.unit ?? '').slice(0, 30), scope: String(link?.scope ?? 'cw').slice(0, 30), + members: [...new Set(Array.isArray(link?.members) ? link.members.map(String).filter(id => validParameterIds.has(id)) : [])] + })).filter(link => link.members.length >= 2); + return circuit; +} + +function base64UrlEncode(bytes) { + let binary = ''; + bytes.forEach(byte => { binary += String.fromCharCode(byte); }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function base64UrlDecode(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} +function circuitHash() { + const clean = deepClone(runtime.circuit); + clean.settings.canvasPanX = 0; + clean.settings.canvasPanY = 0; + return `#c=${base64UrlEncode(new TextEncoder().encode(JSON.stringify(clean)))}`; +} +function loadInitialCircuit() { + const hash = location.hash.match(/^#c=([A-Za-z0-9_-]+)$/); + if (hash) { + try { return safeCircuit(JSON.parse(new TextDecoder().decode(base64UrlDecode(hash[1])))); } + catch (error) { console.warn('Could not decode circuit from URL.', error); } + } + try { + const stored = localStorage.getItem('picsetup.autosave'); + if (stored) return safeCircuit(JSON.parse(stored)); + } catch (error) { console.warn('Could not read autosave.', error); } + return makeDemoCircuit(); +} + +runtime.circuit = loadInitialCircuit(); + +function snapshot() { return JSON.stringify(runtime.circuit); } +function pushSnapshot(serialized = snapshot()) { + if (runtime.history.at(-1) === serialized) return; + runtime.history.push(serialized); + if (runtime.history.length > 100) runtime.history.shift(); + runtime.future.length = 0; +} +function beginContinuousEdit() { if (!runtime.continuousSnapshot) runtime.continuousSnapshot = snapshot(); } +function endContinuousEdit() { + if (!runtime.continuousSnapshot) return; + if (runtime.continuousSnapshot !== snapshot()) pushSnapshot(runtime.continuousSnapshot); + runtime.continuousSnapshot = null; + runtime.dirty = true; + refresh(); + scheduleAutosave(); +} +function cleanParameterLinks(circuit) { + const valid = new Set(listSweepParameters(circuit).map(parameter => parameter.id)); + circuit.parameterLinks = (circuit.parameterLinks ?? []).map(link => ({ ...link, members: [...new Set((link.members ?? []).filter(id => valid.has(id)))] })).filter(link => link.members.length >= 2); +} +function mutateCircuit(mutator, { history = true, inspector = true, lab = false, autosave = true } = {}) { + if (history) pushSnapshot(); + mutator(runtime.circuit); + cleanParameterLinks(runtime.circuit); + runtime.dirty = true; + refresh({ inspector, lab }); + if (autosave) scheduleAutosave(); +} +function replaceCircuit(circuit, { history = true, toast = null } = {}) { + if (history) pushSnapshot(); + runtime.circuit = safeCircuit(circuit); + runtime.selection = null; + runtime.connectStart = null; + runtime.previewPoints = []; + runtime.future.length = 0; + runtime.highlightIds = []; + runtime.lab.result = null; + runtime.lab.baselines = []; + runtime.lab.optimizationResult = null; + runtime.lab.toleranceResult = null; + runtime.lab.pulseResult = null; + runtime.lab.selectedMeasurementId = null; + runtime.dirty = true; + refresh({ lab: true }); + fitCircuit(); + scheduleAutosave(); + if (toast) showToast(toast); +} +function undo() { + if (!runtime.history.length) return; + runtime.future.push(snapshot()); + runtime.circuit = safeCircuit(JSON.parse(runtime.history.pop())); + runtime.selection = null; + runtime.connectStart = null; + runtime.previewPoints = []; + runtime.highlightIds = []; + refresh({ lab: true }); + scheduleAutosave(); +} +function redo() { + if (!runtime.future.length) return; + runtime.history.push(snapshot()); + runtime.circuit = safeCircuit(JSON.parse(runtime.future.pop())); + runtime.selection = null; + runtime.connectStart = null; + runtime.previewPoints = []; + runtime.highlightIds = []; + refresh({ lab: true }); + scheduleAutosave(); +} +function scheduleAutosave() { + clearTimeout(runtime.pendingAutosave); + runtime.pendingAutosave = setTimeout(() => { + try { + localStorage.setItem('picsetup.autosave', JSON.stringify(runtime.circuit)); + runtime.lastSavedAt = Date.now(); + runtime.dirty = false; + } catch (error) { console.warn('Autosave failed.', error); } + }, 300); +} + +function pinnedMeasurements() { + runtime.circuit.lab ??= {}; + runtime.circuit.lab.measurements ??= []; + if (!runtime.circuit.lab.measurements.length) runtime.circuit.lab.measurements = defaultMeasurementSpecs(runtime.circuit); + return runtime.circuit.lab.measurements; +} +function selectedMeasurement() { + const measurements = pinnedMeasurements(); + const selected = measurements.find(item => item.id === runtime.lab.selectedMeasurementId) ?? measurements[0]; + runtime.lab.selectedMeasurementId = selected?.id ?? null; + return selected; +} +function measurementKey(spec) { return `${spec.kind}:${spec.componentId ?? spec.connectionId ?? ''}:${spec.portId ?? ''}:${spec.metric ?? ''}:${spec.direction ?? ''}`; } +function isPinned(spec) { return pinnedMeasurements().some(item => measurementKey(item) === measurementKey(spec)); } +function removeMeasurement(id) { + runtime.circuit.lab.measurements = pinnedMeasurements().filter(item => item.id !== id); + if (runtime.lab.selectedMeasurementId === id) runtime.lab.selectedMeasurementId = runtime.circuit.lab.measurements[0]?.id ?? null; + runtime.dirty = true; + refresh({ lab: true }); + scheduleAutosave(); +} +function addMeasurement(spec, { openLab = true } = {}) { + pinMeasurement(runtime.circuit, spec); + const actual = pinnedMeasurements().find(item => measurementKey(item) === measurementKey(spec)); + runtime.lab.selectedMeasurementId = actual?.id ?? runtime.lab.selectedMeasurementId; + runtime.dirty = true; + if (openLab) openLabPanel('sweep'); + refresh({ lab: true }); + scheduleAutosave(); +} + +function renderCanvas() { + renderer.render(runtime.circuit, runtime.solve, { + selection: runtime.selection, + connectStart: runtime.connectStart, + previewPoints: runtime.previewPoints, + previewValid: runtime.previewValid, + pinnedMeasurements: pinnedMeasurements(), + highlightIds: runtime.highlightIds + }); +} +function refresh({ inspector = true, lab = false } = {}) { + runtime.solve = solveCircuit(runtime.circuit); + renderCanvas(); + updateChrome(); + if (inspector) renderInspector(); + if (lab && runtime.lab.open) renderLab(); + if (dom.physicsDialog.open && runtime.selectedCouplerId) updatePhysicsPanel(); +} + +function updateChrome() { + const solve = runtime.solve; + const budget = solve.powerBudget; + const mode = modeSummary(); + const settings = runtime.circuit.settings; + dom.projectName.textContent = runtime.circuit.name; + dom.undoButton.disabled = runtime.history.length === 0; + dom.redoButton.disabled = runtime.future.length === 0; + dom.emptyHint.hidden = runtime.circuit.components.length > 0; + dom.zoomReadout.textContent = `${Math.round((settings.canvasZoom ?? 1) * 100)}%`; + dom.sourceReadout.textContent = formatPower(solve.sourcePowerMw ?? 0); + dom.detectedReadout.textContent = formatPower(solve.detectedPowerMw ?? 0); + dom.balanceReadout.textContent = solve.ok ? formatResidual(solve.residual) : '—'; + const accounted = budget && budget.launchedMw > 0 ? 100 * budget.accountedMw / budget.launchedMw : 100; + dom.powerBudgetReadout.textContent = `${accounted.toFixed(Math.abs(100 - accounted) < .05 ? 2 : 1)}%`; + dom.transportModeLabel.textContent = mode.label; + dom.transportModeDetail.textContent = mode.detail; + dom.solverHealth.dataset.state = solve.ok ? (solve.warnings?.length || (budget?.balanceErrorFraction ?? 0) > 1e-4 ? 'warning' : 'ok') : 'error'; + dom.solverHealth.querySelector('b').textContent = solve.ok ? (solve.warnings?.length ? 'Check model' : 'Converged') : 'Not solved'; + dom.solverMeta.textContent = `${solve.elapsedMs.toFixed(1)} ms`; + const routing = settings.routingMode === 'schematic' ? 'schematic' : 'physical'; + dom.routingBadge.textContent = routing === 'physical' ? 'Physical' : 'Schematic'; + dom.routingDetail.textContent = routing === 'physical' ? 'curve = optical length' : 'drawing ≠ optical length'; + const worldToUm = finite(settings.worldToUm, DEFAULT_WORLD_TO_UM); + const zoom = finite(settings.canvasZoom, 1); + const scaleWorld = 100 / Math.max(.1, zoom); + const scaleUm = scaleWorld * worldToUm; + dom.scaleReadout.textContent = `${scaleUm >= 100 ? scaleUm.toFixed(0) : scaleUm.toFixed(1)} µm`; + dom.labPinCount.textContent = String(pinnedMeasurements().length); + dom.labButton.classList.toggle('active', runtime.lab.open); + dom.figureButton?.classList.toggle('active', settings.figureMode === 'paper'); + updateToolUI(); + updateTimeUI(); +} +function updateToolUI() { + dom.app.dataset.tool = runtime.tool; + $$('.tool-rail [data-tool]').forEach(button => button.classList.toggle('active', button.dataset.tool === runtime.tool)); + dom.componentTray.hidden = runtime.tool !== 'place'; + $$('[data-place-type]', dom.componentCatalog).forEach(button => button.classList.toggle('active', button.dataset.placeType === runtime.placeType)); +} +function updateTimeUI() { + const speed = runtime.speed; + dom.timeKnob.style.setProperty('--angle', `${270 * clamp(speed / 4, 0, 1)}deg`); + dom.timeKnob.setAttribute('aria-valuenow', String(speed)); + dom.speedReadout.textContent = runtime.paused ? 'Paused' : `${speed.toFixed(1)}×`; + dom.transportIcon.innerHTML = runtime.paused ? '' : ''; + dom.pauseButton.querySelector('svg').innerHTML = runtime.paused ? '' : ''; + dom.pauseButton.querySelector('span').textContent = runtime.paused ? 'Play' : 'Pause'; +} + +function buildComponentCatalog() { + const excluded = new Set(['block']); + const groups = new Map(); + for (const definition of listDefinitions()) { + if (excluded.has(definition.type)) continue; + const category = definition.category ?? 'Other'; + if (!groups.has(category)) groups.set(category, []); + groups.get(category).push(definition); + } + dom.componentCatalog.innerHTML = [...groups.entries()].map(([category, definitions]) => `
+

${escapeHTML(category)}

+ ${definitions.map(definition => ``).join('')} +
`).join(''); +} + +function inspectorHead(type, name, subtitle) { + return `
+ ${escapeHTML(iconForType(type))} +

${escapeHTML(name)}

${escapeHTML(subtitle)}

+ +
`; +} +function parameterScopeCopy(parameter) { + if (parameter.scope === 'pulse') return 'Used by Lab pulse reconstruction or pulse-train visualization; not part of the CW network solve.'; + if (parameter.scope === 'electrical') return 'Electrical or RF metadata/control; it does not enter the optical CW scattering matrix.'; + if (parameter.scope === 'visual') return 'Figure geometry only; it changes composition and export, not the optical solve.'; + return 'Active in the coherent CW compact model and available as a sweep/tuning knob.'; +} +function parameterControl(component, parameter) { + const targetGlobal = parameter.target === 'global'; + const value = targetGlobal ? runtime.circuit.settings.wavelengthNm : finite(component.params?.[parameter.key], parameter.default); + const inactive = ['pulse', 'electrical', 'visual'].includes(parameter.scope); + const parameterId = targetGlobal ? 'global:wavelengthNm' : `component:${component.id}:${parameter.key}`; + const link = parameterLinkFor(runtime.circuit, parameterId); + return ``; +} +function customFieldControl(component, field) { + const value = component.params?.[field.key] ?? field.default ?? ''; + const common = `data-field-key="${escapeHTML(field.key)}"`; + let control = ''; + if (field.type === 'select') { + control = ``; + } else if (field.type === 'textarea') { + control = ``; + } else { + control = ``; + } + return ``; +} +function componentIdentitySection(component) { + const placement = ['auto','above','below','left','right','hidden'].includes(component.params?.labelPosition) ? component.params.labelPosition : 'auto'; + const subtitleVisibility = component.params?.subtitleVisibility === 'hide' ? 'hide' : 'show'; + return `
Figure identityLabels and orientation export with the scene
+ +
+ + +
+
+ + +
+ +
`; +} + +function measurementPinButton(spec, label = 'Pin') { + const payload = encodeURIComponent(JSON.stringify(spec)); + return ``; +} +function componentMeasurementSection(component, result) { + const definition = getDefinition(component.type); + const ports = getPorts(component).filter(port => (port.role ?? 'optical') === 'optical'); + if (!ports.length) return `
Figure-only typed component

This object and its non-optical ports are exported but intentionally excluded from the coherent optical solve.

`; + const rows = ports.map(port => { + const solved = result?.ports?.[port.id]; + const spec = { kind: 'port', componentId: component.id, portId: port.id, direction: 'incoming', metric: 'power', label: `${component.name}:${port.label} incoming power` }; + return `
${escapeHTML(port.label)} · incomingphase ${formatPhase(solved?.incomingPhaseRad ?? 0)} · outgoing ${formatPower(solved?.outgoingPowerMw ?? 0)}
${formatPower(solved?.incomingPowerMw ?? 0)}${measurementPinButton(spec)}
`; + }).join(''); + const componentSpecs = []; + if (definition.detector || definition.probe || definition.bridge) componentSpecs.push({ kind: 'component', componentId: component.id, metric: 'power', label: `${component.name} power` }); + if (definition.detector) componentSpecs.push({ kind: 'component', componentId: component.id, metric: 'current', label: `${component.name} photocurrent` }); + return `
+
MeasurementsPin outputs to Lab
+ ${componentSpecs.length ? `
${componentSpecs.map(spec => measurementPinButton(spec, spec.metric === 'current' ? 'Current' : 'Power')).join('')}
` : ''} +
${rows}
+
`; +} +function componentMetrics(component, result) { + const definition = getDefinition(component.type); + const opticalPorts = Object.values(result?.ports ?? {}).filter(port => (port.domain ?? 'optical') === 'optical'); + const incoming = opticalPorts.reduce((sum, port) => sum + finite(port.incomingPowerMw), 0); + const outgoing = opticalPorts.reduce((sum, port) => sum + finite(port.outgoingPowerMw), 0); + const cells = definition.diagramOnly && !opticalPorts.length ? [ + ['Solver status', 'Figure only'], ['Typed ports', String(getPorts(component).length)], ['Domain owner', component.type.includes('rf') ? 'Electrical' : 'System figure'], ['Export', 'SVG / PNG'] + ] : [ + ['Incident power', formatPower(incoming)], + ['Outgoing power', formatPower(outgoing)], + ['Model loss', formatPower(Math.max(0, result?.power?.netDissipationMw ?? 0))], + ['Ports', String(getPorts(component).length)] + ]; + if (definition.detector) { + cells[0] = ['Optical power', formatPower(result?.measurementMw ?? 0)]; + cells[1] = ['Photocurrent', `${finite(result?.photocurrentMa).toFixed(4)} mA`]; + } else if (component.type === 'coupler') { + const coefficient = couplerCoefficients(component.params, runtime.solve.wavelengthNm); + cells[2] = ['Cross fraction', `${(100 * coefficient.crossPower).toFixed(2)}%`]; + cells[3] = ['Coupling angle', `${coefficient.theta.toFixed(3)} rad`]; + } + return `
${cells.map(([label, value]) => `${escapeHTML(label)}${escapeHTML(value)}`).join('')}
`; +} +function renderComponentInspector(id) { + const component = runtime.circuit.components.find(item => item.id === id); + if (!component) { runtime.selection = null; return renderInspector(); } + const definition = getDefinition(component.type); + const result = runtime.solve.components?.get(component.id); + const sourceSpecial = component.type === 'source' ? `
+
Source behaviorModel and visual layer are explicit
+
+
+ The canvas animation is explanatory. Lab → Pulse reconstructs the linear temporal response from the complex spectrum. +
` : ''; + const specialActions = [ + component.type === 'optical-bridge' ? `` : '', + component.type === 'image-panel' ? `${component.params?.imageDataUrl ? `` : ''}` : '', + component.type === 'coupler' ? `` : '', + component.type === 'block' ? `` : '', + component.type === 'generic' ? `` : '' + ].join(''); + const parameters = (definition.parameters ?? []).map(parameter => parameterControl(component, parameter)).join(''); + const fields = (definition.fields ?? []).map(field => customFieldControl(component, field)).join(''); + const provenance = component.params?.provenance ?? definition.provenance ?? 'Analytical compact model'; + dom.inspectorContent.innerHTML = `${inspectorHead(component.type, component.name, `${definition.label} · ${component.id}`)} + ${sourceSpecial} + ${componentIdentitySection(component)} + ${fields ? `
Figure / interface fieldsTyped metadata travels with exports and bridges
${fields}
` : ''} + ${parameters ? `
Model parametersEvery control declares its active scope
${parameters}
` : ''} + ${specialActions ? `
${specialActions}
` : ''} + ${componentMetrics(component, result)} + ${componentMeasurementSection(component, result)} +
Model contract${escapeHTML(definition.diagramOnly ? 'Figure only' : component.type === 'generic' ? 'Imported' : component.type === 'block' ? 'Hierarchical' : definition.bridge ? 'Bridge' : 'Analytical')}
+
${escapeHTML(provenance)}

${escapeHTML(definition.description ?? '')}

+
${(definition.assumptions ?? []).map(assumption => `${escapeHTML(assumption)}`).join('')}
+
+
`; +} +function connectionFigureSection(connection) { + const arrow = ['none','start','end','both'].includes(connection.params?.arrow) ? connection.params.arrow : 'none'; + return `
Figure annotationRendered on canvas and in SVG / PNG
+ + +
+ + +
+
`; +} + +function renderConnectionInspector(id) { + const connection = runtime.circuit.connections.find(item => item.id === id); + if (!connection) { runtime.selection = null; return renderInspector(); } + const solved = runtime.solve.connections?.get(connection.id); + const aComponent = runtime.circuit.components.find(item => item.id === connection.a.component); + const bComponent = runtime.circuit.components.find(item => item.id === connection.b.component); + const domain = connectionDomain(connection, runtime.circuit.components); + if (domain !== 'optical') { + const domainLabel = domain === 'rf' ? 'RF / coax' : domain === 'electrical' ? 'Electrical signal' : domain === 'control' ? 'Control / logic' : 'Typed connection'; + dom.inspectorContent.innerHTML = `${inspectorHead('waveguide', domainLabel, `${aComponent?.name ?? '?'}:${connection.a.port} ↔ ${bComponent?.name ?? '?'}:${connection.b.port}`)} +
Domain contractPreserved in paper figures and semantic exports
Not an optical waveguide

This ${escapeHTML(domain)} connection is intentionally excluded from the coherent photonic solver. Its owning simulator can consume it through a future typed bridge.

+ ${connectionFigureSection(connection)} +
Domain${escapeHTML(domain)}Endpoints2 typed portsOptical solveExcludedExportIncluded
+
`; + return; + } + const transmission = waveguideTransmission(connection, runtime.circuit.components, runtime.solve.wavelengthNm, runtime.circuit.settings); + const mode = connection.params?.routingMode ?? runtime.circuit.settings.routingMode ?? 'physical'; + const powerSpec = { kind: 'connection', connectionId: connection.id, direction: 'forward', metric: 'power', label: `${connection.id} forward power` }; + const opticalLengthId = `connection:${connection.id}:length`; + const lengthControl = mode === 'schematic' ? `
Explicit optical lengthIndependent of the drawn route in schematic mode.
${transmission.lengthUm.toFixed(2)} µm
CW
` : ''; + dom.inspectorContent.innerHTML = `${inspectorHead('waveguide', 'Waveguide', `${aComponent?.name ?? '?'}:${connection.a.port} ↔ ${bComponent?.name ?? '?'}:${connection.b.port}`)} + ${connectionFigureSection(connection)} +
Routing contract${mode === 'physical' ? 'Rendered curve is measured' : 'Drawing is topology only'}
+
+ Physical mode numerically integrates the exact cubic path that is rendered. Schematic mode stores an independent optical length. + ${lengthControl} +
+
PropagationComplex bidirectional transmission
+ ${connectionParameterControl(connection, 'neff', 'Effective index nₑff', '', 1, 4.5, .001, opticalLengthId.replace(':length', ':neff'))} + ${connectionParameterControl(connection, 'lossDbPerCm', 'Propagation loss', 'dB/cm', 0, 50, .05, opticalLengthId.replace(':length', ':lossDbPerCm'))} +
+
+ Exact optical length${transmission.lengthUm.toFixed(3)} µmPhase delay${formatPhase(transmission.phase)} + Forward power${formatPower(solved?.powerFromAMw ?? 0)}Waveguide loss${formatPower(solved?.lossMw ?? 0)} + Minimum bend radius${Number.isFinite(transmission.minimumBendRadiusUm) ? `${transmission.minimumBendRadiusUm.toFixed(2)} µm` : 'straight'}Declared minimum${transmission.requiredBendRadiusUm.toFixed(2)} µm +
${transmission.bendViolation ? `
Bend-radius declaration violated

The compact waveguide model continues with straight-section loss only; PicSetup does not silently invent a bend-loss coefficient.

` : ''}
+
MeasurementPin the complex path response
${measurementPinButton(powerSpec, 'Forward power')}
+
`; +} +function connectionParameterControl(connection, key, label, unit, min, max, step, parameterId) { + const value = finite(connection.params?.[key], key === 'neff' ? 2.42 : 2); + const digits = step >= 1 ? 0 : step >= .1 ? 1 : step >= .01 ? 2 : 3; + return `
${escapeHTML(label)}Active in the waveguide complex transmission model.
${value.toFixed(digits)}${unit ? ` ${escapeHTML(unit)}` : ''}
CW
`; +} +function renderMultiInspector(ids) { + const components = ids.map(id => runtime.circuit.components.find(item => item.id === id)).filter(Boolean); + if (components.length < 2) { runtime.selection = components[0] ? { kind: 'component', id: components[0].id } : null; return renderInspector(); } + dom.inspectorContent.innerHTML = `${inspectorHead('block', `${components.length} components`, 'Multi-selection · reusable hierarchy')} +

Create a functional block

PicSetup will preserve the selected passive graph, expose every boundary/open port, and reduce it to a live wavelength-dependent external scattering model.

+
name
+ +
+
Selected objectsShift-tap to add or remove
${components.map(component => `
${escapeHTML(component.name)}${escapeHTML(labelForType(component.type))} · ${escapeHTML(component.id)}
${escapeHTML(iconForType(component.type))}
`).join('')}
+
`; +} +function renderInspector() { + const selection = runtime.selection; + if (!selection) { + dom.inspectorEmpty.hidden = false; + dom.inspectorContent.hidden = true; + dom.inspector.classList.remove('open'); + return; + } + dom.inspectorEmpty.hidden = true; + dom.inspectorContent.hidden = false; + dom.inspector.classList.add('open'); + if (selection.kind === 'component') renderComponentInspector(selection.id); + else if (selection.kind === 'connection') renderConnectionInspector(selection.id); + else if (selection.kind === 'multi') renderMultiInspector(selection.ids ?? []); +} + +function updateSelectedComponentParameter(component, key, value, global = false) { + if (global) { + runtime.circuit.settings.wavelengthNm = value; + runtime.circuit.components.filter(item => item.type === 'source').forEach(source => { source.params.wavelengthNm = value; }); + } else component.params[key] = value; +} +function bindInspectorDelegation() { + dom.inspectorContent.addEventListener('pointerdown', event => { + if (event.target.matches('input[type="range"]')) beginContinuousEdit(); + }); + dom.inspectorContent.addEventListener('input', event => { + const selection = runtime.selection; + if (event.target.matches('[data-param-key]') && selection?.kind === 'component') { + const component = runtime.circuit.components.find(item => item.id === selection.id); + if (!component) return; + const key = event.target.dataset.paramKey; + const global = event.target.dataset.paramGlobal === 'true'; + const definition = getDefinition(component.type).parameters?.find(parameter => parameter.key === key); + const parameterId = global ? 'global:wavelengthNm' : `component:${component.id}:${key}`; + resolveParameter(runtime.circuit, parameterId).set(runtime.circuit, finite(event.target.value)); + const output = dom.inspectorContent.querySelector(`[data-param-output="${CSS.escape(key)}"]`); + if (output) output.textContent = formatParameterValue(definition, event.target.value); + runtime.solve = solveCircuit(runtime.circuit); + renderCanvas(); updateChrome(); + if (dom.physicsDialog.open) updatePhysicsPanel(); + } else if (event.target.matches('[data-connection-param]') && selection?.kind === 'connection') { + const connection = runtime.circuit.connections.find(item => item.id === selection.id); + if (!connection) return; + const key = event.target.dataset.connectionParam; + const parameterId = `connection:${connection.id}:${key === 'schematicLengthUm' ? 'length' : key}`; + resolveParameter(runtime.circuit, parameterId).set(runtime.circuit, finite(event.target.value)); + if (key === 'schematicLengthUm') connection.params.routingMode = 'schematic'; + const output = dom.inspectorContent.querySelector(`[data-connection-output="${CSS.escape(key)}"]`); + if (output) output.textContent = key === 'schematicLengthUm' ? `${finite(event.target.value).toFixed(2)} µm` : `${finite(event.target.value).toFixed(key === 'neff' ? 3 : 2)}${event.target.dataset.unit ? ` ${event.target.dataset.unit}` : ''}`; + runtime.solve = solveCircuit(runtime.circuit); renderCanvas(); updateChrome(); + } + }); + dom.inspectorContent.addEventListener('change', event => { + const selection = runtime.selection; + if (event.target.matches('input[type="range"]')) { endContinuousEdit(); return; } + if (selection?.kind === 'component' && event.target.matches('[data-field-key], [data-component-name], [data-component-rotation], [data-component-label-position], [data-component-subtitle-visibility], [data-component-label-x], [data-component-label-y]')) { + const componentId = selection.id; + mutateCircuit(circuit => { + const component = circuit.components.find(item => item.id === componentId); + if (!component) return; + if (event.target.matches('[data-component-name]')) component.name = String(event.target.value || getDefinition(component.type).label).slice(0,120); + else if (event.target.matches('[data-component-rotation]')) component.rotation = clamp(finite(event.target.value,0),-3600,3600); + else if (event.target.matches('[data-component-label-position]')) component.params.labelPosition = ['auto','above','below','left','right','hidden'].includes(event.target.value) ? event.target.value : 'auto'; + else if (event.target.matches('[data-component-subtitle-visibility]')) component.params.subtitleVisibility = event.target.value === 'hide' ? 'hide' : 'show'; + else if (event.target.matches('[data-component-label-x]')) component.params.labelOffsetX = clamp(finite(event.target.value,0),-1000,1000); + else if (event.target.matches('[data-component-label-y]')) component.params.labelOffsetY = clamp(finite(event.target.value,0),-1000,1000); + else component.params[event.target.dataset.fieldKey] = String(event.target.value).slice(0,1000000); + }); + return; + } + if (selection?.kind === 'connection' && event.target.matches('[data-connection-label], [data-connection-arrow], [data-connection-label-x], [data-connection-label-y]')) { + const id = selection.id; + mutateCircuit(circuit => { + const connection = circuit.connections.find(item => item.id === id); + if (!connection) return; + connection.params ??= {}; + if (event.target.matches('[data-connection-label]')) connection.label = String(event.target.value).slice(0,120); + else if (event.target.matches('[data-connection-arrow]')) connection.params.arrow = ['none','start','end','both'].includes(event.target.value) ? event.target.value : 'none'; + else if (event.target.matches('[data-connection-label-x]')) connection.params.labelOffsetX = clamp(finite(event.target.value,0),-1000,1000); + else connection.params.labelOffsetY = clamp(finite(event.target.value,-11),-1000,1000); + }); + } + }); + dom.inspectorContent.addEventListener('click', event => { + const sourceMode = event.target.closest('[data-source-mode]'); + if (sourceMode && runtime.selection?.kind === 'component') { + mutateCircuit(circuit => { const component = circuit.components.find(item => item.id === runtime.selection.id); if (component) component.params.sourceMode = sourceMode.dataset.sourceMode; }); + return; + } + const sourceVisual = event.target.closest('[data-source-visualization]'); + if (sourceVisual && runtime.selection?.kind === 'component') { + mutateCircuit(circuit => { const component = circuit.components.find(item => item.id === runtime.selection.id); if (component) component.params.cwVisualization = sourceVisual.dataset.sourceVisualization; }); + return; + } + const routing = event.target.closest('[data-connection-routing]'); + if (routing && runtime.selection?.kind === 'connection') { + mutateCircuit(circuit => { + const connection = circuit.connections.find(item => item.id === runtime.selection.id); + if (!connection) return; + if (routing.dataset.connectionRouting === 'schematic' && !Number.isFinite(Number(connection.params.schematicLengthUm))) connection.params.schematicLengthUm = connectionLengthUm(connection, circuit.components, { ...circuit.settings, routingMode: 'physical' }); + connection.params.routingMode = routing.dataset.connectionRouting; + }); + return; + } + const action = event.target.closest('[data-inspector-action]'); + if (!action) return; + handleInspectorAction(action); + }); +} +function handleInspectorAction(button) { + const action = button.dataset.inspectorAction; + if (action === 'close') { runtime.selection = null; refresh(); return; } + if (action === 'delete') { deleteSelection(); return; } + if (action === 'open-physics' && runtime.selection?.kind === 'component') { openPhysics(runtime.selection.id); return; } + if (action === 'open-block' && runtime.selection?.kind === 'component') { openBlockSheet(runtime.selection.id); return; } + if (action === 'expand-block' && runtime.selection?.kind === 'component') { + const id = runtime.selection.id; + mutateCircuit(circuit => { const ids = expandHierarchicalBlock(circuit, id); runtime.selection = { kind: 'multi', ids }; }, { lab: true }); + showToast('Block expanded. Its internal graph is editable again.'); + return; + } + if (action === 'import-sparams') { importSParameters(runtime.selection?.kind === 'component' ? runtime.selection.id : null); return; } + if (action === 'open-opticalsetup' && runtime.selection?.kind === 'component') { + const component = runtime.circuit.components.find(item => item.id === runtime.selection.id); + if (!component) return; + const payload = buildOpticalBridgePayload(runtime.circuit, component, runtime.solve, { returnUrl: location.href }); + const url = opticalSetupBridgeUrl(payload, component.params?.targetUrl); + window.open(url, '_blank', 'noopener'); + showToast('OpticalSetup opened with a setup-port/1 handoff. Its editor must support incomingBridge to instantiate it automatically.'); + return; + } + if ((action === 'copy-bridge' || action === 'export-bridge') && runtime.selection?.kind === 'component') { + const component = runtime.circuit.components.find(item => item.id === runtime.selection.id); + if (!component) return; + const payload = buildOpticalBridgePayload(runtime.circuit, component, runtime.solve, { returnUrl: location.href }); + const json = JSON.stringify(payload, null, 2); + if (action === 'copy-bridge') navigator.clipboard?.writeText(json).then(() => showToast('Typed optical bridge JSON copied.')).catch(() => downloadText(`${slug(component.name)}-bridge.json`, json, 'application/json')); + else downloadText(`${slug(component.name)}-bridge.json`, json, 'application/json'); + return; + } + if (action === 'attach-image' && runtime.selection?.kind === 'component') { + const id = runtime.selection.id; + chooseFile('image/png,image/jpeg,image/webp,image/svg+xml', file => { + if (file.size > 8 * 1024 * 1024) return showToast('Use an image smaller than 8 MB for portable figure files.'); + const reader = new FileReader(); + reader.onload = () => mutateCircuit(circuit => { const component = circuit.components.find(item => item.id === id); if (component) component.params.imageDataUrl = String(reader.result); }); + reader.onerror = () => showToast('Could not read that image.'); + reader.readAsDataURL(file); + }); + return; + } + if (action === 'clear-image' && runtime.selection?.kind === 'component') { + const id = runtime.selection.id; + mutateCircuit(circuit => { const component = circuit.components.find(item => item.id === id); if (component) component.params.imageDataUrl = ''; }); + return; + } + if (action === 'pin-measurement') { + try { addMeasurement(JSON.parse(decodeURIComponent(button.dataset.measurement))); } + catch { showToast('Could not pin that measurement.'); } + return; + } + if (action === 'sweep-parameter') { configureSweepParameter(button.dataset.parameterId); openLabPanel('sweep'); return; } + if (action === 'tolerance-parameter') { configureToleranceParameter(button.dataset.parameterId); openLabPanel('tolerance'); return; } + if (action === 'link-parameter') { openParameterLinkSheet(button.dataset.parameterId); return; } + if (action === 'create-block' && runtime.selection?.kind === 'multi') { + const name = $('#blockNameInput', dom.inspectorContent)?.value?.trim() || 'Functional block'; + const ids = [...runtime.selection.ids]; + try { + mutateCircuit(circuit => { const block = createHierarchicalBlock(circuit, ids, { id: uniqueId('block'), name }); runtime.selection = { kind: 'component', id: block.id }; }, { lab: true }); + showToast(`${name} created with a live reduced external model.`); + } catch (error) { showToast(error.message); } + return; + } + if (action === 'remove-multi' && runtime.selection?.kind === 'multi') { + runtime.selection.ids = runtime.selection.ids.filter(id => id !== button.dataset.id); + refresh(); + } +} + +function deleteSelection() { + const selection = runtime.selection; + if (!selection) return; + mutateCircuit(circuit => { + if (selection.kind === 'connection') circuit.connections = circuit.connections.filter(item => item.id !== selection.id); + else { + const ids = new Set(selection.kind === 'multi' ? selection.ids : [selection.id]); + circuit.components = circuit.components.filter(item => !ids.has(item.id)); + circuit.connections = circuit.connections.filter(item => !ids.has(item.a.component) && !ids.has(item.b.component)); + circuit.lab.measurements = pinnedMeasurements().filter(item => !ids.has(item.componentId)); + } + runtime.selection = null; + }, { lab: true }); +} +function setTool(tool) { + runtime.tool = tool; + runtime.connectStart = null; + runtime.previewPoints = []; + runtime.previewValid = null; + runtime.interaction = null; + updateToolUI(); +} +function uniqueId(prefix) { + const existing = new Set([...runtime.circuit.components.map(item => item.id), ...runtime.circuit.connections.map(item => item.id)]); + let index = 1; + while (existing.has(`${prefix}-${index}`)) index += 1; + return `${prefix}-${index}`; +} +function placeComponent(type, point) { + const definition = getDefinition(type); + const component = { + id: uniqueId(definition.prefix ?? type), type, + name: `${definition.shortLabel ?? definition.label} ${runtime.circuit.components.filter(item => item.type === type).length + 1}`, + x: point.x, y: point.y, rotation: 0, params: defaultParams(type) + }; + if (type === 'source') component.params.wavelengthNm = runtime.circuit.settings.wavelengthNm; + mutateCircuit(circuit => { circuit.components.push(component); runtime.selection = { kind: 'component', id: component.id }; }); + runtime.tool = 'select'; + updateToolUI(); +} +function simplifyPoints(points, tolerance = 5) { + if (points.length <= 2) return points; + const output = [points[0]]; + let last = points[0]; + for (let index = 1; index < points.length - 1; index += 1) { + const point = points[index]; + if (Math.hypot(point.x - last.x, point.y - last.y) >= tolerance) { output.push(point); last = point; } + } + output.push(points.at(-1)); + if (output.length <= 3) return output; + const smoothed = [output[0]]; + for (let index = 1; index < output.length - 1; index += 1) { + const a = smoothed.at(-1), b = output[index], c = output[index + 1]; + const area = Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)); + const length = Math.max(1, Math.hypot(c.x - a.x, c.y - a.y)); + if (area / length > 2.5) smoothed.push(b); + } + smoothed.push(output.at(-1)); + return smoothed; +} +function startConnection(endpoint, point, pointerId) { + runtime.connectStart = endpoint; + runtime.previewPoints = [point]; + runtime.previewValid = null; + runtime.interaction = { mode: 'draw-connection', pointerId, start: endpoint, candidate: null }; + dom.svg.setPointerCapture?.(pointerId); + refresh({ inspector: false }); +} +function completeConnection(point) { + const interaction = runtime.interaction; + if (!interaction || interaction.mode !== 'draw-connection') return cancelConnection(); + const candidate = interaction.candidate ?? nearestPort(runtime.circuit, point, 34 / (runtime.circuit.settings.canvasZoom ?? 1), interaction.start); + const validation = validateConnection(runtime.circuit, interaction.start, candidate); + if (!validation.ok) { showToast(validation.reason); return cancelConnection(); } + const startComponent = runtime.circuit.components.find(item => item.id === interaction.start.component); + const endComponent = runtime.circuit.components.find(item => item.id === candidate.component); + const startPort = portPosition(startComponent, interaction.start.port); + const endPort = portPosition(endComponent, candidate.port); + let points = simplifyPoints(runtime.previewPoints, 7); + points[0] = { x: startPort.x, y: startPort.y }; + points[points.length - 1] = { x: endPort.x, y: endPort.y }; + const waypoints = points.slice(1, -1); + const declaredPort = getPorts(startComponent).find(port => port.id === interaction.start.port); + const domain = declaredPort?.role ?? 'optical'; + const prefix = domain === 'optical' ? 'wg' : domain === 'electrical' ? 'wire' : domain === 'rf' ? 'rf' : domain === 'control' ? 'ctrl' : 'link'; + const connection = { + id: uniqueId(prefix), domain, + a: { component: interaction.start.component, port: interaction.start.port }, + b: { component: candidate.component, port: candidate.port }, + waypoints, + params: domain === 'optical' ? { lossDbPerCm: 2, neff: 2.42 } : {} + }; + mutateCircuit(circuit => { circuit.connections.push(connection); runtime.selection = { kind: 'connection', id: connection.id }; }); + cancelConnection(false); +} +function cancelConnection(render = true) { + runtime.connectStart = null; + runtime.previewPoints = []; + runtime.previewValid = null; + runtime.interaction = null; + if (render) refresh(); +} +function toggleMultiSelection(id) { + if (runtime.selection?.kind === 'multi') { + const ids = runtime.selection.ids.includes(id) ? runtime.selection.ids.filter(item => item !== id) : [...runtime.selection.ids, id]; + runtime.selection = ids.length > 1 ? { kind: 'multi', ids } : ids.length === 1 ? { kind: 'component', id: ids[0] } : null; + } else if (runtime.selection?.kind === 'component' && runtime.selection.id !== id) runtime.selection = { kind: 'multi', ids: [runtime.selection.id, id] }; + else if (runtime.selection?.kind === 'component' && runtime.selection.id === id) runtime.selection = null; + else runtime.selection = { kind: 'component', id }; +} + +function handleCanvasPointerDown(event) { + runtime.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (runtime.pointers.size === 2) { + const [first, second] = [...runtime.pointers.values()]; + runtime.pinch = { + distance: Math.hypot(second.x - first.x, second.y - first.y), + centerX: (first.x + second.x) / 2, centerY: (first.y + second.y) / 2, + zoom: runtime.circuit.settings.canvasZoom, + panX: runtime.circuit.settings.canvasPanX, panY: runtime.circuit.settings.canvasPanY + }; + runtime.interaction = null; + return; + } + const target = event.target.closest?.('[data-kind], [data-action]'); + const point = renderer.screenToWorld(event.clientX, event.clientY); + const action = target?.dataset.action; + if (action === 'open-physics') { event.stopPropagation(); openPhysics(target.dataset.id); return; } + if (action === 'open-block') { event.stopPropagation(); openBlockSheet(target.dataset.id); return; } + if (action === 'open-lab') { event.stopPropagation(); openLabPanel('sweep'); return; } + if (runtime.tool === 'erase') { + if (target?.dataset.kind === 'component') runtime.selection = { kind: 'component', id: target.dataset.id }; + else if (target?.dataset.kind === 'connection') runtime.selection = { kind: 'connection', id: target.dataset.id }; + deleteSelection(); + return; + } + if (runtime.tool === 'place' && runtime.placeType && !target?.dataset.kind) { placeComponent(runtime.placeType, snapWorldPoint(point)); return; } + if (target?.dataset.kind === 'port' && (runtime.tool === 'draw' || runtime.tool === 'select')) { + startConnection({ component: target.dataset.component, port: target.dataset.port }, point, event.pointerId); + return; + } + if (target?.dataset.kind === 'component') { + const id = target.dataset.id; + if (event.shiftKey || event.metaKey || event.ctrlKey) { toggleMultiSelection(id); refresh(); return; } + runtime.selection = { kind: 'component', id }; + const component = runtime.circuit.components.find(item => item.id === id); + runtime.interaction = { mode: 'drag-component', pointerId: event.pointerId, id, startPoint: point, startX: component.x, startY: component.y, snapshot: snapshot(), moved: false }; + dom.svg.setPointerCapture?.(event.pointerId); + refresh(); + return; + } + if (target?.dataset.kind === 'connection') { runtime.selection = { kind: 'connection', id: target.dataset.id }; refresh(); return; } + runtime.selection = null; + runtime.interaction = { mode: 'pan', pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startPanX: runtime.circuit.settings.canvasPanX ?? 0, startPanY: runtime.circuit.settings.canvasPanY ?? 0, moved: false }; + dom.svg.setPointerCapture?.(event.pointerId); + refresh(); +} +function handlePinchMove(event) { + const pointer = runtime.pointers.get(event.pointerId); + if (!pointer) return false; + pointer.x = event.clientX; pointer.y = event.clientY; + if (runtime.pointers.size < 2 || !runtime.pinch) return false; + const [first, second] = [...runtime.pointers.values()]; + const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)); + const centerX = (first.x + second.x) / 2, centerY = (first.y + second.y) / 2; + const newZoom = clamp(runtime.pinch.zoom * distance / Math.max(1, runtime.pinch.distance), .35, 4); + const rect = dom.svg.getBoundingClientRect(); + const viewScaleX = 1100 / Math.max(1, rect.width), viewScaleY = 720 / Math.max(1, rect.height); + runtime.circuit.settings.canvasZoom = newZoom; + runtime.circuit.settings.canvasPanX = runtime.pinch.panX + (centerX - runtime.pinch.centerX) * viewScaleX / newZoom; + runtime.circuit.settings.canvasPanY = runtime.pinch.panY + (centerY - runtime.pinch.centerY) * viewScaleY / newZoom; + renderer.setTransform({ zoom: newZoom, panX: runtime.circuit.settings.canvasPanX, panY: runtime.circuit.settings.canvasPanY }); + updateChrome(); + return true; +} +function handleCanvasPointerMove(event) { + if (handlePinchMove(event)) return; + const interaction = runtime.interaction; + if (!interaction || interaction.pointerId !== event.pointerId) return; + const point = renderer.screenToWorld(event.clientX, event.clientY); + if (interaction.mode === 'draw-connection') { + const routePoint = snapWorldPoint(point); + const last = runtime.previewPoints.at(-1); + if (!last || Math.hypot(routePoint.x - last.x, routePoint.y - last.y) > 5) runtime.previewPoints.push(routePoint); + else runtime.previewPoints[runtime.previewPoints.length - 1] = routePoint; + const candidate = nearestPort(runtime.circuit, point, 34 / (runtime.circuit.settings.canvasZoom ?? 1), interaction.start); + const validation = validateConnection(runtime.circuit, interaction.start, candidate); + interaction.candidate = candidate; + runtime.previewValid = candidate ? validation.ok : null; + if (candidate) runtime.previewPoints[runtime.previewPoints.length - 1] = { x: candidate.x, y: candidate.y }; + renderCanvas(); + } else if (interaction.mode === 'drag-component') { + const component = runtime.circuit.components.find(item => item.id === interaction.id); + if (!component) return; + const snapped = snapWorldPoint({ x: interaction.startX + point.x - interaction.startPoint.x, y: interaction.startY + point.y - interaction.startPoint.y }); + component.x = clamp(snapped.x, -1000, 2200); + component.y = clamp(snapped.y, -1000, 1600); + interaction.moved ||= Math.hypot(point.x - interaction.startPoint.x, point.y - interaction.startPoint.y) > 2; + runtime.solve = solveCircuit(runtime.circuit); renderCanvas(); updateChrome(); + if (dom.physicsDialog.open) updatePhysicsPanel(); + } else if (interaction.mode === 'pan') { + const rect = dom.svg.getBoundingClientRect(); + const viewScaleX = 1100 / Math.max(1, rect.width), viewScaleY = 720 / Math.max(1, rect.height); + runtime.circuit.settings.canvasPanX = interaction.startPanX + (event.clientX - interaction.startClientX) * viewScaleX / (runtime.circuit.settings.canvasZoom ?? 1); + runtime.circuit.settings.canvasPanY = interaction.startPanY + (event.clientY - interaction.startClientY) * viewScaleY / (runtime.circuit.settings.canvasZoom ?? 1); + interaction.moved ||= Math.hypot(event.clientX - interaction.startClientX, event.clientY - interaction.startClientY) > 3; + renderer.setTransform({ zoom: runtime.circuit.settings.canvasZoom, panX: runtime.circuit.settings.canvasPanX, panY: runtime.circuit.settings.canvasPanY }); + } +} +function handleCanvasPointerUp(event) { + runtime.pointers.delete(event.pointerId); + if (runtime.pointers.size < 2) runtime.pinch = null; + const interaction = runtime.interaction; + if (!interaction || interaction.pointerId !== event.pointerId) return; + if (interaction.mode === 'draw-connection') completeConnection(renderer.screenToWorld(event.clientX, event.clientY)); + else if (interaction.mode === 'drag-component') { + if (interaction.moved && interaction.snapshot !== snapshot()) { pushSnapshot(interaction.snapshot); runtime.dirty = true; scheduleAutosave(); } + runtime.interaction = null; refresh(); + } else if (interaction.mode === 'pan') { + runtime.interaction = null; + if (!interaction.moved) runtime.selection = null; + refresh(); + } +} +function zoomCanvas(delta, center = null) { + const oldZoom = runtime.circuit.settings.canvasZoom ?? 1; + const newZoom = clamp(oldZoom * delta, .35, 4); + if (Math.abs(newZoom - oldZoom) < 1e-8) return; + if (center) { + const before = renderer.screenToWorld(center.x, center.y); + runtime.circuit.settings.canvasZoom = newZoom; + renderer.setTransform({ zoom: newZoom, panX: runtime.circuit.settings.canvasPanX, panY: runtime.circuit.settings.canvasPanY }); + const after = renderer.screenToWorld(center.x, center.y); + runtime.circuit.settings.canvasPanX += after.x - before.x; + runtime.circuit.settings.canvasPanY += after.y - before.y; + } else runtime.circuit.settings.canvasZoom = newZoom; + renderer.setTransform({ zoom: newZoom, panX: runtime.circuit.settings.canvasPanX, panY: runtime.circuit.settings.canvasPanY }); + updateChrome(); +} +function fitCircuit() { + if (!runtime.circuit.components.length) { + runtime.circuit.settings.canvasZoom = 1; runtime.circuit.settings.canvasPanX = 0; runtime.circuit.settings.canvasPanY = 0; refresh(); return; + } + const xs = runtime.circuit.components.map(component => component.x), ys = runtime.circuit.components.map(component => component.y); + const minX = Math.min(...xs) - 110, maxX = Math.max(...xs) + 110, minY = Math.min(...ys) - 100, maxY = Math.max(...ys) + 100; + const zoom = clamp(Math.min(1000 / Math.max(200, maxX - minX), 620 / Math.max(180, maxY - minY)), .35, 1.55); + runtime.circuit.settings.canvasZoom = zoom; + runtime.circuit.settings.canvasPanX = 550 / zoom - (minX + maxX) / 2; + runtime.circuit.settings.canvasPanY = 360 / zoom - (minY + maxY) / 2; + refresh(); +} +function focusComponent(id) { + const component = runtime.circuit.components.find(item => item.id === id); + if (!component) return; + const zoom = Math.max(1.55, runtime.circuit.settings.canvasZoom ?? 1); + runtime.circuit.settings.canvasZoom = clamp(zoom, .35, 4); + runtime.circuit.settings.canvasPanX = 550 / runtime.circuit.settings.canvasZoom - component.x; + runtime.circuit.settings.canvasPanY = 360 / runtime.circuit.settings.canvasZoom - component.y; + runtime.selection = { kind: 'component', id }; + refresh(); +} + +function openPhysics(componentId) { + const component = runtime.circuit.components.find(item => item.id === componentId && item.type === 'coupler'); + if (!component) return; + runtime.selectedCouplerId = componentId; + runtime.selection = { kind: 'component', id: componentId }; + runtime.physicsView = 'power'; + couplerView.setView('power', false); + if (!dom.physicsDialog.open) dom.physicsDialog.showModal(); + updatePhysicsPanel(); setPhysicsView('power', false); +} +function setPhysicsView(view, setCanvas = true) { + runtime.physicsView = view; + if (setCanvas) couplerView.setView(view, false); + $$('.physics-tabs [data-view]').forEach(button => { const active = button.dataset.view === view; button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); }); + const meta = couplerViewMeta(view); + dom.depthBadge.textContent = meta.depth; dom.fieldScaleLabel.textContent = meta.scale; dom.couplerModelName.textContent = meta.model; +} +function updatePhysicsPanel() { + const coupler = runtime.circuit.components.find(item => item.id === runtime.selectedCouplerId && item.type === 'coupler'); + if (!coupler) { dom.physicsDialog.close(); return; } + runtime.solve = solveCircuit(runtime.circuit); + couplerView.setData(runtime.circuit, coupler, runtime.solve); + renderer.renderMiniMap(dom.miniMapSvg, runtime.circuit, coupler.id); + dom.gapRange.value = coupler.params.gapUm; dom.lengthRange.value = coupler.params.interactionLengthUm; dom.wavelengthRange.value = runtime.circuit.settings.wavelengthNm; + updatePhysicsMetricsOnly(); renderCanvas(); updateChrome(); +} +function updatePhysicsMetricsOnly() { + const coupler = runtime.circuit.components.find(item => item.id === runtime.selectedCouplerId); + if (!coupler) return; + const coefficient = couplerCoefficients(coupler.params, runtime.circuit.settings.wavelengthNm); + dom.gapOutput.textContent = `${finite(coupler.params.gapUm).toFixed(3)} µm`; + dom.lengthOutput.textContent = `${finite(coupler.params.interactionLengthUm).toFixed(2)} µm`; + dom.wavelengthOutput.textContent = `${finite(runtime.circuit.settings.wavelengthNm).toFixed(0)} nm`; + dom.couplingAngleMetric.textContent = `${coefficient.theta.toFixed(3)} rad`; + dom.crossPowerMetric.textContent = `${(coefficient.crossPower * 100).toFixed(1)}%`; + dom.throughPhaseMetric.textContent = coefficient.t < 0 ? '3.14 rad' : '0.00 rad'; + dom.crossPhaseMetric.textContent = coefficient.k < 0 ? '+1.57 rad' : '−1.57 rad'; + const local0 = localCouplerState(coupler, runtime.solve, 0), local1 = localCouplerState(coupler, runtime.solve, 1); + dom.couplerInputLabel.querySelector('b').textContent = formatPower(abs2(local0.in1) + abs2(local0.in2)); + dom.couplerThroughLabel.querySelector('b').textContent = formatPower(local1.p1); + dom.couplerCrossLabel.querySelector('b').textContent = formatPower(local1.p2); +} +function bindPhysicsRange(input, apply) { + input.addEventListener('pointerdown', beginContinuousEdit); + input.addEventListener('input', () => { + apply(finite(input.value)); runtime.solve = solveCircuit(runtime.circuit); renderCanvas(); updateChrome(); + const coupler = runtime.circuit.components.find(item => item.id === runtime.selectedCouplerId); + couplerView.setData(runtime.circuit, coupler, runtime.solve); updatePhysicsMetricsOnly(); + }); + input.addEventListener('change', endContinuousEdit); +} + +function suggestedSweepRange(parameter) { + const current = parameter.get(runtime.circuit); + if (parameter.id === 'global:wavelengthNm') return { start: Math.max(parameter.min, current - 50), stop: Math.min(parameter.max, current + 50), points: 401 }; + if (parameter.unit === 'rad') return { start: parameter.min, stop: parameter.max, points: 361 }; + const span = parameter.max - parameter.min; + return { start: clamp(current - span * .18, parameter.min, parameter.max), stop: clamp(current + span * .18, parameter.min, parameter.max), points: 301 }; +} +function configureSweepParameter(parameterId) { + const parameter = resolveParameter(runtime.circuit, parameterId); + const range = suggestedSweepRange(parameter); + runtime.lab.sweep = { ...runtime.lab.sweep, parameterId: parameter.id, ...range }; +} +function configureToleranceParameter(parameterId) { + const parameter = resolveParameter(runtime.circuit, parameterId); + const existing = runtime.lab.tolerance.parameters.find(item => item.id === parameter.id); + if (!existing) runtime.lab.tolerance.parameters.push({ id: parameter.id, sigma: parameter.tolerance ?? .01, relative: true }); +} +function openLabPanel(tab = runtime.lab.tab) { + runtime.lab.open = true; + runtime.lab.tab = tab; + dom.labPanel.hidden = false; + selectedMeasurement(); + renderLab(); updateChrome(); + requestAnimationFrame(() => labPlot.resize()); +} +function closeLabPanel() { + runtime.lab.open = false; + dom.labPanel.hidden = true; + runtime.highlightIds = []; + renderCanvas(); updateChrome(); +} +function setLabBusy(busy, title = 'Ready', detail = '', progress = 0) { + runtime.lab.busy = busy; + const status = dom.labStatus.closest('.lab-status'); + status.classList.toggle('busy', busy); status.classList.remove('error'); + dom.labStatus.textContent = title; dom.labProgressText.textContent = detail; + dom.labProgress.hidden = !busy; + dom.labProgressBar.style.width = `${clamp(progress, 0, 1) * 100}%`; +} +function setLabError(error) { + runtime.lab.busy = false; + const status = dom.labStatus.closest('.lab-status'); + status.classList.remove('busy'); status.classList.add('error'); + dom.labStatus.textContent = 'Analysis stopped'; dom.labProgressText.textContent = error?.message ?? String(error); + dom.labProgress.hidden = true; +} +function updateLabProgress(fraction, detail = null) { + dom.labProgressBar.style.width = `${clamp(fraction, 0, 1) * 100}%`; + if (detail) dom.labProgressText.textContent = detail; +} +function measurementOptions(selectedId = runtime.lab.selectedMeasurementId) { + return pinnedMeasurements().map(spec => ``).join(''); +} +function parameterOptions(selectedId) { + const groups = new Map(); + const active = listSweepParameters(runtime.circuit).filter(parameter => parameter.scope !== 'pulse' && parameter.scope !== 'electrical'); + for (const parameter of active) { + const group = parameter.kind === 'global' ? 'Global' : parameter.kind === 'connection' ? 'Waveguides' : labelForType(parameter.componentType); + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(parameter); + } + return [...groups.entries()].map(([label, parameters]) => `${parameters.map(parameter => ``).join('')}`).join(''); +} +function renderMeasurementChips() { + return `
${pinnedMeasurements().map((spec, index) => `${escapeHTML(measurementLabel(runtime.circuit, spec))}`).join('')}
`; +} +function renderSweepControls() { + const activeParameters = listSweepParameters(runtime.circuit).filter(parameter => parameter.scope !== 'pulse' && parameter.scope !== 'electrical'); + if (activeParameters.length && !activeParameters.some(parameter => parameter.id === runtime.lab.sweep.parameterId)) configureSweepParameter(activeParameters[0].id); + const sweep = runtime.lab.sweep; + const result = runtime.lab.resultTab === 'sweep' ? runtime.lab.result : null; + return `
Pinned measurementsClick a trace target to focus it
${renderMeasurementChips()}
+
Sweep parameterComplex circuit solve at every point
+ +
+ + + +
+ +
+ ${result ? `
${result.x.length} operating points · ${result.elapsedMs.toFixed(1)} ms total

Mean solve ${result.solveTimeMeanMs.toFixed(3)} ms · ${result.failures} failed points. Click the plot to apply an operating point to the live circuit.

` : ''}`; +} +function defaultTuneKnobs() { + const preferred = listSweepParameters(runtime.circuit).filter(parameter => parameter.id !== 'global:wavelengthNm' && (parameter.key === 'phaseRad' || parameter.key === 'gapUm' || parameter.key === 'interactionLengthUm' || parameter.key === 'schematicLengthUm')); + return preferred.slice(0, 4).map(parameter => parameter.id); +} +function renderTuneControls() { + const measurements = pinnedMeasurements(); + if (!runtime.lab.tune.parameterIds.length) runtime.lab.tune.parameterIds = defaultTuneKnobs(); + const parameters = listSweepParameters(runtime.circuit).filter(parameter => parameter.id !== 'global:wavelengthNm' && parameter.scope !== 'pulse' && parameter.scope !== 'electrical'); + const result = runtime.lab.optimizationResult; + return `
GoalCoordinate refinement · up to four knobs
+ +
+
+ ${runtime.lab.tune.goal === 'target' ? `` : ''} + ${runtime.lab.tune.goal === 'contrast' ? `` : ''} +
+
Tuning knobsSelect 1–4
${parameters.map(parameter => ``).join('')}
+
+ ${result ? `
Objective ${humanNumber(result.before.value)} → ${humanNumber(result.after.value)}

${result.evaluations} evaluations · ${result.settings.map(setting => `${setting.label} = ${humanNumber(setting.value)} ${setting.unit}`).join(' · ')}

` : ''}`; +} +function ensureToleranceDefaults() { + if (runtime.lab.tolerance.parameters.length) return; + const candidates = listSweepParameters(runtime.circuit).filter(parameter => parameter.id !== 'global:wavelengthNm' && parameter.scope !== 'pulse' && parameter.scope !== 'electrical'); + runtime.lab.tolerance.parameters = candidates.slice(0, 3).map(parameter => ({ id: parameter.id, sigma: parameter.tolerance ?? .01, relative: true })); +} +function renderToleranceControls() { + ensureToleranceDefaults(); + const parameters = listSweepParameters(runtime.circuit).filter(parameter => parameter.scope !== 'pulse' && parameter.scope !== 'electrical'); + const selectedMap = new Map(runtime.lab.tolerance.parameters.map(item => [item.id, item])); + const result = runtime.lab.toleranceResult; + const nominal = readMeasurement(runtime.circuit, runtime.solve, selectedMeasurement()).value; + const threshold = runtime.lab.tolerance.threshold ?? nominal * .9; + return `
Yield measurementMonte Carlo + ±3σ corners
+
+ +
+
Fabrication / model variationsσ is relative unless marked absolute
${parameters.map(parameter => { const item = selectedMap.get(parameter.id); return ``; }).join('')}
+ ${result ? `
${(100 * result.yield).toFixed(1)}% yield · σ ${humanNumber(result.std)}

P05 ${humanNumber(result.p05)} · median ${humanNumber(result.p50)} · P95 ${humanNumber(result.p95)} · worst ±3σ corner ${humanNumber(result.worstCorner?.value)}

Sensitivity rankingClick to locate
${result.sensitivities.map(item => ``).join('')}
` : ''}`; +} +function renderPulseControls() { + const source = primarySource(); + const result = runtime.lab.pulseResult; + return `
Temporal reconstructionFFT-style frequency sweep of H(ω)
+ +
+ +
What is solved

A transform-limited Gaussian spectrum is propagated through the complex circuit response, inverse-transformed, then filtered by detector bandwidth. Repetition rate sets train spacing, never optical velocity.

+ +
+ ${result ? `
Delay ${result.metrics.delayPs.toFixed(3)} ps · FWHM ${result.metrics.outputFwhmPs.toFixed(3)} ps

Broadening ${result.metrics.broadening?.toFixed(3) ?? '—'}× · peak transmission ${(100 * result.metrics.peakTransmission).toFixed(2)}% · train period ${result.metrics.repetitionPeriodPs.toFixed(1)} ps${result.detectorBandwidthGhz ? ` · detector ${result.detectorBandwidthGhz.toFixed(1)} GHz` : ''}

` : ''}`; +} +function renderLabMetrics() { + const result = runtime.lab.result; + if (!result) { dom.labMetrics.innerHTML = ''; return; } + if (result.kind === 'sweep') { + const first = result.metrics?.[0] ?? {}; + const cells = [ + ['Max', humanNumber(first.max)], ['Extinction', first.extinctionDb == null ? '—' : `${first.extinctionDb.toFixed(2)} dB`], + ['Insertion loss', first.insertionLossDb == null ? '—' : `${first.insertionLossDb.toFixed(2)} dB`], ['3 dB width', humanNumber(first.bandwidth3dB)], + ['FSR', first.fsr == null ? '—' : humanNumber(first.fsr)], + ['Group delay', first.groupDelayAtMaxPs == null ? '—' : `${first.groupDelayAtMaxPs.toFixed(3)} ps`], + ['Phase span', first.phaseExcursionRad == null ? '—' : `${first.phaseExcursionRad.toFixed(2)} rad`], + ...(result.comparison ? [['Crossover', humanNumber(result.comparison.crossoverX)]] : []) + ]; + dom.labMetrics.innerHTML = cells.map(([label, value]) => `${escapeHTML(label)}${escapeHTML(value)}`).join(''); + } else if (result.kind === 'tolerance') dom.labMetrics.innerHTML = `Yield${(100 * result.yield).toFixed(1)}%P05${humanNumber(result.p05)}P95${humanNumber(result.p95)}`; + else if (result.kind === 'pulse') dom.labMetrics.innerHTML = `Delay${result.metrics.delayPs.toFixed(3)} psOutput FWHM${result.metrics.outputFwhmPs.toFixed(3)} psPeak${(100 * result.metrics.peakTransmission).toFixed(1)}%`; +} +function renderLab() { + dom.labPanel.hidden = !runtime.lab.open; + dom.labPanel.classList.toggle('compact', runtime.lab.compact); + dom.labMinimize?.setAttribute('aria-label', runtime.lab.compact ? 'Expand Lab' : 'Minimize Lab to live sparkline'); + if (!runtime.lab.open) return; + $$('#labTabs [data-lab-tab]').forEach(button => { const active = button.dataset.labTab === runtime.lab.tab; button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); }); + dom.labSubtitle.textContent = ({ sweep: 'Measure across the design space', tune: 'Turn targets into settings', tolerance: 'See whether the design survives variation', pulse: 'Reconstruct temporal response from H(ω)' })[runtime.lab.tab]; + if (runtime.lab.tab === 'sweep') dom.labControls.innerHTML = renderSweepControls(); + else if (runtime.lab.tab === 'tune') dom.labControls.innerHTML = renderTuneControls(); + else if (runtime.lab.tab === 'tolerance') dom.labControls.innerHTML = renderToleranceControls(); + else dom.labControls.innerHTML = renderPulseControls(); + dom.labPlotToolbar.innerHTML = runtime.lab.result?.kind === 'sweep' ? `${runtime.lab.baselines.length ? `` : ''}` : ''; + if (runtime.lab.result && runtime.lab.resultTab === runtime.lab.tab) labPlot.setData(runtime.lab.result, { baselines: runtime.lab.result.kind === 'sweep' ? runtime.lab.baselines : [], cursorIndex: runtime.lab.cursorIndex }); + else labPlot.clear(); + renderLabMetrics(); +} +function readLabField(path, value) { + const parts = path.split('.'); + if (parts.length === 1) runtime.lab[parts[0]] = value; + else runtime.lab[parts[0]][parts[1]] = value; +} +function getMeasurementById(id) { return pinnedMeasurements().find(spec => spec.id === id) ?? selectedMeasurement(); } +async function runSweepAnalysis() { + if (runtime.lab.busy) return; + const token = ++runtime.operationToken; + setLabBusy(true, 'Sweeping', `Solving ${runtime.lab.sweep.points} operating points…`); + renderLab(); + try { + const result = await sweepCircuitAsync(runtime.circuit, runtime.lab.sweep, pinnedMeasurements(), fraction => updateLabProgress(fraction, `Sweep ${(100 * fraction).toFixed(0)}%`)); + if (token !== runtime.operationToken) return; + runtime.lab.result = result; runtime.lab.resultTab = 'sweep'; runtime.lab.cursorIndex = result.currentIndex; + setLabBusy(false, 'Sweep complete', `${result.x.length} points · ${result.elapsedMs.toFixed(1)} ms`); + renderLab(); + } catch (error) { setLabError(error); renderLab(); } +} +async function runTuneAnalysis() { + if (runtime.lab.busy) return; + const measurement = selectedMeasurement(); + const secondary = getMeasurementById(runtime.lab.tune.secondaryMeasurementId); + const token = ++runtime.operationToken; + setLabBusy(true, 'Tuning', 'Exploring selected compact-model knobs…'); renderLab(); + try { + const result = await optimizeCircuit(runtime.circuit, { + measurement, + secondaryMeasurement: runtime.lab.tune.goal === 'contrast' ? secondary : null, + goal: runtime.lab.tune.goal, + targetValue: runtime.lab.tune.targetValue, + robustWindowNm: runtime.lab.tune.robustWindowNm, + parameterIds: runtime.lab.tune.parameterIds, + passes: 4, samplesPerPass: 33 + }, fraction => updateLabProgress(fraction, `Tune ${(100 * fraction).toFixed(0)}%`)); + if (token !== runtime.operationToken) return; + runtime.lab.optimizationResult = result; + setLabBusy(false, 'Target found', `${result.evaluations} compact-circuit evaluations`); renderLab(); + } catch (error) { setLabError(error); renderLab(); } +} +async function runToleranceAnalysis() { + if (runtime.lab.busy) return; + const token = ++runtime.operationToken; + const threshold = runtime.lab.tolerance.threshold ?? readMeasurement(runtime.circuit, runtime.solve, selectedMeasurement()).value * .9; + setLabBusy(true, 'Sampling variation', `${runtime.lab.tolerance.samples} Monte Carlo samples + corners…`); renderLab(); + try { + const result = await runTolerance(runtime.circuit, { + measurement: selectedMeasurement(), parameters: runtime.lab.tolerance.parameters, + samples: runtime.lab.tolerance.samples, threshold, criterion: runtime.lab.tolerance.criterion, + seed: runtime.lab.tolerance.seed + }, fraction => updateLabProgress(fraction, `Yield analysis ${(100 * fraction).toFixed(0)}%`)); + if (token !== runtime.operationToken) return; + runtime.lab.toleranceResult = result; runtime.lab.result = result; runtime.lab.resultTab = 'tolerance'; + setLabBusy(false, 'Yield calculated', `${(100 * result.yield).toFixed(1)}% pass rate · ${result.samples} samples`); renderLab(); + } catch (error) { setLabError(error); renderLab(); } +} +async function runPulseAnalysis() { + if (runtime.lab.busy) return; + const token = ++runtime.operationToken; + setLabBusy(true, 'Reconstructing pulse', 'Sampling the complex spectral transfer function…'); renderLab(); + try { + const result = await simulatePulse(runtime.circuit, selectedMeasurement(), runtime.lab.pulse, fraction => updateLabProgress(fraction, `Pulse reconstruction ${(100 * fraction).toFixed(0)}%`)); + if (token !== runtime.operationToken) return; + runtime.lab.pulseResult = result; runtime.lab.result = result; runtime.lab.resultTab = 'pulse'; + setLabBusy(false, 'Pulse reconstructed', `${result.samples} spectral/time samples`); renderLab(); + } catch (error) { setLabError(error); renderLab(); } +} +function applySweepPoint(index = runtime.lab.cursorIndex) { + const result = runtime.lab.result; + if (result?.kind !== 'sweep' || !Number.isInteger(index)) return; + const value = result.x[clamp(index, 0, result.x.length - 1)]; + const parameter = resolveParameter(runtime.circuit, result.parameter.id); + mutateCircuit(circuit => parameter.set(circuit, value), { lab: false }); + runtime.lab.cursorIndex = index; + showToast(`${result.parameter.label} set to ${humanNumber(value)} ${result.parameter.unit ?? ''}`.trim()); + labPlot.setData(result, { baselines: runtime.lab.baselines, cursorIndex: index }); +} +function keepBaseline() { + const result = runtime.lab.result; + if (result?.kind !== 'sweep') return; + runtime.lab.baselines.push({ name: `Baseline ${runtime.lab.baselines.length + 1}`, x: [...result.x], traces: result.traces.map(trace => ({ label: trace.label, unit: trace.unit, values: [...trace.values] })) }); + if (runtime.lab.baselines.length > 4) runtime.lab.baselines.shift(); + renderLab(); +} +function exportSweepCSV() { + const result = runtime.lab.result; + if (result?.kind !== 'sweep') return; + const rows = [[`${result.parameter.label} (${result.parameter.unit ?? ''})`, ...result.traces.map(trace => `${trace.label} (${trace.unit ?? ''})`)]]; + result.x.forEach((x, index) => rows.push([x, ...result.traces.map(trace => trace.values[index])])); + downloadText(`${slug(runtime.circuit.name)}-sweep.csv`, rows.map(row => row.join(',')).join('\n'), 'text/csv'); +} +function highlightParameter(parameterId) { + const parameter = resolveParameter(runtime.circuit, parameterId); + runtime.highlightIds = [parameter.componentId ?? parameter.connectionId].filter(Boolean); + if (parameter.componentId) runtime.selection = { kind: 'component', id: parameter.componentId }; + else if (parameter.connectionId) runtime.selection = { kind: 'connection', id: parameter.connectionId }; + renderCanvas(); renderInspector(); +} +function bindLabDelegation() { + dom.labTabs.addEventListener('click', event => { + const button = event.target.closest('[data-lab-tab]'); if (!button) return; + runtime.lab.tab = button.dataset.labTab; runtime.highlightIds = []; renderCanvas(); renderLab(); + }); + const handler = event => { + const field = event.target.closest('[data-lab-field]'); + if (field) { + const path = field.dataset.labField; + const numeric = field.type === 'number' || field.type === 'range'; + readLabField(path, numeric ? finite(field.value) : field.value); + if (path === 'sweep.parameterId') configureSweepParameter(field.value); + if (path === 'selectedMeasurementId') runtime.lab.selectedMeasurementId = field.value; + if (event.type === 'change') renderLab(); + return; + } + const knob = event.target.closest('[data-lab-knob]'); + if (knob) { + const id = knob.dataset.labKnob; + if (knob.checked && !runtime.lab.tune.parameterIds.includes(id) && runtime.lab.tune.parameterIds.length < 4) runtime.lab.tune.parameterIds.push(id); + else if (!knob.checked) runtime.lab.tune.parameterIds = runtime.lab.tune.parameterIds.filter(item => item !== id); + else if (knob.checked && runtime.lab.tune.parameterIds.length >= 4) { knob.checked = false; showToast('Tune supports up to four simultaneous knobs.'); } + return; + } + const toleranceEnable = event.target.closest('[data-tolerance-enable]'); + if (toleranceEnable) { + const id = toleranceEnable.dataset.toleranceEnable; + const descriptor = resolveParameter(runtime.circuit, id); + if (toleranceEnable.checked && !runtime.lab.tolerance.parameters.some(item => item.id === id)) runtime.lab.tolerance.parameters.push({ id, sigma: descriptor.tolerance ?? .01, relative: true }); + else if (!toleranceEnable.checked) runtime.lab.tolerance.parameters = runtime.lab.tolerance.parameters.filter(item => item.id !== id); + return; + } + const sigma = event.target.closest('[data-tolerance-sigma]'); + if (sigma) { + const item = runtime.lab.tolerance.parameters.find(parameter => parameter.id === sigma.dataset.toleranceSigma); + if (item) item.sigma = Math.max(0, finite(sigma.value)); + } + }; + dom.labControls.addEventListener('input', handler); + dom.labControls.addEventListener('change', handler); + const clickHandler = event => { + const action = event.target.closest('[data-lab-action]'); if (!action) return; + const name = action.dataset.labAction; + if (name === 'run-sweep') runSweepAnalysis(); + else if (name === 'run-tune') runTuneAnalysis(); + else if (name === 'run-tolerance') runToleranceAnalysis(); + else if (name === 'run-pulse') runPulseAnalysis(); + else if (name === 'apply-tune' && runtime.lab.optimizationResult) replaceCircuit(runtime.lab.optimizationResult.circuit, { toast: 'Tuned design applied to the canvas.' }); + else if (name === 'keep-baseline') keepBaseline(); + else if (name === 'export-sweep') exportSweepCSV(); + else if (name === 'apply-current-point') applySweepPoint(); + else if (name === 'clear-baselines') { runtime.lab.baselines = []; renderLab(); } + else if (name === 'remove-measurement') { event.stopPropagation(); removeMeasurement(action.dataset.id); } + else if (name === 'select-measurement') { runtime.lab.selectedMeasurementId = action.dataset.id; renderLab(); } + else if (name === 'highlight-parameter') highlightParameter(action.dataset.id); + }; + dom.labControls.addEventListener('click', clickHandler); + dom.labPlotToolbar.addEventListener('click', clickHandler); +} + +function showSheet(title, subtitle, html) { + dom.sheetTitle.textContent = title; dom.sheetSubtitle.textContent = subtitle; dom.sheetContent.innerHTML = html; + if (!dom.sheetDialog.open) dom.sheetDialog.showModal(); +} +function openProjectMenu() { + showSheet('Experiments', 'Start, save, import, or hand off', `
+ + ${CIRCUIT_TEMPLATES.map(template => ``).join('')} + + +
`); +} +function openModelSheet() { + const budget = runtime.solve.powerBudget; + showSheet('Active physics model', 'Compact coherent network · explicit model contracts', `
+

Every optical port carries incoming and outgoing complex amplitudes. Components supply b = Sa + s; connected waveguides add wavelength-dependent complex transmission. The whole graph is solved simultaneously, including coherent feedback and counter-propagating amplitudes.

+

Trust contract

  • The displayed solver residual is only the linear-system residual.
  • The optical power budget separately accounts for detectors, terminations, component loss, exact-curve waveguide loss, source absorption, open ports, and model gain.
  • In physical-routing mode, the solver integrates the same cubic route that is rendered. In schematic mode, optical length is an explicit parameter.
  • Every parameter states whether it affects the CW optical model, pulse analysis, or electrical readout.
+

Scope

  • Scalar single-mode compact models; not a cross-section Maxwell solve.
  • Analytical, imported, and hierarchical model provenance remains visible.
  • Pulse Lab samples the complex frequency response and reconstructs the temporal envelope. Canvas packet motion is timing-compressed explanatory animation.
+ ${budget ? `

Current power closure

Launched ${formatPower(budget.launchedMw)}; accounted ${formatPower(budget.accountedMw)}; balance error ${formatPower(Math.abs(budget.balanceErrorMw))}; solver residual ${formatResidual(runtime.solve.residual)}.

` : ''} +
`); +} +function openPowerBudgetSheet() { + const budget = runtime.solve.powerBudget; + if (!budget) { showToast('No power budget is available for the current unsolved circuit.'); return; } + const entries = [ + ['Detected', budget.detectedMw], ['Terminations', budget.terminationMw], ['Component loss', budget.componentLossMw], ['Waveguide loss', budget.waveguideLossMw], + ['Source absorption', budget.sourceAbsorptionMw], ['Open ports', budget.openPortMw], ['Model gain', -budget.modelGainMw], ['Balance error', budget.balanceErrorMw] + ]; + const positive = Math.max(budget.launchedMw, 1e-15); + showSheet('Optical power budget', 'Conservation is separate from solver convergence', `

The network solver residual is ${formatResidual(runtime.solve.residual)}. The table below is the physical power accounting at ${runtime.solve.wavelengthNm.toFixed(2)} nm.

+
+
Launched${formatPower(budget.launchedMw)}Accounted${formatPower(budget.accountedMw)}${entries.map(([label, value]) => `${escapeHTML(label)}${formatPower(value)}`).join('')}
+

Interpretation

A large balance error points to a non-passive or approximate compact model, an open path, or an imported S matrix that requires inspection. A small residual only says the assembled linear equations were solved accurately.

`); +} +function openParameterLinkSheet(parameterId) { + const parameter = resolveParameter(runtime.circuit, parameterId); + const link = parameterLinkFor(runtime.circuit, parameterId); + const currentMembers = link?.members ?? [parameterId]; + const descriptors = new Map(listSweepParameters(runtime.circuit).map(item => [item.id, item])); + const candidates = listSweepParameters(runtime.circuit).filter(item => item.id !== parameterId && !currentMembers.includes(item.id) && (item.unit ?? '') === (parameter.unit ?? '') && (item.scope ?? 'cw') === (parameter.scope ?? 'cw')); + const memberList = currentMembers.map(id => descriptors.get(id)).filter(Boolean); + showSheet('Linked parameter', `${parameter.label} · synchronized numeric value`, `

Linked controls move together in the canvas, sweeps, tuning, and tolerance runs. PicSetup only offers parameters with the same unit and active model scope.

${link ? `

${escapeHTML(link.name)}

    ${memberList.map(item => `
  • ${escapeHTML(item.label)}
  • `).join('')}
` : '

This parameter is not linked yet.

'}
+ ${candidates.length ? candidates.slice(0, 80).map(item => ``).join('') : '

No other compatible parameters are available in this circuit.

'} + ${link ? `` : ''} +
`); +} + +function openRoutingSheet() { + const settings = runtime.circuit.settings; + const violations = [...(runtime.solve?.connections?.values?.() ?? [])].filter(connection => connection.bendViolation); + showSheet('Routing model', 'Choose what the drawing means physically', `

Physical routing

The exact rendered cubic curve is numerically integrated. Moving components or changing a route changes physical length, phase, and loss. Grid snapping is stated in micrometres and converted through the visible canvas scale.

Schematic routing

Route shape communicates topology only. Every waveguide stores an independent optical length, so visual cleanup cannot silently change phase.

${violations.length ? `

${violations.length} route${violations.length === 1 ? '' : 's'} below the declared minimum bend radius. They are highlighted on the canvas; bend loss is not silently invented by the compact model.

` : ''}
+ + + + + + +
`); +} + +function rotatedPoint(component, x, y) { + const angle = finite(component.rotation, 0) * Math.PI / 180; + const cos = Math.cos(angle), sin = Math.sin(angle); + return { x: finite(component.x) + x * cos - y * sin, y: finite(component.y) + x * sin + y * cos }; +} +function figureWorldBounds(circuit = runtime.circuit) { + const points = []; + for (const component of circuit.components ?? []) { + const bounds = componentBounds(component); + for (const [x, y] of [[bounds.x,bounds.y],[bounds.x+bounds.width,bounds.y],[bounds.x+bounds.width,bounds.y+bounds.height],[bounds.x,bounds.y+bounds.height]]) points.push(rotatedPoint(component,x,y)); + } + for (const connection of circuit.connections ?? []) points.push(...getConnectionPoints(connection, circuit.components)); + if (!points.length) return { x: 0, y: 0, width: 1100, height: 720 }; + const padding = finite(circuit.settings?.figurePadding, 36); + const xs = points.map(point => point.x), ys = points.map(point => point.y); + const minX = Math.min(...xs)-padding, maxX=Math.max(...xs)+padding, minY=Math.min(...ys)-padding, maxY=Math.max(...ys)+padding; + return { x:minX, y:minY, width:Math.max(1,maxX-minX), height:Math.max(1,maxY-minY) }; +} +function embeddedStyles() { + const chunks=[]; + for (const sheet of [...document.styleSheets]) { + try { chunks.push([...sheet.cssRules].map(rule => rule.cssText).join('\n')); } + catch { /* cross-origin styles are intentionally omitted */ } + } + return chunks.join('\n'); +} +function buildPaperSvgText() { + const bounds = figureWorldBounds(); + const clone = dom.svg.cloneNode(true); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + clone.setAttribute('viewBox', `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + clone.setAttribute('width', String(Math.ceil(bounds.width))); + clone.setAttribute('height', String(Math.ceil(bounds.height))); + clone.dataset.figureMode = 'paper'; + clone.dataset.semantic = 'detail'; + clone.dataset.paperBackground = runtime.circuit.settings.paperBackground === 'transparent' ? 'transparent' : 'white'; + clone.dataset.showPorts = runtime.circuit.settings.showPortsInFigure === false ? 'false' : 'true'; + for (const id of ['gridLayer','backgroundLayer','connectionLayer','pulseLayer','componentLayer','interactionLayer']) clone.querySelector(`#${id}`)?.removeAttribute('transform'); + clone.querySelector('#pulseLayer')?.replaceChildren(); + clone.querySelector('#interactionLayer')?.replaceChildren(); + if (!runtime.circuit.settings.showGridInFigure) clone.querySelector('#gridLayer')?.remove(); + clone.querySelectorAll('.selection-ring,.component-inspect,.measurement-pin,.connection-hit,.port-hit,.bend-warning').forEach(node => node.remove()); + if (runtime.circuit.settings.showPortsInFigure === false) clone.querySelectorAll('.port,.port-label').forEach(node => node.remove()); + clone.querySelectorAll('[tabindex],[role],[aria-label],[data-kind],[data-action]').forEach(node => { + node.removeAttribute('tabindex'); node.removeAttribute('role'); node.removeAttribute('aria-label'); node.removeAttribute('data-kind'); node.removeAttribute('data-action'); + }); + const bg=clone.querySelector('.canvas-bg'); + if (bg) { + bg.setAttribute('x',String(bounds.x)); bg.setAttribute('y',String(bounds.y)); bg.setAttribute('width',String(bounds.width)); bg.setAttribute('height',String(bounds.height)); + bg.setAttribute('fill', runtime.circuit.settings.paperBackground === 'transparent' ? 'transparent' : '#fff'); + } + const style=document.createElementNS('http://www.w3.org/2000/svg','style'); + style.textContent=embeddedStyles(); + (clone.querySelector('defs') ?? clone).append(style); + return { text:`\n${new XMLSerializer().serializeToString(clone)}`, bounds }; +} +function downloadBlob(filename, blob) { + const url=URL.createObjectURL(blob), anchor=document.createElement('a'); + anchor.href=url; anchor.download=filename; document.body.append(anchor); anchor.click(); anchor.remove(); + setTimeout(()=>URL.revokeObjectURL(url),1000); +} +function exportPaperSvg() { + const { text }=buildPaperSvgText(); + downloadText(`${slug(runtime.circuit.name)}-paper.svg`, text, 'image/svg+xml'); + showToast('Publication SVG exported with embedded styles.'); +} +async function exportPaperPng() { + const { text, bounds }=buildPaperSvgText(); + const blob=new Blob([text],{type:'image/svg+xml'}), url=URL.createObjectURL(blob), image=new Image(); + try { + await new Promise((resolve,reject)=>{ image.onload=resolve; image.onerror=()=>reject(new Error('SVG render failed')); image.src=url; }); + const scale=Math.min(3, Math.max(1.5, 2400/Math.max(bounds.width,bounds.height))); + const canvas=document.createElement('canvas'); + canvas.width=Math.max(1,Math.round(bounds.width*scale)); canvas.height=Math.max(1,Math.round(bounds.height*scale)); + const context=canvas.getContext('2d'); + if (runtime.circuit.settings.paperBackground !== 'transparent') { context.fillStyle='#fff'; context.fillRect(0,0,canvas.width,canvas.height); } + context.drawImage(image,0,0,canvas.width,canvas.height); + const png=await new Promise(resolve=>canvas.toBlob(resolve,'image/png',1)); + if (!png) throw new Error('PNG encoding failed'); + downloadBlob(`${slug(runtime.circuit.name)}-paper.png`,png); + showToast('High-resolution publication PNG exported.'); + } catch (error) { showToast(error.message); } + finally { URL.revokeObjectURL(url); } +} +function openFigureSheet() { + const settings=runtime.circuit.settings; + const bridges=bridgeManifest(runtime.circuit,runtime.solve,{returnUrl:location.href}); + showSheet('Paper figure studio','Compose complete experimental figures and export clean artwork',`

Paper mode keeps simulated PIC components, external optical equipment, electrical/RF/control wiring, chip boundaries, image panels, plots, labels, and typed bridge metadata in one figure. Only optical connections enter the coherent solve.

${runtime.circuit.components.length} objects · ${runtime.circuit.connections.length} links · ${bridges.length} OpticalSetup bridge${bridges.length===1?'':'s'}.

+ + + + + + + + +
`); +} + +function openMoreSheet() { + showSheet('Workflow', 'Explain, share, and hand off', `
+ + + + + + + + + +
`); +} +function openGuideSheet() { + showSheet('Interaction guide', 'Think through a circuit, then hand it off', `

Build

Place any compact model from the categorized library. Draw between compatible free optical ports. Hold Shift/Ctrl/Cmd while selecting components to create a reusable hierarchical block.

Measure

Pin a detector, probe, port, or waveguide measurement. Open Lab to sweep any parameter, derive metrics, tune a target, test yield, or reconstruct pulses.

Navigate

Pinch or scroll to zoom; drag empty canvas to pan. Labels and port details appear automatically at deeper semantic zoom. Double-click a component to focus it.

Keyboard

Delete removes selection; Ctrl/Cmd+Z undoes; Space pauses visualization; L opens Lab; Esc cancels or closes the active layer.

`); +} +function openBlockSheet(id) { + const block = runtime.circuit.components.find(item => item.id === id && item.type === 'block'); + if (!block) return; + const sub = block.params?.subcircuit; + const ports = getPorts(block); + showSheet(block.name, 'Hierarchical compact model · live reduction', `

This external ${ports.length}-port scattering model is extracted from the stored passive subcircuit at every wavelength solve.

External ports

    ${ports.map(port => `
  • ${escapeHTML(port.label)} → ${escapeHTML(port.internalEndpoint?.component ?? '?')}:${escapeHTML(port.internalEndpoint?.port ?? '?')}
  • `).join('')}

Internal graph

${sub?.components?.length ?? 0} components · ${sub?.connections?.length ?? 0} waveguides. ${sub?.components?.map(component => component.name).join(', ') ?? ''}

`); +} +function slug(value) { return String(value).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase() || 'picsetup'; } +async function shareCircuit() { + const hash = circuitHash(); + const url = `${location.href.split('#')[0]}${hash}`; + try { await navigator.clipboard.writeText(url); showToast('Exact semantic circuit link copied.'); } + catch { history.replaceState(null, '', hash); showToast('Share state placed in the address bar.'); } +} +function exportJSON(data = runtime.circuit, suffix = '') { downloadText(`${slug(runtime.circuit.name)}${suffix}.json`, JSON.stringify(data, null, 2), 'application/json'); } +function chooseFile(accept, handler) { + const input = document.createElement('input'); input.type = 'file'; input.accept = accept; + input.addEventListener('change', async () => { const file = input.files?.[0]; if (file) await handler(file); }); input.click(); +} +function importJSON() { + chooseFile('application/json,.json', async file => { + try { + const data = JSON.parse(await file.text()); + const circuit = data.schema?.startsWith?.('picsetup.semantic') ? importSemanticNetlist(data) : data; + replaceCircuit(circuit, { toast: 'Experiment imported.' }); + } catch (error) { showToast(`Import failed: ${error.message}`); } + }); +} +function importSParameters(replaceComponentId = null) { + chooseFile('.json,.csv,application/json,text/csv', async file => { + try { + const params = parseSParameterData(await file.text(), file.name); + mutateCircuit(circuit => { + const existing = replaceComponentId ? circuit.components.find(item => item.id === replaceComponentId && item.type === 'generic') : null; + if (existing) existing.params = { ...existing.params, ...params }; + else { + const component = makeSParameterComponent(params, { x: 550, y: 360 }); + component.id = uniqueId('sblock'); circuit.components.push(component); runtime.selection = { kind: 'component', id: component.id }; + } + }, { lab: true }); + showToast('S-parameter model imported with provenance and wavelength table.'); + } catch (error) { showToast(`S-parameter import failed: ${error.message}`); } + }); +} +function setPhysicalScale() { + const current = finite(runtime.circuit.settings.worldToUm, DEFAULT_WORLD_TO_UM); + const value = prompt('Physical scale in micrometres per canvas unit:', String(current)); + if (value === null) return; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return showToast('Scale must be a positive number.'); + mutateCircuit(circuit => { circuit.settings.worldToUm = clamp(parsed, 1e-4, 100); }); + showToast('Physical canvas scale updated. Existing physical paths now use the new scale.'); +} +function setGridSpacing() { + const current = finite(runtime.circuit.settings.gridUm, 5); + const value = prompt('Physical grid spacing in micrometres:', String(current)); + if (value === null) return; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return showToast('Grid spacing must be a positive number.'); + mutateCircuit(circuit => { circuit.settings.gridUm = clamp(parsed, .01, 10000); }); + showToast('Physical snap grid updated.'); +} +function setMinimumBendRadius() { + const current = finite(runtime.circuit.settings.minBendRadiusUm, 0); + const value = prompt('Declared minimum bend radius in micrometres (validation only):', String(current)); + if (value === null) return; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return showToast('Minimum bend radius must be zero or positive.'); + mutateCircuit(circuit => { circuit.settings.minBendRadiusUm = clamp(parsed, 0, 100000); }); + showToast('Minimum bend-radius validation updated.'); +} +function handleSheetAction(event) { + const button = event.target.closest('[data-sheet-action]'); if (!button) return; + const action = button.dataset.sheetAction; + dom.sheetDialog.close(); + if (action === 'blank') replaceCircuit(makeBlankCircuit(), { toast: 'Blank circuit ready.' }); + else if (action === 'template') replaceCircuit(makeTemplate(button.dataset.templateId), { toast: `${button.querySelector('b')?.textContent ?? 'Template'} loaded.` }); + else if (action === 'save') { localStorage.setItem('picsetup.explicit-save', JSON.stringify(runtime.circuit)); showToast('Experiment saved in this browser.'); } + else if (action === 'load') { const stored = localStorage.getItem('picsetup.explicit-save'); stored ? replaceCircuit(JSON.parse(stored), { toast: 'Saved experiment restored.' }) : showToast('No explicit browser save exists yet.'); } + else if (action === 'lab') openLabPanel(); + else if (action === 'figure') openFigureSheet(); + else if (action === 'toggle-figure-mode') mutateCircuit(circuit => { circuit.settings.figureMode = circuit.settings.figureMode === 'paper' ? 'workbench' : 'paper'; }); + else if (action === 'export-svg') exportPaperSvg(); + else if (action === 'export-png') exportPaperPng(); + else if (action === 'toggle-figure-ports') mutateCircuit(circuit => { circuit.settings.showPortsInFigure = circuit.settings.showPortsInFigure === false; }); + else if (action === 'toggle-figure-grid') mutateCircuit(circuit => { circuit.settings.showGridInFigure = !circuit.settings.showGridInFigure; }); + else if (action === 'toggle-paper-background') mutateCircuit(circuit => { circuit.settings.paperBackground = circuit.settings.paperBackground === 'transparent' ? 'white' : 'transparent'; }); + else if (action === 'export-bridges') downloadText(`${slug(runtime.circuit.name)}-setup-ports.json`, JSON.stringify({ schema: 'setup-project/1', bridges: bridgeManifest(runtime.circuit, runtime.solve, { returnUrl: location.href }) }, null, 2), 'application/json'); + else if (action === 'export-json') exportJSON(); + else if (action === 'export-netlist') downloadText(`${slug(runtime.circuit.name)}-netlist.json`, JSON.stringify(semanticNetlist(runtime.circuit), null, 2), 'application/json'); + else if (action === 'export-sax') downloadText(`${slug(runtime.circuit.name)}-sax.yml`, toSaxYAML(runtime.circuit), 'text/yaml'); + else if (action === 'export-gdsfactory') downloadText(`${slug(runtime.circuit.name)}-gdsfactory.py`, toGdsfactoryPython(runtime.circuit), 'text/x-python'); + else if (action === 'import-json') importJSON(); + else if (action === 'import-sparams') importSParameters(); + else if (action === 'guide') openGuideSheet(); + else if (action === 'link-parameter') { + const sourceId = button.dataset.sourceId, targetId = button.dataset.targetId; + const existing = parameterLinkFor(runtime.circuit, sourceId); + try { + mutateCircuit(circuit => createParameterLink(circuit, [...(existing?.members ?? [sourceId]), targetId], { name: existing?.name ?? `Linked ${resolveParameter(circuit, sourceId).label}` }), { lab: true }); + showToast('Parameters linked across canvas and Lab analyses.'); + } catch (error) { showToast(error.message); } + } else if (action === 'unlink-parameter') { + mutateCircuit(circuit => unlinkParameter(circuit, button.dataset.sourceId), { lab: true }); + showToast('Parameter unlinked.'); + } + else if (action === 'routing') { + mutateCircuit(circuit => { + if (button.dataset.routing === 'schematic') circuit.connections.filter(connection => isOpticalConnection(connection, circuit.components)).forEach(connection => { if (!Number.isFinite(Number(connection.params.schematicLengthUm))) connection.params.schematicLengthUm = connectionLengthUm(connection, circuit.components, { ...circuit.settings, routingMode: 'physical' }); }); + circuit.settings.routingMode = button.dataset.routing; + }); + showToast(`${button.dataset.routing === 'physical' ? 'Physical' : 'Schematic'} routing active.`); + } else if (action === 'scale') setPhysicalScale(); + else if (action === 'toggle-snap') { mutateCircuit(circuit => { circuit.settings.snapToGrid = !circuit.settings.snapToGrid; }); showToast(runtime.circuit.settings.snapToGrid ? 'Physical grid snapping enabled.' : 'Physical grid snapping disabled.'); } + else if (action === 'grid') setGridSpacing(); + else if (action === 'bend-radius') setMinimumBendRadius(); + else if (action === 'expand-block') { + const id = button.dataset.id; + mutateCircuit(circuit => { const ids = expandHierarchicalBlock(circuit, id); runtime.selection = { kind: 'multi', ids }; }, { lab: true }); + showToast('Block expanded.'); + } else if (action === 'export-block') { + const block = runtime.circuit.components.find(item => item.id === button.dataset.id); + if (block) exportJSON(block.params?.subcircuit ?? block, `-${slug(block.name)}`); + } +} +function showToast(message) { + clearTimeout(runtime.toastTimer); dom.toast.textContent = message; dom.toast.classList.add('show'); + runtime.toastTimer = setTimeout(() => dom.toast.classList.remove('show'), 3100); +} +function setPaused(value = !runtime.paused) { runtime.paused = value; updateTimeUI(); } +function bindTimeKnob() { + let drag = null; + const update = event => { + const rect = dom.timeKnob.getBoundingClientRect(), cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; + let angle = Math.atan2(event.clientY - cy, event.clientX - cx) * 180 / Math.PI + 90; if (angle < 0) angle += 360; + const mapped = clamp(angle <= 315 && angle >= 225 ? 0 : angle > 315 ? (angle - 315) / 270 : (angle + 45) / 270, 0, 1); + runtime.speed = Math.max(.1, Math.round(Math.pow(mapped, 1.25) * 40) / 10); updateTimeUI(); + }; + dom.timeKnob.addEventListener('pointerdown', event => { drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false }; dom.timeKnob.setPointerCapture?.(event.pointerId); }); + dom.timeKnob.addEventListener('pointermove', event => { if (!drag || drag.id !== event.pointerId) return; drag.moved ||= Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4; if (drag.moved) update(event); }); + dom.timeKnob.addEventListener('pointerup', event => { if (!drag || drag.id !== event.pointerId) return; if (!drag.moved) setPaused(); drag = null; }); +} + +function bindEvents() { + $$('.tool-rail [data-tool]').forEach(button => button.addEventListener('click', () => setTool(button.dataset.tool))); + dom.componentCatalog.addEventListener('click', event => { + const button = event.target.closest('[data-place-type]'); if (!button) return; + runtime.placeType = button.dataset.placeType; updateToolUI(); showToast(`Tap the canvas to place ${labelForType(runtime.placeType)}.`); + }); + dom.svg.addEventListener('pointerdown', handleCanvasPointerDown); + dom.svg.addEventListener('pointermove', handleCanvasPointerMove); + dom.svg.addEventListener('pointerup', handleCanvasPointerUp); + dom.svg.addEventListener('pointercancel', event => { runtime.pointers.delete(event.pointerId); cancelConnection(); }); + dom.svg.addEventListener('dblclick', event => { + const componentNode = event.target.closest?.('[data-kind="component"]'); + const component = runtime.circuit.components.find(item => item.id === componentNode?.dataset.id); + if (!component) return; + if (component.type === 'coupler') openPhysics(component.id); + else if (component.type === 'block') openBlockSheet(component.id); + else focusComponent(component.id); + }); + dom.viewport.addEventListener('wheel', event => { if (dom.physicsDialog.open) return; event.preventDefault(); zoomCanvas(event.deltaY < 0 ? 1.1 : 1 / 1.1, { x: event.clientX, y: event.clientY }); }, { passive: false }); + dom.undoButton.addEventListener('click', undo); dom.redoButton.addEventListener('click', redo); dom.shareButton.addEventListener('click', shareCircuit); + dom.menuButton.addEventListener('click', openProjectMenu); dom.moreButton.addEventListener('click', openMoreSheet); dom.modelBadge.addEventListener('click', openModelSheet); + dom.routingButton.addEventListener('click', openRoutingSheet); dom.figureButton?.addEventListener('click', openFigureSheet); dom.powerBudgetButton.addEventListener('click', openPowerBudgetSheet); dom.scaleRuler.addEventListener('click', openRoutingSheet); + dom.labButton.addEventListener('click', () => runtime.lab.open ? closeLabPanel() : openLabPanel()); dom.labClose.addEventListener('click', closeLabPanel); dom.labMinimize?.addEventListener('click', () => { runtime.lab.compact = !runtime.lab.compact; renderLab(); }); + dom.loadDemoInline.addEventListener('click', () => replaceCircuit(makeDemoCircuit(), { toast: 'Lab-ready MZI loaded.' })); + dom.zoomOut.addEventListener('click', () => zoomCanvas(1 / 1.15)); dom.zoomIn.addEventListener('click', () => zoomCanvas(1.15)); + dom.zoomReset.addEventListener('click', () => { runtime.circuit.settings.canvasZoom = 1; runtime.circuit.settings.canvasPanX = 0; runtime.circuit.settings.canvasPanY = 0; refresh(); }); + dom.fitButton.addEventListener('click', fitCircuit); dom.pauseButton.addEventListener('click', () => setPaused()); bindTimeKnob(); + bindInspectorDelegation(); bindLabDelegation(); + dom.closePhysics.addEventListener('click', () => dom.physicsDialog.close()); + $$('.physics-tabs [data-view]').forEach(button => button.addEventListener('click', () => setPhysicsView(button.dataset.view))); + dom.physicsInfoButton.addEventListener('click', () => matchMedia('(max-width: 980px)').matches ? dom.physicsDialog.classList.toggle('show-inspector') : openModelSheet()); + dom.equationCard.addEventListener('click', openModelSheet); + bindPhysicsRange(dom.gapRange, value => { const coupler = runtime.circuit.components.find(item => item.id === runtime.selectedCouplerId); if (coupler) coupler.params.gapUm = value; }); + bindPhysicsRange(dom.lengthRange, value => { const coupler = runtime.circuit.components.find(item => item.id === runtime.selectedCouplerId); if (coupler) coupler.params.interactionLengthUm = value; }); + bindPhysicsRange(dom.wavelengthRange, value => { runtime.circuit.settings.wavelengthNm = value; runtime.circuit.components.filter(item => item.type === 'source').forEach(source => { source.params.wavelengthNm = value; }); }); + dom.sheetContent.addEventListener('click', handleSheetAction); + window.addEventListener('keydown', event => { + const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName); + if (typing) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); event.shiftKey ? redo() : undo(); } + else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') { event.preventDefault(); redo(); } + else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); deleteSelection(); } + else if (event.key === ' ') { event.preventDefault(); setPaused(); } + else if (event.key.toLowerCase() === 'l') { event.preventDefault(); runtime.lab.open ? closeLabPanel() : openLabPanel(); } + else if (event.key === 'Escape') { + if (runtime.connectStart) cancelConnection(); + else if (dom.physicsDialog.open) dom.physicsDialog.close(); + else if (runtime.lab.open) closeLabPanel(); + else { runtime.selection = null; refresh(); } + } + }); + window.addEventListener('hashchange', () => { + const match = location.hash.match(/^#c=([A-Za-z0-9_-]+)$/); if (!match) return; + try { replaceCircuit(JSON.parse(new TextDecoder().decode(base64UrlDecode(match[1]))), { history: false, toast: 'Shared circuit opened.' }); } + catch { showToast('The shared circuit state could not be decoded.'); } + }); +} +function animate(now) { + const elapsed = (now - runtime.animationStart) / 1000; + renderer.animate(elapsed, runtime.speed, runtime.paused, runtime.solve?.sourcePowerMw ?? 1); + if (dom.physicsDialog.open) couplerView.draw(elapsed, runtime.speed, runtime.paused); + requestAnimationFrame(animate); +} +function registerServiceWorker() { + if ('serviceWorker' in navigator && location.protocol.startsWith('http')) navigator.serviceWorker.register('./sw.js').catch(error => console.warn('Service worker registration failed.', error)); +} + +buildComponentCatalog(); +bindEvents(); +refresh({ lab: true }); +fitCircuit(); +requestAnimationFrame(animate); +registerServiceWorker(); +window.picsetup = { + runtime, solveCircuit, makeDemoCircuit, makeBlankCircuit, makeTemplate, + listSweepParameters, createParameterLink, unlinkParameter, + sweepCircuitAsync, optimizeCircuit, runTolerance, simulatePulse, + semanticNetlist, toSaxYAML, toGdsfactoryPython, openLabPanel, openPhysics, + autoLayoutCircuit +}; diff --git a/app/src/bridge.js b/app/src/bridge.js new file mode 100644 index 0000000..13393c0 --- /dev/null +++ b/app/src/bridge.js @@ -0,0 +1,115 @@ +export const SETUP_PORT_SCHEMA = 'setup-port/1'; +export const DEFAULT_OPTICALSETUP_URL = 'https://opticalsetup.com/sketch/'; + +const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback; + +function base64UrlEncodeText(text) { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function base64UrlDecodeText(value) { + const padded = String(value).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(value).length + 3) % 4); + const binary = atob(padded); + return new TextDecoder().decode(Uint8Array.from(binary, character => character.charCodeAt(0))); +} + +export function normalizeBridgeId(value, fallback = 'optical-bridge') { + const normalized = String(value ?? '').trim().replace(/[^a-zA-Z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96); + return normalized || fallback; +} + +export function buildOpticalBridgePayload(circuit, component, solve = null, { returnUrl = null } = {}) { + const params = component?.params ?? {}; + const result = solve?.components?.get?.(component?.id); + const port = result?.ports?.pic ?? result?.ports?.optical ?? Object.values(result?.ports ?? {})[0] ?? null; + const direction = ['input', 'output', 'bidirectional'].includes(params.direction) ? params.direction : 'output'; + const efficiency = Math.max(0, Math.min(1, finite(params.couplingEfficiency, 0.7))); + const declaredOpticalSetupPowerMw = Math.max(0, finite(params.powerMw, 0)); + const picIncomingPowerMw = Math.max(0, finite(port?.incomingPowerMw, 0)); + const picOutgoingPowerMw = Math.max(0, finite(port?.outgoingPowerMw, 0)); + const picSidePowerMw = direction === 'input' + ? (picOutgoingPowerMw || declaredOpticalSetupPowerMw * efficiency) + : picIncomingPowerMw; + const opticalSetupSidePowerMw = direction === 'input' + ? declaredOpticalSetupPowerMw + : picSidePowerMw * efficiency; + const interfaceKind = ['free-space', 'fiber', 'edge', 'grating'].includes(params.interfaceKind) ? params.interfaceKind : 'free-space'; + const bridgeId = normalizeBridgeId(params.bridgeId, component?.id ?? 'optical-bridge'); + const wavelengthNm = finite(circuit?.settings?.wavelengthNm, finite(params.wavelengthNm, 1550)); + + return { + schema: SETUP_PORT_SCHEMA, + bridgeId, + source: { + application: 'PicSetup', + documentName: String(circuit?.name ?? 'Untitled experiment'), + componentId: String(component?.id ?? ''), + componentName: String(component?.name ?? 'OpticalSetup bridge') + }, + target: { application: 'OpticalSetup', url: String(params.targetUrl || DEFAULT_OPTICALSETUP_URL) }, + domain: 'optical', + kind: interfaceKind === 'fiber' ? 'fiber-mode' : interfaceKind === 'edge' ? 'chip-edge-mode' : interfaceKind === 'grating' ? 'grating-free-space-mode' : 'free-space-beam', + direction, + referenceFrame: { + type: interfaceKind === 'edge' ? 'pic-edge' : interfaceKind === 'grating' ? 'pic-surface-normal' : interfaceKind, + orientationDeg: finite(component?.rotation, 0), + handedness: 'screen-xy-clockwise-positive' + }, + state: { + wavelengthNm, + opticalPowerMw: opticalSetupSidePowerMw, + powerReference: 'OpticalSetup-side interface power', + picSidePowerMw, + opticalSetupSidePowerMw, + couplingEfficiency: efficiency, + polarization: String(params.polarization || 'TE'), + guidedMode: String(params.guidedMode || 'TE0'), + sourceMode: String(params.sourceMode || 'cw'), + repetitionRateMHz: params.sourceMode === 'pulsed' ? finite(params.repetitionRateMHz, 80) : null, + pulseDurationPs: params.sourceMode === 'pulsed' ? finite(params.pulseDurationPs, 12) : null, + phaseRad: finite(params.phaseRad, 0) + }, + capabilities: { + absolutePower: true, + wavelength: true, + pulseEnvelope: params.sourceMode === 'pulsed', + carrierPhase: true, + fullVectorField: false, + spatialBeamProfile: false, + higherOrderModes: false + }, + omissions: [ + 'PicSetup exports a scalar guided-mode boundary, not a sampled transverse field.', + 'OpticalSetup must choose or reconstruct beam waist, wavefront, numerical aperture, and laboratory coordinates.', + 'The coupling efficiency and both interface-side powers are explicit; unsupported properties are not silently invented.' + ], + returnUrl: returnUrl ? String(returnUrl) : null, + createdAt: new Date().toISOString() + }; +} + +export function encodeBridgePayload(payload) { + return base64UrlEncodeText(JSON.stringify(payload)); +} + +export function decodeBridgePayload(encoded) { + const payload = JSON.parse(base64UrlDecodeText(encoded)); + if (!payload || payload.schema !== SETUP_PORT_SCHEMA) throw new Error(`Unsupported bridge schema: ${payload?.schema ?? 'missing'}`); + return payload; +} + +export function opticalSetupBridgeUrl(payload, targetUrl = null) { + const base = new URL(targetUrl || payload?.target?.url || DEFAULT_OPTICALSETUP_URL, typeof location === 'undefined' ? DEFAULT_OPTICALSETUP_URL : location.href); + base.searchParams.set('incomingBridge', encodeBridgePayload(payload)); + base.searchParams.set('bridgeSchema', SETUP_PORT_SCHEMA); + return base.toString(); +} + +export function bridgeManifest(circuit, solve = null, options = {}) { + return (circuit?.components ?? []) + .filter(component => component.type === 'optical-bridge') + .map(component => buildOpticalBridgePayload(circuit, component, solve, options)); +} diff --git a/app/src/circuit.js b/app/src/circuit.js new file mode 100644 index 0000000..a03288e --- /dev/null +++ b/app/src/circuit.js @@ -0,0 +1,598 @@ +import { componentPorts, getConnectionPoints, couplerCoefficients, connectionDomain } from './physics.js'; +import { getDefinition, getPorts } from './models.js'; +import { smoothPath } from './geometry.js'; + +const NS = 'http://www.w3.org/2000/svg'; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback; +const svgEl = (tag, attrs = {}, text = null) => { + const node = document.createElementNS(NS, tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null) continue; + node.setAttribute(key, String(value)); + } + if (text !== null) node.textContent = text; + return node; +}; + +export { smoothPath }; + +export function componentBounds(component) { + const raw = getDefinition(component.type).bounds ?? { x: -40, y: -40, width: 80, height: 80, rx: 16 }; + const bounds = typeof raw === 'function' ? raw(component) : raw; + return { x: Number(bounds.x ?? -40), y: Number(bounds.y ?? -40), width: Number(bounds.width ?? 80), height: Number(bounds.height ?? 80), rx: Number(bounds.rx ?? 16) }; +} + + +function sourceMode(component) { + return component?.params?.sourceMode === 'pulsed' ? 'pulsed' : 'cw'; +} +function sourceVisualization(component) { + return component?.params?.cwVisualization === 'tracers' ? 'tracers' : 'solid'; +} +function primarySource(circuit) { + return circuit.components.find(component => component.type === 'source' || (getDefinition(component.type).sourceBoundary && ['input', 'bidirectional'].includes(component.params?.direction))) ?? null; +} + +function label(group, component, subtitle = '', y = -51, subtitleY = 55) { + const params = component.params ?? {}; + const placement = ['auto', 'above', 'below', 'left', 'right', 'hidden'].includes(params.labelPosition) ? params.labelPosition : 'auto'; + if (placement === 'hidden') return; + const bounds = componentBounds(component); + let nameX = 0, nameY = y, subX = 0, subY = subtitleY, anchor = 'middle'; + if (placement === 'above') { nameY = bounds.y - 24; subY = bounds.y - 9; } + else if (placement === 'below') { nameY = bounds.y + bounds.height + 18; subY = bounds.y + bounds.height + 34; } + else if (placement === 'left') { nameX = bounds.x - 14; subX = nameX; nameY = -4; subY = 12; anchor = 'end'; } + else if (placement === 'right') { nameX = bounds.x + bounds.width + 14; subX = nameX; nameY = -4; subY = 12; anchor = 'start'; } + const dx = finite(params.labelOffsetX, 0), dy = finite(params.labelOffsetY, 0); + group.append(svgEl('text', { class: 'component-label', x: nameX + dx, y: nameY + dy, 'text-anchor': anchor }, component.name ?? getDefinition(component.type).shortLabel)); + if (subtitle && params.subtitleVisibility !== 'hide') group.append(svgEl('text', { class: 'component-sub', x: subX + dx, y: subY + dy, 'text-anchor': anchor }, subtitle)); +} +function body(group, attrs = {}) { + group.append(svgEl('rect', { class: 'component-body', x: -36, y: -32, width: 72, height: 64, rx: 16, ...attrs })); +} + +function renderSource(group, component) { + body(group, { x: -32, y: -32, width: 64, height: 64 }); + group.append(svgEl('circle', { class: 'source-core', cx: 0, cy: 0, r: 5 })); + for (let angle = 0; angle < 360; angle += 45) { + const rad = angle * Math.PI / 180; + group.append(svgEl('line', { class: 'source-rays', x1: Math.cos(rad) * 10, y1: Math.sin(rad) * 10, x2: Math.cos(rad) * 20, y2: Math.sin(rad) * 20 })); + } + const mode = sourceMode(component); + label(group, component, mode === 'pulsed' ? `${Number(component.params?.pulseDurationPs ?? 12).toFixed(1)} ps · ${Number(component.params?.repetitionRateMHz ?? 80).toFixed(0)} MHz` : `CW · λ ${Number(component.params?.wavelengthNm ?? 1550).toFixed(0)} nm`, -51, -40); +} + +function inspectButton(group, component, action = 'open-physics') { + const inspect = svgEl('g', { class: 'component-inspect', 'data-action': action, 'data-id': component.id, transform: 'translate(34,-29)', tabindex: '0', role: 'button', 'aria-label': action === 'open-block' ? 'Inspect block hierarchy' : 'Inspect local physics' }); + inspect.append(svgEl('circle', { cx: 0, cy: 0, r: 10, fill: 'rgba(4,12,14,.92)', stroke: 'rgba(37,232,255,.7)' })); + if (action === 'open-block') inspect.append(svgEl('path', { d: 'M -4 -4h8v8h-8zM-1-1h8v8', fill: 'none', stroke: '#25e8ff', 'stroke-width': 1.1 })); + else { + inspect.append(svgEl('circle', { cx: -2, cy: -2, r: 3.5, fill: 'none', stroke: '#25e8ff', 'stroke-width': 1.3 })); + inspect.append(svgEl('line', { x1: 1, y1: 1, x2: 5, y2: 5, stroke: '#25e8ff', 'stroke-width': 1.3, 'stroke-linecap': 'round' })); + } + group.append(inspect); +} + +function renderCoupler(group, component, wavelengthNm) { + body(group, { x: -44, y: -34, width: 88, height: 68, rx: 17 }); + group.append(svgEl('path', { class: 'coupler-lines', d: 'M -40 -16 C -13 -16 -13 16 40 16' })); + group.append(svgEl('path', { class: 'coupler-lines', d: 'M -40 16 C -13 16 -13 -16 40 -16' })); + group.append(svgEl('path', { class: 'coupler-field', d: 'M -21 -5 C -8 -1 8 1 21 5' })); + group.append(svgEl('path', { class: 'coupler-field', d: 'M -21 5 C -8 1 8 -1 21 -5', opacity: '.58' })); + const coeff = couplerCoefficients(component.params, wavelengthNm); + label(group, component, `${(Math.min(1, coeff.crossPower) * 100).toFixed(1)}% cross · g ${Number(component.params?.gapUm ?? .2).toFixed(3)} µm`); + inspectButton(group, component); +} + +function renderMmi(group, component) { + body(group, { x: -44, y: -30, width: 88, height: 60, rx: 10 }); + group.append(svgEl('path', { class: 'generic-lines', d: 'M-42-16L-24-16M-42 16L-24 16M24-16L42-16M24 16L42 16M-24-20L24-12L24 12L-24 20Z' })); + group.append(svgEl('text', { class: 'generic-symbol', x: 0, y: 5 }, 'MMI')); + label(group, component, `${Number(component.params?.crossPower ?? 50).toFixed(1)}% cross`); +} + +function renderPhaseLike(group, component) { + const symbol = component.type === 'modulator' ? 'Vπ' : component.type === 'attenuator' ? '−dB' : 'φ'; + body(group, { x: -38, y: -31, width: 76, height: 62, rx: 15 }); + group.append(svgEl('text', { class: component.type === 'phase' ? 'phase-symbol' : 'generic-symbol', x: 0, y: component.type === 'phase' ? 10 : 5 }, symbol)); + let subtitle = ''; + if (component.type === 'phase') subtitle = `${Number(component.params?.phaseRad ?? 0).toFixed(3)} rad · ${Number(component.params?.lengthUm ?? 0).toFixed(0)} µm`; + else if (component.type === 'modulator') subtitle = `${Number(component.params?.voltageV ?? 0).toFixed(2)} V / ${Number(component.params?.vpiV ?? 4).toFixed(2)} Vπ`; + else subtitle = `${Number(component.params?.lossDb ?? 0).toFixed(2)} dB`; + label(group, component, subtitle, -49, 48); +} + +function renderDetector(group, component, result) { + body(group, { x: -32, y: -32, width: 64, height: 64 }); + group.append(svgEl('line', { class: 'detector-beam', x1: -23, y1: 0, x2: -6, y2: 0 })); + group.append(svgEl('path', { class: 'detector-arc', d: component.type === 'termination' ? 'M -5 -17L-5 17M5-17L5 17' : 'M -6 -16 A 18 18 0 0 1 -6 16' })); + if (component.type !== 'termination') group.append(svgEl('line', { class: 'detector-arc', x1: 8, y1: -17, x2: 8, y2: 17 })); + label(group, component, component.type === 'termination' ? 'matched' : `${Number(result?.measurementMw ?? 0).toFixed((result?.measurementMw ?? 0) < .01 ? 4 : 3)} mW`); +} + +function renderProbe(group, component, result) { + body(group, { x: -34, y: -27, width: 68, height: 54, rx: 14 }); + group.append(svgEl('line', { class: 'generic-lines', x1: -45, y1: 0, x2: 45, y2: 0 })); + group.append(svgEl('circle', { class: 'probe-ring', cx: 0, cy: 0, r: 13 })); + group.append(svgEl('circle', { class: 'probe-dot', cx: 0, cy: 0, r: 3 })); + label(group, component, `${Number(result?.measurementMw ?? 0).toFixed(3)} mW`, -44, 42); +} + +function renderSplitter(group, component) { + body(group, { x: -39, y: -33, width: 78, height: 66, rx: 16 }); + group.append(svgEl('path', { class: 'generic-lines', d: 'M-46 0L-10 0M-10 0C8 0 13-18 46-18M-10 0C8 0 13 18 46 18' })); + label(group, component, `${Number(component.params?.topPower ?? 50).toFixed(1)} / ${(100 - Number(component.params?.topPower ?? 50)).toFixed(1)}%`); +} + +function renderRing(group, component) { + body(group, { x: -46, y: -38, width: 92, height: 76, rx: 18 }); + group.append(svgEl('line', { class: 'generic-lines', x1: -46, y1: -16, x2: 46, y2: -16 })); + group.append(svgEl('line', { class: 'generic-lines', x1: -46, y1: 16, x2: 46, y2: 16 })); + group.append(svgEl('circle', { class: 'ring-shape', cx: 0, cy: 0, r: 19 })); + label(group, component, `R ${Number(component.params?.radiusUm ?? 10).toFixed(1)} µm · κ² ${Number(component.params?.couplingPower ?? 12).toFixed(1)}%`, -56, 58); +} + +function renderBragg(group, component) { + body(group, { x: -42, y: -29, width: 84, height: 58, rx: 14 }); + group.append(svgEl('line', { class: 'generic-lines', x1: -48, y1: 0, x2: 48, y2: 0 })); + for (let x = -24; x <= 24; x += 8) group.append(svgEl('line', { class: 'bragg-tooth', x1: x, y1: -13, x2: x, y2: 13 })); + label(group, component, `${Number(component.params?.centerNm ?? 1550).toFixed(1)} nm · ${Number(component.params?.bandwidthNm ?? 5).toFixed(1)} nm`, -48, 46); +} + +function renderCrossing(group, component) { + body(group, { x: -34, y: -34, width: 68, height: 68, rx: 16 }); + group.append(svgEl('path', { class: 'generic-lines', d: 'M-46 0H46M0-46V46' })); + group.append(svgEl('circle', { class: 'crossing-core', cx: 0, cy: 0, r: 7 })); + label(group, component, `${Number(component.params?.crosstalkDb ?? -35).toFixed(1)} dB XT`, -57, 57); +} + +function renderGrating(group, component) { + body(group, { x: -37, y: -30, width: 74, height: 60, rx: 15 }); + group.append(svgEl('line', { class: 'generic-lines', x1: -47, y1: 0, x2: -20, y2: 0 })); + for (let index = 0; index < 5; index += 1) group.append(svgEl('path', { class: 'grating-arc', d: `M ${-12 + index * 7} -${10 + index * 3} Q ${4 + index * 4} 0 ${-12 + index * 7} ${10 + index * 3}` })); + label(group, component, `${Number(component.params?.peakEfficiency ?? 60).toFixed(0)}% peak`); +} + +function renderBlock(group, component) { + body(group, { x: -52, y: -38, width: 104, height: 76, rx: 18 }); + group.append(svgEl('rect', { class: 'block-inner', x: -39, y: -25, width: 78, height: 50, rx: 12 })); + group.append(svgEl('text', { class: 'generic-symbol', x: 0, y: 5 }, component.type === 'block' ? 'SUB' : 'S(λ)')); + const count = component.params?.subcircuit?.components?.length; + label(group, component, component.type === 'block' ? `${count ?? 0} internal components · ${getPorts(component).length} ports` : `${getPorts(component).length}-port · ${component.params?.provenance ?? 'user model'}`, -58, 58); + if (component.type === 'block') inspectButton(group, component, 'open-block'); +} + + +function renderEdgeCoupler(group, component) { + body(group, { x: -44, y: -30, width: 88, height: 60, rx: 14 }); + group.append(svgEl('path', { class: 'generic-lines', d: 'M-52 0H-22L28-18V18L-22 0M28 0H52' })); + group.append(svgEl('path', { class: 'coupler-field', d: 'M-47 -10Q-28 0-47 10' })); + label(group, component, `${Number(component.params?.peakEfficiency ?? 72).toFixed(0)}% · ${component.params?.facetKind ?? 'fiber-array'}`, -48, 47); +} + +function renderOpticalBridge(group, component, result) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'bridge-body', ...bounds })); + group.append(svgEl('path', { class: 'bridge-arrow', d: component.params?.direction === 'input' ? 'M38 0H-24M-24 0l10-8M-24 0l10 8' : component.params?.direction === 'bidirectional' ? 'M-30-7H30M30-7l-9-7M30-7l-9 7M30 8H-30M-30 8l9-7M-30 8l9 7' : 'M-28 0H36M36 0l-10-8M36 0l-10 8' })); + group.append(svgEl('text', { class: 'bridge-brand', x: 0, y: -17 }, 'OPTICALSETUP')); + group.append(svgEl('text', { class: 'bridge-schema', x: 0, y: 24 }, 'setup-port/1')); + const power = component.params?.direction === 'input' ? result?.ports?.pic?.outgoingPowerMw : result?.ports?.pic?.incomingPowerMw; + label(group, component, `${component.params?.interfaceKind ?? 'free-space'} · ${Number(power ?? component.params?.powerMw ?? 0).toFixed(3)} mW`, -58, 60); +} + +function renderSpiral(group, component) { + body(group, { x: -46, y: -38, width: 92, height: 76, rx: 17 }); + group.append(svgEl('path', { class: 'generic-lines spiral-line', d: 'M-54 0H-30C-12 0-12-25 10-25C34-25 35 25 7 25C-15 25-15-12 5-12C18-12 18 12 3 12C-6 12-7 1 1 1H54' })); + label(group, component, `${Number(component.params?.lengthUm ?? 5000).toFixed(0)} µm delay`, -54, 54); +} + +function renderAwg(group, component) { + body(group, { x: -50, y: -46, width: 100, height: 92, rx: 18 }); + group.append(svgEl('path', { class: 'generic-lines', d: 'M-58 0H-34Q-12-38 16-34Q38-30 50-30M-34 0Q-10-14 18-10Q40-8 50-10M-34 0Q-10 14 18 10Q40 8 50 10M-34 0Q-12 38 16 34Q38 30 50 30' })); + for (let i=-2;i<=2;i+=1) group.append(svgEl('path', { class: 'awg-array-line', d: `M${-12+i*4} -25Q${5+i*2} 0${-12+i*4} 25` })); + group.append(svgEl('text', { class: 'generic-symbol', x: 0, y: 5 }, 'AWG')); + label(group, component, `${Number(component.params?.channelSpacingNm ?? 8).toFixed(1)} nm spacing`, -61, 62); +} + +function renderHeater(group, component) { + body(group, { x: -42, y: -33, width: 84, height: 66, rx: 15 }); + group.append(svgEl('line', { class: 'generic-lines', x1: -50, y1: 10, x2: 50, y2: 10 })); + group.append(svgEl('path', { class: 'heater-coil', d: 'M-28-7q7-15 14 0t14 0t14 0t14 0' })); + group.append(svgEl('line', { class: 'electrical-symbol-line', x1: 0, y1: 40, x2: 0, y2: 19 })); + label(group, component, `${Number(component.params?.voltageV ?? 0).toFixed(2)} V · φ ${(Number(component.params?.biasRad ?? 0)+Number(component.params?.voltageV ?? 0)*Number(component.params?.phasePerV ?? .5)).toFixed(2)} rad`, -53, 53); +} + +function renderFiberArray(group, component) { + body(group, { x: -52, y: -45, width: 104, height: 90, rx: 16 }); + for (let i=0;i<4;i+=1) { + const y=-30+i*20; + group.append(svgEl('path', { class: 'fiber-line', d: `M-62 ${y}H-28Q-12 ${y} 0 ${y}H62` })); + group.append(svgEl('circle', { class: 'fiber-core', cx: -23, cy: y, r: 5 })); + } + group.append(svgEl('text', { class: 'generic-symbol', x: 19, y: 5 }, '4×')); + label(group, component, `${Number(component.params?.insertionLossDb ?? 1).toFixed(2)} dB / ch`, -61, 61); +} + +function renderPolarizationController(group, component) { + body(group, { x: -40, y: -31, width: 80, height: 62, rx: 17 }); + group.append(svgEl('line', { class: 'fiber-line', x1: -48, y1: 0, x2: 48, y2: 0 })); + for (const x of [-18,0,18]) group.append(svgEl('circle', { class: 'polarization-loop', cx: x, cy: 0, r: 11 })); + label(group, component, `${component.params?.state ?? 'linear'} · ${Number(component.params?.lossDb ?? .2).toFixed(2)} dB`, -49, 49); +} + +function renderOpticalInstrument(group, component) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'instrument-body', ...bounds })); + const kind = component.params?.instrumentKind ?? 'spectrometer'; + const symbol = { camera:'▣', photodetector:'◖', pmt:'PMT', 'power-meter':'P', wavefront:'≋', polarimeter:'↻', spectrometer:'λ', general:'Σ' }[kind] ?? 'λ'; + group.append(svgEl('rect', { class: 'instrument-screen', x: bounds.x+18, y: bounds.y+16, width: bounds.width-36, height: Math.max(28,bounds.height-45), rx: 8 })); + group.append(svgEl('text', { class: 'instrument-symbol', x: 0, y: 7 }, symbol)); + label(group, component, component.params?.subtitle ?? kind, bounds.y-12, bounds.y+bounds.height+18); +} + +function renderRfSource(group, component) { + body(group, { x: -42, y: -31, width: 84, height: 62, rx: 15 }); + group.append(svgEl('path', { class: 'rf-wave', d: 'M-28 0C-20-20-10-20-2 0S16 20 28 0' })); + label(group, component, `${Number(component.params?.frequencyGhz ?? 10).toFixed(2)} GHz · ${Number(component.params?.amplitudeV ?? 1).toFixed(2)} V`, -49, 49); +} + +function renderElectricalAmplifier(group, component) { + body(group, { x: -42, y: -31, width: 84, height: 62, rx: 14 }); + group.append(svgEl('path', { class: 'electrical-symbol-line amplifier-triangle', d: 'M-29-20L27 0L-29 20Z' })); + group.append(svgEl('text', { class: 'generic-symbol', x: -6, y: 5 }, 'G')); + label(group, component, `${Number(component.params?.gainDb ?? 20).toFixed(1)} dB · ${Number(component.params?.bandwidthGhz ?? 20).toFixed(1)} GHz`, -49, 49); +} + +function renderOscilloscope(group, component) { + body(group, { x: -50, y: -38, width: 100, height: 76, rx: 16 }); + group.append(svgEl('rect', { class: 'instrument-screen', x: -34, y: -23, width: 68, height: 42, rx: 7 })); + group.append(svgEl('path', { class: 'scope-trace', d: 'M-29 5L-20 5L-15-11L-8 15L0-5L8 4L17 4L23-13L29 5' })); + label(group, component, component.params?.subtitle ?? 'time trace', -57, 58); +} + +function renderController(group, component) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'control-body', ...bounds })); + group.append(svgEl('rect', { class: 'instrument-screen', x: bounds.x+18, y: bounds.y+15, width: bounds.width-36, height: bounds.height-38, rx: 8 })); + group.append(svgEl('path', { class: 'controller-nodes', d: `M${bounds.x+30} 0H${bounds.x+bounds.width-30}M-20-10L0 9L22-14` })); + label(group, component, component.params?.subtitle ?? 'calibration · feedback · DSP', bounds.y-12, bounds.y+bounds.height+18); +} + +function renderSystemBlock(group, component) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'system-block-body', ...bounds })); + const kind = component.params?.blockKind ?? 'generic'; + const symbol = { generic:'SYS', laser:'LASER', receiver:'RX', driver:'DRV', processor:'DSP', sample:'SAMPLE' }[kind] ?? 'SYS'; + group.append(svgEl('text', { class: 'system-symbol', x: 0, y: 4 }, symbol)); + label(group, component, component.params?.subtitle ?? 'subsystem', bounds.y-12, bounds.y+bounds.height+18); +} + +function renderChipFrame(group, component) { + const bounds = componentBounds(component); + const fillClass = component.params?.fillStyle ?? 'tint'; + group.append(svgEl('rect', { class: `chip-frame-body chip-fill-${fillClass}`, ...bounds })); + group.append(svgEl('text', { class: 'chip-frame-title', x: bounds.x+22, y: bounds.y+31, 'text-anchor':'start' }, component.name ?? 'PIC')); + group.append(svgEl('text', { class: 'chip-frame-subtitle', x: bounds.x+22, y: bounds.y+51, 'text-anchor':'start' }, component.params?.subtitle ?? 'photonic integrated circuit')); +} + +function renderImagePanel(group, component) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'paper-panel-body', ...bounds })); + const href = component.params?.imageDataUrl; + if (href) group.append(svgEl('image', { class: 'paper-image', href, x: bounds.x+4, y: bounds.y+4, width: bounds.width-8, height: bounds.height-31, preserveAspectRatio: component.params?.imageFit === 'contain' ? 'xMidYMid meet' : 'xMidYMid slice' })); + else { + group.append(svgEl('path', { class: 'image-placeholder', d: `M${bounds.x+20} ${bounds.y+bounds.height-45}L${bounds.x+bounds.width*.42} ${bounds.y+bounds.height*.46}L${bounds.x+bounds.width*.58} ${bounds.y+bounds.height*.65}L${bounds.x+bounds.width-20} ${bounds.y+30}` })); + group.append(svgEl('circle', { class: 'image-placeholder', cx: bounds.x+bounds.width*.7, cy: bounds.y+bounds.height*.28, r: 11 })); + } + group.append(svgEl('text', { class: 'panel-caption', x: 0, y: bounds.y+bounds.height-10 }, component.params?.caption ?? 'Microscope / SEM image')); +} + +function plotTracePath(bounds, style) { + const x0=bounds.x+38, x1=bounds.x+bounds.width-16, y0=bounds.y+bounds.height-38, y1=bounds.y+20; + if (style === 'bars') return `M${x0+18} ${y0}V${y0-35}M${x0+48} ${y0}V${y0-65}M${x0+78} ${y0}V${y0-46}M${x0+108} ${y0}V${y0-80}`; + if (style === 'time') return `M${x0} ${(y0+y1)/2}C${x0+18} ${y1} ${x0+28} ${y0} ${x0+45} ${(y0+y1)/2}S${x0+78} ${y1} ${x0+95} ${(y0+y1)/2}S${x1-18} ${y0} ${x1} ${(y0+y1)/2}`; + if (style === 'spectrum') return `M${x0} ${y0-7}C${x0+30} ${y0-10} ${x0+42} ${y1+10} ${x0+58} ${y1+12}S${x0+78} ${y0-5} ${x0+96} ${y0-10}S${x1-26} ${y1+22} ${x1} ${y0-6}`; + return `M${x0} ${y1+12}C${x0+35} ${y1+15} ${x0+46} ${y0-4} ${x0+61} ${y0-7}S${x0+80} ${y1+15} ${x0+100} ${y1+14}S${x1-30} ${y0-10} ${x1} ${y1+12}`; +} + +function renderPlotPanel(group, component) { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'paper-panel-body', ...bounds })); + const x0=bounds.x+38, x1=bounds.x+bounds.width-16, y0=bounds.y+bounds.height-38, y1=bounds.y+20; + group.append(svgEl('path', { class: 'plot-axis', d: `M${x0} ${y1}V${y0}H${x1}` })); + group.append(svgEl('path', { class: `plot-trace plot-${component.params?.traceStyle ?? 'resonance'}`, d: plotTracePath(bounds, component.params?.traceStyle ?? 'resonance') })); + group.append(svgEl('text', { class: 'plot-label', x: (x0+x1)/2, y: bounds.y+bounds.height-14 }, component.params?.xLabel ?? 'Wavelength (nm)')); + group.append(svgEl('text', { class: 'plot-label plot-y-label', x: bounds.x+12, y: (y0+y1)/2, transform: `rotate(-90 ${bounds.x+12} ${(y0+y1)/2})` }, component.params?.yLabel ?? 'Transmission')); + group.append(svgEl('text', { class: 'panel-caption', x: 0, y: bounds.y-10 }, component.params?.caption ?? 'Response')); +} + +function renderAnnotation(group, component) { + const bounds=componentBounds(component), fontSize=Number(component.params?.fontSize ?? 15); + const align=component.params?.align ?? 'left'; + const anchor=align==='center'?'middle':align==='right'?'end':'start'; + const x=align==='center'?0:align==='right'?bounds.x+bounds.width:bounds.x; + const text=svgEl('text', { class:'annotation-text', x, y:bounds.y+fontSize, 'text-anchor':anchor, 'font-size':fontSize }); + String(component.params?.text ?? '').split('\n').forEach((line,index)=>text.append(svgEl('tspan',{x,dy:index?fontSize*1.35:0},line))); + group.append(text); +} + +function renderPanelLabel(group, component) { + group.append(svgEl('text', { class:'panel-letter', x:0, y:Number(component.params?.fontSize ?? 30)*.34, 'font-size':Number(component.params?.fontSize ?? 30) }, component.params?.text ?? 'a')); +} + +function renderComponent(group, component, result, wavelengthNm) { + switch (component.type) { + case 'source': renderSource(group, component); break; + case 'coupler': renderCoupler(group, component, wavelengthNm); break; + case 'mmi': renderMmi(group, component); break; + case 'phase': case 'modulator': case 'attenuator': renderPhaseLike(group, component); break; + case 'heater': renderHeater(group, component); break; + case 'detector': case 'termination': renderDetector(group, component, result); break; + case 'probe': renderProbe(group, component, result); break; + case 'splitter': renderSplitter(group, component); break; + case 'ring': renderRing(group, component); break; + case 'bragg': renderBragg(group, component); break; + case 'crossing': renderCrossing(group, component); break; + case 'grating': renderGrating(group, component); break; + case 'edge-coupler': renderEdgeCoupler(group, component); break; + case 'optical-bridge': renderOpticalBridge(group, component, result); break; + case 'spiral': renderSpiral(group, component); break; + case 'awg': renderAwg(group, component); break; + case 'fiber-array': renderFiberArray(group, component); break; + case 'polarization-controller': renderPolarizationController(group, component); break; + case 'optical-instrument': renderOpticalInstrument(group, component); break; + case 'rf-source': renderRfSource(group, component); break; + case 'electrical-amplifier': renderElectricalAmplifier(group, component); break; + case 'oscilloscope': renderOscilloscope(group, component); break; + case 'controller': renderController(group, component); break; + case 'system-block': renderSystemBlock(group, component); break; + case 'chip-frame': renderChipFrame(group, component); break; + case 'image-panel': renderImagePanel(group, component); break; + case 'plot-panel': renderPlotPanel(group, component); break; + case 'annotation': renderAnnotation(group, component); break; + case 'panel-label': renderPanelLabel(group, component); break; + case 'generic': case 'block': renderBlock(group, component); break; + default: { + const bounds = componentBounds(component); + group.append(svgEl('rect', { class: 'component-body', ...bounds })); + group.append(svgEl('text', { class: 'generic-symbol', x: 0, y: 5 }, getDefinition(component.type).glyph ?? '•')); + label(group, component, getDefinition(component.type).label); + } + } +} + +export class CircuitRenderer { + constructor(svg) { + this.svg = svg; + this.gridLayer = svg.querySelector('#gridLayer'); + this.backgroundLayer = svg.querySelector('#backgroundLayer'); + this.gridPattern = svg.querySelector('#physicalGrid'); + this.connectionLayer = svg.querySelector('#connectionLayer'); + this.pulseLayer = svg.querySelector('#pulseLayer'); + this.componentLayer = svg.querySelector('#componentLayer'); + this.interactionLayer = svg.querySelector('#interactionLayer'); + this.pathRecords = []; + this.pulseRecords = []; + this.currentTransform = { zoom: 1, panX: 0, panY: 0 }; + } + + setTransform({ zoom = 1, panX = 0, panY = 0 }) { + this.currentTransform = { zoom, panX, panY }; + const transform = `translate(${panX} ${panY}) scale(${zoom})`; + [this.gridLayer, this.backgroundLayer, this.connectionLayer, this.pulseLayer, this.componentLayer, this.interactionLayer].filter(Boolean).forEach(layer => layer.setAttribute('transform', transform)); + this.svg.dataset.semantic = zoom < .68 ? 'overview' : zoom > 1.35 ? 'detail' : 'normal'; + } + + screenToWorld(clientX, clientY) { + const point = this.svg.createSVGPoint(); + point.x = clientX; point.y = clientY; + const matrix = this.componentLayer.getScreenCTM(); + if (!matrix) return { x: 0, y: 0 }; + const transformed = point.matrixTransform(matrix.inverse()); + return { x: transformed.x, y: transformed.y }; + } + + worldToScreen(x, y) { + const point = this.svg.createSVGPoint(); + point.x = x; point.y = y; + const matrix = this.componentLayer.getScreenCTM(); + if (!matrix) return { x, y }; + const transformed = point.matrixTransform(matrix); + return { x: transformed.x, y: transformed.y }; + } + + render(circuit, solveResult, ui = {}) { + this.backgroundLayer?.replaceChildren(); + this.connectionLayer.replaceChildren(); + this.pulseLayer.replaceChildren(); + this.componentLayer.replaceChildren(); + this.interactionLayer.replaceChildren(); + this.pathRecords = []; + this.pulseRecords = []; + + const zoom = circuit.settings?.canvasZoom ?? 1; + this.svg.dataset.figureMode = circuit.settings?.figureMode === 'paper' ? 'paper' : 'workbench'; + this.svg.dataset.paperBackground = circuit.settings?.paperBackground === 'transparent' ? 'transparent' : 'white'; + this.svg.dataset.showPorts = circuit.settings?.showPortsInFigure === false ? 'false' : 'true'; + this.setTransform({ zoom, panX: circuit.settings?.canvasPanX ?? 0, panY: circuit.settings?.canvasPanY ?? 0 }); + if (this.gridLayer && this.gridPattern) { + const paperMode = circuit.settings?.figureMode === 'paper'; + const showGrid = paperMode ? Boolean(circuit.settings?.showGridInFigure) : circuit.settings?.routingMode === 'physical' && Boolean(circuit.settings?.snapToGrid); + this.gridLayer.style.display = showGrid ? '' : 'none'; + let spacing = Math.max(1e-6, Number(circuit.settings?.gridUm ?? 5) / Math.max(1e-6, Number(circuit.settings?.worldToUm ?? .2))); + while (spacing * zoom < 12) spacing *= 5; + while (spacing * zoom > 180) spacing /= 5; + this.gridPattern.setAttribute('width', spacing.toFixed(4)); + this.gridPattern.setAttribute('height', spacing.toFixed(4)); + } + const selected = ui.selection; + const selectedIds = new Set(selected?.kind === 'multi' ? selected.ids : selected?.kind === 'component' ? [selected.id] : []); + const connectStart = ui.connectStart; + const occupiedPorts = new Set(); + const leadSource = primarySource(circuit); + const mode = sourceMode(leadSource); + const cwVisual = sourceVisualization(leadSource); + const showPackets = mode === 'pulsed' || cwVisual === 'tracers'; + const sourceColor = '#8eff42'; + const sourcePower = Math.max(solveResult?.sourcePowerMw ?? 1, 1e-9); + const pinnedComponents = new Set((ui.pinnedMeasurements ?? []).map(item => item.componentId).filter(Boolean)); + const highlighted = new Set(ui.highlightIds ?? []); + + for (const connection of circuit.connections) { + occupiedPorts.add(`${connection.a.component}:${connection.a.port}`); + occupiedPorts.add(`${connection.b.component}:${connection.b.port}`); + } + + for (const connection of circuit.connections) { + const points = getConnectionPoints(connection, circuit.components); + if (points.length < 2) continue; + const d = smoothPath(points); + const isSelected = selected?.kind === 'connection' && selected.id === connection.id; + const domain = connectionDomain(connection, circuit.components); + const solved = solveResult?.connections?.get(connection.id); + const group = svgEl('g', { class: `connection connection-domain-${domain} ${isSelected ? 'connection-selected' : ''} ${highlighted.has(connection.id) ? 'analysis-highlight' : ''} ${solved?.bendViolation ? 'connection-tight-bend' : ''}`, 'data-kind': 'connection', 'data-id': connection.id, 'data-domain': domain }); + const hit = svgEl('path', { class: 'connection-hit', d, 'data-kind': 'connection', 'data-id': connection.id }); + const base = svgEl('path', { class: 'connection-base', d }); + const normalized = domain === 'optical' ? Math.min(1, (solved?.maxPowerMw ?? 0) / sourcePower) : 0; + const arrow = ['start', 'end', 'both'].includes(connection.params?.arrow) ? connection.params.arrow : 'none'; + const markerId = { optical: 'arrowOptical', electrical: 'arrowElectrical', rf: 'arrowRf', control: 'arrowControl', annotation: 'arrowAnnotation' }[domain] ?? 'arrowAnnotation'; + const signal = svgEl('path', { + class: domain === 'optical' ? 'connection-power' : 'connection-signal', d, + opacity: domain === 'optical' ? (0.08 + Math.sqrt(normalized) * 0.92).toFixed(3) : 1, + 'stroke-width': domain === 'optical' ? (3.4 + Math.sqrt(normalized) * 4.8).toFixed(2) : undefined, + stroke: domain === 'optical' ? sourceColor : undefined, + 'marker-start': arrow === 'start' || arrow === 'both' ? `url(#${markerId})` : undefined, + 'marker-end': arrow === 'end' || arrow === 'both' ? `url(#${markerId})` : undefined + }); + group.append(hit, base, signal); + if (solved?.bendViolation && solved.bendPoint) { + const marker = svgEl('g', { class: 'bend-warning', transform: `translate(${solved.bendPoint.x} ${solved.bendPoint.y})`, 'aria-label': 'Bend radius warning' }); + marker.append(svgEl('circle', { cx: 0, cy: 0, r: 7 })); + marker.append(svgEl('text', { x: 0, y: 3 }, '!')); + group.append(marker); + } + this.connectionLayer.append(group); + if (String(connection.label ?? '').trim()) { + let point = points[Math.floor(points.length / 2)] ?? points[0]; + try { + const length = signal.getTotalLength(); + if (length > 0) point = signal.getPointAtLength(length * .5); + } catch { /* SVG path geometry may be unavailable during detached tests */ } + group.append(svgEl('text', { + class: 'connection-label', + x: point.x + finite(connection.params?.labelOffsetX, 0), + y: point.y + finite(connection.params?.labelOffsetY, -11) + }, String(connection.label).slice(0, 120))); + } + this.pathRecords.push({ id: connection.id, path: signal, solved, d, normalized, domain }); + + if (domain !== 'optical' || !showPackets) continue; + const directions = []; + if ((solved?.powerFromAMw ?? 0) > sourcePower * 1e-5) directions.push({ reverse: false, power: solved.powerFromAMw, gradient: 'url(#packetGreen)', mode }); + if ((solved?.powerFromBMw ?? 0) > sourcePower * 1e-5) directions.push({ reverse: true, power: solved.powerFromBMw, gradient: 'url(#packetCyan)', mode }); + for (const direction of directions) { + const repetition = Number(leadSource?.params?.repetitionRateMHz ?? 80); + const count = mode === 'pulsed' ? clamp(Math.round(1 + Math.log10(Math.max(1, repetition)) * .8), 1, 4) : 2; + for (let packetIndex = 0; packetIndex < count; packetIndex += 1) { + const packetGroup = svgEl('g', { class: 'pulse' }); + const normalizedPower = Math.min(1, direction.power / sourcePower); + const duration = Number(leadSource?.params?.pulseDurationPs ?? 12); + const envelopeLength = mode === 'pulsed' ? clamp(9 + 12 * Math.log10(1 + duration), 10, 58) : 14 + normalizedPower * 8; + const thickness = 3.8 + normalizedPower * 2.2; + const halo = svgEl('rect', { class: 'pulse-halo', x: (-envelopeLength / 2 - 2).toFixed(2), y: (-thickness / 2 - 1.2).toFixed(2), width: (envelopeLength + 4).toFixed(2), height: (thickness + 2.4).toFixed(2), rx: 2, fill: direction.gradient }); + const core = svgEl('rect', { class: 'pulse-core', x: (-envelopeLength / 2).toFixed(2), y: (-thickness / 2).toFixed(2), width: envelopeLength.toFixed(2), height: thickness.toFixed(2), rx: 1.5, fill: direction.gradient }); + packetGroup.append(halo, core); + this.pulseLayer.append(packetGroup); + this.pulseRecords.push({ group: packetGroup, path: signal, reverse: direction.reverse, offset: packetIndex / count, power: direction.power, pulsed: mode === 'pulsed', repetitionRateMHz: repetition }); + } + } + } + + const orderedComponents = [...circuit.components].sort((a, b) => (getDefinition(a.type).layer === 'background' ? -1 : 0) - (getDefinition(b.type).layer === 'background' ? -1 : 0)); + for (const component of orderedComponents) { + const definition = getDefinition(component.type); + const isSelected = selectedIds.has(component.id); + const bounds = componentBounds(component); + const group = svgEl('g', { + class: `component component-${component.type} ${definition.diagramOnly ? 'component-diagram-only' : ''} ${isSelected ? 'selected' : ''} ${highlighted.has(component.id) ? 'analysis-highlight' : ''}`, + transform: `translate(${component.x} ${component.y}) rotate(${component.rotation ?? 0})`, + 'data-kind': 'component', 'data-id': component.id, + tabindex: '0', role: 'button', 'aria-label': `${component.name ?? component.type} component` + }); + group.append(svgEl('rect', { class: 'selection-ring', x: bounds.x - 9, y: bounds.y - 9, width: bounds.width + 18, height: bounds.height + 18, rx: bounds.rx + 7 })); + const result = solveResult?.components?.get(component.id); + renderComponent(group, component, result, solveResult?.wavelengthNm ?? 1550); + + const angle = (component.rotation ?? 0) * Math.PI / 180; + const cos = Math.cos(angle), sin = Math.sin(angle); + for (const port of getPorts(component)) { + const localX = port.x; + const localY = port.y; + const key = `${component.id}:${port.id}`; + const isStart = connectStart?.component === component.id && connectStart?.port === port.id; + group.append(svgEl('circle', { class: 'port-hit', cx: localX, cy: localY, r: Math.max(10, 14 / zoom), fill: 'transparent', 'data-kind': 'port', 'data-component': component.id, 'data-port': port.id })); + group.append(svgEl('circle', { class: `port port-role-${port.role ?? 'optical'} port-medium-${port.medium ?? 'guided'} ${isStart ? 'active' : ''} ${occupiedPorts.has(key) ? 'occupied' : ''}`, cx: localX, cy: localY, r: 5, 'data-kind': 'port', 'data-component': component.id, 'data-port': port.id, 'aria-label': `${component.name ?? component.type} ${port.label} port` })); + group.append(svgEl('text', { class: 'port-label', x: localX + (localX < 0 ? -9 : localX > 0 ? 9 : 0), y: localY + (localY < 0 ? -8 : localY > 0 ? 12 : -8), 'text-anchor': localX < 0 ? 'end' : localX > 0 ? 'start' : 'middle' }, port.label)); + } + if (pinnedComponents.has(component.id)) { + const badge = svgEl('g', { class: 'measurement-pin', transform: `translate(${bounds.x + bounds.width - 2},${bounds.y + bounds.height + 6})`, 'data-action': 'open-lab', 'data-id': component.id }); + badge.append(svgEl('circle', { r: 8 })); + badge.append(svgEl('path', { d: 'M-3 1L-1 3L4-3', fill: 'none', 'stroke-width': 1.5 })); + group.append(badge); + } + (definition.layer === 'background' ? this.backgroundLayer : this.componentLayer).append(group); + } + + if (ui.previewPoints?.length > 1) { + const d = smoothPath(ui.previewPoints); + this.interactionLayer.append(svgEl('path', { class: `preview-path ${ui.previewValid === false ? 'preview-invalid' : ''}`, d })); + const last = ui.previewPoints.at(-1); + this.interactionLayer.append(svgEl('circle', { cx: last.x, cy: last.y, r: 5, fill: ui.previewValid === false ? '#ff6d7d' : '#25e8ff', filter: 'url(#softGlow)' })); + } + } + + animate(timeSeconds, speed, paused, sourcePower = 1) { + const visualTime = paused ? 0 : timeSeconds * speed; + for (const pulse of this.pulseRecords) { + let length = 0; + try { length = pulse.path.getTotalLength(); } catch { continue; } + if (!length) continue; + const baseVelocity = pulse.pulsed ? 0.085 : (0.12 + Math.min(1, pulse.power / Math.max(sourcePower, 1e-9)) * 0.05); + let phase = paused ? pulse.offset : (visualTime * baseVelocity + pulse.offset) % 1; + if (pulse.reverse) phase = 1 - phase; + const distance = clamp(phase * length, 0, length); + const point = pulse.path.getPointAtLength(distance); + const ahead = pulse.path.getPointAtLength(Math.min(length, distance + 1.2)); + const behind = pulse.path.getPointAtLength(Math.max(0, distance - 1.2)); + const angle = Math.atan2(ahead.y - behind.y, ahead.x - behind.x) * 180 / Math.PI; + pulse.group.setAttribute('transform', `translate(${point.x.toFixed(2)} ${point.y.toFixed(2)}) rotate(${angle.toFixed(2)})`); + pulse.group.setAttribute('opacity', paused ? '.58' : '.96'); + } + } + + renderMiniMap(targetSvg, circuit, selectedComponentId = null) { + targetSvg.replaceChildren(); + targetSvg.append(svgEl('rect', { width: 240, height: 130, rx: 14, fill: '#04090a', stroke: 'rgba(104,221,207,.18)' })); + const xs = circuit.components.map(component => component.x); + const ys = circuit.components.map(component => component.y); + if (!xs.length) return; + const minX = Math.min(...xs) - 80, maxX = Math.max(...xs) + 80; + const minY = Math.min(...ys) - 80, maxY = Math.max(...ys) + 80; + const scaleFactor = Math.min(214 / Math.max(1, maxX - minX), 104 / Math.max(1, maxY - minY)); + const tx = 13 + (214 - (maxX - minX) * scaleFactor) / 2; + const ty = 13 + (104 - (maxY - minY) * scaleFactor) / 2; + const mapPoint = point => ({ x: tx + (point.x - minX) * scaleFactor, y: ty + (point.y - minY) * scaleFactor }); + for (const connection of circuit.connections) { + const points = getConnectionPoints(connection, circuit.components).map(mapPoint); + const domain = connectionDomain(connection, circuit.components); + const color = domain === 'electrical' ? '#ffb547' : domain === 'rf' ? '#ff68d0' : domain === 'control' ? '#6ba9ff' : '#8eff42'; + targetSvg.append(svgEl('path', { d: smoothPath(points), fill: 'none', stroke: color, 'stroke-width': 2.5, 'stroke-linecap': 'round', opacity: '.85', 'stroke-dasharray': domain === 'control' ? '4 3' : 'none' })); + } + for (const component of circuit.components) { + const point = mapPoint(component); + const selected = component.id === selectedComponentId; + targetSvg.append(svgEl('rect', { x: point.x - (selected ? 8 : 4), y: point.y - (selected ? 8 : 4), width: selected ? 16 : 8, height: selected ? 16 : 8, rx: selected ? 4 : 2, fill: selected ? 'rgba(37,232,255,.16)' : '#0d1718', stroke: selected ? '#25e8ff' : '#91b4ae', 'stroke-dasharray': selected ? '3 2' : 'none' })); + } + } +} diff --git a/app/src/complex.js b/app/src/complex.js new file mode 100644 index 0000000..ebf2db1 --- /dev/null +++ b/app/src/complex.js @@ -0,0 +1,93 @@ +export const C = (re = 0, im = 0) => ({ re, im }); +export const cloneC = z => ({ re: z?.re ?? 0, im: z?.im ?? 0 }); +export const add = (a, b) => C(a.re + b.re, a.im + b.im); +export const sub = (a, b) => C(a.re - b.re, a.im - b.im); +export const mul = (a, b) => C(a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re); +export const scale = (a, s) => C(a.re * s, a.im * s); +export const conj = a => C(a.re, -a.im); +export const abs2 = a => a.re * a.re + a.im * a.im; +export const abs = a => Math.hypot(a.re, a.im); +export const arg = a => Math.atan2(a.im, a.re); +export const expi = phase => C(Math.cos(phase), Math.sin(phase)); +export const div = (a, b) => { + const d = b.re * b.re + b.im * b.im; + if (d < 1e-30) return C(Number.NaN, Number.NaN); + return C((a.re * b.re + a.im * b.im) / d, (a.im * b.re - a.re * b.im) / d); +}; + +export const zeros = n => Array.from({ length: n }, () => C()); +export const zeroMatrix = (rows, cols = rows) => Array.from({ length: rows }, () => zeros(cols)); +export const identityMatrix = n => { + const out = zeroMatrix(n); + for (let i = 0; i < n; i += 1) out[i][i] = C(1, 0); + return out; +}; + +export function matrixMultiply(a, b) { + const rows = a.length; + const inner = b.length; + const cols = b[0]?.length ?? 0; + const out = zeroMatrix(rows, cols); + for (let i = 0; i < rows; i += 1) { + for (let k = 0; k < inner; k += 1) { + const aik = a[i][k]; + if (Math.abs(aik.re) + Math.abs(aik.im) < 1e-15) continue; + for (let j = 0; j < cols; j += 1) { + const bkj = b[k][j]; + if (Math.abs(bkj.re) + Math.abs(bkj.im) < 1e-15) continue; + out[i][j] = add(out[i][j], mul(aik, bkj)); + } + } + } + return out; +} + +export function matrixVectorMultiply(a, v) { + return a.map(row => row.reduce((sum, value, idx) => add(sum, mul(value, v[idx])), C())); +} + +/** + * Complex Gaussian elimination with partial pivoting. + * Returns null for a singular or numerically invalid system. + */ +export function solveLinearSystem(matrix, rhs, epsilon = 1e-11) { + const n = matrix.length; + if (!n || rhs.length !== n) return []; + const a = matrix.map((row, i) => [...row.map(cloneC), cloneC(rhs[i])]); + + for (let col = 0; col < n; col += 1) { + let pivot = col; + let pivotSize = abs2(a[col][col]); + for (let row = col + 1; row < n; row += 1) { + const size = abs2(a[row][col]); + if (size > pivotSize) { + pivot = row; + pivotSize = size; + } + } + if (!Number.isFinite(pivotSize) || pivotSize < epsilon * epsilon) return null; + if (pivot !== col) [a[col], a[pivot]] = [a[pivot], a[col]]; + + const diagonal = a[col][col]; + for (let j = col; j <= n; j += 1) a[col][j] = div(a[col][j], diagonal); + + for (let row = 0; row < n; row += 1) { + if (row === col) continue; + const factor = a[row][col]; + if (abs2(factor) < epsilon * epsilon) continue; + for (let j = col; j <= n; j += 1) { + a[row][j] = sub(a[row][j], mul(factor, a[col][j])); + } + } + } + + const solution = a.map(row => row[n]); + return solution.every(z => Number.isFinite(z.re) && Number.isFinite(z.im)) ? solution : null; +} + +export function formatPhase(value) { + if (!Number.isFinite(value)) return '—'; + let wrapped = ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; + if (Math.abs(wrapped) < 5e-4) wrapped = 0; + return `${wrapped.toFixed(2)} rad`; +} diff --git a/app/src/coupler-view.js b/app/src/coupler-view.js new file mode 100644 index 0000000..3597191 --- /dev/null +++ b/app/src/coupler-view.js @@ -0,0 +1,468 @@ +import { localCouplerState, couplerCoefficients } from './physics.js'; +import { C, add, mul, scale, abs2, arg, expi } from './complex.js'; + +const VIEW_ORDER = ['circuit', 'power', 'phase', 'field', 'supermodes']; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const lerp = (a, b, t) => a + (b - a) * t; +const smoothstep = t => t * t * (3 - 2 * t); + +function rgba(r, g, b, a = 1) { return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${a})`; } + +function blendFieldColor(value, intensity) { + const magnitude = clamp(Math.abs(value), 0, 1); + const alpha = clamp((0.08 + magnitude * 0.8) * intensity, 0, 1); + if (value >= 0) return [142, 255, 66, alpha]; + return [37, 232, 255, alpha]; +} + +export class CouplerView { + constructor(canvas, { onViewChange = null } = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d', { alpha: true }); + this.onViewChange = onViewChange; + this.view = 'power'; + this.circuit = null; + this.coupler = null; + this.solve = null; + this.width = 1; + this.height = 1; + this.dpr = 1; + this.lastFieldFrame = -1; + this.fieldCache = document.createElement('canvas'); + this.fieldCtx = this.fieldCache.getContext('2d'); + this.pointerState = new Map(); + this.pinchStartDistance = 0; + this.wheelAccumulator = 0; + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + this.installGestures(); + } + + destroy() { this.resizeObserver.disconnect(); } + + installGestures() { + this.canvas.addEventListener('wheel', event => { + event.preventDefault(); + this.wheelAccumulator += event.deltaY; + if (Math.abs(this.wheelAccumulator) > 80) { + this.stepView(this.wheelAccumulator > 0 ? -1 : 1); + this.wheelAccumulator = 0; + } + }, { passive: false }); + + this.canvas.addEventListener('pointerdown', event => { + this.canvas.setPointerCapture?.(event.pointerId); + this.pointerState.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (this.pointerState.size === 2) this.pinchStartDistance = this.currentPinchDistance(); + }); + this.canvas.addEventListener('pointermove', event => { + if (!this.pointerState.has(event.pointerId)) return; + this.pointerState.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (this.pointerState.size === 2) { + const distance = this.currentPinchDistance(); + if (this.pinchStartDistance && Math.abs(distance - this.pinchStartDistance) > 45) { + this.stepView(distance > this.pinchStartDistance ? 1 : -1); + this.pinchStartDistance = distance; + } + } + }); + const release = event => { + this.pointerState.delete(event.pointerId); + if (this.pointerState.size < 2) this.pinchStartDistance = 0; + }; + this.canvas.addEventListener('pointerup', release); + this.canvas.addEventListener('pointercancel', release); + } + + currentPinchDistance() { + const values = [...this.pointerState.values()]; + if (values.length < 2) return 0; + return Math.hypot(values[0].x - values[1].x, values[0].y - values[1].y); + } + + stepView(direction) { + const current = VIEW_ORDER.indexOf(this.view); + const next = clamp(current + direction, 0, VIEW_ORDER.length - 1); + if (next !== current) this.setView(VIEW_ORDER[next]); + } + + setView(view, notify = true) { + if (!VIEW_ORDER.includes(view)) return; + this.view = view; + this.lastFieldFrame = -1; + if (notify) this.onViewChange?.(view); + } + + setData(circuit, coupler, solve) { + this.circuit = circuit; + this.coupler = coupler; + this.solve = solve; + this.lastFieldFrame = -1; + } + + resize() { + const rect = this.canvas.getBoundingClientRect(); + this.dpr = Math.min(2, window.devicePixelRatio || 1); + this.width = Math.max(1, rect.width); + this.height = Math.max(1, rect.height); + this.canvas.width = Math.round(this.width * this.dpr); + this.canvas.height = Math.round(this.height * this.dpr); + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.lastFieldFrame = -1; + } + + geometry() { + const w = this.width; + const h = this.height; + const x0 = w * 0.16; + const x1 = w * 0.84; + const couplingStart = w * 0.35; + const couplingEnd = w * 0.69; + const center = h * 0.52; + const outerSeparation = clamp(h * 0.22, 80, 180); + const physicalGap = Number(this.coupler?.params?.gapUm ?? .2); + const innerSeparation = clamp(44 + (physicalGap - .08) / .72 * 76, 42, 118); + return { w, h, x0, x1, couplingStart, couplingEnd, center, outerSeparation, innerSeparation }; + } + + centerY(x, branch) { + const g = this.geometry(); + const sign = branch === 0 ? -1 : 1; + const outer = sign * g.outerSeparation / 2; + const inner = sign * g.innerSeparation / 2; + if (x <= g.couplingStart) { + const t = smoothstep(clamp((x - g.x0) / Math.max(1, g.couplingStart - g.x0), 0, 1)); + return g.center + lerp(outer, inner, t); + } + if (x >= g.couplingEnd) { + const t = smoothstep(clamp((x - g.couplingEnd) / Math.max(1, g.x1 - g.couplingEnd), 0, 1)); + return g.center + lerp(inner, outer, t); + } + return g.center + inner; + } + + zFraction(x) { + const g = this.geometry(); + return clamp((x - g.couplingStart) / Math.max(1, g.couplingEnd - g.couplingStart), 0, 1); + } + + localAtX(x) { + if (!this.coupler || !this.solve) return null; + return localCouplerState(this.coupler, this.solve, this.zFraction(x)); + } + + draw(timeSeconds = 0, speed = 1, paused = false) { + if (!this.width || !this.height) this.resize(); + const ctx = this.ctx; + ctx.clearRect(0, 0, this.width, this.height); + if (!this.coupler || !this.solve) { + this.drawEmpty(); + return; + } + if (this.view === 'circuit') this.drawCircuit(timeSeconds, speed, paused); + else if (this.view === 'power') this.drawPower(timeSeconds, speed, paused); + else if (this.view === 'phase') this.drawPhase(timeSeconds, speed, paused); + else if (this.view === 'field') this.drawField(timeSeconds, speed, paused); + else this.drawSupermodes(timeSeconds, speed, paused); + this.drawDimensions(); + } + + drawEmpty() { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(255,255,255,.5)'; + ctx.font = '12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Select a directional coupler to inspect local physics.', this.width / 2, this.height / 2); + } + + drawGuideOutline(alpha = 1) { + const ctx = this.ctx; + const g = this.geometry(); + for (let branch = 0; branch < 2; branch += 1) { + ctx.beginPath(); + for (let x = g.x0; x <= g.x1; x += 2) { + const y = this.centerY(x, branch); + if (x === g.x0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = rgba(220, 250, 244, .18 * alpha); + ctx.lineWidth = 18; + ctx.lineCap = 'round'; + ctx.stroke(); + ctx.strokeStyle = rgba(221, 255, 249, .68 * alpha); + ctx.lineWidth = 2; + ctx.stroke(); + } + } + + drawCircuit(timeSeconds, speed, paused) { + const ctx = this.ctx; + const g = this.geometry(); + this.drawGuideOutline(.7); + const boxX = g.couplingStart - 18; + const boxW = g.couplingEnd - g.couplingStart + 36; + ctx.save(); + ctx.setLineDash([7, 7]); + ctx.strokeStyle = 'rgba(37,232,255,.55)'; + ctx.lineWidth = 1.5; + this.roundRectPath(ctx, boxX, g.center - g.innerSeparation / 2 - 38, boxW, g.innerSeparation + 76, 18); + ctx.stroke(); + ctx.restore(); + + ctx.fillStyle = 'rgba(6,16,18,.86)'; + ctx.strokeStyle = 'rgba(102,236,220,.24)'; + ctx.lineWidth = 1; + this.roundRectPath(ctx, g.center ? g.w / 2 - 86 : 0, g.center - 38, 172, 76, 17); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#eefaf7'; + ctx.font = '600 13px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('4-port scattering element', g.w / 2, g.center - 8); + ctx.fillStyle = '#25e8ff'; + ctx.font = '11px ui-monospace, monospace'; + ctx.fillText('b = S(g, L, λ) a', g.w / 2, g.center + 14); + + const phase = paused ? 0 : timeSeconds * speed; + ctx.strokeStyle = 'rgba(142,255,66,.95)'; + ctx.lineWidth = 4; + ctx.setLineDash([2, 18]); + ctx.lineDashOffset = -phase * 45; + ctx.beginPath(); + for (let x = g.x0; x <= g.x1; x += 2) { + const y = this.centerY(x, 0); + if (x === g.x0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.setLineDash([]); + } + + drawPower(timeSeconds, speed, paused) { + const ctx = this.ctx; + const g = this.geometry(); + this.drawGuideOutline(.8); + for (let branch = 0; branch < 2; branch += 1) { + for (let x = g.x0; x < g.x1; x += 3) { + const local = this.localAtX(x); + if (!local) continue; + const p = branch === 0 ? local.p1 : local.p2; + const y = this.centerY(x, branch); + const glow = ctx.createRadialGradient(x, y, 0, x, y, 10 + 17 * Math.sqrt(p)); + if (branch === 0) { + glow.addColorStop(0, rgba(224, 255, 178, .95 * clamp(Math.sqrt(p), 0, 1))); + glow.addColorStop(.25, rgba(142, 255, 66, .62 * clamp(Math.sqrt(p), 0, 1))); + glow.addColorStop(1, rgba(142, 255, 66, 0)); + } else { + glow.addColorStop(0, rgba(209, 251, 255, .95 * clamp(Math.sqrt(p), 0, 1))); + glow.addColorStop(.25, rgba(37, 232, 255, .62 * clamp(Math.sqrt(p), 0, 1))); + glow.addColorStop(1, rgba(37, 232, 255, 0)); + } + ctx.fillStyle = glow; + ctx.fillRect(x - 30, y - 30, 60, 60); + } + } + + const phase = paused ? 0 : timeSeconds * speed; + for (let branch = 0; branch < 2; branch += 1) { + ctx.beginPath(); + for (let x = g.x0; x <= g.x1; x += 2) { + const y = this.centerY(x, branch); + if (x === g.x0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = branch === 0 ? 'rgba(230,255,213,.9)' : 'rgba(210,250,255,.9)'; + ctx.lineWidth = 1.3; + ctx.setLineDash([1, 22]); + ctx.lineDashOffset = -phase * 48 + branch * 11; + ctx.stroke(); + } + ctx.setLineDash([]); + } + + drawPhase(timeSeconds, speed, paused) { + const ctx = this.ctx; + const g = this.geometry(); + this.drawGuideOutline(.5); + const animation = paused ? 0 : timeSeconds * speed * 2.2; + for (let branch = 0; branch < 2; branch += 1) { + for (let x = g.x0; x < g.x1; x += 4) { + const local = this.localAtX(x); + if (!local) continue; + const phase = (branch === 0 ? local.phase1 : local.phase2) + animation + x * .055; + const value = Math.cos(phase); + const p = branch === 0 ? local.p1 : local.p2; + const color = value >= 0 ? [142, 255, 66] : [37, 232, 255]; + const y = this.centerY(x, branch); + ctx.fillStyle = rgba(color[0], color[1], color[2], .1 + .74 * Math.abs(value) * clamp(Math.sqrt(p), 0, 1)); + ctx.beginPath(); + ctx.arc(x, y, 3.5 + 5 * Math.abs(value), 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.fillStyle = 'rgba(230,248,244,.7)'; + ctx.font = '10px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Signed phase fronts — green and cyan indicate opposite field sign', g.w / 2, g.center + g.outerSeparation / 2 + 75); + } + + drawField(timeSeconds, speed, paused) { + const frameKey = Math.floor((paused ? 0 : timeSeconds * speed) * 24); + if (frameKey !== this.lastFieldFrame) { + this.lastFieldFrame = frameKey; + this.buildFieldImage(timeSeconds, speed, paused); + } + const g = this.geometry(); + this.ctx.drawImage(this.fieldCache, g.x0 - 24, g.center - g.outerSeparation / 2 - 55, g.x1 - g.x0 + 48, g.outerSeparation + 110); + this.drawGuideOutline(.55); + this.ctx.fillStyle = 'rgba(230,248,244,.62)'; + this.ctx.font = '9px system-ui'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('Re(E) from compact transverse modes · carrier wavelength visually magnified', g.w / 2, g.center + g.outerSeparation / 2 + 76); + } + + buildFieldImage(timeSeconds, speed, paused) { + const g = this.geometry(); + const width = 300; + const height = 170; + this.fieldCache.width = width; + this.fieldCache.height = height; + const image = this.fieldCtx.createImageData(width, height); + const data = image.data; + const animation = paused ? 0 : timeSeconds * speed * 2.6; + const sigma = Math.max(8, 11 + (this.coupler?.params?.gapUm ?? .2) * 6); + + for (let ix = 0; ix < width; ix += 1) { + const xWorld = lerp(g.x0 - 24, g.x1 + 24, ix / (width - 1)); + const local = this.localAtX(xWorld); + if (!local) continue; + const y1World = this.centerY(xWorld, 0); + const y2World = this.centerY(xWorld, 1); + const carrier = animation + ix * .17; + const carrierFactor = expi(carrier); + for (let iy = 0; iy < height; iy += 1) { + const yWorld = lerp(g.center - g.outerSeparation / 2 - 55, g.center + g.outerSeparation / 2 + 55, iy / (height - 1)); + const mode1 = Math.exp(-((yWorld - y1World) ** 2) / (2 * sigma ** 2)); + const mode2 = Math.exp(-((yWorld - y2World) ** 2) / (2 * sigma ** 2)); + const field = mul(add(scale(local.a1, mode1), scale(local.a2, mode2)), carrierFactor); + const intensity = clamp(abs2(field) * 1.7, 0, 1.2); + const signed = clamp(field.re * 1.6, -1, 1); + const [r, gg, b, a] = blendFieldColor(signed, Math.sqrt(intensity)); + const idx = (iy * width + ix) * 4; + data[idx] = r; + data[idx + 1] = gg; + data[idx + 2] = b; + data[idx + 3] = Math.round(a * 255); + } + } + this.fieldCtx.putImageData(image, 0, 0); + } + + drawSupermodes(timeSeconds, speed, paused) { + const ctx = this.ctx; + const g = this.geometry(); + const panelGap = 24; + const panelHeight = Math.min(150, this.height * .22); + const topCenter = g.center - panelHeight / 2 - panelGap; + const bottomCenter = g.center + panelHeight / 2 + panelGap; + const animation = paused ? 0 : timeSeconds * speed * 1.7; + + this.drawModePanel(topCenter, panelHeight, +1, 'Even supermode', animation); + this.drawModePanel(bottomCenter, panelHeight, -1, 'Odd supermode', animation + Math.PI / 2); + + ctx.fillStyle = 'rgba(226,246,241,.66)'; + ctx.font = '9px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Their propagation-constant difference produces the observed power beating.', g.w / 2, bottomCenter + panelHeight / 2 + 42); + } + + drawModePanel(centerY, height, parity, label, animation) { + const ctx = this.ctx; + const g = this.geometry(); + const xStart = g.x0 + 20; + const xEnd = g.x1 - 20; + const separation = clamp(g.innerSeparation * .7, 34, 70); + const sigma = 13; + ctx.save(); + ctx.beginPath(); + this.roundRectPath(ctx, xStart - 15, centerY - height / 2, xEnd - xStart + 30, height, 17); + ctx.clip(); + ctx.fillStyle = 'rgba(6,14,16,.72)'; + ctx.fillRect(xStart - 15, centerY - height / 2, xEnd - xStart + 30, height); + for (let x = xStart; x <= xEnd; x += 3) { + const carrier = Math.cos(animation + x * .09); + for (let y = centerY - height / 2; y <= centerY + height / 2; y += 3) { + const upper = Math.exp(-((y - (centerY - separation / 2)) ** 2) / (2 * sigma ** 2)); + const lower = Math.exp(-((y - (centerY + separation / 2)) ** 2) / (2 * sigma ** 2)); + const value = (upper + parity * lower) * carrier; + if (Math.abs(value) < .035) continue; + const color = value >= 0 ? [142,255,66] : [37,232,255]; + ctx.fillStyle = rgba(color[0],color[1],color[2],clamp(Math.abs(value)*.55,0,.7)); + ctx.fillRect(x, y, 4, 4); + } + } + ctx.restore(); + ctx.strokeStyle = 'rgba(99,221,208,.2)'; + ctx.lineWidth = 1; + this.roundRectPath(ctx, xStart - 15, centerY - height / 2, xEnd - xStart + 30, height, 17); + ctx.stroke(); + ctx.fillStyle = '#dff7f1'; + ctx.font = '600 10px system-ui'; + ctx.textAlign = 'left'; + ctx.fillText(label, xStart, centerY - height / 2 + 18); + ctx.fillStyle = 'rgba(173,200,195,.7)'; + ctx.font = '8px system-ui'; + ctx.fillText(parity > 0 ? 'same field sign across the guides' : 'π phase flip across the guides', xStart, centerY - height / 2 + 32); + } + + drawDimensions() { + if (this.view === 'circuit') return; + const ctx = this.ctx; + const g = this.geometry(); + ctx.save(); + ctx.strokeStyle = 'rgba(213,242,236,.42)'; + ctx.fillStyle = 'rgba(213,242,236,.68)'; + ctx.lineWidth = 1; + ctx.font = '9px system-ui'; + ctx.textAlign = 'center'; + const y = g.center - g.innerSeparation / 2 - 48; + ctx.beginPath(); + ctx.moveTo(g.couplingStart, y); + ctx.lineTo(g.couplingEnd, y); + ctx.moveTo(g.couplingStart, y - 6); ctx.lineTo(g.couplingStart, y + 6); + ctx.moveTo(g.couplingEnd, y - 6); ctx.lineTo(g.couplingEnd, y + 6); + ctx.stroke(); + ctx.fillText(`L = ${(this.coupler.params?.interactionLengthUm ?? 20).toFixed(2)} µm`, (g.couplingStart + g.couplingEnd) / 2, y - 8); + + const x = g.w / 2; + const yTop = g.center - g.innerSeparation / 2; + const yBottom = g.center + g.innerSeparation / 2; + ctx.beginPath(); + ctx.moveTo(x, yTop + 8); ctx.lineTo(x, yBottom - 8); + ctx.moveTo(x - 5, yTop + 8); ctx.lineTo(x + 5, yTop + 8); + ctx.moveTo(x - 5, yBottom - 8); ctx.lineTo(x + 5, yBottom - 8); + ctx.stroke(); + ctx.textAlign = 'left'; + ctx.fillText(`g = ${(this.coupler.params?.gapUm ?? .2).toFixed(3)} µm`, x + 9, g.center + 3); + ctx.restore(); + } + + roundRectPath(ctx, x, y, width, height, radius) { + const r = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); + } +} + +export function couplerViewMeta(view) { + const index = VIEW_ORDER.indexOf(view); + const map = { + circuit: { depth: '1× · scattering overview', model: '4-port scattering model', scale: 'Ports and circuit context' }, + power: { depth: '10× · power flow', model: 'Distributed coupled-mode model', scale: 'Normalized power 0 → 1' }, + phase: { depth: '30× · complex phase', model: 'Complex coupled amplitudes', scale: 'Signed field phase' }, + field: { depth: '100× · reconstructed field', model: 'Compact local field reconstruction', scale: 'Re(E), normalized' }, + supermodes: { depth: '300× · supermodes', model: 'Even / odd modal basis', scale: 'Signed modal field' } + }; + return { ...map[view], index }; +} diff --git a/app/src/export.js b/app/src/export.js new file mode 100644 index 0000000..c15e82b --- /dev/null +++ b/app/src/export.js @@ -0,0 +1,293 @@ +import { defaultParams, getDefinition, getPorts } from './models.js'; +import { connectionDomain, isOpticalConnection } from './physics.js'; +import { bridgeManifest } from './bridge.js'; + +const clone = value => structuredClone(value); +const yamlString = value => JSON.stringify(String(value)); + +export function semanticNetlist(circuit) { + const domains = Object.fromEntries(['optical','electrical','rf','control','annotation'].map(domain => [domain, (circuit.connections ?? []).filter(connection => connectionDomain(connection, circuit.components) === domain).length])); + return { + format: 'picsetup-semantic-netlist', + schemaVersion: 2, + generatedAt: new Date().toISOString(), + model: { + basis: 'scalar TE0 power-normalized complex amplitudes', + equation: 'b = S a + s', + routingMode: circuit.settings?.routingMode ?? 'physical', + worldToUm: circuit.settings?.worldToUm ?? 0.2, + boundary: 'Only optical-domain ports and connections enter the coherent solve; electrical, RF, control, and figure objects remain typed semantic graph elements.' + }, + graphSummary: { + components: circuit.components?.length ?? 0, + connections: circuit.connections?.length ?? 0, + connectionDomains: domains, + figureObjects: (circuit.components ?? []).filter(component => getDefinition(component.type).diagramOnly).length + }, + circuit: clone(circuit), + bridges: bridgeManifest(circuit), + models: Object.fromEntries(circuit.components.map(component => { + const definition = getDefinition(component.type); + return [component.id, { + type: component.type, + label: definition.label, + provenance: component.params?.provenance ?? definition.provenance, + assumptions: definition.assumptions ?? [], + diagramOnly: Boolean(definition.diagramOnly), + ports: getPorts(component).map(port => ({ id: port.id, label: port.label, domain: port.role ?? 'optical', medium: port.medium ?? null })) + }]; + })) + }; +} + +export function toSaxYAML(circuit) { + const lines = [ + `name: ${yamlString(circuit.name ?? 'picsetup_circuit')}`, + 'schema: picsetup-sax-oriented-v1', + 'settings:', + ` wavelength_nm: ${Number(circuit.settings?.wavelengthNm ?? 1550)}`, + 'instances:' + ]; + const physicalComponents = circuit.components.filter(component => !getDefinition(component.type).diagramOnly); + const opticalConnections = circuit.connections.filter(connection => isOpticalConnection(connection, circuit.components)); + for (const component of physicalComponents) { + lines.push(` ${component.id}:`); + lines.push(` component: ${yamlString(component.type)}`); + lines.push(` model: ${yamlString(component.params?.modelName ?? getDefinition(component.type).label)}`); + lines.push(' settings:'); + for (const [key, value] of Object.entries(component.params ?? {})) { + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') lines.push(` ${key}: ${typeof value === 'string' ? yamlString(value) : value}`); + } + } + lines.push('placements:'); + for (const component of physicalComponents) { + lines.push(` ${component.id}: {x: ${Number(component.x ?? 0)}, y: ${Number(component.y ?? 0)}, rotation: ${Number(component.rotation ?? 0)}}`); + } + lines.push('connections:'); + for (const connection of opticalConnections) { + lines.push(` ${connection.a.component},${connection.a.port}: ${yamlString(`${connection.b.component},${connection.b.port}`)}`); + } + lines.push('metadata:'); + lines.push(' note: "SAX-oriented optical export. Figure-only objects and non-optical links remain in the semantic JSON, not this physical-model handoff. Map model names to your PDK/model library before simulation."'); + return `${lines.join('\n')}\n`; +} + +function pythonLiteral(value, indent = 0) { + if (value === null) return 'None'; + if (typeof value === 'boolean') return value ? 'True' : 'False'; + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'None'; + if (typeof value === 'string') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(item => pythonLiteral(item, indent + 1)).join(', ')}]`; + if (typeof value === 'object') return `{${Object.entries(value).filter(([, item]) => ['string','number','boolean'].includes(typeof item)).map(([key, item]) => `${JSON.stringify(key)}: ${pythonLiteral(item, indent + 1)}`).join(', ')}}`; + return 'None'; +} + +export function toGdsfactoryPython(circuit) { + const componentMap = { + source: 'gf.components.straight(length=10)', + detector: 'gf.components.straight(length=10)', + termination: 'gf.components.straight(length=5)', + phase: 'gf.components.straight(length=max(1, params.get("lengthUm", 20)))', + modulator: 'gf.components.straight(length=max(1, params.get("lengthUm", 1000)))', + attenuator: 'gf.components.straight(length=max(1, params.get("lengthUm", 10)))', + coupler: 'gf.components.coupler(gap=params.get("gapUm", 0.2), length=params.get("interactionLengthUm", 20))', + mmi: 'gf.components.mmi2x2()', + splitter: 'gf.components.mmi1x2()', + ring: 'gf.components.ring_double(radius=params.get("radiusUm", 10))', + crossing: 'gf.components.crossing()', + bragg: 'gf.components.dbr()', + grating: 'gf.components.grating_coupler_elliptical()', + 'edge-coupler': 'gf.components.taper(length=20, width1=0.5, width2=0.15)', + spiral: 'gf.components.spiral_racetrack(length=params.get("lengthUm", 5000))', + heater: 'gf.components.straight_heater_metal(length=max(10, params.get("lengthUm", 100)))', + awg: 'gf.components.rectangle(size=(60, 40))', + 'fiber-array': 'gf.components.array(component=gf.components.grating_coupler_elliptical(), columns=1, rows=4, spacing=(0, 127))', + probe: 'gf.components.straight(length=5)', + generic: 'gf.components.rectangle(size=(20, 10))', + block: 'gf.components.rectangle(size=(30, 16))' + }; + const physicalIds = new Set(circuit.components.filter(component => !getDefinition(component.type).diagramOnly).map(component => component.id)); + const layoutCircuit = { + ...clone(circuit), + components: circuit.components.filter(component => physicalIds.has(component.id)), + connections: circuit.connections.filter(connection => isOpticalConnection(connection, circuit.components) && physicalIds.has(connection.a.component) && physicalIds.has(connection.b.component)) + }; + const state = JSON.stringify(layoutCircuit, null, 2); + return `"""PicSetup starter layout. + +Generated as a readable handoff, not a foundry-ready layout. Replace generic mappings, +port names, routes, cross-sections, and process settings with your PDK conventions. +""" +from __future__ import annotations +import json +import gdsfactory as gf + +PICSETUP = json.loads(r'''${state.replace(/'''/g, "\\'\\'\\'")}''') + + +def component_for(instance: dict) -> gf.Component: + params = instance.get("params", {}) + kind = instance["type"] +${Object.entries(componentMap).map(([type, expression], index) => ` ${index ? 'elif' : 'if'} kind == ${JSON.stringify(type)}:\n return ${expression}`).join('\n')} + return gf.components.rectangle(size=(20, 10)) + + +@gf.cell +def picsetup_layout() -> gf.Component: + c = gf.Component(${JSON.stringify((circuit.name ?? 'picsetup').replace(/[^a-zA-Z0-9_]+/g, '_'))}) + refs: dict[str, gf.ComponentReference] = {} + for instance in PICSETUP["components"]: + ref = c << component_for(instance) + # PicSetup canvas coordinates are schematic/world coordinates. Adjust scale for your PDK. + ref.move((instance.get("x", 0), -instance.get("y", 0))) + refs[instance["id"]] = ref + + # Connections are preserved below as semantic endpoints. Port-name mappings vary by PDK. + semantic_connections = [ +${layoutCircuit.connections.map(item => ` (${pythonLiteral(`${item.a.component},${item.a.port}`)}, ${pythonLiteral(`${item.b.component},${item.b.port}`)}),`).join('\n')} + ] + c.info["picsetup_connections"] = semantic_connections + c.info["picsetup_model_state"] = PICSETUP + return c + + +if __name__ == "__main__": + component = picsetup_layout() + component.show() +`; +} + +export function downloadText(filename, text, type = 'text/plain') { + const blob = new Blob([text], { type }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); +} + +function parseComplex(value) { + if (Array.isArray(value)) return [Number(value[0] ?? 0), Number(value[1] ?? 0)]; + if (typeof value === 'object') return [Number(value.re ?? 0), Number(value.im ?? 0)]; + if (typeof value === 'number') return [value, 0]; + const text = String(value ?? '').trim().replace(/i$/i, 'j'); + const match = text.match(/^([+-]?[\d.eE]+)?([+-][\d.eE]+)j$/); + if (match) return [Number(match[1] || 0), Number(match[2] || 0)]; + return [Number(text) || 0, 0]; +} + +export function parseSParameterData(text, filename = 'Imported S matrix') { + const trimmed = text.trim(); + if (!trimmed) throw new Error('The S-parameter file is empty.'); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + const data = JSON.parse(trimmed); + const root = Array.isArray(data) ? { samples: data } : data; + if (Array.isArray(root.wavelengthsNm) && Array.isArray(root.matrices)) { + const count = root.matrices[0]?.length ?? 2; + return { + modelName: root.modelName ?? filename, + provenance: root.provenance ?? 'Imported JSON S-parameter table', + ports: root.ports ?? makePorts(count), + sParameters: { wavelengthsNm: root.wavelengthsNm.map(Number), matrices: root.matrices } + }; + } + if (Array.isArray(root.samples)) { + const wavelengthsNm = root.samples.map(sample => Number(sample.wavelengthNm ?? sample.wavelength_nm ?? sample.lambda_nm)); + const matrices = root.samples.map(sample => sample.S ?? sample.matrix ?? sample.s); + const count = matrices[0]?.length ?? 2; + return { modelName: root.modelName ?? filename, provenance: root.provenance ?? 'Imported JSON S-parameter samples', ports: root.ports ?? makePorts(count), sParameters: { wavelengthsNm, matrices } }; + } + throw new Error('JSON must contain wavelengthsNm + matrices, or a samples array.'); + } + + const lines = trimmed.split(/\r?\n/).filter(line => line.trim() && !line.trim().startsWith('#')); + const delimiter = lines[0].includes('\t') ? '\t' : ','; + const headers = lines[0].split(delimiter).map(value => value.trim().toLowerCase()); + const wavelengthColumn = headers.findIndex(value => ['wavelength_nm','wavelength','lambda_nm','lambda'].includes(value)); + if (wavelengthColumn < 0) throw new Error('CSV requires a wavelength_nm column.'); + const portPairs = []; + for (const header of headers) { + const match = header.match(/^s(\d+)(\d+)_(re|im)$/); + if (match) portPairs.push([Number(match[1]), Number(match[2])]); + } + const portCount = Math.max(2, ...portPairs.flat()); + const wavelengthsNm = []; + const matrices = []; + for (const line of lines.slice(1)) { + const cells = line.split(delimiter).map(value => value.trim()); + const wavelength = Number(cells[wavelengthColumn]); + if (!Number.isFinite(wavelength)) continue; + const matrix = Array.from({ length: portCount }, () => Array.from({ length: portCount }, () => [0, 0])); + for (let row = 1; row <= portCount; row += 1) { + for (let col = 1; col <= portCount; col += 1) { + const reIndex = headers.indexOf(`s${row}${col}_re`); + const imIndex = headers.indexOf(`s${row}${col}_im`); + matrix[row - 1][col - 1] = [Number(cells[reIndex] ?? 0), Number(cells[imIndex] ?? 0)]; + } + } + wavelengthsNm.push(wavelength); + matrices.push(matrix); + } + if (!wavelengthsNm.length) throw new Error('No numeric S-parameter rows were found.'); + return { modelName: filename, provenance: 'Imported CSV complex S-parameter table', ports: makePorts(portCount), sParameters: { wavelengthsNm, matrices } }; +} + +function makePorts(count) { + const leftCount = Math.ceil(count / 2); + const rightCount = count - leftCount; + const ports = []; + for (let index = 0; index < leftCount; index += 1) ports.push({ id: `p${index + 1}`, label: `P${index + 1}`, role: 'optical', x: -52, y: (index - (leftCount - 1) / 2) * 22 }); + for (let index = 0; index < rightCount; index += 1) ports.push({ id: `p${leftCount + index + 1}`, label: `P${leftCount + index + 1}`, role: 'optical', x: 52, y: (index - (rightCount - 1) / 2) * 22 }); + return ports; +} + +export function makeSParameterComponent(params, point = { x: 550, y: 360 }) { + return { + id: `sblock-${Math.random().toString(36).slice(2, 8)}`, + type: 'generic', + name: params.modelName ?? 'S-parameter block', + x: point.x, y: point.y, rotation: 0, + params: { ...defaultParams('generic'), ...clone(params) } + }; +} + +export function autoLayoutCircuit(circuit) { + const components = circuit.components ?? []; + if (!components.length) return circuit; + const depth = new Map(); + const queue = []; + const sources = components.filter(component => component.type === 'source'); + (sources.length ? sources : [components[0]]).forEach(component => { depth.set(component.id, 0); queue.push(component.id); }); + const neighbors = new Map(components.map(component => [component.id, []])); + for (const connection of circuit.connections ?? []) { + neighbors.get(connection.a.component)?.push(connection.b.component); + neighbors.get(connection.b.component)?.push(connection.a.component); + } + while (queue.length) { + const id = queue.shift(); + for (const neighbor of neighbors.get(id) ?? []) { + if (!depth.has(neighbor)) { depth.set(neighbor, depth.get(id) + 1); queue.push(neighbor); } + } + } + let fallbackDepth = Math.max(0, ...depth.values()) + 1; + components.forEach(component => { if (!depth.has(component.id)) depth.set(component.id, fallbackDepth++); }); + const columns = new Map(); + components.forEach(component => { const d = depth.get(component.id); if (!columns.has(d)) columns.set(d, []); columns.get(d).push(component); }); + const maxDepth = Math.max(...columns.keys()); + for (const [d, items] of columns) { + items.forEach((component, index) => { + component.x = 100 + d * Math.min(230, 900 / Math.max(1, maxDepth)); + component.y = 360 + (index - (items.length - 1) / 2) * Math.min(150, 520 / Math.max(1, items.length)); + }); + } + circuit.connections?.forEach(connection => { connection.waypoints = []; }); + return circuit; +} + +export function importSemanticNetlist(data) { + const circuit = clone(data?.circuit ?? data); + if (!Array.isArray(circuit?.components) || !Array.isArray(circuit?.connections)) throw new Error('No PicSetup-compatible circuit graph was found.'); + const hasPlacement = circuit.components.some(component => Number.isFinite(component.x) && Number.isFinite(component.y)); + if (!hasPlacement) autoLayoutCircuit(circuit); + return circuit; +} diff --git a/app/src/geometry.js b/app/src/geometry.js new file mode 100644 index 0000000..29a692f --- /dev/null +++ b/app/src/geometry.js @@ -0,0 +1,139 @@ +const TENSION = 0.78; + +export function curveSegments(points) { + if (!Array.isArray(points) || points.length < 2) return []; + if (points.length === 2) return [{ type: 'line', p0: points[0], p1: points[1] }]; + const segments = []; + for (let i = 0; i < points.length - 1; i += 1) { + const p0 = points[i - 1] ?? points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] ?? p2; + const c1 = { + x: p1.x + ((p2.x - p0.x) / 6) * TENSION, + y: p1.y + ((p2.y - p0.y) / 6) * TENSION + }; + const c2 = { + x: p2.x - ((p3.x - p1.x) / 6) * TENSION, + y: p2.y - ((p3.y - p1.y) / 6) * TENSION + }; + segments.push({ type: 'cubic', p0: p1, c1, c2, p1: p2 }); + } + return segments; +} + +export function smoothPath(points) { + if (!points?.length) return ''; + if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; + const segments = curveSegments(points); + let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + for (const segment of segments) { + if (segment.type === 'line') { + d += ` L ${segment.p1.x.toFixed(2)} ${segment.p1.y.toFixed(2)}`; + } else { + d += ` C ${segment.c1.x.toFixed(2)} ${segment.c1.y.toFixed(2)}, ${segment.c2.x.toFixed(2)} ${segment.c2.y.toFixed(2)}, ${segment.p1.x.toFixed(2)} ${segment.p1.y.toFixed(2)}`; + } + } + return d; +} + +const distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y); +const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + +function cubicLength(segment, tolerance = 0.02, depth = 0) { + const chord = distance(segment.p0, segment.p1); + const polygon = distance(segment.p0, segment.c1) + distance(segment.c1, segment.c2) + distance(segment.c2, segment.p1); + if (depth >= 14 || polygon - chord <= tolerance) return (polygon + chord) / 2; + + const p01 = midpoint(segment.p0, segment.c1); + const p12 = midpoint(segment.c1, segment.c2); + const p23 = midpoint(segment.c2, segment.p1); + const p012 = midpoint(p01, p12); + const p123 = midpoint(p12, p23); + const p0123 = midpoint(p012, p123); + return cubicLength({ type: 'cubic', p0: segment.p0, c1: p01, c2: p012, p1: p0123 }, tolerance / 2, depth + 1) + + cubicLength({ type: 'cubic', p0: p0123, c1: p123, c2: p23, p1: segment.p1 }, tolerance / 2, depth + 1); +} + +export function curveLength(points, tolerance = 0.02) { + return curveSegments(points).reduce((total, segment) => total + (segment.type === 'line' ? distance(segment.p0, segment.p1) : cubicLength(segment, tolerance)), 0); +} + +export function polylineLength(points) { + let total = 0; + for (let i = 1; i < (points?.length ?? 0); i += 1) total += distance(points[i - 1], points[i]); + return total; +} + +export function cubicPoint(segment, t) { + if (segment.type === 'line') { + return { + x: segment.p0.x + (segment.p1.x - segment.p0.x) * t, + y: segment.p0.y + (segment.p1.y - segment.p0.y) * t + }; + } + const u = 1 - t; + const a = u ** 3; + const b = 3 * u * u * t; + const c = 3 * u * t * t; + const d = t ** 3; + return { + x: a * segment.p0.x + b * segment.c1.x + c * segment.c2.x + d * segment.p1.x, + y: a * segment.p0.y + b * segment.c1.y + c * segment.c2.y + d * segment.p1.y + }; +} + + +function cubicDerivatives(segment, t) { + if (segment.type === 'line') { + return { + first: { x: segment.p1.x - segment.p0.x, y: segment.p1.y - segment.p0.y }, + second: { x: 0, y: 0 } + }; + } + const u = 1 - t; + const first = { + x: 3 * (u * u * (segment.c1.x - segment.p0.x) + 2 * u * t * (segment.c2.x - segment.c1.x) + t * t * (segment.p1.x - segment.c2.x)), + y: 3 * (u * u * (segment.c1.y - segment.p0.y) + 2 * u * t * (segment.c2.y - segment.c1.y) + t * t * (segment.p1.y - segment.c2.y)) + }; + const second = { + x: 6 * (u * (segment.c2.x - 2 * segment.c1.x + segment.p0.x) + t * (segment.p1.x - 2 * segment.c2.x + segment.c1.x)), + y: 6 * (u * (segment.c2.y - 2 * segment.c1.y + segment.p0.y) + t * (segment.p1.y - 2 * segment.c2.y + segment.c1.y)) + }; + return { first, second }; +} + +/** + * Returns the smallest local radius of curvature of the rendered route. + * Straight segments report Infinity. The estimate samples the exact cubic + * Bezier representation used by smoothPath, so bend checks match the view. + */ +export function curveMinimumRadius(points, samplesPerSegment = 64) { + const segments = curveSegments(points); + if (!segments.length) return { radius: Number.POSITIVE_INFINITY, point: points?.[0] ?? null, segmentIndex: -1, t: 0 }; + let best = { radius: Number.POSITIVE_INFINITY, point: segments[0].p0, segmentIndex: 0, t: 0 }; + segments.forEach((segment, segmentIndex) => { + if (segment.type === 'line') return; + const samples = Math.max(8, Math.round(samplesPerSegment)); + for (let index = 0; index <= samples; index += 1) { + const t = index / samples; + const { first, second } = cubicDerivatives(segment, t); + const speed2 = first.x * first.x + first.y * first.y; + const cross = Math.abs(first.x * second.y - first.y * second.x); + if (speed2 < 1e-18 || cross < 1e-18) continue; + const radius = Math.pow(speed2, 1.5) / cross; + if (Number.isFinite(radius) && radius < best.radius) best = { radius, point: cubicPoint(segment, t), segmentIndex, t }; + } + }); + return best; +} + +export function sampleCurve(points, samplesPerSegment = 24) { + const segments = curveSegments(points); + if (!segments.length) return points ? [...points] : []; + const out = [segments[0].p0]; + for (const segment of segments) { + for (let i = 1; i <= samplesPerSegment; i += 1) out.push(cubicPoint(segment, i / samplesPerSegment)); + } + return out; +} diff --git a/app/src/models.js b/app/src/models.js new file mode 100644 index 0000000..43f7213 --- /dev/null +++ b/app/src/models.js @@ -0,0 +1,773 @@ +import { C, add, div, expi, mul, scale, zeroMatrix, zeros, abs2 } from './complex.js'; + +export const DEFAULT_WAVELENGTH_NM = 1550; + +const p = (key, label, unit, min, max, step, value, extra = {}) => ({ + key, label, unit, min, max, step, default: value, sweepable: true, ...extra +}); +const f = (key, label, type, value, extra = {}) => ({ key, label, type, default: value, ...extra }); + +const twoPorts = Object.freeze([ + { id: 'left', x: -48, y: 0, role: 'optical', label: 'L' }, + { id: 'right', x: 48, y: 0, role: 'optical', label: 'R' } +]); +const fourBusPorts = Object.freeze([ + { id: 'lt', x: -50, y: -16, role: 'optical', label: 'L₁' }, + { id: 'lb', x: -50, y: 16, role: 'optical', label: 'L₂' }, + { id: 'rt', x: 50, y: -16, role: 'optical', label: 'R₁' }, + { id: 'rb', x: 50, y: 16, role: 'optical', label: 'R₂' } +]); + +export function couplingCoefficient(gapUm, wavelengthNm = DEFAULT_WAVELENGTH_NM) { + const kappaReference = Math.PI / 80; + const gapFactor = Math.exp(-(gapUm - 0.2) / 0.14); + const wavelengthFactor = Math.pow(DEFAULT_WAVELENGTH_NM / Math.max(1, wavelengthNm), 0.55); + return kappaReference * gapFactor * wavelengthFactor; +} + +export function couplerCoefficients(params, wavelengthNm = DEFAULT_WAVELENGTH_NM) { + const gapUm = Math.max(0.04, Number(params?.gapUm ?? 0.2)); + const interactionLengthUm = Math.max(0, Number(params?.interactionLengthUm ?? 20)); + const kappa = couplingCoefficient(gapUm, wavelengthNm); + const theta = kappa * interactionLengthUm; + const amp = Math.pow(10, -Math.max(0, Number(params?.insertionLossDb ?? 0)) / 20); + return { + gapUm, interactionLengthUm, kappa, theta, + t: amp * Math.cos(theta), + k: amp * Math.sin(theta), + amplitudeTransmission: amp, + throughPower: amp * amp * Math.cos(theta) ** 2, + crossPower: amp * amp * Math.sin(theta) ** 2 + }; +} + +function passiveModel(ports) { + return { ports, scattering: zeroMatrix(ports.length), s: zeros(ports.length) }; +} + +function reciprocalTwoPort(component, environment, { phaseRad = 0, lossDb = 0, lengthUm = 0, neff = 2.42 } = {}) { + const model = passiveModel(twoPorts); + const wavelengthUm = Math.max(1e-9, environment.wavelengthNm / 1000); + const propagation = -2 * Math.PI * neff * Math.max(0, lengthUm) / wavelengthUm; + const amplitude = Math.pow(10, -Math.max(0, lossDb) / 20); + const transfer = scale(expi(phaseRad + propagation), amplitude); + model.scattering[0][1] = transfer; + model.scattering[1][0] = transfer; + return model; +} + +function pairTransferModel(ports, t, k) { + const model = passiveModel(ports); + model.scattering[0][2] = t; + model.scattering[0][3] = k; + model.scattering[1][2] = k; + model.scattering[1][3] = t; + model.scattering[2][0] = t; + model.scattering[2][1] = k; + model.scattering[3][0] = k; + model.scattering[3][1] = t; + return model; +} + +function interpolateComplexS(params, wavelengthNm, portCount) { + const fallback = zeroMatrix(portCount); + if (portCount === 2) { + fallback[0][1] = C(1, 0); + fallback[1][0] = C(1, 0); + } + const wavelengths = Array.isArray(params?.sParameters?.wavelengthsNm) ? params.sParameters.wavelengthsNm.map(Number) : []; + const matrices = Array.isArray(params?.sParameters?.matrices) ? params.sParameters.matrices : []; + if (!wavelengths.length || matrices.length !== wavelengths.length) return fallback; + const asComplex = value => Array.isArray(value) ? C(Number(value[0] ?? 0), Number(value[1] ?? 0)) : C(Number(value?.re ?? value ?? 0), Number(value?.im ?? 0)); + const normalize = matrix => Array.from({ length: portCount }, (_, row) => Array.from({ length: portCount }, (_, col) => asComplex(matrix?.[row]?.[col]))); + if (wavelengthNm <= wavelengths[0]) return normalize(matrices[0]); + if (wavelengthNm >= wavelengths.at(-1)) return normalize(matrices.at(-1)); + let hi = wavelengths.findIndex(value => value >= wavelengthNm); + if (hi <= 0) hi = 1; + const lo = hi - 1; + const f = (wavelengthNm - wavelengths[lo]) / Math.max(1e-12, wavelengths[hi] - wavelengths[lo]); + const a = normalize(matrices[lo]); + const b = normalize(matrices[hi]); + return a.map((row, i) => row.map((z, j) => C(z.re + (b[i][j].re - z.re) * f, z.im + (b[i][j].im - z.im) * f))); +} + +const registry = new Map(); +function register(definition) { + registry.set(definition.type, Object.freeze(definition)); + return definition; +} + +register({ + type: 'source', category: 'Sources', label: 'Laser', shortLabel: 'Laser', glyph: '✦', prefix: 'src', + description: 'Coherent scalar source with CW or transform-limited Gaussian pulse settings.', + ports: [{ id: 'out', x: 42, y: 0, role: 'optical', label: 'OUT' }], + bounds: { x: -42, y: -42, width: 84, height: 84, rx: 18 }, + parameters: [ + p('powerMw', 'Power', 'mW', 0, 10, 0.01, 1, { tolerance: 0.01 }), + p('wavelengthNm', 'Wavelength λ', 'nm', 1200, 1700, 1, DEFAULT_WAVELENGTH_NM, { target: 'global', tolerance: 0.1 }), + p('phaseRad', 'Initial phase', 'rad', -Math.PI, Math.PI, 0.005, 0, { tolerance: 0.005 }), + p('repetitionRateMHz', 'Repetition rate', 'MHz', 1, 2000, 1, 80, { scope: 'pulse', tolerance: 0.1 }), + p('pulseDurationPs', 'Intensity FWHM', 'ps', 0.2, 500, 0.1, 12, { scope: 'pulse', tolerance: 0.05 }) + ], + defaults: { sourceMode: 'cw', cwVisualization: 'solid', spectralSamples: 192 }, + assumptions: ['single coherent scalar mode', 'transform-limited Gaussian pulse when pulsed'], + provenance: 'Analytical source boundary condition', + model(component) { + const ports = getDefinition('source').ports; + const model = passiveModel(ports); + const power = Math.max(0, Number(component.params?.powerMw ?? 0)); + model.s[0] = scale(expi(Number(component.params?.phaseRad ?? 0)), Math.sqrt(power)); + return model; + } +}); + +register({ + type: 'detector', category: 'Detectors', label: 'Photodetector', shortLabel: 'Detector', glyph: '◖', prefix: 'det', + description: 'Matched optical termination with optical-power and photocurrent readout.', + ports: [ + { id: 'in', x: -42, y: 0, role: 'optical', medium: 'guided', label: 'OPT' }, + { id: 'elec', x: 42, y: 0, role: 'electrical', medium: 'wire', label: 'I' } + ], + bounds: { x: -42, y: -42, width: 84, height: 84, rx: 18 }, + parameters: [ + p('responsivity', 'Responsivity', 'A/W', 0.05, 2, 0.01, 1, { tolerance: 0.02 }), + p('bandwidthGhz', 'Electrical bandwidth', 'GHz', 0.1, 200, 0.1, 20, { scope: 'pulse', tolerance: 0.05 }), + p('darkCurrentNa', 'Dark current', 'nA', 0, 10000, 1, 0, { scope: 'electrical', tolerance: 0.1 }) + ], + assumptions: ['matched optical termination', 'linear square-law readout'], + provenance: 'Analytical matched detector', + detector: true, + model() { return passiveModel(getDefinition('detector').ports); } +}); + +register({ + type: 'probe', category: 'Detectors', label: 'Optical probe', shortLabel: 'Probe', glyph: '⊙', prefix: 'probe', + description: 'Transparent two-port monitor for complex field, power, and phase.', + ports: twoPorts, bounds: { x: -40, y: -32, width: 80, height: 64, rx: 15 }, + parameters: [p('tapLossDb', 'Tap loss', 'dB', 0, 1, 0.005, 0)], + assumptions: ['ideal non-perturbing monitor at zero tap loss'], provenance: 'Analytical transparent monitor', probe: true, + model(component, env) { return reciprocalTwoPort(component, env, { lossDb: Number(component.params?.tapLossDb ?? 0) }); } +}); + +register({ + type: 'termination', category: 'Detectors', label: 'Termination', shortLabel: 'Termination', glyph: '⊣', prefix: 'term', + description: 'Matched port termination used to close unused paths.', + ports: [{ id: 'in', x: -40, y: 0, role: 'optical', label: 'IN' }], + bounds: { x: -40, y: -32, width: 80, height: 64, rx: 15 }, parameters: [], + assumptions: ['perfectly matched', 'all incident power absorbed'], provenance: 'Ideal boundary condition', termination: true, + model() { return passiveModel(getDefinition('termination').ports); } +}); + +register({ + type: 'phase', category: 'Tuning', label: 'Phase shifter', shortLabel: 'Phase', glyph: 'φ', prefix: 'phase', + description: 'Reciprocal phase shifter with physical propagation length, loss, and an explicit electrical heater/control terminal.', + ports: [...twoPorts, { id: 'heater', x: 0, y: -39, role: 'electrical', medium: 'wire', label: 'HEAT' }], bounds: { x: -48, y: -39, width: 96, height: 78, rx: 17 }, + parameters: [ + p('phaseRad', 'Controlled phase φ', 'rad', -Math.PI, Math.PI, 0.005, 0, { tolerance: 0.01 }), + p('lossDb', 'Insertion loss', 'dB', 0, 5, 0.01, 0.03, { tolerance: 0.02 }), + p('lengthUm', 'Physical length', 'µm', 0, 2000, 1, 20, { tolerance: 0.01 }), + p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42, { tolerance: 0.002 }) + ], + assumptions: ['reciprocal', 'single-mode', 'uniform effective index'], provenance: 'Analytical two-port transfer', + model(component, env) { + const q = component.params ?? {}; + return reciprocalTwoPort(component, env, { phaseRad: Number(q.phaseRad ?? 0), lossDb: Number(q.lossDb ?? 0), lengthUm: Number(q.lengthUm ?? 0), neff: Number(q.neff ?? 2.42) }); + } +}); + +register({ + type: 'modulator', category: 'Tuning', label: 'EO modulator', shortLabel: 'Modulator', glyph: 'Vπ', prefix: 'mod', + description: 'Compact phase modulator driven by V/Vπ with optical loss, physical delay, and an explicit RF terminal.', + ports: [...twoPorts, { id: 'rf', x: 0, y: -39, role: 'rf', medium: 'rf', label: 'RF' }], bounds: { x: -50, y: -39, width: 100, height: 78, rx: 17 }, + parameters: [ + p('voltageV', 'Drive voltage', 'V', -10, 10, 0.01, 0, { tolerance: 0.01 }), + p('vpiV', 'Vπ', 'V', 0.1, 20, 0.01, 4, { tolerance: 0.03 }), + p('biasRad', 'Bias phase', 'rad', -Math.PI, Math.PI, 0.005, 0), + p('lossDb', 'Insertion loss', 'dB', 0, 10, 0.01, 1), + p('lengthUm', 'Active length', 'µm', 1, 10000, 1, 1000), + p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42), + p('bandwidthGhz', 'Electrical bandwidth', 'GHz', 0.1, 200, 0.1, 40, { scope: 'electrical' }) + ], + assumptions: ['linear V/Vπ phase law', 'small-signal electrical bandwidth is metadata for temporal drive'], provenance: 'Analytical compact EO model', + model(component, env) { + const q = component.params ?? {}; + const phase = Number(q.biasRad ?? 0) + Math.PI * Number(q.voltageV ?? 0) / Math.max(1e-9, Number(q.vpiV ?? 4)); + return reciprocalTwoPort(component, env, { phaseRad: phase, lossDb: Number(q.lossDb ?? 0), lengthUm: Number(q.lengthUm ?? 0), neff: Number(q.neff ?? 2.42) }); + } +}); + +register({ + type: 'attenuator', category: 'Tuning', label: 'Attenuator', shortLabel: 'Attenuator', glyph: '−dB', prefix: 'att', + description: 'Reciprocal variable optical attenuator.', ports: twoPorts, + bounds: { x: -46, y: -34, width: 92, height: 68, rx: 16 }, + parameters: [p('lossDb', 'Attenuation', 'dB', 0, 60, 0.05, 3, { tolerance: 0.02 }), p('lengthUm', 'Physical length', 'µm', 0, 1000, 1, 10), p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42)], + assumptions: ['reciprocal', 'flat loss across the local sweep'], provenance: 'Analytical two-port transfer', + model(component, env) { const q = component.params ?? {}; return reciprocalTwoPort(component, env, { lossDb: Number(q.lossDb ?? 0), lengthUm: Number(q.lengthUm ?? 0), neff: Number(q.neff ?? 2.42) }); } +}); + +register({ + type: 'coupler', category: 'Coupling', label: 'Directional coupler', shortLabel: 'Coupler', glyph: '≈', prefix: 'dc', + description: 'Four-port reciprocal directional coupler driven by gap, interaction length, and wavelength.', + ports: fourBusPorts, bounds: { x: -56, y: -42, width: 112, height: 84, rx: 19 }, + parameters: [ + p('gapUm', 'Gap g', 'µm', 0.05, 1.2, 0.005, 0.2, { tolerance: 0.02 }), + p('interactionLengthUm', 'Interaction length Lc', 'µm', 0, 200, 0.25, 20, { tolerance: 0.01 }), + p('insertionLossDb', 'Insertion loss', 'dB', 0, 5, 0.01, 0.04, { tolerance: 0.02 }) + ], + assumptions: ['uniform weak coupling', 'reflectionless', 'single scalar mode per port'], provenance: 'Calibrated coupled-mode approximation', deepView: 'coupler', + model(component, env) { + const coeff = couplerCoefficients(component.params, env.wavelengthNm); + return pairTransferModel(fourBusPorts, C(coeff.t, 0), C(0, coeff.k)); + } +}); + +register({ + type: 'mmi', category: 'Coupling', label: '2×2 MMI', shortLabel: 'MMI', glyph: '⋈', prefix: 'mmi', + description: 'Compact reciprocal 2×2 multimode-interference coupler with specified cross ratio.', + ports: fourBusPorts, bounds: { x: -56, y: -42, width: 112, height: 84, rx: 14 }, + parameters: [p('crossPower', 'Cross power', '%', 0, 100, 0.1, 50, { tolerance: 0.01 }), p('insertionLossDb', 'Insertion loss', 'dB', 0, 5, 0.01, 0.2), p('phaseErrorRad', 'Quadrature error', 'rad', -0.5, 0.5, 0.001, 0)], + assumptions: ['compact 2×2 transfer', 'no back-reflection'], provenance: 'Analytical compact MMI model', + model(component) { + const q = component.params ?? {}; + const ratio = Math.max(0, Math.min(1, Number(q.crossPower ?? 50) / 100)); + const amp = Math.pow(10, -Math.max(0, Number(q.insertionLossDb ?? 0)) / 20); + const t = C(amp * Math.sqrt(1 - ratio), 0); + const k = scale(expi(Math.PI / 2 + Number(q.phaseErrorRad ?? 0)), amp * Math.sqrt(ratio)); + return pairTransferModel(fourBusPorts, t, k); + } +}); + +register({ + type: 'splitter', category: 'Coupling', label: 'Y splitter', shortLabel: 'Y splitter', glyph: 'Y', prefix: 'ys', + description: 'Three-port splitter/combiner with configurable power ratio.', + ports: [ + { id: 'in', x: -48, y: 0, role: 'optical', label: 'IN' }, + { id: 'top', x: 48, y: -18, role: 'optical', label: 'O₁' }, + { id: 'bottom', x: 48, y: 18, role: 'optical', label: 'O₂' } + ], + bounds: { x: -52, y: -42, width: 104, height: 84, rx: 18 }, + parameters: [p('topPower', 'Top-arm power', '%', 0, 100, 0.1, 50, { tolerance: 0.01 }), p('excessLossDb', 'Excess loss', 'dB', 0, 5, 0.01, 0.15)], + assumptions: ['ideal compact split/combination', 'simultaneous reverse inputs may dissipate the antisymmetric mode'], provenance: 'Analytical compact splitter', + model(component) { + const ports = getDefinition('splitter').ports; + const model = passiveModel(ports); + const ratio = Math.max(0, Math.min(1, Number(component.params?.topPower ?? 50) / 100)); + const amp = Math.pow(10, -Math.max(0, Number(component.params?.excessLossDb ?? 0)) / 20); + const t1 = C(amp * Math.sqrt(ratio), 0); + const t2 = C(amp * Math.sqrt(1 - ratio), 0); + model.scattering[0][1] = t1; model.scattering[1][0] = t1; + model.scattering[0][2] = t2; model.scattering[2][0] = t2; + return model; + } +}); + +register({ + type: 'ring', category: 'Resonant', label: 'Add-drop ring', shortLabel: 'Ring', glyph: '○', prefix: 'ring', + description: 'Four-port add-drop ring resonator with wavelength-dependent complex response.', + ports: fourBusPorts, bounds: { x: -58, y: -48, width: 116, height: 96, rx: 20 }, + parameters: [ + p('radiusUm', 'Ring radius', 'µm', 2, 200, 0.1, 10, { tolerance: 0.01 }), + p('couplingPower', 'Coupling per coupler', '%', 0.1, 99, 0.1, 12, { tolerance: 0.02 }), + p('roundTripLossDb', 'Round-trip loss', 'dB', 0, 20, 0.01, 0.3, { tolerance: 0.03 }), + p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42, { tolerance: 0.002 }) + ], + assumptions: ['single ring mode', 'identical lossless bus couplers apart from declared loss', 'no back-scattering doublet'], provenance: 'Analytical add-drop resonator', + model(component, env) { + const q = component.params ?? {}; + const kPower = Math.max(1e-6, Math.min(0.999999, Number(q.couplingPower ?? 12) / 100)); + const t = Math.sqrt(1 - kPower); + const a = Math.pow(10, -Math.max(0, Number(q.roundTripLossDb ?? 0)) / 20); + const circumference = 2 * Math.PI * Math.max(0.01, Number(q.radiusUm ?? 10)); + const phase = -2 * Math.PI * Number(q.neff ?? 2.42) * circumference / Math.max(1e-9, env.wavelengthNm / 1000); + const e = expi(phase); + const denominator = add(C(1, 0), scale(e, -a * t * t)); + const through = div(add(C(t, 0), scale(e, -a * t)), denominator); + const drop = div(scale(expi(phase / 2 - Math.PI / 2), Math.sqrt(a) * kPower), denominator); + const total = abs2(through) + abs2(drop); + const normalization = total > 1 ? 1 / Math.sqrt(total) : 1; + return pairTransferModel(fourBusPorts, scale(through, normalization), scale(drop, normalization)); + } +}); + +register({ + type: 'bragg', category: 'Resonant', label: 'Bragg filter', shortLabel: 'Bragg', glyph: '≋', prefix: 'bragg', + description: 'Reflective Gaussian-notch compact filter.', ports: twoPorts, + bounds: { x: -50, y: -36, width: 100, height: 72, rx: 15 }, + parameters: [p('centerNm', 'Bragg wavelength', 'nm', 1200, 1700, 0.1, 1550, { tolerance: 0.02 }), p('bandwidthNm', 'Reflection FWHM', 'nm', 0.05, 100, 0.05, 5), p('peakReflectivity', 'Peak reflectivity', '%', 0, 100, 0.1, 90), p('insertionLossDb', 'Insertion loss', 'dB', 0, 10, 0.01, 0.2)], + assumptions: ['Gaussian spectral envelope', 'lumped symmetric reflector'], provenance: 'Analytical compact spectral filter', + model(component, env) { + const q = component.params ?? {}; + const x = (env.wavelengthNm - Number(q.centerNm ?? 1550)) / Math.max(1e-9, Number(q.bandwidthNm ?? 5)); + const reflection = Math.max(0, Math.min(1, Number(q.peakReflectivity ?? 90) / 100 * Math.exp(-4 * Math.log(2) * x * x))); + const amp = Math.pow(10, -Math.max(0, Number(q.insertionLossDb ?? 0)) / 20); + const model = passiveModel(twoPorts); + const r = C(0, amp * Math.sqrt(reflection)); + const t = C(amp * Math.sqrt(1 - reflection), 0); + model.scattering[0][0] = r; model.scattering[1][1] = r; + model.scattering[0][1] = t; model.scattering[1][0] = t; + return model; + } +}); + +register({ + type: 'crossing', category: 'Routing', label: 'Waveguide crossing', shortLabel: 'Crossing', glyph: '×', prefix: 'x', + description: 'Four-port crossing with insertion loss and declared crosstalk.', + ports: [ + { id: 'left', x: -46, y: 0, role: 'optical', label: 'L' }, + { id: 'right', x: 46, y: 0, role: 'optical', label: 'R' }, + { id: 'top', x: 0, y: -46, role: 'optical', label: 'T' }, + { id: 'bottom', x: 0, y: 46, role: 'optical', label: 'B' } + ], + bounds: { x: -48, y: -48, width: 96, height: 96, rx: 18 }, + parameters: [p('insertionLossDb', 'Insertion loss', 'dB', 0, 10, 0.01, 0.1), p('crosstalkDb', 'Crosstalk', 'dB', -80, -3, 0.1, -35)], + assumptions: ['symmetric crossing', 'equal crosstalk into the two orthogonal directions'], provenance: 'Analytical compact crossing', + model(component) { + const ports = getDefinition('crossing').ports; + const model = passiveModel(ports); + const amp = Math.pow(10, -Math.max(0, Number(component.params?.insertionLossDb ?? 0)) / 20); + const xPower = Math.max(0, Math.min(0.5, Math.pow(10, Number(component.params?.crosstalkDb ?? -35) / 10))); + const main = C(amp * Math.sqrt(Math.max(0, 1 - 2 * xPower)), 0); + const cross = C(0, amp * Math.sqrt(xPower)); + model.scattering[0][1] = main; model.scattering[1][0] = main; + model.scattering[2][3] = main; model.scattering[3][2] = main; + for (const [i, j] of [[0,2],[0,3],[1,2],[1,3]]) { model.scattering[i][j] = cross; model.scattering[j][i] = cross; } + return model; + } +}); + +register({ + type: 'grating', category: 'I/O', label: 'Grating coupler', shortLabel: 'Grating', glyph: '⌁', prefix: 'gc', + description: 'Two-port compact wavelength-selective I/O coupler.', ports: twoPorts, + bounds: { x: -48, y: -38, width: 96, height: 76, rx: 18 }, + parameters: [p('centerNm', 'Center wavelength', 'nm', 1200, 1700, 0.1, 1550), p('bandwidthNm', 'Power FWHM', 'nm', 1, 200, 0.1, 40), p('peakEfficiency', 'Peak efficiency', '%', 0, 100, 0.1, 60), p('backReflectionDb', 'Back-reflection', 'dB', -80, -3, 0.1, -30)], + assumptions: ['Gaussian coupling spectrum', 'single effective free-space/fiber channel'], provenance: 'Analytical compact grating response', + model(component, env) { + const q = component.params ?? {}; + const x = (env.wavelengthNm - Number(q.centerNm ?? 1550)) / Math.max(1e-9, Number(q.bandwidthNm ?? 40)); + const efficiency = Math.max(0, Math.min(1, Number(q.peakEfficiency ?? 60) / 100 * Math.exp(-4 * Math.log(2) * x * x))); + const reflection = Math.max(0, Math.min(1 - efficiency, Math.pow(10, Number(q.backReflectionDb ?? -30) / 10))); + const model = passiveModel(twoPorts); + const t = C(Math.sqrt(efficiency), 0); + const r = C(0, Math.sqrt(reflection)); + model.scattering[0][1] = t; model.scattering[1][0] = t; + model.scattering[0][0] = r; model.scattering[1][1] = r; + return model; + } +}); + + +register({ + type: 'edge-coupler', category: 'I/O & Bridges', label: 'Edge coupler', shortLabel: 'Edge coupler', glyph: '▷', prefix: 'ec', + description: 'Reciprocal chip-edge or butt-coupling interface between a guided PIC mode and a fiber-side optical mode.', + ports: [ + { id: 'external', x: -52, y: 0, role: 'optical', medium: 'fiber', label: 'FIBER' }, + { id: 'pic', x: 52, y: 0, role: 'optical', medium: 'guided', label: 'PIC' } + ], + bounds: { x: -52, y: -38, width: 104, height: 76, rx: 16 }, + parameters: [ + p('peakEfficiency', 'Coupling efficiency', '%', 0, 100, 0.1, 72, { tolerance: 0.02 }), + p('backReflectionDb', 'Back-reflection', 'dB', -80, -3, 0.1, -35), + p('alignmentLossDb', 'Alignment loss', 'dB', 0, 20, 0.01, 0.3) + ], + fields: [ + f('facetKind', 'Interface', 'select', 'fiber-array', { options: [['fiber-array', 'Fiber array / lensed fiber'], ['free-space', 'Free-space objective'], ['butt-coupled', 'Butt-coupled facet']] }) + ], + assumptions: ['single effective fiber/free-space channel', 'lumped alignment loss', 'no transverse overlap solve'], + provenance: 'Analytical reciprocal I/O boundary', + model(component) { + const ports = getDefinition('edge-coupler').ports; + const model = passiveModel(ports); + const q = component.params ?? {}; + const efficiency = Math.max(0, Math.min(1, Number(q.peakEfficiency ?? 72) / 100 * Math.pow(10, -Math.max(0, Number(q.alignmentLossDb ?? 0.3)) / 10))); + const reflection = Math.max(0, Math.min(1 - efficiency, Math.pow(10, Number(q.backReflectionDb ?? -35) / 10))); + const transfer = C(Math.sqrt(efficiency), 0); + const reflect = C(0, Math.sqrt(reflection)); + model.scattering[0][1] = transfer; model.scattering[1][0] = transfer; + model.scattering[0][0] = reflect; model.scattering[1][1] = reflect; + return model; + } +}); + +register({ + type: 'optical-bridge', category: 'I/O & Bridges', label: 'OpticalSetup port', shortLabel: 'OpticalSetup', glyph: '↗', prefix: 'os', + description: 'Versioned free-space/fiber handoff boundary for opening this optical interface in OpticalSetup.', + ports: [{ id: 'pic', x: 52, y: 0, role: 'optical', medium: 'guided', label: 'PIC' }], + bounds: { x: -58, y: -44, width: 116, height: 88, rx: 20 }, + parameters: [ + p('powerMw', 'Declared input power', 'mW', 0, 1000, 0.01, 1, { tolerance: 0.02 }), + p('couplingEfficiency', 'Coupling efficiency', '', 0, 1, 0.001, 0.7, { tolerance: 0.02 }), + p('phaseRad', 'Boundary phase', 'rad', -Math.PI, Math.PI, 0.005, 0), + p('repetitionRateMHz', 'Repetition rate', 'MHz', 0.001, 100000, 1, 80, { scope: 'pulse' }), + p('pulseDurationPs', 'Pulse duration', 'ps', 0.05, 10000, 0.1, 12, { scope: 'pulse' }) + ], + defaults: { + direction: 'output', interfaceKind: 'free-space', polarization: 'TE', guidedMode: 'TE0', sourceMode: 'cw', + bridgeId: '', targetUrl: 'https://opticalsetup.com/sketch/' + }, + fields: [ + f('direction', 'Bridge direction', 'select', 'output', { options: [['input', 'OpticalSetup → PicSetup'], ['output', 'PicSetup → OpticalSetup'], ['bidirectional', 'Bidirectional boundary']] }), + f('interfaceKind', 'Physical interface', 'select', 'free-space', { options: [['free-space', 'Free-space beam'], ['fiber', 'Fiber mode'], ['edge', 'Chip edge'], ['grating', 'Vertical grating']] }), + f('polarization', 'Polarization', 'select', 'TE', { options: [['TE', 'TE'], ['TM', 'TM'], ['linear', 'Linear'], ['circular', 'Circular'], ['elliptical', 'Elliptical'], ['unpolarized', 'Unpolarized / mixed']] }), + f('guidedMode', 'Guided mode', 'text', 'TE0', { placeholder: 'TE0' }), + f('sourceMode', 'Temporal mode', 'select', 'cw', { options: [['cw', 'Continuous wave'], ['pulsed', 'Pulsed']] }), + f('bridgeId', 'Bridge ID', 'text', '', { placeholder: 'auto from component ID' }), + f('targetUrl', 'OpticalSetup URL', 'url', 'https://opticalsetup.com/sketch/') + ], + assumptions: ['scalar guided mode at the PicSetup boundary', 'coupling efficiency is explicit', 'spatial beam profile is reconstructed downstream'], + provenance: 'Setup Port Contract setup-port/1', bridge: true, sourceBoundary: true, + model(component) { + const ports = getDefinition('optical-bridge').ports; + const model = passiveModel(ports); + const q = component.params ?? {}; + if (q.direction === 'input' || q.direction === 'bidirectional') { + const power = Math.max(0, Number(q.powerMw ?? 0)) * Math.max(0, Math.min(1, Number(q.couplingEfficiency ?? 0.7))); + model.s[0] = scale(expi(Number(q.phaseRad ?? 0)), Math.sqrt(power)); + } + return model; + } +}); + +register({ + type: 'spiral', category: 'Routing', label: 'Spiral delay line', shortLabel: 'Spiral', glyph: '§', prefix: 'spiral', + description: 'Compact two-port delay line with explicit optical length, loss, and effective index.', + ports: twoPorts, bounds: { x: -54, y: -44, width: 108, height: 88, rx: 18 }, + parameters: [ + p('lengthUm', 'Optical length', 'µm', 10, 1000000, 10, 5000, { tolerance: 0.01 }), + p('lossDbPerCm', 'Propagation loss', 'dB/cm', 0, 100, 0.01, 2, { tolerance: 0.05 }), + p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42, { tolerance: 0.002 }) + ], + assumptions: ['uniform single-mode delay', 'bend loss included only in declared aggregate loss'], provenance: 'Analytical reciprocal delay', + model(component, env) { + const q = component.params ?? {}; + const lossDb = Math.max(0, Number(q.lossDbPerCm ?? 2)) * Math.max(0, Number(q.lengthUm ?? 5000)) / 10000; + return reciprocalTwoPort(component, env, { lossDb, lengthUm: Number(q.lengthUm ?? 5000), neff: Number(q.neff ?? 2.42) }); + } +}); + +const awgPorts = Object.freeze([ + { id: 'in', x: -58, y: 0, role: 'optical', medium: 'guided', label: 'IN' }, + { id: 'ch1', x: 58, y: -30, role: 'optical', medium: 'guided', label: 'λ₁' }, + { id: 'ch2', x: 58, y: -10, role: 'optical', medium: 'guided', label: 'λ₂' }, + { id: 'ch3', x: 58, y: 10, role: 'optical', medium: 'guided', label: 'λ₃' }, + { id: 'ch4', x: 58, y: 30, role: 'optical', medium: 'guided', label: 'λ₄' } +]); +register({ + type: 'awg', category: 'Filtering & Routing', label: 'Arrayed waveguide grating', shortLabel: 'AWG', glyph: '≋', prefix: 'awg', + description: 'Four-channel wavelength router with Gaussian channel envelopes and explicit insertion loss.', + ports: awgPorts, bounds: { x: -62, y: -52, width: 124, height: 104, rx: 18 }, + parameters: [ + p('centerNm', 'Center wavelength', 'nm', 400, 5000, 0.1, 1550, { tolerance: 0.01 }), + p('channelSpacingNm', 'Channel spacing', 'nm', 0.1, 100, 0.1, 8), + p('channelBandwidthNm', 'Channel FWHM', 'nm', 0.05, 100, 0.05, 4), + p('insertionLossDb', 'Insertion loss', 'dB', 0, 20, 0.01, 2) + ], + assumptions: ['four Gaussian channels', 'no channel-to-channel reflection', 'single scalar mode'], provenance: 'Analytical compact wavelength router', + model(component, env) { + const q = component.params ?? {}; + const model = passiveModel(awgPorts); + const center = Number(q.centerNm ?? 1550), spacing = Math.max(1e-9, Number(q.channelSpacingNm ?? 8)); + const bandwidth = Math.max(1e-9, Number(q.channelBandwidthNm ?? 4)); + const amp = Math.pow(10, -Math.max(0, Number(q.insertionLossDb ?? 2)) / 20); + const weights = [0, 1, 2, 3].map(index => { + const channelCenter = center + (index - 1.5) * spacing; + const x = (env.wavelengthNm - channelCenter) / bandwidth; + return Math.exp(-4 * Math.log(2) * x * x); + }); + const total = Math.max(1, weights.reduce((sum, value) => sum + value, 0)); + weights.forEach((weight, index) => { + const transfer = C(amp * Math.sqrt(weight / total), 0); + model.scattering[0][index + 1] = transfer; + model.scattering[index + 1][0] = transfer; + }); + return model; + } +}); + +register({ + type: 'heater', category: 'Tuning', label: 'Thermo-optic heater', shortLabel: 'Heater', glyph: '≈', prefix: 'heat', + description: 'Two-port thermo-optic phase element with an explicit electrical control terminal.', + ports: [...twoPorts, { id: 'drive', x: 0, y: 40, role: 'electrical', medium: 'wire', label: 'V' }], + bounds: { x: -50, y: -42, width: 100, height: 84, rx: 17 }, + parameters: [ + p('voltageV', 'Heater voltage', 'V', -20, 20, 0.01, 0, { tolerance: 0.02 }), + p('phasePerV', 'Phase efficiency', 'rad/V', -10, 10, 0.001, 0.5, { tolerance: 0.03 }), + p('biasRad', 'Static phase', 'rad', -Math.PI, Math.PI, 0.005, 0), + p('lossDb', 'Insertion loss', 'dB', 0, 10, 0.01, 0.1), + p('lengthUm', 'Heated length', 'µm', 1, 10000, 1, 100), + p('neff', 'Effective index', '', 1, 4.5, 0.001, 2.42) + ], + assumptions: ['static linear voltage-to-phase law', 'thermal dynamics are metadata only'], provenance: 'Analytical thermo-optic phase model', + model(component, env) { + const q = component.params ?? {}; + const phase = Number(q.biasRad ?? 0) + Number(q.voltageV ?? 0) * Number(q.phasePerV ?? 0.5); + return reciprocalTwoPort(component, env, { phaseRad: phase, lossDb: Number(q.lossDb ?? 0.1), lengthUm: Number(q.lengthUm ?? 100), neff: Number(q.neff ?? 2.42) }); + } +}); + +const fiberArrayPorts = Object.freeze(Array.from({ length: 4 }, (_, index) => ([ + { id: `fiber${index + 1}`, x: -62, y: -30 + index * 20, role: 'optical', medium: 'fiber', label: `F${index + 1}` }, + { id: `pic${index + 1}`, x: 62, y: -30 + index * 20, role: 'optical', medium: 'guided', label: `P${index + 1}` } +])).flat()); +register({ + type: 'fiber-array', category: 'I/O & Bridges', label: 'Fiber array', shortLabel: 'Fiber array', glyph: '▥', prefix: 'fa', + description: 'Four-channel fiber-array representation for system and characterization figures.', + ports: fiberArrayPorts, bounds: { x: -66, y: -54, width: 132, height: 108, rx: 18 }, + parameters: [p('insertionLossDb', 'Per-channel loss', 'dB', 0, 20, 0.01, 1)], + assumptions: ['independent channels', 'identical per-channel loss', 'alignment geometry is diagrammatic'], provenance: 'Analytical parallel I/O channels', + model(component) { + const model = passiveModel(fiberArrayPorts); + const transfer = C(Math.pow(10, -Math.max(0, Number(component.params?.insertionLossDb ?? 1)) / 20), 0); + for (let channel = 0; channel < 4; channel += 1) { + const a = channel * 2, b = a + 1; + model.scattering[a][b] = transfer; model.scattering[b][a] = transfer; + } + return model; + } +}); + +register({ + type: 'polarization-controller', category: 'External optics', label: 'Polarization controller', shortLabel: 'Pol. controller', glyph: '◒', prefix: 'pc', + description: 'External polarization-controller symbol; scalar PicSetup propagation treats it as a reciprocal pass-through.', + ports: [ + { id: 'in', x: -48, y: 0, role: 'optical', medium: 'fiber', label: 'IN' }, + { id: 'out', x: 48, y: 0, role: 'optical', medium: 'fiber', label: 'OUT' } + ], + bounds: { x: -50, y: -38, width: 100, height: 76, rx: 18 }, + parameters: [p('lossDb', 'Insertion loss', 'dB', 0, 10, 0.01, 0.2)], + fields: [f('state', 'Displayed state', 'select', 'linear', { options: [['linear', 'Linear'], ['circular', 'Circular'], ['elliptical', 'Elliptical']] })], + assumptions: ['polarization state is figure metadata in the scalar solver'], provenance: 'Diagram symbol with scalar insertion loss', + model(component) { + const ports = getDefinition('polarization-controller').ports; + const model = passiveModel(ports); + const transfer = C(Math.pow(10, -Math.max(0, Number(component.params?.lossDb ?? 0.2)) / 20), 0); + model.scattering[0][1] = transfer; model.scattering[1][0] = transfer; + return model; + } +}); + +register({ + type: 'optical-instrument', category: 'External optics', label: 'Optical instrument', shortLabel: 'Instrument', glyph: '▣', prefix: 'oi', + description: 'Publication symbol for a camera, spectrometer, power meter, polarimeter, or generic optical detector.', + ports: [{ id: 'in', x: -52, y: 0, role: 'optical', medium: 'free-space', label: 'OPT' }], + bounds: component => ({ x: -Math.max(92, Number(component.params?.width ?? 150)) / 2, y: -Math.max(64, Number(component.params?.height ?? 88)) / 2, width: Math.max(92, Number(component.params?.width ?? 150)), height: Math.max(64, Number(component.params?.height ?? 88)), rx: 16 }), + parameters: [p('width', 'Figure width', 'px', 92, 420, 1, 150, { scope: 'visual', sweepable: false }), p('height', 'Figure height', 'px', 64, 260, 1, 88, { scope: 'visual', sweepable: false })], + defaults: { instrumentKind: 'spectrometer', subtitle: 'measurement' }, + fields: [ + f('instrumentKind', 'Instrument', 'select', 'spectrometer', { options: [['camera', 'Camera'], ['photodetector', 'Photodetector'], ['pmt', 'PMT'], ['power-meter', 'Power meter'], ['wavefront', 'Wavefront sensor'], ['polarimeter', 'Polarimeter'], ['spectrometer', 'Spectrometer'], ['general', 'General detector']] }), + f('subtitle', 'Subtitle', 'text', 'measurement') + ], + detector: true, diagramOnly: true, assumptions: ['instrument symbol is a matched optical boundary', 'readout capabilities are figure metadata'], provenance: 'Publication-only matched boundary', + model() { return passiveModel(getDefinition('optical-instrument').ports); } +}); + +register({ + type: 'rf-source', category: 'Electrical & control', label: 'RF generator', shortLabel: 'RF generator', glyph: '∿', prefix: 'rf', + description: 'Publication-only RF signal generator with a typed RF output port.', + ports: [{ id: 'out', x: 52, y: 0, role: 'rf', medium: 'coax', label: 'RF' }], + bounds: { x: -52, y: -38, width: 104, height: 76, rx: 16 }, + parameters: [p('frequencyGhz', 'Frequency', 'GHz', 0, 500, 0.01, 10, { scope: 'electrical' }), p('amplitudeV', 'Amplitude', 'V', 0, 20, 0.01, 1, { scope: 'electrical' })], + diagramOnly: true, assumptions: ['not part of the optical network solve'], provenance: 'Typed publication symbol' +}); + +register({ + type: 'electrical-amplifier', category: 'Electrical & control', label: 'Electrical amplifier', shortLabel: 'Amplifier', glyph: '▷', prefix: 'amp', + description: 'Publication-only electrical/RF amplifier block.', + ports: [{ id: 'in', x: -52, y: 0, role: 'electrical', medium: 'wire', label: 'IN' }, { id: 'out', x: 52, y: 0, role: 'electrical', medium: 'wire', label: 'OUT' }], + bounds: { x: -52, y: -38, width: 104, height: 76, rx: 16 }, + parameters: [p('gainDb', 'Displayed gain', 'dB', -40, 80, 0.1, 20, { scope: 'electrical' }), p('bandwidthGhz', 'Bandwidth', 'GHz', 0.001, 500, 0.01, 20, { scope: 'electrical' })], + diagramOnly: true, assumptions: ['not part of the optical network solve'], provenance: 'Typed publication symbol' +}); + +register({ + type: 'oscilloscope', category: 'Electrical & control', label: 'Oscilloscope', shortLabel: 'Oscilloscope', glyph: '▱', prefix: 'scope', + description: 'Publication-only oscilloscope with two typed electrical inputs.', + ports: [{ id: 'ch1', x: -58, y: -14, role: 'electrical', medium: 'wire', label: 'CH1' }, { id: 'ch2', x: -58, y: 14, role: 'electrical', medium: 'wire', label: 'CH2' }], + bounds: { x: -60, y: -46, width: 120, height: 92, rx: 18 }, + defaults: { subtitle: 'time trace' }, fields: [f('subtitle', 'Subtitle', 'text', 'time trace')], + diagramOnly: true, assumptions: ['not part of the optical network solve'], provenance: 'Typed publication symbol' +}); + +register({ + type: 'controller', category: 'Electrical & control', label: 'Controller / computer', shortLabel: 'Controller', glyph: '⌘', prefix: 'ctrl', + description: 'Publication-only controller with electrical input and control output.', + ports: component => { + const width = Math.max(110, Number(component.params?.width ?? 150)); + const height = Math.max(70, Number(component.params?.height ?? 90)); + return [ + { id: 'sense', x: -width / 2, y: 0, role: 'electrical', medium: 'wire', label: 'SENSE' }, + { id: 'drive', x: width / 2, y: 0, role: 'electrical', medium: 'wire', label: 'DRIVE' }, + { id: 'logic', x: 0, y: -height / 2, role: 'control', medium: 'logic', label: 'CTRL' } + ]; + }, + bounds: component => ({ x: -Math.max(110, Number(component.params?.width ?? 150)) / 2, y: -Math.max(70, Number(component.params?.height ?? 90)) / 2, width: Math.max(110, Number(component.params?.width ?? 150)), height: Math.max(70, Number(component.params?.height ?? 90)), rx: 17 }), + parameters: [p('width', 'Figure width', 'px', 110, 420, 1, 150, { scope: 'visual', sweepable: false }), p('height', 'Figure height', 'px', 70, 260, 1, 90, { scope: 'visual', sweepable: false })], + defaults: { subtitle: 'calibration · feedback · DSP' }, fields: [f('subtitle', 'Subtitle', 'text', 'calibration · feedback · DSP')], + diagramOnly: true, assumptions: ['control behavior is not simulated here'], provenance: 'Typed publication symbol' +}); + +register({ + type: 'system-block', category: 'Paper figure', label: 'System block', shortLabel: 'System block', glyph: '□', prefix: 'sys', + description: 'General publication block with typed optical, electrical, RF, and control terminals.', + ports: component => { + const width = Math.max(110, Number(component.params?.width ?? 170)), height = Math.max(70, Number(component.params?.height ?? 100)); + return [ + { id: 'opt-in', x: -width / 2, y: -height * .22, role: 'optical', medium: 'fiber', label: 'OPT IN' }, + { id: 'opt-out', x: width / 2, y: -height * .22, role: 'optical', medium: 'fiber', label: 'OPT OUT' }, + { id: 'elec-in', x: -width / 2, y: height * .22, role: 'electrical', medium: 'wire', label: 'ELEC' }, + { id: 'control', x: width / 2, y: height * .22, role: 'control', medium: 'logic', label: 'CTRL' }, + { id: 'rf', x: 0, y: -height / 2, role: 'rf', medium: 'coax', label: 'RF' } + ]; + }, + bounds: component => ({ x: -Math.max(110, Number(component.params?.width ?? 170)) / 2, y: -Math.max(70, Number(component.params?.height ?? 100)) / 2, width: Math.max(110, Number(component.params?.width ?? 170)), height: Math.max(70, Number(component.params?.height ?? 100)), rx: 18 }), + parameters: [p('width', 'Figure width', 'px', 110, 500, 1, 170, { scope: 'visual', sweepable: false }), p('height', 'Figure height', 'px', 70, 320, 1, 100, { scope: 'visual', sweepable: false })], + defaults: { subtitle: 'subsystem', blockKind: 'generic' }, fields: [f('subtitle', 'Subtitle', 'text', 'subsystem'), f('blockKind', 'Block style', 'select', 'generic', { options: [['generic', 'Generic'], ['laser', 'Laser subsystem'], ['receiver', 'Receiver'], ['driver', 'Driver'], ['processor', 'Processor'], ['sample', 'Sample / device under test']] })], + diagramOnly: true, assumptions: ['figure-only block', 'optical terminals act as matched boundaries unless a compact model is substituted'], provenance: 'Publication composition element' +}); + +register({ + type: 'chip-frame', category: 'Paper figure', label: 'Chip boundary', shortLabel: 'Chip frame', glyph: '▭', prefix: 'chip', + description: 'Resizable background boundary for grouping on-chip components in a publication figure.', ports: [], + bounds: component => ({ x: -Math.max(180, Number(component.params?.width ?? 620)) / 2, y: -Math.max(120, Number(component.params?.height ?? 340)) / 2, width: Math.max(180, Number(component.params?.width ?? 620)), height: Math.max(120, Number(component.params?.height ?? 340)), rx: 24 }), + parameters: [p('width', 'Frame width', 'px', 180, 1600, 5, 620, { scope: 'visual', sweepable: false }), p('height', 'Frame height', 'px', 120, 1000, 5, 340, { scope: 'visual', sweepable: false })], + defaults: { subtitle: 'photonic integrated circuit', fillStyle: 'tint' }, + fields: [f('subtitle', 'Subtitle', 'text', 'photonic integrated circuit'), f('fillStyle', 'Fill', 'select', 'tint', { options: [['none', 'Outline only'], ['tint', 'Subtle tint'], ['solid', 'Solid panel']] })], + diagramOnly: true, layer: 'background', assumptions: ['figure-only grouping boundary'], provenance: 'Publication composition element' +}); + +register({ + type: 'image-panel', category: 'Paper figure', label: 'Micrograph / image', shortLabel: 'Image panel', glyph: '▧', prefix: 'img', + description: 'Resizable figure panel that can embed a microscope image, SEM, rendering, or other local image.', ports: [], + bounds: component => ({ x: -Math.max(100, Number(component.params?.width ?? 240)) / 2, y: -Math.max(80, Number(component.params?.height ?? 170)) / 2, width: Math.max(100, Number(component.params?.width ?? 240)), height: Math.max(80, Number(component.params?.height ?? 170)), rx: 12 }), + parameters: [p('width', 'Panel width', 'px', 100, 900, 1, 240, { scope: 'visual', sweepable: false }), p('height', 'Panel height', 'px', 80, 700, 1, 170, { scope: 'visual', sweepable: false })], + defaults: { caption: 'Microscope / SEM image', imageDataUrl: '', imageFit: 'cover' }, + fields: [f('caption', 'Caption', 'text', 'Microscope / SEM image'), f('imageFit', 'Image fit', 'select', 'cover', { options: [['cover', 'Crop to fill'], ['contain', 'Fit entire image']] })], + diagramOnly: true, imagePanel: true, assumptions: ['embedded image is illustrative data, not part of the simulation'], provenance: 'Publication composition element' +}); + +register({ + type: 'plot-panel', category: 'Paper figure', label: 'Plot panel', shortLabel: 'Plot panel', glyph: '⌁', prefix: 'plot', + description: 'Resizable stylized plot placeholder for assembling a multi-panel paper figure.', ports: [], + bounds: component => ({ x: -Math.max(120, Number(component.params?.width ?? 250)) / 2, y: -Math.max(90, Number(component.params?.height ?? 180)) / 2, width: Math.max(120, Number(component.params?.width ?? 250)), height: Math.max(90, Number(component.params?.height ?? 180)), rx: 12 }), + parameters: [p('width', 'Panel width', 'px', 120, 900, 1, 250, { scope: 'visual', sweepable: false }), p('height', 'Panel height', 'px', 90, 700, 1, 180, { scope: 'visual', sweepable: false })], + defaults: { xLabel: 'Wavelength (nm)', yLabel: 'Transmission', caption: 'Measured / simulated response', traceStyle: 'resonance' }, + fields: [f('caption', 'Caption', 'text', 'Measured / simulated response'), f('xLabel', 'X axis', 'text', 'Wavelength (nm)'), f('yLabel', 'Y axis', 'text', 'Transmission'), f('traceStyle', 'Trace', 'select', 'resonance', { options: [['resonance', 'Resonance'], ['spectrum', 'Spectrum'], ['time', 'Time trace'], ['bars', 'Bars']] })], + diagramOnly: true, assumptions: ['placeholder graphic; export real data separately from PicSetup Lab'], provenance: 'Publication composition element' +}); + +register({ + type: 'annotation', category: 'Paper figure', label: 'Text annotation', shortLabel: 'Text', glyph: 'T', prefix: 'note', + description: 'Free publication text for operating conditions, callouts, and explanatory notes.', ports: [], + bounds: component => ({ x: -Math.max(80, Number(component.params?.width ?? 220)) / 2, y: -Math.max(36, Number(component.params?.height ?? 70)) / 2, width: Math.max(80, Number(component.params?.width ?? 220)), height: Math.max(36, Number(component.params?.height ?? 70)), rx: 8 }), + parameters: [p('width', 'Text width', 'px', 80, 700, 1, 220, { scope: 'visual', sweepable: false }), p('height', 'Text height', 'px', 36, 400, 1, 70, { scope: 'visual', sweepable: false }), p('fontSize', 'Font size', 'px', 8, 48, 1, 15, { scope: 'visual', sweepable: false })], + defaults: { text: 'λ = 1550 nm\nTE polarization', align: 'left' }, fields: [f('text', 'Text', 'textarea', 'λ = 1550 nm\nTE polarization'), f('align', 'Alignment', 'select', 'left', { options: [['left', 'Left'], ['center', 'Center'], ['right', 'Right']] })], + diagramOnly: true, assumptions: ['figure-only annotation'], provenance: 'Publication composition element' +}); + +register({ + type: 'panel-label', category: 'Paper figure', label: 'Panel label', shortLabel: 'Panel label', glyph: 'a', prefix: 'panel', + description: 'Large panel letter for multi-panel figures.', ports: [], bounds: { x: -26, y: -26, width: 52, height: 52, rx: 10 }, + parameters: [p('fontSize', 'Font size', 'px', 14, 64, 1, 30, { scope: 'visual', sweepable: false })], + defaults: { text: 'a' }, fields: [f('text', 'Panel letter', 'text', 'a')], + diagramOnly: true, assumptions: ['figure-only annotation'], provenance: 'Publication composition element' +}); + +register({ + type: 'generic', category: 'Custom', label: 'S-parameter block', shortLabel: 'S block', glyph: 'S', prefix: 'sblock', + description: 'User-supplied wavelength-indexed complex scattering matrix.', + ports: component => { + const custom = Array.isArray(component.params?.ports) ? component.params.ports : null; + if (custom?.length) return custom.map((port, index) => ({ id: String(port.id ?? `p${index + 1}`), label: String(port.label ?? `P${index + 1}`), role: 'optical', x: Number(port.x ?? (index % 2 ? 48 : -48)), y: Number(port.y ?? (Math.floor(index / 2) * 24 - 12)) })); + return twoPorts; + }, + bounds: { x: -52, y: -42, width: 104, height: 84, rx: 15 }, + parameters: [], defaults: { modelName: 'Imported S matrix', provenance: 'User supplied', ports: twoPorts }, + assumptions: ['behavior is exactly the supplied interpolated table within its declared range'], provenance: 'User-supplied data', custom: true, + model(component, env) { + const ports = getPorts(component); + return { ports, scattering: interpolateComplexS(component.params, env.wavelengthNm, ports.length), s: zeros(ports.length) }; + } +}); + +register({ + type: 'block', category: 'Custom', label: 'Hierarchical block', shortLabel: 'Block', glyph: '▣', prefix: 'block', + description: 'Reusable subcircuit reduced to an external scattering model at solve time.', + ports: component => Array.isArray(component.params?.ports) ? component.params.ports : twoPorts, + bounds: { x: -62, y: -48, width: 124, height: 96, rx: 20 }, parameters: [], + assumptions: ['external response is extracted from the stored passive subcircuit'], provenance: 'Live hierarchical reduction', hierarchy: true, + model(component, env) { + if (typeof env.extractBlockModel === 'function') return env.extractBlockModel(component, env.wavelengthNm); + const ports = getPorts(component); + return { ports, scattering: interpolateComplexS(component.params, env.wavelengthNm, ports.length), s: zeros(ports.length) }; + } +}); + +export function getDefinition(type) { + return registry.get(type) ?? registry.get('generic'); +} + +export function listDefinitions({ includeCustom = true } = {}) { + return [...registry.values()].filter(definition => includeCustom || !definition.custom); +} + +export function getPorts(component) { + const definition = getDefinition(component?.type); + const ports = typeof definition.ports === 'function' ? definition.ports(component) : definition.ports; + return (ports ?? []).map(port => ({ ...port })); +} + +export function defaultParams(type) { + const definition = getDefinition(type); + const out = { ...(definition.defaults ?? {}) }; + for (const parameter of definition.parameters ?? []) out[parameter.key] = parameter.default; + return out; +} + +function alignModelPorts(model, declaredPorts) { + const suppliedPorts = Array.isArray(model?.ports) ? model.ports : []; + const suppliedIndex = new Map(suppliedPorts.map((port, index) => [port.id, index])); + const aligned = passiveModel(declaredPorts); + for (let row = 0; row < declaredPorts.length; row += 1) { + const sourceRow = suppliedIndex.get(declaredPorts[row].id); + if (sourceRow === undefined) continue; + aligned.s[row] = model.s?.[sourceRow] ?? C(); + for (let col = 0; col < declaredPorts.length; col += 1) { + const sourceCol = suppliedIndex.get(declaredPorts[col].id); + if (sourceCol === undefined) continue; + aligned.scattering[row][col] = model.scattering?.[sourceRow]?.[sourceCol] ?? C(); + } + } + return aligned; +} + +export function buildModel(component, environment) { + const definition = getDefinition(component.type); + const merged = { ...component, params: { ...defaultParams(component.type), ...(component.params ?? {}) } }; + const declaredPorts = getPorts(merged); + const raw = definition.model?.(merged, environment) ?? passiveModel(declaredPorts); + const model = alignModelPorts(raw, declaredPorts); + model.params = merged.params; + model.definition = definition; + return model; +} + +export function parameterDefinition(type, key) { + return getDefinition(type).parameters?.find(parameter => parameter.key === key) ?? null; +} + +export function formatParameterValue(parameter, value) { + const number = Number(value); + if (!Number.isFinite(number)) return '—'; + const step = Number(parameter?.step ?? 0.001); + const digits = step >= 1 ? 0 : step >= 0.1 ? 1 : step >= 0.01 ? 2 : 3; + if (parameter?.unit === '%') return `${number.toFixed(digits)}%`; + return `${number.toFixed(digits)}${parameter?.unit ? ` ${parameter.unit}` : ''}`; +} + +export const COMPONENT_PORTS = new Proxy({}, { + get(_target, type) { + const definition = registry.get(type); + return definition && typeof definition.ports !== 'function' ? definition.ports : []; + }, + ownKeys() { return [...registry.keys()]; }, + getOwnPropertyDescriptor() { return { enumerable: true, configurable: true }; } +}); diff --git a/app/src/physics.js b/app/src/physics.js new file mode 100644 index 0000000..84b43eb --- /dev/null +++ b/app/src/physics.js @@ -0,0 +1,736 @@ +import { + C, add, sub, mul, scale, abs2, arg, expi, + zeroMatrix, zeros, identityMatrix, matrixMultiply, + matrixVectorMultiply, solveLinearSystem +} from './complex.js'; +import { curveLength, curveMinimumRadius, polylineLength } from './geometry.js'; +import { + COMPONENT_PORTS, DEFAULT_WAVELENGTH_NM, buildModel, couplerCoefficients, + couplingCoefficient, defaultParams, getDefinition, getPorts, listDefinitions +} from './models.js'; + +export { COMPONENT_PORTS, DEFAULT_WAVELENGTH_NM, couplerCoefficients, couplingCoefficient, defaultParams, getDefinition, listDefinitions }; + +export const DEFAULT_WORLD_TO_UM = 0.2; +export const WORLD_TO_UM = DEFAULT_WORLD_TO_UM; + +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const clone = value => structuredClone(value); +const endpointKey = endpoint => `${endpoint.component}:${endpoint.port}`; + +function endpointPort(endpoint, components) { + const component = components.find(item => item.id === endpoint?.component); + if (!component) return null; + return getPorts(component).find(port => port.id === endpoint?.port) ?? null; +} + +export function connectionDomain(connection, components = []) { + const declared = String(connection?.domain ?? '').toLowerCase(); + if (['optical', 'electrical', 'rf', 'control', 'annotation'].includes(declared)) return declared; + return endpointPort(connection?.a, components)?.role ?? endpointPort(connection?.b, components)?.role ?? 'optical'; +} + +export function isOpticalConnection(connection, components = []) { + return connectionDomain(connection, components) === 'optical'; +} + +export function componentPorts(component) { + const ports = getPorts(component); + const angle = (component.rotation ?? 0) * Math.PI / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return ports.map(port => ({ + ...port, + x: component.x + port.x * cos - port.y * sin, + y: component.y + port.x * sin + port.y * cos + })); +} + +export function portPosition(component, portId) { + return componentPorts(component).find(port => port.id === portId) ?? null; +} + +export function getConnectionPoints(connection, components) { + const aComponent = components.find(item => item.id === connection.a.component); + const bComponent = components.find(item => item.id === connection.b.component); + if (!aComponent || !bComponent) return []; + const start = portPosition(aComponent, connection.a.port); + const end = portPosition(bComponent, connection.b.port); + if (!start || !end) return []; + const middle = Array.isArray(connection.waypoints) ? connection.waypoints : []; + if (middle.length) return [{ x: start.x, y: start.y }, ...middle.map(point => ({ x: point.x, y: point.y })), { x: end.x, y: end.y }]; + + const dx = end.x - start.x; + const bend = Math.max(36, Math.min(150, Math.abs(dx) * 0.34)); + const direction = Math.sign(dx || 1); + return [ + { x: start.x, y: start.y }, + { x: start.x + direction * bend, y: start.y }, + { x: end.x - direction * bend, y: end.y }, + { x: end.x, y: end.y } + ]; +} + +export { polylineLength }; + +export function connectionLengthUm(connection, components, settings = {}) { + const routingMode = connection.params?.routingMode ?? settings.routingMode ?? 'physical'; + if (routingMode === 'schematic') { + const explicit = Number(connection.params?.schematicLengthUm ?? connection.params?.lengthOverrideUm); + if (Number.isFinite(explicit) && explicit >= 0) return explicit; + } + const worldToUm = Math.max(1e-6, Number(connection.params?.worldToUm ?? settings.worldToUm ?? DEFAULT_WORLD_TO_UM)); + return curveLength(getConnectionPoints(connection, components)) * worldToUm; +} + +export function waveguideTransmission(connection, components, wavelengthNm = DEFAULT_WAVELENGTH_NM, settings = {}) { + const params = connection.params ?? {}; + const routingMode = params.routingMode ?? settings.routingMode ?? 'physical'; + const lengthUm = connectionLengthUm(connection, components, settings); + const neff = Number(params.neff ?? 2.42); + const lossDbPerCm = Math.max(0, Number(params.lossDbPerCm ?? 2)); + const wavelengthUm = Math.max(1e-9, wavelengthNm / 1000); + const phase = -2 * Math.PI * neff * lengthUm / wavelengthUm; + const amplitude = Math.pow(10, -(lossDbPerCm * (lengthUm / 10000)) / 20); + const worldToUm = Math.max(1e-6, Number(params.worldToUm ?? settings.worldToUm ?? DEFAULT_WORLD_TO_UM)); + const bend = routingMode === 'physical' ? curveMinimumRadius(getConnectionPoints(connection, components)) : null; + const minimumBendRadiusUm = bend && Number.isFinite(bend.radius) ? bend.radius * worldToUm : Number.POSITIVE_INFINITY; + const requiredBendRadiusUm = Math.max(0, Number(params.minBendRadiusUm ?? settings.minBendRadiusUm ?? 0)); + return { + value: scale(expi(phase), amplitude), lengthUm, neff, lossDbPerCm, + phase, amplitude, powerTransmission: amplitude * amplitude, + routingMode, minimumBendRadiusUm, requiredBendRadiusUm, + bendViolation: routingMode === 'physical' && Number.isFinite(minimumBendRadiusUm) && minimumBendRadiusUm + 1e-9 < requiredBendRadiusUm, + bendPoint: bend?.point ?? null + }; +} + +function buildEntries(components, wavelengthNm, extractBlockModel) { + const entries = []; + const indexByKey = new Map(); + const modelByComponent = new Map(); + for (const component of components) { + const model = buildModel(component, { wavelengthNm, extractBlockModel }); + modelByComponent.set(component.id, model); + model.ports.forEach((port, localIndex) => { + if ((port.role ?? 'optical') !== 'optical') return; + const index = entries.length; + const key = `${component.id}:${port.id}`; + entries.push({ index, key, component, port, localIndex }); + indexByKey.set(key, index); + }); + } + return { entries, indexByKey, modelByComponent }; +} + +function assembleNetwork(components, connections, settings, wavelengthNm, warnings, blockStack = []) { + const extractBlockModel = (component, lambda) => extractBlockScattering(component, lambda, warnings, blockStack); + const { entries, indexByKey, modelByComponent } = buildEntries(components, wavelengthNm, extractBlockModel); + const n = entries.length; + const S = zeroMatrix(n); + const sourceVector = zeros(n); + + for (const component of components) { + const model = modelByComponent.get(component.id); + const componentEntries = entries.filter(entry => entry.component.id === component.id); + for (const rowEntry of componentEntries) { + sourceVector[rowEntry.index] = model.s[rowEntry.localIndex] ?? C(); + for (const colEntry of componentEntries) { + S[rowEntry.index][colEntry.index] = model.scattering[rowEntry.localIndex]?.[colEntry.localIndex] ?? C(); + } + } + } + + const Cmatrix = zeroMatrix(n); + const connectionData = new Map(); + const occupied = new Set(); + for (const connection of connections) { + if (!isOpticalConnection(connection, components)) continue; + const ai = indexByKey.get(endpointKey(connection.a)); + const bi = indexByKey.get(endpointKey(connection.b)); + if (ai === undefined || bi === undefined) { + warnings.push(`Optical connection ${connection.id} has a missing or non-optical endpoint.`); + continue; + } + if (occupied.has(ai) || occupied.has(bi)) { + warnings.push(`An optical port is connected more than once near ${connection.id}.`); + continue; + } + occupied.add(ai); + occupied.add(bi); + const transmission = waveguideTransmission(connection, components, wavelengthNm, settings); + if (transmission.bendViolation) warnings.push(`Waveguide ${connection.id} reaches ${transmission.minimumBendRadiusUm.toFixed(2)} µm bend radius, below the declared ${transmission.requiredBendRadiusUm.toFixed(2)} µm minimum. The compact model does not add bend loss automatically.`); + Cmatrix[bi][ai] = transmission.value; + Cmatrix[ai][bi] = transmission.value; + connectionData.set(connection.id, { ai, bi, transmission }); + } + + const SC = matrixMultiply(S, Cmatrix); + const A = identityMatrix(n); + for (let row = 0; row < n; row += 1) { + for (let col = 0; col < n; col += 1) A[row][col] = sub(A[row][col], SC[row][col]); + } + return { entries, indexByKey, modelByComponent, S, sourceVector, Cmatrix, connectionData, occupied, A }; +} + +function extractBlockScattering(component, wavelengthNm, warnings = [], stack = []) { + const ports = getPorts(component); + const subcircuit = component.params?.subcircuit; + if (!subcircuit?.components || !Array.isArray(subcircuit.connections)) { + return { ports, scattering: zeroMatrix(ports.length), s: zeros(ports.length) }; + } + if (stack.includes(component.id)) { + warnings.push(`Hierarchy loop detected in ${component.name ?? component.id}.`); + return { ports, scattering: zeroMatrix(ports.length), s: zeros(ports.length) }; + } + if (subcircuit.components.some(item => item.type === 'source')) { + warnings.push(`${component.name ?? component.id} contains a source; active hierarchical extraction is not supported.`); + return { ports, scattering: zeroMatrix(ports.length), s: zeros(ports.length) }; + } + + const localWarnings = []; + const network = assembleNetwork( + subcircuit.components, + subcircuit.connections, + subcircuit.settings ?? {}, + wavelengthNm, + localWarnings, + [...stack, component.id] + ); + warnings.push(...localWarnings.map(message => `${component.name ?? component.id}: ${message}`)); + const scattering = zeroMatrix(ports.length); + const boundaryIndices = ports.map(port => network.indexByKey.get(endpointKey(port.internalEndpoint ?? {}))); + if (boundaryIndices.some(index => index === undefined)) { + warnings.push(`${component.name ?? component.id} has an invalid hierarchical boundary port.`); + return { ports, scattering, s: zeros(ports.length) }; + } + + for (let input = 0; input < ports.length; input += 1) { + const externalIncoming = zeros(network.entries.length); + externalIncoming[boundaryIndices[input]] = C(1, 0); + const rhs = matrixVectorMultiply(network.S, externalIncoming); + const b = solveLinearSystem(network.A, rhs); + if (!b) { + warnings.push(`${component.name ?? component.id} could not be reduced at ${wavelengthNm.toFixed(2)} nm.`); + continue; + } + for (let output = 0; output < ports.length; output += 1) scattering[output][input] = b[boundaryIndices[output]]; + } + return { ports, scattering, s: zeros(ports.length) }; +} + +function componentPowerSummary(component, result) { + const incomingMw = Object.values(result.ports).reduce((sum, port) => sum + port.incomingPowerMw, 0); + const outgoingMw = Object.values(result.ports).reduce((sum, port) => sum + port.outgoingPowerMw, 0); + const generatedMw = result.model.s.reduce((sum, value) => sum + abs2(value), 0); + return { incomingMw, outgoingMw, generatedMw, netDissipationMw: incomingMw + generatedMw - outgoingMw }; +} + +export function solveCircuit(circuit, options = {}) { + const started = performance.now(); + const components = circuit.components ?? []; + const connections = circuit.connections ?? []; + const opticalConnections = connections.filter(connection => isOpticalConnection(connection, components)); + const sources = components.filter(component => { + const definition = getDefinition(component.type); + return component.type === 'source' || (definition.sourceBoundary && ['input', 'bidirectional'].includes(component.params?.direction)); + }); + const wavelengthNm = Number(options.wavelengthNm ?? circuit.settings?.wavelengthNm ?? sources[0]?.params?.wavelengthNm ?? DEFAULT_WAVELENGTH_NM); + const warnings = []; + const network = assembleNetwork(components, opticalConnections, circuit.settings ?? {}, wavelengthNm, warnings); + const b = solveLinearSystem(network.A, network.sourceVector); + + if (!b) { + return { + ok: false, + error: 'The circuit equations are singular. Check ideal feedback loops, invalid imported S matrices, or incompatible connections.', + warnings, wavelengthNm, elapsedMs: performance.now() - started, + portEntries: network.entries, portIndex: network.indexByKey, + a: zeros(network.entries.length), b: zeros(network.entries.length), + components: new Map(), connections: new Map(), + sourcePowerMw: 0, detectedPowerMw: 0, residual: Number.POSITIVE_INFINITY, + powerBudget: null + }; + } + + const a = matrixVectorMultiply(network.Cmatrix, b); + const componentResults = new Map(); + for (const component of components) { + const model = network.modelByComponent.get(component.id); + const definition = getDefinition(component.type); + const ports = {}; + model.ports.forEach(port => { + const index = network.indexByKey.get(`${component.id}:${port.id}`); + const incoming = index === undefined ? C() : (a[index] ?? C()); + const outgoing = index === undefined ? C() : (b[index] ?? C()); + ports[port.id] = { + domain: port.role ?? 'optical', medium: port.medium ?? null, + incoming, outgoing, + incomingPowerMw: abs2(incoming), outgoingPowerMw: abs2(outgoing), + incomingPhaseRad: arg(incoming), outgoingPhaseRad: arg(outgoing) + }; + }); + const opticalPorts = Object.values(ports).filter(port => port.domain === 'optical'); + let measurementMw = 0; + if (definition.detector || definition.termination || definition.bridge) measurementMw = opticalPorts.reduce((sum, port) => sum + port.incomingPowerMw, 0); + else if (definition.probe) measurementMw = Math.max(0, ...opticalPorts.map(port => port.incomingPowerMw)); + const result = { component, ports, measurementMw, opticalPowerMw: measurementMw, model, definition }; + result.power = componentPowerSummary(component, result); + if (definition.detector) { + const responsivity = Number(component.params?.responsivity ?? 1); + const darkCurrentMa = Number(component.params?.darkCurrentNa ?? 0) * 1e-6; + result.photocurrentMa = measurementMw * responsivity + darkCurrentMa; + } + componentResults.set(component.id, result); + } + + const solvedConnections = new Map(); + let waveguideLossMw = 0; + for (const connection of opticalConnections) { + const data = network.connectionData.get(connection.id); + if (!data) continue; + const waveA = b[data.ai]; + const waveB = b[data.bi]; + const deliveredB = mul(data.transmission.value, waveA); + const deliveredA = mul(data.transmission.value, waveB); + const powerFromAMw = abs2(waveA); + const powerFromBMw = abs2(waveB); + const deliveredToBMw = abs2(deliveredB); + const deliveredToAMw = abs2(deliveredA); + const lossMw = Math.max(0, powerFromAMw - deliveredToBMw) + Math.max(0, powerFromBMw - deliveredToAMw); + waveguideLossMw += lossMw; + solvedConnections.set(connection.id, { + ...data.transmission, waveFromA: waveA, waveFromB: waveB, + deliveredToB: deliveredB, deliveredToA: deliveredA, + powerFromAMw, powerFromBMw, deliveredToBMw, deliveredToAMw, lossMw, + maxPowerMw: Math.max(powerFromAMw, powerFromBMw, deliveredToAMw, deliveredToBMw) + }); + } + + const sourcePowerMw = components.reduce((sum, component) => sum + (componentResults.get(component.id)?.power.generatedMw ?? 0), 0); + const detectedPowerMw = components.filter(component => getDefinition(component.type).detector).reduce((sum, component) => sum + (componentResults.get(component.id)?.measurementMw ?? 0), 0); + + const calculated = matrixVectorMultiply(network.A, b); + let residual = 0; + let scaleNorm = 0; + for (let i = 0; i < b.length; i += 1) { + residual += abs2(sub(calculated[i], network.sourceVector[i])); + scaleNorm += abs2(network.sourceVector[i]); + } + const normalizedResidual = Math.sqrt(residual / Math.max(scaleNorm, 1e-20)); + + let detectorAbsorptionMw = 0; + let terminationAbsorptionMw = 0; + let componentLossMw = 0; + let sourceAbsorptionMw = 0; + let modelGainMw = 0; + for (const component of components) { + const result = componentResults.get(component.id); + const definition = getDefinition(component.type); + const isSourceBoundary = component.type === 'source' || (definition.sourceBoundary && ['input', 'bidirectional'].includes(component.params?.direction)); + if (isSourceBoundary) { + sourceAbsorptionMw += result.power.incomingMw; + } else if (definition.detector) { + detectorAbsorptionMw += result.power.incomingMw; + } else if (definition.termination || definition.bridge) { + terminationAbsorptionMw += result.power.incomingMw; + } else if (result.power.netDissipationMw >= -1e-9) { + componentLossMw += Math.max(0, result.power.netDissipationMw); + } else { + modelGainMw += -result.power.netDissipationMw; + } + } + + let openPortPowerMw = 0; + for (const entry of network.entries) { + if (!network.occupied.has(entry.index)) openPortPowerMw += abs2(b[entry.index]); + } + const accountedMw = detectorAbsorptionMw + terminationAbsorptionMw + componentLossMw + waveguideLossMw + sourceAbsorptionMw + openPortPowerMw - modelGainMw; + const balanceErrorMw = sourcePowerMw - accountedMw; + const balanceErrorFraction = Math.abs(balanceErrorMw) / Math.max(sourcePowerMw, 1e-15); + + if (modelGainMw > Math.max(1e-9, sourcePowerMw * 1e-5)) warnings.push('At least one compact model is non-passive for the present coherent multi-port excitation. Inspect imported or approximate S matrices.'); + if (sources.length > 1) { + const differentWavelength = sources.some(source => Math.abs(Number(source.params?.wavelengthNm ?? wavelengthNm) - wavelengthNm) > 1e-6); + if (differentWavelength) warnings.push('Multiple source wavelengths are present; this solve uses the global wavelength and treats all sources as mutually coherent.'); + } + + return { + ok: true, warnings, wavelengthNm, elapsedMs: performance.now() - started, + residual: normalizedResidual, sourcePowerMw, detectedPowerMw, + portEntries: network.entries, portIndex: network.indexByKey, + a, b, components: componentResults, connections: solvedConnections, + powerBudget: { + launchedMw: sourcePowerMw, + detectedMw: detectorAbsorptionMw, + terminationMw: terminationAbsorptionMw, + componentLossMw, + waveguideLossMw, + sourceAbsorptionMw, + openPortMw: openPortPowerMw, + modelGainMw, + accountedMw, + balanceErrorMw, + balanceErrorFraction + } + }; +} + +export function localCouplerState(coupler, solveResult, zFraction = 1, side = 'auto') { + const result = solveResult?.components?.get(coupler.id); + const wavelengthNm = solveResult?.wavelengthNm ?? DEFAULT_WAVELENGTH_NM; + const coeff = couplerCoefficients(coupler.params, wavelengthNm); + const leftPower = (result?.ports.lt?.incomingPowerMw ?? 0) + (result?.ports.lb?.incomingPowerMw ?? 0); + const rightPower = (result?.ports.rt?.incomingPowerMw ?? 0) + (result?.ports.rb?.incomingPowerMw ?? 0); + const activeSide = side === 'auto' ? (rightPower > leftPower ? 'right' : 'left') : side; + const in1 = activeSide === 'left' ? (result?.ports.lt?.incoming ?? C()) : (result?.ports.rt?.incoming ?? C()); + const in2 = activeSide === 'left' ? (result?.ports.lb?.incoming ?? C()) : (result?.ports.rb?.incoming ?? C()); + const fraction = clamp(zFraction, 0, 1); + const z = coeff.interactionLengthUm * fraction; + const angle = coeff.kappa * z; + const c = Math.cos(angle); + const s = Math.sin(angle); + const attenuation = Math.pow(coeff.amplitudeTransmission, fraction); + const a1 = scale(add(scale(in1, c), mul(C(0, -s), in2)), attenuation); + const a2 = scale(add(mul(C(0, -s), in1), scale(in2, c)), attenuation); + return { activeSide, in1, in2, a1, a2, p1: abs2(a1), p2: abs2(a2), coeff, z, phase1: arg(a1), phase2: arg(a2) }; +} + +function component(id, type, name, x, y, params = {}) { + return { id, type, name, x, y, rotation: 0, params: { ...defaultParams(type), ...params } }; +} +function connection(id, a, b, waypoints = [], params = {}, domain = 'optical') { + const baseParams = domain === 'optical' ? { lossDbPerCm: 2, neff: 2.42 } : {}; + return { id, domain, a, b, waypoints, params: { ...baseParams, ...params } }; +} +function baseSettings() { + return { + wavelengthNm: 1550, canvasZoom: 1, canvasPanX: 0, canvasPanY: 0, + routingMode: 'physical', worldToUm: DEFAULT_WORLD_TO_UM, gridUm: 5, + snapToGrid: false, minBendRadiusUm: 0, + figureMode: 'workbench', paperBackground: 'white', showPortsInFigure: true, + showGridInFigure: false, figurePadding: 36 + }; +} + +export function makeDemoCircuit() { + const components = [ + component('src-1', 'source', 'Laser', 105, 360, { powerMw: 1, wavelengthNm: 1550 }), + component('dc-1', 'coupler', 'DC1', 310, 360), + component('phase-1', 'phase', 'Phase shifter', 590, 190, { phaseRad: 1.15, lengthUm: 20 }), + component('dc-2', 'coupler', 'DC2', 835, 360), + component('det-1', 'detector', 'DET 1', 1015, 290), + component('det-2', 'detector', 'DET 2', 1015, 430) + ]; + const connections = [ + connection('wg-1', { component: 'src-1', port: 'out' }, { component: 'dc-1', port: 'lt' }), + connection('wg-2', { component: 'dc-1', port: 'rt' }, { component: 'phase-1', port: 'left' }, [{ x: 440, y: 344 }, { x: 440, y: 190 }]), + connection('wg-3', { component: 'phase-1', port: 'right' }, { component: 'dc-2', port: 'lt' }, [{ x: 742, y: 190 }, { x: 742, y: 344 }]), + connection('wg-4', { component: 'dc-1', port: 'rb' }, { component: 'dc-2', port: 'lb' }, [{ x: 440, y: 510 }, { x: 742, y: 510 }]), + connection('wg-5', { component: 'dc-2', port: 'rt' }, { component: 'det-1', port: 'in' }), + connection('wg-6', { component: 'dc-2', port: 'rb' }, { component: 'det-2', port: 'in' }) + ]; + return { + version: 2, + name: 'MZI — Lab-ready demo', + settings: baseSettings(), + components, connections, + lab: { + measurements: [ + { id: 'm-det-1', kind: 'component', componentId: 'det-1', metric: 'power', label: 'DET 1 power' }, + { id: 'm-det-2', kind: 'component', componentId: 'det-2', metric: 'power', label: 'DET 2 power' } + ] + } + }; +} + +export function makeRingCircuit() { + const components = [ + component('src-ring', 'source', 'Laser', 120, 330), + component('ring-1', 'ring', 'Add-drop ring', 530, 330, { radiusUm: 12, couplingPower: 15 }), + component('det-through', 'detector', 'THRU', 930, 260), + component('det-drop', 'detector', 'DROP', 930, 430), + component('term-add', 'termination', 'ADD termination', 160, 455) + ]; + return { + version: 2, name: 'Add-drop ring filter', settings: baseSettings(), components, + connections: [ + connection('wg-r1', { component: 'src-ring', port: 'out' }, { component: 'ring-1', port: 'lt' }), + connection('wg-r2', { component: 'ring-1', port: 'rt' }, { component: 'det-through', port: 'in' }), + connection('wg-r3', { component: 'ring-1', port: 'rb' }, { component: 'det-drop', port: 'in' }), + connection('wg-r4', { component: 'term-add', port: 'in' }, { component: 'ring-1', port: 'lb' }) + ], + lab: { measurements: [ + { id: 'm-through', kind: 'component', componentId: 'det-through', metric: 'power', label: 'Through' }, + { id: 'm-drop', kind: 'component', componentId: 'det-drop', metric: 'power', label: 'Drop' } + ] } + }; +} + +export function makeDelayCircuit() { + const circuit = makeDemoCircuit(); + circuit.name = 'Delay-line interferometer'; + const phase = circuit.components.find(item => item.type === 'phase'); + phase.name = 'Long delay'; + phase.params.lengthUm = 800; + phase.params.phaseRad = 0; + circuit.components.find(item => item.type === 'source').params.sourceMode = 'pulsed'; + circuit.components.find(item => item.type === 'source').params.pulseDurationPs = 8; + return circuit; +} + +export function makeFilterBankCircuit() { + const components = [component('src-fb', 'source', 'Laser', 90, 360), component('split-fb', 'splitter', 'Input split', 280, 360)]; + const connections = [connection('wg-fb0', { component: 'src-fb', port: 'out' }, { component: 'split-fb', port: 'in' })]; + for (let index = 0; index < 2; index += 1) { + const y = index ? 470 : 250; + const ringId = `ring-fb-${index + 1}`; + const detId = `det-fb-${index + 1}`; + components.push(component(ringId, 'ring', `Ring ${index + 1}`, 560, y, { radiusUm: 9 + index * 1.5, couplingPower: 18 })); + components.push(component(detId, 'detector', `CH ${index + 1}`, 940, y)); + connections.push(connection(`wg-fb-${index + 1}a`, { component: 'split-fb', port: index ? 'bottom' : 'top' }, { component: ringId, port: 'lt' })); + connections.push(connection(`wg-fb-${index + 1}b`, { component: ringId, port: 'rt' }, { component: detId, port: 'in' })); + components.push(component(`term-fb-${index + 1}`, 'termination', `Drop term ${index + 1}`, 750, y + 95)); + connections.push(connection(`wg-fb-${index + 1}c`, { component: ringId, port: 'rb' }, { component: `term-fb-${index + 1}`, port: 'in' })); + } + return { version: 2, name: 'Two-channel ring filter bank', settings: baseSettings(), components, connections, lab: { measurements: components.filter(item => item.type === 'detector').map((item, index) => ({ id: `m-fb-${index}`, kind: 'component', componentId: item.id, metric: 'power', label: item.name })) } }; +} + + +export function makePaperFigureCircuit() { + const components = [ + component('frame-paper', 'chip-frame', 'Silicon photonic chip', 650, 295, { width: 650, height: 330, subtitle: 'coherent transmitter / receiver PIC', fillStyle: 'tint' }), + component('panel-a', 'panel-label', 'Panel a', 52, 58, { text: 'a', fontSize: 34 }), + component('bridge-in', 'optical-bridge', 'From OpticalSetup', 105, 245, { direction: 'input', interfaceKind: 'fiber', powerMw: 2, couplingEfficiency: .82, polarization: 'TE', guidedMode: 'TE0', sourceMode: 'cw' }), + component('pc-paper', 'polarization-controller', 'Polarization controller', 265, 245, { state: 'linear' }), + component('edge-paper', 'edge-coupler', 'Edge coupler', 410, 245, { peakEfficiency: 78, facetKind: 'fiber-array' }), + component('split-paper', 'splitter', '1×2 splitter', 545, 245, { topPower: 50 }), + component('mod-paper', 'modulator', 'EO phase modulator', 680, 175, { voltageV: 1.2, vpiV: 4, labelPosition: 'above', labelOffsetX: -18, labelOffsetY: -2 }), + component('heater-paper', 'heater', 'Thermo-optic phase shifter', 680, 325, { voltageV: 1.1, phasePerV: .7, labelPosition: 'above', subtitleVisibility: 'hide', labelOffsetY: -3 }), + component('combine-paper', 'coupler', '2×2 combiner', 815, 245, { crossPower: 50 }), + component('det-paper', 'detector', 'Integrated photodetector', 960, 205, { responsivity: .9 }), + component('bridge-out', 'optical-bridge', 'To OpticalSetup', 960, 325, { direction: 'output', interfaceKind: 'free-space', couplingEfficiency: .65, polarization: 'TE', guidedMode: 'TE0', labelPosition: 'below' }), + component('rf-paper', 'rf-source', 'RF generator', 680, 70, { frequencyGhz: 20, amplitudeV: 1.2, subtitleVisibility: 'hide', labelOffsetX: 18 }), + component('ctrl-paper', 'controller', 'Control & calibration', 555, 520, { subtitle: 'bias control · calibration · feedback', width: 170, height: 92, subtitleVisibility: 'hide', labelPosition: 'below' }), + component('amp-paper', 'electrical-amplifier', 'TIA / amplifier', 770, 520, { gainDb: 26, bandwidthGhz: 18 }), + component('scope-paper', 'oscilloscope', 'Oscilloscope', 955, 520, { subtitle: 'detected modulation' }), + component('logic-paper', 'system-block', 'Experiment computer', 275, 520, { blockKind: 'processor', subtitle: 'automation · acquisition', width: 170, height: 95, subtitleVisibility: 'hide', labelPosition: 'above', labelOffsetX: -34 }), + component('plot-paper', 'plot-panel', 'Transmission spectrum', 305, 690, { width: 260, height: 165, caption: 'Measured transmission', xLabel: 'Wavelength (nm)', yLabel: 'Transmission (dB)', traceStyle: 'resonance' }), + component('image-paper', 'image-panel', 'Chip micrograph', 650, 690, { width: 260, height: 165, caption: 'Optical micrograph / SEM' }), + component('note-paper', 'annotation', 'Operating conditions', 925, 690, { width: 230, height: 120, fontSize: 14, text: 'λ = 1550 nm\nTE₀ polarization\nCW input: 2.0 mW\nRF drive: 20 GHz', align: 'left' }) + ]; + const connections = [ + connection('wg-paper-1', { component: 'bridge-in', port: 'pic' }, { component: 'pc-paper', port: 'in' }, [], { routingMode: 'schematic', schematicLengthUm: 800 }), + connection('wg-paper-2', { component: 'pc-paper', port: 'out' }, { component: 'edge-paper', port: 'external' }, [], { routingMode: 'schematic', schematicLengthUm: 500 }), + connection('wg-paper-3', { component: 'edge-paper', port: 'pic' }, { component: 'split-paper', port: 'in' }, [], { routingMode: 'schematic', schematicLengthUm: 300 }), + connection('wg-paper-4', { component: 'split-paper', port: 'top' }, { component: 'mod-paper', port: 'left' }, [{ x: 610, y: 227 }, { x: 610, y: 175 }], { routingMode: 'schematic', schematicLengthUm: 800 }), + connection('wg-paper-5', { component: 'mod-paper', port: 'right' }, { component: 'combine-paper', port: 'lt' }, [{ x: 750, y: 175 }, { x: 750, y: 229 }], { routingMode: 'schematic', schematicLengthUm: 800 }), + connection('wg-paper-6', { component: 'split-paper', port: 'bottom' }, { component: 'heater-paper', port: 'left' }, [{ x: 610, y: 263 }, { x: 610, y: 325 }], { routingMode: 'schematic', schematicLengthUm: 850 }), + connection('wg-paper-7', { component: 'heater-paper', port: 'right' }, { component: 'combine-paper', port: 'lb' }, [{ x: 750, y: 325 }, { x: 750, y: 261 }], { routingMode: 'schematic', schematicLengthUm: 850 }), + connection('wg-paper-8', { component: 'combine-paper', port: 'rt' }, { component: 'det-paper', port: 'in' }, [], { routingMode: 'schematic', schematicLengthUm: 450 }), + connection('wg-paper-9', { component: 'combine-paper', port: 'rb' }, { component: 'bridge-out', port: 'pic' }, [], { routingMode: 'schematic', schematicLengthUm: 500 }), + connection('rf-paper-1', { component: 'rf-paper', port: 'out' }, { component: 'mod-paper', port: 'rf' }, [{ x: 758, y: 70 }, { x: 758, y: 135 }, { x: 680, y: 135 }], { arrow: 'end', labelOffsetY: -12 }, 'rf'), + connection('wire-paper-1', { component: 'ctrl-paper', port: 'drive' }, { component: 'heater-paper', port: 'drive' }, [{ x: 655, y: 520 }, { x: 655, y: 390 }, { x: 680, y: 390 }], { arrow: 'end', labelOffsetX: -4, labelOffsetY: -12 }, 'electrical'), + connection('wire-paper-2', { component: 'det-paper', port: 'elec' }, { component: 'amp-paper', port: 'in' }, [{ x: 1020, y: 205 }, { x: 1020, y: 430 }, { x: 700, y: 430 }], { arrow: 'end', labelOffsetY: -12 }, 'electrical'), + connection('wire-paper-3', { component: 'amp-paper', port: 'out' }, { component: 'scope-paper', port: 'ch1' }, [], { arrow: 'end', labelOffsetY: -12 }, 'electrical'), + connection('ctrl-paper-1', { component: 'ctrl-paper', port: 'logic' }, { component: 'logic-paper', port: 'control' }, [{ x: 555, y: 455 }, { x: 360, y: 455 }], { arrow: 'start', labelOffsetY: -12 }, 'control') + ]; + connections.find(item => item.id === 'rf-paper-1').label = '20 GHz drive'; + connections.find(item => item.id === 'wire-paper-1').label = 'heater bias'; + connections.find(item => item.id === 'wire-paper-2').label = 'photocurrent'; + connections.find(item => item.id === 'wire-paper-3').label = 'TIA output'; + connections.find(item => item.id === 'ctrl-paper-1').label = 'automation'; + const settings = { ...baseSettings(), routingMode: 'schematic', figureMode: 'paper', showPortsInFigure: true, showGridInFigure: false, canvasZoom: .82, canvasPanX: 10, canvasPanY: -10 }; + return { + version: 2, + name: 'Hybrid PIC experiment — paper figure', + settings, + components, + connections, + lab: { measurements: [{ id: 'm-paper-det', kind: 'component', componentId: 'det-paper', metric: 'power', label: 'Integrated photodetector power' }] }, + parameterLinks: [] + }; +} + +export const CIRCUIT_TEMPLATES = Object.freeze([ + { id: 'paper-hybrid', name: 'Hybrid PIC paper figure', description: 'Chip, external optics, electrical/RF control, panels, and OpticalSetup bridges', create: makePaperFigureCircuit }, + { id: 'mzi', name: 'Mach–Zehnder interferometer', description: 'Interference, tuning, sweeps, and robustness', create: makeDemoCircuit }, + { id: 'ring', name: 'Add-drop ring filter', description: 'Resonance, FSR, linewidth, and group delay', create: makeRingCircuit }, + { id: 'delay', name: 'Pulsed delay interferometer', description: 'Temporal delay, overlap, and detector bandwidth', create: makeDelayCircuit }, + { id: 'filter-bank', name: 'Ring filter bank', description: 'Multi-channel system topology and spectra', create: makeFilterBankCircuit } +]); + +export function makeTemplate(id) { + return (CIRCUIT_TEMPLATES.find(template => template.id === id) ?? CIRCUIT_TEMPLATES[0]).create(); +} + +export function makeBlankCircuit() { + return { version: 2, name: 'Untitled experiment', settings: baseSettings(), components: [], connections: [], lab: { measurements: [] } }; +} + +export function validateConnection(circuit, first, second) { + if (!first || !second) return { ok: false, reason: 'Choose two compatible typed ports.' }; + if (first.component === second.component && first.port === second.port) return { ok: false, reason: 'A port cannot connect to itself.' }; + const aComponent = circuit.components.find(component => component.id === first.component); + const bComponent = circuit.components.find(component => component.id === second.component); + if (!aComponent || !bComponent) return { ok: false, reason: 'One endpoint no longer exists.' }; + const aPort = getPorts(aComponent).find(port => port.id === first.port); + const bPort = getPorts(bComponent).find(port => port.id === second.port); + if (!aPort || !bPort || aPort.role !== bPort.role) return { ok: false, reason: 'The port domains are incompatible.' }; + const keyA = endpointKey(first); + const keyB = endpointKey(second); + const occupied = circuit.connections.some(item => [endpointKey(item.a), endpointKey(item.b)].some(key => key === keyA || key === keyB)); + if (occupied) return { ok: false, reason: 'Each typed port can carry one connection in this model.' }; + return { ok: true }; +} + +export function nearestPort(circuit, point, maxDistance = 28, exclude = null) { + let best = null; + for (const component of circuit.components) { + for (const port of componentPorts(component)) { + if (exclude && exclude.component === component.id && exclude.port === port.id) continue; + const distance = Math.hypot(point.x - port.x, point.y - port.y); + if (distance <= maxDistance && (!best || distance < best.distance)) best = { component: component.id, port: port.id, x: port.x, y: port.y, distance }; + } + } + return best; +} + +export function createHierarchicalBlock(circuit, componentIds, { id = `block-${Math.random().toString(36).slice(2, 8)}`, name = 'Functional block' } = {}) { + const selected = new Set(componentIds); + const chosen = circuit.components.filter(component => selected.has(component.id)); + if (chosen.length < 2) throw new Error('Select at least two components to create a block.'); + if (chosen.some(component => component.type === 'source')) throw new Error('Active sources cannot be collapsed into a passive block.'); + if (chosen.some(component => getDefinition(component.type).diagramOnly)) throw new Error('Publication-only figure objects cannot be collapsed into an optical compact-model block.'); + const touchingConnections = circuit.connections.filter(item => selected.has(item.a.component) || selected.has(item.b.component)); + if (touchingConnections.some(item => !isOpticalConnection(item, circuit.components))) { + throw new Error('Disconnect electrical, RF, and control links before collapsing an optical hierarchy.'); + } + + const center = { + x: chosen.reduce((sum, item) => sum + item.x, 0) / chosen.length, + y: chosen.reduce((sum, item) => sum + item.y, 0) / chosen.length + }; + const internalConnections = circuit.connections.filter(item => selected.has(item.a.component) && selected.has(item.b.component)); + const boundaryConnections = circuit.connections.filter(item => selected.has(item.a.component) !== selected.has(item.b.component)); + const internalOccupied = new Set(internalConnections.flatMap(item => [endpointKey(item.a), endpointKey(item.b)])); + const boundaryInside = new Map(); + for (const item of boundaryConnections) { + const inside = selected.has(item.a.component) ? item.a : item.b; + boundaryInside.set(endpointKey(inside), inside); + } + + const exposed = []; + for (const chosenComponent of chosen) { + for (const port of getPorts(chosenComponent)) { + if ((port.role ?? 'optical') !== 'optical') continue; + const endpoint = { component: chosenComponent.id, port: port.id }; + const key = endpointKey(endpoint); + if (!internalOccupied.has(key)) exposed.push(endpoint); + } + } + exposed.sort((a, b) => { + const pa = portPosition(circuit.components.find(item => item.id === a.component), a.port); + const pb = portPosition(circuit.components.find(item => item.id === b.component), b.port); + return (pa?.x ?? 0) - (pb?.x ?? 0) || (pa?.y ?? 0) - (pb?.y ?? 0); + }); + + const left = []; + const right = []; + for (const endpoint of exposed) { + const sourceComponent = circuit.components.find(item => item.id === endpoint.component); + const position = portPosition(sourceComponent, endpoint.port) ?? center; + (position.x < center.x ? left : right).push({ endpoint, position }); + } + const arrange = (items, side) => items.map((item, index) => ({ + id: `p${side === 'left' ? 'l' : 'r'}${index + 1}`, + label: `${side === 'left' ? 'L' : 'R'}${index + 1}`, + role: 'optical', x: side === 'left' ? -62 : 62, + y: (index - (items.length - 1) / 2) * 22, + internalEndpoint: clone(item.endpoint) + })); + const ports = [...arrange(left, 'left'), ...arrange(right, 'right')]; + if (!ports.length) throw new Error('The selection has no external or open ports to expose.'); + + const portForEndpoint = new Map(ports.map(port => [endpointKey(port.internalEndpoint), port.id])); + const boundaryOriginals = Object.fromEntries(boundaryConnections.map(item => [item.id, clone(item)])); + const externalConnections = boundaryConnections.map(item => { + const copy = clone(item); + const preservedLengthUm = connectionLengthUm(item, circuit.components, circuit.settings ?? {}); + if (selected.has(copy.a.component)) copy.a = { component: id, port: portForEndpoint.get(endpointKey(copy.a)) }; + if (selected.has(copy.b.component)) copy.b = { component: id, port: portForEndpoint.get(endpointKey(copy.b)) }; + copy.params ??= {}; + copy.params.routingMode = 'schematic'; + copy.params.schematicLengthUm = preservedLengthUm; + copy.params.collapsedBoundary = true; + return copy; + }); + const boundaryIds = new Set(boundaryConnections.map(item => item.id)); + const internalIds = new Set(internalConnections.map(item => item.id)); + + const block = { + id, type: 'block', name, x: center.x, y: center.y, rotation: 0, + params: { + ...defaultParams('block'), ports, + origin: clone(center), + boundaryOriginals, + subcircuit: { + version: 2, + name, + settings: clone(circuit.settings ?? baseSettings()), + components: clone(chosen), + connections: clone(internalConnections) + }, + provenance: 'Live hierarchical reduction' + } + }; + + circuit.components = [...circuit.components.filter(item => !selected.has(item.id)), block]; + circuit.connections = [ + ...circuit.connections.filter(item => !boundaryIds.has(item.id) && !internalIds.has(item.id)), + ...externalConnections + ]; + return block; +} + +export function expandHierarchicalBlock(circuit, blockId) { + const block = circuit.components.find(component => component.id === blockId && component.type === 'block'); + if (!block?.params?.subcircuit) throw new Error('This block has no editable internal circuit.'); + const ports = getPorts(block); + const mapping = new Map(ports.map(port => [port.id, port.internalEndpoint])); + const originals = block.params?.boundaryOriginals ?? {}; + const origin = block.params?.origin ?? { x: block.x, y: block.y }; + const dx = Number(block.x ?? 0) - Number(origin.x ?? block.x ?? 0); + const dy = Number(block.y ?? 0) - Number(origin.y ?? block.y ?? 0); + const moved = Math.hypot(dx, dy) > 1e-8; + + circuit.connections = circuit.connections.map(connection => { + if (connection.a.component !== blockId && connection.b.component !== blockId) return connection; + const mapped = clone(connection); + if (mapped.a.component === blockId) mapped.a = clone(mapping.get(mapped.a.port)); + if (mapped.b.component === blockId) mapped.b = clone(mapping.get(mapped.b.port)); + const original = originals[connection.id]; + if (original && !moved) { + const restored = clone(original); + // Preserve the outside endpoint in case it was edited while the block was collapsed. + if (connection.a.component !== blockId) restored.a = clone(connection.a); + if (connection.b.component !== blockId) restored.b = clone(connection.b); + return restored; + } + // A moved block has no unique physical route back to its expanded boundary. + // Keep the explicitly preserved optical length rather than silently changing phase. + mapped.params ??= {}; + mapped.params.routingMode = 'schematic'; + mapped.params.collapsedBoundary = undefined; + return mapped; + }); + + const internalComponents = clone(block.params.subcircuit.components).map(component => ({ ...component, x: component.x + dx, y: component.y + dy })); + const internalConnections = clone(block.params.subcircuit.connections).map(connection => ({ + ...connection, + waypoints: (connection.waypoints ?? []).map(point => ({ x: point.x + dx, y: point.y + dy })) + })); + circuit.components = [...circuit.components.filter(component => component.id !== blockId), ...internalComponents]; + circuit.connections.push(...internalConnections); + return internalComponents.map(component => component.id); +} diff --git a/app/src/plot.js b/app/src/plot.js new file mode 100644 index 0000000..a3e23d3 --- /dev/null +++ b/app/src/plot.js @@ -0,0 +1,271 @@ +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +function niceNumber(value) { + const absolute = Math.abs(value); + if (!Number.isFinite(value)) return '—'; + if (absolute >= 1e4 || (absolute > 0 && absolute < 1e-3)) return value.toExponential(2); + if (absolute >= 100) return value.toFixed(1); + if (absolute >= 1) return value.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); + return value.toFixed(5).replace(/0+$/, '').replace(/\.$/, ''); +} + +const palette = ['#8eff42', '#25e8ff', '#ffcc66', '#c995ff', '#ff7f9a', '#8cb8ff']; + +export class LabPlot { + constructor(canvas, { onCursor = null } = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.onCursor = onCursor; + this.result = null; + this.baselines = []; + this.cursorIndex = null; + this.padding = { left: 56, right: 18, top: 30, bottom: 38 }; + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + canvas.addEventListener('pointermove', event => this.handlePointer(event, false)); + canvas.addEventListener('pointerdown', event => this.handlePointer(event, true)); + canvas.addEventListener('pointerleave', () => { if (!this.pinned) { this.cursorIndex = null; this.draw(); } }); + } + + resize() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = Math.min(2, window.devicePixelRatio || 1); + const width = Math.max(10, Math.round(rect.width * dpr)); + const height = Math.max(10, Math.round(rect.height * dpr)); + if (this.canvas.width !== width || this.canvas.height !== height) { + this.canvas.width = width; + this.canvas.height = height; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + this.width = rect.width; + this.height = rect.height; + this.draw(); + } + + setData(result, { baselines = [], cursorIndex = null } = {}) { + this.result = result; + this.baselines = baselines; + this.cursorIndex = cursorIndex ?? result?.currentIndex ?? null; + this.pinned = false; + this.draw(); + } + + clear() { + this.result = null; + this.baselines = []; + this.draw(); + } + + plotRect() { + return { + x: this.padding.left, + y: this.padding.top, + width: Math.max(10, (this.width ?? 0) - this.padding.left - this.padding.right), + height: Math.max(10, (this.height ?? 0) - this.padding.top - this.padding.bottom) + }; + } + + handlePointer(event, pin) { + if (!this.result || this.result.kind !== 'sweep') return; + const rect = this.canvas.getBoundingClientRect(); + const plot = this.plotRect(); + const localX = event.clientX - rect.left; + if (localX < plot.x || localX > plot.x + plot.width) return; + const fraction = clamp((localX - plot.x) / plot.width, 0, 1); + this.cursorIndex = Math.round(fraction * (this.result.x.length - 1)); + if (pin) this.pinned = true; + this.draw(); + this.onCursor?.(this.cursorIndex, pin); + } + + background() { + const ctx = this.ctx; + ctx.clearRect(0, 0, this.width, this.height); + const gradient = ctx.createLinearGradient(0, 0, 0, this.height); + gradient.addColorStop(0, 'rgba(9,22,23,.96)'); + gradient.addColorStop(1, 'rgba(3,9,10,.96)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, this.width, this.height); + } + + draw() { + if (!this.ctx || !this.width || !this.height) return; + this.background(); + if (!this.result) return this.drawEmpty(); + if (this.result.kind === 'sweep') this.drawSweep(); + else if (this.result.kind === 'tolerance') this.drawTolerance(); + else if (this.result.kind === 'pulse') this.drawPulse(); + } + + drawEmpty() { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(218,240,235,.68)'; + ctx.font = '600 12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Pin a measurement and sweep a parameter', this.width / 2, this.height / 2 - 6); + ctx.fillStyle = 'rgba(136,166,160,.65)'; + ctx.font = '10px system-ui'; + ctx.fillText('Spectra, tuning, tolerances, and pulse response appear here.', this.width / 2, this.height / 2 + 14); + } + + rangesForSeries(x, series) { + const xMin = Math.min(...x), xMax = Math.max(...x); + const values = series.flatMap(item => item.values).filter(Number.isFinite); + let yMin = Math.min(...values), yMax = Math.max(...values); + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { yMin = 0; yMax = 1; } + if (Math.abs(yMax - yMin) < 1e-15) { yMin -= .5; yMax += .5; } + const pad = (yMax - yMin) * .08; + return { xMin, xMax: xMax === xMin ? xMin + 1 : xMax, yMin: yMin - pad, yMax: yMax + pad }; + } + + axes(ranges, xLabel, yLabel) { + const ctx = this.ctx; + const plot = this.plotRect(); + const mapX = value => plot.x + (value - ranges.xMin) / (ranges.xMax - ranges.xMin) * plot.width; + const mapY = value => plot.y + plot.height - (value - ranges.yMin) / (ranges.yMax - ranges.yMin) * plot.height; + ctx.strokeStyle = 'rgba(113,165,157,.13)'; + ctx.lineWidth = 1; + ctx.font = '9px system-ui'; + ctx.fillStyle = 'rgba(164,194,188,.7)'; + ctx.textAlign = 'center'; + for (let tick = 0; tick <= 5; tick += 1) { + const fraction = tick / 5; + const x = plot.x + plot.width * fraction; + const y = plot.y + plot.height * fraction; + ctx.beginPath(); ctx.moveTo(x, plot.y); ctx.lineTo(x, plot.y + plot.height); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(plot.x, y); ctx.lineTo(plot.x + plot.width, y); ctx.stroke(); + ctx.fillText(niceNumber(ranges.xMin + (ranges.xMax - ranges.xMin) * fraction), x, plot.y + plot.height + 16); + ctx.textAlign = 'right'; + ctx.fillText(niceNumber(ranges.yMax - (ranges.yMax - ranges.yMin) * fraction), plot.x - 8, y + 3); + ctx.textAlign = 'center'; + } + ctx.strokeStyle = 'rgba(156,211,201,.32)'; + ctx.beginPath(); ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x, plot.y + plot.height); ctx.lineTo(plot.x + plot.width, plot.y + plot.height); ctx.stroke(); + ctx.fillStyle = 'rgba(198,223,218,.78)'; + ctx.fillText(xLabel, plot.x + plot.width / 2, this.height - 8); + ctx.save(); + ctx.translate(13, plot.y + plot.height / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText(yLabel, 0, 0); + ctx.restore(); + return { mapX, mapY, plot }; + } + + pathSeries(x, values, mapX, mapY, color, width = 1.8, dashed = false, alpha = 1) { + const ctx = this.ctx; + ctx.save(); + ctx.strokeStyle = color; + ctx.globalAlpha = alpha; + ctx.lineWidth = width; + ctx.setLineDash(dashed ? [5, 5] : []); + ctx.beginPath(); + let started = false; + for (let index = 0; index < x.length; index += 1) { + if (!Number.isFinite(values[index])) continue; + const px = mapX(x[index]), py = mapY(values[index]); + if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); + } + ctx.stroke(); + ctx.restore(); + } + + legend(items) { + const ctx = this.ctx; + let x = this.padding.left; + const y = 17; + ctx.font = '9px system-ui'; + for (const item of items) { + ctx.strokeStyle = item.color; + ctx.lineWidth = 2; + ctx.setLineDash(item.dashed ? [4, 3] : []); + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 13, y); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(218,237,233,.82)'; + ctx.textAlign = 'left'; + ctx.fillText(item.label, x + 18, y + 3); + x += 24 + ctx.measureText(item.label).width; + if (x > this.width - 100) break; + } + } + + drawSweep() { + const result = this.result; + const series = [...result.traces, ...this.baselines.flatMap(baseline => baseline.traces.map(trace => ({ ...trace, baseline: true, baselineName: baseline.name })))]; + const ranges = this.rangesForSeries(result.x, series); + const yUnit = result.traces[0]?.unit ?? ''; + const axes = this.axes(ranges, `${result.parameter.label}${result.parameter.unit ? ` (${result.parameter.unit})` : ''}`, yUnit || 'value'); + this.baselines.forEach((baseline, baselineIndex) => baseline.traces.forEach((trace, index) => this.pathSeries(baseline.x, trace.values, axes.mapX, axes.mapY, palette[index % palette.length], 1.1, true, .48))); + result.traces.forEach((trace, index) => this.pathSeries(result.x, trace.values, axes.mapX, axes.mapY, palette[index % palette.length], 2)); + this.legend(result.traces.map((trace, index) => ({ label: trace.label, color: palette[index % palette.length] }))); + + if (this.cursorIndex !== null && result.x[this.cursorIndex] !== undefined) { + const index = clamp(this.cursorIndex, 0, result.x.length - 1); + const x = axes.mapX(result.x[index]); + const ctx = this.ctx; + ctx.strokeStyle = 'rgba(237,255,250,.45)'; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(x, axes.plot.y); ctx.lineTo(x, axes.plot.y + axes.plot.height); ctx.stroke(); + ctx.setLineDash([]); + result.traces.forEach((trace, traceIndex) => { + const y = axes.mapY(trace.values[index]); + ctx.fillStyle = palette[traceIndex % palette.length]; + ctx.beginPath(); ctx.arc(x, y, 3.5, 0, Math.PI * 2); ctx.fill(); + }); + const lines = [`${result.parameter.label}: ${niceNumber(result.x[index])} ${result.parameter.unit ?? ''}`.trim(), ...result.traces.map(trace => `${trace.label}: ${niceNumber(trace.values[index])} ${trace.unit ?? ''}`.trim())]; + ctx.font = '9px system-ui'; + const width = Math.max(...lines.map(line => ctx.measureText(line).width)) + 16; + const height = lines.length * 14 + 10; + const boxX = clamp(x + 9, axes.plot.x + 4, axes.plot.x + axes.plot.width - width - 4); + const boxY = axes.plot.y + 5; + ctx.fillStyle = 'rgba(3,11,12,.9)'; + ctx.strokeStyle = 'rgba(75,218,199,.32)'; + ctx.beginPath(); ctx.roundRect(boxX, boxY, width, height, 8); ctx.fill(); ctx.stroke(); + lines.forEach((line, lineIndex) => { ctx.fillStyle = lineIndex ? palette[(lineIndex - 1) % palette.length] : 'rgba(226,242,238,.76)'; ctx.textAlign = 'left'; ctx.fillText(line, boxX + 8, boxY + 14 + lineIndex * 14); }); + } + } + + drawTolerance() { + const result = this.result; + const bins = result.histogram ?? []; + if (!bins.length) return this.drawEmpty(); + const x = bins.map(bin => (bin.x0 + bin.x1) / 2); + const ranges = { xMin: bins[0].x0, xMax: bins.at(-1).x1, yMin: 0, yMax: Math.max(...bins.map(bin => bin.count)) * 1.12 }; + const axes = this.axes(ranges, 'Measured output', 'samples'); + const ctx = this.ctx; + bins.forEach((bin, index) => { + const left = axes.mapX(bin.x0), right = axes.mapX(bin.x1); + const top = axes.mapY(bin.count), bottom = axes.mapY(0); + const gradient = ctx.createLinearGradient(0, top, 0, bottom); + gradient.addColorStop(0, 'rgba(142,255,66,.75)'); + gradient.addColorStop(1, 'rgba(37,232,255,.18)'); + ctx.fillStyle = gradient; + ctx.fillRect(left + 1, top, Math.max(1, right - left - 2), bottom - top); + }); + const markers = [ + { value: result.nominal, label: 'nominal', color: '#25e8ff' }, + { value: result.threshold, label: 'yield limit', color: '#ffcc66' }, + { value: result.mean, label: 'mean', color: '#8eff42' } + ]; + markers.forEach(marker => { + const px = axes.mapX(marker.value); + ctx.strokeStyle = marker.color; ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(px, axes.plot.y); ctx.lineTo(px, axes.plot.y + axes.plot.height); ctx.stroke(); + ctx.setLineDash([]); + }); + this.legend(markers.map(marker => ({ label: marker.label, color: marker.color, dashed: true }))); + } + + drawPulse() { + const result = this.result; + const traces = [ + { label: 'Input envelope', values: result.inputIntensity }, + { label: 'Optical output', values: result.outputIntensity }, + ...(result.detectorBandwidthGhz ? [{ label: 'Electrical readout', values: result.electricalIntensity }] : []) + ]; + const ranges = this.rangesForSeries(result.timePs, traces); + ranges.yMin = Math.min(0, ranges.yMin); + const axes = this.axes(ranges, 'Time (ps)', 'normalized intensity'); + traces.forEach((trace, index) => this.pathSeries(result.timePs, trace.values, axes.mapX, axes.mapY, palette[index % palette.length], 2)); + this.legend(traces.map((trace, index) => ({ label: trace.label, color: palette[index % palette.length] }))); + } +} diff --git a/app/styles.css b/app/styles.css new file mode 100644 index 0000000..b9641de --- /dev/null +++ b/app/styles.css @@ -0,0 +1,956 @@ +:root { + color-scheme: dark; + --bg: #030607; + --panel: rgba(10, 17, 19, 0.84); + --panel-strong: rgba(11, 19, 21, 0.96); + --line: rgba(144, 235, 224, 0.18); + --line-strong: rgba(85, 255, 214, 0.42); + --text: #edf8f5; + --muted: #8ba09d; + --green: #8eff42; + --green-soft: #c8ff8e; + --cyan: #25e8ff; + --teal: #43ffd0; + --danger: #ff6d7d; + --warning: #ffd66b; + --shadow: 0 28px 80px rgba(0, 0, 0, 0.42); + --radius: 24px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --transport-h: 94px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: geometricPrecision; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: var(--bg); color: var(--text); } +button, input, output { font: inherit; } +button { color: inherit; } +button, [role="button"] { -webkit-tap-highlight-color: transparent; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; } +svg { display: block; } + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 14% 8%, rgba(54, 255, 182, 0.08), transparent 28%), + radial-gradient(circle at 92% 48%, rgba(38, 221, 255, 0.07), transparent 32%), + linear-gradient(180deg, #040809 0%, #020405 100%); +} + +.glass { + background: linear-gradient(180deg, rgba(16, 27, 29, 0.88), rgba(7, 13, 15, 0.82)); + border: 1px solid var(--line); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025); + backdrop-filter: blur(18px) saturate(130%); + -webkit-backdrop-filter: blur(18px) saturate(130%); +} + +.app-shell { + position: relative; + display: grid; + grid-template-rows: calc(62px + var(--safe-top)) 1fr calc(var(--transport-h) + var(--safe-bottom)); + width: 100%; + height: 100dvh; + isolation: isolate; +} + +.topbar { + z-index: 30; + display: flex; + align-items: center; + gap: 12px; + padding: calc(8px + var(--safe-top)) clamp(12px, 2.2vw, 28px) 8px; + border-width: 0 0 1px; + border-radius: 0; +} + +.brand-button { flex: 0 0 auto; } +.brand-mark { + position: relative; + display: grid; + place-items: center; + width: 31px; + aspect-ratio: 1; + border: 1.5px solid var(--teal); + border-radius: 9px; + transform: rotate(30deg); + box-shadow: 0 0 18px rgba(67, 255, 208, 0.22); +} +.brand-mark span { + width: 18px; + height: 8px; + border-top: 2px solid white; + border-radius: 50%; + transform: rotate(-30deg) translateY(2px); +} +.brand-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.05; } +.brand-copy strong { font-size: 19px; letter-spacing: -0.03em; } +.brand-copy strong span { color: var(--green); } +.brand-copy small { margin-top: 5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; } +.topbar-actions { margin-left: auto; display: flex; gap: 4px; } + +.icon-button { + display: grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 13px; + background: transparent; + cursor: pointer; + transition: background .18s ease, transform .18s ease, opacity .18s ease; +} +.icon-button:hover { background: rgba(255,255,255,.055); } +.icon-button:active { transform: scale(.94); } +.icon-button:disabled { opacity: .28; cursor: default; } +.icon-button svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +#moreButton svg circle { fill: currentColor; stroke: none; } + +.workspace { + position: relative; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(300px, 26vw, 390px); + gap: 14px; + padding: 14px; +} +.canvas-panel { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid rgba(102, 236, 220, .12); border-radius: 28px; background: #030708; box-shadow: var(--shadow); } +.canvas-panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 4; + background: radial-gradient(circle at 54% 44%, transparent 30%, rgba(2, 6, 7, .1) 62%, rgba(1, 4, 5, .62) 100%); +} + +.model-strip { + position: absolute; + z-index: 12; + top: 14px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + max-width: calc(100% - 150px); + padding: 5px; + border-radius: 18px; +} +.model-badge { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; + padding: 6px 9px; + border: 0; + border-radius: 13px; + background: transparent; + cursor: pointer; + text-align: left; +} +.model-badge > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; } +.model-badge b { font-size: 11px; color: #dffdf7; } +.model-badge small { color: var(--muted); font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.model-badge > svg { width: 16px; height: 16px; fill: none; stroke: var(--muted); stroke-width: 1.7; } +.model-icon { position: relative; width: 25px; aspect-ratio: 1; border: 1px solid rgba(37, 232, 255, .65); border-radius: 8px; box-shadow: inset 0 0 10px rgba(37, 232, 255, .12); } +.model-icon::before, .model-icon::after { content: ""; position: absolute; left: 5px; right: 5px; top: 11px; border-top: 1px solid var(--cyan); transform: rotate(45deg); } +.model-icon::after { transform: rotate(-45deg); } +.solver-health { display: flex; align-items: center; gap: 5px; padding: 6px 8px; border-left: 1px solid var(--line); white-space: nowrap; } +.solver-health > span { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 12px var(--green); } +.solver-health b { font-size: 10px; } +.solver-health small { font-size: 9px; color: var(--muted); } +.solver-health[data-state="error"] > span { background: var(--danger); box-shadow: 0 0 12px var(--danger); } +.solver-health[data-state="warning"] > span { background: var(--warning); box-shadow: 0 0 12px var(--warning); } + +.viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; } +.circuit-svg { width: 100%; height: 100%; user-select: none; touch-action: none; transform-origin: center; } +.canvas-bg { pointer-events: all; } + +.connection-hit { fill: none; stroke: transparent; stroke-width: 22; cursor: pointer; pointer-events: stroke; } +.connection-base { fill: none; stroke: rgba(138, 183, 179, .28); stroke-width: 10; stroke-linecap: round; stroke-linejoin: round; } +.connection-core { display:none; } +.connection-power { fill: none; stroke: var(--green); stroke-width: 5.5; stroke-linecap: round; stroke-linejoin: round; filter: url(#softGlow); transition: opacity .18s ease, stroke-width .18s ease; } +.connection-flow { display:none; } +.connection-selected .connection-base { stroke: rgba(37,232,255,.55); stroke-width: 13; } +.connection-invalid .connection-base { stroke: rgba(255,109,125,.6); stroke-dasharray: 8 8; } +.connection-invalid .connection-power { opacity: 0 !important; } + +.component { cursor: grab; transform-box: fill-box; transform-origin: center; } +.component:active { cursor: grabbing; } +.component .selection-ring { opacity: 0; fill: none; stroke: var(--cyan); stroke-width: 1.5; stroke-dasharray: 5 5; filter: url(#softGlow); } +.component.selected .selection-ring { opacity: 1; } +.component-body { fill: rgba(12,22,24,.94); stroke: rgba(199,239,231,.38); stroke-width: 1.7; } +.component.selected .component-body { stroke: var(--cyan); filter: url(#softGlow); } +.component-label { fill: #e9f6f3; font-size: 13px; font-weight: 650; text-anchor: middle; pointer-events: none; } +.component-sub { fill: #8ca29e; font-size: 9.5px; text-anchor: middle; pointer-events: none; } +.component-readout { fill: var(--green); font-size: 12px; font-weight: 680; text-anchor: middle; pointer-events: none; } +.port { fill: #071214; stroke: #7c9d98; stroke-width: 1.4; cursor: crosshair; transition: r .15s ease, stroke .15s ease, fill .15s ease; } +.port:hover, .port.active { r: 7px; fill: var(--teal); stroke: white; filter: url(#softGlow); } +.port.compatible { fill: var(--green); stroke: white; filter: url(#softGlow); } +.port.occupied { fill: #405c59; } +.source-rays { stroke: var(--green); stroke-width: 2; stroke-linecap: round; filter: url(#softGlow); } +.source-core { fill: white; filter: url(#strongGlow); } +.coupler-lines { fill: none; stroke: rgba(220, 255, 248, .68); stroke-width: 2; stroke-linecap: round; } +.coupler-field { fill: none; stroke: url(#flowGradient); stroke-width: 3.5; stroke-linecap: round; filter: url(#softGlow); opacity: .9; } +.phase-symbol { fill: var(--cyan); font-size: 30px; font-weight: 350; text-anchor: middle; filter: url(#softGlow); pointer-events: none; } +.detector-arc { fill: none; stroke: #dff8f2; stroke-width: 2; } +.detector-beam { fill: none; stroke: var(--green); stroke-width: 3; filter: url(#softGlow); } + +.pulse { filter: url(#strongGlow); pointer-events: none; } +.pulse-halo { opacity: .34; } +.pulse-core { opacity: .96; } +.preview-path { fill: none; stroke: var(--cyan); stroke-width: 3; stroke-dasharray: 6 7; filter: url(#softGlow); pointer-events: none; } +.preview-invalid { stroke: var(--danger); } +.snap-line { fill: none; stroke: rgba(142,255,66,.65); stroke-width: 1; stroke-dasharray: 4 6; } + +.tool-rail { + position: absolute; + z-index: 15; + left: 14px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + border-radius: 19px; +} +.tool-rail button { + width: 52px; + min-height: 53px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border: 1px solid transparent; + border-radius: 14px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: .18s ease; +} +.tool-rail button svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.tool-rail button span { font-size: 8px; text-transform: uppercase; letter-spacing: .08em; } +.tool-rail button:hover { color: white; background: rgba(255,255,255,.04); } +.tool-rail button.active { color: var(--green); border-color: rgba(142,255,66,.52); background: rgba(142,255,66,.07); box-shadow: inset 0 0 18px rgba(142,255,66,.06); } + +.component-tray { + position: absolute; + z-index: 18; + left: 80px; + bottom: 18px; + display: flex; + gap: 7px; + padding: 8px; + border-radius: 20px; + animation: rise .18s ease both; +} +.component-tray[hidden] { display: none; } +.tray-heading { min-width: 120px; display: flex; justify-content: center; flex-direction: column; padding: 0 9px; border-right: 1px solid var(--line); } +.tray-heading b { font-size: 11px; } +.tray-heading small { margin-top: 3px; font-size: 8px; color: var(--muted); } +.component-tray > button { min-width: 92px; display: grid; grid-template-columns: 30px 1fr; grid-template-rows: auto auto; gap: 1px 7px; padding: 8px; border: 1px solid transparent; border-radius: 14px; background: rgba(255,255,255,.025); text-align: left; cursor: pointer; } +.component-tray > button:hover, .component-tray > button.active { border-color: var(--line-strong); background: rgba(37,232,255,.06); } +.component-tray > button b { align-self: end; font-size: 10px; } +.component-tray > button small { color: var(--muted); font-size: 7.5px; } +.component-glyph { grid-row: 1/3; align-self: center; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid rgba(255,255,255,.24); border-radius: 8px; color: var(--cyan); } +.source-glyph::before { content: "✦"; color: var(--green); text-shadow: 0 0 10px var(--green); } +.coupler-glyph::before { content: "≈"; color: var(--teal); font-size: 20px; } +.phase-glyph { font-size: 20px; } +.detector-glyph::before { content: "◖"; color: var(--green); font-size: 18px; } + +.canvas-readout { + position: absolute; + z-index: 12; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + display: grid; + grid-template-columns: repeat(4, max-content); + padding: 7px 10px; + border-radius: 16px; +} +.canvas-readout span { display: flex; flex-direction: column; min-width: 92px; padding: 1px 10px; border-right: 1px solid var(--line); } +.canvas-readout span:last-child { border-right: 0; } +.canvas-readout small { font-size: 8px; color: var(--muted); } +.canvas-readout b { margin-top: 3px; font-size: 10px; color: #dcf9f2; } +.canvas-readout span:nth-child(2) b { color: var(--cyan); } +.canvas-readout span:nth-child(3) b { color: var(--green); } + +.zoom-controls { position: absolute; z-index: 14; right: 14px; bottom: 16px; display: flex; border-radius: 14px; overflow: hidden; } +.zoom-controls button { min-width: 34px; height: 34px; border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; } +.zoom-controls button:last-child { border-right: 0; } +.zoom-controls button:hover { color: white; background: rgba(255,255,255,.04); } +.zoom-controls span { font-size: 9px; } + +.empty-hint { position: absolute; z-index: 10; left: 50%; top: 50%; transform: translate(-50%,-50%); display: grid; grid-template-columns: 48px 1fr; gap: 12px; min-width: min(410px, calc(100% - 40px)); padding: 16px; border-radius: 20px; } +.empty-hint[hidden] { display: none; } +.empty-hint b { display: block; font-size: 14px; } +.empty-hint small { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; } +.empty-hint button { grid-column: 2; justify-self: start; margin-top: 7px; border: 1px solid rgba(142,255,66,.45); border-radius: 11px; padding: 7px 10px; background: rgba(142,255,66,.08); color: var(--green); cursor: pointer; font-size: 10px; } +.hint-gesture { width: 44px; height: 44px; border: 1px dashed rgba(37,232,255,.5); border-radius: 14px; position: relative; } +.hint-gesture::before { content: ""; position: absolute; left: 8px; top: 23px; width: 26px; border-top: 2px solid var(--cyan); transform: rotate(-24deg); box-shadow: 0 0 9px var(--cyan); } + +.inspector { + position: relative; + min-height: 0; + overflow: auto; + border-radius: 28px; + padding: 18px; +} +.inspector-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 30px; } +.inspector-empty b { margin-top: 15px; font-size: 14px; } +.inspector-empty p { max-width: 250px; margin: 7px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; } +.scope-icon { position: relative; width: 58px; aspect-ratio: 1; border: 1px solid rgba(37,232,255,.35); border-radius: 50%; box-shadow: inset 0 0 22px rgba(37,232,255,.08), 0 0 24px rgba(37,232,255,.08); } +.scope-icon::before { content:""; position:absolute; inset:13px; border:1px solid rgba(142,255,66,.55); border-radius:50%; } +.scope-icon::after { content:""; position:absolute; left:28px; top:5px; height:48px; border-left:1px solid rgba(255,255,255,.22); box-shadow:-23px 23px 0 -22px white; } + +.inspector-head { display: flex; align-items: start; gap: 12px; padding-bottom: 15px; border-bottom: 1px solid var(--line); } +.inspector-head .type-icon { width: 42px; height: 42px; display:grid; place-items:center; border: 1px solid var(--line-strong); border-radius: 13px; color: var(--cyan); box-shadow: inset 0 0 15px rgba(37,232,255,.07); } +.inspector-head .type-icon.source { color: var(--green); } +.inspector-head .type-icon b { font-size: 20px; font-weight: 450; } +.inspector-head > div:nth-child(2) { min-width:0; flex:1; } +.inspector-head h2 { margin: 0; font-size: 16px; letter-spacing: -.02em; } +.inspector-head p { margin: 5px 0 0; color: var(--muted); font-size: 9px; } +.inspector-head .icon-button { width: 34px; height: 34px; } + +.inspector-section { padding: 15px 0; border-bottom: 1px solid var(--line); } +.inspector-section:last-child { border-bottom: 0; } +.section-title { display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px; } +.section-title b { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; color: #c9ded9; } +.section-title small { color: var(--muted); font-size: 8px; } +.inspector label { display:block; margin: 13px 0; } +.inspector label > span { display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:7px; } +.inspector label b { font-size:10px; font-weight:600; } +.inspector label output { color: #d9f7f1; font-size:10px; font-variant-numeric: tabular-nums; } +.inspector input[type="range"], .coupler-inspector input[type="range"] { width:100%; height: 18px; margin:0; appearance:none; background: transparent; cursor:pointer; } +.inspector input[type="range"]::-webkit-slider-runnable-track, .coupler-inspector input[type="range"]::-webkit-slider-runnable-track { height:2px; border-radius:2px; background:linear-gradient(90deg,var(--cyan),rgba(107,141,138,.4)); } +.inspector input[type="range"]::-webkit-slider-thumb, .coupler-inspector input[type="range"]::-webkit-slider-thumb { appearance:none; width:15px; height:15px; margin-top:-6.5px; border:0; border-radius:50%; background:var(--cyan); box-shadow:0 0 14px rgba(37,232,255,.65); } +.inspector label > small, .coupler-inspector label > small { display:flex; justify-content:space-between; color:var(--muted); font-size:7px; } +.inspector .input-row { display:grid; grid-template-columns:1fr auto; gap:8px; margin:10px 0; } +.inspector input[type="number"], .inspector input[type="text"] { width:100%; border:1px solid var(--line); border-radius:10px; padding:8px 9px; background:rgba(0,0,0,.2); color:var(--text); font-size:10px; } +.inspector .unit { align-self:center; color:var(--muted); font-size:9px; } +.inspector-metrics { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.inspector-metrics span { display:flex; flex-direction:column; min-height:60px; padding:10px; border:1px solid var(--line); border-radius:13px; background:rgba(255,255,255,.018); } +.inspector-metrics small { color:var(--muted); font-size:8px; } +.inspector-metrics b { margin-top:auto; font-size:12px; color:#e8faf6; } +.inspector-metrics span:nth-child(2n) b { color:var(--cyan); } +.inspector-action { width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:9px; padding:11px; border:1px solid var(--line); border-radius:13px; background:rgba(255,255,255,.02); cursor:pointer; text-align:left; } +.inspector-action:hover { border-color:var(--line-strong); background:rgba(37,232,255,.05); } +.inspector-action b { font-size:10px; } +.inspector-action small { color:var(--muted); font-size:8px; } +.inspector-action .arrow { color:var(--cyan); font-size:18px; } +.assumption-chip { display:inline-flex; align-items:center; gap:5px; margin:3px 3px 3px 0; padding:5px 7px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:7.5px; } +.assumption-chip::before { content:""; width:5px; height:5px; border-radius:50%; background:var(--teal); } +.danger-button { width:100%; padding:10px; border:1px solid rgba(255,109,125,.28); border-radius:12px; background:rgba(255,109,125,.05); color:#ff9fab; cursor:pointer; font-size:9px; } +.mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin:10px 0 4px; } +.mode-switch button { padding:9px 10px; border:1px solid var(--line); border-radius:12px; background:rgba(255,255,255,.02); color:var(--muted); cursor:pointer; font-size:9px; } +.mode-switch button.active { color:var(--text); border-color:rgba(37,232,255,.42); background:rgba(37,232,255,.08); box-shadow:inset 0 0 12px rgba(37,232,255,.05); } +.source-note { display:block; margin-top:8px; color:var(--muted); font-size:7.5px; line-height:1.45; } + + +.transport { + z-index: 28; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 20px; + padding: 5px clamp(15px, 2.5vw, 30px) calc(5px + var(--safe-bottom)); + border-width: 1px 0 0; + border-radius: 0; +} +.time-context { display:flex; align-items:center; gap:10px; min-width:0; } +.time-context > div { min-width:0; display:flex; flex-direction:column; } +.time-context b { font-size:10px; } +.time-context small { margin-top:3px; color:var(--muted); font-size:8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.live-dot { width:8px; height:8px; flex:0 0 auto; border-radius:50%; background:var(--green); box-shadow:0 0 13px var(--green); animation:breathe 1.5s ease-in-out infinite; } +.transport-control { display:flex; align-items:center; gap:12px; } +.animal { color:#627572; font-size:18px; } +.rabbit { transform:scaleX(-1); } +.time-knob { --angle: 90deg; position:relative; width:82px; aspect-ratio:1; display:grid; place-items:center; border:0; border-radius:50%; background:conic-gradient(from 225deg, var(--cyan) 0deg, var(--teal) var(--angle), rgba(84,112,109,.18) var(--angle), rgba(84,112,109,.18) 270deg, transparent 270deg); box-shadow:0 0 24px rgba(37,232,255,.13); cursor:grab; } +.time-knob::before { content:""; position:absolute; inset:5px; border-radius:50%; background:linear-gradient(145deg,#182326,#070b0c 63%); box-shadow:inset 2px 2px 5px rgba(255,255,255,.08), inset -4px -6px 10px rgba(0,0,0,.5); } +.time-knob:active { cursor:grabbing; } +.knob-ticks { position:absolute; inset:-5px; border-radius:50%; background:repeating-conic-gradient(from 225deg,rgba(255,255,255,.26) 0 1deg,transparent 1deg 12deg); mask:radial-gradient(transparent 69%,#000 70% 74%,transparent 75%); } +.knob-center { position:relative; z-index:1; display:flex; flex-direction:column; align-items:center; gap:3px; } +.knob-center svg { width:20px; height:20px; fill:none; stroke:#eafbf7; stroke-width:2; stroke-linecap:round; } +.knob-center b { font-size:8px; color:var(--cyan); } +.transport-actions { justify-self:end; display:flex; gap:7px; } +.transport-actions button { min-width:62px; display:flex; align-items:center; gap:7px; padding:8px 10px; border:1px solid var(--line); border-radius:12px; background:transparent; color:var(--muted); cursor:pointer; } +.transport-actions button:hover { color:white; background:rgba(255,255,255,.04); } +.transport-actions svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; } +.transport-actions span { font-size:8px; } + +.physics-dialog, .sheet-dialog { border:0; padding:0; background:transparent; color:var(--text); max-width:none; max-height:none; } +.physics-dialog { width:100vw; height:100dvh; margin:0; overflow:hidden; } +.physics-dialog::backdrop, .sheet-dialog::backdrop { background:rgba(0,4,5,.78); backdrop-filter:blur(10px); } +.physics-shell { width:100%; height:100%; display:grid; grid-template-rows:70px 1fr; background:#030708; } +.physics-header { display:flex; align-items:center; gap:12px; padding:calc(10px + var(--safe-top)) 16px 10px; border-width:0 0 1px; border-radius:0; } +.physics-header > div:nth-child(2) { display:flex; flex-direction:column; } +.physics-header b { font-size:14px; } +.physics-header small { margin-top:3px; color:var(--muted); font-size:8px; } +.physics-header-actions { margin-left:auto; display:flex; align-items:center; gap:8px; } +.depth-badge { padding:6px 9px; border:1px solid rgba(37,232,255,.35); border-radius:999px; color:var(--cyan); font-size:8px; background:rgba(37,232,255,.05); } +.physics-body { min-height:0; display:grid; grid-template-columns:minmax(0,1fr) clamp(300px,27vw,400px); gap:12px; padding:12px; } +.physics-stage { position:relative; min-width:0; min-height:0; overflow:hidden; border:1px solid rgba(102,236,220,.12); border-radius:28px; background:radial-gradient(circle at 50% 50%,rgba(27,65,63,.13),transparent 48%),#030708; } +.physics-stage::before { content:""; position:absolute; inset:0; pointer-events:none; background-image:radial-gradient(circle,rgba(95,141,137,.23) 1px,transparent 1px); background-size:28px 28px; opacity:.55; } +.physics-tabs { position:absolute; z-index:6; left:50%; top:14px; transform:translateX(-50%); display:flex; padding:4px; border-radius:14px; } +.physics-tabs button { min-width:75px; padding:8px 10px; border:1px solid transparent; border-radius:10px; background:transparent; color:var(--muted); cursor:pointer; font-size:9px; } +.physics-tabs button.active { color:var(--cyan); border-color:rgba(37,232,255,.55); background:rgba(37,232,255,.08); box-shadow:inset 0 0 14px rgba(37,232,255,.06); } +#couplerCanvas { position:absolute; inset:0; width:100%; height:100%; touch-action:none; } +.coupler-label { position:absolute; z-index:4; display:flex; flex-direction:column; text-shadow:0 0 12px #000; pointer-events:none; } +.coupler-label small { color:var(--muted); font-size:9px; } +.coupler-label b { margin-top:3px; font-size:12px; color:var(--green); } +.input-label { left:4%; top:45%; } +.through-label { right:4%; top:39%; text-align:right; } +.cross-label { right:4%; top:61%; text-align:right; } +.cross-label b { color:var(--cyan); } +.field-scale { position:absolute; z-index:5; left:50%; bottom:18px; transform:translateX(-50%); display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:11px; } +.field-scale > span { width:70px; height:5px; border-radius:5px; background:linear-gradient(90deg,#0a2630,#246ee8,#28e8ff,#8eff42,#fff); } +.field-scale small { color:var(--muted); font-size:7px; } +.mini-map { position:absolute; z-index:5; right:16px; top:70px; width:180px; padding:8px; border-radius:15px; } +.mini-map svg { width:100%; height:auto; } +.mini-map > span { display:block; color:var(--muted); font-size:7px; margin-top:4px; } +.zoom-coach { position:absolute; z-index:6; left:16px; bottom:16px; display:flex; align-items:center; gap:9px; max-width:280px; padding:8px 10px; border-radius:14px; opacity:.92; } +.zoom-coach > div { display:flex; flex-direction:column; } +.zoom-coach b { font-size:9px; } +.zoom-coach small { margin-top:2px; color:var(--muted); font-size:7px; } +.pinch-icon { position:relative; width:26px; height:26px; border:1px dashed var(--line-strong); border-radius:9px; } +.pinch-icon::before, .pinch-icon::after { content:""; position:absolute; width:7px; border-top:1.5px solid var(--cyan); top:12px; } +.pinch-icon::before { left:4px; transform:rotate(-35deg); } +.pinch-icon::after { right:4px; transform:rotate(35deg); } + +.coupler-inspector { min-height:0; overflow:auto; padding:18px; border-radius:28px; } +.coupler-inspector-heading { display:flex; align-items:start; gap:10px; padding-bottom:15px; border-bottom:1px solid var(--line); } +.coupler-inspector-heading > div { display:flex; flex-direction:column; } +.coupler-inspector-heading b { font-size:14px; } +.coupler-inspector-heading small { margin-top:4px; color:var(--muted); font-size:8px; } +.live-pill { margin-left:auto; display:flex; align-items:center; gap:5px; padding:5px 7px; border:1px solid rgba(142,255,66,.3); border-radius:999px; color:var(--green); font-size:8px; } +.live-pill i { width:5px; height:5px; border-radius:50%; background:var(--green); box-shadow:0 0 8px var(--green); } +.coupler-inspector label { display:block; margin:18px 0; } +.coupler-inspector label > span { display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:8px; } +.coupler-inspector label b { font-size:9px; font-weight:600; } +.coupler-inspector output { color:#e3faf5; font-size:9px; font-variant-numeric:tabular-nums; } +.metric-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; } +.metric-grid span { display:flex; flex-direction:column; padding:10px; border:1px solid var(--line); border-radius:13px; background:rgba(255,255,255,.018); } +.metric-grid small { color:var(--muted); font-size:7px; } +.metric-grid b { margin-top:6px; font-size:10px; } +.equation-card { width:100%; display:grid; grid-template-columns:1fr auto; gap:5px 10px; margin-top:14px; padding:12px; border:1px solid var(--line); border-radius:14px; background:rgba(37,232,255,.025); text-align:left; cursor:pointer; } +.equation-card span { font-size:8px; color:var(--muted); } +.equation-card code { grid-row:1/3; grid-column:2; align-self:center; color:var(--cyan); font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; } +.equation-card small { font-size:7px; color:#b8cbc7; } +.model-truth { display:grid; gap:9px; margin-top:14px; } +.model-truth span { position:relative; display:grid; grid-template-columns:8px 1fr; gap:2px 7px; padding:9px; border:1px solid var(--line); border-radius:12px; } +.model-truth i { grid-row:1/3; width:6px; height:6px; margin-top:3px; border-radius:50%; background:var(--green); box-shadow:0 0 8px var(--green); } +.model-truth span:last-child i { background:var(--cyan); box-shadow:0 0 8px var(--cyan); } +.model-truth b { font-size:8px; } +.model-truth small { color:var(--muted); font-size:7px; line-height:1.4; } + +.sheet-dialog { width:min(520px,calc(100vw - 20px)); margin:auto auto 10px; } +.sheet { max-height:min(82dvh,680px); overflow:auto; padding:10px 16px 18px; border-radius:26px; } +.sheet-handle { width:44px; height:4px; margin:0 auto 10px; border-radius:4px; background:rgba(255,255,255,.18); } +.sheet header { display:flex; align-items:center; padding-bottom:12px; border-bottom:1px solid var(--line); } +.sheet header > div { display:flex; flex-direction:column; } +.sheet header b { font-size:14px; } +.sheet header small { margin-top:3px; color:var(--muted); font-size:8px; } +.sheet header button { margin-left:auto; } +.sheet-menu { display:grid; gap:7px; margin-top:12px; } +.sheet-menu button { width:100%; display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--line); border-radius:14px; background:rgba(255,255,255,.02); text-align:left; cursor:pointer; } +.sheet-menu button:hover { background:rgba(255,255,255,.045); border-color:var(--line-strong); } +.sheet-menu button .menu-icon { width:32px; height:32px; display:grid; place-items:center; border:1px solid var(--line); border-radius:10px; color:var(--cyan); } +.sheet-menu button > span:nth-child(2) { display:flex; flex-direction:column; } +.sheet-menu button b { font-size:10px; } +.sheet-menu button small { margin-top:3px; color:var(--muted); font-size:8px; } +.sheet-copy { margin-top:14px; color:#cbe0db; font-size:10px; line-height:1.55; } +.sheet-copy h3 { margin:16px 0 6px; color:white; font-size:11px; } +.sheet-copy code { color:var(--cyan); } +.sheet-copy ul { padding-left:18px; } +.sheet-copy li { margin:5px 0; } + +.toast { position:fixed; z-index:100; left:50%; bottom:calc(var(--transport-h) + var(--safe-bottom) + 14px); transform:translate(-50%,20px); max-width:min(420px,calc(100vw - 30px)); padding:10px 13px; border:1px solid var(--line-strong); border-radius:13px; background:rgba(5,15,17,.94); box-shadow:0 16px 50px rgba(0,0,0,.45); color:#e8faf6; font-size:9px; opacity:0; pointer-events:none; transition:.22s ease; } +.toast.show { opacity:1; transform:translate(-50%,0); } + +@keyframes breathe { 0%,100%{opacity:.6} 50%{opacity:1} } +@keyframes rise { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} } + +@media (max-width: 980px) { + :root { --transport-h: 84px; } + .workspace { grid-template-columns:1fr; padding:0; gap:0; } + .canvas-panel { border-radius:0; border-width:0; } + .inspector { position:absolute; z-index:22; right:10px; bottom:10px; width:min(360px,calc(100% - 20px)); max-height:52%; border-radius:22px; transform:translateY(calc(100% + 22px)); opacity:0; pointer-events:none; transition:.25s cubic-bezier(.2,.8,.2,1); } + .inspector.open { transform:none; opacity:1; pointer-events:auto; } + .model-strip { top:10px; max-width:calc(100% - 28px); } + .solver-health small { display:none; } + .physics-body { grid-template-columns:1fr; padding:0; } + .physics-stage { border-radius:0; border-width:0; } + .coupler-inspector { position:absolute; z-index:10; left:10px; right:10px; bottom:10px; max-height:48%; border-radius:22px; transform:translateY(calc(100% + 20px)); transition:.25s ease; } + .physics-dialog.show-inspector .coupler-inspector { transform:none; } + .physics-header { height:70px; } + .mini-map { width:150px; } + .canvas-readout { bottom:10px; } + .zoom-controls { bottom:10px; } +} + +@media (max-width: 680px) { + :root { --transport-h: 76px; } + .app-shell { grid-template-rows: calc(56px + var(--safe-top)) 1fr calc(var(--transport-h) + var(--safe-bottom)); } + .topbar { gap:7px; padding-left:8px; padding-right:8px; } + .brand-copy strong { font-size:16px; } + .brand-copy small { display:none; } + .topbar-actions { gap:0; } + .topbar-actions .icon-button { width:34px; height:34px; } + #moreButton { display:none; } + .model-strip { left:9px; right:9px; transform:none; justify-content:space-between; max-width:none; } + .model-badge { padding:5px 7px; } + .model-badge small { max-width:180px; } + .solver-health b { display:none; } + .tool-rail { left:8px; top:auto; bottom:8px; transform:none; flex-direction:row; padding:4px; } + .tool-rail button { width:47px; min-height:45px; } + .tool-rail button span { font-size:7px; } + .component-tray { left:8px; right:8px; bottom:62px; overflow-x:auto; border-radius:18px; } + .tray-heading { display:none; } + .component-tray > button { min-width:106px; } + .canvas-readout { display:none; } + .zoom-controls { right:8px; bottom:8px; } + .transport { grid-template-columns:1fr auto 1fr; gap:4px; padding-left:8px; padding-right:8px; } + .time-context { display:none; } + .transport-control { grid-column:2; gap:8px; } + .time-knob { width:66px; } + .transport-actions { grid-column:3; } + .transport-actions button { min-width:38px; width:38px; height:38px; padding:0; justify-content:center; } + .transport-actions span { display:none; } + .transport-actions button:first-child { display:none; } + .physics-header { padding-left:8px; padding-right:8px; } + .physics-header-actions .icon-button { width:34px; height:34px; } + .depth-badge { font-size:7px; } + .physics-tabs { left:8px; right:8px; transform:none; overflow-x:auto; justify-content:flex-start; } + .physics-tabs button { min-width:68px; } + .mini-map { top:64px; right:9px; width:124px; } + .zoom-coach { left:9px; bottom:9px; max-width:210px; } + .zoom-coach small { display:none; } + .coupler-label b { font-size:10px; } + .coupler-label small { font-size:7px; } + .field-scale { bottom:9px; } + .field-scale small { display:none; } + .field-scale > span { width:55px; } + .sheet-dialog { margin-bottom:6px; } +} + +@media (max-height: 640px) and (orientation: landscape) { + .app-shell { grid-template-rows:56px 1fr 66px; } + .tool-rail { flex-direction:row; top:auto; bottom:8px; transform:none; } + .transport { --transport-h:66px; } + .time-knob { width:58px; } + .physics-body { grid-template-columns:minmax(0,1fr) 320px; } + .coupler-inspector { position:relative; transform:none; left:auto; right:auto; bottom:auto; max-height:none; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior:auto !important; animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; } +} +.empty-hint { pointer-events: none; } +.empty-hint button { pointer-events: auto; } +.inspector-empty[hidden], #inspectorContent[hidden] { display: none !important; } + +/* ────────────────────────────────────────────────────────────────────────── + PicSetup Lab + semantic component system + ────────────────────────────────────────────────────────────────────────── */ +.lab-launch { + height: 38px; display:flex; align-items:center; gap:7px; padding:0 10px; + border:1px solid rgba(142,255,66,.34); border-radius:13px; + background:linear-gradient(180deg,rgba(142,255,66,.09),rgba(37,232,255,.035)); + color:#eaffdf; cursor:pointer; transition:.18s ease; +} +.lab-launch:hover,.lab-launch.active { border-color:rgba(142,255,66,.68); background:rgba(142,255,66,.12); box-shadow:0 0 24px rgba(142,255,66,.08); } +.lab-launch svg { width:17px; height:17px; fill:none; stroke:var(--green); stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; } +.lab-launch span { font-size:9px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; } +.lab-launch i { min-width:17px; height:17px; display:grid; place-items:center; padding:0 4px; border-radius:999px; background:rgba(37,232,255,.12); color:var(--cyan); font-size:7px; font-style:normal; } + +.routing-badge { display:flex; flex-direction:column; align-items:flex-start; min-width:78px; padding:5px 8px; border:0; border-left:1px solid var(--line); background:transparent; cursor:pointer; } +.routing-badge span { color:var(--cyan); font-size:8px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; } +.routing-badge small { margin-top:2px; color:var(--muted); font-size:7px; white-space:nowrap; } +.routing-badge:hover span { color:white; } + +.canvas-readout > button { display:flex; flex-direction:column; min-width:92px; padding:1px 10px; border:0; border-right:1px solid var(--line); background:transparent; text-align:left; cursor:pointer; } +.canvas-readout > button:hover { background:rgba(37,232,255,.045); } +.canvas-readout > button small { font-size:8px; color:var(--muted); } +.canvas-readout > button b { margin-top:3px; color:var(--green); font-size:10px; } +.canvas-readout span:nth-child(4) b { color:#dff7f1; font-family:"SFMono-Regular",Consolas,monospace; } + +.scale-ruler { position:absolute; z-index:14; left:88px; bottom:18px; min-width:104px; display:grid; grid-template-columns:42px 1fr; grid-template-rows:auto auto; gap:1px 8px; align-items:center; padding:7px 9px; border-radius:13px; cursor:pointer; } +.scale-ruler > span { grid-row:1/3; position:relative; display:block; height:8px; border:1px solid rgba(142,255,66,.72); border-top:0; } +.scale-ruler > span::before { content:""; position:absolute; left:50%; top:0; bottom:0; border-left:1px solid rgba(142,255,66,.5); } +.scale-ruler b { font-size:8px; color:var(--green); } +.scale-ruler small { font-size:6.5px; color:var(--muted); } + +.component-tray { max-width:calc(100% - 98px); align-items:stretch; } +.component-catalog { min-width:0; display:flex; gap:8px; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; padding-bottom:2px; } +.catalog-group { flex:0 0 auto; display:grid; grid-template-columns:repeat(2,minmax(100px,1fr)); gap:5px; padding-right:8px; border-right:1px solid var(--line); } +.catalog-group:last-child { border-right:0; } +.catalog-group h3 { grid-column:1/-1; margin:0 0 2px; color:var(--muted); font-size:7px; text-transform:uppercase; letter-spacing:.1em; } +.component-card { min-width:104px; display:grid; grid-template-columns:30px 1fr; grid-template-rows:auto auto; gap:1px 7px; padding:7px 8px; border:1px solid transparent; border-radius:13px; background:rgba(255,255,255,.025); text-align:left; cursor:pointer; } +.component-card:hover,.component-card.active { border-color:var(--line-strong); background:rgba(37,232,255,.065); } +.component-card .component-glyph { grid-row:1/3; align-self:center; } +.component-card b { align-self:end; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; } +.component-card small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:7px; } + +/* component registry renderer */ +.port-hit { cursor:crosshair; pointer-events:all; } +.port-label { display:none; fill:rgba(184,213,207,.76); font-size:7px; pointer-events:none; } +.circuit-svg[data-semantic="detail"] .port-label { display:block; } +.circuit-svg[data-semantic="overview"] .component-sub,.circuit-svg[data-semantic="overview"] .component-label,.circuit-svg[data-semantic="overview"] .component-inspect,.circuit-svg[data-semantic="overview"] .port { display:none; } +.component-inspect { cursor:pointer; opacity:.82; } +.component-inspect:hover { opacity:1; filter:url(#softGlow); } +.generic-lines,.ring-shape,.bragg-tooth,.grating-arc { fill:none; stroke:rgba(220,255,248,.72); stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } +.generic-symbol { fill:var(--cyan); font-size:15px; font-weight:650; text-anchor:middle; pointer-events:none; filter:url(#softGlow); } +.ring-shape { stroke:var(--cyan); } +.bragg-tooth { stroke:var(--green); } +.grating-arc { stroke:var(--cyan); } +.probe-ring { fill:rgba(37,232,255,.04); stroke:var(--cyan); stroke-width:1.6; } +.probe-dot { fill:var(--green); filter:url(#softGlow); } +.crossing-core { fill:#0b1719; stroke:var(--cyan); stroke-width:1.4; } +.block-inner { fill:rgba(37,232,255,.035); stroke:rgba(37,232,255,.42); stroke-width:1; stroke-dasharray:4 4; } +.measurement-pin circle { fill:#071213; stroke:var(--green); stroke-width:1.3; } +.measurement-pin path { stroke:var(--green); } +.measurement-pin { cursor:pointer; filter:url(#softGlow); } +.analysis-highlight .component-body,.component.analysis-highlight .component-body { stroke:var(--warning); stroke-width:2.2; filter:url(#softGlow); } +.connection.analysis-highlight .connection-base { stroke:rgba(255,214,107,.65); stroke-width:14; } + +/* generic inspector */ +.inspector-parameter { margin:13px 0; padding:10px; border:1px solid rgba(129,211,199,.12); border-radius:13px; background:rgba(255,255,255,.012); } +.inspector-parameter.inactive-model { opacity:.68; } +.parameter-head { display:flex; align-items:flex-start; gap:8px; margin-bottom:8px; } +.parameter-head > div { min-width:0; flex:1; } +.parameter-head b { display:block; font-size:10px; } +.parameter-head small { display:block; margin-top:3px; color:var(--muted); font-size:7px; line-height:1.35; } +.parameter-head output { flex:0 0 auto; color:#e2faf5; font-size:9px; font-variant-numeric:tabular-nums; } +.parameter-actions { display:flex; gap:5px; margin-top:8px; } +.parameter-actions button,.pin-button,.small-action { display:inline-flex; align-items:center; gap:5px; padding:6px 8px; border:1px solid var(--line); border-radius:9px; background:rgba(255,255,255,.02); color:var(--muted); cursor:pointer; font-size:7px; } +.parameter-actions button:hover,.pin-button:hover,.small-action:hover,.pin-button.active { color:white; border-color:var(--line-strong); background:rgba(37,232,255,.055); } +.parameter-actions button:first-child { color:var(--cyan); } +.scope-pill,.provenance-pill { display:inline-flex; align-items:center; padding:4px 6px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:6.5px; text-transform:uppercase; letter-spacing:.06em; } +.scope-pill.active { color:var(--green); border-color:rgba(142,255,66,.3); } +.scope-pill.pulse { color:var(--cyan); } +.provenance-card { padding:10px; border:1px solid var(--line); border-radius:13px; background:rgba(37,232,255,.025); } +.provenance-card b { display:block; font-size:9px; } +.provenance-card p { margin:5px 0 0; color:var(--muted); font-size:8px; line-height:1.45; } +.port-measurements { display:grid; gap:6px; } +.port-measurement { display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:7px; padding:8px; border:1px solid var(--line); border-radius:11px; } +.port-measurement > div { min-width:0; } +.port-measurement b { display:block; font-size:8px; } +.port-measurement small { display:block; margin-top:2px; color:var(--muted); font-size:7px; } +.port-measurement output { color:#e5faf6; font-size:8px; } +.power-budget-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-top:12px; } +.power-budget-grid span { display:flex; flex-direction:column; padding:10px; border:1px solid var(--line); border-radius:12px; } +.power-budget-grid small { color:var(--muted); font-size:7px; } +.power-budget-grid b { margin-top:5px; font-size:10px; } +.power-budget-bar { height:10px; display:flex; overflow:hidden; margin-top:12px; border:1px solid var(--line); border-radius:999px; background:rgba(255,255,255,.02); } +.power-budget-bar i { display:block; height:100%; min-width:0; } +.power-budget-bar i:nth-child(1){background:var(--green)} +.power-budget-bar i:nth-child(2){background:var(--cyan)} +.power-budget-bar i:nth-child(3){background:var(--warning)} +.power-budget-bar i:nth-child(4){background:#c995ff} +.selection-summary { padding:13px; border:1px solid var(--line); border-radius:15px; background:rgba(37,232,255,.025); } +.selection-summary h3 { margin:0; font-size:12px; } +.selection-summary p { margin:5px 0 0; color:var(--muted); font-size:8px; line-height:1.45; } + +/* Lab */ +.lab-panel { position:absolute; z-index:26; left:14px; right:14px; bottom:14px; height:min(58vh,540px); display:grid; grid-template-rows:52px minmax(0,1fr) 58px; overflow:hidden; border-radius:26px; box-shadow:0 34px 110px rgba(0,0,0,.62); animation:labRise .24s cubic-bezier(.2,.8,.2,1) both; } +.lab-panel[hidden] { display:none; } +.lab-header { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:14px; padding:7px 10px 7px 14px; border-bottom:1px solid var(--line); background:rgba(5,13,15,.8); } +.lab-title { display:flex; align-items:center; gap:9px; min-width:170px; } +.lab-title > div { display:flex; flex-direction:column; } +.lab-title b { font-size:12px; } +.lab-title small { margin-top:2px; color:var(--muted); font-size:7px; } +.lab-mark { width:30px; height:30px; display:grid; place-items:center; border:1px solid rgba(142,255,66,.38); border-radius:10px; color:var(--green); font-size:20px; box-shadow:inset 0 0 14px rgba(142,255,66,.05); } +.lab-tabs { justify-self:center; display:flex; gap:3px; padding:3px; border:1px solid var(--line); border-radius:12px; background:rgba(0,0,0,.18); } +.lab-tabs button { padding:7px 11px; border:0; border-radius:9px; background:transparent; color:var(--muted); cursor:pointer; font-size:8px; } +.lab-tabs button.active { background:rgba(37,232,255,.09); color:var(--cyan); box-shadow:inset 0 0 13px rgba(37,232,255,.05); } +.lab-body { min-height:0; display:grid; grid-template-columns:minmax(0,1fr) clamp(300px,29vw,390px); gap:1px; background:var(--line); } +.lab-plot-wrap { position:relative; min-width:0; min-height:0; background:#03090a; } +#labPlot { width:100%; height:100%; display:block; touch-action:none; } +.lab-plot-toolbar { position:absolute; left:10px; top:10px; display:flex; gap:5px; pointer-events:none; } +.lab-plot-toolbar button { pointer-events:auto; padding:6px 8px; border:1px solid var(--line); border-radius:9px; background:rgba(5,14,16,.86); color:var(--muted); cursor:pointer; font-size:7px; } +.lab-plot-toolbar button:hover { color:white; border-color:var(--line-strong); } +.lab-controls { min-height:0; overflow:auto; padding:12px; background:linear-gradient(180deg,rgba(10,19,21,.98),rgba(5,11,13,.98)); } +.lab-section { padding-bottom:12px; margin-bottom:12px; border-bottom:1px solid var(--line); } +.lab-section:last-child { border-bottom:0; } +.lab-section-head { display:flex; align-items:baseline; justify-content:space-between; gap:8px; margin-bottom:9px; } +.lab-section-head b { font-size:9px; text-transform:uppercase; letter-spacing:.08em; } +.lab-section-head small { color:var(--muted); font-size:7px; } +.lab-field { display:grid; gap:5px; margin:9px 0; } +.lab-field > span { display:flex; justify-content:space-between; align-items:baseline; gap:8px; } +.lab-field b { font-size:8px; } +.lab-field small { color:var(--muted); font-size:7px; } +.lab-field select,.lab-field input[type="number"],.lab-field input[type="text"] { width:100%; min-width:0; border:1px solid var(--line); border-radius:10px; padding:8px 9px; background:#071013; color:var(--text); font-size:8px; } +.lab-field input[type="range"] { width:100%; } +.lab-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } +.lab-grid.three { grid-template-columns:repeat(3,minmax(0,1fr)); } +.measurement-chips { display:flex; flex-wrap:wrap; gap:5px; } +.measurement-chip { display:flex; align-items:center; gap:6px; padding:6px 7px; border:1px solid var(--line); border-radius:999px; background:rgba(255,255,255,.02); color:#cfe4df; font-size:7px; cursor:pointer; } +.measurement-chip.active { border-color:rgba(142,255,66,.48); color:var(--green); background:rgba(142,255,66,.055); } +.measurement-chip i { width:6px; height:6px; border-radius:50%; background:var(--cyan); } +.measurement-chip button { width:14px; height:14px; display:grid; place-items:center; padding:0; border:0; border-radius:50%; background:rgba(255,255,255,.05); color:var(--muted); cursor:pointer; font-size:9px; } +.lab-primary,.lab-secondary { width:100%; display:flex; align-items:center; justify-content:center; gap:7px; padding:9px 10px; border-radius:11px; cursor:pointer; font-size:8px; font-weight:650; } +.lab-primary { border:1px solid rgba(142,255,66,.48); background:rgba(142,255,66,.09); color:var(--green); } +.lab-primary:hover { background:rgba(142,255,66,.14); } +.lab-primary:disabled { opacity:.35; cursor:default; } +.lab-secondary { border:1px solid var(--line); background:rgba(255,255,255,.025); color:#d4e7e3; } +.lab-secondary:hover { border-color:var(--line-strong); background:rgba(37,232,255,.045); } +.knob-list { display:grid; gap:5px; } +.knob-row { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:7px; padding:7px; border:1px solid var(--line); border-radius:10px; } +.knob-row input { accent-color:var(--green); } +.knob-row b { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:7.5px; } +.knob-row small { color:var(--muted); font-size:6.5px; } +.tolerance-row { display:grid; grid-template-columns:auto minmax(0,1fr) 72px; align-items:center; gap:7px; padding:7px; border:1px solid var(--line); border-radius:10px; } +.tolerance-row input[type="checkbox"] { accent-color:var(--warning); } +.tolerance-row input[type="number"] { width:100%; padding:6px; border:1px solid var(--line); border-radius:8px; background:#071013; color:var(--text); font-size:7px; } +.tolerance-row b { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:7px; } +.tolerance-row small { display:block; margin-top:2px; color:var(--muted); font-size:6px; } +.sensitivity-list { display:grid; gap:5px; } +.sensitivity-item { display:grid; grid-template-columns:1fr 70px; gap:7px; align-items:center; padding:7px; border:1px solid var(--line); border-radius:9px; cursor:pointer; } +.sensitivity-item:hover { border-color:rgba(255,214,107,.45); } +.sensitivity-item b { font-size:7px; } +.sensitivity-bar { height:5px; overflow:hidden; border-radius:999px; background:rgba(255,255,255,.05); } +.sensitivity-bar i { display:block; height:100%; background:var(--warning); } +.lab-footer { min-width:0; display:grid; grid-template-columns:minmax(170px,1fr) minmax(120px,1.2fr) auto; align-items:center; gap:12px; padding:8px 13px; border-top:1px solid var(--line); background:rgba(5,12,14,.94); } +.lab-status { display:grid; grid-template-columns:8px 1fr; grid-template-rows:auto auto; gap:1px 7px; align-items:center; min-width:0; } +.lab-status > span { grid-row:1/3; width:7px; height:7px; border-radius:50%; background:var(--green); box-shadow:0 0 9px var(--green); } +.lab-status.busy > span { background:var(--cyan); box-shadow:0 0 9px var(--cyan); animation:breathe .8s ease-in-out infinite; } +.lab-status.error > span { background:var(--danger); box-shadow:0 0 9px var(--danger); } +.lab-status b { font-size:8px; } +.lab-status small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:7px; } +.lab-progress { height:4px; overflow:hidden; border-radius:999px; background:rgba(255,255,255,.06); } +.lab-progress[hidden] { display:none; } +.lab-progress span { display:block; width:0; height:100%; background:linear-gradient(90deg,var(--green),var(--cyan)); box-shadow:0 0 10px rgba(37,232,255,.55); transition:width .12s linear; } +.lab-metrics { display:flex; gap:6px; min-width:0; overflow-x:auto; } +.lab-metrics span { min-width:82px; display:flex; flex-direction:column; padding:6px 8px; border:1px solid var(--line); border-radius:10px; } +.lab-metrics small { color:var(--muted); font-size:6px; } +.lab-metrics b { margin-top:3px; color:#e6faf6; font-size:8px; } +.lab-result-card { padding:9px; border:1px solid var(--line); border-radius:11px; background:rgba(255,255,255,.018); } +.lab-result-card b { display:block; font-size:8px; } +.lab-result-card p { margin:4px 0 0; color:var(--muted); font-size:7px; line-height:1.4; } + +@keyframes labRise { from{opacity:0;transform:translateY(28px) scale(.985)} to{opacity:1;transform:none} } + +@media (max-width:980px) { + .lab-panel { left:8px; right:8px; bottom:8px; height:min(72vh,620px); } + .lab-body { grid-template-columns:minmax(0,1fr) minmax(270px,38vw); } + .scale-ruler { left:224px; bottom:10px; } +} +@media (max-width:680px) { + .lab-launch { width:34px; padding:0; justify-content:center; border-color:transparent; background:transparent; } + .lab-launch span,.lab-launch i { display:none; } + .lab-launch svg { width:19px; height:19px; } + .routing-badge { min-width:60px; padding:4px 6px; } + .routing-badge small { display:none; } + .component-tray { max-width:none; } + .component-catalog { min-width:max-content; } + .catalog-group { grid-template-columns:repeat(2,96px); } + .component-card { min-width:96px; } + .scale-ruler { left:auto; right:116px; bottom:9px; min-width:87px; grid-template-columns:34px 1fr; } + .scale-ruler small { display:none; } + .lab-panel { z-index:40; left:0; right:0; bottom:0; height:82dvh; border-radius:24px 24px 0 0; border-width:1px 0 0; } + .lab-header { grid-template-columns:1fr auto; gap:7px; padding:7px 8px 7px 11px; } + .lab-title { min-width:0; } + .lab-title small { display:none; } + .lab-tabs { grid-row:2; grid-column:1/-1; width:100%; justify-content:stretch; } + .lab-tabs button { flex:1; padding:6px 4px; } + .lab-body { grid-template-columns:1fr; grid-template-rows:minmax(180px,42%) minmax(0,1fr); } + .lab-controls { border-top:1px solid var(--line); } + .lab-footer { grid-template-columns:1fr auto; min-height:54px; } + .lab-progress { grid-row:2; grid-column:1/-1; } + .lab-metrics { max-width:44vw; } + .canvas-readout { display:none; } +} + +/* Physical-routing trust layer */ +.physical-grid { opacity:.34; } +.connection-tight-bend .connection-base { stroke:rgba(255,185,82,.7); stroke-width:12; stroke-dasharray:5 8; } +.connection-tight-bend .connection-power { filter:url(#softGlow); } +.bend-warning { pointer-events:none; } +.bend-warning circle { fill:rgba(18,12,4,.96); stroke:#ffbd58; stroke-width:1.5; filter:url(#softGlow); } +.bend-warning text { fill:#ffd18a; font:700 8px ui-monospace,monospace; text-anchor:middle; } +.inspector-parameter.linked-parameter { border-color:rgba(37,232,255,.32); box-shadow:inset 0 0 0 1px rgba(37,232,255,.04); } +.inspector-parameter.linked-parameter .parameter-head b::after { content:' linked'; margin-left:6px; color:var(--cyan); font:600 7px ui-monospace,monospace; text-transform:uppercase; letter-spacing:.08em; } + +/* Lab can collapse into a live analytical sparkline without leaving the circuit. */ +.lab-header-actions { display:flex; align-items:center; gap:4px; } +.lab-panel.compact { height:128px; grid-template-rows:44px minmax(0,1fr); } +.lab-panel.compact .lab-header { padding-block:4px; } +.lab-panel.compact .lab-title small { display:none; } +.lab-panel.compact .lab-controls, +.lab-panel.compact .lab-footer { display:none; } +.lab-panel.compact .lab-body { grid-template-columns:1fr; background:transparent; } +.lab-panel.compact .lab-plot-wrap { min-height:0; border:0; } +.lab-panel.compact .lab-plot-toolbar { top:4px; right:6px; } +.lab-panel.compact #labPlot { min-height:0; } + +@media (max-width:700px) { + .lab-panel.compact { height:124px; border-radius:20px 20px 0 0; } + .lab-panel.compact .lab-tabs { display:none; } +} + +/* Typed system-figure connections */ +.connection-signal { fill:none; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; } +.connection-domain-electrical .connection-base { stroke:rgba(255,181,71,.15); stroke-width:9; } +.connection-domain-electrical .connection-signal { stroke:#ffb547; } +.connection-domain-rf .connection-base { stroke:rgba(255,104,208,.14); stroke-width:9; } +.connection-domain-rf .connection-signal { stroke:#ff68d0; stroke-width:3.2; stroke-dasharray:10 4 2 4; } +.connection-domain-control .connection-base { stroke:rgba(107,169,255,.14); stroke-width:9; } +.connection-domain-control .connection-signal { stroke:#6ba9ff; stroke-dasharray:7 6; } +.connection-domain-annotation .connection-signal { stroke:#a8b4b2; stroke-width:1.8; stroke-dasharray:4 5; } +.port-role-electrical { stroke:#ffb547; } +.port-role-rf { stroke:#ff68d0; } +.port-role-control { stroke:#6ba9ff; } +.port-medium-fiber { stroke-width:2.2; } +.component-diagram-only .component-body { stroke-dasharray:5 4; } + +/* Publication/system symbols */ +.bridge-body { fill:rgba(37,232,255,.035); stroke:rgba(37,232,255,.75); stroke-width:1.8; stroke-dasharray:6 5; } +.bridge-arrow { fill:none; stroke:var(--cyan); stroke-width:2; stroke-linecap:round; stroke-linejoin:round; filter:url(#softGlow); } +.bridge-brand { fill:#dffcff; font-size:9px; font-weight:760; text-anchor:middle; letter-spacing:.1em; } +.bridge-schema { fill:#6e9a97; font-size:7px; text-anchor:middle; font-family:ui-monospace,monospace; } +.spiral-line,.awg-array-line,.fiber-line,.polarization-loop,.heater-coil,.electrical-symbol-line,.rf-wave,.scope-trace,.controller-nodes { fill:none; stroke:rgba(220,255,248,.78); stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } +.awg-array-line { stroke:rgba(37,232,255,.62); stroke-width:1.2; } +.fiber-line { stroke:rgba(142,255,66,.74); } +.fiber-core { fill:#081314; stroke:var(--green); stroke-width:1.3; } +.polarization-loop { stroke:var(--cyan); } +.heater-coil,.electrical-symbol-line { stroke:#ffb547; } +.rf-wave { stroke:#ff68d0; stroke-width:2.1; } +.amplifier-triangle { fill:rgba(255,181,71,.035); } +.instrument-body,.control-body,.system-block-body { fill:rgba(12,22,24,.96); stroke:rgba(199,239,231,.42); stroke-width:1.7; } +.instrument-screen { fill:rgba(37,232,255,.035); stroke:rgba(37,232,255,.35); stroke-width:1; } +.instrument-symbol { fill:var(--cyan); font-size:20px; font-weight:700; text-anchor:middle; } +.scope-trace { stroke:#8eff42; } +.controller-nodes { stroke:#6ba9ff; } +.system-symbol { fill:#dff8f2; font-size:15px; font-weight:750; text-anchor:middle; letter-spacing:.08em; } +.chip-frame-body { stroke:rgba(37,232,255,.35); stroke-width:1.6; stroke-dasharray:10 6; } +.chip-fill-none { fill:transparent; } +.chip-fill-tint { fill:rgba(37,232,255,.022); } +.chip-fill-solid { fill:rgba(7,18,20,.82); } +.chip-frame-title { fill:#c9f6ef; font-size:15px; font-weight:760; } +.chip-frame-subtitle { fill:#72938e; font-size:9px; } +.paper-panel-body { fill:rgba(10,18,20,.94); stroke:rgba(199,239,231,.35); stroke-width:1.5; } +.paper-image { pointer-events:none; } +.image-placeholder { fill:none; stroke:rgba(113,156,151,.55); stroke-width:1.5; } +.panel-caption { fill:#dcefeb; font-size:10px; font-weight:650; text-anchor:middle; } +.plot-axis { fill:none; stroke:rgba(210,239,234,.62); stroke-width:1.3; } +.plot-trace { fill:none; stroke:var(--cyan); stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; } +.plot-bars { stroke-width:10; } +.plot-label { fill:#8da6a2; font-size:8px; text-anchor:middle; } +.annotation-text { fill:#dcefeb; pointer-events:none; } +.panel-letter { fill:#f1fffc; font-weight:800; text-anchor:middle; pointer-events:none; } + +/* Paper preview removes neon styling while preserving the editor interactions. */ +.circuit-svg[data-figure-mode="paper"] { background:#fff; } +.circuit-svg[data-figure-mode="paper"][data-paper-background="transparent"] { background:transparent; } +.circuit-svg[data-figure-mode="paper"] .canvas-bg { fill:#fff; } +.circuit-svg[data-figure-mode="paper"][data-paper-background="transparent"] .canvas-bg { fill:transparent; } +.circuit-svg[data-figure-mode="paper"] .component-body, +.circuit-svg[data-figure-mode="paper"] .instrument-body, +.circuit-svg[data-figure-mode="paper"] .control-body, +.circuit-svg[data-figure-mode="paper"] .system-block-body, +.circuit-svg[data-figure-mode="paper"] .paper-panel-body { fill:#fff; stroke:#526b68; filter:none; } +.circuit-svg[data-figure-mode="paper"] .component-diagram-only .component-body { stroke:#758784; } +.circuit-svg[data-figure-mode="paper"] .component-label, +.circuit-svg[data-figure-mode="paper"] .panel-caption, +.circuit-svg[data-figure-mode="paper"] .annotation-text, +.circuit-svg[data-figure-mode="paper"] .panel-letter, +.circuit-svg[data-figure-mode="paper"] .system-symbol, +.circuit-svg[data-figure-mode="paper"] .chip-frame-title { fill:#111b1a; } +.circuit-svg[data-figure-mode="paper"] .component-sub, +.circuit-svg[data-figure-mode="paper"] .plot-label, +.circuit-svg[data-figure-mode="paper"] .chip-frame-subtitle, +.circuit-svg[data-figure-mode="paper"] .bridge-schema { fill:#526460; } +.circuit-svg[data-figure-mode="paper"] .source-rays, +.circuit-svg[data-figure-mode="paper"] .coupler-field, +.circuit-svg[data-figure-mode="paper"] .connection-power, +.circuit-svg[data-figure-mode="paper"] .generic-symbol, +.circuit-svg[data-figure-mode="paper"] .phase-symbol { filter:none; } +.circuit-svg[data-figure-mode="paper"] .source-rays, +.circuit-svg[data-figure-mode="paper"] .connection-power { stroke:#168a72; } +.circuit-svg[data-figure-mode="paper"] .source-core { fill:#168a72; filter:none; } +.circuit-svg[data-figure-mode="paper"] .coupler-field, +.circuit-svg[data-figure-mode="paper"] .generic-symbol, +.circuit-svg[data-figure-mode="paper"] .phase-symbol, +.circuit-svg[data-figure-mode="paper"] .instrument-symbol { stroke:none; fill:#116f74; filter:none; } +.circuit-svg[data-figure-mode="paper"] .generic-lines, +.circuit-svg[data-figure-mode="paper"] .ring-shape, +.circuit-svg[data-figure-mode="paper"] .bragg-tooth, +.circuit-svg[data-figure-mode="paper"] .grating-arc, +.circuit-svg[data-figure-mode="paper"] .spiral-line, +.circuit-svg[data-figure-mode="paper"] .awg-array-line, +.circuit-svg[data-figure-mode="paper"] .fiber-line, +.circuit-svg[data-figure-mode="paper"] .polarization-loop, +.circuit-svg[data-figure-mode="paper"] .scope-trace, +.circuit-svg[data-figure-mode="paper"] .controller-nodes { filter:none; stroke:#294643; } +.circuit-svg[data-figure-mode="paper"] .heater-coil, +.circuit-svg[data-figure-mode="paper"] .electrical-symbol-line { stroke:#b06b00; } +.circuit-svg[data-figure-mode="paper"] .rf-wave { stroke:#a22b80; } +.circuit-svg[data-figure-mode="paper"] .connection-base { stroke:#d7dfdd; stroke-width:7; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-optical .connection-power { stroke:#168a72; stroke-width:3.2 !important; opacity:1 !important; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-electrical .connection-signal { stroke:#b06b00; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-rf .connection-signal { stroke:#a22b80; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-control .connection-signal { stroke:#315ca8; } +.circuit-svg[data-figure-mode="paper"] .bridge-body { fill:#f7fbfb; stroke:#116f74; } +.circuit-svg[data-figure-mode="paper"] .bridge-arrow { stroke:#116f74; filter:none; } +.circuit-svg[data-figure-mode="paper"] .bridge-brand { fill:#0e4e52; } +.circuit-svg[data-figure-mode="paper"] .chip-frame-body { stroke:#759c97; fill:#f8fbfb; } +.circuit-svg[data-figure-mode="paper"] .selection-ring, +.circuit-svg[data-figure-mode="paper"] .component-inspect, +.circuit-svg[data-figure-mode="paper"] .measurement-pin { filter:none; } +.circuit-svg[data-figure-mode="paper"][data-show-ports="false"] .port, +.circuit-svg[data-figure-mode="paper"][data-show-ports="false"] .port-label, +.circuit-svg[data-figure-mode="paper"][data-show-ports="false"] .port-hit { display:none; } +.circuit-svg[data-figure-mode="paper"] .port-label { fill:#3d514e; } + +.figure-badge.active { border-color:rgba(255,255,255,.58); background:#fff; color:#10201e; } +.figure-badge.active small { color:#526460; } +.custom-fields { display:grid; gap:9px; } +.custom-field { display:grid; gap:5px; margin-top:9px; color:var(--muted); font-size:7px; } +.custom-field > span { text-transform:uppercase; letter-spacing:.08em; } +.custom-field input,.custom-field select,.custom-field textarea { width:100%; min-width:0; box-sizing:border-box; border:1px solid var(--line); border-radius:10px; background:rgba(255,255,255,.025); color:#e8faf6; padding:8px 9px; font:inherit; font-size:8px; outline:none; } +.custom-field textarea { resize:vertical; line-height:1.45; } +.custom-field input:focus,.custom-field select:focus,.custom-field textarea:focus { border-color:rgba(37,232,255,.55); box-shadow:0 0 0 2px rgba(37,232,255,.06); } +.custom-field select option { background:#071214; } + +/* Figure annotation controls and labels */ +.identity-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:8px; align-items:end; } +.identity-grid .custom-field { min-width:0; } +.connection-label { + fill:#dff8f2; + stroke:#071214; + stroke-width:4px; + stroke-linejoin:round; + paint-order:stroke fill; + font-size:10px; + font-weight:720; + text-anchor:middle; + dominant-baseline:central; + pointer-events:none; + letter-spacing:.01em; +} +.connection-domain-electrical .connection-label { fill:#ffd798; } +.connection-domain-rf .connection-label { fill:#ffb4e4; } +.connection-domain-control .connection-label { fill:#bdd2ff; } +.connection-domain-annotation .connection-label { fill:#c3cfcd; } +.circuit-svg[data-figure-mode="paper"] .connection-label { + fill:#23312f; + stroke:#fff; + stroke-width:5px; +} +.circuit-svg[data-figure-mode="paper"] .connection-domain-electrical .connection-label { fill:#8b5300; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-rf .connection-label { fill:#842168; } +.circuit-svg[data-figure-mode="paper"] .connection-domain-control .connection-label { fill:#264c91; } + +@media (max-width:520px) { + .identity-grid { grid-template-columns:1fr; gap:0; } +} diff --git a/app/sw.js b/app/sw.js new file mode 100644 index 0000000..d492179 --- /dev/null +++ b/app/sw.js @@ -0,0 +1,50 @@ +const CACHE = 'picsetup-paper-studio-v3'; +const ASSETS = [ + './', + './index.html', + './styles.css', + './icon.svg', + './manifest.webmanifest', + './src/app.js', + './src/complex.js', + './src/geometry.js', + './src/models.js', + './src/physics.js', + './src/bridge.js', + './src/analysis.js', + './src/plot.js', + './src/export.js', + './src/circuit.js', + './src/coupler-view.js' +]; + +self.addEventListener('install', event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; + event.respondWith( + caches.match(event.request).then(cached => { + const network = fetch(event.request).then(response => { + if (response && response.ok && response.type !== 'opaque') { + const copy = response.clone(); + caches.open(CACHE).then(cache => cache.put(event.request, copy)); + } + return response; + }); + return cached || network.catch(() => { + if (event.request.mode === 'navigate') return caches.match('./index.html'); + return Response.error(); + }); + }) + ); +}); diff --git a/app/tests/analysis.test.mjs b/app/tests/analysis.test.mjs new file mode 100644 index 0000000..f4c6eb7 --- /dev/null +++ b/app/tests/analysis.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { makeDemoCircuit, makeRingCircuit, solveCircuit } from '../src/physics.js'; +import { + createParameterLink, defaultMeasurementSpecs, listSweepParameters, optimizeCircuit, + resolveParameter, runTolerance, simulatePulse, sweepCircuit +} from '../src/analysis.js'; + +globalThis.requestAnimationFrame ??= callback => setTimeout(() => callback(performance.now()), 0); + +const detectorSpec = (circuit, id = 'det-1') => ({ id: `m-${id}`, kind: 'component', componentId: id, metric: 'power', label: id }); + +test('MZI sweep derives power, phase, FSR, and group-delay metrics', () => { + const circuit = makeDemoCircuit(); + const result = sweepCircuit(circuit, { parameterId: 'global:wavelengthNm', start: 1500, stop: 1600, points: 401 }, defaultMeasurementSpecs(circuit)); + assert.equal(result.failures, 0); + assert.equal(result.x.length, 401); + assert.ok(result.metrics[0].extinctionDb > 20); + assert.ok(result.metrics[0].insertionLossDb < 1); + assert.ok(result.metrics[0].fsr > 1 && result.metrics[0].fsr < 100); + assert.ok(Number.isFinite(result.metrics[0].groupDelayAtMaxPs)); + assert.ok(Math.abs(result.metrics[0].groupDelayAtMaxPs) < 1000, 'group delay should be physical rather than a derivative sign artifact'); + assert.ok(result.metrics[0].phaseExcursionRad > 1); + assert.ok(result.comparison && result.comparison.imbalanceMax <= 1 + 1e-9); +}); + +test('ring template yields a resonant wavelength response', () => { + const circuit = makeRingCircuit(); + const result = sweepCircuit(circuit, { parameterId: 'global:wavelengthNm', start: 1500, stop: 1600, points: 801 }, defaultMeasurementSpecs(circuit)); + assert.equal(result.failures, 0); + assert.ok(result.metrics.some(metric => metric.extinctionDb > 5)); + assert.ok(result.metrics.some(metric => metric.peakCount > 1)); +}); + +test('linked parameters remain synchronized in direct edits and sweeps', () => { + const circuit = makeDemoCircuit(); + const first = 'component:dc-1:gapUm'; + const second = 'component:dc-2:gapUm'; + const link = createParameterLink(circuit, [first, second], { name: 'Matched coupler gaps' }); + assert.equal(link.members.length, 2); + resolveParameter(circuit, first).set(circuit, 0.31); + assert.equal(circuit.components.find(component => component.id === 'dc-1').params.gapUm, 0.31); + assert.equal(circuit.components.find(component => component.id === 'dc-2').params.gapUm, 0.31); + const descriptor = listSweepParameters(circuit).find(parameter => parameter.id === second); + assert.equal(descriptor.linkId, link.id); + descriptor.set(circuit, 0.24); + assert.equal(circuit.components.find(component => component.id === 'dc-1').params.gapUm, 0.24); +}); + +test('goal-based tuning improves a deliberately poor operating point', async () => { + const circuit = makeDemoCircuit(); + circuit.components.find(component => component.id === 'phase-1').params.phaseRad = -2; + const measurement = detectorSpec(circuit); + const before = solveCircuit(circuit).components.get('det-1').measurementMw; + const result = await optimizeCircuit(circuit, { + measurement, goal: 'maximize', robustWindowNm: 1, + parameterIds: ['component:phase-1:phaseRad'], passes: 3, samplesPerPass: 25 + }); + const after = solveCircuit(result.circuit).components.get('det-1').measurementMw; + assert.ok(after > before + 0.5, `${before} -> ${after}`); + assert.ok(result.evaluations > 20); +}); + +test('Monte Carlo is seeded, reports yield, corners, and sensitivities', async () => { + const circuit = makeDemoCircuit(); + const config = { + measurement: detectorSpec(circuit), samples: 80, threshold: 0.5, criterion: 'min', seed: 12345, + parameters: [ + { id: 'component:phase-1:phaseRad', sigma: 0.08, relative: false }, + { id: 'component:dc-1:gapUm', sigma: 0.03, relative: true } + ] + }; + const a = await runTolerance(circuit, config); + const b = await runTolerance(circuit, config); + assert.deepEqual(a.values, b.values, 'seeded tolerance runs should reproduce exactly'); + assert.ok(a.yield >= 0 && a.yield <= 1); + assert.equal(a.sensitivities.length, 2); + assert.ok(a.worstCorner.value <= a.bestCorner.value); + assert.ok(a.histogram.reduce((sum, bin) => sum + bin.count, 0) === a.samples); +}); + +test('pulse analysis reconstructs finite delay, width, and detector response', async () => { + const circuit = makeDemoCircuit(); + circuit.components.find(component => component.type === 'source').params.sourceMode = 'pulsed'; + const result = await simulatePulse(circuit, detectorSpec(circuit), { durationPs: 8, repetitionRateMHz: 80, samples: 64 }); + assert.equal(result.kind, 'pulse'); + assert.equal(result.samples, 64); + assert.ok(Number.isFinite(result.metrics.delayPs)); + assert.ok(result.metrics.outputFwhmPs >= 0); + assert.ok(result.metrics.repetitionPeriodPs > 0); + assert.ok(result.metrics.peakTransmission >= 0); + assert.equal(result.timePs.length, 64); +}); diff --git a/app/tests/export.test.mjs b/app/tests/export.test.mjs new file mode 100644 index 0000000..0953c22 --- /dev/null +++ b/app/tests/export.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { makeDemoCircuit, solveCircuit } from '../src/physics.js'; +import { createParameterLink } from '../src/analysis.js'; +import { + autoLayoutCircuit, importSemanticNetlist, makeSParameterComponent, parseSParameterData, + semanticNetlist, toGdsfactoryPython, toSaxYAML +} from '../src/export.js'; + +test('semantic and workflow exports preserve topology, models, provenance, and links', () => { + const circuit = makeDemoCircuit(); + createParameterLink(circuit, ['component:dc-1:gapUm', 'component:dc-2:gapUm'], { name: 'Matched gaps' }); + const netlist = semanticNetlist(circuit); + assert.equal(netlist.format, 'picsetup-semantic-netlist'); + assert.equal(netlist.circuit.components.length, circuit.components.length); + assert.equal(netlist.circuit.parameterLinks[0].name, 'Matched gaps'); + assert.ok(netlist.models['dc-1'].provenance); + const roundtrip = importSemanticNetlist(netlist); + assert.equal(roundtrip.connections.length, circuit.connections.length); + + const sax = toSaxYAML(circuit); + assert.match(sax, /instances:/); + assert.match(sax, /connections:/); + assert.match(sax, /Map model names to your PDK/); + + const python = toGdsfactoryPython(circuit); + assert.match(python, /def picsetup_layout/); + assert.match(python, /semantic_connections/); + assert.match(python, /not a foundry-ready layout/i); +}); + +test('JSON and CSV complex S-parameter tables import into a solved custom block', () => { + const json = JSON.stringify({ + modelName: 'Measured pass-through', + ports: [ + { id: 'left', label: 'L', role: 'optical', x: -48, y: 0 }, + { id: 'right', label: 'R', role: 'optical', x: 48, y: 0 } + ], + wavelengthsNm: [1500, 1600], + matrices: [ + [[[0, 0], [1, 0]], [[1, 0], [0, 0]]], + [[[0, 0], [0.8, 0.1]], [[0.8, 0.1], [0, 0]]] + ] + }); + const parsed = parseSParameterData(json, 'measured.json'); + assert.equal(parsed.sParameters.wavelengthsNm.length, 2); + + const csv = 'wavelength_nm,s11_re,s11_im,s12_re,s12_im,s21_re,s21_im,s22_re,s22_im\n1550,0,0,1,0,1,0,0,0\n'; + const parsedCsv = parseSParameterData(csv, 'measured.csv'); + assert.equal(parsedCsv.ports.length, 2); + assert.equal(parsedCsv.sParameters.matrices[0][0][1][0], 1); + + const base = makeDemoCircuit(); + const source = base.components.find(component => component.type === 'source'); + const detector = base.components.find(component => component.id === 'det-1'); + const block = makeSParameterComponent(parsedCsv, { x: 500, y: 300 }); + block.id = 'sblock'; + source.x = 100; source.y = 300; + detector.x = 900; detector.y = 300; + const circuit = { + version: 2, name: 'Imported S test', + settings: { wavelengthNm: 1550, routingMode: 'schematic', worldToUm: 0.2 }, + components: [source, block, detector], + connections: [ + { id: 'a', a: { component: source.id, port: 'out' }, b: { component: block.id, port: block.params.ports[0].id }, waypoints: [], params: { routingMode: 'schematic', schematicLengthUm: 0, lossDbPerCm: 0, neff: 2.42 } }, + { id: 'b', a: { component: block.id, port: block.params.ports[1].id }, b: { component: detector.id, port: 'in' }, waypoints: [], params: { routingMode: 'schematic', schematicLengthUm: 0, lossDbPerCm: 0, neff: 2.42 } } + ], lab: { measurements: [] } + }; + const result = solveCircuit(circuit); + assert.equal(result.ok, true); + assert.ok(Math.abs(result.detectedPowerMw - 1) < 1e-9); +}); + +test('auto-layout assigns finite positions to topology-only imports', () => { + const circuit = makeDemoCircuit(); + circuit.components.forEach(component => { delete component.x; delete component.y; }); + autoLayoutCircuit(circuit); + assert.ok(circuit.components.every(component => Number.isFinite(component.x) && Number.isFinite(component.y))); +}); diff --git a/app/tests/geometry-models.test.mjs b/app/tests/geometry-models.test.mjs new file mode 100644 index 0000000..f425397 --- /dev/null +++ b/app/tests/geometry-models.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { curveLength, curveMinimumRadius, sampleCurve, polylineLength } from '../src/geometry.js'; +import { + connectionLengthUm, createHierarchicalBlock, expandHierarchicalBlock, + makeDemoCircuit, solveCircuit, waveguideTransmission +} from '../src/physics.js'; +import { buildModel, defaultParams, getPorts, listDefinitions } from '../src/models.js'; + +const distanceSum = points => points.slice(1).reduce((sum, point, index) => sum + Math.hypot(point.x - points[index].x, point.y - points[index].y), 0); + +test('rendered cubic geometry and optical geometry are the same curve', () => { + const points = [{ x: 0, y: 0 }, { x: 45, y: 0 }, { x: 55, y: 80 }, { x: 110, y: 80 }]; + const exact = curveLength(points); + const dense = distanceSum(sampleCurve(points, 1000)); + assert.ok(Math.abs(exact - dense) / exact < 2e-5, `adaptive length ${exact} should match dense render sampling ${dense}`); + assert.ok(Math.abs(exact - polylineLength(points)) > 0.1, 'curved route should not silently use control-polyline length'); + const bend = curveMinimumRadius(points, 256); + assert.ok(Number.isFinite(bend.radius) && bend.radius > 0, 'curved route should have a finite bend radius'); + assert.equal(curveMinimumRadius([{ x: 0, y: 0 }, { x: 10, y: 0 }]).radius, Number.POSITIVE_INFINITY); +}); + +test('physical and schematic routing contracts are explicit', () => { + const circuit = makeDemoCircuit(); + const connection = circuit.connections[1]; + const physical = connectionLengthUm(connection, circuit.components, circuit.settings); + connection.params.routingMode = 'schematic'; + connection.params.schematicLengthUm = 432.1; + assert.equal(connectionLengthUm(connection, circuit.components, circuit.settings), 432.1); + connection.params.routingMode = 'physical'; + assert.ok(Math.abs(connectionLengthUm(connection, circuit.components, circuit.settings) - physical) < 1e-9); + + circuit.settings.minBendRadiusUm = 100; + const transmission = waveguideTransmission(connection, circuit.components, 1550, circuit.settings); + assert.equal(transmission.bendViolation, true); + const result = solveCircuit(circuit); + assert.ok(result.warnings.some(message => message.includes('bend radius'))); +}); + +test('the model registry produces dimensionally valid compact models', () => { + const definitions = listDefinitions(); + assert.ok(definitions.length >= 16, 'expanded registry should include the useful circuit library'); + for (const [index, definition] of definitions.entries()) { + const component = { id: `c${index}`, type: definition.type, name: definition.label, x: 0, y: 0, params: defaultParams(definition.type) }; + const ports = getPorts(component); + const model = buildModel(component, { wavelengthNm: 1550, extractBlockModel: null }); + assert.equal(model.ports.length, ports.length, `${definition.type}: port count`); + assert.equal(model.scattering.length, ports.length, `${definition.type}: S rows`); + assert.equal(model.s.length, ports.length, `${definition.type}: source vector`); + model.scattering.forEach(row => assert.equal(row.length, ports.length, `${definition.type}: square S matrix`)); + } +}); + +test('hierarchy collapse and expansion preserve circuit response', () => { + const circuit = makeDemoCircuit(); + const detectorIds = circuit.components.filter(component => component.type === 'detector').map(component => component.id); + const powers = solve => detectorIds.map(id => solve.components.get(id).measurementMw); + const before = powers(solveCircuit(circuit)); + const block = createHierarchicalBlock(circuit, ['dc-1', 'phase-1', 'dc-2'], { id: 'mzi-core', name: 'MZI core' }); + assert.equal(block.type, 'block'); + const collapsed = powers(solveCircuit(circuit)); + collapsed.forEach((value, index) => assert.ok(Math.abs(value - before[index]) < 1e-11)); + const expandedIds = expandHierarchicalBlock(circuit, block.id); + assert.equal(expandedIds.length, 3); + const expanded = powers(solveCircuit(circuit)); + expanded.forEach((value, index) => assert.ok(Math.abs(value - before[index]) < 1e-11)); +}); diff --git a/app/tests/paper-bridge.test.mjs b/app/tests/paper-bridge.test.mjs new file mode 100644 index 0000000..7038458 --- /dev/null +++ b/app/tests/paper-bridge.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + connectionDomain, isOpticalConnection, makePaperFigureCircuit, solveCircuit +} from '../src/physics.js'; +import { + SETUP_PORT_SCHEMA, bridgeManifest, buildOpticalBridgePayload, + decodeBridgePayload, encodeBridgePayload, opticalSetupBridgeUrl +} from '../src/bridge.js'; +import { semanticNetlist, toGdsfactoryPython, toSaxYAML } from '../src/export.js'; +import { getDefinition, getPorts } from '../src/models.js'; +import { opticalSetupBoundaryFromBridge, readIncomingBridge } from '../integrations/opticalsetup/receiver-adapter.js'; + +test('paper hybrid template contains the publication and cross-domain vocabulary', () => { + const circuit = makePaperFigureCircuit(); + const types = new Set(circuit.components.map(component => component.type)); + for (const type of [ + 'chip-frame', 'image-panel', 'plot-panel', 'annotation', 'panel-label', + 'optical-bridge', 'edge-coupler', 'polarization-controller', 'rf-source', + 'electrical-amplifier', 'oscilloscope', 'controller', 'system-block' + ]) assert.ok(types.has(type), `paper template should include ${type}`); + + const domains = new Set(circuit.connections.map(connection => connectionDomain(connection, circuit.components))); + assert.deepEqual([...domains].sort(), ['control', 'electrical', 'optical', 'rf']); + assert.equal(circuit.settings.figureMode, 'paper'); + assert.ok(circuit.components.filter(component => getDefinition(component.type).diagramOnly).length >= 5); + const photocurrent = circuit.connections.find(connection => connection.id === 'wire-paper-2'); + assert.equal(photocurrent.label, 'photocurrent'); + assert.equal(photocurrent.params.arrow, 'end'); +}); + +test('typed figure links remain outside the coherent optical solve', () => { + const circuit = makePaperFigureCircuit(); + const result = solveCircuit(circuit); + assert.equal(result.ok, true); + assert.deepEqual(result.warnings, []); + + const optical = circuit.connections.filter(connection => isOpticalConnection(connection, circuit.components)); + const nonOptical = circuit.connections.filter(connection => !isOpticalConnection(connection, circuit.components)); + assert.equal(result.connections.size, optical.length); + for (const connection of optical) assert.ok(result.connections.has(connection.id)); + for (const connection of nonOptical) assert.equal(result.connections.has(connection.id), false); + assert.ok(nonOptical.some(connection => connectionDomain(connection, circuit.components) === 'electrical')); + assert.ok(nonOptical.some(connection => connectionDomain(connection, circuit.components) === 'rf')); + assert.ok(nonOptical.some(connection => connectionDomain(connection, circuit.components) === 'control')); + + const detector = circuit.components.find(component => component.id === 'det-paper'); + assert.deepEqual(getPorts(detector).map(port => port.role), ['optical', 'electrical']); + assert.ok(Number.isFinite(result.components.get(detector.id).measurementMw)); +}); + +test('OpticalSetup bridge payloads are versioned, explicit, and base64url round-trip safely', () => { + const circuit = makePaperFigureCircuit(); + const solve = solveCircuit(circuit); + const input = circuit.components.find(component => component.id === 'bridge-in'); + const output = circuit.components.find(component => component.id === 'bridge-out'); + + const inPayload = buildOpticalBridgePayload(circuit, input, solve, { returnUrl: 'https://picsetup.com/#example' }); + const outPayload = buildOpticalBridgePayload(circuit, output, solve); + assert.equal(inPayload.schema, SETUP_PORT_SCHEMA); + assert.equal(inPayload.domain, 'optical'); + assert.equal(inPayload.direction, 'input'); + assert.equal(inPayload.kind, 'fiber-mode'); + assert.ok(inPayload.state.opticalPowerMw > 0); + assert.equal(inPayload.returnUrl, 'https://picsetup.com/#example'); + assert.ok(inPayload.omissions.some(item => /spatial field|transverse field/i.test(item))); + assert.equal(outPayload.direction, 'output'); + assert.equal(outPayload.kind, 'free-space-beam'); + assert.ok(outPayload.state.opticalPowerMw > 0); + + const encoded = encodeBridgePayload(outPayload); + assert.match(encoded, /^[A-Za-z0-9_-]+$/); + assert.deepEqual(decodeBridgePayload(encoded), outPayload); + const url = new URL(opticalSetupBridgeUrl(outPayload)); + assert.equal(url.origin, 'https://opticalsetup.com'); + assert.equal(url.searchParams.get('bridgeSchema'), SETUP_PORT_SCHEMA); + assert.deepEqual(decodeBridgePayload(url.searchParams.get('incomingBridge')), outPayload); + assert.equal(bridgeManifest(circuit, solve).length, 2); +}); + + +test('the companion OpticalSetup receiver adapter preserves the handoff without inventing spatial optics', () => { + const circuit = makePaperFigureCircuit(); + const solve = solveCircuit(circuit); + const output = circuit.components.find(component => component.id === 'bridge-out'); + const payload = buildOpticalBridgePayload(circuit, output, solve, { returnUrl: 'https://picsetup.com/#paper' }); + const url = opticalSetupBridgeUrl(payload); + const received = readIncomingBridge(url); + const boundary = opticalSetupBoundaryFromBridge(received); + + assert.equal(received.bridgeId, payload.bridgeId); + assert.equal(boundary.role, 'source'); + assert.equal(boundary.type, 'free-space-interface'); + assert.equal(boundary.opticalPowerMw, payload.state.opticalSetupSidePowerMw); + assert.equal(boundary.wavelengthNm, payload.state.wavelengthNm); + assert.equal(boundary.bridge.returnUrl, 'https://picsetup.com/#paper'); + assert.equal(boundary.unresolvedSpatialState.beamWaistUm, null); + assert.equal(boundary.unresolvedSpatialState.wavefront, 'unspecified'); +}); + +test('semantic export preserves the complete figure graph while physical handoffs exclude it', () => { + const circuit = makePaperFigureCircuit(); + const netlist = semanticNetlist(circuit); + assert.equal(netlist.schemaVersion, 2); + assert.equal(netlist.graphSummary.components, circuit.components.length); + assert.equal(netlist.graphSummary.connections, circuit.connections.length); + assert.equal(netlist.graphSummary.connectionDomains.optical, 9); + assert.equal(netlist.graphSummary.connectionDomains.electrical, 3); + assert.equal(netlist.graphSummary.connectionDomains.rf, 1); + assert.equal(netlist.graphSummary.connectionDomains.control, 1); + assert.equal(netlist.bridges.length, 2); + assert.equal(netlist.models['det-paper'].ports.find(port => port.id === 'elec').domain, 'electrical'); + + const sax = toSaxYAML(circuit); + assert.doesNotMatch(sax, /image-paper:/); + assert.doesNotMatch(sax, /rf-paper-1/); + assert.match(sax, /Figure-only objects and non-optical links/); + + const python = toGdsfactoryPython(circuit); + assert.doesNotMatch(python, /image-paper/); + assert.doesNotMatch(python, /rf-paper-1/); + assert.match(python, /not a foundry-ready layout/i); +}); diff --git a/app/tests/physics.test.mjs b/app/tests/physics.test.mjs new file mode 100644 index 0000000..a5c5eca --- /dev/null +++ b/app/tests/physics.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { couplerCoefficients, makeDemoCircuit, solveCircuit, defaultParams } from '../src/physics.js'; + +const coeff = couplerCoefficients({ gapUm: 0.2, interactionLengthUm: 20, insertionLossDb: 0 }, 1550); +assert.ok(Math.abs(coeff.throughPower - 0.5) < 1e-12, 'reference coupler should be 50:50'); +assert.ok(Math.abs(coeff.throughPower + coeff.crossPower - 1) < 1e-12, 'lossless coupler should conserve power'); + +const direct = { + version: 1, + name: 'direct', + settings: { wavelengthNm: 1550 }, + components: [ + { id: 's', type: 'source', name: 'S', x: 100, y: 100, params: { ...defaultParams('source'), powerMw: 1 } }, + { id: 'd', type: 'detector', name: 'D', x: 300, y: 100, params: defaultParams('detector') } + ], + connections: [{ id: 'w', a: { component: 's', port: 'out' }, b: { component: 'd', port: 'in' }, waypoints: [], params: { neff: 2.42, lossDbPerCm: 0 } }] +}; +const directResult = solveCircuit(direct); +assert.equal(directResult.ok, true); +assert.ok(Math.abs(directResult.detectedPowerMw - 1) < 1e-9, 'lossless direct link should deliver all source power'); +assert.ok(directResult.residual < 1e-9, 'network equation residual should be tiny'); + +const mzi = makeDemoCircuit(); +mzi.components.forEach(component => { + if (component.type === 'coupler') component.params.insertionLossDb = 0; + if (component.type === 'phase') component.params.lossDb = 0; +}); +mzi.connections.forEach(connection => { connection.params.lossDbPerCm = 0; }); +const resultA = solveCircuit(mzi); +assert.equal(resultA.ok, true); +assert.ok(Math.abs(resultA.detectedPowerMw - 1) < 1e-8, 'lossless MZI detector sum should conserve power'); +const outputsA = mzi.components.filter(c => c.type === 'detector').map(c => resultA.components.get(c.id).measurementMw); +mzi.components.find(c => c.type === 'phase').params.phaseRad += 0.8; +const resultB = solveCircuit(mzi); +const outputsB = mzi.components.filter(c => c.type === 'detector').map(c => resultB.components.get(c.id).measurementMw); +assert.ok(Math.abs(outputsA[0] - outputsB[0]) > 0.05, 'phase change should move measurable output power'); +assert.ok(Math.abs(resultB.detectedPowerMw - 1) < 1e-8, 'phase tuning should preserve total power in lossless MZI'); + +console.log('physics tests passed'); diff --git a/package.json b/package.json index 16a1537..76e1e60 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,15 @@ "name": "picsetup", "version": "0.0.0", "private": true, + "type": "module", "description": "Preliminary Setup Universe wrapper for PicSetup.", "homepage": "https://picsetup.com/", "scripts": { + "app:test": "node --test app/tests/*.test.mjs", + "app:check": "for file in app/src/*.js app/integrations/opticalsetup/*.js; do node --check \"$file\" || exit 1; done", + "app:build": "python3 app/build_single.py", + "app:validate": "npm run app:check && npm run app:test && npm run app:build", + "app:serve": "python3 -m http.server 4174 --directory app", "prototype:test": "npm --prefix prototype test", "prototype:check": "npm --prefix prototype run check", "prototype:serve": "npm --prefix prototype run serve" From 6415f1c4e029222a19c54a6c83b6b4aa37ef10f2 Mon Sep 17 00:00:00 2001 From: bertona88 <87537585+bertona88@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:01:16 +0200 Subject: [PATCH 2/3] ci: deploy greenfield PicSetup app --- .github/workflows/pages.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c610917..27f7e8b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,4 +1,4 @@ -name: Deploy prototype to GitHub Pages +name: Deploy PicSetup to GitHub Pages on: push: @@ -28,10 +28,18 @@ jobs: - name: Configure Pages uses: actions/configure-pages@v5 - - name: Upload prototype + - name: Stage PicSetup app + run: | + mkdir -p _site/src _site/integrations/opticalsetup + cp app/index.html app/styles.css app/icon.svg app/manifest.webmanifest app/sw.js _site/ + cp app/src/*.js _site/src/ + cp app/integrations/opticalsetup/receiver-adapter.js _site/integrations/opticalsetup/ + cp prototype/robots.txt _site/robots.txt + + - name: Upload static site uses: actions/upload-pages-artifact@v3 with: - path: prototype + path: _site - name: Deploy id: deployment From d4c9fcacb4087d387b2264699dfdd7a9e5371470 Mon Sep 17 00:00:00 2001 From: bertona88 <87537585+bertona88@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:01:46 +0200 Subject: [PATCH 3/3] docs: record successor deployment authorization --- DEPLOYMENT_AUTHORIZATION.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 DEPLOYMENT_AUTHORIZATION.md diff --git a/DEPLOYMENT_AUTHORIZATION.md b/DEPLOYMENT_AUTHORIZATION.md new file mode 100644 index 0000000..306366f --- /dev/null +++ b/DEPLOYMENT_AUTHORIZATION.md @@ -0,0 +1,5 @@ +# Deployment authorization + +On 2026-07-28, the repository owner authorized merging pull request #2 and replacing the GitHub Pages deployment artifact with the greenfield PicSetup app under `app/`. + +The immutable `prototype/` snapshot remains preserved in the repository and is no longer the Pages publication source after this change.