diff --git a/.gitattributes b/.gitattributes
index 785e55a2..5195bf7b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,7 @@
# Historical documents are byte fixtures; checkout must preserve their hashes.
tests/fixtures/document-v7/*.chemvas -text
tests/fixtures/document-v8/*.chemvas -text
+
+# Contract snapshot assets are pinned to exact upstream bytes; line-ending
+# conversion would invalidate the SHA-256 provenance digests.
+contracts/machine-observation/** -text
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7e43cbc8..adf89a3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,7 +33,6 @@ jobs:
python-version: ["3.12", "3.13"]
env:
QT_QPA_PLATFORM: offscreen
- FACTORY_MACHINE_CONTRACT_VALIDATOR: ${{ github.workspace }}/.machine-contracts/scripts/validate.py
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -63,14 +62,6 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- - name: Check out machine observation contract
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- repository: dhsohn/machine-contracts
- ref: 38581a7737cd0521bb3d50b59115eefe3c9254ca
- path: .machine-contracts
- persist-credentials: false
-
- name: Lint
run: python -m ruff check .
@@ -163,11 +154,15 @@ jobs:
tests/test_windows_installer.py \
tests/test_application_cli.py
+ - name: Compare browser font sizes with the Windows font engine
+ shell: bash
+ env:
+ QT_QPA_PLATFORM: windows
+ run: python -m pytest -q tests/test_web_adapter.py -k browser_font_pixels_match_pinned_native_font
+
rdkit-smoke:
name: RDKit smoke tests
runs-on: ubuntu-latest
- env:
- FACTORY_MACHINE_CONTRACT_VALIDATOR: ${{ github.workspace }}/.machine-contracts/scripts/validate.py
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -188,16 +183,6 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -e ".[dev,rdkit]"
- - name: Check out machine observation contract
- # Real-RDKit pack-step tests validate their machine.json against the
- # same pinned contract as the main test job.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- repository: dhsohn/machine-contracts
- ref: 38581a7737cd0521bb3d50b59115eefe3c9254ca
- path: .machine-contracts
- persist-credentials: false
-
- name: Verify RDKit import
run: |
python - <<'PY'
@@ -235,7 +220,8 @@ jobs:
tests/test_rdkit_hypercoordinate_geometry.py \
tests/test_macrocycle_depiction_evaluation.py \
tests/test_rdkit_real_use.py \
- tests/test_rdkit_smiles_hydrogens.py
+ tests/test_rdkit_smiles_hydrogens.py \
+ tests/test_web_adapter.py
- name: Report coverage
if: ${{ !cancelled() && steps.tests.outcome != 'skipped' }}
diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml
index e7983421..e99d8b93 100644
--- a/.github/workflows/platform.yml
+++ b/.github/workflows/platform.yml
@@ -35,15 +35,7 @@ jobs:
- name: Install Python dependencies
run: python -m pip install -e ".[dev]"
- - name: Check out machine observation contract
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- repository: dhsohn/machine-contracts
- ref: 38581a7737cd0521bb3d50b59115eefe3c9254ca
- path: .machine-contracts
- persist-credentials: false
-
- name: Run the host platform gate
shell: bash
run: |
- FACTORY_MACHINE_CONTRACT_REPO="$PWD/.machine-contracts" bash scripts/check.sh
+ bash scripts/check.sh
diff --git a/AGENTS.md b/AGENTS.md
index 537e55c5..3993c149 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,8 +1,9 @@
# 작업자 진입점 — Chemvas
-공장 공통 규칙은 `~/manual/AGENTS.md`가 지정하는 순서를 따른다(`FACTORY_MANUAL.md` →
-자기 사이트 장부 → 이 파일). 여기에는 그 규칙을 복제하지 않고, **이 레포에만 해당하는
-운영 사실**만 둔다.
+공장 작업자는 `~/manual/AGENTS.md`가 지정하는 순서를 따른다(`FACTORY_MANUAL.md` →
+자기 사이트 장부 → 이 파일). 일반 기여자는 이 레포만으로 개발·검증할 수 있으며
+[CONTRIBUTING.md](CONTRIBUTING.md)가 안내한다. 여기에는 공장 규칙을 복제하지 않고,
+**이 레포에만 해당하는 운영 사실**만 둔다.
## 이 레포가 무엇인가
@@ -51,9 +52,9 @@ worktree에서도 준비 없이 돌고, 맞는 인터프리터가 없거나 `.ve
`.venv`를 지우거나 `PYTHON_BIN`을 지정한다. 테스트는 `PYTHONPATH=app`으로 설치본이
아닌 이 체크아웃의 코드를 쓴다. Qt가 모듈 간에 완전히 리셋되지 않는 전역 상태를 유지하므로, 전체를
한 프로세스에 몰아넣은 실행은 통과해도 CI를 대표하지 않는다 — 이 루프가 게이트다.
-`machine.json` 적합성 검증은 게이트가 `~/machine_contracts`의 정본 validator를 직접
-연결한다(`FACTORY_MACHINE_CONTRACT_REPO`로 위치 변경 가능). 해당 테스트를 직접 돌릴
-때도 validator 경로가 없으면 검증 없이 성공하지 않고 실패한다. 만진 파일만 좁혀 돌리려면:
+`machine.json` 적합성 검증은 `contracts/machine-observation/`의 프로젝트 로컬
+validator를 직접 사용한다. 외부 클론이나 환경변수 없이 동작하며, validator가 없으면
+검증 없이 성공하지 않고 실패한다. 만진 파일만 좁혀 돌리려면:
```bash
bash scripts/check.sh tests/test_.py
@@ -70,10 +71,14 @@ macOS와 네이티브 Windows에서만 지원하며, 기존 편집·텍스트·
| 모듈 경계·리팩터링·테스트 관례 | [CONTRIBUTING.md](CONTRIBUTING.md) 및 [ADR 0005](docs/adr/0005-responsibility-based-editor-boundaries.md) — 구조 변경 전 필독. 구조 검사는 소유권과 의존 경계 계약을 보호한다 |
| 설계 결정 기록 — 공개 계약·메이저 버전·기능 제거·상태 소유권 이동·외부 동작 의존은 같은 PR에 ADR을 쓴다 | [ADR 안내](docs/adr/README.md) |
| 릴리스 절차 | [RELEASING.md](RELEASING.md) |
-| `machine.json` 공통 봉투 | `~/machine_contracts`의 `COMPATIBILITY.md`(v1 동결) |
+| `machine.json` 공통 봉투 | `contracts/machine-observation/NOTICE.md`(v1 동결, 프로젝트 로컬 스냅샷) |
-`machine.json` 표면을 바꾸려면 `machine-contracts`에 먼저 랜딩·릴리스하고, 이 레포 CI의
-pin(`.github/workflows/ci.yml`의 `ref:`)을 의도적으로 전진시킨다.
+`machine.json` 봉투 표면 변경은 이 프로젝트의 메인테이너가 소유한다. 변경 시
+출처·타입·스키마/의미 회귀 커버리지를 기록하고, 공개 계약 변경에는 ADR과 버전 관리를
+적용한다. upstream `machine-contracts`의 새 핀을 동기화하는 것은 선택적 호환 경로이며,
+필수 선행 조건이 아니다. 동기화할 경우 `NOTICE.md`의 커밋 SHA, `PROVENANCE.json`의
+해시, `tests/test_contract_compatibility.py`의 `PIN_HASHES`·`REGISTRY_HASH`를
+함께 전진시키고, 복사한 파일의 원본 스키마/의미 호환성과 출처를 보존한다.
## `make check`가 흡수하지 못하는 것
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2069d4a8..49c0f9a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,193 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- CDXML export (File > Export Figure, `.cdxml`; CLI `render-document --output
+ .cdxml`). Editable ChemDraw XML with native fragments, bonds (single,
+ double center, triple), per-run styled text, lines, arrows as grouped line
+ components, and rect/circle shapes. Unsupported constructs refuse before
+ any file is written. Not verified in ChemDraw; see `docs/CDXML_EXPORT.md`.
+- File > Export MOL in the browser downloads the selected structure as an MDL
+ Molfile through the desktop's selected-only payload and the same V2000 writer
+ and optional RDKit abbreviation fallback, without changing the document.
+- View > Valence Checking in the browser underlines the same overvalent atoms as
+ the desktop from its shared warning policy, as a view-only switch that is on
+ for each opened drawing and never changes, blocks or exports the document.
+ It draws only the warnings in view, at most 50,000 zigzag points per view.
+- The browser's SMILES field previews and inserts structures through the existing
+ native placement planner and committer, without importing Qt. Click to place
+ once or press Escape to cancel; insertion has one Undo/Redo step. RDKit remains
+ optional, and the same unsupported-chemistry checks apply in both adapters.
+- Chemical Copy, Cut and Paste in the browser adapter reuse the desktop's v3
+ selection payload (`chemvas-selection`), selection builder, validator and
+ paste planning without a parallel schema. Pasting applies atom ID remapping,
+ cascade offsets, annotation and group preservation, image budgets and
+ perspective reprojection in a single Undo/Redo step. Selections travel as
+ system clipboard text where browser permissions allow, falling back to a
+ validated copy kept in the current window with an explicit notice. Direct OS
+ clipboard exchange between native Qt and web is not implemented.
+- Automatic recovery drafts for unsaved browser documents in Qt-free
+ `bootstrap/web_drafts.py`, stored in per-user app data or a custom
+ `--drafts-dir` folder. An OS process lock restricts folder ownership to a
+ single running server, while a concurrent second server runs with recovery
+ disabled. Each unsaved document maintains a stable random draft ID with atomic
+ complete `.chemvas` payload envelopes, surviving reloads and server restarts
+ across port changes. File > Recover Unsaved Work allows explicit recovery or
+ discard without multiplying files, requiring confirmation to take over an open
+ window. Recovered drawings remain unsaved, and downloading Save does not clear
+ dirty state or remove drafts; replacement or undo to a clean baseline removes
+ the draft. Up to 16 drafts (96 MiB + 64 KiB each) are retained without age-based
+ deletion.
+
+- Documents with images open editable in the browser. Images select, move,
+ delete, rotate and flip with their pixels upright, stack in front of or
+ behind shapes, align and group as on the desktop. Insert Image places a PNG
+ or JPEG the desktop's way and Image Properties edits its box, aspect lock and
+ opacity. Image bytes are loaded once per image instead of travelling with
+ every edit.
+- Grouped documents open editable in the browser. Edit > Group and Ungroup
+ (Ctrl+G, Ctrl+Shift+G) follow the desktop's rules, clicking or area-selecting
+ a member selects its whole group with the dashed group box, Shift-click
+ toggles a group as one unit, and Align and Distribute treat a group as one
+ object. Connecting two groups' molecules is refused with the desktop's
+ message, and a bond to an ungrouped molecule brings it into the group.
+- Perspective documents open editable in the browser. Ring double bonds follow
+ the stored 3D view, and moving, rotating, flipping or rescaling the drawing
+ keeps the stored depth as the desktop does. The Perspective Rotation tool is
+ not in the browser yet.
+- The browser Ring page offers the desktop's eight ring templates. The Ring
+ tool starts on benzene and places the chosen template free-standing or fused
+ to the atom or bond under the pointer, through the same template planning,
+ geometry and commit code the desktop uses, with the desktop's grey hover
+ preview of where it will go.
+- Outward and either double bonds display in the browser, so no bond style
+ keeps a document read-only, and right-clicking a double bond offers the
+ desktop's Inward, Centered and Outward positions.
+- Text notes open editable in the browser. Their saved rich text renders with
+ the desktop's document font, line spacing, alignment, paragraph margins,
+ sub/superscripts and note box; notes select, move, delete, rotate, flip and
+ align with the desktop's rules. The Text tool creates and edits notes in
+ place, keeping their formatting, saving once when editing ends and removing
+ a note that was emptied. The desktop's Text page steps font sizes and
+ toggles bold, italic, superscript, subscript and alignment for the selected
+ text, for the text typed next at a caret (a caret format alone changes
+ nothing), or for each selected note as one change, and the Color tool recolors
+ whole notes with the rest of a selection. Notes with lists or non-point font
+ sizes stay read-only.
+- Draw and edit all eight bracket kinds in the browser: the Bracket tool
+ previews a drag in the desktop's translucent grey and places the default size
+ on a click; brackets select, move, delete, rotate, flip and align with the
+ desktop's outline bounds, and the Color tool shows the desktop notice instead
+ of recoloring them. Strokes and dagger glyphs come from the same path and
+ layout code the desktop draws with.
+- Shift+T, Shift+G and Shift+E switch to the Bracket, Orbital and Mark tools
+ with their default kind in the browser, as on the desktop; the arrow hotkey
+ also resets the arrow kind.
+- Create and edit all eight orbital types in the browser using the desktop
+ lobe geometry, phase colors, movement, rotation and scale rules. Orbital
+ edits use the existing document history and `.chemvas` file format.
+- Changing bond length now refreshes desktop orbital lobes and handle distances
+ using the existing renderer. The visible size agrees with saving and reopening
+ the drawing, including after Undo/Redo.
+
+- Imported charge and radical marks now appear in the browser using native
+ attachment coordinates, sizes and label layout. Their hit shapes and selection
+ circles now use native geometry and mark/atom priority. The Mark toolbar creates
+ all five native kinds; consistent marked documents support editing and explicit
+ owner reassignment from the mark context menu, with preview and Undo/Redo.
+ Charge shortcuts share native opposite-mark cancellation and collision-free
+ compass placement. Mark hover previews and distant-owner guides are not connected yet.
+
+- Fixed macOS Ctrl-click editing before the mark context menu, handle priority
+ over marks, and excessive scrolling in the owner-candidate preview.
+- When all 16 browser sessions are in use, sessions idle for 30 minutes are
+ closed to make room, so a crashed tab no longer blocks new windows until the
+ server restarts.
+- The browser shows a mark's ownership guidance as its tooltip only while the
+ owner guide is drawn, as the desktop does; other marks show the owner name.
+
+- Browser bond-length changes now rescale molecular geometry and ring fills about
+ the atom center, matching the native command with one-step Undo/Redo.
+
+- Browser grid controls now connect native square/hex geometry, density threshold,
+ strengths and arrow/line endpoint snapping without adding document history.
+
+- Browser Canvas Size now shares native paper sizes, orientation and custom
+ dimension rules, preserving drawing coordinates with single-command Undo/Redo.
+
+- Browser alignment and equal-gap distribution now use native object grouping
+ and rectangle calculations. Partial molecule selections move whole structures;
+ original context icons and Edit submenus are connected.
+
+- Browser horizontal and vertical flip now connect the original selection pivot,
+ point reflection and annotation transforms, including equilibrium labels and
+ arc handedness. Native context icons, menu actions and shortcuts are connected.
+
+- Browser selection frames and rotation knobs now use native eligibility, label
+ bounds, screen sizes and drag-angle/Shift rules. Drag previews keep the original
+ press state and commit one Undo entry; cancellation leaves the document intact.
+ Arrow frame stroke bounds retain small Qt-specific approximation differences.
+
+- Browser plain atom labels and arrow labels now derive document line height
+ before small CSS-box rounding, correcting size-dependent vertical offsets.
+
+- Browser Select now connects the native numeric rotation command for atoms,
+ bonds, ring fills, arrows, lines and shapes, with the original pivot and
+ single-command Undo/Redo. The angle control shares Qt's range and default.
+
+- Browser atom selection uses native label layout bounds and margins rather than
+ glyph ink bounds, restoring long-label boxes and the compact-circle threshold.
+
+- Browser shape selection now uses native paths and padding with outlines above
+ content. Qt borderless rectangle selection no longer acquires interior holes
+ from an inherited odd-even fill rule.
+
+- Molecular selection now reuses native bond bands, atom indicators and connected
+ components. Revision-bound queries and candidate previews supply browser
+ outlines; provisional filled atom circles and bond bands are removed.
+- Browser arrow/line selection now displays native-width outlines with separate
+ head/stem boundaries and a constant screen width, replacing filled bands.
+- Unsupported browser area-selection APIs now produce an explicit notice and
+ restore the prior selection instead of throwing during a drag. Firefox area
+ selection remains unavailable; Shift-click and Select All remain usable.
+- Browser presentation adapter selected with `chemvas --ui web`, alongside
+ `chemvas --ui qt`. Existing Chemvas services own bond and benzene construction,
+ attachment/fusion, deletion, bond styles, atom labels/merging, selected-atom/bond movement and Undo/Redo.
+ Shift-click and Select All support multiple selection and single-command deletion. Browser controls reuse
+ desktop declarations and artwork; `.chemvas` copies reopen in Qt. Unconnected
+ actions remain disabled and unsupported content opens read-only.
+ Dotted bonds reuse native dot geometry and double-bond overlay rules. Invalid
+ edit coordinates/styles are rejected, and failed initial requests no longer
+ consume browser session slots. Context options match the desktop white checked
+ boxes; bond gestures reuse native coordinate picking and release-to-bond snapping.
+ Atom input follows native hover priority, and label click targets use measured
+ ink bounds and compact-label anchor circles from the native predicate.
+ Enter/Delete and atom/growth keys also recognize the full label hit shape. Session font measurements now feed
+ the native renderer directly; known labels need no second drawing request,
+ and previews send edits instead of uploading the document again.
+ Glyph measurement excludes faint antialias fringes and stays inside the font
+ engine's ink bounds, reducing browser/native label-boundary differences.
+ Focused browser controls retain Enter/Space activation while the pointer is
+ over the canvas; pointer presses on toolbar buttons preserve canvas focus.
+ Arrow and line previews now use the native path calculations for all 19 kinds,
+ including equilibrium, circular arcs, quadratic curves, inhibition and wavy lines.
+ Unlabelled arrows and lines now use the native move controller and deletion
+ planner for selection, dragging, mixed deletion and Undo/Redo. Arrow creation
+ now uses the native endpoint snapping, curved control and Shift arc mirroring,
+ with desktop arrow options and icons. Line creation, arrow style controls,
+ endpoint handles and label editing are connected.
+ Selection and eraser now resolve structure targets on the server with native
+ priorities and near-bond distance, replacing the fixed browser bond hit stroke.
+ Pending selection handles release and cancellation before the response arrives.
+ Ring Fill reuses native complete-ring detection and pastel colors. Transparent
+ ring interiors support selection, movement, Color and fill-only erasing;
+ equal-distance bond hits retain native grid order.
+ Empty-canvas drags now select intersecting SVG item shapes, including transparent
+ interiors, with additive selection and cancellation of delayed picks. Selection
+ creates no history command; later edits reuse existing movement and deletion.
+ See [adapter usage and current connections](docs/WEB_ADAPTER.md).
+
### Changed
- Rewrite both READMEs around reproducible publication schemes and checked
@@ -15,6 +202,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The examples line no longer states a document version the sample files do
not have.
+### Fixed
+
+- Saving via a case-variant alias of the current path (e.g. `DRAWING.chemvas`
+ when the document was opened as `drawing.chemvas`) now triggers the
+ external-change SHA guard on case-insensitive volumes instead of skipping
+ the external-change warning and overwriting externally modified bytes.
+- The desktop status bar names the eraser "Eraser", as its toolbar button
+ does, instead of "Delete".
+- The browser status bar, window title and new canvas names follow the
+ desktop: tool names and hints (including the Ring Fill and chosen-color
+ hints), the unsaved marker and file name, and numbered "Canvas N" names.
+ Toolbar clicks reset Bond and Mark to their defaults, and the atom label
+ prompt no longer carries the context field's length limit.
+- Browser Fit to Window, the bond length field and the Text page size limits
+ use the desktop's declarations; hover shortcuts and hover delete ignore the
+ structure under a mark and take the nearest ring atom inside a ring fill;
+ and the hidden-carbon Color notice ignores uncolored orbitals, as on the
+ desktop.
+- Browser keyboard shortcuts follow the desktop's declarations: Control with
+ +, =, -, _ or 0 zooms, F5-F8 act only without modifiers, Alt+arrows turn and
+ Shift+arrows move the selection by the desktop's steps, and Control+Y redoes
+ where Control is the command key. Control-clicking an unselected object or
+ arrow toggles it into the selection instead of replacing the selection.
+- Browser context bar pages use the desktop's captions and control spacing.
+- Browser arrow sliders widen to a loaded setting beyond their default range
+ instead of clamping it, and the arrow label preview uses the interface font
+ on the paper surface at the desktop dialog's shared size.
+- A new browser canvas continues the active drawing's bond length, sheet,
+ arrow, orbital phase and text/note settings, as a new desktop canvas does.
+- Documents whose atom charge or radical annotations differ from their marks
+ open editable in the browser; as on the desktop, an atom's annotation is
+ resynchronized only when its marks change. (The model has no isotopes.)
+- Documents with a calculation plan open editable in the browser. An edit
+ that breaks the plan's component references leaves the plan out of that
+ version with the desktop's save warning, and Undo restores it.
+- An inward ring double bond no longer flips outside its benzene ring when
+ dotted forming bonds close a second ring through the same edge, as in
+ transition-state drawings; the edge keeps the ring made of full bonds. This
+ applies to the desktop and the browser, which share the ring index.
+
## [0.23.0] - 2026-09-28
### Added
diff --git a/CONTRIBUTING.ko.md b/CONTRIBUTING.ko.md
index f874c276..09e99153 100644
--- a/CONTRIBUTING.ko.md
+++ b/CONTRIBUTING.ko.md
@@ -22,13 +22,8 @@ python -m pip install -e ".[dev]" # dev tooling
python -m pip install -e ".[dev,rdkit]" # also enable RDKit features
```
-전체 검증 게이트를 실행하려면 공용 `machine.json` 계약 검증기가 필요합니다:
-
-```bash
-git clone https://github.com/dhsohn/machine-contracts.git ~/machine_contracts
-```
-
-다른 경로에 클론한 경우 `FACTORY_MACHINE_CONTRACT_REPO` 환경변수에 해당 경로를 설정하세요.
+`machine.json` 계약 검증기는 `contracts/machine-observation/`에 프로젝트 로컬
+스냅샷으로 포함되어 있으므로 별도 클론이나 설정이 필요 없습니다.
소스 코드에서 앱을 실행하려면:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f44b1ecc..9a5b59ae 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,13 +22,8 @@ python -m pip install -e ".[dev]" # dev tooling
python -m pip install -e ".[dev,rdkit]" # also enable RDKit features
```
-The shared `machine.json` contract validator is required for full validation:
-
-```bash
-git clone https://github.com/dhsohn/machine-contracts.git ~/machine_contracts
-```
-
-If cloned to a custom location, set `FACTORY_MACHINE_CONTRACT_REPO` to point to that directory.
+The `machine.json` contract validator ships as a project-local snapshot in
+`contracts/machine-observation/` and needs no separate clone or setup.
Launch the app from source:
diff --git a/README.ko.md b/README.ko.md
index adc805a2..3d33525e 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -17,7 +17,7 @@ Chemvas는 **다시 만들 수 있는 반응식 그림**을 위한 오픈소스
**재현 가능한 논문용 반응식**
-- `render-document`는 창을 띄우지 않고 SVG, PDF, PNG를 84 mm나 174 mm 같은 단 너비에 맞춰 내보냅니다.
+- `render-document`는 창을 띄우지 않고 SVG, PDF, PNG, [CDXML](https://github.com/dhsohn/Chemvas/blob/main/docs/CDXML_EXPORT.ko.md)을 84 mm나 174 mm 같은 단 너비에 맞춰 내보냅니다.
- `check-layout`은 내보내기 전에 흔한 라벨 충돌과 용지 밖으로 나간 요소를 알려 줍니다. 모든 객체 쌍을 검사하지는 않습니다.
- [논문 그림 작성 예제](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.ko.md)는 결합 길이와 글자 크기를 고정한 스크립트로 완성 그림을 만들어, 한 원고의 모든 반응식이 같은 축척을 갖게 합니다.
@@ -81,6 +81,10 @@ chemvas render-document revised.chemvas --output scheme.svg --width-mm 174
## 문서 및 가이드
+- [브라우저 어댑터](https://github.com/dhsohn/Chemvas/blob/main/docs/WEB_ADAPTER.ko.md): `chemvas --ui web`으로 Qt 앱과 함께 실행한다.
+ 기본 편집과 사본 저장을 제공하며, 논문용 출력과 전체 데스크톱 사용성은 아직
+ 동등하지 않다.
+
- [헤드리스 & 에이전트 CLI](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.ko.md) · [논문 그림 작성 예제](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.ko.md) · [반응 도식 배치](https://github.com/dhsohn/Chemvas/blob/main/docs/SCHEME_LAYOUT.ko.md)
- [그리기 도구 및 단축키](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.ko.md) · [화학 입출력](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.ko.md#화학-입출력) · [이미지 객체](https://github.com/dhsohn/Chemvas/blob/main/docs/IMAGE_OBJECTS.ko.md) · [문서 호환성 정책](https://github.com/dhsohn/Chemvas/blob/main/docs/DOCUMENT_COMPATIBILITY.ko.md)
- [Reaction Mapping](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.ko.md#데스크톱-반응-매핑): 2D 캔버스에서 반응물·생성물 원자를 대응시키고 결합 변화를 검토합니다. `.chemvas`에 저장해 AI나 협업자에게 반응을 설명할 수 있으며, 선택적인 RDKit 계산 구조 내보내기는 `machine.json`과 XYZ를 생성합니다.
@@ -102,7 +106,8 @@ chemvas render-document revised.chemvas --output scheme.svg --width-mm 174
- `make check`가 lint, 포맷, 타입 검사를 실행한 뒤, Qt 상태가 다음 파일로 새지 않도록 테스트
파일마다 별도 프로세스로 실행합니다. CI도 같은 방식으로 파일별로 실행합니다.
-- `machine.json` 출력은 공통 [machine-contracts](https://github.com/dhsohn/machine-contracts)
- validator로 검증합니다. validator가 없으면 조용히 통과하지 않고 실패합니다.
+- `machine.json` 출력은 `contracts/machine-observation/`의 프로젝트 로컬 계약 스냅샷으로
+ 검증합니다([machine-contracts](https://github.com/dhsohn/machine-contracts) v1 봉투 기반).
+ validator가 없으면 조용히 통과하지 않고 실패합니다.
- 문서 형식, 실행 취소와 롤백, 그림 출력처럼 영향이 큰 변경은 별도 에이전트가 독립적으로
적대적 리뷰를 합니다.
diff --git a/README.md b/README.md
index ffadb619..18236f82 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ Chemvas is an open-source drawing tool for **reaction schemes that you can rebui
**Reproducible publication schemes**
-- `render-document` exports SVG, PDF and PNG without opening a window, fitted to a column width such as 84 mm or 174 mm.
+- `render-document` exports SVG, PDF, PNG and [CDXML](https://github.com/dhsohn/Chemvas/blob/main/docs/CDXML_EXPORT.md) without opening a window, fitted to a column width such as 84 mm or 174 mm.
- `check-layout` reports common label collisions and content outside the sheet before you export. It does not check every pair of objects.
- The [publication recipes](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.md) build finished figures from a script with a fixed bond length and font size, so every scheme in a manuscript has the same scale.
@@ -81,6 +81,10 @@ For detailed instructions and example files, see the [step-by-step guide](https:
## Documentation
+- [Browser adapter](https://github.com/dhsohn/Chemvas/blob/main/docs/WEB_ADAPTER.md): run `chemvas --ui web` alongside
+ the Qt app. Basic editing and saved copies are available; publication rendering
+ and full desktop workflow parity are still in development.
+
- [Headless & Agent CLI](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.md) · [Publication Schemes](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.md) · [Scheme Layout](https://github.com/dhsohn/Chemvas/blob/main/docs/SCHEME_LAYOUT.md)
- [Drawing Tools & Shortcuts](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.md) · [Chemistry I/O](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.md#chemistry-io) · [Image Objects](https://github.com/dhsohn/Chemvas/blob/main/docs/IMAGE_OBJECTS.md) · [Document compatibility](https://github.com/dhsohn/Chemvas/blob/main/docs/DOCUMENT_COMPATIBILITY.md)
- [Reaction Mapping](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.md#desktop-reaction-mapping): Map reactant and product atoms on the 2D canvas, review bond changes, and save the correspondence in `.chemvas` for an AI assistant or collaborator to interpret. Optional RDKit geometry handoff exports `machine.json` and XYZ.
@@ -104,8 +108,9 @@ agent's report that it works:
- `make check` runs lint, formatting and type checks, then runs each test file in its
own process so Qt state cannot leak from one file into the next. CI runs the same
per-file suite.
-- `machine.json` output is validated against the shared
- [machine-contracts](https://github.com/dhsohn/machine-contracts) validator. When the
+- `machine.json` output is validated against the project-local contract snapshot in
+ `contracts/machine-observation/`, derived from the
+ [machine-contracts](https://github.com/dhsohn/machine-contracts) v1 envelope. When the
validator is missing, the check fails instead of passing silently.
- High-impact changes, such as the document format, undo and rollback, and figure
export, get an independent adversarial review from a separate agent.
diff --git a/app/chemvas/adapters/qt/renderer.py b/app/chemvas/adapters/qt/renderer.py
index 859953d4..50b8888b 100644
--- a/app/chemvas/adapters/qt/renderer.py
+++ b/app/chemvas/adapters/qt/renderer.py
@@ -1,45 +1,12 @@
from __future__ import annotations
-from dataclasses import replace
-
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QBrush, QColor, QFont, QPen
-from chemvas.features.rendering import ACS1996Style
-
-
-class Renderer:
- BASE_BOND_LENGTH_PX = 20.0
-
- def __init__(self, style: ACS1996Style | None = None) -> None:
- self.style = style or ACS1996Style()
-
- def set_bond_length(self, length_px: float) -> None:
- self.style = replace(self.style, bond_length_px=length_px)
-
- def metric_scale(self) -> float:
- if self.style.bond_length_px <= 0:
- return 1.0
- return self.style.bond_length_px / self.BASE_BOND_LENGTH_PX
-
- def scaled_style_metric(self, value: float) -> float:
- return value * self.metric_scale()
-
- def bond_line_width(self) -> float:
- return self.scaled_style_metric(self.style.bond_line_width)
-
- def bold_bond_width(self) -> float:
- return self.scaled_style_metric(self.style.bold_bond_width)
-
- def bond_spacing(self) -> float:
- return self.scaled_style_metric(self.style.bond_spacing_px)
-
- def hash_spacing(self) -> float:
- return self.scaled_style_metric(self.style.hash_spacing_px)
+from chemvas.features.rendering import RenderMetrics
- def atom_font_size_pt(self) -> int:
- return max(1, round(self.scaled_style_metric(float(self.style.font_size_pt))))
+class Renderer(RenderMetrics):
def bond_pen(self) -> QPen:
pen = QPen(QColor(self.style.bond_color))
pen.setWidthF(self.bond_line_width())
diff --git a/app/chemvas/bootstrap/application.py b/app/chemvas/bootstrap/application.py
index 7db86638..218a5782 100644
--- a/app/chemvas/bootstrap/application.py
+++ b/app/chemvas/bootstrap/application.py
@@ -77,6 +77,7 @@ def _root_help() -> str:
" chemvas [document]\n"
" chemvas [options]\n\n"
"Run with no arguments to launch the desktop app.\n"
+ "Select an adapter with --ui qt or --ui web (experimental).\n"
"Pass a .chemvas, .svg, or .mol document to open it at startup.\n\n"
"Options:\n"
" -h, --help show this help message and exit\n"
@@ -210,6 +211,18 @@ def _filtered_stderr(stderr_fd: int = 2, platform: str | None = None) -> Iterato
def main() -> None:
+ # Select the presentation adapter before importing Qt or starting its event loop.
+ if len(sys.argv) > 2 and sys.argv[1] == "--ui":
+ adapter = sys.argv[2]
+ if adapter == "web":
+ from chemvas.bootstrap.web_adapter import main as web_main
+
+ web_main(sys.argv[3:])
+ return
+ if adapter == "qt":
+ sys.argv = [sys.argv[0], *sys.argv[3:]]
+ else:
+ _reject_startup_argument(adapter)
# The Windows GUI bootloader has no standard streams. Keep command-line
# operations on the console companion rather than losing their reports.
if (
diff --git a/app/chemvas/bootstrap/document_render.py b/app/chemvas/bootstrap/document_render.py
index cf23a110..43f9cf6b 100644
--- a/app/chemvas/bootstrap/document_render.py
+++ b/app/chemvas/bootstrap/document_render.py
@@ -72,13 +72,13 @@ def _argument_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True)
render_parser = subparsers.add_parser(
"render-document",
- help="render a .chemvas document to a new SVG, PDF, or PNG file",
+ help="render a .chemvas document to a new SVG, PDF, PNG, or CDXML file",
)
render_parser.add_argument("document", help="input .chemvas document")
render_parser.add_argument(
"--output",
required=True,
- help="new non-overwriting .svg, .pdf, or .png output path",
+ help="new non-overwriting .svg, .pdf, .png, or .cdxml output path",
)
render_parser.add_argument(
"--background",
@@ -132,7 +132,7 @@ def _render_document(
min_font_pt: float | None = None,
) -> dict[str, object]:
output_format = _validate_paths(source, output)
- if output_format == "pdf" and min_font_pt is not None:
+ if output_format in {"pdf", "cdxml"} and min_font_pt is not None:
raise ValueError("--min-font-pt supports SVG and PNG output only")
document = read_exact_document(source, max_bytes=MAX_DOCUMENT_BYTES).document
state = cast("Mapping[str, object]", document.state)
@@ -184,8 +184,10 @@ def _validate_paths(source: Path, output: Path) -> str:
if not source.is_file():
raise ValueError(f"input document does not exist: {source}")
output_format = output.suffix.lower().removeprefix(".")
- if output_format not in {"svg", "pdf", "png"}:
- raise ValueError("output must use the .svg, .pdf, or .png filename extension")
+ if output_format not in {"svg", "pdf", "png", "cdxml"}:
+ raise ValueError(
+ "output must use the .svg, .pdf, .png, or .cdxml filename extension"
+ )
if output.exists() or output.is_symlink():
raise ValueError(f"output path already exists: {output}")
if not output.parent.is_dir():
@@ -206,7 +208,10 @@ def _render_offscreen(
with offscreen_document_scene(state, command="render-document") as context:
from chemvas.ui.export.figure_export_service import FigureExportService
- service = FigureExportService(context)
+ groups = (
+ _restore_cli_groups(state, context) if output_format == "cdxml" else None
+ )
+ service = FigureExportService(context, groups=groups)
with tempfile.TemporaryDirectory(prefix="chemvas-render-document-") as raw_tmp:
rendered_path = Path(raw_tmp) / f"rendered.{output_format}"
plan = service.export_figure(
@@ -225,36 +230,41 @@ def _render_offscreen(
f"rendered output exceeds the {MAX_OUTPUT_BYTES}-byte limit"
)
content = rendered_path.read_bytes()
- # The exporter checked all limits before painting, including native PDF
- # height rounding. Reuse its exact plan for dimensions and readability.
- width_pixels, height_pixels = validate_export_budget(
- plan, output_format=output_format, dpi=dpi
- )
output_plan = plan
- if output_format == "pdf":
- from chemvas.ui.export.export_vector import pdf_page_size
-
- page_size = pdf_page_size(plan).sizePoints()
- output_plan = replace(
- plan,
- out_w_pt=float(page_size.width()),
- out_h_pt=float(page_size.height()),
- )
- font_readability = None
- if min_font_pt is not None:
- from chemvas.ui.export.export_readability_service import (
- assess_export_readability,
- )
+ width_pixels: int | None = None
+ height_pixels: int | None = None
+ font_readability: dict[str, object] | None = None
- font_readability = assess_export_readability(
- context,
- plan,
- minimum_font_pt=min_font_pt,
- output_format=output_format,
- dpi=dpi,
- width_pixels=width_pixels,
- height_pixels=height_pixels,
+ if output_format != "cdxml":
+ # The exporter checked all limits before painting, including native PDF
+ # height rounding. Reuse its exact plan for dimensions and readability.
+ width_pixels, height_pixels = validate_export_budget(
+ plan, output_format=output_format, dpi=dpi
)
+ if output_format == "pdf":
+ from chemvas.ui.export.export_vector import pdf_page_size
+
+ page_size = pdf_page_size(plan).sizePoints()
+ output_plan = replace(
+ plan,
+ out_w_pt=float(page_size.width()),
+ out_h_pt=float(page_size.height()),
+ )
+ if min_font_pt is not None:
+ from chemvas.ui.export.export_readability_service import (
+ assess_export_readability,
+ )
+
+ font_readability = assess_export_readability(
+ context,
+ plan,
+ minimum_font_pt=min_font_pt,
+ output_format=output_format,
+ dpi=dpi,
+ width_pixels=width_pixels,
+ height_pixels=height_pixels,
+ )
+
return _RenderedDocument(
content=content,
width_points=float(output_plan.out_w_pt),
@@ -265,6 +275,26 @@ def _render_offscreen(
)
+def _restore_cli_groups(
+ state: dict[str, Any],
+ context: Any,
+) -> dict[int, Any]:
+ from chemvas.features.groups import restored_groups
+ from chemvas.ui.canvas.canvas_document_state import GROUP_COLLECTION_STATES
+
+ records = state.get("groups") or []
+ if not records:
+ return {}
+ item_lists: dict[str, list[int]] = {}
+ for key, name in GROUP_COLLECTION_STATES.items():
+ item_lists[key] = list(context.state.document_collection(name).order)
+ groups_list = restored_groups(records, context.model.atoms, item_lists)
+ result: dict[int, Any] = {}
+ for gid, group in enumerate(groups_list, start=1):
+ result[gid] = group
+ return result
+
+
def _report_number(value: float) -> float:
return round(value, 6)
diff --git a/app/chemvas/bootstrap/web_adapter.py b/app/chemvas/bootstrap/web_adapter.py
new file mode 100644
index 00000000..7c842a1d
--- /dev/null
+++ b/app/chemvas/bootstrap/web_adapter.py
@@ -0,0 +1,7938 @@
+"""Local browser adapter over existing Chemvas UI definitions and editing services."""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import binascii
+import hashlib
+import json
+import math
+import re
+import secrets
+import subprocess
+import sys
+import tempfile
+import time
+import webbrowser
+from collections import Counter
+from contextlib import nullcontext, suppress
+from copy import deepcopy
+from dataclasses import asdict, dataclass, replace
+from decimal import Decimal
+from functools import partial
+from html import escape
+from html.parser import HTMLParser
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from itertools import pairwise
+from pathlib import Path
+from threading import RLock
+from types import SimpleNamespace
+from typing import TYPE_CHECKING, Any, cast, override
+from urllib.parse import parse_qs
+
+from chemvas.bootstrap.web_drafts import (
+ DAMAGED_DRAFT,
+ BrowserDraftStore,
+ DraftError,
+ default_drafts_root,
+ new_draft_id,
+ valid_draft_id,
+)
+from chemvas.core.history import HistoryCommand
+from chemvas.core.molfile import export_molfile_block
+from chemvas.core.rdkit_adapter import RDKitAdapter
+from chemvas.domain.document import (
+ ARROW_LABEL_SIDES,
+ CANVAS_FILE_VERSION,
+ CLIPBOARD_SELECTION_VERSION,
+ MAX_ARROW_LABEL_CHARS,
+ MAX_BOND_LENGTH_PX,
+ OPTIONAL_SETTINGS_KEYS,
+ SETTINGS_KEYS,
+ VALID_ARC_KINDS,
+ VALID_EQUILIBRIUM_KINDS,
+ VALID_MARK_KINDS,
+ AnnotationCollection,
+ Bond,
+ arrow_from_state,
+ arrow_to_state,
+ atom_shows_itself,
+ atom_to_state,
+ bond_to_state,
+ broken_ring_fill_indices,
+ build_normalized_document_payload,
+ calculation_plan_save_warning,
+ clipboard_state_document_record,
+ deserialize_model_state,
+ document_record_clipboard_state,
+ extract_document_state,
+ mirrored_arc_kind,
+ model_bond_pairs,
+ normalize_json_numbers,
+ serialize_model_state_with_warnings,
+ unmarked_isolated_carbon_ids,
+)
+from chemvas.domain.document.groups import SceneGroup
+from chemvas.domain.document.images import (
+ MAX_DOCUMENT_BYTES,
+ MAX_IMAGE_BYTES,
+ image_bytes_from_state,
+ image_state_from_bytes,
+ inserted_image_box,
+ validate_image_collection_budget,
+ validate_image_state,
+ validate_image_states,
+)
+from chemvas.domain.document.marks import (
+ Mark,
+ mark_center_coordinates,
+ mark_kinds_by_atom,
+ mark_state_at_position,
+ scaled_mark_offset,
+)
+from chemvas.domain.document.notes import Note, note_to_document_state
+from chemvas.domain.document.orbitals import (
+ Orbital,
+ orbital_from_state,
+ orbital_to_state,
+)
+from chemvas.domain.document.perspective import (
+ bond_offset_unit,
+ current_atom_coords_3d,
+ perspective_from_state,
+ project_point_3d,
+ reprojected_pasted_coords_3d,
+ rescaled_perspective,
+ ring_center_3d,
+ saved_perspective,
+ stored_coords_match_projection,
+)
+from chemvas.domain.document.ring_fills import RingFill, ring_fill_to_state
+from chemvas.domain.document.shapes import (
+ Shape,
+ moved_shape,
+ normalized_shape,
+ shape_from_state,
+ shape_to_state,
+)
+from chemvas.domain.document.sheet import (
+ CUSTOM_SHEET_SIZE,
+ MAX_SHEET_MM,
+ MIN_SHEET_MM,
+ SHEET_SIZES_MM,
+)
+from chemvas.domain.document.ts_brackets import (
+ TSBracket,
+ moved_ts_bracket,
+ normalized_ts_bracket,
+ ts_bracket_from_state,
+ ts_bracket_to_state,
+)
+from chemvas.domain.json_io import strict_json_loads
+from chemvas.domain.transactions import RestoreOutcome
+from chemvas.features.annotations import (
+ ARROW_LABEL_PREVIEW_POINT_SIZE,
+ ATOM_LABEL_DOCUMENT_MARGIN,
+ ATOM_LABEL_HIT_PADDING_RATIO,
+ BRACKET_MENU_SPECS,
+ BRACKET_SYMBOLS,
+ DEFAULT_BRACKET_KIND,
+ LABEL_SYNTAX_HINT,
+ MAX_NOTE_HTML_CHARS,
+ SAFE_NOTE_HTML_TAGS,
+ SUB_SCALE,
+ arrow_label_html,
+ arrow_label_position,
+ atom_label_presentation,
+ bracket_path_commands,
+ bracket_rect_from_points,
+ bracket_stroke_width,
+ bracket_symbol_layout,
+ cleaned_arrow_labels,
+ flip_annotation,
+ hydride_hydrogen_text,
+ label_bounding_rect,
+ mark_dimensions,
+ mirrored_box_position,
+ orbital_geometry,
+ orbited_box_position,
+ parse_atom_label,
+ place_hydride_stack,
+ place_runs,
+ rotate_annotation,
+ sanitize_note_html,
+ split_hydride_label,
+ uses_compact_label_hit_shape,
+)
+from chemvas.features.document_composition import compose_document_state
+from chemvas.features.graph import (
+ CanvasGraphState,
+ add_bond_to_atom_index,
+ bond_sets_for_atom_ids,
+ build_bond_adjacency_index,
+ build_ring_edge_index,
+ connected_components_for_nodes,
+ ring_atom_ids_for_bond,
+ selected_ring_cycles,
+)
+from chemvas.features.groups import (
+ GROUP_CONNECTION_MESSAGE,
+ GROUP_ITEM_COLLECTIONS,
+ GROUPABLE_STANDALONE_KINDS,
+ connection_allowed,
+ group_atoms_after_merge,
+ group_extensions,
+ grouping_plan,
+ growth_anchors,
+ restored_groups,
+ snapshot_groups,
+)
+from chemvas.features.hover import (
+ ATOM_HOVER_BRUSH_RGBA,
+ ATOM_HOVER_PEN_RGBA,
+ ATOM_HOVER_RADIUS_RATIO,
+ ATOM_HOVER_Z,
+ HOVER_PREVIEW_OPACITY,
+ HOVER_PREVIEW_Z,
+ PREVIEW_COLOR_RGBA,
+ PREVIEW_OPACITY,
+)
+from chemvas.features.insertion import (
+ MAX_SMILES_INPUT_LENGTH,
+ SMILES_RENDER_ERROR,
+ build_3d_conversion_payload,
+ build_atom_annotations,
+ normalized_smiles_input,
+ opposite_charge_mark,
+ plan_mark_rebind,
+ plan_smiles_commit,
+ plan_template_commit,
+ plan_template_preview,
+ plan_template_preview_update,
+ smiles_preview_center,
+)
+from chemvas.features.rendering import (
+ ENDPOINT_SNAP_SCREEN_PX,
+ LINE_ANGLE_STEP_DEGREES,
+ SNAP_MARK_PEN_SCREEN_PX,
+ SNAP_MARK_SCREEN_PX,
+ ACS1996Style,
+ RenderMetrics,
+ arrow_path_commands,
+ arrow_with_moved_endpoint,
+ clamp_curved_midpoint,
+ control_from_midpoint,
+ curved_midpoint,
+ cycle_plain_bond_style,
+ double_position_for_style,
+ endpoint_connection,
+ grid_lines,
+ is_positionable_double_bond_style,
+ line_click_endpoint,
+ line_normal,
+ new_arrow_record,
+ normalized_arrow_control,
+ overvalent_atom_ids,
+ points_on_endpoints,
+ snapped_drawing_point,
+ style_for_double_position,
+)
+from chemvas.features.selection import (
+ ARROW_PICK_SCREEN_PX,
+ DIRECT_SELECT_OBJECT_KINDS,
+ ROTATION_SNAP_STEP_DEGREES,
+ AtomHitCandidate,
+ BondHitCandidate,
+ bond_pick_candidates,
+ choose_mark_atom,
+ choose_preferred_structure_hit,
+ distance_point_to_segment,
+ independent_selection_items,
+ mark_precedes_atom,
+ nearest_atom_id,
+ nearest_bond_id,
+ nearest_ring_atom_id,
+ orbital_handle_positions,
+ orbital_rotation_angle,
+ orbital_scale_factor,
+ reflected_point,
+ rotated_atom_positions,
+ rotated_point_coordinates,
+ rotation_drag_angle,
+ selected_atom_ids_with_bond_endpoints,
+ selection_frame_applies,
+ selection_transform_center,
+)
+from chemvas.shell import toolbar_styles
+from chemvas.shell.icon_design import (
+ DESIGN_ICON_NAMES,
+ TEMPLATE_FALLBACK_ICON,
+ TEMPLATE_ICON_NAMES,
+ design_icon_svg,
+)
+from chemvas.shell.palette import PALETTE, RING_FILL_TINT, SHAPE_FILL_TINT, pastel_rgb
+from chemvas.ui.annotations.shape_geometry import (
+ EDGE_HANDLE_SCREEN_PX,
+ resized_shape_bounds,
+ shape_handle_positions,
+ shape_outline,
+ shape_rect_from_points,
+ shape_stroke_width,
+)
+from chemvas.ui.canvas.canvas_chemdraw_shortcut_service import (
+ CanvasChemdrawShortcutService,
+)
+from chemvas.ui.canvas.canvas_geometry_logic import (
+ glyph_clearance_radius,
+ glyph_contour_clip_t,
+ glyph_convex_hull,
+ mark_clearance,
+ mark_click_offset,
+ mark_target_distance,
+ shortcut_mark_offset,
+)
+from chemvas.ui.canvas.canvas_group_state import (
+ CanvasGroupState,
+ group_ids_for_members_for,
+ register_group_for,
+ restore_group_for,
+)
+from chemvas.ui.canvas.canvas_history_service import CanvasHistoryService
+from chemvas.ui.canvas.canvas_history_state import CanvasHistoryState
+from chemvas.ui.canvas.canvas_mark_registry import CanvasMarkRegistry
+from chemvas.ui.canvas.canvas_move_controller import CanvasMoveController
+from chemvas.ui.canvas.canvas_ring_fill_scene_service import rebuild_ring_fill_polygons
+from chemvas.ui.canvas.canvas_tool_settings_state import (
+ GRID_COLOR,
+ GRID_CONTROL_HINT,
+ GRID_MODES,
+ GRID_STRENGTHS,
+ MIN_GRID_SPACING_PX,
+ CanvasToolSettingsState,
+ grid_step_for,
+ normalized_arrow_style,
+)
+from chemvas.ui.canvas.pick_radius_access import (
+ STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ atom_pick_radius,
+ atom_pick_radius_for,
+)
+from chemvas.ui.canvas.sheet_setup_logic import (
+ OFF_SHEET_EDIT_GUIDANCE,
+ SHEET_DIMENSION_DECIMALS,
+ SHEET_DIMENSION_STEP_MM,
+ SHEET_ORIENTATION_OPTIONS,
+ SHEET_SETUP_TEXT,
+ normalize_sheet_setup,
+ scene_pos_in_sheet,
+ sheet_dimensions_px,
+ sheet_scene_bounds,
+ supported_sheet_sizes,
+)
+from chemvas.ui.insert.insert_mode_logic import (
+ TEMPLATE_BOND_GATE_RATIO,
+ begin_template_insert,
+ build_template_insert_request,
+)
+from chemvas.ui.insert.insert_smiles_commit_service import apply_smiles_commit_plan
+from chemvas.ui.insert.template_commit_logic import commit_template_ring
+from chemvas.ui.insert.template_geometry_resolver_service import (
+ TemplateGeometryResolverService,
+)
+from chemvas.ui.molecule.atom_label_merge_service import AtomLabelMergeService
+from chemvas.ui.molecule.atom_label_service import AtomLabelService
+from chemvas.ui.molecule.bond_geometry_plan_service import (
+ BondGeometryPlanService,
+ BondLinePrimitive,
+ BondPathPrimitive,
+ BondPolygonPrimitive,
+)
+from chemvas.ui.molecule.bond_graphics_draw_service import BondGraphicsDrawService
+from chemvas.ui.molecule.bond_line_geometry_service import BondLineGeometryService
+from chemvas.ui.molecule.bond_ring_double_geometry_service import (
+ BondRingDoubleGeometryService,
+)
+from chemvas.ui.molecule.structure_benzene_build_service import (
+ StructureBenzeneBuildService,
+)
+from chemvas.ui.molecule.structure_bond_build_service import StructureBondBuildService
+from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter
+from chemvas.ui.molecule.structure_geometry_access import (
+ cyclohexane_chair_points_for,
+ default_bond_endpoint_for,
+ regular_ring_points_for_atom_for,
+ regular_ring_points_for_bond_for,
+ sprout_bond_endpoint_for,
+ template_points_for_bond_for,
+)
+from chemvas.ui.molecule.structure_growth_build_service import (
+ StructureGrowthBuildActions,
+ StructureGrowthBuildService,
+)
+from chemvas.ui.molecule.structure_growth_geometry import resolve_bond_placement_context
+from chemvas.ui.molecule.template_geometry import polygon_contains_point
+from chemvas.ui.scene.mark_ownership import (
+ DISTANT_MARK_COLOR,
+ MARK_OWNER_GUIDANCE,
+ mark_is_distant_for,
+ mark_owner_text_for,
+)
+from chemvas.ui.scene.scene_align_logic import (
+ align_deltas,
+ alignment_objects,
+ distribute_deltas,
+)
+from chemvas.ui.scene.scene_clipboard_logic import (
+ MAX_CLIPBOARD_SELECTION_PAYLOAD_BYTES,
+ build_selection_clipboard_payload,
+ decode_clipboard_selection_payload,
+)
+from chemvas.ui.scene.scene_clipboard_transaction_logic import (
+ build_clipboard_paste_plan,
+ clipboard_paste_offset,
+ selection_payload_json,
+ translated_scene_item_state,
+)
+from chemvas.ui.scene.scene_delete_plan import (
+ DeleteSelectionBuckets,
+ build_delete_selection_plan,
+ hover_delete_target,
+)
+from chemvas.ui.scene.scene_paste_apply_logic import apply_paste_payload
+from chemvas.ui.scene.stacking_actions import stacked_depths
+from chemvas.ui.selection.selection_style_access import (
+ GROUP_BOX_DASH_PATTERN,
+ GROUP_BOX_PADDING_RATIO,
+ GROUP_BOX_SCREEN_PX,
+ IMAGE_SELECTION_PADDING,
+ SELECTION_OBJECT_PADDING_RATIO,
+ SELECTION_OUTLINE_SCREEN_PX,
+ group_box_corner_radius,
+ selection_arrow_overlay_width,
+ selection_atom_rect,
+ selection_bond_overlay_width,
+ selection_bond_parts,
+ selection_structure_ids,
+)
+from chemvas.ui.tools.bond_tool_logic import (
+ BOND_PICK_RADIUS_RATIO,
+ BOND_SNAP_RADIUS_RATIO,
+ apply_active_bond_style,
+ is_short_bond_gesture,
+ resolve_bond_endpoint_target,
+ resolve_bond_press_target,
+ resolve_bond_snap_target,
+)
+from chemvas.ui.tools.text_tool_logic import (
+ TextToolTarget,
+ apply_text_input,
+ normalize_text_symbol,
+ plan_text_input,
+ resolve_text_tool_target,
+)
+from chemvas.ui.window.main_window_config import (
+ ALIGN_MENU_SPECS,
+ ALIGN_SPECS,
+ ARROW_KEY_NUDGE,
+ ARROW_KEY_ROTATION_DEGREES,
+ ARROW_MENU_SPECS,
+ ARROW_PRESET_SPECS,
+ ARROW_SLIDER_LABELS,
+ ARROW_SLIDER_PAGE_STEP,
+ ARROW_SLIDER_RANGES,
+ ATOM_INPUT_SPEC,
+ BOND_LENGTH_INPUT_SPEC,
+ BOND_MODIFIERS,
+ BOND_ORDER_SEGMENTS,
+ CANVAS_TEMPLATE_TEXT_FIELDS,
+ CANVAS_TEMPLATE_TOOL_FIELDS,
+ COLOR_PALETTE_SPECS,
+ COLOR_TARGET_KINDS,
+ COLOR_TOOL_MESSAGES,
+ CONTEXT_PAGE_CAPTIONS,
+ DISTRIBUTE_MENU_SPECS,
+ DISTRIBUTE_SPECS,
+ DOUBLE_BOND_CONTEXT_STYLES,
+ FIT_VIEW_MARGIN,
+ FLIP_ACTION_SPECS,
+ HANDLE_ACCENT_COLOR,
+ HANDLE_SCREEN_PX,
+ IMAGE_PROPERTIES_SPEC,
+ LINE_KIND_SPECS,
+ MARK_TOOL_ACTION_SPECS,
+ MOLECULE_INFO_TITLE,
+ MORE_ARROW_KINDS,
+ ORBITAL_MO_TEXT,
+ ORBITAL_PHASE_SPECS,
+ REACTION_MAPPING_TITLE,
+ RING_FILL_GUIDANCE,
+ RING_FILL_TOOL_ACTION_SPEC,
+ ROTATE_ANGLE_DEFAULT,
+ ROTATE_ANGLE_RANGE,
+ ROTATION_HANDLE_STEM_PX,
+ ROTATION_HANDLE_TYPE,
+ SELECTION_FRAME_RADIUS,
+ SHAPE_KIND_SPECS,
+ SHAPE_STROKE_SPECS,
+ SHIFT_TOOL_HOTKEYS,
+ SMILES_ENTRY_SPEC,
+ TEMPLATE_ENTRY_SPECS,
+ TEXT_FORMAT_ACTION_GROUPS,
+ TEXT_FORMAT_TARGET_MESSAGE,
+ TEXT_POINT_SIZE_RANGE,
+ TEXT_SIZE_ACTION_SPECS,
+ TOOL_ACTION_SPECS,
+ TOOL_CONTEXT_PAGE_KEYS,
+ TOOL_HOTKEYS,
+ TOOLBAR_TOOL_GROUPS,
+ VIEW_FUNCTION_KEY_ACTIONS,
+ WHEEL_ANGLE_PER_PIXEL,
+ WHEEL_ZOOM_BASE,
+ ZOOM_KEY_ACTIONS,
+ ZOOM_MAX,
+ ZOOM_MIN,
+ ZOOM_STEP,
+)
+from chemvas.ui.window.main_window_state import CANVAS_NAME_FORMAT
+from chemvas.ui.window.main_window_toolbar_logic import (
+ BOND_STYLE_BY_LABEL,
+ ORBITAL_TYPE_BY_LABEL,
+ TOOL_DISPLAY_NAMES,
+ arrow_preset_from_label,
+ bond_style_from_label,
+ tool_display_name,
+ tool_hint_text,
+)
+from chemvas.ui.window.tab_title_logic import APP_TITLE_SUFFIX, UNSAVED_MARKER
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator, Mapping
+
+ from chemvas.domain.document import MoleculeModel
+
+MAX_REQUEST_BYTES = 2 * 1024 * 1024
+# Embedded images travel only when a document is opened or saved; every other
+# response carries them by content reference, so the rest stays small.
+MAX_OPEN_BYTES = MAX_DOCUMENT_BYTES + MAX_REQUEST_BYTES
+_IMAGE_REFS: dict[int, tuple[str, str]] = {}
+
+
+def image_ref(data: str) -> str:
+ """Content reference of an image's base64 source, cached per string."""
+ cached = _IMAGE_REFS.get(id(data))
+ if cached is not None and cached[0] is data:
+ return cached[1]
+ ref = hashlib.sha256(data.encode("ascii")).hexdigest()
+ if len(_IMAGE_REFS) >= 1024:
+ _IMAGE_REFS.clear()
+ _IMAGE_REFS[id(data)] = (data, ref)
+ return ref
+
+
+def without_image_data(document: dict[str, Any]) -> dict[str, Any]:
+ """The browser's copy of a document: image bytes replaced by references."""
+ state = document.get("state")
+ if not isinstance(state, dict) or not state.get("images"):
+ return document
+ images = state["images"]
+ return {
+ **document,
+ "state": {
+ **state,
+ "images": [
+ {
+ **{
+ key: value
+ for key, value in image.items()
+ if key != "data_base64"
+ },
+ "data_ref": image_ref(image["data_base64"]),
+ }
+ for image in images
+ ],
+ },
+ }
+
+
+MAX_BROWSER_SESSIONS = 16
+# At the session limit, windows idle this long are closed to make room: a tab
+# that crashed or was killed never sends its close request.
+SESSION_IDLE_SECONDS = 30 * 60
+# How long recovery waits for a window it takes a draft over from; within the
+# browser's request timeout, and a refusal changes nothing.
+DRAFT_TAKEOVER_WAIT_SECONDS = 5.0
+ASSETS = Path(__file__).resolve().parents[1] / "web"
+STATIC_FILES = {
+ "/": ("index.html", "text/html; charset=utf-8"),
+ "/style.css": ("style.css", "text/css; charset=utf-8"),
+ "/app.mjs": ("app.mjs", "text/javascript; charset=utf-8"),
+ "/transport.mjs": ("transport.mjs", "text/javascript; charset=utf-8"),
+ "/clipboard.mjs": ("clipboard.mjs", "text/javascript; charset=utf-8"),
+ "/scene.mjs": ("scene.mjs", "text/javascript; charset=utf-8"),
+}
+BOND_ORDERS = dict(BOND_STYLE_BY_LABEL.values())
+
+
+def ui_spec() -> dict[str, Any]:
+ """Serialize the desktop's existing declarations, rather than copy its design."""
+ tools = {
+ key: {
+ "key": key,
+ "label": label,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ "tip": tip,
+ }
+ for key, label, _tool, icon, tip in TOOL_ACTION_SPECS
+ }
+ key, label, icon, tip = RING_FILL_TOOL_ACTION_SPEC
+ tools[key] = {
+ "key": key,
+ "label": label,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ "tip": tip,
+ }
+ return {
+ "groups": [[tools[key] for key in group] for group in TOOLBAR_TOOL_GROUPS],
+ "bond_groups": [
+ [
+ {
+ "key": bond_style_from_label(label)[0],
+ "label": label,
+ "tip": tip,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ }
+ for label, icon, tip in group
+ ]
+ for group in (BOND_ORDER_SEGMENTS, BOND_MODIFIERS)
+ ],
+ "line_options": [
+ {
+ "key": kind,
+ "label": label,
+ "icon": design_icon_svg("line_plain" if kind == "line" else kind),
+ }
+ for kind, label in LINE_KIND_SPECS
+ ],
+ "orbital_options": [
+ {
+ "key": kind,
+ "label": f"Orbital: {label}",
+ "icon": design_icon_svg(f"orbital_{kind}")
+ if not kind.startswith("mo_")
+ else "",
+ "text": ORBITAL_MO_TEXT.get(kind),
+ }
+ for label, kind in ORBITAL_TYPE_BY_LABEL.items()
+ ],
+ "orbital_phases": [
+ {
+ "key": enabled,
+ "label": label,
+ "icon": design_icon_svg(f"orbital_phase_{'on' if enabled else 'off'}"),
+ }
+ for label, enabled in ORBITAL_PHASE_SPECS
+ ],
+ "bracket_options": [
+ {
+ "key": kind,
+ "label": label,
+ "icon": design_icon_svg(f"bracket_{kind}"),
+ }
+ for label, kind in BRACKET_MENU_SPECS
+ ],
+ "default_bracket_kind": CanvasToolSettingsState().active_bracket_type,
+ "mark_options": [
+ {
+ "key": kind,
+ "label": label,
+ "tip": tip,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ }
+ for _key, label, kind, icon, tip in MARK_TOOL_ACTION_SPECS
+ ],
+ "arrow_options": [
+ {
+ "key": kind,
+ "label": label,
+ "icon": design_icon_svg(f"arrow_{kind}"),
+ "more": kind in MORE_ARROW_KINDS,
+ }
+ for label, kind in ARROW_MENU_SPECS
+ ],
+ "arrow_labels": {
+ "hint": LABEL_SYNTAX_HINT,
+ "limit": MAX_ARROW_LABEL_CHARS,
+ # The interface font at the dialog's size, on the paper surface.
+ "preview": {
+ "pixels": browser_font_pixels(ARROW_LABEL_PREVIEW_POINT_SIZE),
+ "script_pixels": qt_script_pixels(ARROW_LABEL_PREVIEW_POINT_SIZE),
+ "weight": 400,
+ "italic": False,
+ "color": PALETTE["text"],
+ "background": PALETTE["surface_canvas"],
+ },
+ },
+ "color_messages": COLOR_TOOL_MESSAGES,
+ "valence_warning": {"color": PALETTE["danger_text"]},
+ "color_palette": [
+ {"label": label, "color": color} for label, color in COLOR_PALETTE_SPECS
+ ],
+ "shape_options": [
+ {"key": kind, "label": label, "icon": design_icon_svg(f"shape_{kind}")}
+ for kind, label in SHAPE_KIND_SPECS
+ ],
+ "shape_strokes": [
+ {"key": kind, "label": label, "icon": design_icon_svg(f"stroke_{kind}")}
+ for kind, label in SHAPE_STROKE_SPECS
+ ],
+ "grid": {
+ "modes": GRID_MODES,
+ "strengths": GRID_STRENGTHS,
+ "hint": GRID_CONTROL_HINT,
+ "color": GRID_COLOR,
+ "minimum_spacing": MIN_GRID_SPACING_PX,
+ "step": CanvasToolSettingsState().grid_snap_step,
+ "style": CanvasToolSettingsState().grid_style,
+ "opacity": CanvasToolSettingsState().grid_opacity,
+ "tiles": {
+ style: {
+ "size": [width, height],
+ "lines": grid_lines((0, 0, width, height), step=1, style=style),
+ }
+ for style, width, height in (("square", 1, 1), ("hex", 3, math.sqrt(3)))
+ },
+ },
+ "sheet_setup": {
+ "text": SHEET_SETUP_TEXT,
+ "sizes": supported_sheet_sizes(),
+ "dimensions": SHEET_SIZES_MM,
+ "custom": CUSTOM_SHEET_SIZE,
+ "orientations": SHEET_ORIENTATION_OPTIONS,
+ "minimum": MIN_SHEET_MM,
+ "maximum": MAX_SHEET_MM,
+ "decimals": SHEET_DIMENSION_DECIMALS,
+ "step": SHEET_DIMENSION_STEP_MM,
+ },
+ "arrange_actions": {
+ kind: [
+ {
+ "mode": mode,
+ "tip": tip,
+ "label": labels[mode],
+ "icon": design_icon_svg(f"{icon}_{mode}"),
+ }
+ for mode, tip in specs
+ ]
+ for kind, specs, labels, icon in (
+ (
+ "align",
+ ALIGN_SPECS,
+ {mode: text for text, mode in ALIGN_MENU_SPECS},
+ "align_objects",
+ ),
+ (
+ "distribute",
+ DISTRIBUTE_SPECS,
+ {mode: text for text, mode in DISTRIBUTE_MENU_SPECS},
+ "distribute",
+ ),
+ )
+ },
+ "flip_actions": [
+ {
+ "label": label,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ "shortcut": shortcut,
+ "horizontal": horizontal,
+ }
+ for _name, icon, label, shortcut, horizontal in FLIP_ACTION_SPECS
+ ],
+ "rotation": {
+ "minimum": ROTATE_ANGLE_RANGE[0],
+ "maximum": ROTATE_ANGLE_RANGE[1],
+ "default": ROTATE_ANGLE_DEFAULT,
+ },
+ "mark_hover": {
+ "color": PREVIEW_COLOR_RGBA,
+ "opacity": HOVER_PREVIEW_OPACITY,
+ "z": HOVER_PREVIEW_Z,
+ "atom_z": ATOM_HOVER_Z,
+ "pen": ATOM_HOVER_PEN_RGBA,
+ "brush": ATOM_HOVER_BRUSH_RGBA,
+ },
+ "handles": {
+ "size": HANDLE_SCREEN_PX,
+ "edge_size": EDGE_HANDLE_SCREEN_PX,
+ "color": HANDLE_ACCENT_COLOR,
+ "rotation_stem": ROTATION_HANDLE_STEM_PX,
+ "rotation_type": ROTATION_HANDLE_TYPE,
+ "frame_radius": SELECTION_FRAME_RADIUS,
+ },
+ # The ring on an Arrow or Line preview end that sits on an existing end.
+ "snap_mark": {
+ "size": SNAP_MARK_SCREEN_PX,
+ "width": SNAP_MARK_PEN_SCREEN_PX,
+ "color": HANDLE_ACCENT_COLOR,
+ },
+ "arrow_style_controls": [
+ {
+ "label": f"{label} arrow preset",
+ "preset": label,
+ "icon": design_icon_svg(f"arrow_preset_{label.lower()}"),
+ }
+ for label in ARROW_PRESET_SPECS
+ ]
+ + [
+ {
+ "label": label,
+ "icon": design_icon_svg(icon),
+ "setting": setting,
+ "minimum": ARROW_SLIDER_RANGES[setting][0],
+ "maximum": ARROW_SLIDER_RANGES[setting][1],
+ "factor": ARROW_SLIDER_RANGES[setting][2],
+ "page_step": ARROW_SLIDER_PAGE_STEP,
+ }
+ for (setting, label), icon in zip(
+ ARROW_SLIDER_LABELS.items(),
+ ("arrow_width", "arrow_head_scale"),
+ strict=True,
+ )
+ ],
+ # Browsers do not expose the desktop system drag-distance preference.
+ "drag_distance": 10,
+ "max_document_bytes": MAX_OPEN_BYTES,
+ "max_clipboard_bytes": MAX_CLIPBOARD_SELECTION_PAYLOAD_BYTES,
+ "smiles": {
+ **SMILES_ENTRY_SPEC,
+ "maximum_length": MAX_SMILES_INPUT_LENGTH,
+ "preview_opacity": PREVIEW_OPACITY,
+ },
+ # The desktop status bar: tool names and hints, and the window title.
+ "tool_names": {
+ key: tool_display_name(key)
+ for key in [*TOOL_DISPLAY_NAMES, *TOOL_CONTEXT_PAGE_KEYS]
+ },
+ "hints": {
+ key: tool_hint_text(key)
+ for key in [*TOOL_DISPLAY_NAMES, *TOOL_CONTEXT_PAGE_KEYS]
+ },
+ "ring_fill_hint": tool_hint_text("select", page="ring_fill"),
+ "color_hint": tool_hint_text("color", color="{color}"),
+ "title": {"unsaved_marker": UNSAVED_MARKER, "suffix": APP_TITLE_SUFFIX},
+ "canvas_name": CANVAS_NAME_FORMAT,
+ # Document settings a new canvas continues from the active one.
+ "new_canvas_settings": [
+ key
+ for key in (
+ "bond_length_px",
+ "sheet_size",
+ "sheet_orientation",
+ "sheet_custom_size_mm",
+ *CANVAS_TEMPLATE_TOOL_FIELDS,
+ *CANVAS_TEMPLATE_TEXT_FIELDS,
+ )
+ if key in SETTINGS_KEYS | OPTIONAL_SETTINGS_KEYS
+ ],
+ "tool_defaults": {
+ "bond": CanvasToolSettingsState().active_bond_style,
+ "mark": CanvasToolSettingsState().mark_kind,
+ "orbital": CanvasToolSettingsState().active_orbital_type,
+ "line": CanvasToolSettingsState().active_line_kind,
+ "shape": CanvasToolSettingsState().active_shape_type,
+ "stroke": CanvasToolSettingsState().active_shape_stroke,
+ },
+ "off_sheet_guidance": OFF_SHEET_EDIT_GUIDANCE,
+ "tool_hotkeys": TOOL_HOTKEYS,
+ "context_pages": TOOL_CONTEXT_PAGE_KEYS,
+ "context_captions": CONTEXT_PAGE_CAPTIONS,
+ "direct_select_kinds": sorted(DIRECT_SELECT_OBJECT_KINDS),
+ # The Ring page: the desktop's ring templates, benzene first.
+ "templates": [
+ {
+ "label": label,
+ "size": size,
+ "style": style,
+ "icon": design_icon_svg(
+ TEMPLATE_ICON_NAMES.get(label, TEMPLATE_FALLBACK_ICON)
+ ),
+ }
+ for label, size, style in TEMPLATE_ENTRY_SPECS
+ ],
+ "text_format": {
+ "sizes": [
+ {
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ "tip": tip,
+ "delta": delta,
+ }
+ for icon, tip, delta in TEXT_SIZE_ACTION_SPECS
+ ],
+ "groups": [
+ [
+ {
+ "key": key,
+ "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]),
+ "tip": tip,
+ }
+ for key, icon, tip in group
+ ]
+ for group in TEXT_FORMAT_ACTION_GROUPS
+ ],
+ "target_message": TEXT_FORMAT_TARGET_MESSAGE,
+ "size_range": TEXT_POINT_SIZE_RANGE,
+ },
+ "shift_tool_hotkeys": {
+ key: {
+ "tool": tool,
+ "value": {
+ "ts_bracket": DEFAULT_BRACKET_KIND,
+ "orbital": CanvasChemdrawShortcutService.DEFAULT_ORBITAL_TYPE,
+ "mark": CanvasChemdrawShortcutService.DEFAULT_MARK_KIND,
+ }[tool],
+ }
+ for key, tool in SHIFT_TOOL_HOTKEYS.items()
+ },
+ "hover_shortcuts": sorted(
+ CanvasChemdrawShortcutService.ATOM_HOTKEYS
+ | CanvasChemdrawShortcutService.BOND_HOTKEYS
+ ),
+ "default_bond_style": CanvasToolSettingsState().active_bond_style,
+ "default_arrow_style": CanvasChemdrawShortcutService.DEFAULT_ARROW_TYPE,
+ "bond_length_input": {**BOND_LENGTH_INPUT_SPEC, "max": MAX_BOND_LENGTH_PX},
+ "navigation": {
+ "zoom_modifier": "meta" if sys.platform == "darwin" else "control",
+ "min": ZOOM_MIN,
+ "max": ZOOM_MAX,
+ "step": ZOOM_STEP,
+ "wheel_base": WHEEL_ZOOM_BASE,
+ "fit_margin": FIT_VIEW_MARGIN,
+ "zoom_keys": ZOOM_KEY_ACTIONS,
+ "function_keys": VIEW_FUNCTION_KEY_ACTIONS,
+ "rotate_keys": ARROW_KEY_ROTATION_DEGREES,
+ "nudge_keys": ARROW_KEY_NUDGE,
+ "angle_per_pixel": WHEEL_ANGLE_PER_PIXEL,
+ },
+ "image_properties": IMAGE_PROPERTIES_SPEC,
+ "max_image_bytes": MAX_IMAGE_BYTES,
+ "atom_input": {
+ **ATOM_INPUT_SPEC,
+ "value": CanvasToolSettingsState().atom_symbol,
+ },
+ "panels": [
+ {"key": key, "label": label, "icon": design_icon_svg(key)}
+ for key, label in (
+ ("molecule_info", MOLECULE_INFO_TITLE),
+ ("reaction_mapping", REACTION_MAPPING_TITLE),
+ )
+ ],
+ }
+
+
+def ui_css() -> str:
+ values = {
+ **PALETTE,
+ **{
+ key.lower(): str(getattr(toolbar_styles, key)) + "px"
+ for key in (
+ "TOOLBAR_THICKNESS",
+ "TOOLBAR_BUTTON_SIZE",
+ "TOOLBAR_ICON_SIZE",
+ "CONTEXT_BAR_CONTENT_HEIGHT",
+ "CONTEXT_BAR_BUTTON_HEIGHT",
+ "CONTEXT_BAR_ICON_SIZE",
+ )
+ },
+ }
+ return (
+ ":root{"
+ + ";".join(
+ f"--{key.replace('_', '-')}: {value}" for key, value in values.items()
+ )
+ + "}"
+ )
+
+
+def new_document() -> dict[str, Any]:
+ state = compose_document_state(
+ {
+ "format": "chemvas-document-composition",
+ "version": 2,
+ "atoms": [],
+ "bonds": [],
+ }
+ )
+ return build_normalized_document_payload(state, CANVAS_FILE_VERSION)
+
+
+def document_info(
+ payload: object,
+ *,
+ render: bool = True,
+ font: BrowserFontMeasurements | None = None,
+ mark_order: dict[int, list[int]] | None = None,
+) -> dict[str, Any]:
+ """Validate without dropping data; unsupported drawings remain read-only."""
+ document = normalize_json_numbers(payload)
+ state = extract_document_state(document)
+ # Image sources have their own validated budget; the drawing stays bounded.
+ if (
+ len(
+ json.dumps(
+ without_image_data(cast("dict[str, Any]", document)),
+ ensure_ascii=False,
+ ).encode()
+ )
+ > MAX_REQUEST_BYTES
+ ):
+ raise ValueError(
+ "The browser adapter supports drawings up to "
+ f"{MAX_REQUEST_BYTES // 2**20} MiB besides embedded images."
+ )
+ model = state["model"]
+ if len(model["atoms"]) > 2000 or len(model["bonds"]) > 3000:
+ raise ValueError(
+ "The browser adapter supports up to 2,000 atoms and 3,000 bonds."
+ )
+ reasons = []
+ for note in state["notes"]:
+ try:
+ browser_note_html(note, state["settings"]["text_font_size"])
+ except ValueError:
+ reasons.append("note lists or formatting without a browser renderer")
+ break
+ settings = state["settings"]
+ width, height = sheet_dimensions_px(
+ settings["sheet_size"],
+ settings["sheet_orientation"],
+ settings.get("sheet_custom_size_mm"),
+ )
+ if mark_order is None:
+ mark_order = {}
+ for index, mark in enumerate(state["marks"]):
+ if mark["atom_id"] is not None:
+ mark_order.setdefault(mark["atom_id"], []).append(index)
+ info = {
+ "mark_order": mark_order,
+ "document": document,
+ "unsupported": reasons,
+ "sheet": [width, height],
+ "style": asdict(ACS1996Style()),
+ }
+ if render:
+ if font is None and (
+ state["marks"]
+ or any(arrow.get("labels") for arrow in state["arrows"])
+ or state["notes"]
+ or any(
+ bracket["bracket_kind"] in BRACKET_SYMBOLS
+ for bracket in state["ts_brackets"]
+ )
+ ):
+ font = BrowserFontMeasurements(
+ {"family": ACS1996Style().font_family, "metrics": {}, "ink": {}}
+ )
+ drawing = drawing_geometry(state) if font is None else font.drawing(state)
+ if not reasons and not drawing.get("needs_measurements"):
+ drawing["scene_rect"] = browser_scene_rect(width, height, drawing)
+ drawing["groups"] = group_selection_units(state)
+ info["drawing"] = drawing
+ return info
+
+
+def browser_font_pixels(point_size: float) -> int:
+ # The desktop pins AA_Use96Dpi; QFont rounds that em size half away from zero.
+ return max(1, math.floor(point_size * 96 / 72 + 0.5))
+
+
+def _browser_label_queries(size: int, labels: Any) -> list[dict[str, Any]]:
+ texts = {"H", "+", "-"}
+ for display, anchor, _at_end, below in labels:
+ texts.update(run.text for run in parse_atom_label(display))
+ if "\n" in display or "\r" in display:
+ texts.update(display.replace("\r\n", "\n").replace("\r", "\n").split("\n"))
+ if anchor:
+ texts.add(anchor)
+ if below is not None:
+ split = split_hydride_label(display)
+ assert split is not None
+ element, count = split
+ texts.update(
+ run.text for run in parse_atom_label(hydride_hydrogen_text(count))
+ )
+ texts.add(element)
+ queries = (
+ [
+ {
+ "key": f"{point_size}:{text}",
+ "text": text,
+ "size": point_size,
+ "pixels": browser_font_pixels(point_size),
+ }
+ for text in sorted(texts)
+ for point_size in (size, size * SUB_SCALE)
+ ]
+ if labels
+ else []
+ )
+ return queries
+
+
+def browser_label_layouts(model: Any, metrics: RenderMetrics) -> dict[str, Any]:
+ """Describe native label presentation without serializing the document twice."""
+ labels = {
+ str(atom_id): atom_label_presentation(model, atom_id, atom.element)
+ for atom_id, atom in model.atoms.items()
+ if atom_shows_itself(atom)
+ }
+ size = metrics.atom_font_size_pt()
+ return {
+ "family": metrics.style.font_family,
+ "size": size,
+ "offset": metrics.style.atom_label_offset_px,
+ "labels": labels,
+ "queries": _browser_label_queries(size, list(labels.values())),
+ "mark_queries": _browser_label_queries(size, [("H", None, False, None)]),
+ }
+
+
+def validate_font_metrics(measurements: Any) -> None:
+ if not isinstance(measurements, dict):
+ raise ValueError("Expected browser font measurements.")
+ for value in measurements.values():
+ if not isinstance(value, dict) or set(value) != {
+ "width",
+ "ascent",
+ "descent",
+ "cap_height",
+ "line_height",
+ "bounding_width",
+ }:
+ raise ValueError(
+ "Expected advance and bounding widths, ascent, descent, capital height and line height."
+ )
+ if any(
+ type(number) not in (int, float, Decimal)
+ or not math.isfinite(number)
+ or number < 0
+ for number in value.values()
+ ):
+ raise ValueError("Font measurements must be finite nonnegative numbers.")
+ if value["ascent"] <= 0 or value["line_height"] <= 0:
+ raise ValueError("Font ascent and line height must be positive.")
+
+
+def place_browser_labels(
+ size: int,
+ labels: list[tuple[str, str | None, bool, bool | None]],
+ measurements: dict[str, dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """Adapt measured font metrics to the existing native run placer, at origin."""
+ if not labels:
+ return []
+ font = measurements[f"{size}:H"]
+
+ def measure(text: str, point_size: float) -> float:
+ return float(measurements[f"{point_size}:{text}"]["width"])
+
+ parameters: dict[str, Any] = {
+ "measure": measure,
+ "ascent": float(font["ascent"]),
+ "descent": float(font["descent"]),
+ "base_point_size": size,
+ }
+ result = []
+ for display, anchor, at_end, below in labels:
+ if below is not None:
+ split = split_hydride_label(display)
+ assert split is not None
+ element, count = split
+ layout, box = place_hydride_stack(
+ element,
+ count,
+ hydrogens_below=below,
+ cap_height=float(font["cap_height"]),
+ **parameters,
+ )
+ center_x, center_y = box[0] + box[2] / 2, box[1] + box[3] / 2
+ else:
+ layout = place_runs(parse_atom_label(display), **parameters)
+ center_x, center_y = layout.width / 2, layout.height / 2
+ if not layout.has_typography:
+ center_y = float(measurements[f"{size}:{display}"]["line_height"]) / 2
+ if anchor:
+ anchor_width = measure(anchor, size)
+ center_x = (
+ layout.width - anchor_width / 2 if at_end else anchor_width / 2
+ )
+ height = (
+ layout.height
+ if layout.has_typography or below is not None
+ else float(measurements[f"{size}:{display}"]["line_height"])
+ )
+ margin = ATOM_LABEL_DOCUMENT_MARGIN
+ result.append(
+ {
+ "rect": (
+ -center_x - margin,
+ -center_y - margin,
+ layout.width + 2 * margin,
+ height + 2 * margin,
+ ),
+ "runs": [
+ {
+ "text": run.text,
+ "size": run.point_size,
+ "pixels": browser_font_pixels(run.point_size),
+ "x": run.x - center_x,
+ "y": run.baseline - center_y,
+ }
+ for run in layout.runs
+ ],
+ }
+ )
+ return result
+
+
+def shape_geometry(
+ state: dict[str, Any], metrics: RenderMetrics
+) -> list[dict[str, Any]]:
+ result = []
+ for source in state["shapes"]:
+ shape = normalized_shape(shape_from_state(source))
+ kind, x, y, width, height, radius = shape_outline(
+ shape.left,
+ shape.top,
+ shape.right - shape.left,
+ shape.bottom - shape.top,
+ shape.shape_kind,
+ )
+ pad = metrics.style.bond_length_px * SELECTION_OBJECT_PADDING_RATIO
+ line_width = shape_stroke_width(metrics.style.bond_line_width)
+ # A native empty path has an empty scene bound at the origin.
+ empty = width == 0 and height == 0
+ selection = {
+ "outline": {
+ "kind": "rect" if empty else kind,
+ "x": -pad if empty else x,
+ "y": -pad if empty else y,
+ "width": pad * 2 if empty else width,
+ "height": pad * 2 if empty else height,
+ "radius": pad * 0.7 if empty else radius,
+ },
+ "width": 0.0
+ if empty
+ else pad * 2 + (0.0 if shape.stroke_style == "none" else line_width),
+ }
+ half = 0.0 if shape.stroke_style == "none" else line_width / 2
+ result.append(
+ {
+ "kind": kind,
+ "x": x,
+ "y": y,
+ "width": width,
+ "height": height,
+ "radius": radius,
+ "stroke": shape.stroke_style,
+ "line_width": line_width,
+ # Painted scene bounds; an empty native path has none.
+ "bounds": None
+ if empty
+ else (x - half, y - half, width + 2 * half, height + 2 * half),
+ "selection": selection,
+ "color": metrics.style.bond_color,
+ "fill": shape.fill,
+ "alpha": shape.fill_alpha,
+ "z": -10.0 if shape.z is None else shape.z,
+ "handles": [
+ {"handle": name, "point": point}
+ for name, point in shape_handle_positions(
+ (shape.left, shape.top, shape.right, shape.bottom)
+ )
+ ],
+ }
+ )
+ return result
+
+
+def arrow_geometry(
+ state: dict[str, Any], metrics: RenderMetrics
+) -> list[dict[str, Any]]:
+ """The same native arrow paths feed browser painting and picking."""
+ arrows = []
+ arrow_point_count = 0
+ endpoint_counts = Counter(
+ tuple(point)
+ for arrow in state["arrows"]
+ for point in (arrow["start"], arrow["end"])
+ )
+ for arrow in state["arrows"]:
+ commands = arrow_path_commands(
+ tuple(arrow["start"]),
+ tuple(arrow["end"]),
+ arrow["kind"],
+ bond_length=metrics.style.bond_length_px,
+ bond_spacing=metrics.style.bond_spacing_px,
+ wave_spacing=metrics.bond_spacing(),
+ line_width=state["settings"]["arrow_line_width"],
+ head_scale=state["settings"]["arrow_head_scale"],
+ control=None if arrow.get("control") is None else tuple(arrow["control"]),
+ double=arrow.get("double", False),
+ mirrored=arrow.get("mirrored", False),
+ )
+ arrow_point_count += sum(len(coordinates) // 2 for _, coordinates in commands)
+ if arrow_point_count > 500_000:
+ raise ValueError("Arrow drawing exceeds the browser path point limit.")
+ record = normalized_arrow_control(arrow_from_state(arrow))
+ positions = [("start", record.start), ("end", record.end)]
+ if record.control is not None:
+ positions.insert(
+ 1,
+ ("control", curved_midpoint(record.start, record.control, record.end)),
+ )
+ own_counts = Counter((record.start, record.end))
+ width = (
+ metrics.bold_bond_width()
+ if arrow["kind"] == "line_bold"
+ else state["settings"]["arrow_line_width"]
+ )
+ arrows.append(
+ {
+ "path": commands,
+ "handles": [
+ {
+ "handle": name,
+ "point": pos,
+ "snapped": name != "control"
+ and endpoint_counts[pos] > own_counts[pos],
+ }
+ for name, pos in positions
+ ],
+ "width": width,
+ "selection_width": selection_arrow_overlay_width(
+ width,
+ metrics.style.bond_length_px * SELECTION_OBJECT_PADDING_RATIO,
+ atom_pick_radius(metrics),
+ ),
+ "dashed": arrow["kind"] in {"dotted", "line_dashed"},
+ "cap": "butt" if arrow["kind"] == "line_bold" else "round",
+ "join": "miter" if arrow["kind"] == "line_bold" else "round",
+ "color": arrow.get("color") or metrics.style.bond_color,
+ }
+ )
+ return arrows
+
+
+def _curve_frame_controls(
+ start: tuple[float, ...],
+ control: tuple[float, ...],
+ end: tuple[float, ...],
+ width: float,
+) -> list[tuple[float, ...]]:
+ """Bound the offset cubic controls used by Qt's path-item frame.
+
+ The painted quadratic remains unchanged. Quarter-point distance and normal
+ checks split only the bounding approximation, with Qt's width-dependent
+ tolerance and bounded subdivision depth.
+ """
+ result: list[tuple[float, ...]] = []
+ threshold = max(0.00025, min(0.25, 1 / width))
+ for offset in (-width / 2, width / 2):
+ pending = [(start, control, end, 0)]
+ while pending:
+ a, q, b, depth = pending.pop()
+ points = [
+ a,
+ tuple(a[i] + 2 * (q[i] - a[i]) / 3 for i in (0, 1)),
+ tuple(b[i] + 2 * (q[i] - b[i]) / 3 for i in (0, 1)),
+ b,
+ ]
+ unique: list[tuple[float, ...]] = []
+ indices = []
+ for p in points:
+ if not unique or p != unique[-1]:
+ unique.append(p)
+ indices.append(len(unique) - 1)
+ if len(unique) < 2:
+ continue
+ normals = []
+ for p, r in pairwise(unique):
+ dx, dy = r[0] - p[0], r[1] - p[1]
+ length = math.hypot(dx, dy)
+ normals.append((dy / length, -dx / length))
+ normals = [normals[0], *normals, normals[-1]]
+ shifted = []
+ for i, p in enumerate(unique):
+ n, m = normals[i : i + 2]
+ divisor = 1 + sum(n[k] * m[k] for k in (0, 1))
+ vector = (
+ n
+ if abs(divisor) < 1e-12
+ else tuple((n[k] + m[k]) / divisor for k in (0, 1))
+ )
+ shifted.append(tuple(p[k] + offset * vector[k] for k in (0, 1)))
+ shifted = [shifted[i] for i in indices]
+ fits = True
+ for t in (0.25, 0.5, 0.75):
+ weights = (
+ (1 - t) ** 3,
+ 3 * t * (1 - t) ** 2,
+ 3 * t * t * (1 - t),
+ t**3,
+ )
+ delta = [
+ sum((shifted[j][k] - points[j][k]) * weights[j] for j in range(4))
+ for k in (0, 1)
+ ]
+ tangent = [
+ 2 * ((1 - t) * (q[k] - a[k]) + t * (b[k] - q[k])) for k in (0, 1)
+ ]
+ norm = sum(abs(v) for v in tangent)
+ if abs(
+ sum(v * v for v in delta) - offset * offset
+ ) > threshold * offset * offset or (
+ norm
+ and abs(sum(tangent[k] * delta[k] for k in (0, 1))) / norm
+ > threshold * abs(offset)
+ ):
+ fits = False
+ break
+ if fits or depth >= 9:
+ result.extend(shifted)
+ else:
+ left = tuple((a[k] + q[k]) / 2 for k in (0, 1))
+ right = tuple((q[k] + b[k]) / 2 for k in (0, 1))
+ middle = tuple((left[k] + right[k]) / 2 for k in (0, 1))
+ pending.extend(
+ [(middle, right, b, depth + 1), (a, left, middle, depth + 1)]
+ )
+ return result
+
+
+def bracket_rect(source: dict[str, Any]) -> tuple[float, float, float, float]:
+ bracket = normalized_ts_bracket(ts_bracket_from_state(source))
+ return (
+ bracket.left,
+ bracket.top,
+ bracket.right - bracket.left,
+ bracket.bottom - bracket.top,
+ )
+
+
+def bracket_glyph_queries(
+ state: dict[str, Any], bond_length: float
+) -> list[dict[str, Any]]:
+ """Font queries for dagger glyphs, measured at their own pixel sizes."""
+ queries = {}
+ for source in state["ts_brackets"]:
+ text = BRACKET_SYMBOLS.get(source["bracket_kind"])
+ if text is not None:
+ pixels, _x, _y = bracket_symbol_layout(bracket_rect(source), bond_length)
+ key = f"bracket:{pixels}:{text}"
+ # Point size at the pinned 96 DPI, so the query resolves to ``pixels``.
+ queries[key] = {
+ "key": key,
+ "text": text,
+ "size": pixels * 72 / 96,
+ "pixels": pixels,
+ }
+ return list(queries.values())
+
+
+# QPainterPathStroker's default miter limit, in stroke widths.
+_STROKER_MITER_LIMIT = 2.0
+
+
+def bracket_outline(
+ commands: list[tuple[str, tuple[float, ...]]], width: float
+) -> list[list[tuple[float, float]]]:
+ """Closed rings approximating the flat-cap, miter-join outline Qt fills.
+
+ Points are offset along exact curve normals and joins keep their miter
+ corners, so bounds and pick distances agree well below a device pixel;
+ Qt offsets curves as curves rather than samples.
+ """
+ half = width / 2
+ limit = _STROKER_MITER_LIMIT * width
+ rings: list[list[tuple[float, float]]] = []
+ left: list[tuple[float, float]] = []
+ right: list[tuple[float, float]] = []
+ start = current = (0.0, 0.0)
+ first: tuple[float, float] | None = None
+ previous: tuple[float, float] | None = None
+
+ def unit_normal(dx: float, dy: float) -> tuple[float, float]:
+ length = math.hypot(dx, dy)
+ return (-dy / length, dx / length)
+
+ def join(
+ point: tuple[float, float],
+ before: tuple[float, float],
+ after: tuple[float, float],
+ ) -> None:
+ sx, sy = before[0] + after[0], before[1] + after[1]
+ length = math.hypot(sx, sy)
+ reach = half / (length / 2) if length else math.inf
+ if reach <= limit:
+ ux, uy = sx / length, sy / length
+ left.append((point[0] + ux * reach, point[1] + uy * reach))
+ right.append((point[0] - ux * reach, point[1] - uy * reach))
+ return
+ # Qt clips an over-long miter at the limit, across the turn.
+ # Directions follow from normals: (dx, dy) = (ny, -nx).
+ dx, dy = before[1] - after[1], after[0] - before[0]
+ length = math.hypot(dx, dy)
+ if length:
+ ux, uy = dx / length, dy / length
+ tip = (point[0] + ux * limit, point[1] + uy * limit)
+ left.append((tip[0] - uy * half, tip[1] + ux * half))
+ right.append((tip[0] + uy * half, tip[1] - ux * half))
+
+ def close() -> None:
+ # Qt strokes a subpath that returns to its start as closed: a join
+ # replaces both flat caps there.
+ if first is not None and previous is not None and current == start:
+ join(start, previous, first)
+ if left:
+ rings.append([*left, *reversed(right), left[0]])
+
+ for command, values in commands:
+ if command == "M":
+ close()
+ left, right = [], []
+ start = current = (values[0], values[1])
+ first = previous = None
+ continue
+ if command == "L":
+ end = (values[0], values[1])
+ if end == current:
+ # A flat square bracket has zero-length sides; Qt drops them.
+ continue
+ normal = unit_normal(end[0] - current[0], end[1] - current[1])
+ samples = [(current, normal), (end, normal)]
+ else:
+ (x0, y0), (x1, y1, x2, y2, x3, y3) = current, values
+ end = (x3, y3)
+ samples = []
+ for step in range(65):
+ t = step / 64
+ u = 1 - t
+ dx = (
+ 3 * u * u * (x1 - x0)
+ + 6 * u * t * (x2 - x1)
+ + 3 * t * t * (x3 - x2)
+ )
+ dy = (
+ 3 * u * u * (y1 - y0)
+ + 6 * u * t * (y2 - y1)
+ + 3 * t * t * (y3 - y2)
+ )
+ if not dx and not dy:
+ dx, dy = x3 - x0, y3 - y0
+ samples.append(
+ (
+ (
+ u**3 * x0
+ + 3 * u * u * t * x1
+ + 3 * u * t * t * x2
+ + t**3 * x3,
+ u**3 * y0
+ + 3 * u * u * t * y1
+ + 3 * u * t * t * y2
+ + t**3 * y3,
+ ),
+ unit_normal(dx, dy),
+ )
+ )
+ if previous is None:
+ first = samples[0][1]
+ else:
+ join(current, previous, samples[0][1])
+ for (x, y), (nx, ny) in samples:
+ left.append((x + nx * half, y + ny * half))
+ right.append((x - nx * half, y - ny * half))
+ previous, current = samples[-1][1], end
+ close()
+ return rings
+
+
+def arrow_frame_bounds(geometry: dict[str, Any]) -> tuple[float, float, float, float]:
+ points = []
+ previous = (0, 0)
+ pad = geometry["width"] / 2
+ for command, values in geometry["path"]:
+ end = tuple(values[-2:])
+ if command == "Q":
+ points.extend(
+ _curve_frame_controls(
+ previous, tuple(values[:2]), end, geometry["width"]
+ )
+ )
+ if command in ("L", "Q") and (previous != end or command == "Q"):
+ if geometry["cap"] == "butt":
+ dx, dy = end[0] - previous[0], end[1] - previous[1]
+ length = math.hypot(dx, dy)
+ if length:
+ for p in (previous, end):
+ for side in (-1, 1):
+ points.append(
+ (
+ p[0] + side * pad * dy / length,
+ p[1] - side * pad * dx / length,
+ )
+ )
+ else:
+ for p in (previous, end):
+ points.extend([(p[0] - pad, p[1] - pad), (p[0] + pad, p[1] + pad)])
+ if command == "L":
+ dx, dy = end[0] - previous[0], end[1] - previous[1]
+ length = math.hypot(dx, dy)
+ tangent, normal = (
+ (dx / length, dy / length),
+ (-dy / length, dx / length),
+ )
+ # Native round caps use two cubic quarters; their controls
+ # extend beyond the painted circle on diagonal segments.
+ for point, direction in ((previous, -1), (end, 1)):
+ for side in (-1, 1):
+ for a, b in ((1.0, 0.5522847498), (0.5522847498, 1.0)):
+ points.append(
+ tuple(
+ point[i]
+ + pad
+ * (
+ direction * a * tangent[i]
+ + side * b * normal[i]
+ )
+ for i in (0, 1)
+ )
+ )
+ previous = end
+ if not points:
+ return (0.0, 0.0, 0.0, 0.0)
+ x, y = min(p[0] for p in points), min(p[1] for p in points)
+ return (x, y, max(p[0] for p in points) - x, max(p[1] for p in points) - y)
+
+
+def browser_scene_rect(
+ width: float, height: float, drawing: dict[str, Any]
+) -> tuple[float, float, float, float]:
+ """Adapt persistent drawing bounds to the native sheet scroll range."""
+ rects = list(drawing.get("atom_hit_rects", {}).values())
+ for primitives in drawing["bonds"].values():
+ for primitive in primitives:
+ if "line" in primitive:
+ x1, y1, x2, y2 = primitive["line"]
+ rects.append(
+ arrow_frame_bounds(
+ {
+ "path": [("M", (x1, y1)), ("L", (x2, y2))],
+ "width": drawing["line_width"],
+ "cap": "round",
+ }
+ )
+ )
+ else:
+ points = primitive.get("polygon", primitive.get("dots", []))
+ if not points:
+ continue
+ pad = (
+ drawing["line_width"] / 2
+ if primitive.get("outlined")
+ else primitive.get("radius", 0)
+ )
+ left, top = min(p[0] for p in points), min(p[1] for p in points)
+ rects.append(
+ (
+ left - pad,
+ top - pad,
+ max(p[0] for p in points) - left + 2 * pad,
+ max(p[1] for p in points) - top + 2 * pad,
+ )
+ )
+ rects.extend(arrow_frame_bounds(arrow) for arrow in drawing["arrows"])
+ rects.extend(
+ (label["x"], label["y"], label["width"], label["height"])
+ for label in drawing.get("arrow_labels", [])
+ )
+ for shape in drawing["shapes"]:
+ if shape["stroke"] == "none" and (not shape["fill"] or shape["alpha"] == 0):
+ continue
+ if shape["bounds"] is not None:
+ rects.append(shape["bounds"])
+ for mark in drawing.get("marks", []):
+ if mark["kind"] in {"plus", "minus"}:
+ if "hit_rect" in mark:
+ rects.append(mark["hit_rect"])
+ elif mark["kind"] == "radical":
+ radius = mark["radius"]
+ rects.append(
+ (mark["x"] - radius, mark["y"] - radius, radius * 2, radius * 2)
+ )
+ else:
+ rects.append(mark["bounds"])
+ rects.extend(orbital["bounds"] for orbital in drawing.get("orbitals", []))
+ rects.extend(
+ bracket["bounds"]
+ for bracket in drawing.get("brackets", [])
+ if bracket["bounds"]
+ )
+ box = drawing.get("note_box")
+ for note in drawing.get("notes", []):
+ # An empty note paints no glyphs; its box still paints.
+ if note["has_text"]:
+ rects.append(note["bounds"])
+ if box:
+ pad = box["padding"] + (box["width"] / 2 if box["stroke"] else 0)
+ rects.append(
+ note_rect_bounds(
+ note, -pad, -pad, note["width"] + pad, note["height"] + pad
+ )
+ )
+ rects = [rect for rect in rects if rect[2] or rect[3]]
+ content = None
+ if rects:
+ left, top = min(r[0] for r in rects), min(r[1] for r in rects)
+ right, bottom = max(r[0] + r[2] for r in rects), max(r[1] + r[3] for r in rects)
+ if right > left and bottom > top:
+ content = (left, top, right - left, bottom - top)
+ return sheet_scene_bounds(width, height, content)
+
+
+def points_bounds(
+ points: list[tuple[float, float]],
+) -> tuple[float, float, float, float]:
+ left, top = min(x for x, _ in points), min(y for _, y in points)
+ return left, top, max(x for x, _ in points) - left, max(y for _, y in points) - top
+
+
+def validated_color(value: object) -> str:
+ if not isinstance(value, str) or re.fullmatch(r"#[0-9a-fA-F]{6}", value) is None:
+ raise ValueError("Expected a six-digit color.")
+ return value
+
+
+def validated_drawing_scale(value: object) -> float:
+ if type(value) not in (int, float, Decimal):
+ raise ValueError("Invalid drawing scale.")
+ scale = float(cast("Any", value))
+ # Fit Page can show a large sheet below the manual zoom minimum.
+ if not 0 < scale <= ZOOM_MAX or not math.isfinite(ENDPOINT_SNAP_SCREEN_PX / scale):
+ raise ValueError("Invalid drawing scale.")
+ return scale
+
+
+def arrow_pick_segments(
+ commands: list[tuple[str, tuple[float, ...]]], scale: float
+) -> Iterator[tuple[BrowserPoint, BrowserPoint]]:
+ """Flatten native quadratic commands with Qt's screen-space stopping rule.
+
+ The equivalent quadratic flatness test uses the cubic control distances,
+ a 0.5-pixel Manhattan threshold and at most nine midpoint subdivisions.
+ """
+ previous = (0.0, 0.0)
+ for command, coordinates in commands:
+ end = (coordinates[-2] * scale, coordinates[-1] * scale)
+ if command == "L":
+ yield BrowserPoint(*previous), BrowserPoint(*end)
+ elif command == "Q":
+ control = (coordinates[0] * scale, coordinates[1] * scale)
+ pending = [(previous, control, end, 9)]
+ while pending:
+ start, control, finish, depth = pending.pop()
+ dx, dy = finish[0] - start[0], finish[1] - start[1]
+ length = abs(dx) + abs(dy)
+ if length > 1:
+ flat = (
+ abs(dx * (control[1] - start[1]) - dy * (control[0] - start[0]))
+ < 0.375 * length
+ )
+ else:
+ flat = (
+ sum(
+ abs(2 * (control[i] - start[i]) / 3)
+ + abs(
+ (finish[i] - start[i] + 2 * (control[i] - start[i])) / 3
+ )
+ for i in (0, 1)
+ )
+ < 0.5
+ )
+ if flat or depth == 0:
+ yield BrowserPoint(*start), BrowserPoint(*finish)
+ continue
+ left = ((start[0] + control[0]) / 2, (start[1] + control[1]) / 2)
+ right = ((control[0] + finish[0]) / 2, (control[1] + finish[1]) / 2)
+ middle = ((left[0] + right[0]) / 2, (left[1] + right[1]) / 2)
+ pending.extend(
+ (
+ (middle, right, finish, depth - 1),
+ (start, left, middle, depth - 1),
+ )
+ )
+ previous = end
+
+
+def image_bounds(record: Mapping[str, Any]) -> tuple[float, float, float, float]:
+ return (
+ float(record["x"]),
+ float(record["y"]),
+ float(record["width"]),
+ float(record["height"]),
+ )
+
+
+def image_geometry(state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Embedded images at their document boxes; pixels come by reference."""
+ images = []
+ for record in state.get("images", []):
+ x, y, width, height = image_bounds(record)
+ pad = IMAGE_SELECTION_PADDING
+ images.append(
+ {
+ "ref": image_ref(record["data_base64"]),
+ "x": x,
+ "y": y,
+ "width": width,
+ "height": height,
+ "opacity": float(record["opacity"]),
+ "z": float(record.get("z", -2.0)),
+ "bounds": (x, y, width, height),
+ # The desktop outlines a selected image with a dashed box.
+ "selection": {
+ "x": x - pad,
+ "y": y - pad,
+ "width": width + 2 * pad,
+ "height": height + 2 * pad,
+ "radius": group_box_corner_radius(
+ width + 2 * pad, height + 2 * pad
+ ),
+ },
+ }
+ )
+ return images
+
+
+def annotation_bounds(
+ drawing: dict[str, Any], target: str, index: int
+) -> tuple[float, float, float, float]:
+ """Scene bounds of a drawn annotation, as its graphics item reports them."""
+ if target == "arrow":
+ return arrow_frame_bounds(drawing["arrows"][index])
+ if target == "image":
+ return tuple(drawing["images"][index]["bounds"])
+ collection = {
+ "mark": "marks",
+ "orbital": "orbitals",
+ "ts_bracket": "brackets",
+ "note": "notes",
+ "shape": "shapes",
+ }[target]
+ return tuple(drawing[collection][index]["bounds"] or (0, 0, 0, 0))
+
+
+GROUP_SELECTION_TARGETS = {
+ "images": "image",
+ "notes": "note",
+ "marks": "mark",
+ "arrows": "arrow",
+ "ts_brackets": "ts_bracket",
+ "shapes": "shape",
+ "orbitals": "orbital",
+}
+
+
+def group_selection_units(state: dict[str, Any]) -> list[list[str]]:
+ """Selection keys each group selects as one unit.
+
+ Like the desktop's group expansion, a group selects its atoms, the bonds
+ and ring fills between them, their marks and its standalone items.
+ """
+ units = []
+ for group in state.get("groups", []):
+ atoms = {int(atom_id) for atom_id in group["atoms"]}
+ keys = [f"atom:{atom_id}" for atom_id in sorted(atoms)]
+ keys += [
+ f"bond:{index}"
+ for index, bond in enumerate(state["model"]["bonds"])
+ if bond is not None and bond["a"] in atoms and bond["b"] in atoms
+ ]
+ keys += [
+ f"ring:{index}"
+ for index, ring in enumerate(state["ring_fills"])
+ if ring["atom_ids"] and atoms.issuperset(ring["atom_ids"])
+ ]
+ keys += [
+ f"mark:{index}"
+ for index, mark in enumerate(state["marks"])
+ if mark["atom_id"] in atoms
+ ]
+ keys += [
+ f"{GROUP_SELECTION_TARGETS[kind]}:{index}"
+ for kind, index in group["items"]
+ if kind in GROUP_SELECTION_TARGETS
+ ]
+ units.append(keys)
+ return units
+
+
+def perspective_geometry(
+ state: dict[str, Any],
+ model: MoleculeModel,
+ ring_ids: Callable[[Bond], list[int] | None],
+) -> dict[str, Any]:
+ """The desktop's 3D geometry ports over the document's stored depth points."""
+ coords_3d, center_3d, anchor_2d = perspective_from_state(state.get("perspective"))
+ bond_length = float(state["settings"]["bond_length_px"])
+
+ def atom_coords_3d(atom_id: int) -> tuple[float, float, float] | None:
+ atom = model.atoms.get(atom_id)
+ if atom is None:
+ return None
+ return current_atom_coords_3d(
+ (atom.x, atom.y),
+ coords_3d.get(atom_id),
+ bond_length_px=bond_length,
+ center_3d=center_3d,
+ anchor_2d=anchor_2d,
+ )
+
+ def ring_center_3d_for_bond(
+ bond: Bond, *, screen_delta: tuple[float, float] = (0.0, 0.0)
+ ) -> tuple[float, float, float] | None:
+ ring = ring_ids(bond)
+ if ring is None:
+ return None
+ return ring_center_3d(
+ (c for c in map(atom_coords_3d, ring) if c is not None),
+ screen_delta=screen_delta,
+ bond_length_px=bond_length,
+ center_3d=center_3d,
+ )
+
+ def offset_unit(a_id: int, b_id: int, target: Any = None) -> Any:
+ a, b = model.atoms.get(a_id), model.atoms.get(b_id)
+ if a is None or b is None:
+ return None
+ return bond_offset_unit(
+ (a.x, a.y),
+ (b.x, b.y),
+ target,
+ bond_length_px=bond_length,
+ center_3d=center_3d,
+ anchor_2d=anchor_2d,
+ )
+
+ return {
+ "bond_offset_unit_3d": offset_unit,
+ "ring_center_3d_for_bond": ring_center_3d_for_bond,
+ "current_atom_coords_3d": atom_coords_3d,
+ "project_point_3d": lambda p: project_point_3d(
+ p, bond_length_px=bond_length, center_3d=center_3d, anchor_2d=anchor_2d
+ ),
+ "rotation_state": SimpleNamespace(
+ projection_center_3d=center_3d, projection_anchor_2d=anchor_2d
+ ),
+ }
+
+
+def drawing_geometry(
+ state: dict[str, Any],
+ label_ink: dict[int, list[tuple[float, float]]] | None = None,
+) -> dict[str, Any]:
+ """Connect the actual desktop geometry planner to SVG-compatible primitives."""
+ model = deserialize_model_state(state["model"])
+ graph = CanvasGraphState()
+ for index, bond in enumerate(model.bonds):
+ if bond:
+ add_bond_to_atom_index(graph.atom_bond_ids, index, bond.a, bond.b)
+ edges = build_ring_edge_index(model.atoms, model.bonds)
+
+ point = BrowserPoint
+
+ def ring_ids(bond: Bond) -> list[int] | None:
+ return ring_atom_ids_for_bond(
+ bond,
+ (record["atom_ids"] for record in state.get("ring_fills", [])),
+ edges,
+ )
+
+ def ring_center(bond: Bond) -> Any:
+ ring = ring_ids(bond)
+ return (
+ None
+ if ring is None
+ else point(
+ sum(model.atoms[i].x for i in ring) / len(ring),
+ sum(model.atoms[i].y for i in ring) / len(ring),
+ )
+ )
+
+ ports = perspective_geometry(state, model, ring_ids)
+
+ def label_rect(atom_id: int | None) -> bool | None:
+ if atom_id is None:
+ return None
+ atom = model.atoms.get(atom_id)
+ return True if atom and atom_shows_itself(atom) else None
+
+ metrics = RenderMetrics()
+ metrics.set_bond_length(state["settings"]["bond_length_px"])
+ # Qt supplies a rounded QPainterPath stroke; the browser supplies sampled ink.
+ # A circumscribed 64-sided disk bounds the same radius without a Qt dependency.
+ radius = glyph_clearance_radius(metrics.bond_line_width()) / math.cos(math.pi / 64)
+ disk = [
+ (radius * math.cos(i * math.tau / 64), radius * math.sin(i * math.tau / 64))
+ for i in range(64)
+ ]
+ contours = {}
+ for atom_id, ink in (label_ink or {}).items():
+ hull = glyph_convex_hull(ink)
+ expanded = glyph_convex_hull(
+ (x + dx, y + dy) for x, y in hull for dx, dy in disk
+ )
+ if expanded:
+ contours[atom_id] = [*expanded, expanded[0]]
+
+ def trim_labels(
+ a_id: int | None,
+ b_id: int | None,
+ x1: float,
+ y1: float,
+ x2: float,
+ y2: float,
+ offsets: tuple[tuple[float, float], ...] = (),
+ ) -> tuple[float, float]:
+ t0, t1 = 0.0, 1.0
+ for atom_id, start in ((a_id, True), (b_id, False)):
+ contour = contours.get(atom_id) if atom_id is not None else None
+ if contour is None:
+ continue
+ clipped = glyph_contour_clip_t(
+ (x1, y1),
+ (x2, y2),
+ [contour],
+ offsets,
+ start_inside=polygon_contains_point((x1, y1), contour),
+ end_inside=polygon_contains_point((x2, y2), contour),
+ )
+ if clipped is not None:
+ if start:
+ t0 = max(t0, clipped[1])
+ else:
+ t1 = min(t1, clipped[0])
+ return t0, max(t0, t1)
+
+ geometry: Any = SimpleNamespace(
+ trim_line_for_labels=trim_labels,
+ label_rect_for_atom=label_rect,
+ line_normal=line_normal,
+ ring_center_for_bond=ring_center,
+ **{key: port for key, port in ports.items() if key != "rotation_state"},
+ )
+ rendering = SimpleNamespace(
+ bond_spacing=metrics.bond_spacing,
+ bond_line_width=metrics.bond_line_width,
+ bold_bond_width=metrics.bold_bond_width,
+ hash_spacing=metrics.hash_spacing,
+ bold_bond_pen=lambda: SimpleNamespace(widthF=metrics.bold_bond_width),
+ style=metrics.style,
+ )
+ context: Any = SimpleNamespace(
+ model=model,
+ state=SimpleNamespace(
+ graph_state=graph,
+ rotation_state=ports["rotation_state"],
+ ),
+ geometry=geometry,
+ renderer=rendering,
+ )
+ lines = BondLineGeometryService(context, point_factory=point)
+ ring_lines = BondRingDoubleGeometryService(context, renderer=geometry)
+ for name in (
+ "parallel_bond_segments",
+ "plain_double_segments",
+ "hash_segments",
+ "hash_topology_count",
+ ):
+ setattr(geometry, name, getattr(lines, name))
+ # Preserve the native dot data until SVG materialization instead of making a Qt path.
+ geometry.dotted_bond_path = lines.dotted_bond_dots
+ geometry.wedge_polygon = lines.wedge_triangle
+ geometry.ring_double_segments = ring_lines.ring_double_segments
+ geometry.graphics_drawer = BondGraphicsDrawService(
+ context,
+ renderer=geometry,
+ point_factory=point,
+ polygon_factory=lambda points: [(p.x(), p.y()) for p in points],
+ )
+ planner = BondGeometryPlanService(context, renderer=geometry)
+ result: dict[str, list[dict[str, Any]]] = {}
+ selection_parts = {}
+ for index, bond in enumerate(model.bonds):
+ if not bond:
+ continue
+ primitives = planner.primitives_for_bond(
+ bond, model.atoms[bond.a], model.atoms[bond.b]
+ )
+ result[str(index)] = []
+ for item in primitives:
+ if isinstance(item, BondLinePrimitive):
+ result[str(index)].append({"line": item.segment})
+ elif isinstance(item, BondPolygonPrimitive):
+ result[str(index)].append(
+ {
+ "polygon": list(cast("Any", item.polygon)),
+ "outlined": item.outlined,
+ }
+ )
+ elif isinstance(item, BondPathPrimitive):
+ centers, radius = cast("Any", item.path)
+ result[str(index)].append({"dots": centers, "radius": radius})
+ parts = []
+ for primitive in result[str(index)]:
+ if "line" in primitive:
+ segment = primitive["line"]
+ parts.append(
+ {
+ **primitive,
+ "empty": segment[:2] == segment[2:],
+ "width": selection_bond_overlay_width(
+ metrics.bond_line_width(),
+ metrics.style.bond_spacing_px,
+ atom_pick_radius(metrics),
+ ),
+ }
+ )
+ else:
+ parts.append(
+ {
+ "shape": primitive,
+ "empty": not primitive.get("polygon", primitive.get("dots")),
+ }
+ )
+ selection_parts[str(index)] = selection_bond_parts(
+ parts,
+ bond=bond,
+ atom_a=model.atoms[bond.a],
+ atom_b=model.atoms[bond.b],
+ trim_line=trim_labels,
+ spacing=metrics.style.bond_spacing_px,
+ in_ring=bond.order == 2 and ring_center(bond) is not None,
+ )
+ brackets = []
+ for source in state["ts_brackets"]:
+ bounds = bracket_rect(source)
+ kind = source["bracket_kind"]
+ pixels, x, y = bracket_symbol_layout(bounds, metrics.style.bond_length_px)
+ path = bracket_path_commands(bounds, kind, metrics.style.bond_length_px)
+ width = bracket_stroke_width(metrics.style.bond_line_width)
+ brackets.append(
+ {
+ "kind": kind,
+ "path": path,
+ "width": width,
+ "color": metrics.style.bond_color,
+ # Glyph bounds need the measured font; BrowserFontMeasurements adds them.
+ "bounds": points_bounds(
+ [point for ring in bracket_outline(path, width) for point in ring]
+ )
+ if path
+ else None,
+ "symbol": {
+ "text": BRACKET_SYMBOLS[kind],
+ "x": x,
+ "y": y,
+ "pixels": pixels,
+ "family": metrics.style.font_family,
+ }
+ if kind in BRACKET_SYMBOLS
+ else None,
+ }
+ )
+ orbitals = []
+ for source in state["orbitals"]:
+ record = orbital_from_state(
+ {**source, "kind": "orbital", "orbital_kind": source["kind"]}
+ )
+ ellipses, node = orbital_geometry(
+ record.center, record.kind, metrics.style.bond_length_px
+ )
+ pad = metrics.bond_line_width() / 2
+ left = min(x for x, _y, _w, _h, _phase in ellipses) - pad
+ top = min(y for _x, y, _w, _h, _phase in ellipses) - pad
+ right = max(x + w for x, _y, w, _h, _phase in ellipses) + pad
+ bottom = max(y + h for _x, y, _w, h, _phase in ellipses) + pad
+ if node is not None:
+ left = min(left, min(node[0], node[2]) - pad)
+ top = min(top, min(node[1], node[3]) - pad)
+ right = max(right, max(node[0], node[2]) + pad)
+ bottom = max(bottom, max(node[1], node[3]) + pad)
+ cx, cy = record.center
+ corners = [
+ rotated_point_coordinates(
+ BrowserPoint(
+ cx + (x - cx) * record.scale, cy + (y - cy) * record.scale
+ ),
+ BrowserPoint(cx, cy),
+ math.radians(record.rotation),
+ )
+ for x, y in ((left, top), (right, top), (right, bottom), (left, bottom))
+ ]
+ xs, ys = zip(*corners, strict=True)
+ orbitals.append(
+ {
+ "kind": record.kind,
+ "center": record.center,
+ "scale": record.scale,
+ "rotation": record.rotation,
+ "hit_rect": (left, top, right - left, bottom - top),
+ "polygon": corners,
+ "handles": [
+ {"handle": handle, "point": point}
+ for handle, point in zip(
+ ("scale", "rotate"),
+ orbital_handle_positions(
+ record.center, metrics.style.bond_length_px * 0.8
+ ),
+ strict=True,
+ )
+ ],
+ "bounds": (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)),
+ "ellipses": ellipses,
+ "node": node,
+ "width": metrics.bond_line_width(),
+ "color": metrics.style.bond_color,
+ "positive": metrics.style.orbital_positive_color,
+ "negative": metrics.style.orbital_negative_color,
+ "alpha": metrics.style.orbital_alpha,
+ "phase": state["settings"]["orbital_phase_enabled"],
+ }
+ )
+ return {
+ "bonds": result,
+ "selection_bonds": selection_parts,
+ "atom_labels": {
+ str(i): atom.element
+ for i, atom in model.atoms.items()
+ if atom_shows_itself(atom)
+ },
+ "line_width": metrics.bond_line_width(),
+ "font_size": metrics.atom_font_size_pt(),
+ "label_measurements": browser_label_layouts(model, metrics),
+ "atom_hit_radii": {
+ str(atom_id): atom_pick_radius(metrics)
+ if not atom_shows_itself(atom) or uses_compact_label_hit_shape(atom.element)
+ else None
+ for atom_id, atom in model.atoms.items()
+ },
+ # View > Valence Checking's warned atoms, from the desktop's own policy.
+ "valence_warnings": sorted(overvalent_atom_ids(model)),
+ "arrows": arrow_geometry(state, metrics),
+ "selection_style": {
+ "screen_width": SELECTION_OUTLINE_SCREEN_PX,
+ "color": HANDLE_ACCENT_COLOR,
+ "group_screen_width": GROUP_BOX_SCREEN_PX,
+ "group_dash": GROUP_BOX_DASH_PATTERN,
+ },
+ "shapes": shape_geometry(state, metrics),
+ "orbitals": orbitals,
+ "brackets": brackets,
+ "images": image_geometry(state),
+ "note_style": browser_note_style(state["settings"]),
+ }
+
+
+# The sanitizer's inline tags; lists, quotes, divs and stay unrendered.
+_NOTE_INLINE_TAGS = SAFE_NOTE_HTML_TAGS - {
+ "blockquote",
+ "br",
+ "div",
+ "font",
+ "li",
+ "ol",
+ "p",
+ "ul",
+}
+_NOTE_POINT_SIZE_RE = re.compile(r"(\d{1,3}(?:\.\d{1,2})?)pt\Z")
+# Qt gives a block without its own margins to plain text and bare inline HTML.
+# The page's CSP ignores style attributes; the browser applies data-style itself.
+_NOTE_PLAIN_BLOCK = (
+ '
'
+)
+
+
+def qt_script_pixels(point_size: float) -> int:
+ """QTextEngine scales sub/superscript runs to two thirds of QFont::pointSize()."""
+ return browser_font_pixels(max(1, math.floor(point_size + 0.5) * 2 // 3))
+
+
+class _BrowserNoteHtml(HTMLParser):
+ """Qt's saved note subset, with point sizes resolved to Qt's integer pixels."""
+
+ def __init__(self, point_size: float) -> None:
+ super().__init__(convert_charrefs=True)
+ self.parts: list[str] = []
+ # Open elements as (tag, unscaled point size, script alignment).
+ self.stack: list[tuple[str, float, str | None]] = []
+ self.point_size = point_size
+ self.implicit_block = False
+ # Qt writes an empty paragraph as a marked block holding one .
+ self.empty_block = False
+
+ def open_block(self) -> None:
+ if not self.stack:
+ self.parts.append(_NOTE_PLAIN_BLOCK)
+ self.stack.append(("p", self.point_size, None))
+ self.implicit_block = True
+
+ def close_implicit_block(self) -> None:
+ if self.implicit_block:
+ while self.stack:
+ self.parts.append(f"{self.stack.pop()[0]}>")
+ self.implicit_block = False
+
+ @override
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag == "br":
+ if not self.empty_block:
+ self.open_block()
+ self.parts.append(" ")
+ return
+ if tag == "p":
+ self.close_implicit_block()
+ if self.stack:
+ raise ValueError("Nested note paragraphs have no browser renderer.")
+ elif tag in _NOTE_INLINE_TAGS:
+ self.open_block()
+ else:
+ raise ValueError(
+ "Note lists and block formatting have no browser renderer."
+ )
+ _, point_size, script = (
+ self.stack[-1] if self.stack else ("", self.point_size, None)
+ )
+ sized = False
+ styles, extra = [], ""
+ for name, value in attrs:
+ if name == "align" and value is not None:
+ extra += f' align="{escape(value)}"'
+ if name != "style" or value is None:
+ continue
+ for declaration in value.split(";"):
+ if ":" not in declaration:
+ continue
+ key, text = (part.strip() for part in declaration.split(":", 1))
+ if key == "font-size":
+ match = _NOTE_POINT_SIZE_RE.fullmatch(text)
+ if match is None:
+ raise ValueError("Note font sizes need Qt point units.")
+ point_size, sized = float(match.group(1)), True
+ elif key == "vertical-align":
+ if text == "baseline" and script is None:
+ continue
+ if text not in {"sub", "super"} or script is not None:
+ raise ValueError(
+ "Nested note script alignment has no browser renderer."
+ )
+ script = text
+ elif key == "-qt-paragraph-type":
+ self.empty_block = tag == "p"
+ elif key != "line-height":
+ # Document line spacing replaces saved block heights.
+ styles.append(f"{key}:{text}")
+ if tag == "p" and script is not None:
+ raise ValueError("Paragraph script alignment has no browser renderer.")
+ if tag in {"sub", "sup"}:
+ if script is not None:
+ raise ValueError(
+ "Nested note script alignment has no browser renderer."
+ )
+ script = "sub" if tag == "sub" else "super"
+ tag = "span"
+ parent_script = self.stack[-1][2] if self.stack else None
+ if script is not None and (script != parent_script or sized):
+ styles.append(f"font-size:{qt_script_pixels(point_size)}px")
+ if script != parent_script:
+ extra += (
+ f' data-script="{script}"'
+ f' data-base-pixels="{browser_font_pixels(point_size)}"'
+ )
+ elif sized:
+ styles.append(f"font-size:{browser_font_pixels(point_size)}px")
+ if sized:
+ # The editor saves the Qt point size, not its rounded pixels.
+ extra += f' data-pt="{point_size:g}"'
+ style = f' data-style="{escape("; ".join(styles))}"' if styles else ""
+ self.parts.append(f"<{tag}{style}{extra}>")
+ self.stack.append((tag, point_size, script))
+
+ @override
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "p":
+ self.implicit_block = self.empty_block = False
+ if self.stack:
+ self.parts.append(f"{self.stack.pop()[0]}>")
+
+ @override
+ def handle_data(self, data: str) -> None:
+ if self.empty_block or (not self.stack and not data.strip()):
+ return
+ self.open_block()
+ self.parts.append(escape(data, quote=False))
+
+
+def browser_note_html(note: dict[str, Any], point_size: float) -> str:
+ """The HTML Qt restores for a note, as browser markup; raises when unsupported."""
+ html = sanitize_note_html(note.get("html"))
+ if html is None:
+ return "".join(
+ f"{_NOTE_PLAIN_BLOCK}{escape(line, quote=False)}
"
+ for line in str(note.get("text", "")).split("\n")
+ )
+ parser = _BrowserNoteHtml(point_size)
+ parser.feed(html)
+ parser.close()
+ parser.close_implicit_block()
+ return "".join(parser.parts)
+
+
+def browser_note_style(settings: dict[str, Any]) -> dict[str, Any]:
+ """The document font, alignment and spacing that apply_note_style gives notes."""
+ return {
+ "family": settings["text_font_family"],
+ "pixels": browser_font_pixels(settings["text_font_size"]),
+ "point_size": settings["text_font_size"],
+ "weight": settings["text_font_weight"],
+ "italic": settings["text_italic"],
+ "align": settings["text_alignment"],
+ "line_spacing": settings["text_line_spacing"],
+ }
+
+
+class _NotePlainText(HTMLParser):
+ """QTextDocument::toPlainText over the saved note subset."""
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.parts: list[str] = []
+ self.blocks = 0
+
+ @override
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag == "br":
+ self.parts.append("\n")
+ elif tag == "p":
+ if self.blocks:
+ self.parts.append("\n")
+ self.blocks += 1
+
+ @override
+ def handle_data(self, data: str) -> None:
+ self.parts.append(data.replace("\xa0", " "))
+
+
+def note_plain_text(html: str) -> str:
+ parser = _NotePlainText()
+ parser.feed(browser_note_html({"html": html}, 12))
+ parser.close()
+ return "".join(parser.parts)
+
+
+class _RecoloredNoteHtml(HTMLParser):
+ """Merge one foreground colour over a whole note, as the Color tool does."""
+
+ def __init__(self, color: str) -> None:
+ super().__init__(convert_charrefs=True)
+ self.color = color
+ self.parts: list[str] = []
+ self.stack: list[str] = []
+ self.block = (0, False)
+ # Qt's empty paragraph holds a marker , not a line separator.
+ self.empty_marker = False
+
+ @override
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ styles = [
+ declaration.strip()
+ for name, value in attrs
+ if name == "style" and value
+ for declaration in value.split(";")
+ if declaration.strip()
+ and declaration.split(":", 1)[0].strip().lower() != "color"
+ ]
+ if tag not in {"p", "br"}:
+ styles.append(f"color:{self.color}")
+ kept = "".join(
+ f' {name}="{escape(value)}"'
+ for name, value in attrs
+ if name not in {"style", "color"} and value is not None
+ )
+ style = f' style="{escape("; ".join(styles))}"' if styles else ""
+ if tag == "p":
+ self.block = (len(self.parts), False)
+ self.empty_marker = any(
+ declaration.startswith("-qt-paragraph-type") for declaration in styles
+ )
+ if tag == "br" and self.stack[-1:] == ["p"] and not self.empty_marker:
+ # A line separator directly in a paragraph is a coloured run too.
+ self.parts.append(f' ')
+ return
+ self.parts.append(f"<{tag}{kept}{style}>")
+ if tag != "br":
+ self.stack.append(tag)
+
+ @override
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "br" or not self.stack:
+ return
+ self.stack.pop()
+ if tag == "p" and not self.block[1]:
+ # QTextDocument colours an empty block's own character format.
+ index = self.block[0]
+ opening = self.parts[index]
+ self.parts[index] = (
+ opening.replace(' style="', f' style="color:{self.color}; ', 1)
+ if ' style="' in opening
+ else opening.replace("
")
+
+ @override
+ def handle_data(self, data: str) -> None:
+ text = escape(data, quote=False)
+ if data:
+ self.block = (self.block[0], True)
+ # Text directly in a paragraph gets its own coloured run.
+ self.parts.append(
+ f'{text}'
+ if self.stack[-1:] == ["p"]
+ else text
+ )
+
+
+def recolored_note(note: dict[str, Any], color: str) -> str:
+ """The saved note HTML with every character in the given colour."""
+ html = sanitize_note_html(note.get("html")) or "".join(
+ f'
{escape(line, quote=False)}
'
+ for line in str(note.get("text", "")).split("\n")
+ )
+ parser = _RecoloredNoteHtml(color)
+ parser.feed(html)
+ parser.close()
+ return sanitize_note_html("".join(parser.parts)) or ""
+
+
+def saved_note_text(html: object) -> tuple[str, str]:
+ """The sanitized note HTML the desktop saves, with its toPlainText text."""
+ if not isinstance(html, str) or len(html) > MAX_NOTE_HTML_CHARS:
+ raise ValueError("Expected bounded note HTML.")
+ saved = sanitize_note_html(html) or ""
+ # Raises for formatting the browser could not render back.
+ return saved, note_plain_text(saved) if saved else ""
+
+
+def browser_notes(state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Notes as apply_note_style restores them, for the browser's text layout."""
+ settings = state["settings"]
+ style = browser_note_style(settings)
+ notes = []
+ for index, note in enumerate(state["notes"]):
+ try:
+ html = browser_note_html(note, settings["text_font_size"])
+ except ValueError:
+ # The read-only preview shows unsupported formatting as its text.
+ html = browser_note_html({"text": note.get("text", "")}, 0)
+ key = hashlib.sha256(
+ json.dumps(["note", style, html], sort_keys=True).encode()
+ ).hexdigest()
+ notes.append(
+ {
+ **style,
+ "key": key,
+ "html": html,
+ "id": index,
+ "color": settings["text_color"],
+ }
+ )
+ return notes
+
+
+def note_rect_bounds(
+ note: dict[str, Any], left: float, top: float, right: float, bottom: float
+) -> tuple[float, float, float, float]:
+ """Scene bounds of a rectangle in a note's coordinates, turned about its position."""
+ angle = math.radians(note["rotation"])
+ cos, sin = math.cos(angle), math.sin(angle)
+ return points_bounds(
+ [
+ (note["x"] + dx * cos - dy * sin, note["y"] + dx * sin + dy * cos)
+ for dx, dy in ((left, top), (right, top), (right, bottom), (left, bottom))
+ ]
+ )
+
+
+def note_geometry(
+ record: dict[str, Any], note: dict[str, Any], box: tuple[float, ...]
+) -> dict[str, Any]:
+ """A note's measured layout rectangle, placed and rotated from its record."""
+ width, height = box
+ geometry = {
+ **note,
+ "x": float(record["x"]),
+ "y": float(record["y"]),
+ "rotation": float(record.get("rotation", 0.0)),
+ "width": width,
+ "height": height,
+ "has_text": bool(str(record.get("text", "")).strip()),
+ }
+ geometry["bounds"] = note_rect_bounds(geometry, 0, 0, width, height)
+ return geometry
+
+
+def browser_note_box(settings: dict[str, Any]) -> dict[str, Any] | None:
+ """update_note_box's padded background and border, or None when disabled."""
+ if not (settings["note_box_enabled"] or settings["note_border_enabled"]):
+ return None
+ fill = None
+ if settings["note_box_enabled"]:
+ red, green, blue = (
+ int(settings["note_box_color"][i : i + 2], 16) for i in (1, 3, 5)
+ )
+ fill = f"rgba({red},{green},{blue},{settings['note_box_alpha']})"
+ return {
+ "padding": float(settings["note_padding"]),
+ "fill": fill,
+ "stroke": settings["note_border_color"]
+ if settings["note_border_enabled"]
+ else None,
+ "width": float(settings["note_border_width"]),
+ }
+
+
+def browser_arrow_labels(state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Original rich-text syntax, with Qt font sizes for the browser font engine."""
+ settings = state["settings"]
+ size = settings["text_font_size"]
+ style = {
+ "family": settings["text_font_family"],
+ "pixels": browser_font_pixels(size),
+ "script_pixels": qt_script_pixels(size),
+ "weight": settings["text_font_weight"],
+ "italic": settings["text_italic"],
+ }
+ labels = []
+ for index, arrow in enumerate(state["arrows"]):
+ # The desktop builds label items in its fixed side order.
+ for side in ARROW_LABEL_SIDES:
+ text = arrow.get("labels", {}).get(side)
+ if text is None:
+ continue
+ html = arrow_label_html(text)
+ key = hashlib.sha256(
+ json.dumps([style, html], sort_keys=True).encode()
+ ).hexdigest()
+ labels.append(
+ {
+ **style,
+ "key": key,
+ "html": html,
+ "id": index,
+ "side": side,
+ "color": arrow.get("color") or settings["text_color"],
+ }
+ )
+ return labels
+
+
+class BrowserFontMeasurements:
+ """Bounded, replaceable browser font data; never part of document history."""
+
+ def __init__(self, request: Any) -> None:
+ if not isinstance(request, dict) or not {"family", "metrics", "ink"} <= set(
+ request
+ ) <= {"family", "metrics", "ink", "label_boxes"}:
+ raise ValueError("Expected font family, metrics and glyph ink.")
+ if request["family"] != ACS1996Style().font_family:
+ raise ValueError("Unexpected browser font family.")
+ measurements, ink = request["metrics"], request["ink"]
+ for values in (measurements, ink):
+ if (
+ not isinstance(values, dict)
+ or len(values) > 8192
+ or any(not isinstance(key, str) or len(key) > 300 for key in values)
+ ):
+ raise ValueError("Expected bounded font measurements.")
+ validate_font_metrics(measurements)
+ point_count = 0
+ for points in ink.values():
+ if (
+ not isinstance(points, list)
+ or len(points) > 4096
+ or any(
+ not isinstance(point, list)
+ or len(point) != 2
+ or any(
+ type(value) not in (int, float, Decimal)
+ or not math.isfinite(value)
+ for value in point
+ )
+ for point in points
+ )
+ ):
+ raise ValueError("Glyph ink must contain bounded finite points.")
+ point_count += len(points)
+ if point_count > 500_000:
+ raise ValueError("Measured glyph geometry is too large.")
+ boxes = request.get("label_boxes", {})
+ if (
+ not isinstance(boxes, dict)
+ or len(boxes) > 8192
+ or any(
+ not isinstance(key, str)
+ or len(key) != 64
+ or not isinstance(box, list)
+ or len(box) != 2
+ or any(
+ type(value) not in (int, float, Decimal)
+ or not math.isfinite(value)
+ or not 0 < value <= 100_000
+ for value in box
+ )
+ for key, box in boxes.items()
+ )
+ ):
+ raise ValueError("Expected bounded rich text boxes.")
+ self.label_boxes = {
+ key: tuple(float(value) for value in box) for key, box in boxes.items()
+ }
+ self.metrics = deepcopy(measurements)
+ self.ink = {
+ key: glyph_convex_hull((float(x), float(y)) for x, y in points)
+ for key, points in ink.items()
+ }
+
+ def drawing(self, state: dict[str, Any]) -> dict[str, Any]:
+ model = deserialize_model_state(state["model"])
+ metrics = RenderMetrics()
+ metrics.set_bond_length(state["settings"]["bond_length_px"])
+ spec = browser_label_layouts(model, metrics)
+ rich_labels = browser_arrow_labels(state)
+ spec["arrow_labels"] = rich_labels
+ notes = browser_notes(state)
+ spec["notes"] = notes
+ mark_labels = [
+ (
+ mark.get("text")
+ if mark.get("text") is not None
+ else "+"
+ if mark["kind"] == "plus"
+ else "-",
+ None,
+ False,
+ None,
+ )
+ for mark in state["marks"]
+ if mark["kind"] in {"plus", "minus"}
+ ]
+ mark_labels = [label for label in mark_labels if label[0]]
+ glyph_queries = bracket_glyph_queries(state, metrics.style.bond_length_px)
+ if (
+ not spec["labels"]
+ and not rich_labels
+ and not notes
+ and not state["marks"]
+ and not glyph_queries
+ ):
+ return drawing_geometry(state)
+ spec["queries"] = [
+ *_browser_label_queries(
+ spec["size"],
+ list(spec["labels"].values())
+ + mark_labels
+ + ([("H", None, False, None)] if state["marks"] else []),
+ ),
+ *glyph_queries,
+ ]
+ if any(
+ label["key"] not in self.label_boxes for label in [*rich_labels, *notes]
+ ) or any(
+ query["key"] not in self.metrics
+ or f"{query['pixels']}:{query['text']}" not in self.ink
+ for query in spec["queries"]
+ ):
+ # Do not render a throwaway, unclipped scene while asking for its font.
+ return {"label_measurements": spec, "needs_measurements": True}
+ labels = list(
+ dict.fromkeys(
+ [tuple(label) for label in spec["labels"].values()] + mark_labels
+ )
+ )
+ relative = dict(
+ zip(
+ labels,
+ place_browser_labels(spec["size"], labels, self.metrics),
+ strict=True,
+ )
+ )
+ layouts, label_ink, hit_rects, selection_rects, label_rects = {}, {}, {}, {}, {}
+ point_count = 0
+ for key, label in spec["labels"].items():
+ atom_id = int(key)
+ atom = model.atoms[atom_id]
+ runs = [
+ {
+ **run,
+ "x": atom.x + spec["offset"] + run["x"],
+ "y": atom.y - spec["offset"] + run["y"],
+ }
+ for run in relative[tuple(label)]["runs"]
+ ]
+ bx, by, bw, bh = relative[tuple(label)]["rect"]
+ label_rects[key] = (
+ atom.x + spec["offset"] + bx,
+ atom.y - spec["offset"] + by,
+ bw,
+ bh,
+ )
+ selection_rects[key] = label_bounding_rect(
+ (atom.x + spec["offset"] + bx, atom.y - spec["offset"] + by, bw, bh),
+ metrics.style.bond_length_px * ATOM_LABEL_HIT_PADDING_RATIO,
+ atom_pick_radius(metrics)
+ if uses_compact_label_hit_shape(atom.element)
+ else None,
+ )
+ if any(not math.isfinite(value) for value in selection_rects[key]):
+ raise ValueError("Measured label bounds overflowed.")
+ if any(not math.isfinite(run[key]) for run in runs for key in ("x", "y")):
+ raise ValueError("Measured label coordinates overflowed.")
+ points = [
+ (x + run["x"], y + run["y"])
+ for run in runs
+ for x, y in self.ink[f"{run['pixels']}:{run['text']}"]
+ ]
+ point_count += len(points)
+ if point_count > 500_000:
+ raise ValueError("Measured label geometry is too large.")
+ if any(not math.isfinite(value) for point in points for value in point):
+ raise ValueError("Measured label coordinates overflowed.")
+ layouts[key], label_ink[atom_id] = runs, points
+ if points:
+ hit_rects[key] = points_bounds(points)
+ drawing = drawing_geometry(state, label_ink)
+ positioned = []
+ for label in rich_labels:
+ record = normalized_arrow_control(
+ arrow_from_state(state["arrows"][label["id"]])
+ )
+ points = [
+ (values[i], values[i + 1])
+ for _, values in drawing["arrows"][label["id"]]["path"]
+ for i in range(0, len(values), 2)
+ ]
+ width, height = self.label_boxes[label["key"]]
+ x, y = arrow_label_position(
+ record,
+ points,
+ bond_spacing=metrics.style.bond_spacing_px,
+ side=label["side"],
+ width=width,
+ height=height,
+ )
+ if not all(math.isfinite(value) for value in (x, y)):
+ raise ValueError("Measured arrow label coordinates overflowed.")
+ positioned.append(
+ {**label, "x": x, "y": y, "width": width, "height": height}
+ )
+ marks = []
+ for index, mark in enumerate(state["marks"]):
+ center = mark_center_coordinates(mark, model.atoms)
+ if center is None:
+ continue
+ x, y = center
+ kind = mark["kind"]
+ geometry = {
+ "id": index,
+ "kind": kind,
+ "x": x,
+ "y": y,
+ "color": mark.get("color") or metrics.style.atom_color,
+ }
+ if kind in {"plus", "minus"}:
+ text = mark.get("text")
+ text = ("+" if kind == "plus" else "-") if text is None else text
+ runs = relative[(text, None, False, None)]["runs"] if text else []
+ if text:
+ bx, by, bw, bh = relative[(text, None, False, None)]["rect"]
+ else:
+ height = float(self.metrics[f"{spec['size']}:H"]["line_height"])
+ margin = ATOM_LABEL_DOCUMENT_MARGIN
+ bx, by, bw, bh = (
+ -margin,
+ -height / 2 - margin,
+ margin * 2,
+ height + margin * 2,
+ )
+ geometry["runs"] = [
+ {**run, "x": x + run["x"], "y": y + run["y"]} for run in runs
+ ]
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+ if len(lines) > 1 and all(
+ run.role == "normal" for run in parse_atom_label(text)
+ ):
+ base = self.metrics[f"{spec['size']}:H"]
+ width = max(
+ float(self.metrics[f"{spec['size']}:{line}"]["width"])
+ for line in lines
+ )
+ height = float(base["line_height"]) * len(lines)
+ margin = ATOM_LABEL_DOCUMENT_MARGIN
+ bx, by, bw, bh = (
+ -width / 2 - margin,
+ -height / 2 - margin,
+ width + margin * 2,
+ height + margin * 2,
+ )
+ geometry["runs"] = [
+ {
+ "text": line,
+ "size": spec["size"],
+ "pixels": browser_font_pixels(spec["size"]),
+ "x": x - width / 2,
+ "y": y
+ - height / 2
+ + float(base["ascent"])
+ + row * float(base["line_height"]),
+ }
+ for row, line in enumerate(lines)
+ if line
+ ]
+ geometry["bounds"] = label_bounding_rect(
+ (x + bx, y + by, bw, bh), 0.0, atom_pick_radius(metrics)
+ )
+ ink = [
+ (px + run["x"], py + run["y"])
+ for run in geometry["runs"]
+ for px, py in self.ink[f"{run['pixels']}:{run['text']}"]
+ ]
+ if ink:
+ geometry["hit_rect"] = points_bounds(ink)
+ else:
+ base_font = self.metrics[f"{spec['size']}:H"]
+ radius, stroke, extent = mark_dimensions(
+ kind,
+ metrics.style.bond_line_width,
+ float(base_font["ascent"]) + float(base_font["descent"]),
+ )
+ geometry.update(radius=radius, stroke=stroke, extent=extent)
+ hit_pad = max(0.0, atom_pick_radius(metrics) - radius)
+ # Qt path bounds include the custom hit stroke before adding padding.
+ bound_radius = (
+ (radius + max(stroke / 2, hit_pad) + hit_pad)
+ if kind.startswith("circled_")
+ else radius + hit_pad
+ )
+ geometry["bounds"] = (
+ x - bound_radius,
+ y - bound_radius,
+ bound_radius * 2,
+ bound_radius * 2,
+ )
+ geometry["hit_radius"] = atom_pick_radius(metrics)
+ marks.append(geometry)
+ if any(
+ not math.isfinite(value)
+ for mark in marks
+ for value in (
+ mark["x"],
+ mark["y"],
+ mark.get("radius", 0),
+ mark.get("stroke", 0),
+ *mark["bounds"],
+ *mark.get("hit_rect", ()),
+ *[run[key] for run in mark.get("runs", []) for key in ("x", "y")],
+ )
+ ):
+ raise ValueError("Measured mark coordinates overflowed.")
+ result = {
+ **drawing,
+ "atom_layouts": layouts,
+ "atom_label_rects": label_rects,
+ "atom_hit_rects": hit_rects,
+ "atom_selection_rects": selection_rects,
+ "mark_owner_rects": {
+ str(atom_id): selection_atom_rect(
+ atom.x,
+ atom.y,
+ atom_pick_radius(metrics),
+ selection_rects.get(str(atom_id)),
+ )
+ for atom_id, atom in model.atoms.items()
+ },
+ "arrow_labels": positioned,
+ "marks": marks,
+ "notes": [
+ note_geometry(
+ state["notes"][note["id"]], note, self.label_boxes[note["key"]]
+ )
+ for note in notes
+ ],
+ "note_box": browser_note_box(state["settings"]),
+ # The selection box pads the layout rectangle like the note box.
+ "note_padding": float(state["settings"]["note_padding"]),
+ }
+ for bracket in result["brackets"]:
+ symbol = bracket["symbol"]
+ glyph = self.ink[f"{symbol['pixels']}:{symbol['text']}"] if symbol else None
+ if glyph:
+ outline = [(symbol["x"] + x, symbol["y"] + y) for x, y in glyph]
+ bracket["bounds"] = points_bounds(outline)
+ # Convex ink hull: the pick outline Qt takes from the glyph path.
+ bracket["outline"] = [*outline, outline[0]]
+ if marks:
+ result["mark_owners"] = BrowserStructureAdapter(state).mark_owner_feedback(
+ result, self
+ )
+ return result
+
+
+@dataclass(frozen=True)
+class BrowserPoint:
+ """Coordinate boundary for existing services that consume x()/y() points."""
+
+ px: float
+ py: float
+
+ def x(self) -> float:
+ return self.px
+
+ def y(self) -> float:
+ return self.py
+
+
+@dataclass(frozen=True)
+class BrowserRect:
+ """Rectangle port for the original native alignment calculations."""
+
+ x: float
+ y: float
+ w: float
+ h: float
+
+ def left(self) -> float:
+ return self.x
+
+ def top(self) -> float:
+ return self.y
+
+ def right(self) -> float:
+ return self.x + self.w
+
+ def bottom(self) -> float:
+ return self.y + self.h
+
+ def width(self) -> float:
+ return self.w
+
+ def height(self) -> float:
+ return self.h
+
+ def center(self) -> BrowserPoint:
+ return BrowserPoint(self.x + self.w / 2, self.y + self.h / 2)
+
+ def isValid(self) -> bool: # noqa: N802 - graphics port
+ return self.w > 0 and self.h > 0
+
+ def united(self, other: BrowserRect) -> BrowserRect:
+ if not self.w and not self.h:
+ return other
+ if not other.w and not other.h:
+ return self
+ x, y = min(self.x, other.x), min(self.y, other.y)
+ return BrowserRect(
+ x,
+ y,
+ max(self.right(), other.right()) - x,
+ max(self.bottom(), other.bottom()) - y,
+ )
+
+
+@dataclass
+class BrowserRingItem:
+ """Ring graphics port: the shared fitter writes a candidate's polygon."""
+
+ record: dict[str, Any]
+
+ def data(self, role: int) -> Any:
+ return "ring" if role == 0 else self.record["atom_ids"] if role == 2 else None
+
+ def setPolygon(self, points: tuple[BrowserPoint, ...]) -> None: # noqa: N802 - graphics port
+ self.record["points"] = [(point.x(), point.y()) for point in points]
+
+
+@dataclass(eq=False)
+class BrowserSceneItem:
+ """Annotation graphics port over the candidate's canonical document record."""
+
+ record: dict[str, Any]
+ bounds: BrowserRect | None = None
+
+ def data(self, role: int) -> Any:
+ return self.record["kind"] if role == 0 else self.record_id(role)
+
+ def record_id(self, role: int) -> int | None:
+ # Role 3 is the desktop's scene record id, which groups store.
+ return id(self.record) if role == 3 else None
+
+ def sceneBoundingRect(self) -> BrowserRect: # noqa: N802 - graphics port
+ if self.bounds is None:
+ raise ValueError("Alignment bounds have not been measured.")
+ return self.bounds
+
+
+@dataclass(eq=False)
+class BrowserNoteItem(BrowserSceneItem):
+ @override
+ def data(self, role: int) -> Any:
+ return "note" if role == 0 else self.record_id(role)
+
+ def moveBy(self, dx: float, dy: float) -> None: # noqa: N802 - graphics port
+ self.record["x"] = float(self.record["x"]) + dx
+ self.record["y"] = float(self.record["y"]) + dy
+
+
+@dataclass(eq=False)
+class BrowserOrbitalItem(BrowserSceneItem):
+ @override
+ def data(self, role: int) -> Any:
+ return "orbital" if role == 0 else self.record_id(role)
+
+ def orbital_state(self) -> dict[str, object]:
+ return {**self.record, "kind": "orbital", "orbital_kind": self.record["kind"]}
+
+ def apply_orbital_state(self, state: Mapping[str, object]) -> None:
+ record = orbital_from_state({**self.orbital_state(), **state})
+ if record.kind != self.record["kind"]:
+ raise ValueError("An orbital kind cannot be replaced in place.")
+ self.record.update(
+ kind=record.kind,
+ center=record.center,
+ scale=record.scale,
+ rotation=record.rotation,
+ )
+
+
+@dataclass(eq=False)
+class BrowserMarkItem:
+ """The native move controller sees a mark item; the browser stores its record."""
+
+ record: dict[str, Any]
+ center: BrowserPoint
+ bounds: BrowserRect | None = None
+
+ def sceneBoundingRect(self) -> BrowserRect: # noqa: N802 - graphics port
+ if self.bounds is None:
+ raise ValueError("Alignment bounds have not been measured.")
+ return self.bounds
+
+ def data(self, role: int) -> Any:
+ if role == 0:
+ return "mark"
+ return self.record if role == 1 else id(self.record) if role == 3 else None
+
+ def setData(self, role: int, value: dict[str, Any]) -> None: # noqa: N802 - graphics port
+ if role != 1:
+ raise ValueError("Only mark metadata can be changed.")
+ self.record.update(value)
+
+ def moveBy(self, dx: float, dy: float) -> None: # noqa: N802 - graphics port
+ self.set_center(BrowserPoint(self.center.x() + dx, self.center.y() + dy))
+
+ def set_center(self, center: BrowserPoint) -> None:
+ self.center = center
+ self.record.update(x=center.x(), y=center.y())
+
+
+class BrowserAtomLabelService(AtomLabelService):
+ """Label service that reports merges, which the desktop records in history."""
+
+ def __init__(
+ self, *args: Any, on_merge: Callable[[int, set[int]], None], **kwargs: Any
+ ) -> None:
+ super().__init__(*args, **kwargs)
+ self.on_merge = on_merge
+
+ @override
+ def merge_overlapping_atoms(self, atom_id: int) -> tuple[list[int], dict[str, Any]]:
+ merge_ids, merge_info = super().merge_overlapping_atoms(atom_id)
+ if merge_ids:
+ self.on_merge(atom_id, set(merge_ids))
+ return merge_ids, merge_info
+
+
+class BrowserStructureAdapter:
+ """Materialize existing structure builders into a candidate document and SVG.
+
+ The existing committer owns atom merging, bond order and ring construction.
+ document_info renders the accepted candidate once, instead of creating
+ QGraphicsItems during each mutation.
+ """
+
+ def __init__(
+ self,
+ state: dict[str, Any],
+ *,
+ grid: str = "none",
+ mark_order: dict[int, list[int]] | None = None,
+ measured_drawing: dict[str, Any] | None = None,
+ ) -> None:
+ if not isinstance(grid, str) or grid not in GRID_MODES:
+ raise ValueError("Unknown grid mode.")
+ self.document_state = state
+ # Font-measured drawing of the unedited state, for glyph pick outlines.
+ self.measured_drawing = measured_drawing
+ self.model = deserialize_model_state(state["model"])
+ self.renderer = RenderMetrics()
+ self.renderer.set_bond_length(state["settings"]["bond_length_px"])
+ coords_3d, center_3d, anchor_2d = perspective_from_state(
+ state.get("perspective")
+ )
+ # document_info materializes the complete SVG after the build commits.
+ self.bond_renderer = SimpleNamespace(add_bond_graphics=lambda _bond_id: None)
+ self.runtime_state = SimpleNamespace(
+ tool_settings_state=CanvasToolSettingsState(
+ grid_snap_enabled=grid != "none",
+ grid_style="hex" if grid == "hex" else "square",
+ ),
+ mark_registry=CanvasMarkRegistry(),
+ graph_state=SimpleNamespace(atom_bond_ids={}),
+ atom_graphics_state=SimpleNamespace(atom_items={}, atom_dots={}),
+ bond_graphics_state=SimpleNamespace(bond_items={}),
+ atom_coords_3d_state=SimpleNamespace(atom_coords_3d=coords_3d),
+ rotation_state=SimpleNamespace(
+ projection_center_3d=center_3d, projection_anchor_2d=anchor_2d
+ ),
+ hover_preview_state=SimpleNamespace(atom_id=None),
+ handle_state=SimpleNamespace(target=None),
+ ring_items=lambda: (),
+ callback_state=SimpleNamespace(error=self.reject_edit),
+ )
+ self.services = SimpleNamespace(
+ canvas_atom_mutation_service=self.model,
+ canvas_bond_mutation_service=self.model,
+ scene_item_controller=SimpleNamespace(attach_scene_item=self.attach_ring),
+ scene_decoration_build_service=SimpleNamespace(
+ mark_center=lambda item: item.center,
+ set_mark_center=lambda item, center: item.set_center(center),
+ ),
+ )
+ self.mark_items = []
+ for record in state["marks"]:
+ center = mark_center_coordinates(record, self.model.atoms)
+ if center is None:
+ raise ValueError("A mark requires a valid center.")
+ item = BrowserMarkItem(record, BrowserPoint(*center))
+ self.mark_items.append(item)
+ if record["atom_id"] is not None:
+ self.runtime_state.mark_registry.add_for_atom(record["atom_id"], item)
+ if mark_order is not None:
+ self.runtime_state.mark_registry.by_atom = {
+ atom_id: [self.mark_items[index] for index in indices]
+ for atom_id, indices in mark_order.items()
+ }
+ # Group items are record ids, like the desktop's scene record ids: the
+ # candidate's record dicts keep their identity through each edit.
+ self.runtime_state.group_state = CanvasGroupState()
+ for group in restored_groups(
+ state.get("groups") or [], self.model.atoms, self.group_item_ids()
+ ):
+ register_group_for(self, group.atom_ids, group.item_ids)
+ self.initial_bond_count = len(self.model.bonds)
+ self.group_merges: list[tuple[int, set[int]]] = []
+ # A paste's selection and cascade source, for the session to record.
+ self.pasted: dict[str, Any] | None = None
+ # The browser materializes labels once, after candidate validation.
+ self.render_context = SimpleNamespace(
+ arrows=SimpleNamespace(
+ record=lambda item: arrow_from_state(item.record),
+ set_record=lambda item, record: item.record.update(
+ arrow_to_state(record)
+ ),
+ ),
+ atom_labels=SimpleNamespace(
+ atom_item_for_id=lambda _id: None,
+ ensure_carbon_dot=lambda _id: None,
+ draw_atom=lambda _id, **kwargs: None,
+ relayout_atom_label=lambda _id: False,
+ ),
+ )
+ graph = SimpleNamespace(rebuild_bond_adjacency=lambda: None)
+ self.labels = BrowserAtomLabelService(
+ cast("Any", self),
+ graph_service=graph,
+ merge_service=AtomLabelMergeService(
+ self, graph_service=graph, capture_history=False
+ ),
+ connection_allowed=self.group_connection_allowed,
+ on_merge=lambda atom_id, removed: self.group_merges.append(
+ (atom_id, removed)
+ ),
+ )
+ self.committer = StructureBuildCommitter(cast("Any", self))
+ self.bond_builder = StructureBondBuildService(
+ self,
+ self.committer,
+ hit_testing_service=self,
+ graph_service=self.committer,
+ move_controller=SimpleNamespace(
+ redraw_bond=lambda _id: None,
+ redraw_connected_bonds=lambda _id, **kwargs: None,
+ ),
+ connection_allowed=self.group_connection_allowed,
+ )
+ self.builder = StructureBenzeneBuildService(
+ self,
+ self.committer,
+ point_factory=BrowserPoint,
+ center_inside_existing_ring=self.center_inside_ring,
+ )
+
+ self.services.atom_label_service = self.labels
+ self.services.canvas_ring_fill_scene_service = SimpleNamespace(
+ update_ring_fills_for_atoms=lambda ids, **_kwargs: (
+ rebuild_ring_fill_polygons(
+ self.model,
+ ids,
+ (
+ BrowserRingItem(record)
+ for record in self.document_state["ring_fills"]
+ ),
+ point_factory=BrowserPoint,
+ polygon_factory=tuple,
+ )
+ ),
+ create_ring_fill_item=lambda points, ids: RingFill(
+ tuple(ids),
+ self.renderer.style.ring_fill_color,
+ self.renderer.style.ring_fill_alpha,
+ ),
+ )
+ self.candidate_accepted = True
+
+ def run_growth(action: Callable[[], bool]) -> bool:
+ self.candidate_accepted = action()
+ return self.candidate_accepted
+
+ self.growth = StructureGrowthBuildService(
+ StructureGrowthBuildActions(
+ atom_point=lambda atom_id: cast(
+ "Any",
+ BrowserPoint(
+ self.model.atoms[atom_id].x, self.model.atoms[atom_id].y
+ ),
+ ),
+ sprout_bond_endpoint=partial(
+ sprout_bond_endpoint_for, self, point_factory=BrowserPoint
+ ),
+ add_bond_between_points=partial(
+ self.bond_builder.add_bond_between_points, record=False
+ ),
+ add_benzene_ring=lambda center, attach_atom_id=None, attach_bond_id=None: (
+ self.insert_benzene(
+ center.x(), center.y(), attach_atom_id, attach_bond_id
+ )
+ ),
+ has_atom=lambda atom_id: self.model.atom_for_id(atom_id) is not None,
+ default_bond_endpoint=partial(
+ default_bond_endpoint_for, self, point_factory=BrowserPoint
+ ),
+ add_atom_label=self.committer.add_atom_label,
+ regular_ring_points_for_atom=partial(
+ regular_ring_points_for_atom_for, self, point_factory=BrowserPoint
+ ),
+ regular_ring_points_for_bond=partial(
+ regular_ring_points_for_bond_for, self, point_factory=BrowserPoint
+ ),
+ cyclohexane_chair_points=partial(
+ cyclohexane_chair_points_for, self, point_factory=BrowserPoint
+ ),
+ template_points_for_bond=partial(
+ template_points_for_bond_for, self, point_factory=BrowserPoint
+ ),
+ add_ring_from_points=self.committer.add_ring_from_points,
+ bond_placement_context=lambda bond_id: resolve_bond_placement_context(
+ bond_id,
+ bonds=self.model.bonds,
+ atoms=self.model.atoms,
+ point_factory=BrowserPoint,
+ ),
+ # The browser candidate and session own this transaction/history.
+ run_recorded_additions_action=run_growth,
+ add_atom=self.committer.add_atom,
+ add_bond=self.committer.add_bond,
+ add_bond_graphics=self.committer.add_bond_graphics,
+ growth_allowed=lambda atom_id=None, bond_id=None: (
+ self.group_connection_allowed(
+ growth_anchors(
+ self.model.bonds, atom_id=atom_id, bond_id=bond_id
+ )
+ )
+ ),
+ ),
+ point_factory=BrowserPoint,
+ )
+
+ @staticmethod
+ def reject_edit(message: str) -> None:
+ raise ValueError(message)
+
+ def group_item_ids(self) -> dict[str, list[int]]:
+ return {
+ key: [id(record) for record in self.document_state.get(key, [])]
+ for key in GROUP_ITEM_COLLECTIONS
+ }
+
+ def group_connection_allowed(
+ self, anchors: set[int], *, notify: bool = True
+ ) -> bool:
+ """The desktop's group connection preflight; a refusal rejects the edit."""
+ if not anchors or connection_allowed(
+ self.runtime_state.group_state.groups, self.model.bonds, set(anchors)
+ ):
+ return True
+ if notify:
+ self.reject_edit(GROUP_CONNECTION_MESSAGE)
+ return False
+
+ def publish_groups(self) -> None:
+ """Extend groups over newly joined molecules, then save live members.
+
+ Like the desktop's history recording, new bonds extend the group that
+ owns their molecule and a label merge keeps one group's identity.
+ """
+ state = self.runtime_state.group_state
+ updates = group_extensions(
+ state.groups,
+ self.model.bonds,
+ range(self.initial_bond_count, len(self.model.bonds)),
+ )
+ for survivor_id, removed in self.group_merges:
+ update = group_atoms_after_merge(
+ state.groups, self.model.bonds, survivor_id, removed
+ )
+ if update is not None:
+ updates[update[0]] = update[1]
+ for group_id, atom_ids in updates.items():
+ restore_group_for(
+ self,
+ group_id,
+ SceneGroup(atom_ids, list(state.groups[group_id].item_ids)),
+ )
+ groups = snapshot_groups(
+ state.groups,
+ self.model.atoms,
+ {
+ record_id: (key, index)
+ for key, record_ids in self.group_item_ids().items()
+ for index, record_id in enumerate(record_ids)
+ },
+ )
+ if groups:
+ self.document_state["groups"] = groups
+ else:
+ self.document_state.pop("groups", None)
+
+ def group_members(self, items: object) -> tuple[set[int], list[Any]]:
+ """The desktop's group members of a selection: atoms and standalone items."""
+ buckets = self.selection_buckets(items)
+ atom_ids = self.selected_atom_ids(buckets)
+ standalone = [
+ item
+ for item in [
+ *buckets.note_items,
+ *buckets.arrow_items,
+ *buckets.ts_bracket_items,
+ *buckets.other_items,
+ ]
+ if item.data(0) in GROUPABLE_STANDALONE_KINDS
+ ]
+ for mark in buckets.mark_items:
+ # A bound mark stands in for its atom; only a free mark is a member.
+ atom_id = cast("BrowserMarkItem", mark).record["atom_id"]
+ if atom_id is None:
+ standalone.append(mark)
+ else:
+ atom_ids.add(atom_id)
+ return atom_ids, standalone
+
+ def group_boxes(
+ self, items: object, drawing: dict[str, Any]
+ ) -> list[dict[str, float]]:
+ """Dashed boxes of the groups a selection touches, as the desktop draws them."""
+ groups = self.runtime_state.group_state.groups
+ if not groups:
+ return []
+ atom_ids, standalone = self.group_members(items)
+ refs = {
+ record_id: (GROUP_SELECTION_TARGETS[key], index)
+ for key, record_ids in self.group_item_ids().items()
+ if key in GROUP_SELECTION_TARGETS
+ for index, record_id in enumerate(record_ids)
+ }
+ radius = atom_pick_radius(self.renderer)
+ pad = self.renderer.style.bond_length_px * GROUP_BOX_PADDING_RATIO
+ boxes = []
+ for group_id in sorted(group_ids_for_members_for(self, atom_ids, standalone)):
+ group = groups[group_id]
+ rect = None
+ for atom_id in sorted(group.atom_ids & set(self.model.atoms)):
+ atom = self.model.atoms[atom_id]
+ part = BrowserRect(
+ *selection_atom_rect(
+ atom.x,
+ atom.y,
+ radius,
+ drawing.get("atom_selection_rects", {}).get(str(atom_id)),
+ )
+ )
+ rect = part if rect is None else rect.united(part)
+ for record_id in group.item_ids:
+ if record_id in refs:
+ part = BrowserRect(*annotation_bounds(drawing, *refs[record_id]))
+ rect = part if rect is None else rect.united(part)
+ if rect is None:
+ continue
+ x, y = rect.left() - pad, rect.top() - pad
+ width, height = rect.width() + 2 * pad, rect.height() + 2 * pad
+ boxes.append(
+ {
+ "x": x,
+ "y": y,
+ "width": width,
+ "height": height,
+ "radius": group_box_corner_radius(width, height),
+ }
+ )
+ return boxes
+
+ def group_selection(self, items: object, *, ungroup: bool) -> None:
+ """Edit > Group and Ungroup over the desktop's selected group members."""
+ atom_ids, standalone = self.group_members(items)
+ state = self.runtime_state.group_state
+ if ungroup:
+ for group_id in group_ids_for_members_for(self, atom_ids, standalone):
+ state.groups.pop(group_id)
+ return
+ try:
+ plan = grouping_plan(
+ state.groups,
+ self.model.bonds,
+ atom_ids,
+ [item.data(3) for item in standalone],
+ )
+ except ValueError as error:
+ self.reject_edit(str(error))
+ return
+ if plan is None:
+ return
+ for group_id in plan.absorbed:
+ state.groups.pop(group_id)
+ register_group_for(self, plan.atom_ids, plan.item_ids)
+
+ def find_atom_near(self, x: float, y: float, max_dist: float) -> int | None:
+ return nearest_atom_id(
+ self.model.atoms, self.model.atoms, x=x, y=y, max_dist=max_dist
+ )
+
+ def snapped_point(
+ self,
+ point: tuple[float, float],
+ endpoints: list[tuple[float, float]],
+ *,
+ radius: float,
+ avoid: tuple[float, float] | None = None,
+ angle_step: float | None = None,
+ ) -> tuple[float, float]:
+ """The native drawing funnel with this document's grid settings."""
+ settings = self.runtime_state.tool_settings_state
+ return snapped_drawing_point(
+ point,
+ endpoints,
+ radius=radius,
+ avoid=avoid,
+ angle_step=angle_step,
+ grid_step=grid_step_for(self) if settings.grid_snap_enabled else 0.0,
+ grid_style=settings.grid_style,
+ )
+
+ def require_sheet_position(self, x: float, y: float) -> None:
+ if any(
+ type(v) not in (int, float, Decimal) or not math.isfinite(v) for v in (x, y)
+ ):
+ raise ValueError("Edit coordinates must be finite numbers.")
+ settings = self.document_state["settings"]
+ width, height = sheet_dimensions_px(
+ settings["sheet_size"],
+ settings["sheet_orientation"],
+ settings.get("sheet_custom_size_mm"),
+ )
+ if not scene_pos_in_sheet(x, y, (-width / 2, -height / 2, width, height)):
+ raise ValueError(OFF_SHEET_EDIT_GUIDANCE)
+
+ def atom_target(self, edit: dict[str, Any]) -> TextToolTarget:
+ self.require_sheet_position(edit["x"], edit["y"])
+ x, y = float(edit["x"]), float(edit["y"])
+ atom_id = edit.get("atom_id")
+ if atom_id is not None and (
+ type(atom_id) is not int or self.model.atom_for_id(atom_id) is None
+ ):
+ raise ValueError("Unknown atom tool target.")
+ radius = self.renderer.style.bond_length_px
+ hover_atom_id, hover_bond_id = self.structure_target(
+ x,
+ y,
+ bond_gate_ratio=STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ direct_atom_id=atom_id,
+ )
+ needs_nearby = hover_atom_id is None and atom_id is None
+ return resolve_text_tool_target(
+ self.model,
+ pos=(x, y),
+ hover_atom_id=hover_atom_id,
+ item_atom_id=atom_id,
+ hover_bond_id=hover_bond_id,
+ nearby_atom_id=self.find_atom_near(x, y, radius * 0.9)
+ if needs_nearby
+ else None,
+ nearby_bond_id=nearest_bond_id(
+ self.model,
+ range(len(self.model.bonds)),
+ BrowserPoint(x, y),
+ radius * 0.6,
+ point_factory=BrowserPoint,
+ )
+ if needs_nearby
+ else None,
+ )
+
+ def apply_atom_input(self, edit: dict[str, Any]) -> None:
+ text = edit["text"]
+ # Only the context bar field limits length; the label prompt does not.
+ if not isinstance(text, str):
+ raise ValueError("Atom labels must be text.")
+ if edit["kind"] == "atom_prompt":
+ atom_id = edit["atom_id"]
+ if type(atom_id) is not int or self.model.atom_for_id(atom_id) is None:
+ raise ValueError("The atom no longer exists.")
+ self.require_sheet_position(edit["x"], edit["y"])
+ self.labels.apply_atom_label_prompt(atom_id, text, record=False)
+ self.publish_model()
+ return
+ target = self.atom_target(edit)
+ atom = self.model.atom_for_id(target.atom_id)
+ apply_text_input(
+ target,
+ normalize_text_symbol(text),
+ atom.element if atom is not None else "",
+ add_atom=lambda text, x, y: self.labels.add_labelled_atom(
+ text, x, y, record=False
+ ),
+ update_label=lambda atom_id, text, **kwargs: (
+ self.labels.add_or_update_atom_label(
+ atom_id, text, record=False, **kwargs
+ )
+ ),
+ notify_error=self.reject_edit,
+ )
+ self.publish_model()
+
+ def move_arrow_handle(self, edit: dict[str, Any]) -> None:
+ if set(edit) != {"kind", "id", "handle", "position", "previous", "scale"}:
+ raise ValueError("Unexpected handle fields.")
+ self.selection_buckets([{"target": "arrow", "id": edit["id"]}])
+ source = self.document_state["arrows"][edit["id"]]
+ pressed = normalized_arrow_control(arrow_from_state(source))
+ handle = edit["handle"]
+ if handle not in ("start", "end", "control") or (
+ handle == "control" and pressed.control is None
+ ):
+ raise ValueError("Unknown arrow handle.")
+ scale = validated_drawing_scale(edit["scale"])
+ positions = (
+ [edit["position"]]
+ if edit["previous"] is None
+ else [edit["previous"], edit["position"]]
+ )
+ for point in positions:
+ if (
+ not isinstance(point, list)
+ or len(point) != 2
+ or any(
+ type(v) not in (int, float, Decimal) or not math.isfinite(v)
+ for v in point
+ )
+ ):
+ raise ValueError("A handle position needs two finite coordinates.")
+ endpoints = [
+ tuple(point)
+ for index, arrow in enumerate(self.document_state["arrows"])
+ if index != edit["id"]
+ for point in (arrow["start"], arrow["end"])
+ ]
+ record = pressed
+ for point in positions:
+ moved = (float(point[0]), float(point[1]))
+ if handle == "control":
+ mid = clamp_curved_midpoint(
+ pressed.start,
+ pressed.end,
+ moved,
+ snap_enabled=self.runtime_state.tool_settings_state.curved_snap,
+ snap_distance=self.renderer.style.bond_length_px
+ * self.runtime_state.tool_settings_state.curved_snap_step,
+ )
+ record = replace(
+ record,
+ control=control_from_midpoint(pressed.start, pressed.end, mid),
+ )
+ else:
+ moved = self.snapped_point(
+ moved, endpoints, radius=ENDPOINT_SNAP_SCREEN_PX / scale
+ )
+ record = arrow_with_moved_endpoint(
+ record,
+ pressed,
+ moved,
+ handle,
+ bond_length=self.renderer.style.bond_length_px,
+ )
+ if record != pressed:
+ source.update(arrow_to_state(record))
+
+ def set_arrow_labels(self, edit: dict[str, Any]) -> None:
+ self.selection_buckets([{"target": "arrow", "id": edit["id"]}])
+ labels = edit["labels"]
+ if (
+ not isinstance(labels, dict)
+ or set(labels) - {"above", "below"}
+ or any(
+ not isinstance(text, str) or len(text) > MAX_ARROW_LABEL_CHARS
+ for text in labels.values()
+ )
+ ):
+ raise ValueError(
+ f"Arrow labels must contain at most {MAX_ARROW_LABEL_CHARS} characters per field."
+ )
+ arrow = self.document_state["arrows"][edit["id"]]
+ arrow.pop("labels", None)
+ if cleaned := cleaned_arrow_labels(labels):
+ arrow["labels"] = cleaned
+
+ def apply_ring_fill(self, edit: dict[str, Any]) -> str | None:
+ if set(edit) != {"kind", "selection", "color"}:
+ raise ValueError("Unexpected ring fill fields.")
+ color = validated_color(edit["color"])
+ buckets = self.selection_buckets(edit["selection"])
+ targets = [cast("BrowserRingItem", item).record for item in buckets.ring_items]
+ existing = {
+ frozenset(ring["atom_ids"]): ring
+ for ring in self.document_state["ring_fills"]
+ }
+ for ids in selected_ring_cycles(
+ self.model.bonds, buckets.atom_ids, buckets.bond_ids
+ ):
+ key = frozenset(ids)
+ if key not in existing:
+ self.attach_ring(RingFill(tuple(ids), None, 0.0))
+ existing[key] = self.document_state["ring_fills"][-1]
+ if all(ring is not existing[key] for ring in targets):
+ targets.append(existing[key])
+ if not targets:
+ return RING_FILL_GUIDANCE
+ rgb = (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
+ fill = "#" + "".join(
+ f"{component:02x}" for component in pastel_rgb(rgb, RING_FILL_TINT)
+ )
+ for ring in targets:
+ ring.update(color=fill, alpha=1.0)
+ return None
+
+ def apply_color(self, edit: dict[str, Any]) -> str | None:
+ fields = {"kind", "selection", "color"}
+ if set(edit) not in (fields, fields | {"x", "y", "hits", "scale"}):
+ raise ValueError("Unexpected color fields.")
+ color = validated_color(edit["color"])
+ color = color.lower()
+ selection = edit["selection"]
+ self.selection_buckets(selection)
+ if "x" in edit:
+ target = self.pick_target(
+ edit["x"], edit["y"], edit["hits"], preferred=False, scale=edit["scale"]
+ )
+ if target is not None:
+ selection = [target]
+ # The desktop colors only these kinds; orbitals and the like are ignored.
+ buckets = self.selection_buckets(
+ [
+ item
+ for item in selection
+ if item["target"] in COLOR_TARGET_KINDS or item["target"] == "arrow"
+ ]
+ )
+ for ring_item in buckets.ring_items:
+ ids = set(ring_item.data(2))
+ buckets.atom_ids.update(ids)
+ buckets.bond_ids.update(
+ bond_sets_for_atom_ids(
+ ids, {}, self.model.bonds, bond_for_id=self.model.bond_for_id
+ )[0]
+ )
+ for atom_id in buckets.atom_ids:
+ self.model.atoms[atom_id].color = color
+ for bond_id in buckets.bond_ids:
+ bond = self.model.bonds[bond_id]
+ assert bond is not None
+ bond.color = color
+ for item in buckets.arrow_items:
+ cast("BrowserSceneItem", item).record["color"] = color
+ for mark in buckets.mark_items:
+ cast("BrowserMarkItem", mark).record["color"] = color
+ for note in buckets.note_items:
+ record = cast("BrowserSceneItem", note).record
+ record["html"] = recolored_note(record, color)
+ rgb = (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
+ fill = "#" + "".join(
+ f"{component:02x}" for component in pastel_rgb(rgb, SHAPE_FILL_TINT)
+ )
+ for item in buckets.other_items:
+ if item.data(0) == "shape":
+ cast("BrowserSceneItem", item).record.update(fill=fill, fill_alpha=1.0)
+ self.publish_model()
+ if buckets.ts_bracket_items:
+ return COLOR_TOOL_MESSAGES["ts_bracket"]
+ if (
+ buckets.atom_ids
+ and not (
+ buckets.bond_ids
+ or buckets.arrow_items
+ or buckets.other_items
+ or buckets.mark_items
+ or buckets.note_items
+ )
+ and all(
+ not atom_shows_itself(self.model.atoms[i]) for i in buckets.atom_ids
+ )
+ ):
+ return COLOR_TOOL_MESSAGES["hidden"]
+ return None
+
+ def insert_image(self, edit: dict[str, Any]) -> None:
+ """Insert Image: the desktop's validation, budget and placement."""
+ encoded, view = edit.get("data_base64"), edit.get("view")
+ if set(edit) != {"kind", "data_base64", "view"} or not isinstance(encoded, str):
+ raise ValueError("Expected image data and the visible scene rect.")
+ if len(encoded) > 4 * ((MAX_IMAGE_BYTES + 2) // 3):
+ raise ValueError("The image exceeds the 16 MiB limit.")
+ if (
+ not isinstance(view, list)
+ or len(view) != 4
+ or any(
+ type(value) not in (int, float, Decimal) or not math.isfinite(value)
+ for value in view
+ )
+ ):
+ raise ValueError("The visible scene rect needs four finite numbers.")
+ try:
+ data = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as error:
+ raise ValueError("The image data must be base64.") from error
+ state = image_state_from_bytes(data)
+ settings = self.document_state["settings"]
+ width, height = sheet_dimensions_px(
+ settings["sheet_size"],
+ settings["sheet_orientation"],
+ settings.get("sheet_custom_size_mm"),
+ )
+ x, y, box_width, box_height = inserted_image_box(
+ float(cast("float", state["width"])),
+ float(cast("float", state["height"])),
+ cast("tuple[float, float, float, float]", tuple(float(v) for v in view)),
+ (-width / 2, -height / 2, width, height),
+ )
+ state.update(x=x, y=y, width=box_width, height=box_height)
+ images = self.document_state.get("images", [])
+ validate_image_collection_budget([*images, state])
+ self.document_state["images"] = [*images, state]
+
+ def set_image_properties(self, edit: dict[str, Any]) -> None:
+ """Image Properties: box, aspect lock and opacity of one image."""
+ fields = {"x", "y", "width", "height", "lock_aspect", "opacity"}
+ changes = edit.get("changes")
+ if (
+ set(edit) != {"kind", "id", "changes"}
+ or not isinstance(changes, dict)
+ or not set(changes) <= fields
+ ):
+ raise ValueError("Expected an image and its changed properties.")
+ buckets = self.selection_buckets([{"target": "image", "id": edit["id"]}])
+ record = cast("BrowserSceneItem", buckets.other_items[0]).record
+ state = {
+ **record,
+ **{
+ key: float(value) if key != "lock_aspect" else value
+ for key, value in changes.items()
+ },
+ }
+ validate_image_state(state)
+ record.update(state)
+
+ def stack_selection(self, edit: dict[str, Any]) -> None:
+ if (
+ set(edit) != {"kind", "selection", "front"}
+ or type(edit["front"]) is not bool
+ ):
+ raise ValueError("Expected selection and a boolean stacking direction.")
+ buckets = self.selection_buckets(edit["selection"])
+ selected = {
+ id(cast("BrowserSceneItem", item).record) for item in buckets.other_items
+ }
+ # The desktop stacks images and shapes as one band, images first.
+ objects = [
+ *self.document_state.get("images", []),
+ *self.document_state["shapes"],
+ ]
+ for index, z in stacked_depths(
+ [
+ float(record.get("z", -2 if record.get("kind") == "image" else -10))
+ for record in objects
+ ],
+ {i for i, record in enumerate(objects) if id(record) in selected},
+ front=edit["front"],
+ ):
+ objects[index]["z"] = z
+
+ def move_orbital_handle(self, edit: dict[str, Any]) -> None:
+ if set(edit) != {"kind", "id", "handle", "position"}:
+ raise ValueError("Unexpected handle fields.")
+ buckets = self.selection_buckets([{"target": "orbital", "id": edit["id"]}])
+ item = cast("BrowserOrbitalItem", buckets.other_items[0])
+ pos = edit["position"]
+ if (
+ not isinstance(pos, list)
+ or len(pos) != 2
+ or any(
+ type(value) not in (int, float, Decimal) or not math.isfinite(value)
+ for value in pos
+ )
+ ):
+ raise ValueError("A handle position needs two finite coordinates.")
+ center = BrowserPoint(*item.record["center"])
+ position = BrowserPoint(*(float(value) for value in pos))
+ if edit["handle"] == "scale":
+ item.apply_orbital_state(
+ {
+ "scale": orbital_scale_factor(
+ center, position, self.renderer.style.bond_length_px * 0.8
+ )
+ }
+ )
+ elif edit["handle"] == "rotate":
+ settings = self.runtime_state.tool_settings_state
+ item.apply_orbital_state(
+ {
+ "rotation": orbital_rotation_angle(
+ center,
+ position,
+ snap_enabled=settings.orbital_snap_enabled,
+ snap_step=settings.orbital_snap_step,
+ )
+ }
+ )
+ else:
+ raise ValueError("Unknown orbital handle.")
+
+ def move_shape_handle(self, edit: dict[str, Any]) -> None:
+ if set(edit) != {"kind", "id", "handle", "position"}:
+ raise ValueError("Unexpected handle fields.")
+ self.selection_buckets([{"target": "shape", "id": edit["id"]}])
+ source = self.document_state["shapes"][edit["id"]]
+ shape = normalized_shape(shape_from_state(source))
+ bounds = (shape.left, shape.top, shape.right, shape.bottom)
+ if not isinstance(edit["handle"], str) or edit["handle"] not in dict(
+ shape_handle_positions(bounds)
+ ):
+ raise ValueError("Unknown shape handle.")
+ pos = edit["position"]
+ if (
+ not isinstance(pos, list)
+ or len(pos) != 2
+ or any(
+ type(v) not in (int, float, Decimal) or not math.isfinite(v)
+ for v in pos
+ )
+ ):
+ raise ValueError("A handle position needs two finite coordinates.")
+ left, top, right, bottom = resized_shape_bounds(
+ bounds, edit["handle"], (float(pos[0]), float(pos[1]))
+ )
+ source.update(
+ shape_to_state(
+ replace(shape, left=left, top=top, right=right, bottom=bottom)
+ )
+ )
+
+ def insert_orbital(self, edit: dict[str, Any]) -> None:
+ if set(edit) != {"kind", "x", "y", "orbital_kind"}:
+ raise ValueError("Unexpected orbital fields.")
+ kind = edit["orbital_kind"]
+ if not isinstance(kind, str) or kind not in ORBITAL_TYPE_BY_LABEL.values():
+ raise ValueError("Unknown orbital kind.")
+ self.require_sheet_position(edit["x"], edit["y"])
+ x, y = float(edit["x"]), float(edit["y"])
+ self.document_state["orbitals"].append(asdict(Orbital(kind, (x, y))))
+
+ def insert_bracket(self, edit: dict[str, Any]) -> None:
+ if set(edit) != {"kind", "start", "end", "style"}:
+ raise ValueError("Unexpected bracket fields.")
+ for point in (edit["start"], edit["end"]):
+ if (
+ not isinstance(point, list)
+ or len(point) != 2
+ or any(
+ type(value) not in (int, float, Decimal) or not math.isfinite(value)
+ for value in point
+ )
+ ):
+ raise ValueError("Bracket coordinates must be finite points.")
+ self.require_sheet_position(*point)
+ kind = edit["style"]
+ if not isinstance(kind, str) or kind not in dict(BRACKET_MENU_SPECS).values():
+ raise ValueError("Unknown bracket kind.")
+ x, y, width, height = bracket_rect_from_points(
+ (float(edit["start"][0]), float(edit["start"][1])),
+ (float(edit["end"][0]), float(edit["end"][1])),
+ self.renderer.style.bond_length_px,
+ )
+ self.document_state["ts_brackets"].append(
+ ts_bracket_to_state(
+ TSBracket(
+ left=x,
+ top=y,
+ right=x + width,
+ bottom=y + height,
+ bracket_kind=kind,
+ )
+ )
+ )
+
+ def insert_shape(self, edit: dict[str, Any]) -> None:
+ start, end = edit["start"], edit["end"]
+ if any(
+ not isinstance(point, list) or len(point) != 2 for point in (start, end)
+ ):
+ raise ValueError("Shape coordinates must be finite points.")
+ self.require_sheet_position(*start)
+ self.require_sheet_position(*end)
+ if (
+ not isinstance(edit["style"], str)
+ or not isinstance(edit["stroke"], str)
+ or edit["style"] not in dict(SHAPE_KIND_SPECS)
+ or edit["stroke"] not in dict(SHAPE_STROKE_SPECS)
+ ):
+ raise ValueError("Unknown shape kind or stroke.")
+ x, y, width, height = shape_rect_from_points(
+ (float(start[0]), float(start[1])),
+ (float(end[0]), float(end[1])),
+ self.renderer.style.bond_length_px,
+ )
+ self.document_state["shapes"].append(
+ shape_to_state(
+ Shape(
+ left=x,
+ top=y,
+ right=x + width,
+ bottom=y + height,
+ shape_kind=edit["style"],
+ stroke_style=edit["stroke"],
+ )
+ )
+ )
+
+ def set_drawing_settings(self, edit: dict[str, Any]) -> None:
+ settings = self.document_state["settings"]
+ if edit["kind"] == "orbital_phase":
+ if set(edit) != {"kind", "enabled"} or type(edit["enabled"]) is not bool:
+ raise ValueError("Expected an orbital phase flag.")
+ settings["orbital_phase_enabled"] = edit["enabled"]
+ return
+ if edit["kind"] == "sheet_setup":
+ if set(edit) != {"kind", "size", "orientation", "custom_size_mm"}:
+ raise ValueError("Unexpected canvas size fields.")
+ if (
+ not isinstance(edit["size"], str)
+ or not isinstance(edit["orientation"], str)
+ or edit["size"] not in supported_sheet_sizes()
+ or edit["orientation"] not in dict(SHEET_ORIENTATION_OPTIONS)
+ ):
+ raise ValueError("Unknown canvas size or orientation.")
+ size, orientation, custom = normalize_sheet_setup(
+ edit["size"], edit["orientation"], edit["custom_size_mm"]
+ )
+ settings.update(sheet_size=size, sheet_orientation=orientation)
+ settings.pop("sheet_custom_size_mm", None)
+ if custom is not None:
+ settings["sheet_custom_size_mm"] = list(custom)
+ return
+ if edit["kind"] == "bond_length":
+ if set(edit) != {"kind", "value"}:
+ raise ValueError("Unexpected bond length fields.")
+ length = float(edit["value"])
+ scale = length / float(settings["bond_length_px"])
+ if scale == 1:
+ return
+ before_positions = {
+ atom_id: (atom.x, atom.y) for atom_id, atom in self.model.atoms.items()
+ }
+ if before_positions:
+ center_x, center_y = self.model.center()
+ self.model.scale_about(center_x, center_y, scale)
+ for ring in self.document_state["ring_fills"]:
+ ring["points"] = [
+ (
+ center_x + (x - center_x) * scale,
+ center_y + (y - center_y) * scale,
+ )
+ for x, y in ring["points"]
+ ]
+ for atom_id, marks in self.runtime_state.mark_registry.items():
+ atom = self.model.atoms[atom_id]
+ for mark in marks:
+ dx, dy = scaled_mark_offset(
+ mark.record, before_positions[atom_id], scale
+ )
+ mark.record.update(dx=dx, dy=dy)
+ mark.set_center(BrowserPoint(atom.x + dx, atom.y + dy))
+ rotation = self.runtime_state.rotation_state
+ coords_state = self.runtime_state.atom_coords_3d_state
+ (
+ coords_state.atom_coords_3d,
+ rotation.projection_center_3d,
+ rotation.projection_anchor_2d,
+ ) = rescaled_perspective(
+ coords_state.atom_coords_3d,
+ rotation.projection_center_3d,
+ rotation.projection_anchor_2d,
+ scale=scale,
+ origin=(center_x, center_y),
+ )
+ settings["bond_length_px"] = length
+ self.renderer.set_bond_length(length)
+ self.publish_model()
+ return
+ width, head = settings["arrow_line_width"], settings["arrow_head_scale"]
+ if set(edit) == {"kind", "preset"}:
+ if edit["preset"] not in ARROW_PRESET_SPECS:
+ raise ValueError("Unknown arrow preset.")
+ width, head = arrow_preset_from_label(edit["preset"])
+ elif set(edit) == {"kind", "setting", "value"}:
+ setting, value = edit["setting"], edit["value"]
+ if not isinstance(setting, str) or setting not in ARROW_SLIDER_RANGES:
+ raise ValueError("Unknown arrow slider.")
+ minimum, maximum, factor = ARROW_SLIDER_RANGES[setting]
+ if type(value) is not int or not minimum <= value <= maximum:
+ raise ValueError("Invalid arrow slider value.")
+ if setting == "arrow_line_width":
+ width = value / factor
+ else:
+ head = value / factor
+ else:
+ raise ValueError("Unexpected arrow style fields.")
+ settings.update(normalized_arrow_style(width, head))
+
+ def insert_arrow(
+ self, edit: dict[str, Any], *, preview: bool = False
+ ) -> list[list[float]]:
+ """Add the drawn record; return its ends that sit on an existing end."""
+ start, end = edit["start"], edit["end"]
+ if any(
+ not isinstance(point, list) or len(point) != 2 for point in (start, end)
+ ):
+ raise ValueError("A point needs two coordinates.")
+ line_tool = edit["kind"] == "line"
+ kind = edit["style"]
+ allowed = (
+ {value for value, _ in LINE_KIND_SPECS}
+ if line_tool
+ else {value for _, value in ARROW_MENU_SPECS}
+ )
+ if not isinstance(kind, str) or kind not in allowed:
+ raise ValueError("Unsupported arrow or line style.")
+ if line_tool:
+ self.selection_buckets(edit["hits"])
+ if type(edit["dragged"]) is not bool or type(edit["shift"]) is not bool:
+ raise ValueError("Expected arrow gesture flags.")
+ scale = validated_drawing_scale(edit["scale"])
+ radius = ENDPOINT_SNAP_SCREEN_PX / scale
+ self.require_sheet_position(*start)
+ self.require_sheet_position(*end)
+ endpoints = [
+ tuple(point)
+ for arrow in self.document_state["arrows"]
+ for point in (arrow["start"], arrow["end"])
+ ]
+ first = self.snapped_point(
+ (float(start[0]), float(start[1])), endpoints, radius=radius
+ )
+ last = (
+ self.snapped_point(
+ (float(end[0]), float(end[1])),
+ endpoints,
+ radius=radius,
+ avoid=first,
+ angle_step=LINE_ANGLE_STEP_DEGREES
+ if line_tool and edit["shift"]
+ else None,
+ )
+ if edit["dragged"]
+ else first
+ )
+ # The native preview rings each of its ends that sits on an existing end,
+ # a click's included: it previews the caught press point and adds nothing.
+ marks = [
+ [float(x), float(y)]
+ for x, y in points_on_endpoints([first, last], endpoints)
+ ]
+ if first == last:
+ if not line_tool or preview:
+ return marks
+ click_end = line_click_endpoint(
+ first,
+ bond_length=self.renderer.style.bond_length_px,
+ occupied=self.center_inside_ring(BrowserPoint(*first))
+ or self.pick_target(*first, edit["hits"], preferred=False, scale=scale)
+ is not None,
+ )
+ if click_end is None:
+ return []
+ last = click_end
+ if edit["shift"] and kind in VALID_ARC_KINDS:
+ kind = mirrored_arc_kind(kind)
+ record = new_arrow_record(first, last, kind)
+ self.document_state["arrows"].append(arrow_to_state(record))
+ return marks
+
+ def insert_smiles(
+ self,
+ edit: dict[str, Any],
+ model: MoleculeModel | None,
+ *,
+ font: BrowserFontMeasurements | None,
+ preview: bool,
+ ) -> None:
+ if set(edit) != {"kind", "smiles", "x", "y"}:
+ raise ValueError("Expected SMILES text and a placement position.")
+ model = model or prepared_smiles_model(
+ edit["smiles"], self.renderer.style.bond_length_px
+ )
+ x, y = float(edit["x"]), float(edit["y"])
+ self.require_sheet_position(x, y)
+ plan = plan_smiles_commit(model, smiles_preview_center(model), (x, y))
+ pending_marks: list[tuple[int, BrowserPoint, str, BrowserMarkItem]] = []
+
+ def materialize_mark(
+ atom_id: int, point: BrowserPoint, *, kind: str
+ ) -> BrowserMarkItem:
+ atom = self.model.atoms[atom_id]
+ # Collect the complete font request before label-aware placement.
+ # These provisional offsets exist only in a disposable preview.
+ item = self.add_mark_record(
+ kind,
+ atom_id,
+ point.x(),
+ point.y(),
+ point.x() - atom.x,
+ point.y() - atom.y,
+ synchronize_annotation=False,
+ )
+ pending_marks.append((atom_id, point, kind, item))
+ return item
+
+ self.services.canvas_mark_scene_service = SimpleNamespace(
+ materialize_mark_for_atom=materialize_mark
+ )
+ self.candidate_accepted = apply_smiles_commit_plan(
+ cast("Any", self), plan, point_factory=BrowserPoint, record=False
+ )
+ if not self.candidate_accepted:
+ raise ValueError("Could not place this SMILES structure.")
+ self.publish_model()
+ if pending_marks:
+ font = font or BrowserFontMeasurements(
+ {"family": ACS1996Style().font_family, "metrics": {}, "ink": {}}
+ )
+ drawing = font.drawing(self.document_state)
+ if drawing.get("needs_measurements"):
+ if preview:
+ return
+ raise ValueError("SMILES placement needs completed font measurements.")
+ for atom_id, point, kind, item in pending_marks:
+ dx, dy = self.mark_offset(
+ atom_id, point.x(), point.y(), kind, drawing, font
+ )
+ atom = self.model.atoms[atom_id]
+ item.set_center(BrowserPoint(atom.x + dx, atom.y + dy))
+ item.record.update(dx=dx, dy=dy)
+
+ def insert_bond(self, start: list[float], end: list[float], style: str) -> None:
+ if any(
+ not isinstance(point, list) or len(point) != 2 for point in (start, end)
+ ):
+ raise ValueError("A point needs two coordinates.")
+ self.require_sheet_position(*start)
+ self.require_sheet_position(*end)
+ start_point = BrowserPoint(*(float(value) for value in start))
+ end_point = BrowserPoint(*(float(value) for value in end))
+ radius = self.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO
+ start_id = self.find_atom_near(start_point.x(), start_point.y(), radius)
+ _atom_id, preferred_bond_id = self.structure_target(
+ start_point.x(),
+ start_point.y(),
+ bond_gate_ratio=STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ )
+ press_bond_id = resolve_bond_press_target(
+ atom_id=start_id,
+ item_kind="bond" if preferred_bond_id is not None else None,
+ item_bond_id=preferred_bond_id,
+ nearby_bond_id=nearest_bond_id(
+ self.model,
+ range(len(self.model.bonds)),
+ start_point,
+ radius,
+ point_factory=BrowserPoint,
+ )
+ if start_id is None
+ else None,
+ hover_bond_id=None,
+ )
+ if press_bond_id is not None:
+ self.apply_bond_style(press_bond_id, style)
+ return
+ short_click = is_short_bond_gesture(
+ (start_point.x(), start_point.y()),
+ (end_point.x(), end_point.y()),
+ self.renderer.style.bond_length_px,
+ )
+ if start_id is not None:
+ atom = self.model.atoms[start_id]
+ start_point = BrowserPoint(atom.x, atom.y)
+ end_id = self.find_atom_near(end_point.x(), end_point.y(), radius)
+ snapped = resolve_bond_snap_target(
+ self.model,
+ pos=(end_point.x(), end_point.y()),
+ atom_id=end_id,
+ bond_id=nearest_bond_id(
+ self.model,
+ range(len(self.model.bonds)),
+ end_point,
+ self.renderer.style.bond_length_px * BOND_SNAP_RADIUS_RATIO,
+ point_factory=BrowserPoint,
+ )
+ if end_id is None
+ else None,
+ start_atom_id=start_id,
+ ignore_start=True,
+ )
+ end_point = BrowserPoint(*snapped.pos)
+ end_id = self.find_atom_near(end_point.x(), end_point.y(), radius)
+ endpoint = resolve_bond_endpoint_target(
+ self.model,
+ start=(start_point.x(), start_point.y()),
+ end=(end_point.x(), end_point.y()),
+ atom_id=end_id,
+ start_atom_id=start_id,
+ snap_angle_step=self.runtime_state.tool_settings_state.snap_angle_step,
+ bond_length=self.renderer.style.bond_length_px,
+ )
+ if short_click:
+ end_point = cast(
+ "BrowserPoint",
+ default_bond_endpoint_for(
+ self, cast("Any", start_point), start_id, point_factory=BrowserPoint
+ ),
+ )
+ else:
+ end_point = BrowserPoint(*endpoint)
+ self.bond_builder.add_bond_between_points(
+ cast("Any", start_point),
+ cast("Any", end_point),
+ style,
+ BOND_ORDERS[style],
+ record=False,
+ )
+ self.publish_model()
+
+ def apply_bond_style(self, bond_id: int, style: str) -> None:
+ bond = self.model.bond_for_id(bond_id)
+ if type(bond_id) is not int or bond is None:
+ raise ValueError("Unknown bond.")
+
+ def apply_style(style: str, order: int) -> None:
+ bond.style, bond.order = style, order
+
+ apply_active_bond_style(
+ bond,
+ style,
+ BOND_ORDERS[style],
+ apply_style=apply_style,
+ cycle_style=lambda: apply_style(
+ *cycle_plain_bond_style(
+ bond.style, bond.order, allow_double_variants=False
+ )
+ ),
+ notify_error=self.reject_edit,
+ )
+ self.publish_model()
+
+ def double_bond_menu(
+ self, x: float, y: float, hits: object, scale: object
+ ) -> dict[str, Any] | None:
+ """The desktop's right-click position menu for the double bond at a point."""
+ target = self.pick_target(x, y, hits, preferred=False, scale=scale)
+ if target is not None and target["target"] == "bond":
+ bond_id = target["id"]
+ else:
+ # Native context menus take a nearby bond within a wider radius.
+ length = self.renderer.style.bond_length_px
+ radius = max(length * 0.35, length * STRUCTURE_BOND_PICK_RADIUS_RATIO)
+ bond_id = nearest_bond_id(
+ self.model,
+ bond_pick_candidates(
+ self.model, float(x), float(y), radius, max(8.0, length)
+ ),
+ BrowserPoint(float(x), float(y)),
+ radius,
+ point_factory=BrowserPoint,
+ )
+ bond = None if bond_id is None else self.model.bond_for_id(bond_id)
+ if bond is None or not is_positionable_double_bond_style(
+ bond.style, bond.order
+ ):
+ return None
+ current = double_position_for_style(bond.style, bond.order)
+ return {
+ "bond": bond_id,
+ "entries": [
+ {"label": label, "position": position, "checked": position == current}
+ for label, position in DOUBLE_BOND_CONTEXT_STYLES
+ if style_for_double_position(bond.style, bond.order, position)
+ ],
+ }
+
+ def edit_note_text(self, edit: dict[str, Any]) -> None:
+ """Commit the note editor like NoteItem's focus-out: save or remove."""
+ if not {"id", "html"} <= set(edit):
+ raise ValueError("Expected the note and its HTML.")
+ note_id = edit["id"]
+ saved, text = saved_note_text(edit["html"])
+ notes = self.document_state["notes"]
+ if note_id is None:
+ if set(edit) != {"kind", "id", "html", "x", "y"}:
+ raise ValueError("A new note needs its position.")
+ self.require_sheet_position(edit["x"], edit["y"])
+ if not text.strip():
+ raise ValueError("An empty note is not added.")
+ notes.append(
+ note_to_document_state(
+ Note(text=text, html=saved, x=float(edit["x"]), y=float(edit["y"]))
+ )
+ )
+ elif (
+ type(note_id) is int
+ and 0 <= note_id < len(notes)
+ and set(edit)
+ == {
+ "kind",
+ "id",
+ "html",
+ }
+ ):
+ if text.strip():
+ notes[note_id].update(text=text, html=saved)
+ else:
+ # An emptied note is deleted, as the desktop does on focus out.
+ del notes[note_id]
+ else:
+ raise ValueError("The note no longer exists.")
+ self.publish_model()
+
+ def format_notes(self, edit: dict[str, Any]) -> None:
+ """Text formatting applied to whole selected notes, as one change."""
+ notes = self.document_state["notes"]
+ if (
+ set(edit) != {"kind", "notes"}
+ or not isinstance(edit["notes"], list)
+ or not 0 < len(edit["notes"]) <= len(notes)
+ ):
+ raise ValueError("Expected the formatted notes.")
+ changes = {}
+ for item in edit["notes"]:
+ if not isinstance(item, dict) or set(item) != {"id", "html"}:
+ raise ValueError("Expected each note and its HTML.")
+ note_id = item["id"]
+ if type(note_id) is not int or not 0 <= note_id < len(notes):
+ raise ValueError("The note no longer exists.")
+ saved, text = saved_note_text(item["html"])
+ if text != notes[note_id]["text"]:
+ raise ValueError("Formatting cannot change a note's text.")
+ changes[note_id] = saved
+ for note_id, saved in changes.items():
+ notes[note_id]["html"] = saved
+ self.publish_model()
+
+ def apply_double_position(self, bond_id: object, position: object) -> None:
+ bond = self.model.bond_for_id(bond_id) if type(bond_id) is int else None
+ if bond is None:
+ raise ValueError("Unknown bond.")
+ target = (
+ style_for_double_position(bond.style, bond.order, position)
+ if position in {style for _, style in DOUBLE_BOND_CONTEXT_STYLES}
+ else None
+ )
+ if target is None:
+ raise ValueError("Only inward, centered or outward double bonds can move.")
+ bond.style, bond.order = target, 2
+ self.publish_model()
+
+ def apply_hover_shortcut(
+ self,
+ x: float,
+ y: float,
+ key: str,
+ direct_atom_id: int | None = None,
+ *,
+ font: BrowserFontMeasurements | None = None,
+ hits: object = None,
+ scale: object = None,
+ ) -> str | None:
+ if not isinstance(key, str) or key not in (
+ CanvasChemdrawShortcutService.ATOM_HOTKEYS
+ | CanvasChemdrawShortcutService.BOND_HOTKEYS
+ | TOOL_HOTKEYS.keys()
+ ):
+ raise ValueError("Unsupported hover shortcut.")
+ atom_id, bond_id = self.hover_structure(x, y, direct_atom_id, hits, scale)
+
+ if (
+ atom_id is not None and key in CanvasChemdrawShortcutService.ATOM_HOTKEYS
+ ) or (
+ bond_id is not None and key in CanvasChemdrawShortcutService.BOND_HOTKEYS
+ ):
+ self.require_sheet_position(x, y)
+
+ def restyle(bond_id: int, style: str, order: int) -> None:
+ bond = self.model.bond_for_id(bond_id)
+ assert bond is not None
+ bond.style, bond.order = style, order
+
+ shortcuts = CanvasChemdrawShortcutService(
+ lambda: self.model,
+ hover_state=cast("Any", None),
+ atom_label_service=cast(
+ "Any",
+ SimpleNamespace(
+ add_or_update_atom_label=partial(
+ self.labels.add_or_update_atom_label, record=False
+ ),
+ ),
+ ),
+ structure_build_service=cast("Any", self.growth),
+ notify_error=self.reject_edit,
+ scene_transform_controller=cast(
+ "Any", SimpleNamespace(apply_bond_style=restyle)
+ ),
+ tool_mode_controller=cast("Any", None),
+ mark_scene_service=cast(
+ "Any",
+ SimpleNamespace(
+ change_charge_for_atom=lambda atom_id, delta: (
+ self.change_charge_for_atom(atom_id, delta, font)
+ )
+ ),
+ ),
+ )
+ handled = (
+ atom_id is not None and shortcuts.handle_atom_text(key, atom_id)
+ ) or (bond_id is not None and shortcuts.handle_bond_text(key, bond_id))
+ if handled:
+ if self.candidate_accepted:
+ self.publish_model()
+ return None
+ # Without a hovered target the key reaches the native generic hotkeys.
+ return TOOL_HOTKEYS.get(key) or SHIFT_TOOL_HOTKEYS.get(key)
+
+ def center_inside_ring(self, center: BrowserPoint) -> bool:
+ return any(
+ polygon_contains_point((center.x(), center.y()), ring["points"])
+ for ring in self.document_state.get("ring_fills", [])
+ )
+
+ def attach_ring(self, ring: RingFill) -> None:
+ record = ring_fill_to_state(ring, self.model.atoms)
+ record.pop("kind")
+ self.document_state.setdefault("ring_fills", []).append(record)
+
+ def ring_points(
+ self, center: Any, atom_id: int | None = None, bond_id: int | None = None
+ ) -> Any:
+ return self.builder.benzene_ring_points(
+ center,
+ atom_id,
+ bond_id,
+ regular_ring_points_for_atom=lambda n, atom_id: (
+ regular_ring_points_for_atom_for(
+ self,
+ n,
+ atom_id,
+ point_factory=BrowserPoint,
+ )
+ ),
+ regular_ring_points_for_bond=lambda n, bond_id, hint: (
+ regular_ring_points_for_bond_for(
+ self,
+ n,
+ bond_id,
+ hint,
+ point_factory=BrowserPoint,
+ )
+ ),
+ )
+
+ def structure_target(
+ self,
+ x: float,
+ y: float,
+ *,
+ bond_gate_ratio: float,
+ direct_atom_id: int | None = None,
+ ) -> tuple[int | None, int | None]:
+ if any(
+ type(v) not in (int, float, Decimal) or not math.isfinite(v) for v in (x, y)
+ ):
+ raise ValueError("Atom coordinates must be finite numbers.")
+ x, y = float(x), float(y)
+ if direct_atom_id is not None:
+ if (
+ type(direct_atom_id) is not int
+ or direct_atom_id not in self.model.atoms
+ ):
+ raise ValueError("Unknown atom hit target.")
+ return direct_atom_id, None
+ radius = atom_pick_radius_for(self)
+ atom_id = self.find_atom_near(x, y, radius)
+ atom = self.model.atom_for_id(atom_id) if atom_id is not None else None
+ pos = BrowserPoint(x, y)
+ length = self.renderer.style.bond_length_px
+ bond_id = nearest_bond_id(
+ self.model,
+ bond_pick_candidates(
+ self.model, x, y, length * bond_gate_ratio, max(8.0, length)
+ ),
+ pos,
+ length * bond_gate_ratio,
+ point_factory=BrowserPoint,
+ )
+ bond_hit = None
+ if bond_id is not None:
+ bond = self.model.bonds[bond_id]
+ assert bond is not None
+ a, b = self.model.atoms[bond.a], self.model.atoms[bond.b]
+ bond_hit = BondHitCandidate(
+ bond_id,
+ distance_point_to_segment(
+ pos, BrowserPoint(a.x, a.y), BrowserPoint(b.x, b.y)
+ ),
+ )
+ hit = choose_preferred_structure_hit(
+ AtomHitCandidate(atom_id, math.hypot(atom.x - x, atom.y - y))
+ if atom_id is not None and atom is not None
+ else None,
+ bond_hit,
+ atom_pick_radius=radius,
+ bond_pick_radius=length * STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ )
+ return (
+ hit.id if hit is not None and hit.kind == "atom" else None,
+ hit.id if hit is not None and hit.kind == "bond" else None,
+ )
+
+ def hover_structure(
+ self,
+ x: float,
+ y: float,
+ direct_atom_id: int | None = None,
+ hits: object = None,
+ scale: object = None,
+ ) -> tuple[int | None, int | None]:
+ """preferred_structure_hit_at_scene_pos: the structure under the pointer.
+
+ A mark on top hides the structure; inside a ring fill the nearest ring
+ atom within 0.4 bond lengths is taken when no atom or bond is near.
+ """
+ top = (
+ None
+ if hits is None
+ else self.pick_target(x, y, hits, preferred=False, scale=scale)
+ )
+ if top is not None and top["target"] == "mark":
+ return None, None
+ if top is not None and top["target"] == "atom":
+ return top["id"], None
+ atom_id, bond_id = self.structure_target(
+ x,
+ y,
+ bond_gate_ratio=STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ direct_atom_id=direct_atom_id,
+ )
+ if atom_id is not None or bond_id is not None or top is None:
+ return atom_id, bond_id
+ if top["target"] == "ring":
+ ring = self.document_state["ring_fills"][top["id"]]
+ return (
+ nearest_ring_atom_id(
+ [
+ (
+ i,
+ math.hypot(
+ self.model.atoms[i].x - x, self.model.atoms[i].y - y
+ ),
+ )
+ for i in ring["atom_ids"]
+ if i in self.model.atoms
+ ],
+ max_distance=self.renderer.style.bond_length_px * 0.4,
+ ),
+ None,
+ )
+ return None, top["id"] if top["target"] == "bond" else None
+
+ def insert_template(
+ self,
+ x: float,
+ y: float,
+ ring_size: object,
+ style: object,
+ atom_id: int | None = None,
+ bond_id: int | None = None,
+ ) -> None:
+ """InsertController.commit_template_insert for the Ring tool's template."""
+ if (ring_size, style) not in {
+ (size, name) for _, size, name in TEMPLATE_ENTRY_SPECS
+ }:
+ raise ValueError("Unknown ring template.")
+ session = begin_template_insert(cast("int", ring_size), cast("str", style))
+ request = (
+ None
+ if session is None
+ else build_template_insert_request(session, (x, y), bond_id, atom_id)
+ )
+ plan = None if request is None else plan_template_commit(request)
+ if request is None or plan is None:
+ raise ValueError("The ring template cannot be placed here.")
+ self.group_connection_allowed(
+ growth_anchors(self.model.bonds, atom_id=plan.atom_id, bond_id=plan.bond_id)
+ )
+ if plan.generator == "benzene":
+ self.insert_benzene(x, y, plan.atom_id, plan.bond_id)
+ return
+ resolution = TemplateGeometryResolverService(
+ self, point_factory=BrowserPoint
+ ).resolve_insert(request, plan)
+ # The committer is typed for Qt points; BrowserPoint offers the same x()/y().
+ committer = cast("Any", self.committer)
+ if (
+ resolution is None
+ or resolution.points is None
+ or len(resolution.points) != plan.ring_size
+ or not commit_template_ring(
+ self,
+ plan,
+ [BrowserPoint(px, py) for px, py in resolution.points],
+ add_atom_with_merge=committer.add_atom_with_merge,
+ add_ring_from_points=committer.add_ring_from_points,
+ add_ring_fill=committer.add_ring_fill,
+ bond_exists=lambda a, b: (
+ self.committer.bond_id_between(a, b) is not None
+ ),
+ )
+ ):
+ raise ValueError("The ring template cannot be placed here.")
+ self.publish_model()
+
+ def template_preview(
+ self,
+ x: float,
+ y: float,
+ ring_size: object,
+ style: object,
+ direct_atom_id: int | None = None,
+ ) -> dict[str, Any] | None:
+ """InsertController.render_template_preview for the Ring tool's template."""
+ if (ring_size, style) not in {
+ (size, name) for _, size, name in TEMPLATE_ENTRY_SPECS
+ }:
+ raise ValueError("Unknown ring template.")
+ try:
+ self.require_sheet_position(x, y)
+ except ValueError:
+ return None
+ atom_id, bond_id = self.structure_target(
+ x,
+ y,
+ bond_gate_ratio=TEMPLATE_BOND_GATE_RATIO,
+ direct_atom_id=direct_atom_id,
+ )
+ session = begin_template_insert(cast("int", ring_size), cast("str", style))
+ request = (
+ None
+ if session is None
+ else build_template_insert_request(session, (x, y), bond_id, atom_id)
+ )
+ plan = None if request is None else plan_template_preview(request)
+ if request is None or plan is None:
+ return None
+ if plan.generator == "benzene":
+ if not self.group_connection_allowed(
+ growth_anchors(
+ self.model.bonds, atom_id=plan.atom_id, bond_id=plan.bond_id
+ ),
+ notify=False,
+ ):
+ return None
+ placement = self.builder.plan_placement(
+ cast("Any", BrowserPoint(x, y)),
+ plan.atom_id,
+ plan.bond_id,
+ benzene_ring_points=self.ring_points,
+ )
+ if placement is None:
+ return None
+ points = [(point.x(), point.y()) for point in placement.points]
+ bond_orders: list[int] | None = placement.bond_orders
+ else:
+ resolution = TemplateGeometryResolverService(
+ self, point_factory=BrowserPoint
+ ).resolve_insert(request, plan)
+ if resolution is None or resolution.points is None:
+ return None
+ points, bond_orders = list(resolution.points), resolution.bond_orders
+ preview = plan_template_preview_update(
+ points,
+ max(0.6, self.renderer.style.bond_line_width * 0.6),
+ 0,
+ 0,
+ aromatic=plan.ring_style == "benzene" and plan.ring_size == 6,
+ bond_orders=bond_orders,
+ )
+ if preview.geometry is None:
+ return None
+ return {
+ "segments": list(preview.geometry.line_segments),
+ "dots": list(preview.geometry.dot_rects),
+ "width": self.renderer.bond_line_width(),
+ "color": PREVIEW_COLOR_RGBA,
+ "opacity": PREVIEW_OPACITY,
+ }
+
+ def insert_benzene(
+ self, x: float, y: float, atom_id: int | None = None, bond_id: int | None = None
+ ) -> None:
+ for target, records in (
+ (atom_id, self.model.atoms),
+ (bond_id, dict(enumerate(self.model.bonds))),
+ ):
+ if target is not None and (
+ type(target) is not int
+ or target not in records
+ or records[target] is None
+ ):
+ raise ValueError("Unknown ring attachment.")
+ self.group_connection_allowed(
+ growth_anchors(self.model.bonds, atom_id=atom_id, bond_id=bond_id)
+ )
+ self.builder.build_benzene_ring(
+ cast("Any", BrowserPoint(x, y)),
+ atom_id,
+ bond_id,
+ benzene_ring_points=self.ring_points,
+ add_atom_with_merge=self.committer.add_atom_with_merge,
+ bond_exists=lambda a, b: self.committer.bond_id_between(a, b) is not None,
+ create_ring_fill_item=lambda points, ids: RingFill(
+ tuple(ids),
+ self.renderer.style.ring_fill_color,
+ self.renderer.style.ring_fill_alpha,
+ ),
+ )
+ self.publish_model()
+
+ def publish_model(self) -> None:
+ state, warnings = serialize_model_state_with_warnings(
+ self.model,
+ {key for key, atom in self.model.atoms.items() if atom.explicit_label},
+ )
+ if warnings:
+ raise ValueError(
+ "The edit produced invalid model data: " + "; ".join(warnings)
+ )
+ self.document_state["model"] = state
+
+ def publish_perspective(self) -> None:
+ """Save the stored depth points that still project onto a live atom.
+
+ The shared move controller carries points with moved atoms, as on the
+ desktop; a deleted atom's point is left out here.
+ """
+ coords = self.runtime_state.atom_coords_3d_state.atom_coords_3d
+ rotation = self.runtime_state.rotation_state
+ perspective = saved_perspective(
+ coords,
+ {atom_id: (atom.x, atom.y) for atom_id, atom in self.model.atoms.items()},
+ rotation.projection_center_3d,
+ rotation.projection_anchor_2d,
+ bond_length_px=self.renderer.style.bond_length_px,
+ )
+ if perspective is None:
+ self.document_state.pop("perspective", None)
+ else:
+ self.document_state["perspective"] = perspective
+
+ def pick_target(
+ self, x: float, y: float, hits: object, *, preferred: bool, scale: object
+ ) -> dict[str, Any] | None:
+ """Adapt native graphics hits, then use the existing structure picker.
+
+ Select uses preferred structure distance; Shift/eraser use the raw
+ graphics target and the native near-bond fallback instead.
+ """
+ if any(
+ type(v) not in (int, float, Decimal) or not math.isfinite(v) for v in (x, y)
+ ):
+ raise ValueError("Pick coordinates must be finite numbers.")
+ x, y = float(x), float(y)
+ scale = validated_drawing_scale(scale)
+ self.selection_buckets(hits)
+ direct: dict[str, int] = {}
+ covered = False
+ for hit in cast("list[dict[str, Any]]", hits):
+ if hit["target"] == "note" and not covered:
+ # A note above any structure ink takes the pointer; only a
+ # visible atom label, never an implicit carbon, stays on top.
+ atom_id = direct.get("atom")
+ if atom_id is None or not atom_shows_itself(self.model.atoms[atom_id]):
+ return {"target": "note", "id": hit["id"]}
+ covered = covered or hit["target"] not in {"atom", "ring"}
+ direct.setdefault(hit["target"], hit["id"])
+ if "mark" in direct:
+ mark = self.mark_items[direct["mark"]]
+ atom = self.model.atoms[direct["atom"]] if "atom" in direct else None
+ if mark_precedes_atom(
+ (x, y),
+ (mark.center.x(), mark.center.y()),
+ (atom.x, atom.y) if atom is not None else None,
+ ):
+ return {"target": "mark", "id": direct["mark"]}
+ if "atom" in direct:
+ return {"target": "atom", "id": direct["atom"]}
+ bond_id = direct.get("bond")
+ if bond_id is None:
+ bond_id = nearest_bond_id(
+ self.model,
+ bond_pick_candidates(
+ self.model,
+ x,
+ y,
+ self.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ max(8.0, self.renderer.style.bond_length_px),
+ ),
+ BrowserPoint(x, y),
+ self.renderer.style.bond_length_px * STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ point_factory=BrowserPoint,
+ )
+ if bond_id is None and "ring" not in direct:
+ for index in reversed(range(len(self.document_state["ring_fills"]))):
+ if polygon_contains_point(
+ (x, y),
+ self.document_state["ring_fills"][index]["points"],
+ ):
+ direct["ring"] = index
+ break
+
+ def preferred_structure() -> dict[str, Any] | None:
+ atom_id, preferred_bond = self.structure_target(
+ x, y, bond_gate_ratio=STRUCTURE_BOND_PICK_RADIUS_RATIO
+ )
+ if atom_id is not None:
+ return {"target": "atom", "id": atom_id}
+ if preferred_bond is not None:
+ return {"target": "bond", "id": preferred_bond}
+ return None
+
+ if bond_id is None and "ring" in direct:
+ if preferred:
+ if (structure := preferred_structure()) is not None:
+ return structure
+ atom_id = nearest_ring_atom_id(
+ [
+ (
+ i,
+ math.hypot(
+ self.model.atoms[i].x - x, self.model.atoms[i].y - y
+ ),
+ )
+ for i in self.document_state["ring_fills"][direct["ring"]][
+ "atom_ids"
+ ]
+ ],
+ max_distance=self.renderer.style.bond_length_px * 0.4,
+ )
+ if atom_id is not None:
+ return {"target": "atom", "id": atom_id}
+ return {"target": "ring", "id": direct["ring"]}
+ # A foreground shape stops the native near-arrow search, while native
+ # atom/bond precedence above still applies through decorative panels.
+ first_other = next(
+ (
+ hit
+ for hit in cast("list[dict[str, Any]]", hits)
+ if hit["target"] in {"shape", "image", "arrow", "orbital", "ts_bracket"}
+ ),
+ None,
+ )
+ if (
+ bond_id is None
+ and first_other is not None
+ and (
+ first_other["target"] in {"orbital", "ts_bracket"}
+ or (
+ first_other["target"] in {"shape", "image"}
+ and self.panel_depth(first_other) >= 0
+ )
+ )
+ ):
+ return first_other
+ # Native Select takes a directly hit arrow before structure fallback.
+ if bond_id is None and "arrow" in direct:
+ return {"target": "arrow", "id": direct["arrow"]}
+ if bond_id is None and (near := self.nearest_line_target(x, y, scale)):
+ return near
+ if preferred and (structure := preferred_structure()) is not None:
+ return structure
+ if bond_id is not None:
+ return {"target": "bond", "id": bond_id}
+ # The topmost shape or image panel under the pointer.
+ return next(
+ (
+ {"target": hit["target"], "id": hit["id"]}
+ for hit in cast("list[dict[str, Any]]", hits)
+ if hit["target"] in {"shape", "image"}
+ ),
+ None,
+ )
+
+ def panel_depth(self, hit: dict[str, Any]) -> float:
+ """Stacking depth of a shape or image, with the desktop's defaults."""
+ if hit["target"] == "image":
+ return float(self.document_state["images"][hit["id"]].get("z", -2.0))
+ return float(self.document_state["shapes"][hit["id"]].get("z", -10))
+
+ def nearest_line_target(
+ self, x: float, y: float, scale: float
+ ) -> dict[str, Any] | None:
+ """Native Select's near-arrow search, which also takes bracket strokes."""
+ nearest = None
+ best_distance = ARROW_PICK_SCREEN_PX
+ point = BrowserPoint(x * scale, y * scale)
+ # Brackets populate after arrows, so they come first at equal depth.
+ brackets = self.document_state["ts_brackets"]
+ length = self.renderer.style.bond_length_px
+ width = bracket_stroke_width(self.renderer.style.bond_line_width)
+ measured = (self.measured_drawing or {}).get("brackets")
+ for bracket_id in reversed(range(len(brackets))):
+ source = brackets[bracket_id]
+ path = bracket_path_commands(
+ bracket_rect(source), source["bracket_kind"], length
+ )
+ # Qt measures to its flattened filled outline, not the centre line.
+ rings = bracket_outline(path, width) if path else []
+ if not path and measured and measured[bracket_id].get("outline"):
+ rings = [measured[bracket_id]["outline"]]
+ distance = min(
+ (
+ distance_point_to_segment(
+ point,
+ BrowserPoint(a[0] * scale, a[1] * scale),
+ BrowserPoint(b[0] * scale, b[1] * scale),
+ )
+ for ring in rings
+ for a, b in pairwise(ring)
+ ),
+ default=math.inf,
+ )
+ if distance < best_distance:
+ nearest, best_distance = ("ts_bracket", bracket_id), distance
+ paths = arrow_geometry(self.document_state, self.renderer)
+ segment_count = 0
+ # Native scene order puts the last added arrow first on equal distance.
+ for arrow_id in reversed(range(len(paths))):
+ distance = math.inf
+ for a, b in arrow_pick_segments(paths[arrow_id]["path"], scale):
+ segment_count += 1
+ if segment_count > 500_000:
+ raise ValueError(
+ "Arrow picking exceeds the browser path point limit."
+ )
+ distance = min(distance, distance_point_to_segment(point, a, b))
+ if distance < best_distance:
+ nearest, best_distance = ("arrow", arrow_id), distance
+ if nearest is not None:
+ return {"target": nearest[0], "id": nearest[1]}
+ return None
+
+ def selection_components(
+ self, items: object, drawing: dict[str, Any]
+ ) -> list[list[dict[str, Any]]]:
+ buckets = self.selection_buckets(items)
+ atom_ids = set(buckets.atom_ids)
+ for ring in buckets.ring_items:
+ atom_ids.update(cast("BrowserRingItem", ring).record["atom_ids"])
+ neighbors, atom_bonds = build_bond_adjacency_index(
+ self.model.atoms, self.model.bonds
+ )
+ atom_ids, bond_ids = selection_structure_ids(
+ self.model, atom_ids, buckets.bond_ids, atom_bonds
+ )
+ components: list[list[dict[str, Any]]] = []
+ for component in connected_components_for_nodes(atom_ids, neighbors):
+ bonds = {
+ i
+ for i in bond_ids
+ if (bond := self.model.bond_for_id(i)) is not None
+ and bond.a in component
+ and bond.b in component
+ }
+ bonded = {
+ a
+ for i in bonds
+ if (bond := self.model.bond_for_id(i)) is not None
+ for a in (bond.a, bond.b)
+ }
+ parts = []
+ for atom_id in sorted(component):
+ atom = self.model.atoms[atom_id]
+ if atom_id in bonded and not atom_shows_itself(atom):
+ continue
+ parts.append(
+ {
+ "rect": selection_atom_rect(
+ atom.x,
+ atom.y,
+ atom_pick_radius(self.renderer),
+ drawing.get("atom_selection_rects", {}).get(str(atom_id)),
+ )
+ }
+ )
+ for bond_id in sorted(bonds):
+ parts.extend(drawing["selection_bonds"].get(str(bond_id), []))
+ if parts:
+ components.append(parts)
+ radius = atom_pick_radius(self.renderer)
+ for mark in buckets.mark_items:
+ center = cast("BrowserMarkItem", mark).center
+ components.append(
+ [
+ {
+ "rect": (
+ center.x() - radius,
+ center.y() - radius,
+ radius * 2,
+ radius * 2,
+ )
+ }
+ ]
+ )
+ orbital_ids = {
+ id(cast("BrowserOrbitalItem", item).record)
+ for item in buckets.other_items
+ if isinstance(item, BrowserOrbitalItem)
+ }
+ for record, geometry in zip(
+ self.document_state["orbitals"], drawing["orbitals"], strict=True
+ ):
+ if id(record) in orbital_ids:
+ components.append(
+ [
+ {
+ "polygon": geometry["polygon"],
+ "width": self.renderer.style.bond_length_px
+ * SELECTION_OBJECT_PADDING_RATIO
+ * 2,
+ }
+ ]
+ )
+ # Native object outline: the filled bracket shape grown by the padding.
+ pad = self.renderer.style.bond_length_px * SELECTION_OBJECT_PADDING_RATIO
+ bracket_ids = {
+ id(cast("BrowserSceneItem", item).record)
+ for item in buckets.ts_bracket_items
+ }
+ for record, geometry in zip(
+ self.document_state["ts_brackets"], drawing["brackets"], strict=True
+ ):
+ if id(record) not in bracket_ids:
+ continue
+ symbol = geometry["symbol"]
+ components.append(
+ [
+ {
+ "text": symbol,
+ "width": pad * 2,
+ "bounds": geometry["bounds"],
+ }
+ if symbol
+ else {
+ "path": geometry["path"],
+ "width": geometry["width"] + pad * 2,
+ }
+ ]
+ )
+ return components
+
+ def selected_atom_ids(self, buckets: DeleteSelectionBuckets) -> set[int]:
+ """Atoms a selection moves or turns: atoms, bond ends and ring members."""
+ atom_ids = selected_atom_ids_with_bond_endpoints(
+ buckets.atom_ids, buckets.bond_ids, bonds=self.model.bonds
+ )
+ for ring in buckets.ring_items:
+ atom_ids.update(ring.data(2))
+ return atom_ids
+
+ def selection_frame(
+ self, items: object, drawing: dict[str, Any]
+ ) -> dict[str, Any] | None:
+ buckets = self.selection_buckets(items)
+ atom_ids = self.selected_atom_ids(buckets)
+ orbital_ids = {
+ id(cast("BrowserOrbitalItem", item).record)
+ for item in buckets.other_items
+ if isinstance(item, BrowserOrbitalItem)
+ }
+ if not selection_frame_applies(
+ len(atom_ids), len(buckets.arrow_items) + len(orbital_ids)
+ ):
+ return None
+ rects = []
+ for atom_id in sorted(atom_ids):
+ atom = self.model.atoms[atom_id]
+ label_rect = drawing.get("atom_selection_rects", {}).get(str(atom_id))
+ rects.append(
+ selection_atom_rect(
+ atom.x, atom.y, atom_pick_radius(self.renderer), label_rect
+ )
+ )
+ if label_rect is not None:
+ rects.append(label_rect)
+ selected_arrows = {
+ id(item.record)
+ for item in cast("list[BrowserSceneItem]", buckets.arrow_items)
+ }
+ for index, record in enumerate(self.document_state["arrows"]):
+ if id(record) not in selected_arrows:
+ continue
+ rects.append(arrow_frame_bounds(drawing["arrows"][index]))
+ for label in drawing.get("arrow_labels", []):
+ if label["id"] == index:
+ rects.append(
+ (label["x"], label["y"], label["width"], label["height"])
+ )
+ rects.extend(
+ geometry["bounds"]
+ for record, geometry in zip(
+ self.document_state["orbitals"], drawing["orbitals"], strict=True
+ )
+ if id(record) in orbital_ids
+ )
+ return {
+ "rects": rects,
+ "padding": self.renderer.style.bond_length_px
+ * SELECTION_OBJECT_PADDING_RATIO,
+ }
+
+ def selection_buckets(self, items: object) -> DeleteSelectionBuckets:
+ if not isinstance(items, list) or len(items) > 5000 + len(
+ self.document_state["arrows"]
+ ) + len(self.document_state["shapes"]) + len(
+ self.document_state["ring_fills"]
+ ) + len(self.mark_items) + len(self.document_state["orbitals"]) + len(
+ self.document_state["ts_brackets"]
+ ) + len(self.document_state["notes"]) + len(
+ self.document_state.get("images", [])
+ ):
+ raise ValueError("Expected a bounded list of selected items.")
+ buckets = DeleteSelectionBuckets()
+ annotation_ids = set()
+ for item in items:
+ if not isinstance(item, dict) or set(item) != {"target", "id"}:
+ raise ValueError("Expected target and id for each selected item.")
+ kind, item_id = item["target"], item["id"]
+ if (
+ kind
+ not in {
+ "atom",
+ "bond",
+ "arrow",
+ "shape",
+ "ring",
+ "mark",
+ "orbital",
+ "ts_bracket",
+ "note",
+ "image",
+ }
+ or type(item_id) is not int
+ or item_id < 0
+ ):
+ raise ValueError("Invalid selection target.")
+ if kind == "atom":
+ if self.model.atom_for_id(item_id) is None:
+ raise ValueError("The atom no longer exists.")
+ buckets.atom_ids.add(item_id)
+ elif kind == "bond":
+ if self.model.bond_for_id(item_id) is None:
+ raise ValueError("The bond no longer exists.")
+ buckets.bond_ids.add(item_id)
+ else:
+ records = self.document_state.get(
+ {
+ "arrow": "arrows",
+ "shape": "shapes",
+ "ring": "ring_fills",
+ "mark": "marks",
+ "orbital": "orbitals",
+ "ts_bracket": "ts_brackets",
+ "note": "notes",
+ "image": "images",
+ }[kind],
+ [],
+ )
+ if item_id >= len(records):
+ raise ValueError(f"The {kind} no longer exists.")
+ if (kind, item_id) not in annotation_ids:
+ target = {
+ "arrow": buckets.arrow_items,
+ "shape": buckets.other_items,
+ "ring": buckets.ring_items,
+ "mark": buckets.mark_items,
+ "orbital": buckets.other_items,
+ "ts_bracket": buckets.ts_bracket_items,
+ "note": buckets.note_items,
+ "image": buckets.other_items,
+ }[kind]
+ wrapper = (
+ BrowserRingItem
+ if kind == "ring"
+ else BrowserOrbitalItem
+ if kind == "orbital"
+ else BrowserNoteItem
+ if kind == "note"
+ else BrowserSceneItem
+ )
+ cast("list[Any]", target).append(
+ self.mark_items[item_id]
+ if kind == "mark"
+ else wrapper(records[item_id])
+ )
+ annotation_ids.add((kind, item_id))
+ return buckets
+
+ def transform_center(
+ self,
+ atom_ids: set[int],
+ buckets: DeleteSelectionBuckets,
+ drawing: dict[str, Any] | None,
+ *,
+ include_dependent_marks: bool,
+ ) -> tuple[float, float] | None:
+ arrows = cast("list[BrowserSceneItem]", buckets.arrow_items)
+ shapes = cast("list[BrowserSceneItem]", buckets.other_items)
+ points = [(self.model.atoms[i].x, self.model.atoms[i].y) for i in atom_ids]
+ for item in arrows:
+ points.extend(
+ item.record[key]
+ for key in ("start", "end", "control")
+ if item.record.get(key) is not None
+ )
+ selected_shapes = {id(item.record) for item in shapes}
+ selected_orbitals = [
+ item for item in shapes if isinstance(item, BrowserOrbitalItem)
+ ]
+ if selected_orbitals:
+ orbital_drawing = (
+ drawing_geometry(self.document_state)["orbitals"]
+ if drawing is None or drawing.get("needs_measurements")
+ else drawing["orbitals"]
+ )
+ for source, geometry in zip(
+ self.document_state["orbitals"], orbital_drawing, strict=True
+ ):
+ if id(source) in selected_shapes:
+ left, top, width, height = geometry["bounds"]
+ points.extend(((left, top), (left + width, top + height)))
+ if buckets.ts_bracket_items:
+ bracket_ids = {
+ id(cast("BrowserSceneItem", item).record)
+ for item in buckets.ts_bracket_items
+ }
+ # Stroke bounds need no font; unmeasured daggers fail closed below.
+ bracket_drawing = (
+ drawing_geometry(self.document_state)["brackets"]
+ if drawing is None or drawing.get("needs_measurements")
+ else drawing["brackets"]
+ )
+ for source, bracket in zip(
+ self.document_state["ts_brackets"], bracket_drawing, strict=True
+ ):
+ if id(source) not in bracket_ids:
+ continue
+ if bracket["bounds"] is None:
+ raise ValueError(
+ "Dagger transforms need completed font measurements."
+ )
+ left, top, width, height = bracket["bounds"]
+ points.extend(((left, top), (left + width, top + height)))
+ for source, shape in zip(
+ self.document_state["shapes"],
+ shape_geometry(self.document_state, self.renderer),
+ strict=True,
+ ):
+ if id(source) not in selected_shapes:
+ continue
+ if shape["bounds"] is None:
+ continue
+ left, top, width, height = shape["bounds"]
+ if width > 0 and height > 0:
+ points.extend([(left, top), (left + width, top + height)])
+ for bounds in self.note_bounds(buckets, drawing).values():
+ left, top, width, height = bounds
+ points.extend(((left, top), (left + width, top + height)))
+ for item in shapes:
+ if item.data(0) == "image":
+ left, top, width, height = image_bounds(item.record)
+ if width > 0 and height > 0:
+ points.extend(((left, top), (left + width, top + height)))
+ pivot_marks = (
+ buckets.mark_items
+ if include_dependent_marks
+ else independent_selection_items(buckets.mark_items, atom_ids)
+ )
+ if pivot_marks:
+ if drawing is None or drawing.get("needs_measurements"):
+ raise ValueError("Mark transforms need completed font measurements.")
+ bounds_by_record = {
+ id(record): geometry["bounds"]
+ for record, geometry in zip(
+ self.document_state["marks"], drawing["marks"], strict=True
+ )
+ }
+ for mark_item in pivot_marks:
+ left, top, width, height = bounds_by_record[
+ id(cast("BrowserMarkItem", mark_item).record)
+ ]
+ if width > 0 and height > 0:
+ points.extend(((left, top), (left + width, top + height)))
+ return selection_transform_center(points)
+
+ def note_bounds(
+ self, buckets: DeleteSelectionBuckets, drawing: dict[str, Any] | None
+ ) -> dict[int, tuple[float, float, float, float]]:
+ """Measured scene boxes of the selected notes, keyed by record identity."""
+ if not buckets.note_items:
+ return {}
+ if drawing is None or drawing.get("needs_measurements"):
+ raise ValueError("Note transforms need completed font measurements.")
+ selected = {
+ id(cast("BrowserSceneItem", item).record) for item in buckets.note_items
+ }
+ return {
+ id(record): geometry["bounds"]
+ for record, geometry in zip(
+ self.document_state["notes"], drawing["notes"], strict=True
+ )
+ if id(record) in selected
+ }
+
+ def transform_selection(
+ self, edit: dict[str, Any], drawing: dict[str, Any] | None = None
+ ) -> None:
+ if edit["kind"] in {"align", "distribute"}:
+ if set(edit) != {"kind", "selection", "mode"} or drawing is None:
+ raise ValueError("Invalid alignment fields.")
+ self.arrange_selection(
+ edit["selection"],
+ edit["mode"],
+ distribute=edit["kind"] == "distribute",
+ drawing=drawing,
+ )
+ return
+ angle = None
+ horizontal = None
+ if edit["kind"] == "flip":
+ if (
+ set(edit) != {"kind", "selection", "horizontal"}
+ or type(edit["horizontal"]) is not bool
+ ):
+ raise ValueError("Invalid flip fields.")
+ horizontal = edit["horizontal"]
+ elif set(edit) == {"kind", "selection", "value"}:
+ angle = float(edit["value"])
+ if not ROTATE_ANGLE_RANGE[0] <= angle <= ROTATE_ANGLE_RANGE[1]:
+ raise ValueError(
+ f"Rotation angle must be between {ROTATE_ANGLE_RANGE[0]} and "
+ f"{ROTATE_ANGLE_RANGE[1]} degrees."
+ )
+ elif set(edit) == {"kind", "selection", "start", "end", "shift"}:
+ if type(edit["shift"]) is not bool:
+ raise ValueError("Rotation snap must be a boolean.")
+ for key in ("start", "end"):
+ point = edit[key]
+ if (
+ not isinstance(point, list)
+ or len(point) != 2
+ or any(
+ type(value) not in (int, float, Decimal)
+ or not math.isfinite(value)
+ for value in point
+ )
+ ):
+ raise ValueError(
+ "Rotation pointer coordinates must be finite points."
+ )
+ else:
+ raise ValueError("Invalid rotation fields.")
+ buckets = self.selection_buckets(edit["selection"])
+ arrows = cast("list[BrowserSceneItem]", buckets.arrow_items)
+ shapes = cast("list[BrowserSceneItem]", buckets.other_items)
+ if horizontal is not None:
+ for item in arrows:
+ if (
+ item.record["kind"] in VALID_EQUILIBRIUM_KINDS
+ and item.record["start"] == item.record["end"]
+ ):
+ raise ValueError(
+ "Cannot flip a zero-length equilibrium arrow: its direction is undefined. Move an endpoint before flipping this selection."
+ )
+ if angle == 0:
+ return
+ atom_ids = self.selected_atom_ids(buckets)
+ note_bounds = self.note_bounds(buckets, drawing)
+ center = self.transform_center(
+ atom_ids, buckets, drawing, include_dependent_marks=horizontal is not None
+ )
+ if center is None:
+ return
+ if angle is None and horizontal is None:
+ angle = rotation_drag_angle(
+ BrowserPoint(*center),
+ BrowserPoint(*(float(value) for value in edit["start"])),
+ BrowserPoint(*(float(value) for value in edit["end"])),
+ snap_step=ROTATION_SNAP_STEP_DEGREES if edit["shift"] else None,
+ )
+ if angle == 0:
+ return
+ positions = (
+ {
+ i: reflected_point(
+ BrowserPoint(self.model.atoms[i].x, self.model.atoms[i].y),
+ BrowserPoint(*center),
+ horizontal,
+ )
+ for i in atom_ids
+ }
+ if horizontal is not None
+ else rotated_atom_positions(
+ atom_ids,
+ atoms=self.model.atoms,
+ center=BrowserPoint(*center),
+ angle_radians=math.radians(cast("float", angle)),
+ )
+ )
+ selected_mark_ids = {id(item) for item in buckets.mark_items}
+ mark_updates = []
+ for transformed_mark in self.mark_items:
+ if (
+ transformed_mark.record["atom_id"] not in atom_ids
+ and id(transformed_mark) not in selected_mark_ids
+ ):
+ continue
+ position = (
+ reflected_point(
+ transformed_mark.center, BrowserPoint(*center), horizontal
+ )
+ if horizontal is not None
+ else rotated_point_coordinates(
+ transformed_mark.center,
+ BrowserPoint(*center),
+ math.radians(cast("float", angle)),
+ )
+ )
+ mark_updates.append(
+ (
+ transformed_mark,
+ mark_state_at_position(
+ transformed_mark.record,
+ position,
+ transformed_atom_positions=positions,
+ atoms=self.model.atoms,
+ ),
+ )
+ )
+ controller = CanvasMoveController(
+ cast("Any", self),
+ point_factory=BrowserPoint,
+ hit_testing_service=cast(
+ "Any", SimpleNamespace(mark_spatial_index_dirty=lambda: None)
+ ),
+ )
+ controller.set_atom_positions(positions, update_selection=False)
+ for mark_item, state in mark_updates:
+ mark_item.record.update(state)
+ mark_item.set_center(BrowserPoint(state["x"], state["y"]))
+
+ def transformed(record: Any) -> Any:
+ if horizontal is not None:
+ return flip_annotation(record, center=center, horizontal=horizontal)
+ return rotate_annotation(
+ record, center=center, angle_degrees=cast("float", angle)
+ )
+
+ for item in arrows:
+ state = arrow_to_state(transformed(arrow_from_state(item.record)))
+ # False is omitted by the native serializer; remove the prior flag.
+ item.record.pop("mirrored", None)
+ item.record.update(state)
+ for item in shapes:
+ if isinstance(item, BrowserOrbitalItem):
+ item.apply_orbital_state(
+ orbital_to_state(
+ transformed(orbital_from_state(item.orbital_state()))
+ )
+ )
+ elif item.data(0) == "image":
+ # Image pixels stay upright; only the image's box moves.
+ source = item.record
+ position = (float(source["x"]), float(source["y"]))
+ size = (float(source["width"]), float(source["height"]))
+ source["x"], source["y"] = (
+ mirrored_box_position(
+ position,
+ (*position, *size),
+ center=center,
+ horizontal=horizontal,
+ )
+ if horizontal is not None
+ else orbited_box_position(
+ position,
+ size,
+ center=center,
+ angle_degrees=cast("float", angle),
+ )
+ )
+ else:
+ item.record.update(
+ shape_to_state(transformed(shape_from_state(item.record)))
+ )
+ for bracket in buckets.ts_bracket_items:
+ source = cast("BrowserSceneItem", bracket).record
+ source.update(
+ ts_bracket_to_state(transformed(ts_bracket_from_state(source)))
+ )
+ self.transform_notes(note_bounds, center, angle, horizontal)
+ self.publish_model()
+
+ def transform_notes(
+ self,
+ note_bounds: dict[int, tuple[float, float, float, float]],
+ center: tuple[float, float],
+ angle: float | None,
+ horizontal: bool | None,
+ ) -> None:
+ """Turn notes about the center, or mirror their scene boxes upright."""
+ for record_id, bounds in note_bounds.items():
+ source = next(n for n in self.document_state["notes"] if id(n) == record_id)
+ if horizontal is not None:
+ source["x"], source["y"] = mirrored_box_position(
+ (float(source["x"]), float(source["y"])),
+ bounds,
+ center=center,
+ horizontal=horizontal,
+ )
+ continue
+ note = rotate_annotation(
+ Note(
+ x=float(source["x"]),
+ y=float(source["y"]),
+ rotation=float(source.get("rotation", 0.0)),
+ ),
+ center=center,
+ angle_degrees=cast("float", angle),
+ )
+ source.update(x=note.x, y=note.y, rotation=note.rotation)
+ if not note.rotation:
+ # The native serializer omits an upright note's rotation.
+ del source["rotation"]
+
+ def arrange_selection(
+ self, items: object, mode: str, *, distribute: bool, drawing: dict[str, Any]
+ ) -> None:
+ buckets = self.selection_buckets(items)
+ if drawing.get("needs_measurements") or (
+ drawing.get("atom_labels") and not drawing.get("atom_selection_rects")
+ ):
+ raise ValueError("Alignment needs completed font measurements.")
+ selected_atoms = self.selected_atom_ids(buckets)
+ neighbors, _ = build_bond_adjacency_index(self.model.atoms, self.model.bonds)
+ structures = [
+ component
+ for component in connected_components_for_nodes(
+ set(self.model.atoms), neighbors
+ )
+ if component & selected_atoms
+ ]
+ scene_items = independent_selection_items(
+ [
+ *buckets.arrow_items,
+ *buckets.other_items,
+ *buckets.mark_items,
+ *buckets.ts_bracket_items,
+ *buckets.note_items,
+ ],
+ selected_atoms,
+ )
+ keys: dict[int, tuple[str, int]] = {}
+ for item_kind, records in (
+ ("arrow", self.document_state["arrows"]),
+ ("shape", self.document_state["shapes"]),
+ ("mark", self.document_state["marks"]),
+ ("orbital", self.document_state["orbitals"]),
+ ("ts_bracket", self.document_state["ts_brackets"]),
+ ("note", self.document_state["notes"]),
+ ("image", self.document_state.get("images", [])),
+ ):
+ for index, record in enumerate(records):
+ keys[id(record)] = (item_kind, index)
+ for item in scene_items:
+ item.bounds = BrowserRect(
+ *annotation_bounds(drawing, *keys[id(item.record)])
+ )
+
+ def object_rect(atom_ids: set[int], annotations: list[Any]) -> Any:
+ rect = None
+ radius = atom_pick_radius(self.renderer)
+ for atom_id in sorted(atom_ids):
+ atom = self.model.atoms[atom_id]
+ bounds = drawing.get("atom_selection_rects", {}).get(
+ str(atom_id),
+ (atom.x - radius, atom.y - radius, radius * 2, radius * 2),
+ )
+ atom_rect = BrowserRect(*bounds)
+ rect = atom_rect if rect is None else rect.united(atom_rect)
+ for item in annotations:
+ item_rect = item.sceneBoundingRect()
+ rect = item_rect if rect is None else rect.united(item_rect)
+ return rect
+
+ objects = alignment_objects(
+ structures,
+ scene_items,
+ groups=self.runtime_state.group_state.groups.values(),
+ object_rect=object_rect,
+ )
+ rectangles = [target.rect for target in objects]
+ deltas = (
+ distribute_deltas(rectangles, mode)
+ if distribute
+ else align_deltas(rectangles, mode)
+ )
+ for target, (dx, dy) in zip(objects, deltas, strict=True):
+ if abs(dx) < 1e-9 and abs(dy) < 1e-9:
+ continue
+ selection = [{"target": "atom", "id": i} for i in target.atom_ids]
+ for item in target.items:
+ kind, index = keys[
+ id(cast("BrowserSceneItem | BrowserMarkItem", item).record)
+ ]
+ selection.append({"target": kind, "id": index})
+ self.move_selection(selection, dx, dy)
+
+ def rebind_mark(self, mark_id: int, atom_id: int) -> bool:
+ buckets = self.selection_buckets([{"target": "mark", "id": mark_id}])
+ item = cast("BrowserMarkItem", buckets.mark_items[0])
+ plan = plan_mark_rebind(
+ self.model, item, atom_id, self.runtime_state.mark_registry.by_atom
+ )
+ if plan is None:
+ return False
+ atom = self.model.atoms[atom_id]
+ item.record.update(
+ atom_id=atom_id,
+ dx=item.center.x() - atom.x,
+ dy=item.center.y() - atom.y,
+ )
+ for owner, marks in plan.after_marks.items():
+ self.runtime_state.mark_registry.by_atom[owner] = list(marks)
+ self.model.set_atom_annotation(owner, plan.after_annotations.get(owner))
+ for owner in unmarked_isolated_carbon_ids(
+ {plan.old_id} if plan.old_id is not None else set(),
+ atoms=self.model.atoms,
+ bonds=self.model.bonds,
+ has_visible_label=lambda owner: atom_shows_itself(self.model.atoms[owner]),
+ has_marks=lambda owner: bool(
+ self.runtime_state.mark_registry.get_for_atom(owner)
+ ),
+ ):
+ self.model.atoms[owner].explicit_label = True
+ self.publish_model()
+ return True
+
+ def mark_target_distance_for_atom(
+ self,
+ atom_id: int,
+ direction_x: float,
+ direction_y: float,
+ kind: str,
+ *,
+ drawing: dict[str, Any],
+ font: BrowserFontMeasurements,
+ ) -> float:
+ atom = self.model.atoms[atom_id]
+ length = self.renderer.style.bond_length_px
+ size = self.renderer.atom_font_size_pt()
+ label_bounds = None
+ clearance = 0.0
+ label_rect = drawing.get("atom_label_rects", {}).get(str(atom_id))
+ if label_rect is not None:
+ left, top, width, height = label_rect
+ pad = max(0.05, self.renderer.style.bond_line_width * 0.05)
+ label_bounds = (
+ left - pad,
+ top - pad,
+ left + width + pad,
+ top + height + pad,
+ )
+ base = font.metrics[f"{size}:H"]
+ font_height = float(base["ascent"]) + float(base["descent"])
+ symbol_width = 0.0
+ if kind in {"plus", "minus"}:
+ symbol = "+" if kind == "plus" else "-"
+ symbol_width = float(font.metrics[f"{size}:{symbol}"]["bounding_width"])
+ clearance = mark_clearance(
+ kind,
+ bond_length=length,
+ line_width=self.renderer.style.bond_line_width,
+ font_height=font_height,
+ symbol_width=symbol_width,
+ symbol_height=font_height,
+ )
+ return mark_target_distance(
+ (atom.x, atom.y), label_bounds, clearance, direction_x, direction_y
+ )
+
+ def mark_offset(
+ self,
+ atom_id: int,
+ x: float,
+ y: float,
+ kind: str,
+ drawing: dict[str, Any],
+ font: BrowserFontMeasurements,
+ ) -> tuple[float, float]:
+ atom = self.model.atoms[atom_id]
+ return mark_click_offset(
+ (atom.x, atom.y),
+ (x, y),
+ bond_length=self.renderer.style.bond_length_px,
+ target_distance=partial(
+ self.mark_target_distance_for_atom,
+ atom_id,
+ kind=kind,
+ drawing=drawing,
+ font=font,
+ ),
+ )
+
+ def mark_owner_feedback(
+ self, drawing: dict[str, Any], font: BrowserFontMeasurements
+ ) -> dict[str, Any]:
+ self.render_context.geometry = SimpleNamespace(
+ mark_target_distance_for_atom=partial(
+ self.mark_target_distance_for_atom, drawing=drawing, font=font
+ )
+ )
+ owners = {}
+ for index, item in enumerate(self.mark_items):
+ atom_id = item.record["atom_id"]
+ atom = self.model.atom_for_id(atom_id)
+ feedback: dict[str, Any] = {
+ "text": mark_owner_text_for(cast("Any", self), cast("Any", item))
+ }
+ if atom is not None:
+ feedback.update(
+ rect=drawing["mark_owner_rects"][str(atom_id)],
+ line=[atom.x, atom.y, item.center.x(), item.center.y()],
+ color=DISTANT_MARK_COLOR
+ if mark_is_distant_for(cast("Any", self), cast("Any", item))
+ else drawing["selection_style"]["color"],
+ tooltip=f"Owner: {atom.element} #{atom_id}. {MARK_OWNER_GUIDANCE}",
+ )
+ owners[str(index)] = feedback
+ return owners
+
+ def mark_placement(
+ self,
+ x: float,
+ y: float,
+ kind: str,
+ *,
+ scale: float,
+ hits: object,
+ drawing: dict[str, Any],
+ font: BrowserFontMeasurements,
+ ) -> tuple[int | None, float, float, float | None, float | None]:
+ if not isinstance(kind, str) or kind not in VALID_MARK_KINDS:
+ raise ValueError("Unknown charge or radical kind.")
+ scale = validated_drawing_scale(scale)
+ self.require_sheet_position(x, y)
+ x, y = float(x), float(y)
+ if drawing.get("needs_measurements"):
+ raise ValueError("Mark placement needs completed font measurements.")
+ direct = self.selection_buckets(hits).atom_ids
+ length = self.renderer.style.bond_length_px
+ base_radius = length * 0.35
+ tolerance = max(length * 0.05, 1.5 / scale)
+ candidates = []
+ offsets = {}
+ for atom_id, atom in self.model.atoms.items():
+ radius = atom_pick_radius(self.renderer)
+ bx, by, bw, bh = drawing.get("atom_selection_rects", {}).get(
+ str(atom_id), (atom.x - radius, atom.y - radius, radius * 2, radius * 2)
+ )
+ if (
+ bx + bw <= x - base_radius
+ or bx >= x + base_radius
+ or by + bh <= y - base_radius
+ or by >= y + base_radius
+ ):
+ continue
+ offset = self.mark_offset(atom_id, x, y, kind, drawing, font)
+ offsets[atom_id] = offset
+ candidates.append(
+ (
+ atom_id,
+ math.hypot(x - atom.x, y - atom.y),
+ atom_id in direct,
+ math.hypot(*offset),
+ )
+ )
+ owner_id = choose_mark_atom(
+ candidates, base_radius=base_radius, tolerance=tolerance
+ )
+ dx = dy = None
+ if owner_id is not None:
+ dx, dy = offsets[owner_id]
+ atom = self.model.atoms[owner_id]
+ x, y = atom.x + dx, atom.y + dy
+ return owner_id, x, y, dx, dy
+
+ def insert_mark(
+ self,
+ x: float,
+ y: float,
+ kind: str,
+ *,
+ scale: float,
+ hits: object,
+ drawing: dict[str, Any],
+ font: BrowserFontMeasurements,
+ ) -> None:
+ owner_id, x, y, dx, dy = self.mark_placement(
+ x, y, kind, scale=scale, hits=hits, drawing=drawing, font=font
+ )
+ self.add_mark_record(kind, owner_id, x, y, dx, dy)
+
+ def add_mark_record(
+ self,
+ kind: str,
+ owner_id: int | None,
+ x: float,
+ y: float,
+ dx: float | None,
+ dy: float | None,
+ *,
+ synchronize_annotation: bool = True,
+ ) -> BrowserMarkItem:
+ record = {
+ "kind": kind,
+ "text": "+" if kind == "plus" else "-" if kind == "minus" else None,
+ "atom_id": owner_id,
+ "dx": dx,
+ "dy": dy,
+ "x": x,
+ "y": y,
+ }
+ self.document_state["marks"].append(record)
+ item = BrowserMarkItem(record, BrowserPoint(x, y))
+ self.mark_items.append(item)
+ if owner_id is not None:
+ self.runtime_state.mark_registry.add_for_atom(owner_id, item)
+ if owner_id is not None and synchronize_annotation:
+ annotations = build_atom_annotations(
+ {owner_id},
+ {owner_id: owner_id},
+ {
+ owner_id: [
+ item.record["kind"]
+ for item in self.runtime_state.mark_registry.get_for_atom(
+ owner_id
+ )
+ ]
+ },
+ )
+ self.model.set_atom_annotation(owner_id, annotations.get(owner_id))
+ self.publish_model()
+ return item
+
+ def change_charge_for_atom(
+ self, atom_id: int, delta: int, font: BrowserFontMeasurements | None
+ ) -> None:
+ if font is None:
+ raise ValueError("Charge placement needs completed font measurements.")
+ marks = self.runtime_state.mark_registry.get_for_atom(atom_id) or []
+ cancel = opposite_charge_mark(marks, delta)
+ if cancel is not None:
+ self.delete_selection(
+ [{"target": "mark", "id": self.mark_items.index(cancel)}]
+ )
+ return
+ atom = self.model.atoms[atom_id]
+ kind = "plus" if delta > 0 else "minus"
+ drawing = font.drawing(self.document_state)
+ dx, dy = self.mark_offset(atom_id, atom.x, atom.y, kind, drawing, font)
+ self.add_mark_record(kind, atom_id, atom.x + dx, atom.y + dy, dx, dy)
+ item = self.mark_items[-1]
+ drawing = font.drawing(self.document_state)
+
+ def ink_rect(index: int) -> tuple[float, float, float, float]:
+ mark = drawing["marks"][index]
+ x, y, width, height = (
+ mark.get("hit_rect", (0.0, 0.0, 0.0, 0.0))
+ if self.mark_items[index].record["kind"] in {"plus", "minus"}
+ else mark["bounds"]
+ )
+ return x, y, x + width, y + height
+
+ left, top, right, bottom = ink_rect(len(self.mark_items) - 1)
+ center = item.center
+ dx, dy = shortcut_mark_offset(
+ (atom.x, atom.y),
+ (
+ left - center.x(),
+ top - center.y(),
+ right - center.x(),
+ bottom - center.y(),
+ ),
+ [
+ ink_rect(index)
+ for index, other in enumerate(self.mark_items)
+ if other is not item and other.record["atom_id"] == atom_id
+ ],
+ bond_length=self.renderer.style.bond_length_px,
+ click_offset=lambda x, y: self.mark_offset(
+ atom_id, x, y, kind, drawing, font
+ ),
+ )
+ item.set_center(BrowserPoint(atom.x + dx, atom.y + dy))
+ item.record.update(dx=dx, dy=dy)
+
+ def move_selection(
+ self, items: object, dx: float, dy: float, *, reach: float | None = None
+ ) -> None:
+ """Move the selection; with ``reach``, join an arrow end as the desktop does.
+
+ Like the desktop's selection drag, the moved arrows' ends are then
+ offered to the other arrows' ends, and the smallest shift within
+ ``reach`` moves the whole selection once more.
+ """
+ buckets = self.selection_buckets(items)
+ if dx == 0 and dy == 0:
+ return
+ self.move_buckets(buckets, dx, dy)
+ if reach is not None and buckets.arrow_items:
+ moved = [
+ cast("BrowserSceneItem", item).record for item in buckets.arrow_items
+ ]
+ moved_ids = {id(record) for record in moved}
+ connection = endpoint_connection(
+ [
+ (float(x), float(y))
+ for record in moved
+ for x, y in (record["start"], record["end"])
+ ],
+ [
+ (float(x), float(y))
+ for record in self.document_state["arrows"]
+ if id(record) not in moved_ids
+ for x, y in (record["start"], record["end"])
+ ],
+ radius=reach,
+ )
+ if connection is not None and connection[0] != (0.0, 0.0):
+ self.move_buckets(buckets, *connection[0])
+ self.publish_model()
+
+ def move_buckets(
+ self, buckets: DeleteSelectionBuckets, dx: float, dy: float
+ ) -> None:
+ atoms = self.selected_atom_ids(buckets)
+ controller = CanvasMoveController(
+ cast("Any", self),
+ point_factory=BrowserPoint,
+ hit_testing_service=cast(
+ "Any", SimpleNamespace(mark_spatial_index_dirty=lambda: None)
+ ),
+ ring_polygon_rebuilder=partial(
+ rebuild_ring_fill_polygons,
+ point_factory=BrowserPoint,
+ polygon_factory=tuple,
+ ),
+ )
+ controller.move_atoms(
+ atoms,
+ dx,
+ dy,
+ bond_ids=set(),
+ redraw_bond_ids=set(),
+ update_selection=False,
+ affected_ring_items=tuple(
+ BrowserRingItem(ring)
+ for ring in self.document_state.get("ring_fills", [])
+ ),
+ )
+ for item in [*buckets.arrow_items, *buckets.note_items]:
+ controller.move_item(item, dx, dy, update_selection=False)
+ for item in independent_selection_items(buckets.mark_items, atoms):
+ controller.move_item(item, dx, dy, update_selection=False)
+ for item in buckets.other_items:
+ if item.data(0) == "orbital":
+ controller.move_item(item, dx, dy, update_selection=False)
+ continue
+ source = cast("BrowserSceneItem", item).record
+ if item.data(0) == "image":
+ source.update(x=source["x"] + dx, y=source["y"] + dy)
+ continue
+ source.update(
+ shape_to_state(
+ moved_shape(normalized_shape(shape_from_state(source)), dx, dy)
+ )
+ )
+ for item in buckets.ts_bracket_items:
+ source = cast("BrowserSceneItem", item).record
+ source.update(
+ ts_bracket_to_state(
+ moved_ts_bracket(ts_bracket_from_state(source), dx, dy)
+ )
+ )
+
+ def delete_hover(
+ self,
+ x: float,
+ y: float,
+ direct_atom_id: int | None = None,
+ hits: object = None,
+ scale: object = None,
+ ) -> None:
+ target = hover_delete_target(
+ *self.hover_structure(x, y, direct_atom_id, hits, scale),
+ bonds=self.model.bonds,
+ atom_has_visible_label=lambda atom_id: atom_shows_itself(
+ self.model.atoms[atom_id]
+ ),
+ )
+ if target is None:
+ return
+ self.require_sheet_position(x, y)
+ kind, item_id = target
+ if kind == "label":
+ self.labels.add_or_update_atom_label(
+ item_id, "C", record=False, show_carbon=False
+ )
+ self.publish_model()
+ else:
+ self.delete_selection([{"target": kind, "id": item_id}])
+
+ def delete_selection(self, items: object) -> None:
+ buckets = self.selection_buckets(items)
+ plan = build_delete_selection_plan(
+ buckets,
+ bonds=self.model.bonds,
+ marks_by_atom=self.runtime_state.mark_registry.by_atom,
+ atom_has_visible_label=lambda atom_id: atom_shows_itself(
+ self.model.atoms[atom_id]
+ ),
+ )
+ for bond_id in plan.bond_ids_to_remove:
+ self.model.clear_bond(bond_id)
+ for atom_id in plan.atom_ids:
+ self.model.pop_atom(atom_id)
+ removed_records = {
+ id(cast("BrowserSceneItem", item).record) for item in plan.scene_items
+ }
+ self.document_state["arrows"] = [
+ arrow
+ for arrow in self.document_state["arrows"]
+ if id(arrow) not in removed_records
+ ]
+ self.document_state["shapes"] = [
+ shape
+ for shape in self.document_state["shapes"]
+ if id(shape) not in removed_records
+ ]
+ self.document_state["ts_brackets"] = [
+ bracket
+ for bracket in self.document_state["ts_brackets"]
+ if id(bracket) not in removed_records
+ ]
+ self.document_state["notes"] = [
+ note
+ for note in self.document_state["notes"]
+ if id(note) not in removed_records
+ ]
+ self.document_state["orbitals"] = [
+ orbital
+ for orbital in self.document_state["orbitals"]
+ if id(orbital) not in removed_records
+ ]
+ self.document_state["marks"] = [
+ record
+ for record in self.document_state["marks"]
+ if id(record) not in removed_records
+ ]
+ if "images" in self.document_state:
+ images = [
+ image
+ for image in self.document_state["images"]
+ if id(image) not in removed_records
+ ]
+ # The desktop omits an emptied image collection when it saves.
+ if images:
+ self.document_state["images"] = images
+ else:
+ del self.document_state["images"]
+ self.mark_items[:] = [
+ item for item in self.mark_items if id(item.record) not in removed_records
+ ]
+ for _atom_id, marks in self.runtime_state.mark_registry.items():
+ marks[:] = [
+ item for item in marks if id(item.record) not in removed_records
+ ]
+ annotations = build_atom_annotations(
+ plan.mark_owner_ids,
+ {atom_id: atom_id for atom_id in plan.mark_owner_ids},
+ {
+ atom_id: [item.record["kind"] for item in marks]
+ for atom_id, marks in self.runtime_state.mark_registry.items()
+ },
+ )
+ for atom_id in plan.mark_owner_ids:
+ self.model.set_atom_annotation(atom_id, annotations.get(atom_id))
+ for atom_id in unmarked_isolated_carbon_ids(
+ plan.mark_owner_ids,
+ atoms=self.model.atoms,
+ bonds=self.model.bonds,
+ has_visible_label=lambda atom_id: atom_shows_itself(
+ self.model.atoms[atom_id]
+ ),
+ has_marks=lambda atom_id: bool(
+ self.runtime_state.mark_registry.get_for_atom(atom_id)
+ ),
+ ):
+ self.model.atoms[atom_id].explicit_label = True
+ rings = self.document_state.get("ring_fills", [])
+ broken = broken_ring_fill_indices(
+ [ring["atom_ids"] for ring in rings],
+ atom_ids=set(self.model.atoms),
+ bond_pairs=model_bond_pairs(self.model),
+ )
+ self.document_state["ring_fills"] = [
+ ring
+ for index, ring in enumerate(rings)
+ if index not in broken and id(ring) not in removed_records
+ ]
+ self.publish_model()
+
+ def record_collections(self) -> dict[int, str]:
+ """The document collection of each annotation record, by record id."""
+ return {
+ id(record): key
+ for key in ("ring_fills", *GROUP_ITEM_COLLECTIONS)
+ for record in self.document_state.get(key, [])
+ }
+
+ def clipboard_payload(self, items: object) -> str:
+ """Edit > Copy: the desktop's selection payload of a browser selection.
+
+ The desktop's builder chooses the atoms, bonds, complete rings, attached
+ and selected marks, objects, groups and perspective points, and the
+ text is the one the desktop puts on its clipboard.
+ """
+ buckets = self.selection_buckets(items)
+ explicit_atom_ids = set(buckets.atom_ids)
+ for ring in buckets.ring_items:
+ explicit_atom_ids.update(
+ atom_id for atom_id in ring.data(2) if atom_id in self.model.atoms
+ )
+ collections = self.record_collections()
+ order = {
+ id(record): (key, index)
+ for key in GROUP_ITEM_COLLECTIONS
+ for index, record in enumerate(self.document_state.get(key, []))
+ }
+ # Collection order keeps each pasted collection in its drawing order.
+ selected: list[Any] = [
+ *buckets.mark_items,
+ *buckets.note_items,
+ *buckets.arrow_items,
+ *buckets.ts_bracket_items,
+ *buckets.other_items,
+ ]
+ selected.sort(key=lambda item: order[id(item.record)])
+ coords = self.runtime_state.atom_coords_3d_state.atom_coords_3d
+ rotation = self.runtime_state.rotation_state
+
+ def perspective(atom_ids: set[int]) -> dict[str, Any] | None:
+ # Like the desktop, only depth points that still project onto
+ # their atoms travel with the copy.
+ points: list[dict[str, Any]] = []
+ for atom_id in sorted(atom_ids & set(coords)):
+ atom = self.model.atom_for_id(atom_id)
+ if atom is not None and stored_coords_match_projection(
+ coords[atom_id],
+ (atom.x, atom.y),
+ bond_length_px=self.renderer.style.bond_length_px,
+ center_3d=rotation.projection_center_3d,
+ anchor_2d=rotation.projection_anchor_2d,
+ ):
+ points.append({"atom_id": atom_id, "coords": list(coords[atom_id])})
+ if not points:
+ return None
+ return {
+ "atom_coords_3d": points,
+ "projection_center_3d": rotation.projection_center_3d,
+ "projection_anchor_2d": rotation.projection_anchor_2d,
+ }
+
+ def atom_state(atom_id: int) -> dict[str, Any]:
+ atom = self.model.atom_for_id(atom_id)
+ if atom is None:
+ return {}
+ state = atom_to_state(atom, False)
+ annotation = self.model.atom_annotation_for(atom_id)
+ if annotation:
+ state["annotation"] = annotation
+ return state
+
+ def bond_state(bond: Any) -> dict[str, Any]:
+ return cast("dict[str, Any]", bond_to_state(bond))
+
+ def item_state(item: Any) -> dict[str, Any]:
+ record = item.record
+ return document_record_clipboard_state(collections[id(record)], record)
+
+ payload = build_selection_clipboard_payload(
+ selected_items=selected,
+ explicit_atom_ids=explicit_atom_ids,
+ selected_bond_ids=set(buckets.bond_ids),
+ bonds=self.model.bonds,
+ ring_states=[
+ document_record_clipboard_state("ring_fills", record)
+ for record in self.document_state.get("ring_fills", [])
+ ],
+ mark_states=[
+ (id(record), document_record_clipboard_state("marks", record))
+ for record in self.document_state["marks"]
+ ],
+ atom_state_getter=atom_state,
+ bond_state_getter=bond_state,
+ scene_item_state_getter=item_state,
+ perspective_state_getter=perspective,
+ version=CLIPBOARD_SELECTION_VERSION,
+ groups=[
+ (group.atom_ids, group.item_ids)
+ for group in self.runtime_state.group_state.groups.values()
+ ],
+ )
+ if payload is None:
+ raise ValueError("Select a structure or object on the canvas to copy.")
+ text = selection_payload_json(payload)
+ if len(text.encode()) > MAX_CLIPBOARD_SELECTION_PAYLOAD_BYTES:
+ raise ValueError("The selection is too large to copy.")
+ # A copy is only usable if the paste validation accepts it.
+ try:
+ decode_clipboard_selection_payload(
+ [text], version=CLIPBOARD_SELECTION_VERSION
+ )
+ except ValueError:
+ raise ValueError(
+ "This selection cannot be copied as a Chemvas selection."
+ ) from None
+ return text
+
+ def paste_clipboard(
+ self, edit: dict[str, Any], *, preview: bool, source: str | None, count: int
+ ) -> None:
+ """Edit > Paste: the desktop's validated payload, offset and id remapping.
+
+ ``pasted`` receives the pasted items as a browser selection, and the
+ paste source and count the next paste offsets from.
+ """
+ text = edit.get("payload")
+ if preview:
+ raise ValueError("Paste has no preview.")
+ if set(edit) != {"kind", "payload"} or not isinstance(text, str):
+ raise ValueError("Expected the clipboard selection text.")
+ if len(text.encode()) > MAX_CLIPBOARD_SELECTION_PAYLOAD_BYTES:
+ raise ValueError("The Chemvas clipboard selection is too large to paste.")
+ payload, payload_json = decode_clipboard_selection_payload(
+ [text], version=CLIPBOARD_SELECTION_VERSION
+ )
+ plan = build_clipboard_paste_plan(
+ payload=payload,
+ payload_json=payload_json,
+ previous_source_json=source,
+ previous_paste_count=count,
+ bond_length_px=self.renderer.style.bond_length_px,
+ clipboard_paste_offset=clipboard_paste_offset,
+ before_next_atom_id=int(self.model.next_atom_id),
+ before_bond_count=len(self.model.bonds),
+ )
+ if plan is None or not plan.has_payload_content():
+ raise ValueError("The Chemvas clipboard selection is empty.")
+ incoming_images = [
+ state
+ for state in plan.scene_items
+ if isinstance(state, dict) and state.get("kind") == "image"
+ ]
+ if incoming_images:
+ validate_image_collection_budget(
+ [*self.document_state.get("images", []), *incoming_images]
+ )
+ validate_image_states(incoming_images)
+ added: set[int] = set()
+
+ def add_record(state: dict[str, Any]) -> BrowserSceneItem | BrowserMarkItem:
+ collection, record = clipboard_state_document_record(state)
+ if collection == "arrows":
+ # The canonical record every browser arrow edit stores.
+ record = arrow_to_state(arrow_from_state(record))
+ added.add(id(record))
+ if collection != "marks":
+ self.document_state.setdefault(collection, []).append(record)
+ return BrowserSceneItem(record)
+ center = mark_center_coordinates(record, self.model.atoms)
+ if center is None:
+ raise ValueError("A mark requires a valid center.")
+ self.document_state["marks"].append(record)
+ item = BrowserMarkItem(record, BrowserPoint(*center))
+ self.mark_items.append(item)
+ if record["atom_id"] is not None:
+ self.runtime_state.mark_registry.add_for_atom(record["atom_id"], item)
+ return item
+
+ def restore_bond(bond_id: int, state: dict[str, Any]) -> None:
+ bond = self.model.bonds[bond_id]
+ if bond is not None:
+ bond.style, bond.color = state["style"], state["color"]
+
+ def apply_perspective(
+ coords_3d: dict[int, tuple[float, float, float]],
+ center_3d: tuple[float, float, float] | None,
+ _anchor_2d: tuple[float, float] | None,
+ ) -> None:
+ rotation = self.runtime_state.rotation_state
+ atoms = self.model.atoms
+ self.runtime_state.atom_coords_3d_state.atom_coords_3d.update(
+ reprojected_pasted_coords_3d(
+ coords_3d,
+ center_3d,
+ {
+ atom_id: (atoms[atom_id].x, atoms[atom_id].y)
+ for atom_id in coords_3d
+ if atom_id in atoms
+ },
+ bond_length_px=self.renderer.style.bond_length_px,
+ target_center_3d=rotation.projection_center_3d,
+ target_anchor_2d=rotation.projection_anchor_2d,
+ )
+ )
+
+ result = apply_paste_payload(
+ atoms=plan.atoms,
+ bonds=plan.bonds,
+ rings=plan.rings,
+ marks=plan.marks,
+ scene_items=plan.scene_items,
+ perspective=plan.perspective,
+ dx=plan.dx,
+ dy=plan.dy,
+ add_atom=self.model.add_atom,
+ apply_atom_color=lambda atom_id, color: setattr(
+ self.model.atoms[atom_id], "color", color
+ ),
+ set_atom_annotation=self.model.set_atom_annotation,
+ # The document stores a shown label as the atom's explicit flag.
+ add_or_update_atom_label=lambda atom_id, *_args, **_kwargs: setattr(
+ self.model.atoms[atom_id], "explicit_label", True
+ ),
+ add_bond=self.model.add_bond,
+ restore_bond_from_state=restore_bond,
+ translated_scene_item_state=translated_scene_item_state,
+ create_scene_item_from_state=add_record,
+ apply_perspective=apply_perspective,
+ )
+ if not result.has_changes():
+ raise ValueError("The Chemvas clipboard selection is empty.")
+ # A note the browser cannot render would leave the drawing read-only.
+ font_size = self.document_state["settings"]["text_font_size"]
+ for note in self.document_state["notes"]:
+ if id(note) not in added:
+ continue
+ try:
+ browser_note_html(note, font_size)
+ except ValueError:
+ raise ValueError(
+ "The clipboard selection contains note lists or formatting "
+ "without a browser renderer. Paste it in the desktop app."
+ ) from None
+ for group in plan.groups:
+ register_group_for(
+ self,
+ {result.atom_id_map[atom_id] for atom_id in group["atoms"]},
+ [
+ cast("Any", result.scene_item_map[ref[0], ref[1]]).data(3)
+ for ref in group["items"]
+ ],
+ )
+ self.publish_model()
+ targets = {
+ "ring_fills": "ring",
+ "marks": "mark",
+ "arrows": "arrow",
+ "shapes": "shape",
+ "orbitals": "orbital",
+ "ts_brackets": "ts_bracket",
+ "notes": "note",
+ "images": "image",
+ }
+ selection = [
+ *(
+ {"target": "atom", "id": atom_id}
+ for atom_id in sorted(result.new_atom_ids)
+ ),
+ *(
+ {"target": "bond", "id": bond_id}
+ for bond_id in range(plan.before_bond_count, len(self.model.bonds))
+ if self.model.bonds[bond_id] is not None
+ ),
+ *(
+ {"target": target, "id": index}
+ for key, target in targets.items()
+ for index, record in enumerate(self.document_state.get(key, []))
+ if id(record) in added
+ ),
+ ]
+ self.pasted = {
+ "selection": selection,
+ "source": plan.paste_source_json,
+ "count": plan.paste_count,
+ }
+
+
+def validated_edit_fields(edit: object) -> tuple[dict[str, Any], str]:
+ """Validate common wire fields before adapting an individual edit."""
+ if not isinstance(edit, dict):
+ raise ValueError("edit must be an object.")
+ kind = edit.get("kind")
+ for key in ("x", "y", "dx", "dy", "value"):
+ if key in edit and (
+ type(edit[key]) not in (int, float, Decimal) or not math.isfinite(edit[key])
+ ):
+ raise ValueError("Edit coordinates and lengths must be finite numbers.")
+ if "grid" in edit and kind not in {"arrow", "line", "arrow_handle"}:
+ raise ValueError("Grid snapping only applies to arrow and line gestures.")
+ if kind in {"bond", "bond_style"} and edit.get("style") not in BOND_ORDERS:
+ raise ValueError("Unsupported bond style.")
+ fields = dict(edit)
+ grid = fields.pop("grid", "none")
+ return fields, grid
+
+
+def move_endpoint_reach(edit: dict[str, Any]) -> float | None:
+ """A move's endpoint reach in scene units, from the view scale it carries.
+
+ With the scale, a moved arrow end joins another within the desktop's
+ on-screen reach; a move without it only translates.
+ """
+ if "scale" not in edit:
+ return None
+ return ENDPOINT_SNAP_SCREEN_PX / validated_drawing_scale(edit["scale"])
+
+
+def edit_document(
+ request: object,
+ *,
+ font: BrowserFontMeasurements | None = None,
+ preview: bool = False,
+ mark_order: dict[int, list[int]] | None = None,
+ smiles_model: MoleculeModel | None = None,
+ paste_source: tuple[str | None, int] = (None, 0),
+) -> dict[str, Any]:
+ """Only connected Chemvas operations can publish a validated candidate.
+
+ ``paste_source`` is the payload and count the previous paste offset from;
+ a paste result carries its own under ``paste``.
+ """
+ if not isinstance(request, dict) or set(request) != {"document", "edit"}:
+ raise ValueError("Expected document and edit.")
+ info = document_info(request["document"], render=False)
+ if info["unsupported"]:
+ raise ValueError(
+ "This document is read-only in the browser. Use Qt to edit it."
+ )
+ payload = info["document"]
+ edit, grid = validated_edit_fields(request["edit"])
+ kind = edit.get("kind")
+ candidate = deepcopy(extract_document_state(payload))
+ daggers = any(
+ bracket["bracket_kind"] in BRACKET_SYMBOLS
+ for bracket in candidate["ts_brackets"]
+ )
+ adapter = BrowserStructureAdapter(
+ candidate,
+ grid=grid,
+ mark_order=mark_order,
+ measured_drawing=document_info(payload, font=font)["drawing"]
+ if daggers and font is not None
+ else None,
+ )
+ shortcut_tool = None
+ edit_notice = None
+ snap_marks = None
+ if kind == "bond" and set(edit) == {"kind", "start", "end", "style"}:
+ adapter.insert_bond(edit["start"], edit["end"], edit["style"])
+ elif kind in {"arrow", "line"} and set(edit) == {
+ "kind",
+ "start",
+ "end",
+ "style",
+ "dragged",
+ "shift",
+ "scale",
+ } | ({"hits"} if kind == "line" else set()):
+ snap_marks = adapter.insert_arrow(edit, preview=preview)
+ elif kind == "bond_style" and set(edit) == {"kind", "id", "style"}:
+ adapter.apply_bond_style(edit["id"], edit["style"])
+ elif kind == "double_position" and set(edit) == {"kind", "id", "position"}:
+ adapter.apply_double_position(edit["id"], edit["position"])
+ elif kind == "hover_shortcut" and {"kind", "x", "y", "key"} <= set(edit) <= {
+ "kind",
+ "x",
+ "y",
+ "key",
+ "atom_id",
+ "hits",
+ "scale",
+ }:
+ shortcut_tool = adapter.apply_hover_shortcut(
+ float(edit["x"]),
+ float(edit["y"]),
+ edit["key"],
+ edit.get("atom_id"),
+ font=font,
+ hits=edit.get("hits"),
+ scale=edit.get("scale"),
+ )
+ elif kind == "ring" and {"kind", "x", "y"} <= set(edit) <= {
+ "kind",
+ "x",
+ "y",
+ "atom_id",
+ "size",
+ "style",
+ }:
+ x, y = float(edit["x"]), float(edit["y"])
+ adapter.require_sheet_position(x, y)
+ # The Ring tool starts on benzene, like the desktop's template session.
+ adapter.insert_template(
+ x,
+ y,
+ edit.get("size", 6),
+ edit.get("style", "benzene"),
+ *adapter.structure_target(
+ x,
+ y,
+ bond_gate_ratio=TEMPLATE_BOND_GATE_RATIO,
+ direct_atom_id=edit.get("atom_id"),
+ ),
+ )
+ elif kind in {"rotate", "flip", "align", "distribute"}:
+ adapter.transform_selection(
+ edit,
+ document_info(payload, font=font)["drawing"]
+ if kind in {"align", "distribute"}
+ or adapter.mark_items
+ or daggers
+ or candidate["notes"]
+ else None,
+ )
+ elif kind == "move" and set(edit) - {"scale"} == {"kind", "selection", "dx", "dy"}:
+ adapter.move_selection(
+ edit["selection"],
+ float(edit["dx"]),
+ float(edit["dy"]),
+ reach=move_endpoint_reach(edit),
+ )
+ elif kind == "erase" and set(edit) == {"kind", "x", "y", "hits", "scale"}:
+ target = adapter.pick_target(
+ edit["x"], edit["y"], edit["hits"], preferred=False, scale=edit["scale"]
+ )
+ if target is not None:
+ adapter.delete_selection([target])
+ elif kind == "delete_selection" and set(edit) == {"kind", "selection"}:
+ adapter.delete_selection(edit["selection"])
+ elif kind in {"group", "ungroup"} and set(edit) == {"kind", "selection"}:
+ adapter.group_selection(edit["selection"], ungroup=kind == "ungroup")
+ elif kind == "delete_hover" and {"kind", "x", "y"} <= set(edit) <= {
+ "kind",
+ "x",
+ "y",
+ "atom_id",
+ "hits",
+ "scale",
+ }:
+ adapter.delete_hover(
+ float(edit["x"]),
+ float(edit["y"]),
+ edit.get("atom_id"),
+ edit.get("hits"),
+ edit.get("scale"),
+ )
+ elif (
+ kind == "atom_prompt" and set(edit) == {"kind", "atom_id", "text", "x", "y"}
+ ) or (
+ kind == "atom"
+ and {"kind", "x", "y", "text"}
+ <= set(edit)
+ <= {
+ "kind",
+ "x",
+ "y",
+ "text",
+ "atom_id",
+ }
+ ):
+ adapter.apply_atom_input(edit)
+ elif kind == "mark" and set(edit) == {
+ "kind",
+ "x",
+ "y",
+ "mark_kind",
+ "scale",
+ "hits",
+ }:
+ if font is None:
+ raise ValueError("Mark placement needs completed font measurements.")
+ adapter.insert_mark(
+ edit["x"],
+ edit["y"],
+ edit["mark_kind"],
+ scale=edit["scale"],
+ hits=edit["hits"],
+ drawing=font.drawing(candidate),
+ font=font,
+ )
+ elif kind == "mark_owner" and set(edit) == {"kind", "id", "atom_id"}:
+ adapter.rebind_mark(edit["id"], edit["atom_id"])
+ elif kind in (
+ record_edits := dict[str, "Callable[[dict[str, Any]], object]"](
+ {
+ "smiles": lambda value: adapter.insert_smiles(
+ value, smiles_model, font=font, preview=preview
+ ),
+ "orbital": adapter.insert_orbital,
+ "ts_bracket": adapter.insert_bracket,
+ "orbital_handle": adapter.move_orbital_handle,
+ "arrow_handle": adapter.move_arrow_handle,
+ "shape_handle": adapter.move_shape_handle,
+ "stack": adapter.stack_selection,
+ "note_text": adapter.edit_note_text,
+ "note_format": adapter.format_notes,
+ "insert_image": adapter.insert_image,
+ "image_properties": adapter.set_image_properties,
+ "paste": lambda value: adapter.paste_clipboard(
+ value,
+ preview=preview,
+ source=paste_source[0],
+ count=paste_source[1],
+ ),
+ }
+ )
+ ):
+ # These check their own fields.
+ record_edits[kind](edit)
+ elif kind == "arrow_labels" and set(edit) == {"kind", "id", "labels"}:
+ adapter.set_arrow_labels(edit)
+ elif kind in {"color", "ring_fill"}:
+ edit_notice = (
+ adapter.apply_color if kind == "color" else adapter.apply_ring_fill
+ )(edit)
+ elif kind == "shape" and set(edit) == {"kind", "start", "end", "style", "stroke"}:
+ adapter.insert_shape(edit)
+ elif kind in {"arrow_style", "bond_length", "sheet_setup", "orbital_phase"}:
+ adapter.set_drawing_settings(edit)
+ else:
+ raise ValueError("Unsupported edit or unexpected fields.")
+ if adapter.candidate_accepted:
+ adapter.publish_perspective()
+ adapter.publish_groups()
+ # Like a desktop snapshot, keep the plan only while it matches the graph;
+ # Undo returns the document that still carries it.
+ plan_warning = calculation_plan_save_warning(
+ adapter.model, candidate.get("calculation_plan")
+ )
+ if plan_warning is not None:
+ del candidate["calculation_plan"]
+ edit_notice = plan_warning
+ mark_indices = {id(item): index for index, item in enumerate(adapter.mark_items)}
+ next_mark_order = {
+ atom_id: [mark_indices[id(item)] for item in items]
+ for atom_id, items in adapter.runtime_state.mark_registry.items()
+ if items
+ }
+ result = document_info(
+ build_normalized_document_payload(candidate, payload["version"])
+ if adapter.candidate_accepted
+ else payload,
+ font=font,
+ mark_order=next_mark_order if adapter.candidate_accepted else mark_order,
+ )
+ if kind == "hover_shortcut":
+ result["shortcut_tool"] = shortcut_tool
+ result["paste"] = adapter.pasted
+ result["edit_notice"] = edit_notice
+ # Snap rings are disposable like the preview itself, never part of the document.
+ return (
+ {**result, "snap_marks": snap_marks}
+ if preview and snap_marks is not None
+ else result
+ )
+
+
+def atom_input_plan(request: object) -> dict[str, Any]:
+ if (
+ not isinstance(request, dict)
+ or set(request) != {"document", "edit", "symbol"}
+ or not isinstance(request["edit"], dict)
+ ):
+ raise ValueError("Expected document, atom input target and symbol.")
+ info = document_info(request["document"], render=False)
+ if info["unsupported"]:
+ raise ValueError("This document is read-only in the browser.")
+ symbol = request["symbol"]
+ if not isinstance(symbol, str) or len(symbol) > int(ATOM_INPUT_SPEC["max_length"]):
+ raise ValueError(
+ f"Atom labels must contain at most {ATOM_INPUT_SPEC['max_length']} characters."
+ )
+ adapter = BrowserStructureAdapter(extract_document_state(info["document"]))
+ if request["edit"].get("kind") == "atom_prompt":
+ if (
+ not {"kind", "x", "y"}
+ <= set(request["edit"])
+ <= {"kind", "x", "y", "atom_id"}
+ ):
+ raise ValueError("Expected the atom prompt scene position.")
+ atom_id, _bond_id = adapter.structure_target(
+ request["edit"]["x"],
+ request["edit"]["y"],
+ bond_gate_ratio=STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ direct_atom_id=request["edit"].get("atom_id"),
+ )
+ if atom_id is not None:
+ adapter.require_sheet_position(request["edit"]["x"], request["edit"]["y"])
+ initial = (
+ adapter.labels.atom_label_prompt_initial(atom_id)
+ if atom_id is not None
+ else None
+ )
+ return {
+ "atom_id": atom_id,
+ "needs_prompt": initial is not None,
+ "initial": initial or "",
+ "text": None,
+ }
+ target = adapter.atom_target(request["edit"])
+ atom = adapter.model.atom_for_id(target.atom_id)
+ return asdict(plan_text_input(symbol, atom.element if atom is not None else ""))
+
+
+class StaleRevisionError(ValueError):
+ """The client must read current state before it can edit again."""
+
+
+class DocumentChange(HistoryCommand):
+ """Adapt a validated document value to Chemvas's existing history service."""
+
+ history_transaction_owns_exact_state = True
+ history_transaction_snapshot_covers_state = True
+
+ def __init__(self, before: dict[str, Any], after: dict[str, Any]) -> None:
+ self.before, self.after = before["document"], after["document"]
+ self.before_mark_order, self.after_mark_order = (
+ before["mark_order"],
+ after["mark_order"],
+ )
+
+ @override
+ def undo(self, operations: Any) -> None:
+ operations.info = document_info(
+ self.before, font=operations.font, mark_order=self.before_mark_order
+ )
+
+ @override
+ def redo(self, operations: Any) -> None:
+ operations.info = document_info(
+ self.after, font=operations.font, mark_order=self.after_mark_order
+ )
+
+
+def selection_presentation(
+ info: dict[str, Any], selection: object
+) -> tuple[list[list[dict[str, Any]]], dict[str, Any] | None, list[dict[str, float]]]:
+ adapter = BrowserStructureAdapter(extract_document_state(info["document"]))
+ return (
+ adapter.selection_components(selection, info["drawing"]),
+ adapter.selection_frame(selection, info["drawing"]),
+ adapter.group_boxes(selection, info["drawing"]),
+ )
+
+
+def prepared_smiles_model(text: object, bond_length: float) -> MoleculeModel:
+ """The native optional parser, bounded before conversion and publication."""
+ if not isinstance(text, str):
+ raise ValueError("SMILES must be text.")
+ text = normalized_smiles_input(text)
+ if not text:
+ raise ValueError("Enter a SMILES string to preview.")
+ backend = RDKitAdapter()
+ model = backend.smiles_to_2d(text, scale=bond_length)
+ if model is None:
+ raise ValueError(backend.last_error or SMILES_RENDER_ERROR)
+ if len(model.atoms) > 2000 or len(model.bonds) > 3000:
+ raise ValueError(
+ "The browser adapter supports up to 2,000 atoms and 3,000 bonds."
+ )
+ if smiles_preview_center(model) is None:
+ raise ValueError(SMILES_RENDER_ERROR)
+ return model
+
+
+def selected_molfile(document: dict[str, Any], selection: object) -> str:
+ """File > Export MOL: the desktop's selected-only export, as MDL text.
+
+ Selected atoms, bonds and the atoms of selected ring fills choose the
+ structure; with none of those, the owners of selected charges and radicals
+ do. Selected bonds export only themselves, attached marks give charges and
+ radicals, and the desktop's MOL policy writes the result.
+ """
+ adapter = BrowserStructureAdapter(deepcopy(extract_document_state(document)))
+ buckets = adapter.selection_buckets(selection)
+ atom_ids, bond_ids = set(buckets.atom_ids), set(buckets.bond_ids)
+ for ring in buckets.ring_items:
+ atom_ids.update(
+ atom_id
+ for atom_id in ring.data(2)
+ if adapter.model.atom_for_id(atom_id) is not None
+ )
+ if not atom_ids and not bond_ids:
+ for mark in buckets.mark_items:
+ owner = cast("BrowserMarkItem", mark).record["atom_id"]
+ if owner is not None and adapter.model.atom_for_id(owner) is not None:
+ atom_ids.add(owner)
+ if not atom_ids and not bond_ids:
+ raise ValueError("Select a molecular structure on the canvas first.")
+ records = adapter.document_state["marks"]
+ marks = AnnotationCollection(
+ records={
+ index: Mark(kind=record["kind"], atom_id=record["atom_id"])
+ for index, record in enumerate(records)
+ },
+ order=list(range(len(records))),
+ )
+ model, atom_annotations = build_3d_conversion_payload(
+ adapter.model,
+ atom_ids,
+ bond_ids,
+ mark_kinds_by_atom(marks),
+ # The MOL payload keeps no structure bounds.
+ bounds_getter=lambda *_args, **_kwargs: (0.0, 0.0, 0.0, 0.0),
+ )
+ return export_molfile_block(
+ model, atom_annotations=atom_annotations, rdkit=RDKitAdapter()
+ )
+
+
+FIGURE_EXPORT_TIMEOUT_SECONDS = 120
+# The desktop's offscreen document export, run once per request. It reads the
+# private document copy from stdin and writes only inside the private temporary
+# folder figure_svg creates and removes; it answers with the SVG text or the
+# native refusal message.
+_FIGURE_EXPORT_CHILD = """
+import json, os, sys
+from pathlib import Path
+sys.path.insert(0, sys.argv[1])
+from chemvas.bootstrap.document_cli_shared import offscreen_canvas, qt_platform
+from chemvas.bootstrap.document_render import MAX_OUTPUT_BYTES
+from chemvas.domain.document import extract_document_state
+# As in a direct native export, the application exists before the offscreen
+# canvas: offscreen_application then keeps the application's own default font,
+# substituting its command-line family only for an application it creates.
+os.environ["QT_QPA_PLATFORM"] = qt_platform()
+if sys.platform == "win32":
+ os.environ["QT_FONT_DPI"] = "96"
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi)
+application = QApplication(["chemvas-web-export-figure"])
+request = json.loads(sys.stdin.buffer.read())
+try:
+ with offscreen_canvas(extract_document_state(request["document"]), command="web-export-figure") as (_canvas, service):
+ # The parent owns this folder and removes it, even if this process is killed.
+ path = Path(sys.argv[2]) / "figure.svg"
+ service.export_figure(str(path), fmt="svg", scope=request["scope"])
+ if path.stat().st_size > MAX_OUTPUT_BYTES:
+ raise ValueError(f"rendered output exceeds the {MAX_OUTPUT_BYTES}-byte limit")
+ reply = {"svg": path.read_text(encoding="utf-8")}
+except ValueError as error:
+ reply = {"error": str(error)}
+sys.stdout.write(json.dumps(reply))
+"""
+
+
+def figure_svg(document: dict[str, Any], scope: str) -> str:
+ """File > Export Figure as plain SVG, from the desktop's own offscreen export.
+
+ That export paints a Qt scene, so it runs in a short-lived child process of
+ this interpreter over this checkout's ``app``; the threaded server stays
+ Qt-free. The child receives a private copy of the accepted document, and the
+ native owner's refusals come back as its own messages.
+ """
+ request = json.dumps(
+ {"document": document, "scope": scope}, ensure_ascii=False, allow_nan=False
+ ).encode()
+ # This process owns the export folder, so it is removed after a timeout too:
+ # subprocess.run has killed and waited for the child before the folder exits.
+ with tempfile.TemporaryDirectory(prefix="chemvas-web-figure-") as folder:
+ try:
+ child = subprocess.run(
+ [
+ sys.executable,
+ "-I",
+ "-c",
+ _FIGURE_EXPORT_CHILD,
+ str(Path(__file__).resolve().parents[2]),
+ folder,
+ ],
+ input=request,
+ capture_output=True,
+ timeout=FIGURE_EXPORT_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ raise ValueError(
+ "The figure export took too long. No file was written."
+ ) from None
+ if child.returncode != 0:
+ raise ValueError("The figure could not be exported. No file was written.")
+ reply = json.loads(child.stdout)
+ if "error" in reply:
+ raise ValueError(reply["error"])
+ svg = reply["svg"]
+ if isinstance(svg, str):
+ return svg
+ raise ValueError("The figure could not be exported. No file was written.")
+
+
+class BrowserSession:
+ """One document owner; the browser only mirrors accepted state.
+
+ With a draft store, the session also keeps one durable draft of its
+ unsaved document, written after each accepted change and before the reply.
+ """
+
+ def __init__(self, drafts: BrowserDraftStore | None = None) -> None:
+ self.lock = RLock()
+ self.closed = False
+ self.last_used = time.monotonic()
+ self.font = BrowserFontMeasurements(
+ {"family": ACS1996Style().font_family, "metrics": {}, "ink": {}}
+ )
+ self.info = document_info(new_document(), font=self.font)
+ # The document as opened; a recovered draft has none and stays unsaved.
+ self.saved: str | None = json.dumps(self.info["document"], sort_keys=True)
+ self.name = "Canvas 1.chemvas"
+ # One parsed insertion, independent of document/history. Pointer previews
+ # and measurement retries reuse it instead of rerunning RDKit.
+ self.smiles_cache: tuple[str, float, MoleculeModel] | None = None
+ # The desktop canvas's paste cascade: the payload the next paste
+ # offsets from and how many pastes it has seen. Not document data.
+ self.paste_source: str | None = None
+ self.paste_count = 0
+ self.drafts = drafts
+ self.draft_id = new_draft_id()
+ # The document text the draft file holds, and whether a failed write
+ # left the document ahead of it.
+ self.draft_text: str | None = None
+ self.draft_behind = False
+ self.draft_problem: str | None = None
+ self.revision = 0
+ self.state = CanvasHistoryState()
+ operations: Any = self
+ self.history = CanvasHistoryService(
+ operations, self.state, replay_context=nullcontext
+ )
+
+ def replace_document(
+ self, info: dict[str, Any], name: str, *, saved: bool, draft_id: str
+ ) -> None:
+ """Open another document in this window; the replaced one's draft goes.
+
+ The browser asks before replacing unsaved work, so its draft is the
+ discarded work. A recovered draft becomes this window's draft.
+ """
+ previous = self.draft_id
+ self.history.clear()
+ self.smiles_cache = None
+ self.paste_source, self.paste_count = None, 0
+ self.name = name
+ self.info = info
+ text = json.dumps(info["document"], sort_keys=True)
+ self.saved = text if saved else None
+ self.draft_id = draft_id
+ self.draft_text = None if saved else text
+ if self.draft_behind:
+ # The report was the replaced document's failed write; a failed
+ # removal of an earlier draft is kept.
+ self.draft_problem = None
+ self.draft_behind = False
+ if self.drafts is not None and previous != draft_id:
+ try:
+ self.drafts.discard(previous, self)
+ except DraftError as error:
+ # The file stays as an ordinary draft other windows and this
+ # one list, so it can still be discarded or recovered.
+ self.drafts.release(previous, self)
+ self.draft_problem = (
+ f"{error} It stays in File > Recover Unsaved Work…, where "
+ "it can be discarded."
+ )
+
+ def open_document(self, request: dict[str, Any]) -> None:
+ """Open a chosen file or new canvas, or recover a draft, in this window."""
+ if request.get("action") == "recover_draft":
+ self.recover_draft(request)
+ return
+ name = request.get("name", "Canvas 1.chemvas")
+ if not isinstance(name, str):
+ raise ValueError("The document name must be text.")
+ info = document_info(request["document"], font=self.font)
+ self.replace_document(info, name, saved=True, draft_id=new_draft_id())
+
+ def record_paste(self, paste: dict[str, Any] | None) -> list[dict[str, Any]] | None:
+ """Keep an accepted paste's cascade source; return its selection."""
+ if paste is None:
+ return None
+ self.paste_source, self.paste_count = paste["source"], paste["count"]
+ selection: list[dict[str, Any]] = paste["selection"]
+ return selection
+
+ def keep_draft(self, action: object, text: str, dirty: bool) -> None:
+ """One durable draft while the document is unsaved; none once clean."""
+ if action not in {"load", "recover_draft", "edit", "undo", "redo"}:
+ return
+ if self.drafts is None or not self.drafts.available:
+ return
+ # A reported problem stays until a draft is written or removed again,
+ # or, for a failed write, until the document it left behind is gone.
+ try:
+ if not dirty:
+ if self.drafts.discard(self.draft_id, self) or self.draft_behind:
+ self.draft_problem = None
+ self.draft_text = None
+ self.draft_behind = False
+ elif text != self.draft_text:
+ self.drafts.write(
+ self.draft_id, self, name=self.name, document=self.info["document"]
+ )
+ self.draft_text = text
+ self.draft_behind = False
+ self.draft_problem = None
+ elif self.draft_behind and self.drafts.owner_of(self.draft_id) is self:
+ # Undo returned to the text the draft already holds.
+ self.draft_behind = False
+ self.draft_problem = None
+ except DraftError as error:
+ self.draft_behind = dirty
+ self.draft_problem = str(error)
+
+ def release_draft(self) -> None:
+ """This window closed: its draft stays for explicit recovery."""
+ if self.drafts is not None:
+ self.drafts.release(self.draft_id, self)
+
+ def recovery_status(self, dirty: bool) -> dict[str, Any]:
+ if self.drafts is None or not self.drafts.available:
+ return {
+ "available": False,
+ "state": "off",
+ "message": None if self.drafts is None else self.drafts.problem,
+ }
+ state = "saved" if dirty else "clean"
+ if self.draft_problem is not None:
+ state = "failed"
+ return {"available": True, "state": state, "message": self.draft_problem}
+
+ def draft_query(self, action: str, request: dict[str, Any]) -> dict[str, Any]:
+ """List or discard drafts; neither reads or changes this window's drawing."""
+ if action == "discard_draft":
+ if set(request) - {"session", "revision", "action", "draft"}:
+ raise ValueError("Expected the draft to discard.")
+ if self.drafts is None or not self.drafts.available:
+ raise ValueError("Automatic recovery is not available in this window.")
+ self.drafts.remove(valid_draft_id(request.get("draft")))
+ elif set(request) - {"session", "revision", "action"}:
+ raise ValueError("Unexpected recovery list fields.")
+ return self.draft_listing()
+
+ def draft_listing(self) -> dict[str, Any]:
+ """File > Recover Unsaved Work: the drafts other windows left or hold."""
+ if self.drafts is None or not self.drafts.available:
+ return {
+ "drafts": [],
+ "available": False,
+ "message": None if self.drafts is None else self.drafts.problem,
+ }
+ try:
+ entries = self.drafts.entries()
+ except DraftError as error:
+ # An unreadable folder is not an empty one.
+ return {"drafts": [], "available": False, "message": str(error)}
+ now = time.monotonic()
+ drafts = []
+ for entry in entries:
+ if entry.owner is self:
+ continue
+ last_used = getattr(entry.owner, "last_used", None)
+ idle = now - last_used if isinstance(last_used, float) else None
+ drafts.append(
+ {
+ "id": entry.draft_id,
+ "name": entry.name,
+ "saved_at": entry.saved_at,
+ "problem": entry.problem,
+ "open": entry.owner is not None,
+ "idle_seconds": idle,
+ }
+ )
+ return {"drafts": drafts, "available": True, "message": None}
+
+ def recover_draft(self, request: dict[str, Any]) -> None:
+ """Open a draft here; with ``takeover``, close the window holding it.
+
+ Runs under this session's lock after the revision check. A takeover
+ waits a bounded time for the holder's lock, so two windows taking
+ over each other's drafts refuse instead of deadlocking. The holder
+ stays open and unchanged unless its draft holds its current document
+ and the draft opens here.
+ """
+ if set(request) - {"session", "revision", "action", "draft", "takeover"}:
+ raise ValueError("Expected the draft to recover.")
+ takeover = request.get("takeover", False)
+ if type(takeover) is not bool:
+ raise ValueError("Expected the draft to recover.")
+ drafts = self.drafts
+ if drafts is None or not drafts.available:
+ raise ValueError(
+ getattr(drafts, "problem", None)
+ or "Automatic recovery is not available in this window."
+ )
+ draft_id = valid_draft_id(request.get("draft"))
+ holder = drafts.owner_of(draft_id)
+ if not takeover or holder is None or holder is self:
+ self.open_draft(drafts, draft_id, None)
+ return
+ if not isinstance(holder, BrowserSession):
+ raise DraftError("This drawing is still open in another window.")
+ if not holder.lock.acquire(timeout=DRAFT_TAKEOVER_WAIT_SECONDS):
+ raise DraftError(
+ "The window holding this drawing is busy. Try again in a moment; "
+ "nothing was changed."
+ )
+ try:
+ if holder.closed or holder.draft_id != draft_id:
+ raise DraftError(
+ "This drawing moved to another window meanwhile. Open "
+ "File > Recover Unsaved Work… again."
+ )
+ current = json.dumps(holder.info["document"], sort_keys=True)
+ if holder.draft_behind or current != holder.draft_text:
+ raise DraftError(
+ "The other window has changes its recovery draft could not "
+ "keep, so it stays open. Switch to it and save a copy."
+ )
+ self.open_draft(drafts, draft_id, holder)
+ # Its later requests, including delayed ones, report the end.
+ holder.closed = True
+ finally:
+ holder.lock.release()
+
+ def open_draft(
+ self, drafts: BrowserDraftStore, draft_id: str, holder: BrowserSession | None
+ ) -> None:
+ """Claim the draft from ``holder`` and open it; a refusal changes nothing."""
+ name, document = drafts.take(draft_id, self, holder=holder)
+ try:
+ info = document_info(document, font=self.font)
+ except (ValueError, TypeError, KeyError, OverflowError, RecursionError):
+ drafts.release(draft_id, self, to=holder)
+ raise DraftError(DAMAGED_DRAFT) from None
+ self.replace_document(info, name, saved=False, draft_id=draft_id)
+
+ def prepared_smiles_for_edit(self, edit: object) -> MoleculeModel | None:
+ if not isinstance(edit, dict) or edit.get("kind") != "smiles":
+ return None
+ if set(edit) != {"kind", "smiles", "x", "y"}:
+ raise ValueError("Expected SMILES text and a placement position.")
+ validated_edit_fields(edit)
+ if not isinstance(edit["smiles"], str):
+ raise ValueError("SMILES must be text.")
+ text = normalized_smiles_input(edit["smiles"])
+ length = float(self.info["document"]["state"]["settings"]["bond_length_px"])
+ if self.smiles_cache is not None and self.smiles_cache[:2] == (text, length):
+ return self.smiles_cache[2]
+ model = prepared_smiles_model(text, length)
+ self.smiles_cache = (text, length, model)
+ return model
+
+ def image_source(self, ref: str) -> tuple[bytes, str] | None:
+ with self.lock:
+ for image in extract_document_state(self.info["document"]).get(
+ "images", []
+ ):
+ if image_ref(image["data_base64"]) == ref:
+ return image_bytes_from_state(image), image["mime_type"]
+ return None
+
+ def capture_history_transaction_for_history(self, **kwargs: Any) -> dict[str, Any]:
+ return self.info
+
+ def restore_history_transaction_for_history(
+ self, snapshot: dict[str, Any]
+ ) -> RestoreOutcome:
+ self.info = snapshot
+ return RestoreOutcome(authoritative=True)
+
+ def release_history_transaction_for_history(self, snapshot: dict[str, Any]) -> None:
+ pass
+
+ def structure_query(self, action: str, request: dict[str, Any]) -> dict[str, Any]:
+ """Read-only hit and markup queries against the accepted document."""
+ if action == "atom_input":
+ if set(request) - {"session", "revision", "action", "edit", "symbol"}:
+ raise ValueError("Expected an atom input target and symbol.")
+ return atom_input_plan(
+ {
+ "document": self.info["document"],
+ "edit": request.get("edit"),
+ "symbol": request.get("symbol"),
+ }
+ )
+ if action == "copy":
+ # Edit > Copy reads the accepted document; the next paste offsets
+ # from this payload, as after a desktop copy.
+ if set(request) - {"session", "revision", "action", "selection"}:
+ raise ValueError("Expected the selection to copy.")
+ adapter = BrowserStructureAdapter(
+ deepcopy(extract_document_state(self.info["document"]))
+ )
+ payload = adapter.clipboard_payload(request.get("selection"))
+ self.paste_source, self.paste_count = payload, 0
+ return {"payload": payload, "revision": self.revision}
+ if action == "export":
+ # Save: the one response that carries embedded image sources.
+ if set(request) - {"session", "revision", "action"}:
+ raise ValueError("Unexpected export fields.")
+ return {"document": self.info["document"], "revision": self.revision}
+ if action == "export_figure":
+ # File > Export Figure reads the accepted document and changes nothing.
+ if (
+ set(request)
+ - {"session", "revision", "action", "format", "scope", "selection"}
+ or request.get("format") != "svg"
+ or request.get("scope") not in {"sheet", "selection"}
+ or not isinstance(request.get("selection", []), list)
+ ):
+ raise ValueError("Expected an SVG figure scope.")
+ if request.get("selection"):
+ raise ValueError(
+ "Selection-only figure export is not connected in the browser yet."
+ )
+ return {
+ "svg": figure_svg(self.info["document"], request["scope"]),
+ "revision": self.revision,
+ }
+ if action == "export_mol":
+ # File > Export MOL reads the accepted document and changes nothing.
+ if "selection" not in request or set(request) - {
+ "session",
+ "revision",
+ "action",
+ "selection",
+ }:
+ raise ValueError("Expected the selection to export as MOL.")
+ return {
+ "molfile": selected_molfile(
+ self.info["document"], request["selection"]
+ ),
+ "revision": self.revision,
+ }
+ if action == "template_preview":
+ if set(request) - {
+ "session",
+ "revision",
+ "action",
+ "x",
+ "y",
+ "atom_id",
+ "size",
+ "style",
+ }:
+ raise ValueError("Expected a bounded template preview request.")
+ adapter = BrowserStructureAdapter(
+ extract_document_state(self.info["document"])
+ )
+ return {
+ "preview": adapter.template_preview(
+ float(request["x"]),
+ float(request["y"]),
+ request.get("size"),
+ request.get("style"),
+ request.get("atom_id"),
+ ),
+ "revision": self.revision,
+ }
+ if action == "note_markup":
+ # The editor's formatted text, in the markup notes render with.
+ if set(request) - {"session", "revision", "action", "html"}:
+ raise ValueError("Expected note HTML.")
+ saved, _ = saved_note_text(request.get("html"))
+ settings = extract_document_state(self.info["document"])["settings"]
+ return {
+ "html": browser_note_html({"html": saved}, settings["text_font_size"])
+ }
+ fields = {"session", "revision", "action", "x", "y", "hits", "scale"}
+ if action == "pick":
+ fields.add("preferred")
+ if set(request) - fields or (
+ action == "pick" and type(request.get("preferred")) is not bool
+ ):
+ raise ValueError(f"Expected a bounded {action} request.")
+ adapter = BrowserStructureAdapter(
+ extract_document_state(self.info["document"]),
+ measured_drawing=self.info.get("drawing"),
+ )
+ args = (request["x"], request["y"], request["hits"])
+ if action == "bond_menu":
+ return {
+ "menu": adapter.double_bond_menu(*args, request.get("scale")),
+ "revision": self.revision,
+ }
+ return {
+ "target": adapter.pick_target(
+ *args, preferred=request["preferred"], scale=request.get("scale")
+ ),
+ "revision": self.revision,
+ }
+
+ def dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
+ action = request.get("action")
+ shortcut_tool = None
+ edit_notice = None
+ pasted = None
+ result = None
+ if action in {"drafts", "discard_draft"}:
+ return self.draft_query(action, request)
+ if action != "read" and request.get("revision") != self.revision:
+ raise StaleRevisionError(
+ "This window has stale state. Refresh it before editing."
+ )
+ if action == "selection":
+ if set(request) - {"session", "revision", "action", "selection"}:
+ raise ValueError("Unexpected selection presentation fields.")
+ info = self.info
+ if info["drawing"].get("needs_measurements"):
+ raise ValueError("Selection needs completed font measurements.")
+ components, frame, groups = selection_presentation(
+ info, request.get("selection")
+ )
+ return {
+ "components": components,
+ "frame": frame,
+ "groups": groups,
+ "revision": self.revision,
+ }
+ if action == "mark_preview":
+ if set(request) - {
+ "session",
+ "revision",
+ "action",
+ "x",
+ "y",
+ "kind",
+ "scale",
+ "hits",
+ "font",
+ }:
+ raise ValueError("Unexpected mark preview fields.")
+ if self.info["unsupported"]:
+ raise ValueError("Mark preview needs an editable drawing.")
+ font = BrowserFontMeasurements(request["font"])
+ adapter = BrowserStructureAdapter(
+ deepcopy(extract_document_state(self.info["document"]))
+ )
+ drawing = font.drawing(adapter.document_state)
+ owner, x, y, _dx, _dy = adapter.mark_placement(
+ request["x"],
+ request["y"],
+ request["kind"],
+ scale=request["scale"],
+ hits=request["hits"],
+ drawing=drawing,
+ font=font,
+ )
+ # A free transient glyph uses the existing renderer without changing chemistry.
+ adapter.add_mark_record(request["kind"], None, x, y, None, None)
+ atom = adapter.model.atom_for_id(owner)
+ return {
+ "mark": font.drawing(adapter.document_state)["marks"][-1],
+ "atom": [
+ atom.x,
+ atom.y,
+ adapter.renderer.style.bond_length_px * ATOM_HOVER_RADIUS_RATIO,
+ ]
+ if atom is not None
+ else None,
+ "owner": owner,
+ "revision": self.revision,
+ }
+ if action == "label_preview":
+ if set(request) - {"session", "revision", "action", "labels"}:
+ raise ValueError("Unexpected label preview fields.")
+ labels = request.get("labels")
+ if (
+ not isinstance(labels, dict)
+ or set(labels) != {"above", "below"}
+ or any(
+ not isinstance(text, str) or len(text) > 4096
+ for text in labels.values()
+ )
+ ):
+ raise ValueError("Expected bounded arrow label preview text.")
+ return {
+ "html": {side: arrow_label_html(text) for side, text in labels.items()}
+ }
+ if action in {
+ "pick",
+ "bond_menu",
+ "note_markup",
+ "template_preview",
+ "atom_input",
+ "export",
+ "export_mol",
+ "export_figure",
+ "copy",
+ }:
+ return self.structure_query(action, request)
+ if action == "measure":
+ if set(request) - {
+ "session",
+ "revision",
+ "action",
+ "font",
+ "edit",
+ "selection",
+ }:
+ raise ValueError("Unexpected font measurement fields.")
+ font = BrowserFontMeasurements(request["font"])
+ result = (
+ edit_document(
+ {"document": self.info["document"], "edit": request["edit"]},
+ font=font,
+ mark_order=self.info["mark_order"],
+ preview=True,
+ smiles_model=self.prepared_smiles_for_edit(request["edit"]),
+ )
+ if "edit" in request
+ else document_info(
+ self.info["document"], font=font, mark_order=self.info["mark_order"]
+ )
+ )
+ if result["drawing"].get("needs_measurements"):
+ raise ValueError("Font measurements do not match the drawing.")
+ self.font = font
+ if "edit" not in request:
+ self.info = result
+ elif action == "preview":
+ if set(request) - {"session", "revision", "action", "edit", "selection"}:
+ raise ValueError("Unexpected preview fields.")
+ result = edit_document(
+ {"document": self.info["document"], "edit": request["edit"]},
+ font=self.font,
+ mark_order=self.info["mark_order"],
+ preview=True,
+ smiles_model=self.prepared_smiles_for_edit(request["edit"]),
+ )
+ elif action in {"load", "recover_draft"}:
+ self.open_document(request)
+ elif action == "edit":
+ candidate = edit_document(
+ {"document": self.info["document"], "edit": request["edit"]},
+ font=self.font,
+ mark_order=self.info["mark_order"],
+ smiles_model=self.prepared_smiles_for_edit(request["edit"]),
+ paste_source=(self.paste_source, self.paste_count),
+ )
+ shortcut_tool = candidate.pop("shortcut_tool", None)
+ edit_notice = candidate.pop("edit_notice", None)
+ paste = candidate.pop("paste", None)
+ if candidate["document"] != self.info["document"]:
+ # Push first: a failed record cannot publish the candidate.
+ self.history.push(DocumentChange(self.info, candidate))
+ self.info = candidate
+ pasted = self.record_paste(paste)
+ elif action in {"undo", "redo"}:
+ getattr(self.history, action)()
+ elif action != "read":
+ raise ValueError("Unknown browser action.")
+ if (
+ action in {"preview", "measure"}
+ and "selection" in request
+ and result is not None
+ and not result["drawing"].get("needs_measurements")
+ ):
+ components, frame, groups = selection_presentation(
+ result, request["selection"]
+ )
+ result = {
+ **result,
+ "selection_components": components,
+ "selection_frame": frame,
+ "selection_groups": groups,
+ }
+ if action not in {"read", "measure", "preview"}:
+ self.revision += 1
+ text = json.dumps(self.info["document"], sort_keys=True)
+ dirty = text != self.saved
+ # The draft is durable before the browser hears of the change.
+ self.keep_draft(action, text, dirty)
+ return {
+ **(self.info if result is None else result),
+ "shortcut_tool": shortcut_tool,
+ "edit_notice": edit_notice,
+ "pasted": pasted,
+ "revision": self.revision,
+ "name": self.name,
+ "can_undo": bool(self.state.history),
+ "can_redo": bool(self.state.redo_stack),
+ "dirty": dirty,
+ "recovery": self.recovery_status(dirty),
+ }
+
+
+class BrowserServer(ThreadingHTTPServer):
+ """One loopback server with isolated in-memory document sessions.
+
+ Its only file writes are the optional draft store's recovery drafts.
+ """
+
+ def __init__(
+ self, port: int = 0, *, drafts: BrowserDraftStore | None = None
+ ) -> None:
+ self.token = secrets.token_urlsafe(32)
+ self.sessions: dict[str, BrowserSession] = {}
+ self.session_lock = RLock()
+ self.drafts = drafts
+ super().__init__(("127.0.0.1", port), BrowserHandler)
+ self.origin = f"http://127.0.0.1:{self.server_port}"
+
+ @override
+ def server_close(self) -> None:
+ super().server_close()
+ if self.drafts is not None:
+ self.drafts.close()
+
+ def drop_idle_sessions(self) -> None:
+ """Close sessions unused for SESSION_IDLE_SECONDS; call with session_lock."""
+ now = time.monotonic()
+ for session_id, session in list(self.sessions.items()):
+ if now - session.last_used >= SESSION_IDLE_SECONDS:
+ session.closed = True
+ session.release_draft()
+ del self.sessions[session_id]
+
+ def forget_closed_sessions(self) -> None:
+ """Drop windows a recovery took over; their requests already report the end."""
+ with self.session_lock:
+ for session_id, session in list(self.sessions.items()):
+ if session.closed:
+ del self.sessions[session_id]
+
+
+class BrowserHandler(BaseHTTPRequestHandler):
+ server: BrowserServer
+
+ @override
+ def setup(self) -> None:
+ super().setup()
+ self.connection.settimeout(10)
+
+ @override
+ def log_message(self, format: str, *args: Any) -> None:
+ """Request bodies and launch credentials are never logged."""
+
+ def _reply(self, status: int, body: bytes, content_type: str) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header(
+ "Content-Security-Policy",
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
+ )
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _json(self, status: int, value: object) -> None:
+ self._reply(
+ status,
+ json.dumps(value, ensure_ascii=False, allow_nan=False).encode(),
+ "application/json; charset=utf-8",
+ )
+
+ def _allowed(self, *, api: bool) -> bool:
+ host = self.server.origin.removeprefix("http://")
+ if (
+ self.headers.get("Host") != host
+ or self.headers.get("Origin", self.server.origin) != self.server.origin
+ ):
+ self._json(
+ 403,
+ {"error": "This browser adapter accepts its own local window only."},
+ )
+ return False
+ if api and not secrets.compare_digest(
+ self.headers.get("Authorization", "").encode(),
+ ("Bearer " + self.server.token).encode(),
+ ):
+ self._json(
+ 401, {"error": "Launch Chemvas again to open an authorized window."}
+ )
+ return False
+ return True
+
+ def do_GET(self) -> None:
+ if not self._allowed(api=self.path.startswith("/api/")):
+ return
+ if self.path == "/api/new":
+ self._json(200, document_info(new_document(), render=False))
+ elif self.path == "/api/ui":
+ self._json(200, ui_spec())
+ elif self.path.startswith("/api/image?"):
+ self._image(parse_qs(self.path.partition("?")[2]))
+ elif self.path == "/ui.css":
+ self._reply(200, ui_css().encode(), "text/css; charset=utf-8")
+ elif self.path in STATIC_FILES:
+ filename, mime = STATIC_FILES[self.path]
+ self._reply(200, (ASSETS / filename).read_bytes(), mime)
+ else:
+ self._json(404, {"error": "Not found."})
+
+ def _image(self, query: dict[str, list[str]]) -> None:
+ """One embedded image source of a session's current document."""
+ session_ids, refs = query.get("session", []), query.get("ref", [])
+ with self.server.session_lock:
+ session = (
+ self.server.sessions.get(session_ids[0])
+ if len(session_ids) == 1
+ else None
+ )
+ source = (
+ session.image_source(refs[0])
+ if session is not None and len(refs) == 1
+ else None
+ )
+ if source is None:
+ self._json(404, {"error": "This image is not part of the drawing."})
+ return
+ self._reply(200, *source)
+
+ def do_POST(self) -> None:
+ if not self._allowed(api=True):
+ return
+ if self.path not in {"/api/open", "/api/session"}:
+ self._json(404, {"error": "Not found."})
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ if not 0 < length <= MAX_OPEN_BYTES:
+ self._json(
+ 413,
+ {
+ "error": "The browser adapter accepts JSON files up to "
+ f"{MAX_OPEN_BYTES // 2**20} MiB."
+ },
+ )
+ return
+ if self.headers.get(
+ "Content-Type"
+ ) != "application/json" or self.headers.get("Transfer-Encoding"):
+ self._json(
+ 415, {"error": "Expected a JSON request with Content-Length."}
+ )
+ return
+ body = self.rfile.read(length)
+ if len(body) != length:
+ raise ValueError("Incomplete request.")
+ request = strict_json_loads(body)
+ if self.path == "/api/session":
+ if not isinstance(request, dict):
+ raise ValueError("Expected a session request.")
+ session_result: dict[str, Any] | None = None
+ with self.server.session_lock:
+ session_id = request.get("session")
+ session: BrowserSession | None
+ if not session_id:
+ if request.get("revision") != 0:
+ raise ValueError(
+ "A new session must start at revision zero."
+ )
+ if len(self.server.sessions) >= MAX_BROWSER_SESSIONS:
+ self.server.drop_idle_sessions()
+ if len(self.server.sessions) >= MAX_BROWSER_SESSIONS:
+ raise ValueError(
+ "Close a browser window before opening another."
+ )
+ if request.get("takeover") is not None:
+ # A takeover waits on another window's lock, never
+ # while this server-wide lock is held.
+ raise ValueError("Recover drafts from an open window.")
+ session_id = secrets.token_urlsafe(24)
+ session = BrowserSession(self.server.drafts)
+ if request.get("action") == "close":
+ session_result = {}
+ else:
+ session_result = {
+ **session.dispatch(request),
+ "session": session_id,
+ }
+ self.server.sessions[session_id] = session
+ else:
+ session = self.server.sessions.get(session_id)
+ if session is not None:
+ session.last_used = time.monotonic()
+ if session is None:
+ raise ValueError("This browser adapter session has ended.")
+ if session_result is None:
+ with session.lock:
+ if session.closed:
+ raise ValueError("This browser adapter session has ended.")
+ if request.get("action") == "close":
+ session.closed = True
+ session.release_draft()
+ with self.server.session_lock:
+ self.server.sessions.pop(session_id, None)
+ session_result = {}
+ else:
+ session_result = {
+ **session.dispatch(request),
+ "session": session_id,
+ }
+ if request.get("action") == "recover_draft":
+ self.server.forget_closed_sessions()
+ if request.get("action") != "export" and "document" in session_result:
+ session_result = {
+ **session_result,
+ "document": without_image_data(session_result["document"]),
+ }
+ self._json(200, session_result)
+ return
+ result = document_info(request, render=False)
+ except StaleRevisionError as exc:
+ self._json(409, {"error": str(exc)})
+ except (ValueError, TypeError, KeyError, OverflowError, RecursionError) as exc:
+ self._json(400, {"error": str(exc) or "Invalid document."})
+ else:
+ self._json(200, result)
+
+
+def main(argv: list[str] | None = None) -> None:
+ parser = argparse.ArgumentParser(
+ description="Open the Chemvas browser adapter. Stop with Ctrl+C."
+ )
+ parser.add_argument(
+ "--port", type=int, default=0, help="Local port (default: choose a free port)"
+ )
+ parser.add_argument(
+ "--no-browser",
+ action="store_true",
+ help="Print the launch URL without opening a browser",
+ )
+ parser.add_argument(
+ "--drafts-dir",
+ type=Path,
+ default=None,
+ help="Folder for automatic recovery drafts (default: per-user app data)",
+ )
+ args = parser.parse_args(argv)
+ drafts = BrowserDraftStore(args.drafts_dir or default_drafts_root())
+ with BrowserServer(args.port, drafts=drafts) as server:
+ url = f"{server.origin}/#token={server.token}"
+ print(f"Chemvas browser adapter: {url}", flush=True)
+ if drafts.problem is not None:
+ print(drafts.problem, flush=True)
+ if not args.no_browser:
+ webbrowser.open(url)
+ with suppress(KeyboardInterrupt):
+ server.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/app/chemvas/bootstrap/web_drafts.py b/app/chemvas/bootstrap/web_drafts.py
new file mode 100644
index 00000000..6ebb8c34
--- /dev/null
+++ b/app/chemvas/bootstrap/web_drafts.py
@@ -0,0 +1,408 @@
+"""Durable drafts of unsaved browser documents, owned by one browser server.
+
+The browser server listens on a new loopback port with a new launch credential
+each time it starts, so the browser's own storage, which belongs to that
+origin, cannot outlive a server restart. Drafts are therefore files in a
+per-user folder that the server writes after each accepted change.
+
+One running server owns the folder. It proves this with an operating-system
+lock on ``owner.lock`` that ends with its process, including after a crash. A
+second server started meanwhile works without drafts and says so, so two
+processes never write or recover the same draft.
+
+Each unsaved document has exactly one draft file, named by a random id. A window
+keeps its draft claimed while it is open; closing the window releases the
+claim and leaves the file for explicit recovery. Recovering claims that same
+file for the recovering window instead of copying it, so repeated recovery,
+reloads and restarts never multiply drafts. Files are only deleted when their
+document is clean again, when the user replaces or discards it, never because
+of age or a download.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import math
+import os
+import re
+import secrets
+import stat
+import sys
+import time
+from dataclasses import dataclass
+from decimal import Decimal
+from pathlib import Path
+from threading import RLock
+from typing import IO, Any
+
+from chemvas.core.document_io import atomic_write_text
+from chemvas.domain.document import MAX_DOCUMENT_BYTES
+from chemvas.domain.json_io import strict_json_loads
+
+DRAFT_FORMAT = "chemvas-browser-draft"
+DRAFT_VERSION = 1
+# The same bound as the browser server's in-memory sessions.
+MAX_DRAFTS = 16
+MAX_DRAFT_NAME_CHARS = 1024
+# One complete document, embedded images included, plus its small envelope.
+MAX_DRAFT_BYTES = MAX_DOCUMENT_BYTES + 64 * 1024
+LOCK_NAME = "owner.lock"
+_DRAFT_ID = re.compile(r"[A-Za-z0-9_-]{16,64}")
+_DRAFT_KEYS = frozenset(("format", "version", "name", "saved_at", "document"))
+DAMAGED_DRAFT = "This draft is damaged and cannot be recovered. Its file has been kept."
+
+
+class DraftError(ValueError):
+ """A draft could not be written, read or changed; existing files are intact."""
+
+
+def default_drafts_root() -> Path:
+ """The per-user folder for browser drafts, resolved without Qt."""
+ home = Path.home()
+ if sys.platform == "darwin":
+ base = home / "Library" / "Application Support"
+ elif sys.platform == "win32":
+ base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
+ else:
+ base = Path(os.environ.get("XDG_DATA_HOME", ""))
+ if not base.is_absolute():
+ base = home / ".local" / "share"
+ return base / "Chemvas" / "browser-drafts"
+
+
+def new_draft_id() -> str:
+ return secrets.token_urlsafe(18)
+
+
+def valid_draft_id(value: object) -> str:
+ """A draft id from a request; it names a file, so nothing else passes."""
+ if not isinstance(value, str) or _DRAFT_ID.fullmatch(value) is None:
+ raise DraftError("This recovery draft does not exist.")
+ return value
+
+
+@dataclass(frozen=True)
+class DraftEntry:
+ """One draft file; ``problem`` says why it cannot be recovered."""
+
+ draft_id: str
+ name: str | None
+ saved_at: float | None
+ problem: str | None
+ owner: object | None
+
+
+def _hold_lock(handle: IO[bytes]) -> None:
+ if sys.platform == "win32":
+ import msvcrt
+
+ # Every server locks the same first byte of the empty lock file.
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+
+def _drop_lock(handle: IO[bytes]) -> None:
+ if sys.platform == "win32":
+ import msvcrt
+
+ with contextlib.suppress(OSError):
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ handle.close()
+
+
+def _reason(error: OSError) -> str:
+ return error.strerror or str(error) or type(error).__name__
+
+
+class BrowserDraftStore:
+ """The draft folder of one running browser server and its window claims."""
+
+ def __init__(self, root: Path) -> None:
+ self.root = root
+ self.lock = RLock()
+ self.problem: str | None = None
+ self._claims: dict[str, object] = {}
+ self._lock_handle: IO[bytes] | None = None
+ try:
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
+ handle = (root / LOCK_NAME).open("a+b")
+ except OSError as error:
+ self.problem = f"Automatic recovery is unavailable: {_reason(error)}."
+ return
+ try:
+ _hold_lock(handle)
+ except OSError:
+ handle.close()
+ self.problem = (
+ "Automatic recovery is off: another running Chemvas browser "
+ "server keeps the recovery drafts. Save copies of your work here, "
+ "or close the other server and start this one again."
+ )
+ return
+ # Staging files left by interrupted writes are kept: another program's
+ # save into a chosen --drafts-dir uses the same names without this lock.
+ self._lock_handle = handle
+
+ @property
+ def available(self) -> bool:
+ return self._lock_handle is not None
+
+ def close(self) -> None:
+ """Stop owning the folder; drafts stay for the next server."""
+ with self.lock:
+ handle, self._lock_handle = self._lock_handle, None
+ self._claims.clear()
+ if handle is not None:
+ _drop_lock(handle)
+
+ def owner_of(self, draft_id: str) -> object | None:
+ with self.lock:
+ return self._claims.get(draft_id)
+
+ def write(
+ self, draft_id: str, owner: object, *, name: str, document: dict[str, Any]
+ ) -> None:
+ """Replace the owner's draft with the document it now holds."""
+ with self.lock:
+ self._require()
+ path = self._path(draft_id)
+ holder = self._claims.get(draft_id)
+ if holder is not owner and (holder is not None or self._present(path)):
+ raise DraftError(
+ "Another window now holds this drawing's recovery draft."
+ )
+ if holder is None and len(self._draft_paths()) >= MAX_DRAFTS:
+ raise DraftError(
+ f"Recovery storage already holds {MAX_DRAFTS} drafts. Recover "
+ "or discard earlier work with File > Recover Unsaved Work… to "
+ "protect this drawing."
+ )
+ text = json.dumps(
+ {
+ "format": DRAFT_FORMAT,
+ "version": DRAFT_VERSION,
+ "name": name[:MAX_DRAFT_NAME_CHARS],
+ "saved_at": time.time(),
+ "document": document,
+ },
+ ensure_ascii=False,
+ allow_nan=False,
+ )
+ if len(text.encode()) > MAX_DRAFT_BYTES:
+ raise DraftError(
+ "This drawing is too large for automatic recovery. Save a copy."
+ )
+ try:
+ atomic_write_text(path, text)
+ except OSError as error:
+ raise DraftError(
+ f"The recovery draft could not be written ({_reason(error)}). "
+ "Its previous version, if any, is unchanged. Save a copy."
+ ) from error
+ self._claims[draft_id] = owner
+
+ def discard(self, draft_id: str, owner: object) -> bool:
+ """Delete the owner's draft: its document is clean or was replaced.
+
+ Returns whether the owner held a draft to delete.
+ """
+ with self.lock:
+ if self._claims.get(draft_id) is not owner:
+ return False
+ if self.available:
+ try:
+ self._path(draft_id).unlink(missing_ok=True)
+ except OSError as error:
+ raise DraftError(
+ "An outdated recovery draft could not be removed "
+ f"({_reason(error)})."
+ ) from error
+ del self._claims[draft_id]
+ return True
+
+ def release(
+ self, draft_id: str, owner: object, *, to: object | None = None
+ ) -> None:
+ """The owner's window closed; its draft stays for explicit recovery.
+
+ With ``to``, a refused takeover returns the claim to its holder.
+ """
+ with self.lock:
+ if self._claims.get(draft_id) is owner:
+ if to is None:
+ del self._claims[draft_id]
+ else:
+ self._claims[draft_id] = to
+
+ def take(
+ self, draft_id: str, owner: object, *, holder: object | None = None
+ ) -> tuple[str, dict[str, Any]]:
+ """Move the claim from ``holder`` (None: released) to ``owner``.
+
+ Returns the draft's name and document. Nothing changes unless the
+ claim is still ``holder``'s and the file reads as a draft; a caller
+ that then rejects the document gives the claim back with ``release``.
+ """
+ with self.lock:
+ self._require()
+ current = self._claims.get(draft_id)
+ if current is owner:
+ raise DraftError("This drawing is already open in this window.")
+ if current is not holder:
+ raise DraftError(
+ "This drawing is still open in another window. Switch to it, "
+ "or recover it here to close it there."
+ if holder is None
+ else "This drawing moved to another window meanwhile. Open "
+ "File > Recover Unsaved Work… again."
+ )
+ path = self._path(draft_id)
+ if not self._is_draft_file(path):
+ raise DraftError("This recovery draft no longer exists.")
+ envelope = self._read(path)
+ self._claims[draft_id] = owner
+ return envelope["name"], envelope["document"]
+
+ def remove(self, draft_id: str) -> None:
+ """Discard a draft that no open window holds."""
+ with self.lock:
+ self._require()
+ if draft_id in self._claims:
+ raise DraftError(
+ "This drawing is open in a window. Close it there first."
+ )
+ try:
+ self._path(draft_id).unlink()
+ except FileNotFoundError:
+ return
+ except OSError as error:
+ raise DraftError(
+ f"The recovery draft could not be discarded ({_reason(error)})."
+ ) from error
+
+ def entries(self) -> list[DraftEntry]:
+ """Every draft in the folder, newest first; unreadable ones are kept."""
+ with self.lock:
+ if not self.available:
+ return []
+ entries: list[DraftEntry] = []
+ for path in self._draft_paths():
+ owner = self._claims.get(path.stem)
+ try:
+ envelope = self._read(path)
+ except DraftError as error:
+ entries.append(DraftEntry(path.stem, None, None, str(error), owner))
+ continue
+ entries.append(
+ DraftEntry(
+ path.stem, envelope["name"], envelope["saved_at"], None, owner
+ )
+ )
+ return sorted(entries, key=lambda entry: -(entry.saved_at or 0.0))
+
+ def _require(self) -> None:
+ if not self.available:
+ raise DraftError(self.problem or "Automatic recovery has stopped.")
+
+ def _path(self, draft_id: str) -> Path:
+ return self.root / f"{valid_draft_id(draft_id)}.json"
+
+ def _draft_paths(self) -> list[Path]:
+ """The folder's draft files; an unreadable folder is an error, not empty."""
+ try:
+ children = list(self.root.iterdir())
+ except OSError as error:
+ raise DraftError(
+ f"The recovery folder could not be read ({_reason(error)}). "
+ "Its drafts are kept; save copies of your work."
+ ) from error
+ return [
+ path
+ for path in children
+ if path.suffix == ".json"
+ and _DRAFT_ID.fullmatch(path.stem) is not None
+ and self._is_draft_file(path)
+ ]
+
+ @staticmethod
+ def _present(path: Path) -> bool:
+ try:
+ path.lstat()
+ except FileNotFoundError:
+ return False
+ except OSError as error:
+ raise DraftError(
+ f"The recovery folder could not be read ({_reason(error)})."
+ ) from error
+ return True
+
+ @staticmethod
+ def _is_draft_file(path: Path) -> bool:
+ # Path.is_file reads some stat errors as "not a file"; those are errors.
+ try:
+ return stat.S_ISREG(os.lstat(path).st_mode)
+ except FileNotFoundError:
+ return False
+ except OSError as error:
+ raise DraftError(
+ f"The recovery folder could not be read ({_reason(error)})."
+ ) from error
+
+ @staticmethod
+ def _read(path: Path) -> dict[str, Any]:
+ try:
+ with path.open("rb") as stream:
+ data = stream.read(MAX_DRAFT_BYTES + 1)
+ except OSError as error:
+ raise DraftError(
+ f"This draft could not be read ({_reason(error)}). Its file has been kept."
+ ) from error
+ if len(data) > MAX_DRAFT_BYTES:
+ raise DraftError(DAMAGED_DRAFT)
+ try:
+ envelope = strict_json_loads(data)
+ except (ValueError, RecursionError, UnicodeError):
+ raise DraftError(DAMAGED_DRAFT) from None
+ if not isinstance(envelope, dict) or envelope.get("format") != DRAFT_FORMAT:
+ raise DraftError(DAMAGED_DRAFT)
+ if type(envelope.get("version")) is not int:
+ raise DraftError(DAMAGED_DRAFT)
+ if envelope["version"] != DRAFT_VERSION:
+ raise DraftError(
+ "This draft was written by another Chemvas version and cannot be "
+ "recovered here. Its file has been kept."
+ )
+ saved_at = envelope.get("saved_at")
+ name = envelope.get("name")
+ if isinstance(saved_at, bool) or not isinstance(saved_at, (int, Decimal)):
+ raise DraftError(DAMAGED_DRAFT)
+ if (
+ set(envelope) != _DRAFT_KEYS
+ or not isinstance(name, str)
+ or len(name) > MAX_DRAFT_NAME_CHARS
+ or not math.isfinite(saved_at)
+ or not isinstance(envelope.get("document"), dict)
+ ):
+ raise DraftError(DAMAGED_DRAFT)
+ return {**envelope, "saved_at": float(saved_at)}
+
+
+__all__ = [
+ "DAMAGED_DRAFT",
+ "DRAFT_FORMAT",
+ "DRAFT_VERSION",
+ "MAX_DRAFTS",
+ "MAX_DRAFT_BYTES",
+ "BrowserDraftStore",
+ "DraftEntry",
+ "DraftError",
+ "default_drafts_root",
+ "new_draft_id",
+ "valid_draft_id",
+]
diff --git a/app/chemvas/core/molfile.py b/app/chemvas/core/molfile.py
index 3625757d..35155481 100644
--- a/app/chemvas/core/molfile.py
+++ b/app/chemvas/core/molfile.py
@@ -3,7 +3,7 @@
import math
from itertools import batched
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from chemvas.domain.atom_aliases import ATOM_ALIAS_DEFINITIONS
from chemvas.domain.document import Bond, MoleculeModel
@@ -777,10 +777,46 @@ def _parse_property_entries(
return [(numbers[i], numbers[i + 1]) for i in range(1, len(numbers), 2)]
+def export_molfile_block(
+ model: MoleculeModel,
+ *,
+ atom_annotations: Mapping[int, Mapping[str, int]] | None,
+ rdkit: Any,
+) -> str:
+ """The MOL text File > Export MOL writes for an export payload.
+
+ The V2000 writer runs first, and its hard capacity and range limits surface
+ as they are. Its other refusals (abbreviation labels such as Ph or CF3) fall
+ back to ``rdkit.model_to_mol_block``, which expands them; without RDKit the
+ error says so. The desktop and browser exports share this one policy.
+ """
+ if not model.atoms:
+ raise ValueError("There is no molecular structure to export.")
+ try:
+ return write_molfile(model, atom_annotations=atom_annotations)
+ except MolfileLimitError:
+ # Hard V2000 capacity/range limits hold for any writer; falling
+ # back to RDKit would either mask them or blame missing RDKit.
+ raise
+ except MolfileError as exc:
+ block: str | None = rdkit.model_to_mol_block(
+ model, atom_annotations=atom_annotations
+ )
+ if block is None:
+ reason = getattr(rdkit, "last_error", None)
+ if not reason or "not available" in reason.lower():
+ raise ValueError(
+ f"{exc} Install RDKit to expand these abbreviations automatically."
+ ) from exc
+ raise ValueError(reason) from exc
+ return block
+
+
__all__ = [
"MolfileError",
"MolfileLimitError",
"MolfileParseError",
+ "export_molfile_block",
"fit_molfile_model",
"parse_molfile",
"read_molfile",
diff --git a/app/chemvas/domain/document/__init__.py b/app/chemvas/domain/document/__init__.py
index f9eef2d1..6558e134 100644
--- a/app/chemvas/domain/document/__init__.py
+++ b/app/chemvas/domain/document/__init__.py
@@ -23,6 +23,7 @@
broken_ring_fill_indices,
orphaned_atom_ids,
ring_fill_is_intact,
+ unmarked_isolated_carbon_ids,
)
from .graph import connected_atom_components
from .images import (
@@ -43,7 +44,12 @@
)
from .model import Atom, Bond, MoleculeModel
from .orbitals import Orbital, orbital_from_state, orbital_to_state
-from .plan_validation import validate_calculation_plan, validated_plan_and_inventory
+from .plan_validation import (
+ CALCULATION_PLAN_GRAPH_MISMATCH_WARNING,
+ calculation_plan_save_warning,
+ validate_calculation_plan,
+ validated_plan_and_inventory,
+)
from .schema import (
ARC_KIND_SWEEPS,
ARROW_LABEL_SIDES,
@@ -76,7 +82,9 @@
bond_to_state,
build_document_payload,
build_normalized_document_payload,
+ clipboard_state_document_record,
deserialize_model_state,
+ document_record_clipboard_state,
extract_document_state,
selection_payload_to_canvas_state,
serialize_model_state,
@@ -101,6 +109,7 @@
__all__ = [
"ARC_KIND_SWEEPS",
"ARROW_LABEL_SIDES",
+ "CALCULATION_PLAN_GRAPH_MISMATCH_WARNING",
"CANVAS_FILE_VERSION",
"CHEMVAS_FILE_TYPE",
"CLIPBOARD_SELECTION_VERSION",
@@ -153,9 +162,12 @@
"build_document_payload",
"build_normalized_document_payload",
"calculation_plan_from_state",
+ "calculation_plan_save_warning",
"calculation_plan_to_state",
+ "clipboard_state_document_record",
"connected_atom_components",
"deserialize_model_state",
+ "document_record_clipboard_state",
"extract_document_state",
"image_bytes_from_state",
"image_from_state",
@@ -182,6 +194,7 @@
"shape_to_state",
"ts_bracket_from_state",
"ts_bracket_to_state",
+ "unmarked_isolated_carbon_ids",
"validate_calculation_plan",
"validate_clipboard_selection_payload",
"validate_image_collection_budget",
diff --git a/app/chemvas/domain/document/edits.py b/app/chemvas/domain/document/edits.py
index d5cf687a..f0f76616 100644
--- a/app/chemvas/domain/document/edits.py
+++ b/app/chemvas/domain/document/edits.py
@@ -15,7 +15,7 @@
from .state_values import ring_atom_ids_form_cycle
if TYPE_CHECKING:
- from collections.abc import Callable, Collection, Iterable, Sequence
+ from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
from .model import Atom, Bond
@@ -115,10 +115,39 @@ def ring_fill_is_intact(
)
+def unmarked_isolated_carbon_ids(
+ atom_ids: set[int],
+ *,
+ atoms: Mapping[int, Atom],
+ bonds: Sequence[Bond | None],
+ has_visible_label: Callable[[int], bool],
+ has_marks: Callable[[int], bool],
+) -> set[int]:
+ """Surviving carbons that a user mark-removal edit must reveal."""
+ candidates = {
+ atom_id
+ for atom_id in atom_ids
+ if (atom := atoms.get(atom_id)) is not None
+ and atom.element.upper() == "C"
+ and not has_visible_label(atom_id)
+ and not has_marks(atom_id)
+ }
+ if not candidates:
+ return set()
+ # Check live model bonds once for the affected candidates, not all
+ # document atoms; stale adjacency must not reveal a bonded carbon.
+ for bond in bonds:
+ if bond is not None:
+ candidates.discard(bond.a)
+ candidates.discard(bond.b)
+ return candidates
+
+
__all__ = [
"atom_shows_itself",
"bond_endpoint_ids",
"broken_ring_fill_indices",
"orphaned_atom_ids",
"ring_fill_is_intact",
+ "unmarked_isolated_carbon_ids",
]
diff --git a/app/chemvas/domain/document/images.py b/app/chemvas/domain/document/images.py
index ea8e3584..eb4ecd42 100644
--- a/app/chemvas/domain/document/images.py
+++ b/app/chemvas/domain/document/images.py
@@ -139,6 +139,37 @@ def image_state_from_bytes(
return state
+def inserted_image_box(
+ width: float,
+ height: float,
+ visible: tuple[float, float, float, float],
+ sheet: tuple[float, float, float, float],
+) -> tuple[float, float, float, float]:
+ """Center a new image in the visible part of the sheet.
+
+ It keeps its native size unless that exceeds 70% of the placement area.
+ Without a visible part of the sheet, the whole sheet is the placement.
+ """
+ left = max(visible[0], sheet[0])
+ top = max(visible[1], sheet[1])
+ right = min(visible[0] + visible[2], sheet[0] + sheet[2])
+ bottom = min(visible[1] + visible[3], sheet[1] + sheet[3])
+ if right <= left or bottom <= top:
+ left, top, right, bottom = (
+ sheet[0],
+ sheet[1],
+ sheet[0] + sheet[2],
+ sheet[1] + sheet[3],
+ )
+ scale = min(1.0, (right - left) * 0.7 / width, (bottom - top) * 0.7 / height)
+ return (
+ (left + right) / 2 - width * scale / 2,
+ (top + bottom) / 2 - height * scale / 2,
+ width * scale,
+ height * scale,
+ )
+
+
def image_bytes_from_state(state: Mapping[str, object]) -> bytes:
"""Validate every field and decode the exact embedded source bytes."""
_validate_fields(state)
@@ -159,8 +190,23 @@ def image_bytes_from_state(state: Mapping[str, object]) -> bytes:
return data
+# Sources already decoded and inspected, keyed by the identity of their
+# immutable base64 text. A document revalidated after each edit keeps the same
+# string objects, so its rasters are not decoded again.
+_VERIFIED_SOURCES: dict[int, tuple[str, object, object, object]] = {}
+
+
def validate_image_state(state: Mapping[str, object]) -> None:
+ encoded = state.get("data_base64")
+ key = (state.get("mime_type"), state.get("pixel_width"), state.get("pixel_height"))
+ verified = _VERIFIED_SOURCES.get(id(encoded))
+ if verified is not None and verified[0] is encoded and verified[1:] == key:
+ _validate_fields(state)
+ return
image_bytes_from_state(state)
+ if len(_VERIFIED_SOURCES) >= 256:
+ _VERIFIED_SOURCES.clear()
+ _VERIFIED_SOURCES[id(encoded)] = (cast("str", encoded), *key)
def validate_image_states(states: object) -> None:
diff --git a/app/chemvas/domain/document/marks.py b/app/chemvas/domain/document/marks.py
index d977248d..8c67679c 100644
--- a/app/chemvas/domain/document/marks.py
+++ b/app/chemvas/domain/document/marks.py
@@ -3,9 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
+ from collections.abc import Mapping
+
from chemvas.domain.document.annotation_collection import AnnotationCollection
@@ -42,3 +44,55 @@ def mark_kinds_by_atom(document: AnnotationCollection[Mark]) -> dict[int, list[s
if mark.atom_id is not None:
result.setdefault(mark.atom_id, []).append(mark.kind)
return result
+
+
+def mark_center_coordinates(
+ state: Mapping[str, object], model_atoms: Mapping[int, Any]
+) -> tuple[float, float] | None:
+ center = None
+ atom_id = state.get("atom_id")
+ dx = state.get("dx")
+ dy = state.get("dy")
+ if isinstance(atom_id, int) and atom_id in model_atoms:
+ atom = model_atoms[atom_id]
+ if isinstance(dx, (int, float)) and isinstance(dy, (int, float)):
+ center = (atom.x + dx, atom.y + dy)
+ if center is None:
+ x = state.get("x")
+ y = state.get("y")
+ if isinstance(x, (int, float)) and isinstance(y, (int, float)):
+ center = (float(x), float(y))
+ return center
+
+
+def mark_state_at_position(
+ state: Mapping[str, Any],
+ position: tuple[float, float],
+ *,
+ transformed_atom_positions: Mapping[int, tuple[float, float]],
+ atoms: Mapping[int, Any],
+) -> dict[str, Any]:
+ """Apply a native geometric transform without changing the chemical owner."""
+ result = dict(state, x=position[0], y=position[1])
+ atom_id = state.get("atom_id")
+ if isinstance(atom_id, int):
+ atom_position = transformed_atom_positions.get(atom_id)
+ if atom_position is None:
+ atom = atoms.get(atom_id)
+ if atom is not None:
+ atom_position = (atom.x, atom.y)
+ if atom_position is not None:
+ result["dx"] = position[0] - atom_position[0]
+ result["dy"] = position[1] - atom_position[1]
+ return result
+
+
+def scaled_mark_offset(
+ state: Mapping[str, Any], atom_position: tuple[float, float], scale: float
+) -> tuple[float, float]:
+ return (
+ (state["dx"] if state["dx"] is not None else state["x"] - atom_position[0])
+ * scale,
+ (state["dy"] if state["dy"] is not None else state["y"] - atom_position[1])
+ * scale,
+ )
diff --git a/app/chemvas/domain/document/perspective.py b/app/chemvas/domain/document/perspective.py
index e3c9812b..2c3255ce 100644
--- a/app/chemvas/domain/document/perspective.py
+++ b/app/chemvas/domain/document/perspective.py
@@ -1,5 +1,11 @@
from __future__ import annotations
+import math
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Mapping
+
Coords3D = tuple[float, float, float]
@@ -48,6 +54,38 @@ def unproject_point_3d(
)
+def reprojected_pasted_coords_3d(
+ coords_3d: Mapping[int, Coords3D],
+ source_center_3d: Coords3D | None,
+ atoms_xy: Mapping[int, tuple[float, float]],
+ *,
+ bond_length_px: float,
+ target_center_3d: Coords3D | None,
+ target_anchor_2d: tuple[float, float] | None,
+) -> dict[int, Coords3D]:
+ """Pasted depth points, unprojected through the target drawing's camera.
+
+ A pasted atom keeps its 2D position. Its depth keeps the same offset from
+ the camera depth when both the copied and the target drawing have a camera.
+ """
+ result: dict[int, Coords3D] = {}
+ for atom_id, coords in coords_3d.items():
+ atom_xy = atoms_xy.get(atom_id)
+ if atom_xy is None:
+ continue
+ target_z = coords[2]
+ if target_center_3d is not None and source_center_3d is not None:
+ target_z = target_center_3d[2] + (coords[2] - source_center_3d[2])
+ result[atom_id] = unproject_point_3d(
+ atom_xy,
+ target_z,
+ bond_length_px=bond_length_px,
+ center_3d=target_center_3d,
+ anchor_2d=target_anchor_2d,
+ )
+ return result
+
+
def translate_projected_point_3d(
point: Coords3D,
dx: float,
@@ -68,3 +106,195 @@ def translate_projected_point_3d(
else 1.0
)
return point[0] + dx / scale, point[1] + dy / scale, point[2]
+
+
+def stored_coords_match_projection(
+ point: Coords3D,
+ atom_xy: tuple[float, float],
+ *,
+ bond_length_px: float,
+ center_3d: Coords3D | None,
+ anchor_2d: tuple[float, float] | None,
+) -> bool:
+ """Whether a stored 3D point still projects onto its atom.
+
+ 2D coordinates are the truth; a stored point is a depth cache that a 2D
+ edit leaves stale once its projection drifts from the atom.
+ """
+ x, y = project_point_3d(
+ point, bond_length_px=bond_length_px, center_3d=center_3d, anchor_2d=anchor_2d
+ )
+ return math.hypot(x - atom_xy[0], y - atom_xy[1]) <= max(1.0, bond_length_px * 0.15)
+
+
+def current_atom_coords_3d(
+ atom_xy: tuple[float, float],
+ stored: Coords3D | None,
+ *,
+ bond_length_px: float,
+ center_3d: Coords3D | None,
+ anchor_2d: tuple[float, float] | None,
+) -> Coords3D:
+ """Return the stored point while it is valid, else the flat atom point."""
+ if stored is not None and stored_coords_match_projection(
+ stored,
+ atom_xy,
+ bond_length_px=bond_length_px,
+ center_3d=center_3d,
+ anchor_2d=anchor_2d,
+ ):
+ return stored
+ return atom_xy[0], atom_xy[1], 0.0
+
+
+def ring_center_3d(
+ points: Iterable[Coords3D],
+ *,
+ screen_delta: tuple[float, float] = (0.0, 0.0),
+ bond_length_px: float,
+ center_3d: Coords3D | None,
+) -> Coords3D | None:
+ """Average ring vertices after translating each by the screen delta.
+
+ Vertices are translated before averaging because depth clamping makes the
+ inverse scale of an average differ from the average of inverse scales.
+ """
+ coords = [
+ translate_projected_point_3d(
+ point,
+ *screen_delta,
+ bond_length_px=bond_length_px,
+ center_3d=center_3d,
+ )
+ if screen_delta != (0.0, 0.0)
+ else point
+ for point in points
+ ]
+ if len(coords) < 3:
+ return None
+ count = len(coords)
+ return (
+ sum(c[0] for c in coords) / count,
+ sum(c[1] for c in coords) / count,
+ sum(c[2] for c in coords) / count,
+ )
+
+
+def bond_offset_unit(
+ atom_a: tuple[float, float],
+ atom_b: tuple[float, float],
+ target: Coords3D | None,
+ *,
+ bond_length_px: float,
+ center_3d: Coords3D | None,
+ anchor_2d: tuple[float, float] | None,
+) -> tuple[float, float] | None:
+ """Unit normal of a bond, turned toward a projected 3D target if given."""
+ dx, dy = atom_b[0] - atom_a[0], atom_b[1] - atom_a[1]
+ length = math.hypot(dx, dy)
+ if length < 1e-9:
+ return None
+ nx, ny = -dy / length, dx / length
+ if target is not None:
+ tx, ty = project_point_3d(
+ target,
+ bond_length_px=bond_length_px,
+ center_3d=center_3d,
+ anchor_2d=anchor_2d,
+ )
+ if (
+ nx * (tx - (atom_a[0] + atom_b[0]) * 0.5)
+ + ny * (ty - (atom_a[1] + atom_b[1]) * 0.5)
+ < 0
+ ):
+ nx, ny = -nx, -ny
+ return nx, ny
+
+
+def rescaled_perspective(
+ coords: Mapping[int, Coords3D],
+ center_3d: Coords3D | None,
+ anchor_2d: tuple[float, float] | None,
+ *,
+ scale: float,
+ origin: tuple[float, float],
+) -> tuple[dict[int, Coords3D], Coords3D | None, tuple[float, float] | None]:
+ """Scale stored points with a bond length change about a 2D origin.
+
+ Depth scales about the camera depth, so projected shapes keep their
+ proportions; the camera and anchor move with the drawing in 2D only.
+ """
+ ox, oy = origin
+
+ def scaled(x: float, y: float) -> tuple[float, float]:
+ return ox + (x - ox) * scale, oy + (y - oy) * scale
+
+ z_center = center_3d[2] if center_3d is not None else 0.0
+ rescaled = {
+ atom_id: (*scaled(x, y), z_center + (z - z_center) * scale)
+ for atom_id, (x, y, z) in coords.items()
+ }
+ center = (*scaled(center_3d[0], center_3d[1]), center_3d[2]) if center_3d else None
+ anchor = scaled(*anchor_2d) if anchor_2d is not None else None
+ return rescaled, center, anchor
+
+
+def _finite_point_or_none(point: tuple[float, ...] | None) -> tuple[float, ...] | None:
+ if point is None:
+ return None
+ if all(isinstance(value, (int, float)) and math.isfinite(value) for value in point):
+ return point
+ return None
+
+
+def saved_perspective(
+ coords: Mapping[int, Coords3D],
+ atoms_xy: Mapping[int, tuple[float, float]],
+ center_3d: Coords3D | None,
+ anchor_2d: tuple[float, float] | None,
+ *,
+ bond_length_px: float,
+) -> dict[str, object] | None:
+ """Return the document perspective record, or None when nothing is valid.
+
+ Only points that still project onto a live atom are saved.
+ """
+ valid = {
+ atom_id: point
+ for atom_id, point in coords.items()
+ if atom_id in atoms_xy
+ and stored_coords_match_projection(
+ point,
+ atoms_xy[atom_id],
+ bond_length_px=bond_length_px,
+ center_3d=center_3d,
+ anchor_2d=anchor_2d,
+ )
+ }
+ if not valid:
+ return None
+ return {
+ "atom_coords_3d": valid,
+ "projection_center_3d": _finite_point_or_none(center_3d),
+ "projection_anchor_2d": _finite_point_or_none(anchor_2d),
+ }
+
+
+def perspective_from_state(
+ record: Mapping[str, Any] | None,
+) -> tuple[dict[int, Coords3D], Coords3D | None, tuple[float, float] | None]:
+ """Read a validated document perspective record into runtime values."""
+ record = record or {}
+ coords = {
+ int(atom_id): (float(point[0]), float(point[1]), float(point[2]))
+ for atom_id, point in record.get("atom_coords_3d", {}).items()
+ }
+ center = record.get("projection_center_3d")
+ anchor = record.get("projection_anchor_2d")
+ return (
+ coords,
+ (float(center[0]), float(center[1]), float(center[2]))
+ if center is not None
+ else None,
+ (float(anchor[0]), float(anchor[1])) if anchor is not None else None,
+ )
diff --git a/app/chemvas/domain/document/plan_validation.py b/app/chemvas/domain/document/plan_validation.py
index b65a9a4b..93bf9d98 100644
--- a/app/chemvas/domain/document/plan_validation.py
+++ b/app/chemvas/domain/document/plan_validation.py
@@ -14,7 +14,10 @@
from typing import TYPE_CHECKING
-from .calculation_plan import calculation_plan_from_state
+from .calculation_plan import (
+ CalculationPlanGraphMismatchError,
+ calculation_plan_from_state,
+)
from .inspection import component_inventory, document_model
from .state_values import model_bond_pairs
@@ -23,6 +26,7 @@
from .calculation_plan import CalculationPlan
from .inspection import ComponentInventory
+ from .model import MoleculeModel
def validate_calculation_plan(
@@ -76,4 +80,39 @@ def validated_plan_and_inventory(
return plan, inventory
-__all__ = ["validate_calculation_plan", "validated_plan_and_inventory"]
+CALCULATION_PLAN_GRAPH_MISMATCH_WARNING = (
+ "The calculation plan was not saved because the molecular graph "
+ "no longer matches its component references. Undo the graph edit "
+ "to recover those references, or reopen a previously saved copy."
+)
+
+
+def calculation_plan_save_warning(
+ model: MoleculeModel, plan_state: object
+) -> str | None:
+ """Why a document snapshot must leave out its plan, or None to keep it."""
+ if plan_state is None:
+ return None
+ try:
+ calculation_plan_from_state(
+ plan_state,
+ atom_ids=set(model.atoms),
+ bond_pairs=model_bond_pairs(model),
+ )
+ except CalculationPlanGraphMismatchError:
+ return CALCULATION_PLAN_GRAPH_MISMATCH_WARNING
+ except ValueError as exc:
+ return (
+ f"The calculation plan was not saved because it is invalid: {exc} "
+ "Reopen a previously saved copy, or attach a repaired plan using "
+ "chemvas attach-plan."
+ )
+ return None
+
+
+__all__ = [
+ "CALCULATION_PLAN_GRAPH_MISMATCH_WARNING",
+ "calculation_plan_save_warning",
+ "validate_calculation_plan",
+ "validated_plan_and_inventory",
+]
diff --git a/app/chemvas/domain/document/shapes.py b/app/chemvas/domain/document/shapes.py
index 17b58b37..a4d6255d 100644
--- a/app/chemvas/domain/document/shapes.py
+++ b/app/chemvas/domain/document/shapes.py
@@ -114,8 +114,19 @@ def normalized_shape(shape: Shape) -> Shape:
)
+def moved_shape(shape: Shape, dx: float, dy: float) -> Shape:
+ return replace(
+ shape,
+ left=shape.left + dx,
+ top=shape.top + dy,
+ right=shape.right + dx,
+ bottom=shape.bottom + dy,
+ )
+
+
__all__ = [
"Shape",
+ "moved_shape",
"normalized_shape",
"shape_from_state",
"shape_to_state",
diff --git a/app/chemvas/domain/document/state.py b/app/chemvas/domain/document/state.py
index e6cedda5..85e11743 100644
--- a/app/chemvas/domain/document/state.py
+++ b/app/chemvas/domain/document/state.py
@@ -370,6 +370,19 @@ def serialize_settings( # noqa: PLR0913 -- mirrors the persisted settings field
}
+# The document collections that clipboard rings, marks and scene items fill.
+_CLIPBOARD_DOCUMENT_COLLECTIONS = (
+ "ring_fills",
+ "notes",
+ "marks",
+ "arrows",
+ "ts_brackets",
+ "shapes",
+ "orbitals",
+ "images",
+)
+
+
def selection_payload_to_canvas_state(
selection_payload: Mapping[str, object],
template_settings: Mapping[str, object],
@@ -403,78 +416,27 @@ def selection_payload_to_canvas_state(
if annotation:
atom_annotations[atom_id] = annotation
- ring_fills: list[StateDict] = []
- note_states: list[StateDict] = []
- arrow_states: list[StateDict] = []
- ts_bracket_states: list[StateDict] = []
- shape_states: list[StateDict] = []
- orbital_states: list[StateDict] = []
- image_states: list[StateDict] = []
-
- for ring_state in rings:
- ring_fills.append(
- {
- "points": ring_state["points"],
- "atom_ids": ring_state["atom_ids"],
- "color": ring_state["color"],
- "alpha": ring_state["alpha"],
- }
- )
-
- mark_states = [
- {
- "kind": mark_state["mark_kind"],
- "text": mark_state["text"],
- "atom_id": mark_state["atom_id"],
- "dx": mark_state["dx"],
- "dy": mark_state["dy"],
- "x": mark_state["x"],
- "y": mark_state["y"],
- **({"color": mark_state["color"]} if "color" in mark_state else {}),
- }
- for mark_state in marks
- ]
-
- item_refs: dict[tuple[str, int], tuple[str, int]] = {
- ("marks", index): ("marks", index) for index in range(len(mark_states))
+ collections: dict[str, list[StateDict]] = {
+ key: [] for key in _CLIPBOARD_DOCUMENT_COLLECTIONS
}
- for index, item_state in enumerate(scene_items):
- kind = item_state.get("kind")
- if kind == "note":
- item_refs["scene_items", index] = ("notes", len(note_states))
- note_state = {
- "text": item_state["text"],
- "x": item_state["x"],
- "y": item_state["y"],
- }
- if "rotation" in item_state:
- note_state["rotation"] = item_state["rotation"]
- html = item_state.get("html")
- if isinstance(html, str):
- note_state["html"] = html
- note_states.append(note_state)
- elif kind in VALID_ARROW_KINDS:
- item_refs["scene_items", index] = ("arrows", len(arrow_states))
- arrow_states.append(dict(item_state))
- elif kind == "ts_bracket":
- item_refs["scene_items", index] = ("ts_brackets", len(ts_bracket_states))
- ts_bracket_states.append(dict(item_state))
- elif kind == "shape":
- item_refs["scene_items", index] = ("shapes", len(shape_states))
- shape_states.append(dict(item_state))
- elif kind == "image":
- item_refs["scene_items", index] = ("images", len(image_states))
- image_states.append(dict(item_state))
- elif kind == "orbital":
- item_refs["scene_items", index] = ("orbitals", len(orbital_states))
- orbital_states.append(
- {
- "kind": item_state["orbital_kind"],
- "center": item_state["center"],
- "scale": item_state["scale"],
- "rotation": item_state["rotation"],
- }
- )
+ item_refs: dict[tuple[str, int], tuple[str, int]] = {}
+ for section, states in (
+ ("rings", rings),
+ ("marks", marks),
+ ("scene_items", scene_items),
+ ):
+ for index, item_state in enumerate(states):
+ collection, record = clipboard_state_document_record(item_state)
+ item_refs[section, index] = (collection, len(collections[collection]))
+ collections[collection].append(record)
+ ring_fills = collections["ring_fills"]
+ note_states = collections["notes"]
+ mark_states = collections["marks"]
+ arrow_states = collections["arrows"]
+ ts_bracket_states = collections["ts_brackets"]
+ shape_states = collections["shapes"]
+ orbital_states = collections["orbitals"]
+ image_states = collections["images"]
model_state = {
"atoms": atom_states,
@@ -518,6 +480,100 @@ def selection_payload_to_canvas_state(
return state
+def clipboard_state_document_record(
+ item_state: Mapping[str, object],
+) -> tuple[str, StateDict]:
+ """The document collection and record of one validated clipboard state.
+
+ Clipboard rings, marks and scene items carry a ``kind`` naming what they
+ are; documents keep them in typed collections, where marks and orbitals
+ store their own kind in that field instead.
+ """
+ kind = item_state.get("kind")
+ if kind == "ring":
+ return "ring_fills", {
+ "points": item_state["points"],
+ "atom_ids": item_state["atom_ids"],
+ "color": item_state["color"],
+ "alpha": item_state["alpha"],
+ }
+ if kind == "mark":
+ return "marks", {
+ "kind": item_state["mark_kind"],
+ "text": item_state["text"],
+ "atom_id": item_state["atom_id"],
+ "dx": item_state["dx"],
+ "dy": item_state["dy"],
+ "x": item_state["x"],
+ "y": item_state["y"],
+ **({"color": item_state["color"]} if "color" in item_state else {}),
+ }
+ if kind == "note":
+ note_state: StateDict = {
+ "text": item_state["text"],
+ "x": item_state["x"],
+ "y": item_state["y"],
+ }
+ if "rotation" in item_state:
+ note_state["rotation"] = item_state["rotation"]
+ html = item_state.get("html")
+ if isinstance(html, str):
+ note_state["html"] = html
+ return "notes", note_state
+ if kind == "orbital":
+ return "orbitals", {
+ "kind": item_state["orbital_kind"],
+ "center": item_state["center"],
+ "scale": item_state["scale"],
+ "rotation": item_state["rotation"],
+ }
+ if kind in VALID_ARROW_KINDS:
+ return "arrows", dict(item_state)
+ collection = {"ts_bracket": "ts_brackets", "shape": "shapes", "image": "images"}
+ if isinstance(kind, str) and kind in collection:
+ return collection[kind], dict(item_state)
+ raise ValueError("Invalid clipboard payload.")
+
+
+def document_record_clipboard_state(
+ collection: str, record: Mapping[str, object]
+) -> StateDict:
+ """The clipboard state of one document record; the inverse of the above."""
+ if collection == "ring_fills":
+ return {
+ "kind": "ring",
+ "points": record["points"],
+ "atom_ids": record["atom_ids"],
+ "color": record["color"],
+ "alpha": record["alpha"],
+ }
+ if collection == "marks":
+ return {
+ "kind": "mark",
+ "mark_kind": record["kind"],
+ "text": record["text"],
+ "atom_id": record["atom_id"],
+ "dx": record["dx"],
+ "dy": record["dy"],
+ "x": record["x"],
+ "y": record["y"],
+ **({"color": record["color"]} if "color" in record else {}),
+ }
+ if collection == "notes":
+ return {"kind": "note", **record}
+ if collection == "orbitals":
+ return {
+ "kind": "orbital",
+ "orbital_kind": record["kind"],
+ "center": record["center"],
+ "scale": record["scale"],
+ "rotation": record["rotation"],
+ }
+ if collection in {"arrows", "ts_brackets", "shapes", "images"}:
+ return dict(record)
+ raise ValueError(f"Unknown document collection: {collection}")
+
+
def _clipboard_perspective_to_canvas_state(
perspective_state: object,
) -> StateDict | None:
diff --git a/app/chemvas/domain/document/ts_brackets.py b/app/chemvas/domain/document/ts_brackets.py
index ae9488fb..3686e308 100644
--- a/app/chemvas/domain/document/ts_brackets.py
+++ b/app/chemvas/domain/document/ts_brackets.py
@@ -81,7 +81,18 @@ def normalized_ts_bracket(ts_bracket: TSBracket) -> TSBracket:
__all__ = [
"TSBracket",
+ "moved_ts_bracket",
"normalized_ts_bracket",
"ts_bracket_from_state",
"ts_bracket_to_state",
]
+
+
+def moved_ts_bracket(ts_bracket: TSBracket, dx: float, dy: float) -> TSBracket:
+ return replace(
+ ts_bracket,
+ left=ts_bracket.left + dx,
+ top=ts_bracket.top + dy,
+ right=ts_bracket.right + dx,
+ bottom=ts_bracket.bottom + dy,
+ )
diff --git a/app/chemvas/features/annotations/__init__.py b/app/chemvas/features/annotations/__init__.py
index d2c8e621..2fb55be2 100644
--- a/app/chemvas/features/annotations/__init__.py
+++ b/app/chemvas/features/annotations/__init__.py
@@ -1,43 +1,147 @@
"""Text and scene-annotation layout, validation, and geometry."""
-from .arrow_label import arrow_label_html, arrow_label_normal, parse_arrow_label
+import math
+
+from .arrow_label import (
+ ARROW_LABEL_PREVIEW_POINT_SIZE,
+ LABEL_SYNTAX_HINT,
+ arrow_label_html,
+ arrow_label_normal,
+ arrow_label_position,
+ cleaned_arrow_labels,
+ parse_arrow_label,
+)
from .brackets import (
BRACKET_MENU_SPECS,
+ BRACKET_SYMBOLS,
DEFAULT_BRACKET_KIND,
+ bracket_path_commands,
+ bracket_rect_from_points,
+ bracket_stroke_width,
+ bracket_symbol_layout,
normalized_bracket_kind,
)
from .label_layout import (
+ ATOM_LABEL_DOCUMENT_MARGIN,
+ ATOM_LABEL_HIT_PADDING_RATIO,
SUB_SCALE,
LabelLayout,
LabelRun,
+ atom_label_presentation,
attachment_anchor_token,
attachment_group_at_end,
hydride_display_text,
hydride_hydrogen_text,
+ label_bounding_rect,
+ mark_dimensions,
parse_atom_label,
place_hydride_stack,
place_runs,
reversed_display_text,
split_hydride_label,
+ uses_compact_label_hit_shape,
+)
+from .note_html import MAX_NOTE_HTML_CHARS, SAFE_NOTE_HTML_TAGS, sanitize_note_html
+from .transforms import (
+ flip_annotation,
+ mirrored_box_position,
+ orbited_box_position,
+ rotate_annotation,
)
-from .note_html import MAX_NOTE_HTML_CHARS, sanitize_note_html
-from .transforms import flip_annotation, rotate_annotation
+
+
+def _radial_orbital_lobes(
+ angles_and_phases: tuple[tuple[float, bool], ...],
+ rx: float,
+ ry: float,
+) -> tuple[tuple[float, float, float, float, bool], ...]:
+ return tuple(
+ (
+ math.cos(math.radians(angle)) * 1.1,
+ math.sin(math.radians(angle)) * 1.1,
+ rx,
+ ry,
+ positive,
+ )
+ for angle, positive in angles_and_phases
+ )
+
+
+# Ellipse lobes per orbital kind as (dx, dy, rx, ry, positive_phase), all in
+# units of the base radius around the placement center. mo_antibonding also
+# paints a nodal line between its lobes (materialized by the drawing adapter).
+_ORBITAL_LOBE_SPECS: dict[str, tuple[tuple[float, float, float, float, bool], ...]] = {
+ "s": ((0.0, 0.0, 1.0, 1.0, True),),
+ "p": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, False)),
+ "sp": ((-1.2, 0.0, 1.2, 0.7, True), (0.6, 0.0, 0.6, 0.4, False)),
+ "sp2": _radial_orbital_lobes(
+ ((0.0, True), (120.0, True), (240.0, True)), 0.75, 0.5
+ ),
+ "sp3": _radial_orbital_lobes(
+ ((45.0, True), (135.0, True), (225.0, True), (315.0, True)), 0.7, 0.45
+ ),
+ "d": _radial_orbital_lobes(
+ ((45.0, True), (135.0, False), (225.0, True), (315.0, False)), 0.7, 0.45
+ ),
+ "mo_bonding": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, True)),
+ "mo_antibonding": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, False)),
+}
+
+
+def orbital_geometry(
+ center: tuple[float, float], kind: str, bond_length: float
+) -> tuple[
+ list[tuple[float, float, float, float, bool]],
+ tuple[float, float, float, float] | None,
+]:
+ radius = bond_length * 0.35
+ ellipses = []
+ for dx, dy, rx_factor, ry_factor, positive in _ORBITAL_LOBE_SPECS.get(kind, ()):
+ cx = center[0] + dx * radius
+ cy = center[1] + dy * radius
+ rx, ry = rx_factor * radius, ry_factor * radius
+ ellipses.append((cx - rx, cy - ry, rx * 2, ry * 2, positive))
+ node = (
+ (center[0], center[1] - radius * 0.8, center[0], center[1] + radius * 0.8)
+ if kind == "mo_antibonding"
+ else None
+ )
+ return ellipses, node
+
__all__ = [
+ "ARROW_LABEL_PREVIEW_POINT_SIZE",
+ "ATOM_LABEL_DOCUMENT_MARGIN",
+ "ATOM_LABEL_HIT_PADDING_RATIO",
"BRACKET_MENU_SPECS",
+ "BRACKET_SYMBOLS",
"DEFAULT_BRACKET_KIND",
+ "LABEL_SYNTAX_HINT",
"MAX_NOTE_HTML_CHARS",
+ "SAFE_NOTE_HTML_TAGS",
"SUB_SCALE",
"LabelLayout",
"LabelRun",
"arrow_label_html",
"arrow_label_normal",
+ "arrow_label_position",
+ "atom_label_presentation",
"attachment_anchor_token",
"attachment_group_at_end",
+ "bracket_path_commands",
+ "bracket_rect_from_points",
+ "bracket_stroke_width",
+ "bracket_symbol_layout",
+ "cleaned_arrow_labels",
"flip_annotation",
"hydride_display_text",
"hydride_hydrogen_text",
+ "label_bounding_rect",
+ "mark_dimensions",
+ "mirrored_box_position",
"normalized_bracket_kind",
+ "orbital_geometry",
+ "orbited_box_position",
"parse_arrow_label",
"parse_atom_label",
"place_hydride_stack",
@@ -46,4 +150,5 @@
"rotate_annotation",
"sanitize_note_html",
"split_hydride_label",
+ "uses_compact_label_hit_shape",
]
diff --git a/app/chemvas/features/annotations/arrow_label.py b/app/chemvas/features/annotations/arrow_label.py
index 838a4130..7eed27e8 100644
--- a/app/chemvas/features/annotations/arrow_label.py
+++ b/app/chemvas/features/annotations/arrow_label.py
@@ -10,6 +10,14 @@
import html
import math
+from typing import TYPE_CHECKING
+
+from chemvas.domain.document import ARC_KIND_SWEEPS, ARROW_LABEL_SIDES
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+ from chemvas.domain.document import Arrow
from .label_layout import LabelRun
@@ -25,6 +33,71 @@ def _append_run(runs: list[LabelRun], text: str, role: str) -> None:
runs.append(LabelRun(text, role))
+# The label dialog previews on paper in the interface font at this size.
+ARROW_LABEL_PREVIEW_POINT_SIZE = 14
+
+LABEL_SYNTAX_HINT = (
+ "Use _{...} for subscripts and ^{...} for superscripts. "
+ "Examples: K_{2}CO_{3}, H_{2}SO_{4}, ΔG^{‡}.\n"
+ "Without braces, _ or ^ applies until the next space, _ or ^. "
+ "Braces do not nest and backslash escaping is not supported. "
+ "A trailing _ or ^, or one followed by a space, is literal. "
+ "Enter inserts a line break; Tab moves to the next field. "
+ "Each field is limited to 200 characters; shorten longer text before OK, "
+ "or use a Note. "
+ "Leave a field empty to remove that label."
+)
+
+
+def cleaned_arrow_labels(labels: Mapping[str, str]) -> dict[str, str]:
+ return {
+ side: text
+ for side, text in labels.items()
+ if side in ARROW_LABEL_SIDES and text.strip()
+ }
+
+
+def arrow_label_position(
+ record: Arrow,
+ path_points: list[tuple[float, float]],
+ *,
+ bond_spacing: float,
+ side: str,
+ width: float,
+ height: float,
+) -> tuple[float, float]:
+ """Native label placement; adapters supply their font engine's box size."""
+ from chemvas.features.rendering import arc_midpoint, curved_midpoint
+
+ start, end, control = record.start, record.end, record.control
+ if control is not None:
+ mid = curved_midpoint(start, control, end)
+ elif record.kind in ARC_KIND_SWEEPS:
+ sweep, left = ARC_KIND_SWEEPS[record.kind]
+ mid = arc_midpoint(start, end, sweep_degrees=sweep, bulge_left=left)
+ else:
+ mid = ((start[0] + end[0]) * 0.5, (start[1] + end[1]) * 0.5)
+ nx, ny = arrow_label_normal(end[0] - start[0], end[1] - start[1])
+ # Measure how far the arrow's own strokes (harpoons, barbs) reach
+ # from the axis along the normal, so the label clears them at any
+ # bond length; a curved arrow's or arc's chord ends are not part of
+ # that, since their labels sit at the curve midpoint instead.
+ extent = 0.0
+ if control is None and record.kind not in ARC_KIND_SWEEPS:
+ for x, y in path_points:
+ extent = max(extent, abs((x - mid[0]) * nx + (y - mid[1]) * ny))
+ gap = extent + bond_spacing
+ # Half of the label box projected onto the normal, so a vertical
+ # arrow clears the label's width and a horizontal one its height.
+ half_extent = abs(nx) * width * 0.5 + abs(ny) * height * 0.5
+ distance = gap + half_extent
+ sign = 1.0 if side == "above" else -1.0
+ return (
+ mid[0] + nx * sign * distance - width * 0.5,
+ mid[1] + ny * sign * distance - height * 0.5,
+ )
+
+
def arrow_label_normal(dx: float, dy: float) -> tuple[float, float]:
"""The readable Above side: toward smaller y, or left for vertical arrows."""
length = math.hypot(dx, dy) or 1.0
@@ -88,4 +161,12 @@ def arrow_label_html(text: str) -> str:
return "".join(parts)
-__all__ = ["arrow_label_html", "arrow_label_normal", "parse_arrow_label"]
+__all__ = [
+ "ARROW_LABEL_PREVIEW_POINT_SIZE",
+ "LABEL_SYNTAX_HINT",
+ "arrow_label_html",
+ "arrow_label_normal",
+ "arrow_label_position",
+ "cleaned_arrow_labels",
+ "parse_arrow_label",
+]
diff --git a/app/chemvas/features/annotations/brackets.py b/app/chemvas/features/annotations/brackets.py
index 10e45ecb..17f6fe08 100644
--- a/app/chemvas/features/annotations/brackets.py
+++ b/app/chemvas/features/annotations/brackets.py
@@ -3,6 +3,8 @@
from chemvas.domain.document.schema import VALID_TS_BRACKET_KINDS
DEFAULT_BRACKET_KIND = "square_pair"
+# The dagger kinds are glyphs in the document font rather than strokes.
+BRACKET_SYMBOLS = {"dagger": "\u2020", "double_dagger": "\u2021"}
BRACKET_MENU_SPECS: list[tuple[str, str]] = [
("Square Brackets", "square_pair"),
@@ -26,6 +28,170 @@ def normalized_bracket_kind(
__all__ = [
"BRACKET_MENU_SPECS",
+ "BRACKET_SYMBOLS",
"DEFAULT_BRACKET_KIND",
+ "bracket_path_commands",
+ "bracket_rect_from_points",
+ "bracket_stroke_width",
+ "bracket_symbol_layout",
"normalized_bracket_kind",
]
+
+
+def bracket_path_commands(
+ bounds: tuple[float, float, float, float], kind: str, bond_length: float
+) -> list[tuple[str, tuple[float, ...]]]:
+ """Original bracket strokes; Qt and SVG materialize these same commands."""
+ x, top, width, height = bounds
+ bottom = top + height
+ hook = min(width * 0.18, bond_length * 0.55)
+ hook = max(hook, bond_length * 0.28)
+ commands: list[tuple[str, tuple[float, ...]]] = []
+ if kind not in {
+ "square_pair",
+ "square_left",
+ "parentheses_pair",
+ "parenthesis_left",
+ "braces_pair",
+ "brace_left",
+ }:
+ return commands
+ for left in (True, False) if kind.endswith("_pair") else (True,):
+ outer_x = x if left else x + width
+ inner_x = outer_x + hook if left else outer_x - hook
+ if kind in {"square_pair", "square_left"}:
+ commands.extend(
+ [
+ ("M", (inner_x, top)),
+ ("L", (outer_x, top)),
+ ("L", (outer_x, bottom)),
+ ("L", (inner_x, bottom)),
+ ]
+ )
+ elif kind in {"parentheses_pair", "parenthesis_left"}:
+ middle = top + height / 2
+ control = height * 0.22
+ commands.extend(
+ [
+ ("M", (inner_x, top)),
+ (
+ "C",
+ (
+ outer_x,
+ top + control,
+ outer_x,
+ middle - control,
+ outer_x,
+ middle,
+ ),
+ ),
+ (
+ "C",
+ (
+ outer_x,
+ middle + control,
+ outer_x,
+ bottom - control,
+ inner_x,
+ bottom,
+ ),
+ ),
+ ]
+ )
+ else:
+ mid = top + height / 2
+ quarter = height / 4.0
+ sign = 1.0 if left else -1.0
+ waist_x = outer_x + sign * hook * 0.18
+ shoulder_x = outer_x + sign * hook * 0.62
+ commands.extend(
+ [
+ ("M", (inner_x, top)),
+ (
+ "C",
+ (
+ outer_x,
+ top,
+ outer_x,
+ top + quarter * 0.55,
+ waist_x,
+ top + quarter,
+ ),
+ ),
+ (
+ "C",
+ (
+ shoulder_x,
+ top + quarter * 1.32,
+ shoulder_x,
+ mid - quarter * 0.35,
+ outer_x,
+ mid,
+ ),
+ ),
+ (
+ "C",
+ (
+ shoulder_x,
+ mid + quarter * 0.35,
+ shoulder_x,
+ bottom - quarter * 1.32,
+ waist_x,
+ bottom - quarter,
+ ),
+ ),
+ (
+ "C",
+ (
+ outer_x,
+ bottom - quarter * 0.55,
+ outer_x,
+ bottom,
+ inner_x,
+ bottom,
+ ),
+ ),
+ ]
+ )
+ return commands
+
+
+def bracket_stroke_width(bond_line_width: float) -> float:
+ return max(0.8, bond_line_width * 0.58)
+
+
+def bracket_symbol_layout(
+ bounds: tuple[float, float, float, float], bond_length: float
+) -> tuple[int, float, float]:
+ """Pixel size and baseline anchor of a dagger glyph centred in ``bounds``."""
+ x, y, width, height = bounds
+ size = min(height * 0.62, bond_length * 1.35)
+ # A box 25 tall asks for exactly 15.5 px. Moving a bracket is arithmetic
+ # on its edges, which can leave the height one float step short of 25;
+ # rounding that noise away first keeps the glyph the size it was.
+ pixels = max(10, round(round(size, 6)))
+ return pixels, x + width / 2 - pixels * 0.2, y + height / 2 + pixels * 0.36
+
+
+def bracket_rect_from_points(
+ start: tuple[float, float], end: tuple[float, float], bond_length: float
+) -> tuple[float, float, float, float]:
+ width, height = end[0] - start[0], end[1] - start[1]
+ x, y = start
+ if width < 0:
+ x += width
+ width = -width
+ if height < 0:
+ y += height
+ height = -height
+ min_width, min_height = bond_length * 1.8, bond_length * 2.4
+ if width < 4.0 and height < 4.0:
+ return (
+ start[0] - min_width / 2,
+ start[1] - min_height / 2,
+ min_width,
+ min_height,
+ )
+ cx, cy = x + width / 2, y + height / 2
+ width, height = max(width, min_width), max(height, min_height)
+ return cx - width / 2, cy - height / 2, width, height
diff --git a/app/chemvas/features/annotations/label_layout.py b/app/chemvas/features/annotations/label_layout.py
index b5ec2455..04f9b4f1 100644
--- a/app/chemvas/features/annotations/label_layout.py
+++ b/app/chemvas/features/annotations/label_layout.py
@@ -13,19 +13,57 @@
from __future__ import annotations
+import math
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
+from chemvas.domain.atom_aliases import ATOM_ALIAS_DEFINITIONS
+from chemvas.features.graph import connected_atom_unit_vectors
+
if TYPE_CHECKING:
from collections.abc import Callable
+ from chemvas.domain.document import MoleculeModel
+
# A label like "NH", "OH", "NH2", "CH3": one element symbol followed by an
# optional run of hydrogens. These get directional layout so the element sits on
# the atom and the hydrogens point away from the bonds (H outside a ring).
_HYDRIDE_RE = re.compile(r"^([A-Z][a-z]?)(?:H(\d*))?$")
+ATOM_LABEL_DOCUMENT_MARGIN = 4.0
+ATOM_LABEL_HIT_PADDING_RATIO = 0.12
+
+
+def label_bounding_rect(
+ base: tuple[float, float, float, float],
+ hit_padding: float,
+ hit_radius: float | None,
+) -> tuple[float, float, float, float]:
+ """The native layout rectangle united with its circular or padded hit box."""
+ x, y, width, height = base
+ if hit_radius is not None and hit_radius > 0:
+ px, py = max(0.0, hit_radius - width / 2), max(0.0, hit_radius - height / 2)
+ else:
+ px = py = max(0.0, hit_padding)
+ return x - px, y - py, width + 2 * px, height + 2 * py
+
+
+def uses_compact_label_hit_shape(text: str) -> bool:
+ text = text.strip()
+ if len(text) == 1:
+ return text.isalpha() and text.upper() == text
+ if len(text) == 2:
+ return (
+ text[0].isalpha()
+ and text[0].upper() == text[0]
+ and text[1].isalpha()
+ and text[1].lower() == text[1]
+ )
+ return False
+
+
def split_hydride_label(text: str) -> tuple[str, int] | None:
"""Split ``"NH2"`` -> ``("N", 2)``; return ``None`` if not element+hydrogens.
@@ -428,6 +466,154 @@ def _ink_below_baseline(line: LabelLayout) -> float:
)
+def _open_direction(vectors: list[tuple[float, float]]) -> tuple[float, float]:
+ """Direction toward the open side of an atom, for hydride label placement.
+
+ Opposite the vector sum of the bonds (the same negative-sum rule the bond
+ sprout uses at a two-bond vertex). When the bonds cancel out (a straight
+ C-NH-C), fall back to the perpendicular of the first bond, flipped so its
+ dominant component is positive -- a flat chain stacks its H underneath
+ regardless of bond insertion order, like ChemDraw.
+ """
+ sum_x = sum(dx for dx, _ in vectors)
+ sum_y = sum(dy for _, dy in vectors)
+ if math.hypot(sum_x, sum_y) > 1e-6:
+ return -sum_x, -sum_y
+ if vectors:
+ perp_x, perp_y = vectors[0][1], -vectors[0][0]
+ if (perp_y if abs(perp_y) >= abs(perp_x) else perp_x) < 0.0:
+ perp_x, perp_y = -perp_x, -perp_y
+ return perp_x, perp_y
+ return 1.0, 0.0
+
+
+def atom_label_presentation(
+ model: MoleculeModel, atom_id: int, text: str
+) -> tuple[str, str | None, bool, bool | None]:
+ # Element+hydrogen labels ("NH", "OH", "NH2", "CH3") anchor on the element
+ # with the hydrogens pointing away from the bonds; other multi-part
+ # labels ("CF3", "Ph3P") anchor on the token facing the bonds. Returns
+ # (display_text, anchor_element, anchor_at_end, hydrogens_below);
+ # hydrogens_below is None for the horizontal layouts and picks the
+ # stacked line side otherwise.
+ split = split_hydride_label(text)
+ if split is None:
+ return _token_anchor_layout(model, atom_id, text)
+ element, h_count = split
+ if h_count <= 0:
+ return _token_anchor_layout(model, atom_id, text)
+ atom = model.atoms.get(atom_id)
+ if atom is not None and atom.explicit_label:
+ return text, None, False, None
+ # Put the hydrogens on the open side of the atom, quantised to the
+ # dominant axis. A vertical open side (both bonds of a vertex rising,
+ # or a flat C-NH-C chain) stacks the H on its own line under/over the
+ # element, ChemDraw-style, instead of forcing a horizontal layout.
+ vectors = connected_atom_unit_vectors(model, atom_id)
+ open_x, open_y = _open_direction(vectors)
+ if abs(open_y) > abs(open_x):
+ hydrogens_below = open_y > 0.0
+ v_direction = 1.0 if hydrogens_below else -1.0
+ # Mirror of the horizontal guard below: keep full-box clearance when
+ # a bond runs almost straight along the hydrogen direction.
+ if any(dy * v_direction > 0.95 for _, dy in vectors):
+ return text, None, False, None
+ return text, element, False, hydrogens_below
+ face_left = open_x < 0.0
+ # Only when a bond runs almost straight along that horizontal direction
+ # (within ~18 degrees) would the hydrogens sit on top of it; keep the
+ # label centred with full-box clearance there. Ordinary diagonal
+ # ring/chain neighbours -- a regular hexagon N-H has bonds near
+ # (+-0.866, 0.5) -- stay anchored.
+ h_direction = -1.0 if face_left else 1.0
+ if any(dx * h_direction > 0.95 for dx, _ in vectors):
+ return text, None, False, None
+ display = hydride_display_text(element, h_count, face_left=face_left)
+ return display, element, face_left, None
+
+
+def _token_anchor_layout(
+ model: MoleculeModel, atom_id: int, text: str
+) -> tuple[str, str | None, bool, bool | None]:
+ # Multi-part labels ("CF3", "Ph3P", "OMe") anchor on their attachment
+ # group, so that glyph sits on the atom and bonds trim to it instead of
+ # clearing the whole label box. When the attachment group is typed on
+ # the side away from the bonds ("CF3" or "OTs" approached from the
+ # right), the display text reverses group-wise ("F3C", "TsO"),
+ # ChemDraw-style, without touching the stored label. When the
+ # attachment end is unknowable, the token facing the bonds anchors
+ # as typed. Known reversible labels retain their attachment anchor
+ # even on a vertical open side; an exactly vertical bond preserves the
+ # typed order. Unknown or unreversible vertical labels, and a bond
+ # running along the label body, keep the centred full-clearance layout.
+ vectors = connected_atom_unit_vectors(model, atom_id)
+ if not vectors:
+ # With no attachment direction there is no chemical reason to
+ # reverse the user's text or select one end as the bond anchor.
+ return text, None, False, None
+ open_x, open_y = _open_direction(vectors)
+ # Compact alkyl names have no explicit attachment-carbon glyph and
+ # cannot reverse as element groups. Keep their conventional spelling,
+ # but align the facing terminal glyph rather than the whole word.
+ if text in {"tBu", "t-Bu", "i-Pr"} and abs(open_x) >= 1e-6:
+ at_end = open_x < 0.0
+ body_direction = -1.0 if at_end else 1.0
+ if not any(dx * body_direction > 0.95 for dx, _ in vectors):
+ return text, text[-1] if at_end else text[0], at_end, None
+ attachment_at_end = _attachment_at_end(text)
+ if abs(open_y) > abs(open_x) and (
+ attachment_at_end is None or reversed_display_text(text) is None
+ ):
+ return text, None, False, None
+ anchor_at_end = (
+ attachment_at_end
+ if attachment_at_end is not None and abs(open_x) < 1e-6
+ else open_x < 0.0
+ )
+ display = text
+ if attachment_at_end is not None and attachment_at_end != anchor_at_end:
+ flipped = reversed_display_text(text)
+ if flipped is not None:
+ display = flipped
+ token = attachment_anchor_token(display, at_end=anchor_at_end)
+ if token is None:
+ return text, None, False, None
+ # Same guard as the horizontal hydride layout: a bond running almost
+ # straight along the label body would sit under the text.
+ body_direction = -1.0 if anchor_at_end else 1.0
+ if any(dx * body_direction > 0.95 for dx, _ in vectors):
+ return text, None, False, None
+ return display, token, anchor_at_end, None
+
+
+def _attachment_at_end(text: str) -> bool | None:
+ # The alias table is the chemistry authority: its keys are typed
+ # attachment-first, so a label matching a key attaches at the start and
+ # a label whose group-reversal matches a key ("Ph3P" -> "PPh3", "MeO"
+ # -> "OMe") attaches at the end. Unknown labels fall back to the
+ # syntactic signals; None means the end is genuinely ambiguous.
+ # Generic substituent R is not an expandable molecular alias. Its
+ # display still has an unambiguous oxygen attachment in OR / RO.
+ if text in {"OR", "RO"}:
+ return text == "RO"
+ if text in ATOM_ALIAS_DEFINITIONS:
+ return False
+ flipped = reversed_display_text(text)
+ if flipped is not None and flipped in ATOM_ALIAS_DEFINITIONS:
+ return True
+ return attachment_group_at_end(text)
+
+
+def mark_dimensions(
+ kind: str, line_width: float, font_height: float
+) -> tuple[float, float, float]:
+ """Native radical radius or circled-charge radius, stroke and symbol extent."""
+ if kind == "radical":
+ return max(1.2, line_width * 0.7), 0.0, 0.0
+ radius = max(4.0, font_height * 0.26)
+ return radius, max(0.9, line_width * 0.65), radius * 0.48
+
+
__all__ = [
"STACK_GAP_RATIO",
"SUB_DROP_RATIO",
@@ -436,10 +622,12 @@ def _ink_below_baseline(line: LabelLayout) -> float:
"LabelLayout",
"LabelRun",
"PlacedRun",
+ "atom_label_presentation",
"attachment_anchor_token",
"attachment_group_at_end",
"hydride_display_text",
"hydride_hydrogen_text",
+ "mark_dimensions",
"parse_atom_label",
"place_hydride_stack",
"place_runs",
diff --git a/app/chemvas/features/annotations/transforms.py b/app/chemvas/features/annotations/transforms.py
index 1f98a1f6..abca799a 100644
--- a/app/chemvas/features/annotations/transforms.py
+++ b/app/chemvas/features/annotations/transforms.py
@@ -14,6 +14,8 @@
TSBracket,
mirrored_arc_kind,
)
+from chemvas.domain.document.notes import Note
+from chemvas.domain.document.orbitals import Orbital
from .arrow_label import arrow_label_normal
@@ -36,12 +38,24 @@ def rotate_annotation(
) -> Arrow: ...
+@overload
+def rotate_annotation(
+ record: Orbital, *, center: tuple[float, float], angle_degrees: float
+) -> Orbital: ...
+
+
+@overload
def rotate_annotation(
- record: Shape | TSBracket | Arrow,
+ record: Note, *, center: tuple[float, float], angle_degrees: float
+) -> Note: ...
+
+
+def rotate_annotation(
+ record: Shape | TSBracket | Arrow | Orbital | Note,
*,
center: tuple[float, float],
angle_degrees: float,
-) -> Shape | TSBracket | Arrow:
+) -> Shape | TSBracket | Arrow | Orbital | Note:
angle = math.radians(angle_degrees)
cos_a, sin_a = math.cos(angle), math.sin(angle)
@@ -49,6 +63,18 @@ def rotate(point: tuple[float, float]) -> tuple[float, float]:
dx, dy = point[0] - center[0], point[1] - center[1]
return center[0] + dx * cos_a - dy * sin_a, center[1] + dx * sin_a + dy * cos_a
+ if isinstance(record, Orbital):
+ return replace(
+ record,
+ center=rotate(record.center),
+ rotation=(record.rotation + angle_degrees) % 360.0,
+ )
+ if isinstance(record, Note):
+ # A note turns about its position, its QGraphicsItem transform origin.
+ x, y = rotate((record.x, record.y))
+ return replace(
+ record, x=x, y=y, rotation=(record.rotation + angle_degrees) % 360.0
+ )
if isinstance(record, Arrow):
return replace(
record,
@@ -81,9 +107,18 @@ def flip_annotation(
) -> Arrow: ...
+@overload
+def flip_annotation(
+ record: Orbital, *, center: tuple[float, float], horizontal: bool
+) -> Orbital: ...
+
+
def flip_annotation(
- record: Shape | TSBracket | Arrow, *, center: tuple[float, float], horizontal: bool
-) -> Shape | TSBracket | Arrow:
+ record: Shape | TSBracket | Arrow | Orbital,
+ *,
+ center: tuple[float, float],
+ horizontal: bool,
+) -> Shape | TSBracket | Arrow | Orbital:
def flip(point: tuple[float, float]) -> tuple[float, float]:
return (
(2 * center[0] - point[0], point[1])
@@ -91,6 +126,15 @@ def flip(point: tuple[float, float]) -> tuple[float, float]:
else (point[0], 2 * center[1] - point[1])
)
+ if isinstance(record, Orbital):
+ x, y = record.center
+ return replace(
+ record,
+ center=(center[0] - (x - center[0]), y)
+ if horizontal
+ else (x, center[1] - (y - center[1])),
+ rotation=180.0 - record.rotation if horizontal else -record.rotation,
+ )
if not isinstance(record, Arrow):
first = flip((record.left, record.top))
second = flip((record.right, record.bottom))
@@ -125,3 +169,37 @@ def flip(point: tuple[float, float]) -> tuple[float, float]:
mirrored=not record.mirrored if equilibrium else record.mirrored,
labels=labels,
)
+
+
+def orbited_box_position(
+ position: tuple[float, float],
+ size: tuple[float, float],
+ *,
+ center: tuple[float, float],
+ angle_degrees: float,
+) -> tuple[float, float]:
+ """Images stay upright: their box's center orbits the pivot."""
+ x, y = position
+ box_x, box_y = x + size[0] * 0.5, y + size[1] * 0.5
+ radians = math.radians(angle_degrees)
+ cos_a, sin_a = math.cos(radians), math.sin(radians)
+ dx, dy = box_x - center[0], box_y - center[1]
+ return (
+ x + center[0] + dx * cos_a - dy * sin_a - box_x,
+ y + center[1] + dx * sin_a + dy * cos_a - box_y,
+ )
+
+
+def mirrored_box_position(
+ position: tuple[float, float],
+ bounds: tuple[float, float, float, float],
+ *,
+ center: tuple[float, float],
+ horizontal: bool,
+) -> tuple[float, float]:
+ """Notes and images stay upright: only their scene box's center mirrors."""
+ x, y = position
+ left, top, width, height = bounds
+ if horizontal:
+ return x + 2 * (center[0] - (left + width * 0.5)), y
+ return x, y + 2 * (center[1] - (top + height * 0.5))
diff --git a/app/chemvas/features/export/dialog.py b/app/chemvas/features/export/dialog.py
index 0fbacf78..39b20282 100644
--- a/app/chemvas/features/export/dialog.py
+++ b/app/chemvas/features/export/dialog.py
@@ -18,6 +18,7 @@
("PDF - vector", "pdf", ".pdf"),
("PNG - raster", "png", ".png"),
("TIFF - raster", "tiff", ".tiff"),
+ ("CDXML - ChemDraw XML (not verified in ChemDraw)", "cdxml", ".cdxml"),
)
DPI_OPTIONS: tuple[int, ...] = (150, 300, 600, 1200)
@@ -43,12 +44,19 @@
)
_RASTER_FORMATS = frozenset({"png", "tiff"})
-_SUFFIX = {"svg": ".svg", "pdf": ".pdf", "png": ".png", "tiff": ".tiff"}
+_SUFFIX = {
+ "svg": ".svg",
+ "pdf": ".pdf",
+ "png": ".png",
+ "tiff": ".tiff",
+ "cdxml": ".cdxml",
+}
_FILTER = {
"svg": "SVG (*.svg)",
"pdf": "PDF (*.pdf)",
"png": "PNG (*.png)",
"tiff": "TIFF (*.tif *.tiff)",
+ "cdxml": "CDXML (*.cdxml)",
}
# Every suffix this dialog can write. A typed suffix from this set names a
@@ -59,6 +67,7 @@
"pdf": frozenset({".pdf"}),
"png": frozenset({".png"}),
"tiff": frozenset({".tif", ".tiff"}),
+ "cdxml": frozenset({".cdxml"}),
}
_KNOWN_SUFFIXES = frozenset(
suffix for suffixes in _FORMAT_SUFFIXES.values() for suffix in suffixes
diff --git a/app/chemvas/features/graph/__init__.py b/app/chemvas/features/graph/__init__.py
index daf89212..06b1668d 100644
--- a/app/chemvas/features/graph/__init__.py
+++ b/app/chemvas/features/graph/__init__.py
@@ -28,9 +28,11 @@
from __future__ import annotations
+import math
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
+from chemvas.domain.document import Bond
from chemvas.features.graph.algorithms import (
adjacency_for_bonds,
axis_from_rotation_hint_policy,
@@ -40,11 +42,20 @@
preferred_rotation_side_for_bond_policy,
reachable_component_without_edge,
reachable_from,
+ selected_ring_cycles,
)
if TYPE_CHECKING:
- from collections.abc import Callable, Iterable, MutableMapping, Sequence
+ from collections.abc import (
+ Callable,
+ Collection,
+ Iterable,
+ Mapping,
+ MutableMapping,
+ Sequence,
+ )
+ from chemvas.domain.document import MoleculeModel
from chemvas.features.graph.algorithms import BondLike
@@ -70,6 +81,77 @@ def reset(self) -> None:
self.bond_cycle_cache = {}
+def connected_atom_unit_vectors(
+ model: MoleculeModel, atom_id: int
+) -> list[tuple[float, float]]:
+ atom = model.atoms.get(atom_id)
+ if atom is None:
+ return []
+ vectors = []
+ for bond in model.bonds:
+ if bond is None or (bond.a != atom_id and bond.b != atom_id):
+ continue
+ other = model.atoms.get(bond.b if bond.a == atom_id else bond.a)
+ if other is None:
+ continue
+ dx, dy = other.x - atom.x, other.y - atom.y
+ length = math.hypot(dx, dy)
+ if length > 1e-9:
+ vectors.append((dx / length, dy / length))
+ return vectors
+
+
+def build_ring_edge_index(
+ atom_ids: Collection[int],
+ bonds: Iterable[Bond | None],
+) -> dict[tuple[int, int], list[int]]:
+ """The scene's canonical topology order, preferring rings of full bonds.
+
+ Dotted partial bonds (e.g. forming bonds in a transition state) can close a
+ pseudo-ring through an aromatic edge; that edge still belongs to its ring of
+ full bonds, so its inward double bond keeps facing the benzene centre.
+ """
+ live = [
+ edge
+ for edge in bonds
+ if edge is not None and edge.a in atom_ids and edge.b in atom_ids
+ ]
+ topology = {(min(edge.a, edge.b), max(edge.a, edge.b)) for edge in live}
+ partial = {
+ (min(edge.a, edge.b), max(edge.a, edge.b))
+ for edge in live
+ if edge.style == "dotted"
+ }
+ rings = find_rings(Bond(a, b) for a, b in sorted(topology))
+ by_edge: dict[tuple[int, int], list[int]] = {}
+ partial_counts: dict[tuple[int, int], int] = {}
+ for ring in rings:
+ edges = [
+ (min(a, b), max(a, b))
+ for a, b in zip(ring, [*ring[1:], ring[0]], strict=True)
+ ]
+ count = sum(edge in partial for edge in edges)
+ for edge in edges:
+ if edge not in by_edge or count < partial_counts[edge]:
+ by_edge[edge], partial_counts[edge] = list(ring), count
+ return by_edge
+
+
+def ring_atom_ids_for_bond(
+ bond: BondLike,
+ preferred_rings: Iterable[Sequence[int]],
+ topology: Mapping[tuple[int, int], list[int]],
+) -> list[int] | None:
+ """Prefer the first live document ring; fall back to cached graph topology."""
+ for atom_ids in preferred_rings:
+ if any(
+ {atom_ids[index], atom_ids[(index + 1) % len(atom_ids)]} == {bond.a, bond.b}
+ for index in range(len(atom_ids))
+ ):
+ return list(atom_ids)
+ return topology.get((min(bond.a, bond.b), max(bond.a, bond.b)))
+
+
def ensure_neighbor_entry(
atom_neighbors: MutableMapping[int, set[int]],
atom_id: int,
@@ -304,7 +386,9 @@ def cached_bond_in_cycle(
"bond_matches_atoms",
"bond_sets_for_atom_ids",
"build_bond_adjacency_index",
+ "build_ring_edge_index",
"cached_bond_in_cycle",
+ "connected_atom_unit_vectors",
"connected_components_for_nodes",
"edge_has_reachable_alternative_path",
"ensure_bond_index_entry",
@@ -316,4 +400,6 @@ def cached_bond_in_cycle(
"reachable_from",
"remove_bond_from_atom_index",
"remove_neighbor_edge",
+ "ring_atom_ids_for_bond",
+ "selected_ring_cycles",
]
diff --git a/app/chemvas/features/graph/algorithms.py b/app/chemvas/features/graph/algorithms.py
index 38de055a..0cf800d7 100644
--- a/app/chemvas/features/graph/algorithms.py
+++ b/app/chemvas/features/graph/algorithms.py
@@ -238,6 +238,20 @@ def _fundamental_cycle_candidates(
return cycles
+def selected_ring_cycles(
+ bonds: Sequence[BondLike | None], atom_ids: set[int], bond_ids: set[int]
+) -> list[list[int]]:
+ """A complete atom selection OR a complete bond selection qualifies.
+
+ Combining their endpoints would invent unselected cycle edges.
+ """
+ atom_bonds = [
+ bond for bond in bonds if bond is not None and {bond.a, bond.b} <= atom_ids
+ ]
+ rings = find_rings(atom_bonds) + find_rings(bonds[i] for i in sorted(bond_ids))
+ return list({frozenset(ring): ring for ring in rings}.values())
+
+
def find_rings(bonds: Iterable[BondLike | None]) -> list[list[int]]:
"""Smallest set of smallest rings for a bond graph.
diff --git a/app/chemvas/features/groups/__init__.py b/app/chemvas/features/groups/__init__.py
new file mode 100644
index 00000000..9e4ccc9b
--- /dev/null
+++ b/app/chemvas/features/groups/__init__.py
@@ -0,0 +1,277 @@
+"""Group membership rules shared by the desktop canvas and the browser adapter.
+
+A group holds whole molecules (atom ids) and standalone annotations (record
+ids). Callers own the id spaces; these rules only compare and combine them.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from chemvas.domain.document.groups import SceneGroup
+from chemvas.domain.document.schema import VALID_ARROW_KINDS
+from chemvas.features.graph import (
+ adjacency_for_bonds,
+ connected_components_for_nodes,
+ reachable_from,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Mapping, Sequence
+
+ from chemvas.domain.document import Bond
+
+GROUPABLE_STANDALONE_KINDS = frozenset(
+ {"note", "image", "ts_bracket", "shape", "orbital"}
+) | frozenset(VALID_ARROW_KINDS)
+
+# Saved group item references name these state collections.
+GROUP_ITEM_COLLECTIONS = (
+ "images",
+ "notes",
+ "marks",
+ "arrows",
+ "ts_brackets",
+ "shapes",
+ "orbitals",
+)
+
+GROUP_CONNECTION_MESSAGE = (
+ "These structures belong to different groups. Select both structures, "
+ "use Edit > Group, then retry the connection."
+)
+
+GROUP_SIZE_MESSAGE = (
+ "Group needs at least two objects: a molecule is one object. "
+ "Select its caption or another object too, then use Edit > Group."
+)
+
+
+def group_ids_for_members(
+ groups: Mapping[int, SceneGroup],
+ atom_ids: set[int],
+ item_ids: Iterable[int],
+) -> set[int]:
+ """Groups that hold any of the atoms or items."""
+ items = set(item_ids)
+ return {
+ group_id
+ for group_id, group in groups.items()
+ if group.atom_ids & atom_ids or items.intersection(group.item_ids)
+ }
+
+
+def connection_allowed(
+ groups: Mapping[int, SceneGroup],
+ bonds: Iterable[Bond | None],
+ anchors: set[int],
+) -> bool:
+ """Whether joining the anchors' molecules keeps every molecule in one group."""
+ if len(groups) < 2:
+ return True
+ connected = reachable_from(anchors, adjacency_for_bonds(bonds))
+ return len(group_ids_for_members(groups, connected, ())) < 2
+
+
+def growth_anchors(
+ bonds: Sequence[Bond | None],
+ *,
+ atom_id: int | None = None,
+ bond_id: int | None = None,
+) -> set[int]:
+ """Atoms a growth shortcut attaches to: the hovered atom or bond ends."""
+ anchors = {atom_id} if atom_id is not None else set()
+ if bond_id is not None and 0 <= bond_id < len(bonds):
+ bond = bonds[bond_id]
+ if bond is not None:
+ anchors.update((bond.a, bond.b))
+ return anchors
+
+
+def group_extensions(
+ groups: Mapping[int, SceneGroup],
+ bonds: Sequence[Bond | None],
+ bond_ids: Iterable[int],
+) -> dict[int, set[int]]:
+ """New atom sets for groups whose molecule new bonds connected to more atoms.
+
+ Raises ``ValueError`` when a new bond joined two groups' molecules.
+ """
+ if not groups:
+ return {}
+ touched = {
+ atom_id
+ for bond_id in bond_ids
+ if (bond := bonds[bond_id]) is not None
+ for atom_id in (bond.a, bond.b)
+ }
+ adjacency = adjacency_for_bonds(bonds)
+ updates: dict[int, set[int]] = {}
+ while touched:
+ component = reachable_from({min(touched)}, adjacency)
+ touched -= component
+ owners = group_ids_for_members(groups, component, ())
+ if len(owners) > 1:
+ raise ValueError(GROUP_CONNECTION_MESSAGE)
+ if owners:
+ owner = next(iter(owners))
+ updates.setdefault(owner, set(groups[owner].atom_ids)).update(component)
+ return {
+ group_id: atom_ids
+ for group_id, atom_ids in sorted(updates.items())
+ if atom_ids != groups[group_id].atom_ids
+ }
+
+
+def group_atoms_after_merge(
+ groups: Mapping[int, SceneGroup],
+ bonds: Iterable[Bond | None],
+ survivor_id: int,
+ removed_atom_ids: set[int],
+) -> tuple[int, set[int]] | None:
+ """The one group that keeps a merged atom's molecule, with its new atoms.
+
+ Raises ``ValueError`` when the merge joined two groups' molecules.
+ """
+ if not groups or not removed_atom_ids:
+ return None
+ component = reachable_from({survivor_id}, adjacency_for_bonds(bonds))
+ owners = group_ids_for_members(groups, component | removed_atom_ids, ())
+ if len(owners) > 1:
+ raise ValueError(GROUP_CONNECTION_MESSAGE)
+ if not owners:
+ return None
+ group_id = next(iter(owners))
+ previous = groups[group_id].atom_ids
+ atom_ids = (previous - removed_atom_ids) | component
+ return None if atom_ids == previous else (group_id, atom_ids)
+
+
+@dataclass(frozen=True, slots=True)
+class GroupingPlan:
+ atom_ids: set[int]
+ item_ids: list[int]
+ absorbed: list[int]
+
+
+def grouping_plan(
+ groups: Mapping[int, SceneGroup],
+ bonds: Iterable[Bond | None],
+ atom_ids: set[int],
+ item_ids: Sequence[int],
+) -> GroupingPlan | None:
+ """Plan Edit > Group for the selected atoms and standalone items.
+
+ The group takes whole molecules and absorbs every group it overlaps. None
+ means nothing changes. Raises ``ValueError`` when fewer than two objects
+ would form a new group.
+ """
+ if not atom_ids and not item_ids:
+ return None
+ # A persistent group owns whole molecules; a group of partial molecules
+ # would stretch bonds to ungrouped atoms when dragged.
+ adjacency = adjacency_for_bonds(bonds)
+ atom_ids = reachable_from(atom_ids, adjacency)
+ overlapping = group_ids_for_members(groups, atom_ids, item_ids)
+ if (
+ not overlapping
+ and len(connected_components_for_nodes(atom_ids, adjacency)) + len(item_ids) < 2
+ ):
+ raise ValueError(GROUP_SIZE_MESSAGE)
+ merged_atom_ids = set(atom_ids)
+ merged_items = list(item_ids)
+ absorbed_ids: set[int] = set()
+ # Older documents may hold partial-molecule groups; absorbing one also
+ # absorbs groups its remaining molecule atoms reach.
+ while remaining := overlapping - absorbed_ids:
+ for group_id in sorted(remaining):
+ for member in groups[group_id].item_ids:
+ if member not in merged_items:
+ merged_items.append(member)
+ merged_atom_ids |= groups[group_id].atom_ids
+ absorbed_ids |= remaining
+ merged_atom_ids = reachable_from(merged_atom_ids, adjacency)
+ overlapping = group_ids_for_members(groups, merged_atom_ids, merged_items)
+ if len(overlapping) == 1:
+ existing = groups[next(iter(overlapping))]
+ if merged_atom_ids <= existing.atom_ids and all(
+ item in existing.item_ids for item in merged_items
+ ):
+ return None
+ return GroupingPlan(merged_atom_ids, merged_items, sorted(overlapping))
+
+
+def snapshot_groups(
+ groups: Mapping[int, SceneGroup],
+ live_atom_ids: Iterable[int],
+ item_refs: Mapping[int, tuple[str, int]],
+) -> list[dict[str, Any]]:
+ """Saved group records, keeping only live members.
+
+ Groups are disjoint at runtime; the first group keeps a member that drifted
+ into two, since overlapping members fail validation. Emptied groups go.
+ """
+ live = set(live_atom_ids)
+ saved: list[dict[str, Any]] = []
+ seen_atoms: set[int] = set()
+ seen_refs: set[tuple[str, int]] = set()
+ for group_id in sorted(groups):
+ group = groups[group_id]
+ atoms = sorted(
+ atom_id
+ for atom_id in group.atom_ids
+ if atom_id in live and atom_id not in seen_atoms
+ )
+ refs = [
+ item_refs[item]
+ for item in group.item_ids
+ if item in item_refs and item_refs[item] not in seen_refs
+ ]
+ if not atoms and not refs:
+ continue
+ seen_atoms.update(atoms)
+ seen_refs.update(refs)
+ saved.append({"atoms": atoms, "items": [list(ref) for ref in refs]})
+ return saved
+
+
+def restored_groups(
+ records: Iterable[Mapping[str, object]],
+ live_atom_ids: Iterable[int],
+ item_lists: Mapping[str, Sequence[int]],
+) -> list[SceneGroup]:
+ """Runtime groups for saved records, in saved order, skipping empty ones."""
+ live = set(live_atom_ids)
+ restored = []
+ for record in records:
+ atom_ids = {
+ int(atom_id)
+ for atom_id in record.get("atoms", []) # type: ignore[attr-defined]
+ if int(atom_id) in live
+ }
+ items = []
+ for kind, index in record.get("items", []): # type: ignore[attr-defined]
+ candidates = item_lists.get(kind, ())
+ if 0 <= index < len(candidates):
+ items.append(candidates[index])
+ if atom_ids or items:
+ restored.append(SceneGroup(atom_ids, items))
+ return restored
+
+
+__all__ = [
+ "GROUPABLE_STANDALONE_KINDS",
+ "GROUP_CONNECTION_MESSAGE",
+ "GROUP_ITEM_COLLECTIONS",
+ "GROUP_SIZE_MESSAGE",
+ "GroupingPlan",
+ "connection_allowed",
+ "group_atoms_after_merge",
+ "group_extensions",
+ "group_ids_for_members",
+ "grouping_plan",
+ "growth_anchors",
+ "restored_groups",
+ "snapshot_groups",
+]
diff --git a/app/chemvas/features/hover/__init__.py b/app/chemvas/features/hover/__init__.py
index 1322dece..303b7d5e 100644
--- a/app/chemvas/features/hover/__init__.py
+++ b/app/chemvas/features/hover/__init__.py
@@ -8,6 +8,16 @@
if TYPE_CHECKING:
from chemvas.features.selection import StructureHit
+PREVIEW_COLOR_RGBA = (120, 120, 120, 140)
+# Insert previews (templates, SMILES) draw at this opacity on top of the color.
+PREVIEW_OPACITY = 0.5
+HOVER_PREVIEW_OPACITY = 0.55
+HOVER_PREVIEW_Z = 4.5
+ATOM_HOVER_Z = 5.0
+ATOM_HOVER_RADIUS_RATIO = 0.25
+ATOM_HOVER_PEN_RGBA = (13, 148, 136, 150)
+ATOM_HOVER_BRUSH_RGBA = (13, 148, 136, 30)
+
HoverAction = Literal["clear", "free_bond_preview", "atom_hit", "bond_hit", "noop"]
@@ -89,6 +99,14 @@ def plan_structure_hover_update(
__all__ = [
+ "ATOM_HOVER_BRUSH_RGBA",
+ "ATOM_HOVER_PEN_RGBA",
+ "ATOM_HOVER_RADIUS_RATIO",
+ "ATOM_HOVER_Z",
+ "HOVER_PREVIEW_OPACITY",
+ "HOVER_PREVIEW_Z",
+ "PREVIEW_COLOR_RGBA",
+ "PREVIEW_OPACITY",
"HoverState",
"HoverUpdatePlan",
"plan_structure_hover_update",
diff --git a/app/chemvas/features/insertion/__init__.py b/app/chemvas/features/insertion/__init__.py
index 86977c0e..4ce4fc89 100644
--- a/app/chemvas/features/insertion/__init__.py
+++ b/app/chemvas/features/insertion/__init__.py
@@ -1,6 +1,8 @@
"""Structure, template, and SMILES insertion planning."""
from .smiles import (
+ MAX_SMILES_INPUT_LENGTH,
+ SMILES_RENDER_ERROR,
SmilesAtomPlacement,
SmilesBondPlacement,
SmilesCommitPlan,
@@ -8,6 +10,7 @@
annotation_mark_direction,
annotation_mark_kinds,
normalized_atom_annotation,
+ normalized_smiles_input,
plan_smiles_commit,
smiles_preview_center,
smiles_preview_offset,
@@ -19,6 +22,8 @@
build_submodel,
expand_atom_ids_for_structure,
model_with_atom_annotations,
+ opposite_charge_mark,
+ plan_mark_rebind,
)
from .template import (
Point2D,
@@ -39,6 +44,8 @@
)
__all__ = [
+ "MAX_SMILES_INPUT_LENGTH",
+ "SMILES_RENDER_ERROR",
"Point2D",
"SmilesAtomPlacement",
"SmilesBondPlacement",
@@ -61,6 +68,9 @@
"model_with_atom_annotations",
"normalize_template_ring_style",
"normalized_atom_annotation",
+ "normalized_smiles_input",
+ "opposite_charge_mark",
+ "plan_mark_rebind",
"plan_smiles_commit",
"plan_template_commit",
"plan_template_preview",
diff --git a/app/chemvas/features/insertion/smiles.py b/app/chemvas/features/insertion/smiles.py
index 57e23630..605b243d 100644
--- a/app/chemvas/features/insertion/smiles.py
+++ b/app/chemvas/features/insertion/smiles.py
@@ -11,6 +11,19 @@
Point2D = tuple[float, float]
+MAX_SMILES_INPUT_LENGTH = 1024
+SMILES_RENDER_ERROR = "Failed to render SMILES."
+
+
+def normalized_smiles_input(value: str) -> str:
+ """Use the same bounded input before either presentation invokes RDKit."""
+ text = value.strip()
+ if len(text) > MAX_SMILES_INPUT_LENGTH:
+ raise ValueError(
+ f"SMILES input is too long (maximum {MAX_SMILES_INPUT_LENGTH} characters)."
+ )
+ return text
+
@dataclass(frozen=True, kw_only=True)
class SmilesAtomPlacement:
@@ -209,6 +222,8 @@ def smiles_preview_offset(preview_center: Point2D, cursor_pos: Point2D) -> Point
__all__ = [
+ "MAX_SMILES_INPUT_LENGTH",
+ "SMILES_RENDER_ERROR",
"SmilesAtomPlacement",
"SmilesBondPlacement",
"SmilesCommitPlan",
@@ -216,6 +231,7 @@ def smiles_preview_offset(preview_center: Point2D, cursor_pos: Point2D) -> Point
"annotation_mark_direction",
"annotation_mark_kinds",
"normalized_atom_annotation",
+ "normalized_smiles_input",
"plan_smiles_commit",
"smiles_preview_center",
"smiles_preview_offset",
diff --git a/app/chemvas/features/insertion/structure_payload.py b/app/chemvas/features/insertion/structure_payload.py
index 0b9d56e6..ddb856a5 100644
--- a/app/chemvas/features/insertion/structure_payload.py
+++ b/app/chemvas/features/insertion/structure_payload.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from collections.abc import Callable, Collection, Iterable, Mapping
+from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
from chemvas.domain.document import Bond, MoleculeModel
@@ -208,3 +210,87 @@ def _normalized_atom_annotations(
if annotation:
annotations[int(atom_id)] = annotation
return annotations
+
+
+@dataclass(frozen=True)
+class MarkRebindPlan:
+ old_id: int | None
+ before_marks: dict[int, tuple[Any, ...]]
+ after_marks: dict[int, tuple[Any, ...]]
+ before_annotations: AtomAnnotations
+ after_annotations: AtomAnnotations
+
+
+def plan_mark_rebind(
+ model: MoleculeModel,
+ item: Any,
+ atom_id: int,
+ marks_by_atom: Mapping[int, Sequence[Any]],
+) -> MarkRebindPlan | None:
+ """Validate explicit ownership transfer without changing glyph or model state."""
+ if type(atom_id) is not int or model.atom_for_id(atom_id) is None:
+ raise ValueError("Choose an existing atom in this document.")
+ old_id = (item.data(1) or {}).get("atom_id")
+ if atom_id == old_id:
+ return None
+ if old_id is not None and model.atom_for_id(old_id) is None:
+ raise ValueError("The mark's original atom no longer exists.")
+ affected_ids = {atom_id} | ({old_id} if old_id is not None else set())
+ before_marks = {key: tuple(marks_by_atom.get(key) or ()) for key in affected_ids}
+ if old_id is not None and item not in before_marks[old_id]:
+ raise ValueError(
+ "The mark's binding is inconsistent; reload the document before reassigning."
+ )
+ annotations = model.atom_annotations
+ before_annotations = {
+ key: dict(annotations[key]) for key in affected_ids if key in annotations
+ }
+ expected = build_atom_annotations(
+ affected_ids,
+ {key: key for key in affected_ids},
+ {
+ key: [(mark.data(1) or {})["kind"] for mark in items]
+ for key, items in before_marks.items()
+ },
+ )
+ normalized = {
+ key: {k: v for k, v in value.items() if v}
+ for key, value in before_annotations.items()
+ }
+ normalized = {key: value for key, value in normalized.items() if value}
+ if normalized != expected:
+ raise ValueError(
+ "Atom annotations and marks disagree; resolve them before reassigning a mark."
+ )
+ after_marks = dict(before_marks)
+ if old_id is not None:
+ after_marks[old_id] = tuple(
+ mark for mark in before_marks[old_id] if mark is not item
+ )
+ after_marks[atom_id] = (*before_marks[atom_id], item)
+ after_annotations = build_atom_annotations(
+ affected_ids,
+ {key: key for key in affected_ids},
+ {
+ key: [(mark.data(1) or {})["kind"] for mark in items]
+ for key, items in after_marks.items()
+ },
+ )
+ return MarkRebindPlan(
+ old_id, before_marks, after_marks, before_annotations, after_annotations
+ )
+
+
+def opposite_charge_mark(items: Sequence[Any], delta: int) -> Any | None:
+ """A charge shortcut cancels the last opposite mark before adding a new one."""
+ if delta not in {-1, 1}:
+ raise ValueError("Charge shortcuts require a change of +1 or -1.")
+ opposite = {"minus", "circled_minus"} if delta > 0 else {"plus", "circled_plus"}
+ return next(
+ (
+ item
+ for item in reversed(items)
+ if (item.data(1) or {}).get("kind") in opposite
+ ),
+ None,
+ )
diff --git a/app/chemvas/features/rendering/__init__.py b/app/chemvas/features/rendering/__init__.py
index 170af0cb..cb837134 100644
--- a/app/chemvas/features/rendering/__init__.py
+++ b/app/chemvas/features/rendering/__init__.py
@@ -1,11 +1,12 @@
"""Bond rendering policy and Qt geometry primitives."""
-from .acs1996_style import ACS1996Style
+from .acs1996_style import ACS1996Style, RenderMetrics
from .bond_dotted import dotted_bond_dot_centers
from .bond_geometry import (
LineSegment,
bold_double_strip_geometry,
line_intersection,
+ line_normal,
normal_away_from_parallel_segment,
normalize_3d,
offset_segment,
@@ -47,10 +48,29 @@
style_for_existing_bond_overlay,
)
from .line_geometry import (
+ ENDPOINT_SNAP_SCREEN_PX,
+ LINE_ANGLE_STEP_DEGREES,
+ SNAP_MARK_PEN_SCREEN_PX,
+ SNAP_MARK_SCREEN_PX,
arc_midpoint,
arc_points,
+ arrow_head_polylines,
+ arrow_path_commands,
+ arrow_with_moved_endpoint,
+ clamp_curved_midpoint,
+ control_from_midpoint,
+ control_with_moved_end,
+ curved_control_point,
+ curved_midpoint,
+ endpoint_connection,
+ grid_lines,
hex_grid_cells,
+ line_click_endpoint,
nearest_endpoint,
+ new_arrow_record,
+ normalized_arrow_control,
+ points_on_endpoints,
+ snapped_drawing_point,
snapped_endpoint,
snapped_line_end,
snapped_to_grid,
@@ -69,37 +89,58 @@
"DOUBLE_STYLE_CENTER",
"DOUBLE_STYLE_DEFAULT",
"DOUBLE_STYLE_OUTER",
+ "ENDPOINT_SNAP_SCREEN_PX",
+ "LINE_ANGLE_STEP_DEGREES",
"PLAIN_DOUBLE_STYLES",
+ "SNAP_MARK_PEN_SCREEN_PX",
+ "SNAP_MARK_SCREEN_PX",
"STANDARD_BOND_STYLES",
"ACS1996Style",
"LineSegment",
+ "RenderMetrics",
"ValenceWarningCache",
"arc_midpoint",
"arc_points",
+ "arrow_head_polylines",
+ "arrow_path_commands",
+ "arrow_with_moved_endpoint",
"base_plain_double_style_for_dotted_variant",
"bold_double_strip_geometry",
"bold_double_style_for_position",
"bold_double_style_for_style",
+ "clamp_curved_midpoint",
+ "control_from_midpoint",
+ "control_with_moved_end",
+ "curved_control_point",
+ "curved_midpoint",
"cycle_plain_bond_style",
"dotted_bond_dot_centers",
"dotted_double_variant_for_style",
"double_position_for_style",
+ "endpoint_connection",
+ "grid_lines",
"hash_segments_from_segment",
"hex_grid_cells",
"is_bold_double_bond_style",
"is_dotted_double_bond_style",
"is_plain_double_bond_style",
"is_positionable_double_bond_style",
+ "line_click_endpoint",
"line_intersection",
+ "line_normal",
"nearest_endpoint",
+ "new_arrow_record",
"normal_away_from_parallel_segment",
"normalize_3d",
+ "normalized_arrow_control",
"normalized_bold_double_style",
"normalized_plain_double_style",
"offset_segment",
"overvalent_atom_ids",
"plain_double_preview_segments",
+ "points_on_endpoints",
"refresh_bond_graphics",
+ "snapped_drawing_point",
"snapped_endpoint",
"snapped_line_end",
"snapped_to_grid",
diff --git a/app/chemvas/features/rendering/acs1996_style.py b/app/chemvas/features/rendering/acs1996_style.py
index 033871ce..8f50b3ec 100644
--- a/app/chemvas/features/rendering/acs1996_style.py
+++ b/app/chemvas/features/rendering/acs1996_style.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
@dataclass(frozen=True, kw_only=True)
@@ -28,3 +28,38 @@ class ACS1996Style:
orbital_positive_color: str = "#2f6ed3"
orbital_negative_color: str = "#d84a3a"
orbital_alpha: float = 0.25
+
+
+class RenderMetrics:
+ """Existing desktop metric calculations, shared by rendering adapters."""
+
+ BASE_BOND_LENGTH_PX = 20.0
+
+ def __init__(self, style: ACS1996Style | None = None) -> None:
+ self.style = style or ACS1996Style()
+
+ def set_bond_length(self, length_px: float) -> None:
+ self.style = replace(self.style, bond_length_px=length_px)
+
+ def metric_scale(self) -> float:
+ if self.style.bond_length_px <= 0:
+ return 1.0
+ return self.style.bond_length_px / self.BASE_BOND_LENGTH_PX
+
+ def scaled_style_metric(self, value: float) -> float:
+ return value * self.metric_scale()
+
+ def bond_line_width(self) -> float:
+ return self.scaled_style_metric(self.style.bond_line_width)
+
+ def bold_bond_width(self) -> float:
+ return self.scaled_style_metric(self.style.bold_bond_width)
+
+ def bond_spacing(self) -> float:
+ return self.scaled_style_metric(self.style.bond_spacing_px)
+
+ def hash_spacing(self) -> float:
+ return self.scaled_style_metric(self.style.hash_spacing_px)
+
+ def atom_font_size_pt(self) -> int:
+ return max(1, round(self.scaled_style_metric(float(self.style.font_size_pt))))
diff --git a/app/chemvas/features/rendering/bond_geometry.py b/app/chemvas/features/rendering/bond_geometry.py
index 4222ca3a..695d18f8 100644
--- a/app/chemvas/features/rendering/bond_geometry.py
+++ b/app/chemvas/features/rendering/bond_geometry.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import math
+from typing import Any
from .bond_style import DOUBLE_STYLE_OUTER
@@ -135,10 +136,28 @@ def strip_corners(
)
+def line_normal(
+ x1: float, y1: float, x2: float, y2: float, target: Any = None
+) -> tuple[float, float]:
+ dx, dy = x2 - x1, y2 - y1
+ length = math.hypot(dx, dy)
+ if length < 1e-9:
+ return 0.0, 0.0
+ nx, ny = -dy / length, dx / length
+ if (
+ target is not None
+ and nx * (target.x() - (x1 + x2) * 0.5) + ny * (target.y() - (y1 + y2) * 0.5)
+ < 0
+ ):
+ return -nx, -ny
+ return nx, ny
+
+
__all__ = [
"LineSegment",
"bold_double_strip_geometry",
"line_intersection",
+ "line_normal",
"normal_away_from_parallel_segment",
"normalize_3d",
"offset_segment",
diff --git a/app/chemvas/features/rendering/line_geometry.py b/app/chemvas/features/rendering/line_geometry.py
index 51fb9228..b3368dc5 100644
--- a/app/chemvas/features/rendering/line_geometry.py
+++ b/app/chemvas/features/rendering/line_geometry.py
@@ -1,10 +1,43 @@
-"""Qt-free geometry for the free line tool: angle lock and wavy strokes."""
+"""Qt-free arrow paths, line strokes and endpoint/grid snapping."""
from __future__ import annotations
import math
+from dataclasses import replace
+
+from chemvas.domain.document import (
+ ARC_KIND_SWEEPS,
+ VALID_CURVED_ARROW_KINDS,
+ VALID_LINE_KINDS,
+ Arrow,
+)
Point2D = tuple[float, float]
+PathCommand = tuple[str, tuple[float, ...]]
+
+# Snapping is an input affordance, so its reach is a distance on screen
+# rather than in the document: an endpoint this many pixels from the cursor
+# is caught, at any zoom. A dashed connector then meets an energy level
+# exactly, and cycle arcs share corners, without aiming at a few pixels.
+ENDPOINT_SNAP_SCREEN_PX = 12.0
+# Diameter of the ring that says an end has been caught. It has to clear
+# a bold line's own width to be seen at all.
+SNAP_MARK_SCREEN_PX = 16.0
+# The ring's outline, also on screen at any zoom.
+SNAP_MARK_PEN_SCREEN_PX = 1.6
+# Shift locks the drag to multiples of this angle so energy-diagram levels and
+# connectors come out exactly horizontal, vertical or diagonal.
+LINE_ANGLE_STEP_DEGREES = 15.0
+# A click on empty canvas places a horizontal level this many bond lengths
+# long, starting at the (snapped) press point, in the active line style.
+LEVEL_PRESET_BOND_LENGTHS = 2.0
+# An endpoint drag stops here rather than collapsing an arrow or line into a
+# dot, which renders as a bare arrow head or a wavy blob. An arrow already
+# shorter than this stops at its length when the drag began instead.
+_MIN_ARROW_LENGTH_BOND_LENGTHS = 0.1
+# A curved arrow's control handle keeps the curve's midpoint within this
+# fraction of its chord from the chord's midpoint.
+_CURVE_MIDPOINT_REACH_RATIO = 0.8
# Samples per half-wave of a wavy line; enough that the polyline reads as a
# smooth sine at the on-screen and exported sizes the ACS metrics produce.
@@ -15,6 +48,135 @@
_MAX_HALF_WAVES = 2048
+def curved_midpoint(start: Point2D, control: Point2D, end: Point2D) -> Point2D:
+ # Midpoint of the quadratic curve at t = 0.5.
+ return (
+ 0.25 * start[0] + 0.5 * control[0] + 0.25 * end[0],
+ 0.25 * start[1] + 0.5 * control[1] + 0.25 * end[1],
+ )
+
+
+def control_from_midpoint(start: Point2D, end: Point2D, mid: Point2D) -> Point2D:
+ return (
+ 2.0 * mid[0] - 0.5 * (start[0] + end[0]),
+ 2.0 * mid[1] - 0.5 * (start[1] + end[1]),
+ )
+
+
+def control_with_moved_end(
+ anchor: Point2D, pressed_end: Point2D, moved_end: Point2D, control: Point2D
+) -> Point2D:
+ """``control`` carried along as a chord's free end moves about ``anchor``.
+
+ The chord from ``anchor`` to ``pressed_end`` turns and scales onto the
+ chord to ``moved_end``, and the control turns and scales with it, so the
+ curve keeps its shape. An unmoved end returns ``control`` bit for bit. A
+ chord of zero length has no direction to turn, so its control stays put.
+
+ A file can hold a bulge many times its chord, and scaling that with a
+ growing chord would throw the control out by the length ratio. So the
+ carried control reaches from the chord's midpoint no further than the
+ larger of the reach the control handle allows on the new chord and the
+ reach at the press. Every curve the control handle can draw keeps its shape.
+ """
+ pressed_x, pressed_y = pressed_end[0] - anchor[0], pressed_end[1] - anchor[1]
+ length_sq = pressed_x * pressed_x + pressed_y * pressed_y
+ if length_sq <= 0.0:
+ return control
+ moved_x, moved_y = moved_end[0] - anchor[0], moved_end[1] - anchor[1]
+ # moved / pressed as complex numbers: the turn, scaled by the length ratio.
+ # For an unmoved end the first numerator repeats ``length_sq`` exactly and
+ # the second cancels to zero.
+ scaled_cos = (moved_x * pressed_x + moved_y * pressed_y) / length_sq
+ scaled_sin = (moved_y * pressed_x - moved_x * pressed_y) / length_sq
+ offset_x, offset_y = control[0] - anchor[0], control[1] - anchor[1]
+ # Add the change to ``control`` instead of rebuilding it from ``anchor``:
+ # anchor + (control - anchor) can round away from control.
+ carried = (
+ control[0] + (scaled_cos - 1.0) * offset_x - scaled_sin * offset_y,
+ control[1] + scaled_sin * offset_x + (scaled_cos - 1.0) * offset_y,
+ )
+ # The curve's midpoint lies halfway between the chord's midpoint and the
+ # control, so the control may reach twice as far as the midpoint. An
+ # unmoved end repeats the press-time reach bit for bit and is not limited.
+ moved_mid = ((anchor[0] + moved_end[0]) / 2.0, (anchor[1] + moved_end[1]) / 2.0)
+ reach = (carried[0] - moved_mid[0], carried[1] - moved_mid[1])
+ reach_length = math.hypot(*reach)
+ pressed_reach = (
+ control[0] - (anchor[0] + pressed_end[0]) / 2.0,
+ control[1] - (anchor[1] + pressed_end[1]) / 2.0,
+ )
+ reach_limit = max(
+ 2.0 * _CURVE_MIDPOINT_REACH_RATIO * math.hypot(moved_x, moved_y),
+ math.hypot(*pressed_reach),
+ )
+ if reach_length <= reach_limit:
+ return carried
+ ratio = reach_limit / reach_length
+ return (moved_mid[0] + reach[0] * ratio, moved_mid[1] + reach[1] * ratio)
+
+
+def clamp_curved_midpoint(
+ start: Point2D,
+ end: Point2D,
+ mid: Point2D,
+ *,
+ snap_enabled: bool,
+ snap_distance: float | None,
+) -> Point2D:
+ chord_mid = ((start[0] + end[0]) / 2.0, (start[1] + end[1]) / 2.0)
+ dx, dy = end[0] - start[0], end[1] - start[1]
+ length = math.hypot(dx, dy) or 1.0
+ nx, ny = -dy / length, dx / length
+ vx, vy = mid[0] - chord_mid[0], mid[1] - chord_mid[1]
+ offset = vx * nx + vy * ny
+ if snap_enabled and snap_distance is not None and snap_distance > 0:
+ offset = round(offset / snap_distance) * snap_distance
+ max_offset = length * _CURVE_MIDPOINT_REACH_RATIO
+ offset = max(-max_offset, min(max_offset, offset))
+ return (chord_mid[0] + nx * offset, chord_mid[1] + ny * offset)
+
+
+def arrow_with_moved_endpoint(
+ record: Arrow, pressed: Arrow, moved: Point2D, endpoint: str, *, bond_length: float
+) -> Arrow:
+ """Native endpoint mutation, always based on the record at drag start."""
+ anchor, pressed_end = (
+ (pressed.end, pressed.start)
+ if endpoint == "start"
+ else (pressed.start, pressed.end)
+ )
+ min_length = min(
+ bond_length * _MIN_ARROW_LENGTH_BOND_LENGTHS,
+ math.hypot(pressed_end[0] - anchor[0], pressed_end[1] - anchor[1]),
+ )
+ if math.hypot(moved[0] - anchor[0], moved[1] - anchor[1]) < min_length:
+ return record
+ updated = replace(
+ record,
+ start=moved if endpoint == "start" else anchor,
+ end=moved if endpoint == "end" else anchor,
+ )
+ if updated.kind in VALID_CURVED_ARROW_KINDS:
+ assert pressed.control is not None
+ updated = replace(
+ updated,
+ control=control_with_moved_end(anchor, pressed_end, moved, pressed.control),
+ )
+ return updated
+
+
+def line_click_endpoint(
+ start: Point2D, *, bond_length: float, occupied: bool
+) -> Point2D | None:
+ """Native LineTool click: ignore existing objects, otherwise place a level."""
+ if occupied:
+ # A click on an existing object is a selection or a
+ # double-click gesture, never a request for a new level.
+ return None
+ return (start[0] + bond_length * LEVEL_PRESET_BOND_LENGTHS, start[1])
+
+
def snapped_line_end(start: Point2D, end: Point2D, *, step_degrees: float) -> Point2D:
"""Rotate ``end`` about ``start`` onto the nearest multiple of ``step_degrees``.
@@ -89,6 +251,73 @@ def nearest_endpoint(
return best
+def points_on_endpoints(
+ points: list[Point2D], endpoints: list[Point2D]
+) -> list[Point2D]:
+ """The ``points`` that are sitting exactly on one of ``endpoints``.
+
+ A gesture takes an endpoint by copying it, so equality is the whole
+ test; this is what the snap ring is drawn from, rather than a record of
+ which stage of the funnel answered.
+ """
+ targets = set(endpoints)
+ return [point for point in points if point in targets]
+
+
+def endpoint_connection(
+ moving: list[Point2D], targets: list[Point2D], *, radius: float
+) -> tuple[Point2D, Point2D] | None:
+ """The shift that joins a ``moving`` end to a ``targets`` end, and where.
+
+ ``None`` when no pair is within ``radius``. The smallest shift wins, so
+ the pair a drag has come closest to joining is the one that joins, and
+ moving several items at once still connects only once.
+ """
+ best: tuple[float, Point2D, Point2D] | None = None
+ for point in moving:
+ found = nearest_endpoint(point, targets, radius=radius)
+ if found is None:
+ continue
+ shift = (found[0] - point[0], found[1] - point[1])
+ distance = math.hypot(*shift)
+ if best is None or distance < best[0]:
+ best = (distance, shift, found)
+ return None if best is None else (best[1], best[2])
+
+
+def snapped_drawing_point(
+ point: Point2D,
+ candidates: list[Point2D],
+ *,
+ radius: float,
+ avoid: Point2D | None = None,
+ angle_step: float | None = None,
+ grid_step: float = 0.0,
+ grid_style: str = "square",
+) -> Point2D:
+ """Native drawing funnel: endpoint, explicit angle, then grid.
+
+ Reject the closest endpoint when it is the gesture's start; do not choose
+ a farther endpoint in its place. The grid may still collapse a short drag.
+ """
+ endpoint = nearest_endpoint(point, candidates, radius=radius)
+ if endpoint is not None:
+ # Preserve QPointF equality: absolute tolerance only at zero,
+ # otherwise relative to the smaller coordinate magnitude.
+ avoided = avoid is not None and all(
+ abs(a - b) <= 1e-12
+ if a == 0.0 or b == 0.0
+ else abs(a - b) * 1e12 <= min(abs(a), abs(b))
+ for a, b in zip(endpoint, avoid, strict=True)
+ )
+ if not avoided:
+ return endpoint
+ if angle_step is not None and avoid is not None:
+ return snapped_line_end(avoid, point, step_degrees=angle_step)
+ snap = snapped_to_hex_grid if grid_style == "hex" else snapped_to_grid
+ return snap(point, step=grid_step)
+
+
def snapped_endpoint(
point: Point2D, candidates: list[Point2D], *, radius: float
) -> Point2D:
@@ -188,6 +417,29 @@ def hex_grid_cells(
return cells
+def grid_lines(
+ bounds: tuple[float, float, float, float], *, step: float, style: str
+) -> list[tuple[float, float, float, float]]:
+ """Native background segments; the painter clips them to the exposed sheet."""
+ if step <= 0:
+ return []
+ left, top, right, bottom = bounds
+ if style == "hex":
+ cells = hex_grid_cells(bounds, step=step)
+ # Three sides per cell cover each shared edge once, preserving alpha.
+ return [(*cell[i], *cell[i + 1]) for cell in cells for i in range(3)]
+ first_x = math.ceil(left / step) * step
+ first_y = math.ceil(top / step) * step
+ lines = [(x, top, x, bottom) for x in _grid_coordinates(first_x, right, step)]
+ lines.extend((left, y, right, y) for y in _grid_coordinates(first_y, bottom, step))
+ return lines
+
+
+def _grid_coordinates(first: float, limit: float, step: float) -> list[float]:
+ count = int((limit - first) / step) + 1 if limit >= first else 0
+ return [first + index * step for index in range(max(0, count))]
+
+
def snapped_to_hex_grid(point: Point2D, *, step: float) -> Point2D:
if step <= 0:
return point
@@ -209,11 +461,217 @@ def arc_midpoint(
return (center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle))
+def curved_control_point(start: Point2D, end: Point2D) -> Point2D:
+ """The native curved-arrow handle's initial control point."""
+ dx = end[0] - start[0]
+ dy = end[1] - start[1]
+ length = math.hypot(dx, dy) or 1.0
+ nx = -dy / length
+ ny = dx / length
+ return (
+ start[0] + dx * 0.5 + nx * length * 0.3,
+ start[1] + dy * 0.5 + ny * length * 0.3,
+ )
+
+
+def new_arrow_record(
+ start: Point2D, end: Point2D, kind: str, *, mirrored: bool = False
+) -> Arrow:
+ """Build the initial record shared by native and browser drawing."""
+ return normalized_arrow_control(
+ Arrow(
+ kind="arrow" if kind == "reaction" else kind,
+ start=start,
+ end=end,
+ mirrored=mirrored,
+ )
+ )
+
+
+def normalized_arrow_control(record: Arrow) -> Arrow:
+ """Apply the native record rule before either adapter renders an arrow."""
+ if record.kind in VALID_CURVED_ARROW_KINDS and record.control is None:
+ return replace(
+ record,
+ control=curved_control_point(record.start, record.end),
+ double=record.kind == "curved_double",
+ )
+ if record.kind not in VALID_CURVED_ARROW_KINDS and record.control is not None:
+ return replace(record, control=None)
+ return record
+
+
+def arrow_path_commands(
+ start: Point2D,
+ end: Point2D,
+ kind: str,
+ *,
+ bond_length: float,
+ bond_spacing: float,
+ wave_spacing: float,
+ line_width: float,
+ head_scale: float,
+ control: Point2D | None = None,
+ double: bool = False,
+ mirrored: bool = False,
+) -> list[PathCommand]:
+ """ArrowRenderer's path construction, shared by Qt and SVG output."""
+ commands: list[PathCommand] = []
+
+ def polyline(points: list[Point2D]) -> None:
+ # QPainterPath discards zero-length lines; SVG round caps would paint dots.
+ if all(point == points[0] for point in points[1:]):
+ return
+ commands.append(("M", points[0]))
+ commands.extend(("L", point) for point in points[1:])
+
+ def head(a: Point2D, b: Point2D, *, half: bool = False) -> None:
+ for points in arrow_head_polylines(
+ a,
+ b,
+ head_len=bond_length * head_scale,
+ line_width=line_width,
+ double=False,
+ half=half,
+ mirrored=mirrored if half else False,
+ ):
+ polyline(points)
+
+ if kind in VALID_LINE_KINDS:
+ polyline(
+ wavy_line_points(
+ start, end, half_wavelength=wave_spacing, amplitude=wave_spacing * 0.5
+ )
+ if kind == "line_wavy"
+ else [start, end]
+ )
+ elif kind in ARC_KIND_SWEEPS:
+ sweep_degrees, bulge_left = ARC_KIND_SWEEPS[kind]
+ points = arc_points(
+ start, end, sweep_degrees=sweep_degrees, bulge_left=bulge_left
+ )
+ polyline(points)
+ # The head follows the arc's final tangent, not the chord.
+ head(points[-2], end)
+ elif kind in VALID_CURVED_ARROW_KINDS:
+ if control is None:
+ control = curved_control_point(start, end)
+ double = kind == "curved_double"
+ if start != control or control != end:
+ commands.extend([("M", start), ("Q", (*control, *end))])
+ head(control, end)
+ if double:
+ head(control, start)
+ elif kind.startswith("equilibrium"):
+ dx, dy = end[0] - start[0], end[1] - start[1]
+ length = math.hypot(dx, dy) or 1.0
+ nx, ny = -dy / length, dx / length
+ # Keep the shafts one bond spacing apart, with room for thick strokes.
+ offset = max(bond_spacing * 0.5, line_width)
+ if mirrored:
+ offset = -offset
+ forward_start = (start[0] - nx * offset, start[1] - ny * offset)
+ forward_end = (end[0] - nx * offset, end[1] - ny * offset)
+ reverse_start = (end[0] + nx * offset, end[1] + ny * offset)
+ reverse_end = (start[0] + nx * offset, start[1] + ny * offset)
+ # A favored direction keeps that harpoon full length and shortens the
+ # other one to half, centred on the arrow, the way ChemDraw draws it.
+ for a, b, shortened in (
+ (forward_start, forward_end, kind == "equilibrium_reverse"),
+ (reverse_start, reverse_end, kind == "equilibrium_forward"),
+ ):
+ if shortened:
+ mid_x, mid_y = (a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5
+ a, b = (
+ (mid_x + (a[0] - mid_x) * 0.5, mid_y + (a[1] - mid_y) * 0.5),
+ (mid_x + (b[0] - mid_x) * 0.5, mid_y + (b[1] - mid_y) * 0.5),
+ )
+ polyline([a, b])
+ head(a, b, half=True)
+ else:
+ polyline([start, end])
+ if kind == "inhibit":
+ dx, dy = end[0] - start[0], end[1] - start[1]
+ length = math.hypot(dx, dy) or 1.0
+ nx, ny = -dy / length, dx / length
+ bar = bond_length * 0.2
+ polyline(
+ [
+ (end[0] - nx * bar, end[1] - ny * bar),
+ (end[0] + nx * bar, end[1] + ny * bar),
+ ]
+ )
+ else:
+ head(start, end)
+ if kind == "resonance":
+ head(end, start)
+ return commands
+
+
+def arrow_head_polylines(
+ start: Point2D,
+ end: Point2D,
+ *,
+ head_len: float,
+ line_width: float,
+ double: bool,
+ half: bool = False,
+ mirrored: bool = False,
+) -> list[list[Point2D]]:
+ """ArrowRenderer's existing head construction, with tuple drawing outputs."""
+ angle = math.atan2(end[1] - start[1], end[0] - start[0])
+ head_angle = math.radians(-25 if mirrored else 25)
+ offsets = [0.0]
+ if double:
+ offset_mag = max(1.4, line_width * 1.2)
+ offsets = [-offset_mag, offset_mag]
+ polylines = []
+ for offset in offsets:
+ dx = math.cos(angle + math.pi / 2) * offset
+ dy = math.sin(angle + math.pi / 2) * offset
+ tip = (end[0] + dx, end[1] + dy) if double else end
+ right = (
+ tip[0] - head_len * math.cos(angle + head_angle),
+ tip[1] - head_len * math.sin(angle + head_angle),
+ )
+ # A half head keeps the barb on the side the line was offset toward,
+ # so an equilibrium pair carries both barbs on the outside and reads
+ # as the conventional harpoon arrow rather than two full heads.
+ if half:
+ polylines.append([right, tip])
+ continue
+ left = (
+ tip[0] - head_len * math.cos(angle - head_angle),
+ tip[1] - head_len * math.sin(angle - head_angle),
+ )
+ polylines.append([left, tip, right])
+ return polylines
+
+
__all__ = [
+ "ENDPOINT_SNAP_SCREEN_PX",
+ "LEVEL_PRESET_BOND_LENGTHS",
+ "LINE_ANGLE_STEP_DEGREES",
+ "SNAP_MARK_PEN_SCREEN_PX",
+ "SNAP_MARK_SCREEN_PX",
"arc_midpoint",
"arc_points",
+ "arrow_head_polylines",
+ "arrow_path_commands",
+ "arrow_with_moved_endpoint",
+ "clamp_curved_midpoint",
+ "control_from_midpoint",
+ "control_with_moved_end",
+ "curved_control_point",
+ "curved_midpoint",
+ "grid_lines",
"hex_grid_cells",
+ "line_click_endpoint",
"nearest_endpoint",
+ "new_arrow_record",
+ "normalized_arrow_control",
+ "points_on_endpoints",
+ "snapped_drawing_point",
"snapped_endpoint",
"snapped_line_end",
"snapped_to_grid",
diff --git a/app/chemvas/features/selection/__init__.py b/app/chemvas/features/selection/__init__.py
index aa5d2fbd..1a8278fb 100644
--- a/app/chemvas/features/selection/__init__.py
+++ b/app/chemvas/features/selection/__init__.py
@@ -2,22 +2,48 @@
from .active_tool import ActiveToolReference
from .hit import (
+ ARROW_PICK_SCREEN_PX,
AtomHitCandidate,
BondHitCandidate,
SelectionHitRequest,
SelectionRect,
SelectionSnapshot,
StructureHit,
+ bond_grid_candidates,
+ bond_pick_candidates,
+ build_bond_grid,
build_selection_snapshot,
+ choose_mark_atom,
choose_preferred_structure_hit,
+ distance_point_to_segment,
+ independent_selection_items,
+ mark_precedes_atom,
+ nearest_atom_id,
+ nearest_bond_id,
nearest_ring_atom_id,
padded_rect_contains_point,
selected_atom_ids_with_bond_endpoints,
selection_hit_matches,
structure_hit_is_selected,
)
-from .press import SelectionPressContext, SelectionPressDecision, plan_selection_press
-from .rotation import rotated_atom_positions
+from .press import (
+ DIRECT_SELECT_OBJECT_KINDS,
+ SelectionPressContext,
+ SelectionPressDecision,
+ plan_selection_press,
+)
+from .rotation import (
+ ROTATION_SNAP_STEP_DEGREES,
+ orbital_handle_positions,
+ orbital_rotation_angle,
+ orbital_scale_factor,
+ reflected_point,
+ rotated_atom_positions,
+ rotated_point_coordinates,
+ rotation_drag_angle,
+ selection_frame_applies,
+ selection_transform_center,
+)
from .rotation_geometry import (
Coords3D,
axis_rotated_coords,
@@ -35,6 +61,9 @@
)
__all__ = [
+ "ARROW_PICK_SCREEN_PX",
+ "DIRECT_SELECT_OBJECT_KINDS",
+ "ROTATION_SNAP_STEP_DEGREES",
"ActiveToolReference",
"AtomHitCandidate",
"BondHitCandidate",
@@ -46,23 +75,40 @@
"SelectionSnapshot",
"StructureHit",
"axis_rotated_coords",
+ "bond_grid_candidates",
+ "bond_pick_candidates",
+ "build_bond_grid",
"build_selection_snapshot",
"center_for_coords_3d",
+ "choose_mark_atom",
"choose_preferred_structure_hit",
+ "distance_point_to_segment",
"dominant_axis_angle_from_drag",
"flatten_coords_to_plane",
"fragment_plane_normal_for",
+ "independent_selection_items",
+ "mark_precedes_atom",
+ "nearest_atom_id",
+ "nearest_bond_id",
"nearest_ring_atom_id",
"normalize_3d",
+ "orbital_handle_positions",
+ "orbital_rotation_angle",
+ "orbital_scale_factor",
"padded_rect_contains_point",
"plan_selection_press",
"project_point_3d",
+ "reflected_point",
"rigid_rotated_coords",
"rigid_rotation_angles_from_drag",
"rotate_point_around_axis",
"rotated_atom_positions",
+ "rotated_point_coordinates",
+ "rotation_drag_angle",
"selected_atom_ids_with_bond_endpoints",
+ "selection_frame_applies",
"selection_hit_matches",
+ "selection_transform_center",
"structure_hit_is_selected",
"translate_projected_point_3d",
"unproject_point_3d",
diff --git a/app/chemvas/features/selection/hit.py b/app/chemvas/features/selection/hit.py
index 668111a6..ad81756e 100644
--- a/app/chemvas/features/selection/hit.py
+++ b/app/chemvas/features/selection/hit.py
@@ -1,16 +1,20 @@
from __future__ import annotations
+import math
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
- from collections.abc import Iterable, Sequence
+ from collections.abc import Iterable, Mapping, Sequence
- from chemvas.domain.document import Bond
+ from chemvas.domain.document import Atom, Bond
StructureKind = Literal["atom", "bond", "ring", "other"]
Point2D = tuple[float, float]
+# Input tolerance only: neither document strokes nor export bounds grow.
+ARROW_PICK_SCREEN_PX = 6.0
+
@dataclass(frozen=True)
class AtomHitCandidate:
@@ -132,6 +136,34 @@ def choose_preferred_structure_hit(
return None
+def nearest_atom_id(
+ atoms: Mapping[int, Atom],
+ candidates: Iterable[int],
+ *,
+ x: float,
+ y: float,
+ max_dist: float,
+) -> int | None:
+ """The canvas pick rule; distance ties belong to the lowest atom ID."""
+ nearest_id = None
+ nearest_dist_sq = max_dist * max_dist
+ for atom_id in candidates:
+ atom = atoms.get(atom_id)
+ if atom is None:
+ continue
+ dx = atom.x - x
+ dy = atom.y - y
+ dist_sq = dx * dx + dy * dy
+ # Lowest atom id breaks exact-distance ties so the pick
+ # does not depend on set iteration order.
+ if dist_sq < nearest_dist_sq or (
+ dist_sq == nearest_dist_sq and (nearest_id is None or atom_id < nearest_id)
+ ):
+ nearest_id = atom_id
+ nearest_dist_sq = dist_sq
+ return nearest_id
+
+
def nearest_ring_atom_id(
atom_distances: Sequence[tuple[int, float]],
*,
@@ -204,6 +236,151 @@ def selection_hit_matches(request: SelectionHitRequest) -> bool:
)
+def distance_point_to_segment(p: Any, a: Any, b: Any) -> float:
+ abx = b.x() - a.x()
+ aby = b.y() - a.y()
+ apx = p.x() - a.x()
+ apy = p.y() - a.y()
+ ab_len_sq = abx * abx + aby * aby
+ if ab_len_sq == 0:
+ return math.hypot(apx, apy)
+ t = max(0.0, min(1.0, (apx * abx + apy * aby) / ab_len_sq))
+ cx = a.x() + abx * t
+ cy = a.y() + aby * t
+ return math.hypot(p.x() - cx, p.y() - cy)
+
+
+def bond_pick_candidates(
+ model: Any, x: float, y: float, max_dist: float, cell_size: float
+) -> Iterable[int]:
+ """Keep native grid traversal order, including equal-distance bond ties."""
+ radius = math.ceil(max_dist / cell_size)
+ ix, iy = math.floor(x / cell_size), math.floor(y / cell_size)
+ grid = build_bond_grid(
+ model, cell_size, bounds=(ix - radius, iy - radius, ix + radius, iy + radius)
+ )
+ return bond_grid_candidates(grid, x, y, max_dist, cell_size)
+
+
+def build_bond_grid(
+ model: Any, cell_size: float, *, bounds: tuple[int, int, int, int] | None = None
+) -> dict[tuple[int, int], set[int]]:
+ """Build native bond cells; a browser pick needs only its query window."""
+ grid: dict[tuple[int, int], set[int]] = {}
+ for bond_id, bond in enumerate(model.bonds):
+ if bond is None:
+ continue
+ a, b = model.atom_for_id(bond.a), model.atom_for_id(bond.b)
+ if a is None or b is None:
+ continue
+ min_ix, min_iy = (
+ math.floor(min(a.x, b.x) / cell_size),
+ math.floor(min(a.y, b.y) / cell_size),
+ )
+ max_ix, max_iy = (
+ math.floor(max(a.x, b.x) / cell_size),
+ math.floor(max(a.y, b.y) / cell_size),
+ )
+ if bounds is not None:
+ min_ix, min_iy = max(min_ix, bounds[0]), max(min_iy, bounds[1])
+ max_ix, max_iy = min(max_ix, bounds[2]), min(max_iy, bounds[3])
+ for ix in range(min_ix, max_ix + 1):
+ for iy in range(min_iy, max_iy + 1):
+ grid.setdefault((ix, iy), set()).add(bond_id)
+ return grid
+
+
+def bond_grid_candidates(
+ grid: Any, x: float, y: float, max_dist: float, cell_size: float
+) -> Iterable[int]:
+ radius = math.ceil(max_dist / cell_size)
+ ix, iy = math.floor(x / cell_size), math.floor(y / cell_size)
+ return (
+ bond_id
+ for cx in range(ix - radius, ix + radius + 1)
+ for cy in range(iy - radius, iy + radius + 1)
+ for bond_id in grid.get((cx, cy), ())
+ )
+
+
+def nearest_bond_id(
+ model: Any,
+ candidates: Iterable[int],
+ pos: Any,
+ max_dist: float,
+ *,
+ point_factory: Any,
+) -> int | None:
+ nearest = None
+ nearest_dist = max_dist
+ seen: set[int] = set()
+ for bond_id in candidates:
+ if bond_id in seen:
+ continue
+ seen.add(bond_id)
+ bond = model.bond_for_id(bond_id)
+ if bond is None:
+ continue
+ a = model.atom_for_id(bond.a)
+ b = model.atom_for_id(bond.b)
+ if a is None or b is None:
+ continue
+ dist = distance_point_to_segment(
+ pos, point_factory(a.x, a.y), point_factory(b.x, b.y)
+ )
+ if dist <= nearest_dist:
+ nearest = bond_id
+ nearest_dist = dist
+ return nearest
+
+
+def independent_selection_items(
+ selection_items: list[Any], atom_ids: set[int]
+) -> list[Any]:
+ items: list[Any] = []
+ seen = set()
+ for item in selection_items:
+ if item is None or item in seen:
+ continue
+ seen.add(item)
+ kind = item.data(0)
+ if kind in {"atom", "bond", "ring"}:
+ continue
+ if kind == "mark":
+ data = item.data(1) or {}
+ atom_id = data.get("atom_id")
+ if isinstance(atom_id, int) and atom_id in atom_ids:
+ continue
+ items.append(item)
+ return items
+
+
+def choose_mark_atom(
+ candidates: Iterable[tuple[int, float, bool, float]],
+ *,
+ base_radius: float,
+ tolerance: float,
+) -> int | None:
+ """Label hits precede nearby atoms inside their native mark-placement corridor."""
+ eligible = [
+ (not on_label, distance, atom_id)
+ for atom_id, distance, on_label, offset_length in candidates
+ if on_label or distance <= max(base_radius, offset_length + tolerance)
+ ]
+ return min(eligible)[2] if eligible else None
+
+
+def mark_precedes_atom(
+ point: Point2D, mark_center: Point2D, atom: Point2D | None
+) -> bool:
+ """A directly hit mark wins unless the hit atom is at least as close."""
+ if atom is None:
+ return True
+ return math.hypot(
+ mark_center[0] - point[0], mark_center[1] - point[1]
+ ) < math.hypot(atom[0] - point[0], atom[1] - point[1])
+
+
__all__ = [
"AtomHitCandidate",
"BondHitCandidate",
@@ -211,8 +388,16 @@ def selection_hit_matches(request: SelectionHitRequest) -> bool:
"SelectionRect",
"SelectionSnapshot",
"StructureHit",
+ "bond_grid_candidates",
+ "bond_pick_candidates",
+ "build_bond_grid",
"build_selection_snapshot",
+ "choose_mark_atom",
"choose_preferred_structure_hit",
+ "distance_point_to_segment",
+ "independent_selection_items",
+ "mark_precedes_atom",
+ "nearest_bond_id",
"nearest_ring_atom_id",
"padded_rect_contains_point",
"selected_atom_ids_with_bond_endpoints",
diff --git a/app/chemvas/features/selection/press.py b/app/chemvas/features/selection/press.py
index b96bbda6..1d4acb1e 100644
--- a/app/chemvas/features/selection/press.py
+++ b/app/chemvas/features/selection/press.py
@@ -20,6 +20,13 @@ class SelectionPressDecision:
action: SelectionPressAction
+# Scene objects a press selects directly (with arrows); Control toggles one
+# that is not selected yet instead of starting a drag.
+DIRECT_SELECT_OBJECT_KINDS = frozenset(
+ ("note", "shape", "image", "mark", "orbital", "ts_bracket")
+)
+
+
def plan_selection_press(context: SelectionPressContext) -> SelectionPressDecision:
if context.hits_current_selection and context.has_selection_target:
return SelectionPressDecision(action="drag_current_selection")
@@ -29,6 +36,7 @@ def plan_selection_press(context: SelectionPressContext) -> SelectionPressDecisi
__all__ = [
+ "DIRECT_SELECT_OBJECT_KINDS",
"SelectionPressContext",
"SelectionPressDecision",
"plan_selection_press",
diff --git a/app/chemvas/features/selection/rotation.py b/app/chemvas/features/selection/rotation.py
index 4566b6bb..44e78e20 100644
--- a/app/chemvas/features/selection/rotation.py
+++ b/app/chemvas/features/selection/rotation.py
@@ -9,6 +9,11 @@
from chemvas.domain.document import Atom
+# Holding Shift while turning the rotation handle snaps the sweep to this
+# many degrees, so a scheme can be squared up without typing an angle.
+ROTATION_SNAP_STEP_DEGREES = 15.0
+
+
class Point2D(Protocol):
"""Anything with Qt's ``QPointF`` accessors; the feature stays Qt-free."""
@@ -16,6 +21,37 @@ def x(self) -> float: ...
def y(self) -> float: ...
+def selection_transform_center(
+ points: Iterable[tuple[float, float]],
+) -> tuple[float, float] | None:
+ points = list(points)
+ if not points:
+ return None
+ xs, ys = zip(*points, strict=True)
+ return (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0
+
+
+def reflected_point(
+ point: Point2D, center: Point2D, horizontal: bool
+) -> tuple[float, float]:
+ if horizontal:
+ return center.x() - (point.x() - center.x()), point.y()
+ return point.x(), center.y() - (point.y() - center.y())
+
+
+def rotated_point_coordinates(
+ point: Point2D, center: Point2D, angle_radians: float
+) -> tuple[float, float]:
+ cos_a = math.cos(angle_radians)
+ sin_a = math.sin(angle_radians)
+ dx = point.x() - center.x()
+ dy = point.y() - center.y()
+ return (
+ center.x() + dx * cos_a - dy * sin_a,
+ center.y() + dx * sin_a + dy * cos_a,
+ )
+
+
def rotated_atom_positions(
atom_ids: Iterable[int],
*,
@@ -39,4 +75,77 @@ def rotated_atom_positions(
return rotated
-__all__ = ["rotated_atom_positions"]
+def rotation_drag_angle(
+ center: Point2D,
+ start: Point2D,
+ pos: Point2D,
+ *,
+ snap_step: float | None = None,
+) -> float:
+ """Degrees the pointer has swept around ``center`` since ``start``.
+
+ Positive is clockwise on screen (y grows downward). ``snap_step``
+ rounds the sweep to that many degrees, for a Shift-constrained drag.
+ """
+ start_angle = math.atan2(start.y() - center.y(), start.x() - center.x())
+ angle = math.atan2(pos.y() - center.y(), pos.x() - center.x())
+ sweep = math.degrees(angle - start_angle)
+ sweep = (sweep + 180.0) % 360.0 - 180.0
+ if snap_step:
+ sweep = round(sweep / snap_step) * snap_step
+ return sweep
+
+
+def selection_frame_applies(atom_count: int, rotatable_item_count: int) -> bool:
+ """Whether a selection gets a frame with a rotation handle.
+
+ Rotation only means something for two or more atoms, or for an item
+ that turns about its own centre; a lone atom has nothing to rotate.
+ """
+ return atom_count >= 2 or rotatable_item_count >= 1
+
+
+def orbital_handle_positions(
+ center: tuple[float, float], base_dist: float
+) -> tuple[tuple[float, float], tuple[float, float]]:
+ return (center[0] + base_dist, center[1]), (center[0], center[1] - base_dist)
+
+
+def orbital_scale_factor(
+ center: Point2D,
+ pos: Point2D,
+ base_dist: float,
+ *,
+ minimum_scale: float = 0.2,
+) -> float:
+ safe_base_dist = max(float(base_dist), 1e-6)
+ dist = math.hypot(pos.x() - center.x(), pos.y() - center.y())
+ return max(minimum_scale, dist / safe_base_dist)
+
+
+def orbital_rotation_angle(
+ center: Point2D,
+ pos: Point2D,
+ *,
+ snap_enabled: bool,
+ snap_step: int,
+) -> float:
+ angle = math.degrees(math.atan2(pos.y() - center.y(), pos.x() - center.x()))
+ if snap_enabled:
+ step = max(1, int(snap_step))
+ angle = round(angle / step) * step
+ return angle
+
+
+__all__ = [
+ "ROTATION_SNAP_STEP_DEGREES",
+ "orbital_handle_positions",
+ "orbital_rotation_angle",
+ "orbital_scale_factor",
+ "reflected_point",
+ "rotated_atom_positions",
+ "rotated_point_coordinates",
+ "rotation_drag_angle",
+ "selection_frame_applies",
+ "selection_transform_center",
+]
diff --git a/app/chemvas/shell/icon_design.py b/app/chemvas/shell/icon_design.py
index 04971017..af20a3d3 100644
--- a/app/chemvas/shell/icon_design.py
+++ b/app/chemvas/shell/icon_design.py
@@ -2,14 +2,67 @@
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QByteArray, QRectF
-from PyQt6.QtSvg import QSvgRenderer
-
from chemvas.shell.palette import PALETTE
if TYPE_CHECKING:
from PyQt6.QtGui import QPainter
+# Ring template previews by their TEMPLATE_ENTRY_SPECS label.
+TEMPLATE_ICON_NAMES: dict[str, str] = {
+ "Benzene": "template_benzene",
+ "Cyclopropane": "template_ring3",
+ "Cyclobutane": "template_ring4",
+ "Cyclopentane": "template_ring5",
+ "Cyclohexane (Chair)": "template_chair",
+ "Cyclohexane (Chair, flipped)": "template_chair_flip",
+ "Cycloheptane": "template_ring7",
+ "Cyclooctane": "template_ring8",
+}
+TEMPLATE_FALLBACK_ICON = "template_ring6"
+
+DESIGN_ICON_NAMES: dict[str, str] = {
+ "icon_select": "move",
+ "icon_bond": "bond",
+ "icon_bond_bold": "bond_bold",
+ "icon_mark": "atom_orbit",
+ "icon_mark_plus": "plus",
+ "icon_mark_minus": "minus",
+ "icon_mark_circled_plus": "circled_plus",
+ "icon_mark_circled_minus": "circled_minus",
+ "icon_mark_radical": "radical",
+ "icon_text": "atom",
+ "icon_note": "note",
+ "icon_text_bold": "text_bold",
+ "icon_text_italic": "text_italic",
+ "icon_text_superscript": "text_superscript",
+ "icon_text_subscript": "text_subscript",
+ "icon_text_size_increase": "text_size_increase",
+ "icon_text_size_decrease": "text_size_decrease",
+ "icon_align_left": "align_left",
+ "icon_align_center": "align_center",
+ "icon_align_right": "align_right",
+ "icon_ring": "benzene",
+ "icon_ring_fill": "ring_fill",
+ "icon_eraser": "eraser",
+ "icon_bond_double": "bond_double",
+ "icon_bond_triple": "bond_triple",
+ "icon_bond_wedge": "wedge",
+ "icon_bond_hash": "hash",
+ "icon_bond_dotted": "bond_dotted",
+ "icon_arrow_width": "arrow_width",
+ "icon_arrow_head_scale": "arrow_head_scale",
+ "icon_flip_h": "flip_h",
+ "icon_flip_v": "flip_v",
+ "icon_rotate": "rotate",
+ "icon_arrow": "arrow",
+ "icon_ts_bracket": "bracket",
+ "icon_orbital": "orbital",
+ "icon_shape": "shape",
+ "icon_line": "line",
+ "icon_color": "color",
+ "icon_perspective": "perspective",
+}
+
_ICON_COLOR = PALETTE["icon"]
# One drawing language for every glyph: a 24-unit box, a 1.8-unit round-capped
@@ -205,7 +258,7 @@
}
-def _svg_document(name: str, color: str) -> str:
+def design_icon_svg(name: str, color: str = "currentColor") -> str:
fill = "currentColor" if name == "wedge" else "none"
width = "1.6" if name == "hash" else "1.8"
return (
@@ -223,8 +276,11 @@ def has_design_icon(name: str) -> bool:
def draw_design_icon(
painter: QPainter, name: str, *, color: str | None = None, size: float = 30.0
) -> None:
+ from PyQt6.QtCore import QByteArray, QRectF
+ from PyQt6.QtSvg import QSvgRenderer
+
renderer = QSvgRenderer(
- QByteArray(_svg_document(name, color or _ICON_COLOR).encode("utf-8"))
+ QByteArray(design_icon_svg(name, color or _ICON_COLOR).encode("utf-8"))
)
# Render the 24-unit viewBox into the central 90% of the target size: the
# glyphs already keep their own margin inside the box, so a 5% pad is
@@ -233,4 +289,11 @@ def draw_design_icon(
renderer.render(painter, QRectF(pad, pad, size * 0.9, size * 0.9))
-__all__ = ["draw_design_icon", "has_design_icon"]
+__all__ = [
+ "DESIGN_ICON_NAMES",
+ "TEMPLATE_FALLBACK_ICON",
+ "TEMPLATE_ICON_NAMES",
+ "design_icon_svg",
+ "draw_design_icon",
+ "has_design_icon",
+]
diff --git a/app/chemvas/shell/icon_factory.py b/app/chemvas/shell/icon_factory.py
index 5b4a0848..c86bf381 100644
--- a/app/chemvas/shell/icon_factory.py
+++ b/app/chemvas/shell/icon_factory.py
@@ -3,6 +3,9 @@
from typing import TYPE_CHECKING
from chemvas.shell.icon_design import (
+ DESIGN_ICON_NAMES,
+ TEMPLATE_FALLBACK_ICON,
+ TEMPLATE_ICON_NAMES,
draw_design_icon,
has_design_icon,
)
@@ -13,17 +16,6 @@
if TYPE_CHECKING:
from PyQt6.QtGui import QIcon
-_TEMPLATE_ICON_BY_LABEL: dict[str, str] = {
- "Benzene": "template_benzene",
- "Cyclopropane": "template_ring3",
- "Cyclobutane": "template_ring4",
- "Cyclopentane": "template_ring5",
- "Cyclohexane (Chair)": "template_chair",
- "Cyclohexane (Chair, flipped)": "template_chair_flip",
- "Cycloheptane": "template_ring7",
- "Cyclooctane": "template_ring8",
-}
-
class MainWindowIconFactory:
ICON_SIZE = 30
@@ -49,64 +41,64 @@ def _design_icon(self, name: str, fallback: str) -> QIcon:
return self.make_design_icon(name if has_design_icon(name) else fallback)
def icon_select(self) -> QIcon:
- return self.make_design_icon("move")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_select"])
def icon_bond(self) -> QIcon:
- return self.make_design_icon("bond")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond"])
def icon_bond_bold(self) -> QIcon:
- return self.make_design_icon("bond_bold")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_bold"])
def icon_mark(self) -> QIcon:
- return self.make_design_icon("atom_orbit")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark"])
def icon_mark_plus(self) -> QIcon:
- return self.make_design_icon("plus")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_plus"])
def icon_mark_minus(self) -> QIcon:
- return self.make_design_icon("minus")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_minus"])
def icon_mark_circled_plus(self) -> QIcon:
- return self.make_design_icon("circled_plus")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_circled_plus"])
def icon_mark_circled_minus(self) -> QIcon:
- return self.make_design_icon("circled_minus")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_circled_minus"])
def icon_mark_radical(self) -> QIcon:
- return self.make_design_icon("radical")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_radical"])
def icon_text(self) -> QIcon:
- return self.make_design_icon("atom")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text"])
def icon_note(self) -> QIcon:
- return self.make_design_icon("note")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_note"])
def icon_text_bold(self) -> QIcon:
- return self.make_design_icon("text_bold")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_bold"])
def icon_text_italic(self) -> QIcon:
- return self.make_design_icon("text_italic")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_italic"])
def icon_text_superscript(self) -> QIcon:
- return self.make_design_icon("text_superscript")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_superscript"])
def icon_text_subscript(self) -> QIcon:
- return self.make_design_icon("text_subscript")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_subscript"])
def icon_text_size_increase(self) -> QIcon:
- return self.make_design_icon("text_size_increase")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_size_increase"])
def icon_text_size_decrease(self) -> QIcon:
- return self.make_design_icon("text_size_decrease")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_size_decrease"])
def icon_align_left(self) -> QIcon:
- return self.make_design_icon("align_left")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_left"])
def icon_align_center(self) -> QIcon:
- return self.make_design_icon("align_center")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_center"])
def icon_align_right(self) -> QIcon:
- return self.make_design_icon("align_right")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_right"])
def icon_align_objects(self, mode: str) -> QIcon:
return self._design_icon(f"align_objects_{mode}", "align_objects_left")
@@ -115,28 +107,28 @@ def icon_distribute(self, axis: str) -> QIcon:
return self._design_icon(f"distribute_{axis}", "distribute_horizontal")
def icon_ring(self) -> QIcon:
- return self.make_design_icon("benzene")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_ring"])
def icon_ring_fill(self) -> QIcon:
- return self.make_design_icon("ring_fill")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_ring_fill"])
def icon_eraser(self) -> QIcon:
- return self.make_design_icon("eraser")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_eraser"])
def icon_bond_double(self) -> QIcon:
- return self.make_design_icon("bond_double")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_double"])
def icon_bond_triple(self) -> QIcon:
- return self.make_design_icon("bond_triple")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_triple"])
def icon_bond_wedge(self) -> QIcon:
- return self.make_design_icon("wedge")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_wedge"])
def icon_bond_hash(self) -> QIcon:
- return self.make_design_icon("hash")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_hash"])
def icon_bond_dotted(self) -> QIcon:
- return self.make_design_icon("bond_dotted")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_dotted"])
def icon_arrow_preview(self, kind: str) -> QIcon:
return self._design_icon(f"arrow_{kind}", "arrow_reaction")
@@ -147,10 +139,10 @@ def icon_arrow_preset(self, label: str) -> QIcon:
)
def icon_arrow_width(self) -> QIcon:
- return self.make_design_icon("arrow_width")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow_width"])
def icon_arrow_head_scale(self) -> QIcon:
- return self.make_design_icon("arrow_head_scale")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow_head_scale"])
def icon_orbital_preview(self, kind: str) -> QIcon:
return self._design_icon(f"orbital_{kind}", "orbital_s")
@@ -162,32 +154,33 @@ def icon_orbital_phase(self, enabled: bool) -> QIcon:
def icon_template_preview(self, label: str) -> QIcon:
return self._design_icon(
- _TEMPLATE_ICON_BY_LABEL.get(label, "template_ring6"), "template_ring6"
+ TEMPLATE_ICON_NAMES.get(label, TEMPLATE_FALLBACK_ICON),
+ TEMPLATE_FALLBACK_ICON,
)
def icon_flip_h(self) -> QIcon:
- return self.make_design_icon("flip_h")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_flip_h"])
def icon_flip_v(self) -> QIcon:
- return self.make_design_icon("flip_v")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_flip_v"])
def icon_rotate(self) -> QIcon:
- return self.make_design_icon("rotate")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_rotate"])
def icon_arrow(self) -> QIcon:
- return self.make_design_icon("arrow")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow"])
def icon_ts_bracket(self) -> QIcon:
- return self.make_design_icon("bracket")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_ts_bracket"])
def icon_bracket_preview(self, kind: str) -> QIcon:
return self._design_icon(f"bracket_{kind}", "bracket_square_pair")
def icon_orbital(self) -> QIcon:
- return self.make_design_icon("orbital")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_orbital"])
def icon_shape(self) -> QIcon:
- return self.make_design_icon("shape")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_shape"])
def icon_shape_kind(self, kind: str) -> QIcon:
return self._design_icon(f"shape_{kind}", "shape_circle")
@@ -196,16 +189,16 @@ def icon_shape_stroke(self, style: str) -> QIcon:
return self._design_icon(f"stroke_{style}", "stroke_solid")
def icon_line(self) -> QIcon:
- return self.make_design_icon("line")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_line"])
def icon_line_kind(self, kind: str) -> QIcon:
return self._design_icon("line_plain" if kind == "line" else kind, "line_plain")
def icon_color(self) -> QIcon:
- return self.make_design_icon("color")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_color"])
def icon_perspective(self) -> QIcon:
- return self.make_design_icon("perspective")
+ return self.make_design_icon(DESIGN_ICON_NAMES["icon_perspective"])
__all__ = ["MainWindowIconFactory"]
diff --git a/app/chemvas/shell/palette.py b/app/chemvas/shell/palette.py
index b429c1ff..488619fa 100644
--- a/app/chemvas/shell/palette.py
+++ b/app/chemvas/shell/palette.py
@@ -40,4 +40,17 @@
}
-__all__ = ["PALETTE"]
+SHAPE_FILL_TINT = 0.12
+RING_FILL_TINT = 0.25
+
+
+def pastel_rgb(rgb: tuple[int, int, int], tint: float) -> tuple[int, int, int]:
+ """Original opaque pastel blend used for shape panels and ring fills."""
+ return (
+ round(255 - (255 - rgb[0]) * tint),
+ round(255 - (255 - rgb[1]) * tint),
+ round(255 - (255 - rgb[2]) * tint),
+ )
+
+
+__all__ = ["PALETTE", "RING_FILL_TINT", "SHAPE_FILL_TINT", "pastel_rgb"]
diff --git a/app/chemvas/ui/annotations/arrows.py b/app/chemvas/ui/annotations/arrows.py
index 51920806..748d659f 100644
--- a/app/chemvas/ui/annotations/arrows.py
+++ b/app/chemvas/ui/annotations/arrows.py
@@ -1,22 +1,20 @@
from __future__ import annotations
-import math
-from dataclasses import replace
from typing import TYPE_CHECKING
from PyQt6.QtCore import QPointF, Qt
from PyQt6.QtGui import QBrush, QColor, QFont, QPainterPath
from chemvas.domain.document import (
- ARC_KIND_SWEEPS,
- VALID_ARC_KINDS,
- VALID_CURVED_ARROW_KINDS,
- VALID_LINE_KINDS,
Arrow,
arrow_from_state,
)
-from chemvas.features.annotations import arrow_label_html, arrow_label_normal
-from chemvas.features.rendering import arc_midpoint, arc_points, wavy_line_points
+from chemvas.features.annotations import arrow_label_html, arrow_label_position
+from chemvas.features.rendering import (
+ arrow_path_commands,
+ new_arrow_record,
+ normalized_arrow_control,
+)
from chemvas.ui.canvas.graphics_items import (
ArrowLabelItem,
ArrowPathItem,
@@ -25,7 +23,6 @@
bind_scene_record,
new_scene_record_id,
)
-from chemvas.ui.selection.selection_handles import default_curved_control
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -65,17 +62,7 @@ def discard_record(self, item: QGraphicsPathItem) -> None:
def set_record(self, item: QGraphicsPathItem, record: Arrow) -> None:
"""The sole mutation path for document arrows, including their paint."""
- if record.kind in VALID_CURVED_ARROW_KINDS and record.control is None:
- control = default_curved_control(
- QPointF(*record.start), QPointF(*record.end)
- )
- record = replace(
- record,
- control=(control.x(), control.y()),
- double=record.kind == "curved_double",
- )
- elif record.kind not in VALID_CURVED_ARROW_KINDS and record.control is not None:
- record = replace(record, control=None)
+ record = normalized_arrow_control(record)
state = self.context.state.arrow_state
record_id = item.data(ARROW_ID_ROLE)
if record_id is None:
@@ -100,13 +87,14 @@ def create_from_state(self, state: Mapping[str, object]) -> ArrowPathItem:
def render_record(self, item: QGraphicsPathItem, record: Arrow) -> None:
start, end = QPointF(*record.start), QPointF(*record.end)
- rebuilt = self._build_arrow_graphics(start, end, record.kind, record.mirrored)
- if record.kind in VALID_CURVED_ARROW_KINDS and record.control is not None:
- rebuilt.setPath(
- self.build_curved_arrow_path(
- start, end, QPointF(*record.control), record.double
- )
- )
+ rebuilt = self._build_arrow_graphics(
+ start,
+ end,
+ record.kind,
+ record.mirrored,
+ control=None if record.control is None else QPointF(*record.control),
+ double=record.double,
+ )
item.setPos(0.0, 0.0)
item.setPath(rebuilt.path())
pen = rebuilt.pen()
@@ -123,210 +111,97 @@ def build_arrow_item(
item = ArrowPathItem()
self.set_record(
item,
- Arrow(
- kind="arrow" if kind == "reaction" else kind,
- start=(start.x(), start.y()),
- end=(end.x(), end.y()),
+ new_arrow_record(
+ (start.x(), start.y()),
+ (end.x(), end.y()),
+ kind,
mirrored=mirrored,
),
)
return item
- def _build_arrow_graphics(
- self, start: QPointF, end: QPointF, kind: str, mirrored: bool = False
- ):
- if kind in VALID_LINE_KINDS:
- return self.build_line_item(start, end, kind)
- if kind in VALID_ARC_KINDS:
- return self.build_arc_arrow(start, end, kind)
- if kind == "equilibrium":
- return self.build_equilibrium_item(start, end, mirrored=mirrored)
- if kind == "equilibrium_forward":
- return self.build_equilibrium_item(
- start, end, favored="forward", mirrored=mirrored
- )
- if kind == "equilibrium_reverse":
- return self.build_equilibrium_item(
- start, end, favored="reverse", mirrored=mirrored
- )
- if kind == "resonance":
- return self.build_double_head_arrow(start, end)
- if kind == "curved_single":
- return self.build_curved_arrow(start, end, double=False)
- if kind == "curved_double":
- return self.build_curved_arrow(start, end, double=True)
- if kind == "inhibit":
- return self.build_inhibition_arrow(start, end)
- if kind == "dotted":
- return self.build_dotted_arrow(start, end)
- return self.build_single_head_arrow(start, end)
-
- def build_single_head_arrow(self, start: QPointF, end: QPointF):
- path = QPainterPath()
- path.moveTo(start)
- path.lineTo(end)
- self.add_arrow_head(path, start, end, double=False)
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen())
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_double_head_arrow(self, start: QPointF, end: QPointF):
- path = QPainterPath()
- path.moveTo(start)
- path.lineTo(end)
- self.add_arrow_head(path, start, end, double=False)
- self.add_arrow_head(path, end, start, double=False)
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen())
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_dotted_arrow(self, start: QPointF, end: QPointF):
- path = QPainterPath()
- path.moveTo(start)
- path.lineTo(end)
- self.add_arrow_head(path, start, end, double=False)
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen(dotted=True))
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_arc_arrow(self, start: QPointF, end: QPointF, kind: str):
- sweep_degrees, bulge_left = ARC_KIND_SWEEPS[kind]
- points = arc_points(
+ def _build_arrow_path(
+ self,
+ start: QPointF,
+ end: QPointF,
+ kind: str,
+ *,
+ control: QPointF | None = None,
+ double: bool = False,
+ mirrored: bool = False,
+ ) -> QPainterPath:
+ style = self.context.renderer.style
+ commands = arrow_path_commands(
(start.x(), start.y()),
(end.x(), end.y()),
- sweep_degrees=sweep_degrees,
- bulge_left=bulge_left,
- )
- path = QPainterPath()
- path.moveTo(*points[0])
- for x, y in points[1:]:
- path.lineTo(x, y)
- # The head follows the arc's final tangent, not the chord.
- self.add_arrow_head(path, QPointF(*points[-2]), end, double=False)
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen())
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_line_item(self, start: QPointF, end: QPointF, kind: str):
- path = QPainterPath()
- if kind == "line_wavy":
+ kind,
+ bond_length=style.bond_length_px,
+ bond_spacing=style.bond_spacing_px
+ if kind.startswith("equilibrium")
+ else 0.0,
# One half-wave per bond spacing keeps the wave in step with the
# ACS bond metrics, so it scales with the document like a bond.
- spacing = self.context.renderer.bond_spacing()
- points = wavy_line_points(
- (start.x(), start.y()),
- (end.x(), end.y()),
- half_wavelength=spacing,
- amplitude=spacing * 0.5,
- )
- path.moveTo(*points[0])
- for x, y in points[1:]:
- path.lineTo(x, y)
- else:
- path.moveTo(start)
- path.lineTo(end)
- item = ArrowPathItem(path)
- if kind == "line_bold":
- item.setPen(self.context.renderer.bold_bond_pen())
- else:
- item.setPen(self.arrow_pen(dotted=kind == "line_dashed"))
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_curved_arrow(self, start: QPointF, end: QPointF, double: bool):
- control = default_curved_control(start, end)
- item = ArrowPathItem(self.build_curved_arrow_path(start, end, control, double))
- item.setPen(self.arrow_pen())
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_curved_arrow_path(
- self, start: QPointF, end: QPointF, control: QPointF, double: bool
- ) -> QPainterPath:
- """The same quadratic path and heads for creation and later edits."""
+ wave_spacing=self.context.renderer.bond_spacing()
+ if kind == "line_wavy"
+ else 0.0,
+ line_width=self.settings.arrow_line_width,
+ head_scale=self.settings.arrow_head_scale,
+ control=None if control is None else (control.x(), control.y()),
+ double=double,
+ mirrored=mirrored,
+ )
path = QPainterPath()
- path.moveTo(start)
- path.quadTo(control, end)
- if double:
- self.add_arrow_head(path, control, end, double=False)
- self.add_arrow_head(path, control, start, double=False)
- else:
- self.add_arrow_head(path, control, end, double=False)
+ for command, coordinates in commands:
+ if command == "M":
+ path.moveTo(*coordinates)
+ elif command == "L":
+ path.lineTo(*coordinates)
+ else:
+ path.quadTo(*coordinates)
return path
- def build_inhibition_arrow(self, start: QPointF, end: QPointF):
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- length = math.hypot(dx, dy) or 1.0
- nx = -dy / length
- ny = dx / length
- bar = self.context.renderer.style.bond_length_px * 0.2
-
- path = QPainterPath()
- path.moveTo(start)
- path.lineTo(end)
- bar_start = QPointF(end.x() - nx * bar, end.y() - ny * bar)
- bar_end = QPointF(end.x() + nx * bar, end.y() + ny * bar)
- path.moveTo(bar_start)
- path.lineTo(bar_end)
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen())
- item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
- return item
-
- def build_equilibrium_item(
+ def _build_arrow_graphics(
self,
start: QPointF,
end: QPointF,
- favored: str | None = None,
- *,
+ kind: str,
mirrored: bool = False,
+ *,
+ control: QPointF | None = None,
+ double: bool = False,
):
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- length = math.hypot(dx, dy) or 1.0
- nx = -dy / length
- ny = dx / length
- # Keep the shafts one bond spacing apart, with room for thick strokes.
- offset = max(
- self.context.renderer.style.bond_spacing_px * 0.5,
- self.settings.arrow_line_width,
+ item = ArrowPathItem(
+ self._build_arrow_path(
+ start,
+ end,
+ kind,
+ control=control,
+ double=double,
+ mirrored=mirrored,
+ )
+ )
+ item.setPen(
+ self.context.renderer.bold_bond_pen()
+ if kind == "line_bold"
+ else self.arrow_pen(dotted=kind in {"dotted", "line_dashed"})
)
- if mirrored:
- offset = -offset
- forward_start = QPointF(start.x() - nx * offset, start.y() - ny * offset)
- forward_end = QPointF(end.x() - nx * offset, end.y() - ny * offset)
- reverse_start = QPointF(end.x() + nx * offset, end.y() + ny * offset)
- reverse_end = QPointF(start.x() + nx * offset, start.y() + ny * offset)
- # A favored direction keeps that harpoon full length and shortens the
- # other one to half, centred on the arrow, the way ChemDraw draws it.
- if favored == "forward":
- reverse_start, reverse_end = self._centered_half(reverse_start, reverse_end)
- elif favored == "reverse":
- forward_start, forward_end = self._centered_half(forward_start, forward_end)
-
- path = QPainterPath()
- self.add_harpoon(path, forward_start, forward_end, mirrored=mirrored)
- self.add_harpoon(path, reverse_start, reverse_end, mirrored=mirrored)
-
- item = ArrowPathItem(path)
- item.setPen(self.arrow_pen())
item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
return item
- @staticmethod
- def _centered_half(start: QPointF, end: QPointF) -> tuple[QPointF, QPointF]:
- mid_x = (start.x() + end.x()) * 0.5
- mid_y = (start.y() + end.y()) * 0.5
- return (
- QPointF(
- mid_x + (start.x() - mid_x) * 0.5, mid_y + (start.y() - mid_y) * 0.5
- ),
- QPointF(mid_x + (end.x() - mid_x) * 0.5, mid_y + (end.y() - mid_y) * 0.5),
+ def build_curved_arrow_path(
+ self,
+ start: QPointF,
+ end: QPointF,
+ control: QPointF,
+ double: bool,
+ ) -> QPainterPath:
+ """Use the same path owner when dragging an existing curve's handles."""
+ return self._build_arrow_path(
+ start,
+ end,
+ "curved_double" if double else "curved_single",
+ control=control,
+ double=double,
)
def render_labels(self, item: QGraphicsPathItem) -> None:
@@ -341,47 +216,16 @@ def render_labels(self, item: QGraphicsPathItem) -> None:
if not record.labels:
return
labels = dict(record.labels)
- start, end = QPointF(*record.start), QPointF(*record.end)
- control = None if record.control is None else QPointF(*record.control)
- kind = record.kind
- if isinstance(control, QPointF):
- # Midpoint of the quadratic curve at t = 0.5.
- mid = QPointF(
- 0.25 * start.x() + 0.5 * control.x() + 0.25 * end.x(),
- 0.25 * start.y() + 0.5 * control.y() + 0.25 * end.y(),
- )
- elif kind in ARC_KIND_SWEEPS:
- sweep_degrees, bulge_left = ARC_KIND_SWEEPS[kind]
- mid = QPointF(
- *arc_midpoint(
- (start.x(), start.y()),
- (end.x(), end.y()),
- sweep_degrees=sweep_degrees,
- bulge_left=bulge_left,
- )
- )
- else:
- mid = QPointF((start.x() + end.x()) * 0.5, (start.y() + end.y()) * 0.5)
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- nx, ny = arrow_label_normal(dx, dy)
style = self.context.state.text_style_state
font = QFont(style.text_font_family, style.text_font_size)
font.setWeight(style.text_font_weight)
font.setItalic(style.text_italic)
- # Measure how far the arrow's own strokes (harpoons, barbs) reach
- # from the axis along the normal, so the label clears them at any
- # bond length; a curved arrow's or arc's chord ends are not part of
- # that, since their labels sit at the curve midpoint instead.
path = item.mapToScene(item.path())
- arrow_extent = 0.0
- if not isinstance(control, QPointF) and kind not in ARC_KIND_SWEEPS:
- for index in range(path.elementCount()):
- element = path.elementAt(index)
- offset = (element.x - mid.x()) * nx + (element.y - mid.y()) * ny
- arrow_extent = max(arrow_extent, abs(offset))
- gap = arrow_extent + self.context.renderer.style.bond_spacing_px
- for side, sign in (("above", 1.0), ("below", -1.0)):
+ path_points = [
+ (path.elementAt(i).x, path.elementAt(i).y)
+ for i in range(path.elementCount())
+ ]
+ for side in ("above", "below"):
text = labels.get(side)
if not text:
continue
@@ -392,73 +236,18 @@ def render_labels(self, item: QGraphicsPathItem) -> None:
child.setDefaultTextColor(QColor(record.color or style.text_color))
child.setHtml(arrow_label_html(text))
rect = child.boundingRect()
- # Half of the label box projected onto the normal, so a vertical
- # arrow clears the label's width and a horizontal one its height.
- half_extent = abs(nx) * rect.width() * 0.5 + abs(ny) * rect.height() * 0.5
- distance = gap + half_extent
- center = QPointF(
- mid.x() + nx * sign * distance, mid.y() + ny * sign * distance
- )
top_left = QPointF(
- center.x() - rect.width() * 0.5, center.y() - rect.height() * 0.5
+ *arrow_label_position(
+ record,
+ path_points,
+ bond_spacing=self.context.renderer.style.bond_spacing_px,
+ side=side,
+ width=rect.width(),
+ height=rect.height(),
+ )
)
child.setPos(item.mapFromScene(top_left))
- def add_harpoon(
- self,
- path: QPainterPath,
- start: QPointF,
- end: QPointF,
- *,
- mirrored: bool = False,
- ) -> None:
- path.moveTo(start)
- path.lineTo(end)
- self.add_arrow_head(
- path, start, end, double=False, half=True, mirrored=mirrored
- )
-
- def add_arrow_head(
- self,
- path: QPainterPath,
- start: QPointF,
- end: QPointF,
- double: bool,
- half: bool = False,
- mirrored: bool = False,
- ) -> None:
- angle = math.atan2(end.y() - start.y(), end.x() - start.x())
- head_len = (
- self.context.renderer.style.bond_length_px * self.settings.arrow_head_scale
- )
- head_angle = math.radians(-25 if mirrored else 25)
- offsets = [0.0]
- if double:
- offset_mag = max(1.4, self.settings.arrow_line_width * 1.2)
- offsets = [-offset_mag, offset_mag]
- for offset in offsets:
- dx = math.cos(angle + math.pi / 2) * offset
- dy = math.sin(angle + math.pi / 2) * offset
- tip = QPointF(end.x() + dx, end.y() + dy) if double else end
- right = QPointF(
- tip.x() - head_len * math.cos(angle + head_angle),
- tip.y() - head_len * math.sin(angle + head_angle),
- )
- # A half head keeps the barb on the side the line was offset toward,
- # so an equilibrium pair carries both barbs on the outside and reads
- # as the conventional harpoon arrow rather than two full heads.
- if half:
- path.moveTo(right)
- path.lineTo(tip)
- continue
- left = QPointF(
- tip.x() - head_len * math.cos(angle - head_angle),
- tip.y() - head_len * math.sin(angle - head_angle),
- )
- path.moveTo(left)
- path.lineTo(tip)
- path.lineTo(right)
-
def arrow_pen(self, dotted: bool = False):
pen = self.context.renderer.bond_pen()
pen.setWidthF(self.settings.arrow_line_width)
diff --git a/app/chemvas/ui/annotations/graphics.py b/app/chemvas/ui/annotations/graphics.py
index 7a942e54..e9da9558 100644
--- a/app/chemvas/ui/annotations/graphics.py
+++ b/app/chemvas/ui/annotations/graphics.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import math
from typing import TYPE_CHECKING, override
from PyQt6.QtCore import QPointF, QRectF, Qt
@@ -21,7 +20,17 @@
)
from chemvas.domain.document import is_hex_color
-from chemvas.features.annotations import DEFAULT_BRACKET_KIND, normalized_bracket_kind
+from chemvas.features.annotations import (
+ BRACKET_SYMBOLS,
+ DEFAULT_BRACKET_KIND,
+ bracket_path_commands,
+ bracket_rect_from_points,
+ bracket_stroke_width,
+ bracket_symbol_layout,
+ mark_dimensions,
+ normalized_bracket_kind,
+ orbital_geometry,
+)
from chemvas.ui.annotations.marks import MarkItem
from chemvas.ui.annotations.shape_geometry import (
DEFAULT_SHAPE_KIND,
@@ -30,6 +39,8 @@
normalized_stroke_style,
pen_style_for_stroke,
shape_path,
+ shape_rect_from_points,
+ shape_stroke_width,
)
from chemvas.ui.canvas.graphics_items import (
AtomDotItem,
@@ -43,44 +54,6 @@
from chemvas.ui.scene.scene_render_context import SceneRenderContext
-def _radial_orbital_lobes(
- angles_and_phases: tuple[tuple[float, bool], ...],
- rx: float,
- ry: float,
-) -> tuple[tuple[float, float, float, float, bool], ...]:
- return tuple(
- (
- math.cos(math.radians(angle)) * 1.1,
- math.sin(math.radians(angle)) * 1.1,
- rx,
- ry,
- positive,
- )
- for angle, positive in angles_and_phases
- )
-
-
-# Ellipse lobes per orbital kind as (dx, dy, rx, ry, positive_phase), all in
-# units of the base radius around the placement center. mo_antibonding also
-# paints a nodal line between its lobes (handled in build_orbital_items).
-_ORBITAL_LOBE_SPECS: dict[str, tuple[tuple[float, float, float, float, bool], ...]] = {
- "s": ((0.0, 0.0, 1.0, 1.0, True),),
- "p": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, False)),
- "sp": ((-1.2, 0.0, 1.2, 0.7, True), (0.6, 0.0, 0.6, 0.4, False)),
- "sp2": _radial_orbital_lobes(
- ((0.0, True), (120.0, True), (240.0, True)), 0.75, 0.5
- ),
- "sp3": _radial_orbital_lobes(
- ((45.0, True), (135.0, True), (225.0, True), (315.0, True)), 0.7, 0.45
- ),
- "d": _radial_orbital_lobes(
- ((45.0, True), (135.0, False), (225.0, True), (315.0, False)), 0.7, 0.45
- ),
- "mo_bonding": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, True)),
- "mo_antibonding": ((-1.0, 0.0, 1.0, 0.7, True), (1.0, 0.0, 1.0, 0.7, False)),
-}
-
-
class _DotMarkItem(MarkItem, AtomDotItem):
pass
@@ -215,7 +188,9 @@ def refresh_mark_item_geometry(self, item, kind: str) -> None:
"""Use native mark dimensions without replacing identity, color or text."""
selection_radius = atom_pick_radius(self.context.renderer)
if kind == "radical" and isinstance(item, AtomDotItem):
- radius = max(1.2, self.context.renderer.style.bond_line_width * 0.7)
+ radius, _, _ = mark_dimensions(
+ kind, self.context.renderer.style.bond_line_width, 0.0
+ )
rect = QRectF(-radius, -radius, radius * 2.0, radius * 2.0)
item.setRect(rect)
item.set_hit_padding(max(0.0, selection_radius - radius))
@@ -237,11 +212,11 @@ def refresh_mark_item_geometry(self, item, kind: str) -> None:
item, _ChargeCircleMarkItem
):
raise ValueError(f"Cannot refresh mark geometry for {kind!r}")
- radius = max(
- 4.0, QFontMetricsF(self.context.renderer.atom_font()).height() * 0.26
+ radius, stroke_width, symbol_extent = mark_dimensions(
+ kind,
+ self.context.renderer.style.bond_line_width,
+ QFontMetricsF(self.context.renderer.atom_font()).height(),
)
- stroke_width = max(0.9, self.context.renderer.style.bond_line_width * 0.65)
- symbol_extent = radius * 0.48
path = QPainterPath()
path.addEllipse(QRectF(-radius, -radius, radius * 2.0, radius * 2.0))
path.moveTo(-symbol_extent, 0.0)
@@ -275,110 +250,29 @@ def set_mark_center(self, item, center: QPointF) -> None:
item.setPos(center)
def ts_bracket_rect_from_points(self, start: QPointF, end: QPointF) -> QRectF:
- rect = QRectF(start, end).normalized()
- min_width = self.context.renderer.style.bond_length_px * 1.8
- min_height = self.context.renderer.style.bond_length_px * 2.4
- if rect.width() < 4.0 and rect.height() < 4.0:
- return QRectF(
- start.x() - min_width / 2.0,
- start.y() - min_height / 2.0,
- min_width,
- min_height,
- )
- center = rect.center()
- width = max(rect.width(), min_width)
- height = max(rect.height(), min_height)
return QRectF(
- center.x() - width / 2.0, center.y() - height / 2.0, width, height
+ *bracket_rect_from_points(
+ (start.x(), start.y()),
+ (end.x(), end.y()),
+ self.context.renderer.style.bond_length_px,
+ )
)
def ts_bracket_stroke_width(self) -> float:
- return max(0.8, self.context.renderer.style.bond_line_width * 0.58)
-
- def _add_square_bracket_lines(
- self, path: QPainterPath, rect: QRectF, hook: float, *, left: bool
- ) -> None:
- if left:
- x = rect.left()
- path.moveTo(x + hook, rect.top())
- path.lineTo(x, rect.top())
- path.lineTo(x, rect.bottom())
- path.lineTo(x + hook, rect.bottom())
- return
- x = rect.right()
- path.moveTo(x - hook, rect.top())
- path.lineTo(x, rect.top())
- path.lineTo(x, rect.bottom())
- path.lineTo(x - hook, rect.bottom())
-
- def _add_parenthesis_lines(
- self, path: QPainterPath, rect: QRectF, hook: float, *, left: bool
- ) -> None:
- top = rect.top()
- bottom = rect.bottom()
- middle = rect.center().y()
- control = rect.height() * 0.22
- outer_x = rect.left() if left else rect.right()
- inner_x = outer_x + hook if left else outer_x - hook
- path.moveTo(inner_x, top)
- path.cubicTo(outer_x, top + control, outer_x, middle - control, outer_x, middle)
- path.cubicTo(
- outer_x, middle + control, outer_x, bottom - control, inner_x, bottom
- )
-
- def _add_brace_lines(
- self, path: QPainterPath, rect: QRectF, hook: float, *, left: bool
- ) -> None:
- top = rect.top()
- bottom = rect.bottom()
- mid = rect.center().y()
- quarter = rect.height() / 4.0
- sign = 1.0 if left else -1.0
- outer_x = rect.left() if left else rect.right()
- inner_x = outer_x + sign * hook
- waist_x = outer_x + sign * hook * 0.18
- shoulder_x = outer_x + sign * hook * 0.62
- path.moveTo(inner_x, top)
- path.cubicTo(
- outer_x, top, outer_x, top + quarter * 0.55, waist_x, top + quarter
- )
- path.cubicTo(
- shoulder_x,
- top + quarter * 1.32,
- shoulder_x,
- mid - quarter * 0.35,
- outer_x,
- mid,
- )
- path.cubicTo(
- shoulder_x,
- mid + quarter * 0.35,
- shoulder_x,
- bottom - quarter * 1.32,
- waist_x,
- bottom - quarter,
- )
- path.cubicTo(outer_x, bottom - quarter * 0.55, outer_x, bottom, inner_x, bottom)
+ return bracket_stroke_width(self.context.renderer.style.bond_line_width)
def _stroked_bracket_lines(self, rect: QRectF, bracket_kind: str) -> QPainterPath:
rect = QRectF(rect).normalized()
- hook = min(
- rect.width() * 0.18, self.context.renderer.style.bond_length_px * 0.55
- )
- hook = max(hook, self.context.renderer.style.bond_length_px * 0.28)
bracket_lines = QPainterPath()
- if bracket_kind in {"square_pair", "square_left"}:
- self._add_square_bracket_lines(bracket_lines, rect, hook, left=True)
- if bracket_kind == "square_pair":
- self._add_square_bracket_lines(bracket_lines, rect, hook, left=False)
- elif bracket_kind in {"parentheses_pair", "parenthesis_left"}:
- self._add_parenthesis_lines(bracket_lines, rect, hook, left=True)
- if bracket_kind == "parentheses_pair":
- self._add_parenthesis_lines(bracket_lines, rect, hook, left=False)
- elif bracket_kind in {"braces_pair", "brace_left"}:
- self._add_brace_lines(bracket_lines, rect, hook, left=True)
- if bracket_kind == "braces_pair":
- self._add_brace_lines(bracket_lines, rect, hook, left=False)
+ for command, coordinates in bracket_path_commands(
+ rect.getRect(), bracket_kind, self.context.renderer.style.bond_length_px
+ ):
+ if command == "M":
+ bracket_lines.moveTo(*coordinates)
+ elif command == "L":
+ bracket_lines.lineTo(*coordinates)
+ else:
+ bracket_lines.cubicTo(*coordinates)
stroker = QPainterPathStroker()
stroker.setWidth(self.ts_bracket_stroke_width())
@@ -386,27 +280,14 @@ def _stroked_bracket_lines(self, rect: QRectF, bracket_kind: str) -> QPainterPat
stroker.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
return stroker.createStroke(bracket_lines)
- def _bracket_symbol_font(self, rect: QRectF) -> QFont:
- font = QFont(self.context.renderer.style.font_family)
- size = min(
- rect.height() * 0.62, self.context.renderer.style.bond_length_px * 1.35
- )
- # A box 25 tall asks for exactly 15.5 px. Moving a bracket is arithmetic
- # on its edges, which can leave the height one float step short of 25;
- # rounding that noise away first keeps the glyph the size it was.
- font.setPixelSize(max(10, round(round(size, 6))))
- return font
-
def _add_bracket_symbol(
- self, path: QPainterPath, rect: QRectF, symbol: str, *, align_right: bool
+ self, path: QPainterPath, rect: QRectF, symbol: str
) -> QPainterPath:
- font = self._bracket_symbol_font(rect)
- x = (
- rect.right() + rect.width() * 0.035
- if align_right
- else rect.center().x() - font.pixelSize() * 0.2
+ pixels, x, y = bracket_symbol_layout(
+ rect.getRect(), self.context.renderer.style.bond_length_px
)
- y = rect.center().y() + font.pixelSize() * 0.36
+ font = QFont(self.context.renderer.style.font_family)
+ font.setPixelSize(pixels)
path.addText(
x,
y,
@@ -420,12 +301,10 @@ def ts_bracket_path(
) -> QPainterPath:
rect = QRectF(rect).normalized()
bracket_kind = normalized_bracket_kind(bracket_kind)
- if bracket_kind == "dagger":
- path = QPainterPath()
- return self._add_bracket_symbol(path, rect, "\u2020", align_right=False)
- if bracket_kind == "double_dagger":
- path = QPainterPath()
- return self._add_bracket_symbol(path, rect, "\u2021", align_right=False)
+ if bracket_kind in BRACKET_SYMBOLS:
+ return self._add_bracket_symbol(
+ QPainterPath(), rect, BRACKET_SYMBOLS[bracket_kind]
+ )
return self._stroked_bracket_lines(rect, bracket_kind)
@@ -465,19 +344,16 @@ def preview_ts_bracket(
SHAPE_Z_VALUE = -10.0
def shape_rect_from_points(self, start: QPointF, end: QPointF) -> QRectF:
- rect = QRectF(start, end).normalized()
- min_size = self.context.renderer.style.bond_length_px * 1.2
- if rect.width() < 4.0 and rect.height() < 4.0:
- return QRectF(
- start.x() - min_size / 2.0,
- start.y() - min_size / 2.0,
- min_size,
- min_size,
+ return QRectF(
+ *shape_rect_from_points(
+ (start.x(), start.y()),
+ (end.x(), end.y()),
+ self.context.renderer.style.bond_length_px,
)
- return rect
+ )
def shape_stroke_width(self) -> float:
- return max(1.4, self.context.renderer.style.bond_line_width)
+ return shape_stroke_width(self.context.renderer.style.bond_line_width)
def shape_pen(self, stroke_style: str = DEFAULT_STROKE_STYLE) -> QPen:
pen = QPen(QColor(self.context.renderer.style.bond_color))
@@ -531,7 +407,9 @@ def preview_shape(
return item
def build_orbital_items(self, center: QPointF, kind: str):
- radius = self.context.renderer.style.bond_length_px * 0.35
+ ellipses, node_line = orbital_geometry(
+ (center.x(), center.y()), kind, self.context.renderer.style.bond_length_px
+ )
pen = self.context.renderer.bond_pen()
pos_color = QColor(self.context.renderer.style.orbital_positive_color)
neg_color = QColor(self.context.renderer.style.orbital_negative_color)
@@ -540,23 +418,14 @@ def build_orbital_items(self, center: QPointF, kind: str):
phase_enabled = self.context.state.tool_settings_state.orbital_phase_enabled
items: list[QGraphicsItem] = []
- for dx, dy, rx_factor, ry_factor, positive in _ORBITAL_LOBE_SPECS.get(kind, ()):
- cx = center.x() + dx * radius
- cy = center.y() + dy * radius
- rx = rx_factor * radius
- ry = ry_factor * radius
- item = QGraphicsEllipseItem(cx - rx, cy - ry, rx * 2, ry * 2)
+ for x, y, width, height, positive in ellipses:
+ item = QGraphicsEllipseItem(x, y, width, height)
item.setPen(pen)
if phase_enabled:
item.setBrush(pos_color if positive else neg_color)
items.append(item)
- if kind == "mo_antibonding":
- node = NoSelectLineItem(
- center.x(),
- center.y() - radius * 0.8,
- center.x(),
- center.y() + radius * 0.8,
- )
+ if node_line is not None:
+ node = NoSelectLineItem(*node_line)
node.setPen(pen)
items.append(node)
return items
diff --git a/app/chemvas/ui/annotations/items.py b/app/chemvas/ui/annotations/items.py
index 89abc60e..9f3b0114 100644
--- a/app/chemvas/ui/annotations/items.py
+++ b/app/chemvas/ui/annotations/items.py
@@ -377,6 +377,26 @@ def __init__(
self._lobe_center = QPointF(*record.center)
self._render_geometry()
+ def refresh_lobes(
+ self,
+ build_orbital_items: Callable[[QPointF, str], list[QGraphicsItem]],
+ base_handle_dist: float,
+ ) -> None:
+ record = self.document.records[self.record_id]
+ lobes = build_orbital_items(self._lobe_center, record.kind)
+ scene = self.scene()
+ for child in self.childItems():
+ self.removeFromGroup(child)
+ if scene is not None:
+ scene.removeItem(child)
+ self.setPos(0, 0)
+ self.setScale(1)
+ self.setRotation(0)
+ for lobe in lobes:
+ self.addToGroup(lobe)
+ self.base_handle_dist = base_handle_dist
+ self._render_geometry()
+
def orbital_state(self) -> dict[str, object]:
return orbital_to_state(self.document.records[self.record_id])
diff --git a/app/chemvas/ui/annotations/records.py b/app/chemvas/ui/annotations/records.py
index 7b0445b7..8d56a27d 100644
--- a/app/chemvas/ui/annotations/records.py
+++ b/app/chemvas/ui/annotations/records.py
@@ -18,6 +18,7 @@
ts_bracket_from_state,
ts_bracket_to_state,
)
+from chemvas.domain.document.ts_brackets import moved_ts_bracket
from chemvas.ui.annotations.shape_geometry import shape_path
from chemvas.ui.scene.scene_record_ids import (
bind_scene_record,
@@ -180,16 +181,6 @@ def ts_bracket_rect_of(ts_bracket: TSBracket) -> QRectF:
)
-def moved_ts_bracket(ts_bracket: TSBracket, dx: float, dy: float) -> TSBracket:
- return replace(
- ts_bracket,
- left=ts_bracket.left + dx,
- top=ts_bracket.top + dy,
- right=ts_bracket.right + dx,
- bottom=ts_bracket.bottom + dy,
- )
-
-
def render_ts_bracket_item(
context: SceneRenderContext, item: Any, ts_bracket: TSBracket
) -> None:
diff --git a/app/chemvas/ui/annotations/shape_geometry.py b/app/chemvas/ui/annotations/shape_geometry.py
index ac8656e4..23ff52ad 100644
--- a/app/chemvas/ui/annotations/shape_geometry.py
+++ b/app/chemvas/ui/annotations/shape_geometry.py
@@ -7,8 +7,14 @@
from __future__ import annotations
-from PyQt6.QtCore import QRectF, Qt
-from PyQt6.QtGui import QPainterPath
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from PyQt6.QtCore import QRectF, Qt
+ from PyQt6.QtGui import QPainterPath
+
+# The smaller, edge-midpoint resize handle; HANDLE_SCREEN_PX sizes the rest.
+EDGE_HANDLE_SCREEN_PX = 6.0
# Order is the order shown in the option bar.
SHAPE_KINDS: tuple[str, ...] = ("circle", "ellipse", "rounded_rect", "rect")
@@ -19,13 +25,6 @@
STROKE_STYLES: tuple[str, ...] = ("solid", "dashed", "dotted", "none")
DEFAULT_STROKE_STYLE = "solid"
-_PEN_STYLE_BY_STROKE: dict[str, Qt.PenStyle] = {
- "solid": Qt.PenStyle.SolidLine,
- "dashed": Qt.PenStyle.DashLine,
- "dotted": Qt.PenStyle.DotLine,
- "none": Qt.PenStyle.NoPen,
-}
-
# Corner radius of a rounded rectangle as a fraction of its shorter side.
_ROUNDED_CORNER_FRACTION = 0.28
_ROUNDED_CORNER_MAX = 18.0
@@ -42,33 +41,102 @@ def normalized_stroke_style(
def pen_style_for_stroke(stroke_style: object) -> Qt.PenStyle:
- return _PEN_STYLE_BY_STROKE[normalized_stroke_style(stroke_style)]
+ from PyQt6.QtCore import Qt
+
+ return {
+ "solid": Qt.PenStyle.SolidLine,
+ "dashed": Qt.PenStyle.DashLine,
+ "dotted": Qt.PenStyle.DotLine,
+ "none": Qt.PenStyle.NoPen,
+ }[normalized_stroke_style(stroke_style)]
+
+
+def shape_rect_from_points(
+ start: tuple[float, float], end: tuple[float, float], bond_length: float
+) -> tuple[float, float, float, float]:
+ left, right = sorted((start[0], end[0]))
+ top, bottom = sorted((start[1], end[1]))
+ width, height = right - left, bottom - top
+ min_size = bond_length * 1.2
+ if width < 4.0 and height < 4.0:
+ return start[0] - min_size / 2, start[1] - min_size / 2, min_size, min_size
+ return left, top, width, height
+
+
+def shape_outline(
+ x: float, y: float, width: float, height: float, shape_kind: object
+) -> tuple[str, float, float, float, float, float]:
+ """Original outline geometry; Qt/SVG only materialize its ellipse or rectangle."""
+ kind = normalized_shape_kind(shape_kind)
+ radius = 0.0
+ if kind == "circle":
+ diameter = min(width, height)
+ x, y = x + width / 2 - diameter / 2, y + height / 2 - diameter / 2
+ width = height = diameter
+ kind = "ellipse"
+ elif kind == "rounded_rect":
+ radius = min(_ROUNDED_CORNER_MAX, min(width, height) * _ROUNDED_CORNER_FRACTION)
+ kind = "rect"
+ return kind, x, y, width, height, radius
+
+
+def shape_stroke_width(bond_line_width: float) -> float:
+ return max(1.4, bond_line_width)
+
+
+def shape_handle_positions(
+ bounds: tuple[float, float, float, float],
+) -> list[tuple[str, tuple[float, float]]]:
+ left, top, right, bottom = bounds
+ cx, cy = (left + right) / 2, (top + bottom) / 2
+ return [
+ ("shape_nw", (left, top)),
+ ("shape_n", (cx, top)),
+ ("shape_ne", (right, top)),
+ ("shape_e", (right, cy)),
+ ("shape_se", (right, bottom)),
+ ("shape_s", (cx, bottom)),
+ ("shape_sw", (left, bottom)),
+ ("shape_w", (left, cy)),
+ ]
+
+
+def resized_shape_bounds(
+ bounds: tuple[float, float, float, float],
+ anchor: str,
+ pos: tuple[float, float],
+ *,
+ min_size: float = 8.0,
+) -> tuple[float, float, float, float]:
+ left, top, right, bottom = bounds
+ direction = anchor.removeprefix("shape_")
+ if "w" in direction:
+ left = min(pos[0], right - min_size)
+ if "e" in direction:
+ right = max(pos[0], left + min_size)
+ if "n" in direction:
+ top = min(pos[1], bottom - min_size)
+ if "s" in direction:
+ bottom = max(pos[1], top + min_size)
+ return left, top, right, bottom
def shape_path(rect: QRectF, shape_kind: object) -> QPainterPath:
"""Build the outline path for ``shape_kind`` inside ``rect``."""
- kind = normalized_shape_kind(shape_kind)
+ from PyQt6.QtCore import QRectF
+ from PyQt6.QtGui import QPainterPath
+
bounds = QRectF(rect).normalized()
+ kind, x, y, width, height, radius = shape_outline(
+ bounds.x(), bounds.y(), bounds.width(), bounds.height(), shape_kind
+ )
+ bounds = QRectF(x, y, width, height)
path = QPainterPath()
- if kind == "circle":
- diameter = min(bounds.width(), bounds.height())
- center = bounds.center()
- square = QRectF(
- center.x() - diameter / 2.0,
- center.y() - diameter / 2.0,
- diameter,
- diameter,
- )
- path.addEllipse(square)
- elif kind == "ellipse":
+ if kind == "ellipse":
path.addEllipse(bounds)
- elif kind == "rounded_rect":
- radius = min(
- _ROUNDED_CORNER_MAX,
- min(bounds.width(), bounds.height()) * _ROUNDED_CORNER_FRACTION,
- )
+ elif radius:
path.addRoundedRect(bounds, radius, radius)
- else: # "rect"
+ else:
path.addRect(bounds)
return path
@@ -81,5 +149,8 @@ def shape_path(rect: QRectF, shape_kind: object) -> QPainterPath:
"normalized_shape_kind",
"normalized_stroke_style",
"pen_style_for_stroke",
+ "shape_outline",
"shape_path",
+ "shape_rect_from_points",
+ "shape_stroke_width",
]
diff --git a/app/chemvas/ui/annotations/state.py b/app/chemvas/ui/annotations/state.py
index eade1f81..c87b41a5 100644
--- a/app/chemvas/ui/annotations/state.py
+++ b/app/chemvas/ui/annotations/state.py
@@ -13,6 +13,7 @@
)
from chemvas.domain.document import VALID_ARROW_KINDS
+from chemvas.domain.document.marks import mark_center_coordinates
from chemvas.features.annotations import normalized_bracket_kind, sanitize_note_html
from chemvas.ui.annotations.items import ImageItem, NoteItem, OrbitalItem, RingFillItem
from chemvas.ui.annotations.marks import MarkItem
@@ -400,20 +401,8 @@ def _restore_mark_position(item, state, model_atoms, mark_center_setter) -> None
def mark_center_from_state(
state: Mapping[str, object], model_atoms: Mapping[int, Any]
) -> QPointF | None:
- center = None
- atom_id = state.get("atom_id")
- dx = state.get("dx")
- dy = state.get("dy")
- if isinstance(atom_id, int) and atom_id in model_atoms:
- atom = model_atoms[atom_id]
- if isinstance(dx, (int, float)) and isinstance(dy, (int, float)):
- center = QPointF(atom.x + dx, atom.y + dy)
- if center is None:
- x = state.get("x")
- y = state.get("y")
- if isinstance(x, (int, float)) and isinstance(y, (int, float)):
- center = QPointF(float(x), float(y))
- return center
+ center = mark_center_coordinates(state, model_atoms)
+ return None if center is None else QPointF(*center)
__all__ = [
diff --git a/app/chemvas/ui/canvas/canvas_background_painter.py b/app/chemvas/ui/canvas/canvas_background_painter.py
index 5bc9ebee..e6d05dd4 100644
--- a/app/chemvas/ui/canvas/canvas_background_painter.py
+++ b/app/chemvas/ui/canvas/canvas_background_painter.py
@@ -1,17 +1,15 @@
from __future__ import annotations
-import math
-
from PyQt6.QtCore import QLineF, Qt
from PyQt6.QtGui import QColor, QPen
-from chemvas.features.rendering import hex_grid_cells
+from chemvas.features.rendering import grid_lines
+from chemvas.ui.canvas.canvas_tool_settings_state import (
+ GRID_COLOR,
+ MIN_GRID_SPACING_PX,
+ grid_step_for,
+)
from chemvas.ui.canvas.sheet_setup_access import sheet_rect_for
-from chemvas.ui.tools.endpoint_snap_access import grid_step_for
-
-# Below this on-screen spacing the grid reads as a grey wash rather than as a
-# guide, so it is left unpainted while the snapping itself keeps working.
-MIN_GRID_SPACING_PX = 6.0
def draw_canvas_background_for(canvas, painter, rect) -> None:
@@ -55,37 +53,23 @@ def _draw_grid(canvas, painter, rect, sheet_rect) -> None:
if step * scale < MIN_GRID_SPACING_PX:
return
settings = canvas.runtime_state.tool_settings_state
- color = QColor("#8c8c87")
+ color = QColor(GRID_COLOR)
color.setAlphaF(settings.grid_opacity)
pen = QPen(color)
pen.setWidthF(0.0)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setClipRect(area, Qt.ClipOperation.IntersectClip)
- if settings.grid_style == "hex":
- cells = hex_grid_cells(
- (area.left(), area.top(), area.right(), area.bottom()), step=step
- )
- # Three sides per cell cover each shared edge once, preserving alpha.
- lines = [QLineF(*cell[i], *cell[i + 1]) for cell in cells for i in range(3)]
- else:
- first_x = math.ceil(area.left() / step) * step
- first_y = math.ceil(area.top() / step) * step
- lines = [
- QLineF(x, area.top(), x, area.bottom())
- for x in _grid_coordinates(first_x, area.right(), step)
- ]
- lines.extend(
- QLineF(area.left(), y, area.right(), y)
- for y in _grid_coordinates(first_y, area.bottom(), step)
+ lines = [
+ QLineF(*line)
+ for line in grid_lines(
+ (area.left(), area.top(), area.right(), area.bottom()),
+ step=step,
+ style=settings.grid_style,
)
+ ]
if lines:
painter.drawLines(lines)
-def _grid_coordinates(first: float, limit: float, step: float) -> list[float]:
- count = int((limit - first) / step) + 1 if limit >= first else 0
- return [first + index * step for index in range(max(0, count))]
-
-
__all__ = ["draw_canvas_background_for"]
diff --git a/app/chemvas/ui/canvas/canvas_chemdraw_shortcut_service.py b/app/chemvas/ui/canvas/canvas_chemdraw_shortcut_service.py
index f93c7197..0d8764fb 100644
--- a/app/chemvas/ui/canvas/canvas_chemdraw_shortcut_service.py
+++ b/app/chemvas/ui/canvas/canvas_chemdraw_shortcut_service.py
@@ -2,20 +2,14 @@
from typing import TYPE_CHECKING, ClassVar
-from PyQt6.QtCore import Qt
-
from chemvas.features.annotations import DEFAULT_BRACKET_KIND
-from chemvas.features.rendering import (
- DOTTED_DOUBLE_STYLE_DEFAULT,
- DOUBLE_STYLE_CENTER,
- DOUBLE_STYLE_DEFAULT,
- DOUBLE_STYLE_OUTER,
- bold_double_style_for_style,
- style_for_double_position,
-)
-from chemvas.ui.canvas.input_view_access import (
- chemdraw_shortcut_text_for,
- shortcut_modifiers_for,
+from chemvas.ui.canvas.canvas_tool_settings_state import CanvasToolSettingsState
+from chemvas.ui.tools.bond_tool_logic import BOND_SHORTCUT_KEYS, bond_shortcut_style
+from chemvas.ui.window.main_window_config import (
+ ARROW_KEY_NUDGE,
+ ARROW_KEY_ROTATION_DEGREES,
+ SHIFT_TOOL_HOTKEYS,
+ TOOL_HOTKEYS,
)
if TYPE_CHECKING:
@@ -37,28 +31,6 @@ class CanvasChemdrawShortcutService:
DEFAULT_ORBITAL_TYPE = "s"
DEFAULT_MARK_KIND = "plus"
- DOUBLE_POSITION_STYLES: ClassVar[dict[str, str]] = {
- "c": DOUBLE_STYLE_CENTER,
- "l": DOUBLE_STYLE_DEFAULT,
- "r": DOUBLE_STYLE_OUTER,
- }
-
- ROTATE_ARROW_ANGLES: ClassVar[dict[int, float]] = {
- Qt.Key.Key_Up: -15.0,
- Qt.Key.Key_Down: 15.0,
- Qt.Key.Key_Left: -1.0,
- Qt.Key.Key_Right: 1.0,
- }
-
- NUDGE_STEP = 10.0
-
- NUDGE_ARROW_OFFSETS: ClassVar[dict[int, tuple[float, float]]] = {
- Qt.Key.Key_Up: (0.0, -NUDGE_STEP),
- Qt.Key.Key_Down: (0.0, NUDGE_STEP),
- Qt.Key.Key_Left: (-NUDGE_STEP, 0.0),
- Qt.Key.Key_Right: (NUDGE_STEP, 0.0),
- }
-
LABEL_HOTKEYS: ClassVar[dict[str, str]] = {
"f": "F",
"F": "CF3",
@@ -96,6 +68,9 @@ class CanvasChemdrawShortcutService:
"K": "t-Bu",
}
+ ATOM_HOTKEYS = frozenset(LABEL_HOTKEYS) | frozenset("0123456789azvu+-")
+ BOND_HOTKEYS = BOND_SHORTCUT_KEYS | frozenset("4567890a")
+
def __init__(
self,
model_provider: Callable[[], MoleculeModel],
@@ -140,6 +115,19 @@ def handle_shortcut(self, event: QKeyEvent) -> bool:
return self.handle_generic_hotkey(event)
def handle_object_shortcut(self, event: QKeyEvent) -> bool:
+ from PyQt6.QtCore import Qt
+
+ from chemvas.ui.canvas.input_view_access import shortcut_modifiers_for
+
+ rotate_arrow_angles = {
+ getattr(Qt.Key, f"Key_{name}"): angle
+ for name, angle in ARROW_KEY_ROTATION_DEGREES.items()
+ }
+ nudge_arrow_offsets = {
+ getattr(Qt.Key, f"Key_{name}"): offset
+ for name, offset in ARROW_KEY_NUDGE.items()
+ }
+
modifiers = shortcut_modifiers_for(event)
if modifiers == (
Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier
@@ -151,46 +139,46 @@ def handle_object_shortcut(self, event: QKeyEvent) -> bool:
self.scene_transform.flip_selected_items(horizontal=False)
return True
if modifiers == Qt.KeyboardModifier.AltModifier:
- angle = self.ROTATE_ARROW_ANGLES.get(event.key())
+ angle = rotate_arrow_angles.get(event.key())
if angle is not None:
self.scene_transform.rotate_selected_items(angle)
return True
if modifiers == Qt.KeyboardModifier.ShiftModifier:
- offset = self.NUDGE_ARROW_OFFSETS.get(event.key())
+ offset = nudge_arrow_offsets.get(event.key())
if offset is not None:
return bool(self.scene_transform.translate_selected_items(*offset))
return False
def handle_generic_hotkey(self, event: QKeyEvent) -> bool:
+ from PyQt6.QtCore import Qt
+
+ from chemvas.ui.canvas.input_view_access import shortcut_modifiers_for
+
modifiers = shortcut_modifiers_for(event)
if modifiers == Qt.KeyboardModifier.NoModifier:
- if event.key() == Qt.Key.Key_Space:
- self.tool_mode.set_tool("select")
- return True
- if event.key() == Qt.Key.Key_X:
- self.tool_mode.set_bond_style("single", 1)
- return True
- if event.key() == Qt.Key.Key_A:
- self.tool_mode.set_tool("text")
- return True
- if event.key() == Qt.Key.Key_T:
- self.tool_mode.set_tool("note")
- return True
- if event.key() == Qt.Key.Key_E:
+ key = chr(event.key()).lower() if 0 <= event.key() < 128 else ""
+ tool = TOOL_HOTKEYS.get(key)
+ if tool == "bond":
+ defaults = CanvasToolSettingsState()
+ self.tool_mode.set_bond_style(
+ defaults.active_bond_style, defaults.active_bond_order
+ )
+ elif tool == "arrow":
self.tool_mode.set_arrow_type(self.DEFAULT_ARROW_TYPE)
- return True
- if event.key() == Qt.Key.Key_J:
- self.tool_mode.set_tool("benzene")
+ elif tool is not None:
+ self.tool_mode.set_tool(tool)
+ if tool is not None:
return True
if modifiers == Qt.KeyboardModifier.ShiftModifier:
- if event.key() == Qt.Key.Key_T:
+ key = chr(event.key()) if 0 <= event.key() < 128 else ""
+ tool = SHIFT_TOOL_HOTKEYS.get(key)
+ if tool == "ts_bracket":
self.tool_mode.set_bracket_type(DEFAULT_BRACKET_KIND)
- return True
- if event.key() == Qt.Key.Key_G:
+ elif tool == "orbital":
self.tool_mode.set_orbital_type(self.DEFAULT_ORBITAL_TYPE)
- return True
- if event.key() == Qt.Key.Key_E:
+ elif tool == "mark":
self.tool_mode.set_mark_kind(self.DEFAULT_MARK_KIND)
+ if tool is not None:
return True
if modifiers == Qt.KeyboardModifier.AltModifier and event.key() == Qt.Key.Key_D:
self.tool_mode.set_tool("perspective")
@@ -198,6 +186,13 @@ def handle_generic_hotkey(self, event: QKeyEvent) -> bool:
return False
def handle_atom_hotkey(self, event: QKeyEvent, atom_id: int) -> bool:
+ from PyQt6.QtCore import Qt
+
+ from chemvas.ui.canvas.input_view_access import (
+ chemdraw_shortcut_text_for,
+ shortcut_modifiers_for,
+ )
+
if self._model().atom_for_id(atom_id) is None:
return False
modifiers = shortcut_modifiers_for(event)
@@ -210,6 +205,12 @@ def handle_atom_hotkey(self, event: QKeyEvent, atom_id: int) -> bool:
self.atom_labels.prompt_atom_label(atom_id)
return True
text = chemdraw_shortcut_text_for(event)
+ return self.handle_atom_text(text, atom_id)
+
+ def handle_atom_text(self, text: str, atom_id: int) -> bool:
+ """Run the native atom-key decisions after an input adapter normalizes text."""
+ if self._model().atom_for_id(atom_id) is None:
+ return False
if not text:
return False
if text == "+":
@@ -266,6 +267,13 @@ def handle_atom_hotkey(self, event: QKeyEvent, atom_id: int) -> bool:
return False
def handle_bond_hotkey(self, event: QKeyEvent, bond_id: int) -> bool:
+ from PyQt6.QtCore import Qt
+
+ from chemvas.ui.canvas.input_view_access import (
+ chemdraw_shortcut_text_for,
+ shortcut_modifiers_for,
+ )
+
bond = self._model().bond_for_id(bond_id)
if bond is None:
return False
@@ -276,69 +284,26 @@ def handle_bond_hotkey(self, event: QKeyEvent, bond_id: int) -> bool:
):
return False
text = chemdraw_shortcut_text_for(event)
- if bond.style == "double_either" and (
- text in {"b", "d", *self.DOUBLE_POSITION_STYLES}
- or (
- modifiers == Qt.KeyboardModifier.ShiftModifier
- and event.key() in {Qt.Key.Key_B, Qt.Key.Key_D}
- )
- ):
- self._notify_error(
- "This appearance change would erase unknown double-bond stereo. "
- "Choose Double (2) first to clear it explicitly.",
- )
- return True
- if modifiers == Qt.KeyboardModifier.ShiftModifier:
- if event.key() == Qt.Key.Key_B:
- # 'b' applies a bold single; Shift+B upgrades to a bold double
- # (order 2 renders via the bold multi-line path).
- self.scene_transform.apply_bond_style(
- bond_id,
- bold_double_style_for_style(bond.style, bond.order),
- 2,
- )
- return True
- if event.key() == Qt.Key.Key_H:
- self.scene_transform.apply_bond_style(bond_id, "hash", 1)
- return True
- if event.key() == Qt.Key.Key_D:
- self.scene_transform.apply_bond_style(
- bond_id, DOTTED_DOUBLE_STYLE_DEFAULT, 2
- )
- return True
- if text == "d":
- self.scene_transform.apply_bond_style(bond_id, "dotted", 1)
- return True
- if text in self.DOUBLE_POSITION_STYLES:
- if bond.order != 2:
- return False
- position_style = self.DOUBLE_POSITION_STYLES[text]
- target_style = style_for_double_position(
- bond.style, bond.order, position_style
- )
- # Preserve the previous shortcut behavior for other order-2 styles:
- # l/c/r converts those bonds back to an ordinary double.
- self.scene_transform.apply_bond_style(
- bond_id, target_style or position_style, 2
- )
- return True
- if text == "1":
- self.scene_transform.apply_bond_style(bond_id, "single", 1)
- return True
- if text == "2":
- self.scene_transform.apply_bond_style(bond_id, "double", 2)
- return True
- if text == "3":
- self.scene_transform.apply_bond_style(bond_id, "triple", 3)
- return True
- if text == "b":
- self.scene_transform.apply_bond_style(bond_id, "bold_in", 1)
- return True
- if text == "w":
- self.scene_transform.apply_bond_style(bond_id, "wedge", 1)
+ if modifiers == Qt.KeyboardModifier.ShiftModifier and event.key() in {
+ Qt.Key.Key_B,
+ Qt.Key.Key_H,
+ Qt.Key.Key_D,
+ }:
+ text = chr(event.key())
+ return self.handle_bond_text(text, bond_id)
+
+ def handle_bond_text(self, text: str, bond_id: int) -> bool:
+ """Run the native bond-key decisions with either presentation adapter."""
+ bond = self._model().bond_for_id(bond_id)
+ if bond is None:
+ return False
+ try:
+ style = bond_shortcut_style(bond, text)
+ except ValueError as error:
+ self._notify_error(str(error))
return True
- if text == "h":
- self.scene_transform.apply_bond_style(bond_id, "hash", 1)
+ if style is not None:
+ self.scene_transform.apply_bond_style(bond_id, *style)
return True
if text == "a":
self.structure_build.fuse_benzene_to_bond(bond_id)
diff --git a/app/chemvas/ui/canvas/canvas_color_mutation_service.py b/app/chemvas/ui/canvas/canvas_color_mutation_service.py
index 4c52f639..d05ed034 100644
--- a/app/chemvas/ui/canvas/canvas_color_mutation_service.py
+++ b/app/chemvas/ui/canvas/canvas_color_mutation_service.py
@@ -13,7 +13,8 @@
HistoryCommand,
)
from chemvas.core.model_commands import UpdateAtomColorCommand
-from chemvas.features.graph import find_rings
+from chemvas.features.graph import selected_ring_cycles
+from chemvas.shell.palette import RING_FILL_TINT, SHAPE_FILL_TINT, pastel_rgb
from chemvas.ui.annotations.materialize import restore_ring_projections
from chemvas.ui.annotations.records import (
require_shape_record_for,
@@ -38,6 +39,7 @@
from chemvas.ui.scene.scene_item_access import item_is_in_canvas_scene
from chemvas.ui.transactions.document import document_transaction
from chemvas.ui.transactions.scene_runtime import graphics_item_is_deleted
+from chemvas.ui.window.main_window_config import COLOR_TOOL_MESSAGES, RING_FILL_GUIDANCE
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
@@ -83,7 +85,7 @@ def redo(self, operations: HistoryBondColorOperations) -> None:
class CanvasColorMutationService:
# Opaque pastel panels read as tinted paper behind a structure.
- SHAPE_FILL_TINT = 0.12
+ SHAPE_FILL_TINT = SHAPE_FILL_TINT
def __init__(
self,
@@ -144,8 +146,7 @@ def apply_color_to_items(self, items: Iterable[object], color: QColor) -> None:
):
notify_error_for(
self.canvas,
- "Color stored for implicit carbon; hidden carbon vertices stay hidden. "
- "Color the bonds or show an explicit atom label for visible color.",
+ COLOR_TOOL_MESSAGES["hidden"],
)
def _apply_color(self, item, color: QColor) -> list[HistoryCommand]:
@@ -201,11 +202,7 @@ def _apply_color(self, item, color: QColor) -> list[HistoryCommand]:
),
)
if kind == "ts_bracket":
- notify_error_for(
- self.canvas,
- "TS brackets and daggers use the document bond color; "
- "per-item color is not supported.",
- )
+ notify_error_for(self.canvas, COLOR_TOOL_MESSAGES["ts_bracket"])
return []
def _mutate_scene_item(
@@ -225,11 +222,7 @@ def _mutate_scene_item(
@staticmethod
def _pastel_fill(color: QColor, tint: float) -> QColor:
- return QColor(
- round(255 - (255 - color.red()) * tint),
- round(255 - (255 - color.green()) * tint),
- round(255 - (255 - color.blue()) * tint),
- )
+ return QColor(*pastel_rgb((color.red(), color.green(), color.blue()), tint))
def _apply_atom_item_graphic(self, item, color: QColor) -> None:
if isinstance(item, QGraphicsTextItem):
@@ -264,7 +257,9 @@ def _apply_atom_color(self, item, color: QColor) -> list[HistoryCommand]:
)
return [UpdateAtomColorCommand(atom_id, before, atom.color)]
- def apply_ring_fill_color(self, item, color: QColor, alpha: float = 0.25) -> None:
+ def apply_ring_fill_color(
+ self, item, color: QColor, alpha: float = RING_FILL_TINT
+ ) -> None:
if (
item is not None
and not graphics_item_is_deleted(item)
@@ -273,7 +268,7 @@ def apply_ring_fill_color(self, item, color: QColor, alpha: float = 0.25) -> Non
self.apply_ring_fill_color_to_items([item], color, alpha)
def apply_ring_fill_color_to_items(
- self, items: Iterable[object], color: QColor, alpha: float = 0.25
+ self, items: Iterable[object], color: QColor, alpha: float = RING_FILL_TINT
) -> None:
if not color.isValid():
return
@@ -302,19 +297,9 @@ def apply_ring_fill_color_to_items(
frozenset(item.data(2)): item
for item in restore_ring_projections(self.canvas.render_context)
}
- atom_selection_bonds = [
- bond
- for bond in self.canvas.model.bonds
- if bond is not None and {bond.a, bond.b} <= selected_atoms
- ]
- # A complete atom selection OR a complete bond selection qualifies.
- # Combining their endpoints would invent unselected cycle edges.
- selected_rings = find_rings(atom_selection_bonds) + find_rings(
- self.canvas.model.bond_for_id(bond_id)
- for bond_id in sorted(selected_bond_ids)
- )
- unique_rings = {frozenset(ring): ring for ring in selected_rings}
- for ring in unique_rings.values():
+ for ring in selected_ring_cycles(
+ self.canvas.model.bonds, selected_atoms, selected_bond_ids
+ ):
item = existing.get(frozenset(ring))
if item is not None:
if item not in targets:
@@ -333,7 +318,7 @@ def apply_ring_fill_color_to_items(
if alpha > 0:
notify_error_for(
self.canvas,
- "Ring Fill: select a complete ring (all its atoms or bonds) first.",
+ RING_FILL_GUIDANCE,
)
return
diff --git a/app/chemvas/ui/canvas/canvas_document_session_service.py b/app/chemvas/ui/canvas/canvas_document_session_service.py
index ef825a36..82a1d0d5 100644
--- a/app/chemvas/ui/canvas/canvas_document_session_service.py
+++ b/app/chemvas/ui/canvas/canvas_document_session_service.py
@@ -10,7 +10,7 @@
atomic_write_text,
write_document,
)
-from chemvas.core.molfile import MolfileError, MolfileLimitError, write_molfile
+from chemvas.core.molfile import export_molfile_block
from chemvas.core.rdkit_adapter import RDKitAdapter
from chemvas.core.rdkit_diagnostics import RDKIT_UNAVAILABLE_MESSAGE
from chemvas.core.svg_roundtrip import (
@@ -629,29 +629,10 @@ def export_mol(self, path: str, *, selected_only: bool = False) -> None:
export_model, atom_annotations = self._build_xyz_payload(
selected_only=selected_only
)
- if not export_model.atoms:
- raise ValueError("There is no molecular structure to export.")
- block: str | None
- try:
- block = write_molfile(export_model, atom_annotations=atom_annotations)
- except MolfileLimitError:
- # Hard V2000 capacity/range limits hold for any writer; falling
- # back to RDKit would either mask them or blame missing RDKit.
- raise
- except MolfileError as exc:
- # The structure uses abbreviation labels (Ph, CF3, ...) that are not
- # single elements. Fall back to RDKit, which expands them into explicit
- # atoms; without RDKit there is no way to expand them.
- block = self.canvas.rdkit.model_to_mol_block(
- export_model, atom_annotations=atom_annotations
- )
- if block is None:
- reason = getattr(self.canvas.rdkit, "last_error", None)
- if not reason or "not available" in reason.lower():
- raise ValueError(
- f"{exc} Install RDKit to expand these abbreviations automatically."
- ) from exc
- raise ValueError(reason) from exc
+ # The one MOL policy the browser export shares: writer, hard limits, RDKit.
+ block = export_molfile_block(
+ export_model, atom_annotations=atom_annotations, rdkit=self.canvas.rdkit
+ )
atomic_write_text(path, block)
def export_xyz_async(
@@ -700,7 +681,10 @@ def export_figure(
max_height_mm: float | None = None,
min_font_pt: float | None = None,
) -> ExportPlan:
- exporter = FigureExportService(self.canvas.render_context)
+ exporter = FigureExportService(
+ self.canvas.render_context,
+ groups=self.canvas.runtime_state.group_state.groups,
+ )
return exporter.export_figure(
path,
fmt=fmt,
diff --git a/app/chemvas/ui/canvas/canvas_document_state.py b/app/chemvas/ui/canvas/canvas_document_state.py
index c9f35589..54b8be8b 100644
--- a/app/chemvas/ui/canvas/canvas_document_state.py
+++ b/app/chemvas/ui/canvas/canvas_document_state.py
@@ -1,15 +1,14 @@
from __future__ import annotations
-import math
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt
from chemvas.domain.document import (
VALID_MARK_KINDS,
- CalculationPlanGraphMismatchError,
arrow_to_state,
calculation_plan_from_state,
+ calculation_plan_save_warning,
is_hex_color,
model_bond_pairs,
ring_atom_ids_form_cycle,
@@ -22,7 +21,9 @@
from chemvas.domain.document.marks import mark_to_state
from chemvas.domain.document.notes import note_to_document_state
from chemvas.domain.document.orbitals import orbital_to_state
+from chemvas.domain.document.perspective import saved_perspective
from chemvas.domain.document.ring_fills import ring_fill_to_state
+from chemvas.features.groups import restored_groups, snapshot_groups
from chemvas.ui.annotations.state import (
note_state_dict_for,
)
@@ -35,9 +36,6 @@
sheet_orientation_for,
sheet_size_for,
)
-from chemvas.ui.molecule.atom_coords_access import (
- stored_atom_coords_3d_matches_projection_for,
-)
if TYPE_CHECKING:
from chemvas.ui.canvas.canvas_view import CanvasView
@@ -94,23 +92,12 @@ def snapshot_canvas_document_state_with_warnings(canvas) -> tuple[dict, list[str
_add_projection_state(canvas, state)
if canvas.runtime_state.image_state.order:
state["images"] = canvas.runtime_state.image_state.snapshot(image_to_state)
- try:
- calculation_plan = savable_calculation_plan_for(canvas)
- except CalculationPlanGraphMismatchError:
- warnings.append(
- "The calculation plan was not saved because the molecular graph "
- "no longer matches its component references. Undo the graph edit "
- "to recover those references, or reopen a previously saved copy."
- )
- except ValueError as exc:
- warnings.append(
- f"The calculation plan was not saved because it is invalid: {exc} "
- "Reopen a previously saved copy, or attach a repaired plan using "
- "chemvas attach-plan."
- )
- else:
- if calculation_plan is not None:
- state["calculation_plan"] = calculation_plan
+ calculation_plan = calculation_plan_for(canvas)
+ plan_warning = calculation_plan_save_warning(canvas.model, calculation_plan)
+ if plan_warning is not None:
+ warnings.append(plan_warning)
+ elif calculation_plan is not None:
+ state["calculation_plan"] = calculation_plan
groups = _snapshot_groups(canvas)
if groups:
state["groups"] = groups
@@ -136,32 +123,16 @@ def savable_calculation_plan_for(canvas: CanvasView) -> dict[str, object] | None
def _add_projection_state(canvas, state: dict) -> None:
- model = canvas.model
- coords_3d = {
- atom_id: coords
- for atom_id, coords in canvas.runtime_state.atom_coords_3d_state.atom_coords_3d.items()
- if stored_atom_coords_3d_matches_projection_for(canvas, atom_id, coords)
- }
- if not coords_3d:
- return
rotation = canvas.runtime_state.rotation_state
- state["perspective"] = {
- "atom_coords_3d": {
- atom_id: coords
- for atom_id, coords in coords_3d.items()
- if atom_id in model.atoms
- },
- "projection_center_3d": _finite_point_or_none(rotation.projection_center_3d),
- "projection_anchor_2d": _finite_point_or_none(rotation.projection_anchor_2d),
- }
-
-
-def _finite_point_or_none(point):
- if point is None:
- return None
- if all(isinstance(value, (int, float)) and math.isfinite(value) for value in point):
- return point
- return None
+ perspective = saved_perspective(
+ canvas.runtime_state.atom_coords_3d_state.atom_coords_3d,
+ {atom_id: (atom.x, atom.y) for atom_id, atom in canvas.model.atoms.items()},
+ rotation.projection_center_3d,
+ rotation.projection_anchor_2d,
+ bond_length_px=canvas.renderer.style.bond_length_px,
+ )
+ if perspective is not None:
+ state["perspective"] = perspective
def _alignment_name(alignment) -> str:
@@ -174,7 +145,7 @@ def _alignment_name(alignment) -> str:
return "left"
-_GROUP_COLLECTIONS = {
+GROUP_COLLECTION_STATES = {
"images": "image_items",
"notes": "note_items",
"marks": "mark_items",
@@ -201,65 +172,33 @@ def document_item_lists_for(canvas) -> dict[str, list]:
def _snapshot_groups(canvas) -> list[dict]:
- state_groups = canvas.runtime_state.group_state.groups
- if not state_groups:
- return []
- item_index = {
- record_id: (kind_key, index)
- for kind_key, name in _GROUP_COLLECTIONS.items()
- for index, record_id in enumerate(
- canvas.runtime_state.document_collection(name).order
- )
- }
- model_atoms = canvas.model.atoms
- groups: list[dict] = []
- # Runtime grouping keeps groups disjoint; the seen-sets are healing for
- # drifted state, since overlapping members would fail save validation.
- seen_atom_ids: set[int] = set()
- seen_item_refs: set[tuple[str, int]] = set()
- for group_id in sorted(state_groups):
- group = state_groups[group_id]
- atoms = sorted(
- atom_id
- for atom_id in group.atom_ids
- if atom_id in model_atoms and atom_id not in seen_atom_ids
- )
- item_refs = [
- item_index[item]
- for item in group.item_ids
- if item in item_index and item_index[item] not in seen_item_refs
- ]
- if not atoms and not item_refs:
- continue
- seen_atom_ids.update(atoms)
- seen_item_refs.update(item_refs)
- groups.append({"atoms": atoms, "items": [list(ref) for ref in item_refs]})
- return groups
+ return snapshot_groups(
+ canvas.runtime_state.group_state.groups,
+ canvas.model.atoms,
+ {
+ record_id: (kind_key, index)
+ for kind_key, name in GROUP_COLLECTION_STATES.items()
+ for index, record_id in enumerate(
+ canvas.runtime_state.document_collection(name).order
+ )
+ },
+ )
def restore_document_groups(canvas, state: dict) -> None:
clear_groups_for(canvas)
- groups_state = state.get("groups") or []
- if not groups_state:
+ records = state.get("groups") or []
+ if not records:
return
- item_lists = {
- key: canvas.runtime_state.document_collection(name).order
- for key, name in _GROUP_COLLECTIONS.items()
- }
- model_atoms = canvas.model.atoms
- for group_state in groups_state:
- atom_ids = {
- int(atom_id)
- for atom_id in group_state.get("atoms", [])
- if int(atom_id) in model_atoms
- }
- items = []
- for kind_key, index in group_state.get("items", []):
- candidates = item_lists.get(kind_key, [])
- if 0 <= index < len(candidates):
- items.append(candidates[index])
- if atom_ids or items:
- register_group_for(canvas, atom_ids, items)
+ for group in restored_groups(
+ records,
+ canvas.model.atoms,
+ {
+ key: canvas.runtime_state.document_collection(name).order
+ for key, name in GROUP_COLLECTION_STATES.items()
+ },
+ ):
+ register_group_for(canvas, group.atom_ids, group.item_ids)
def snapshot_ring_fills(canvas) -> list[dict]:
diff --git a/app/chemvas/ui/canvas/canvas_geometry_controller.py b/app/chemvas/ui/canvas/canvas_geometry_controller.py
index d2ecb8c4..9f997cc9 100644
--- a/app/chemvas/ui/canvas/canvas_geometry_controller.py
+++ b/app/chemvas/ui/canvas/canvas_geometry_controller.py
@@ -14,6 +14,8 @@
SetRingPolygonsCommand,
UpdateBondLengthCommand,
)
+from chemvas.domain.document.marks import scaled_mark_offset
+from chemvas.domain.document.perspective import rescaled_perspective
from chemvas.ui.annotations.state import mark_state_dict_for, scene_item_history_state
from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
@@ -95,13 +97,9 @@ def set_bond_length(self, length_px: float) -> None:
self._rescale_perspective_state(scale, center_x, center_y)
for item, state in before_marks:
data = dict(item.data(1))
- atom_x, atom_y = before_positions[data["atom_id"]]
- data["dx"] = (
- state["dx"] if state["dx"] is not None else state["x"] - atom_x
- ) * scale
- data["dy"] = (
- state["dy"] if state["dy"] is not None else state["y"] - atom_y
- ) * scale
+ data["dx"], data["dy"] = scaled_mark_offset(
+ state, before_positions[data["atom_id"]], scale
+ )
item.setData(1, data)
self.hit_testing_service.mark_spatial_index_dirty()
refresh_bond_length_graphics_for(self.canvas)
@@ -189,39 +187,26 @@ def _rescale_ring_polygons(
)
ring_item.setPolygon(scaled)
- @staticmethod
- def _scaled_xy(
- x: float, y: float, scale: float, center_x: float, center_y: float
- ) -> tuple[float, float]:
- return center_x + (x - center_x) * scale, center_y + (y - center_y) * scale
-
def _rescale_perspective_state(
self, scale: float, center_x: float, center_y: float
) -> None:
rotation_state = self.canvas.runtime_state.rotation_state
- projection_center = rotation_state.projection_center_3d
- z_center = projection_center[2] if projection_center is not None else 0.0
+ coords_state = self.canvas.runtime_state.atom_coords_3d_state
atom_ids = set(self.canvas.model.atoms)
- for atom_id, (x, y, z) in list(
- self.canvas.runtime_state.atom_coords_3d_state.atom_coords_3d.items()
- ):
- if atom_id not in atom_ids:
- continue
- scaled_x, scaled_y = self._scaled_xy(x, y, scale, center_x, center_y)
- self.canvas.runtime_state.atom_coords_3d_state.atom_coords_3d[atom_id] = (
- scaled_x,
- scaled_y,
- z_center + (z - z_center) * scale,
- )
- if projection_center is not None:
- x, y, z = projection_center
- scaled_x, scaled_y = self._scaled_xy(x, y, scale, center_x, center_y)
- rotation_state.projection_center_3d = (scaled_x, scaled_y, z)
- if rotation_state.projection_anchor_2d is not None:
- x, y = rotation_state.projection_anchor_2d
- rotation_state.projection_anchor_2d = self._scaled_xy(
- x, y, scale, center_x, center_y
- )
+ rescaled, center, anchor = rescaled_perspective(
+ {
+ atom_id: coords
+ for atom_id, coords in coords_state.atom_coords_3d.items()
+ if atom_id in atom_ids
+ },
+ rotation_state.projection_center_3d,
+ rotation_state.projection_anchor_2d,
+ scale=scale,
+ origin=(center_x, center_y),
+ )
+ coords_state.atom_coords_3d.update(rescaled)
+ rotation_state.projection_center_3d = center
+ rotation_state.projection_anchor_2d = anchor
__all__ = ["CanvasGeometryController"]
diff --git a/app/chemvas/ui/canvas/canvas_geometry_logic.py b/app/chemvas/ui/canvas/canvas_geometry_logic.py
index fba71e94..6213ab22 100644
--- a/app/chemvas/ui/canvas/canvas_geometry_logic.py
+++ b/app/chemvas/ui/canvas/canvas_geometry_logic.py
@@ -1,5 +1,14 @@
from __future__ import annotations
+import math
+from itertools import pairwise
+from typing import TYPE_CHECKING
+
+from chemvas.features.annotations import mark_dimensions
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable
+
Point = tuple[float, float]
"""A scene point as ``(x, y)``."""
@@ -74,10 +83,212 @@ def ray_rect_exit_distance(origin: Point, direction: Point, rect: Rect) -> float
return max(0.0, t_max)
+def glyph_clearance_radius(stroke_width: float) -> float:
+ """Native square-cap envelope plus visible air gap and flattening allowance."""
+ return stroke_width / math.sqrt(2.0) + max(0.2, stroke_width * 0.5) + 0.01
+
+
+def glyph_convex_hull(points: Iterable[Point]) -> list[Point]:
+ """Native label silhouette, closing counters and gaps between runs."""
+ points = sorted(set(points))
+ if len(points) < 3:
+ return []
+ hulls = []
+ for ordered in (points, list(reversed(points))):
+ half: list[tuple[float, float]] = []
+ for point in ordered:
+ while len(half) >= 2:
+ a, b = half[-2:]
+ cross = (b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (
+ point[0] - a[0]
+ )
+ if cross > 0:
+ break
+ half.pop()
+ half.append(point)
+ hulls.extend(half[:-1])
+ return hulls
+
+
+def glyph_contour_clip_t(
+ p1: Point,
+ p2: Point,
+ contours: Iterable[Iterable[Point]],
+ offsets: tuple[Point, ...] = (),
+ *,
+ start_inside: bool = False,
+ end_inside: bool = False,
+) -> tuple[float, float] | None:
+ """Native first/last contour crossings, including the full offset band.
+
+ Adapters provide closed, scene-space contours of the glyph envelope and
+ its painted clearance. Containment uses the adapter's filled-path rule.
+ A collapsed segment has no visible span and returns None, even inside ink.
+ """
+ hits = []
+ if start_inside:
+ hits.append(0.0)
+ if end_inside:
+ hits.append(1.0)
+ length = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
+ if length == 0:
+ return None
+ ux, uy = (p2[0] - p1[0]) / length, (p2[1] - p1[1]) / length
+ along = [ux * x + uy * y for x, y in offsets] or [0.0]
+ across = [-uy * x + ux * y for x, y in offsets] or [0.0]
+ low, high = min(across), max(across)
+ for contour in contours:
+ polygon = tuple(contour)
+ if offsets:
+ # Inspect the entire band occupied by parallel strokes or a
+ # filled strip, including ink between (not only on) its edges.
+ points = [
+ (
+ (x - p1[0]) * ux + (y - p1[1]) * uy,
+ -(x - p1[0]) * uy + (y - p1[1]) * ux,
+ )
+ for x, y in polygon
+ ]
+ xs = [x for x, y in points if low <= y <= high]
+ for index in range(len(points) - 1):
+ x0, y0 = points[index]
+ x1, y1 = points[index + 1]
+ if abs(y1 - y0) < 1e-12:
+ continue
+ for y in (low, high):
+ ratio = (y - y0) / (y1 - y0)
+ if 0 <= ratio <= 1:
+ xs.append(x0 + ratio * (x1 - x0))
+ if xs:
+ first = (min(xs) - max(along)) / length
+ last = (max(xs) - min(along)) / length
+ if first <= 1 and last >= 0:
+ hits.extend((max(0.0, first), min(1.0, last)))
+ continue
+ # Preserve the native 64x intersection tolerance used for Qt contours.
+ scale = 64.0
+ start = (p1[0] * scale, p1[1] * scale)
+ end = (p2[0] * scale, p2[1] * scale)
+ for left, right in pairwise(polygon):
+ hit = segment_intersection_t(
+ start,
+ end,
+ (left[0] * scale, left[1] * scale),
+ (right[0] * scale, right[1] * scale),
+ )
+ if hit is not None:
+ hits.append(hit)
+ return (min(hits), max(hits)) if hits else None
+
+
+def mark_clearance(
+ kind: str,
+ *,
+ bond_length: float,
+ line_width: float,
+ font_height: float,
+ symbol_width: float = 0.0,
+ symbol_height: float = 0.0,
+) -> float:
+ """Native mark-to-label clearance after the presentation adapter measures text."""
+ gap = max(0.6, bond_length * 0.05)
+ if kind in {"plus", "minus"}:
+ half_diagonal = math.hypot(symbol_width, symbol_height) * 0.5
+ return max(half_diagonal, font_height * 0.35) + gap
+ if kind in {"radical", "circled_plus", "circled_minus"}:
+ radius, stroke, _extent = mark_dimensions(kind, line_width, font_height)
+ return radius + stroke + gap
+ return gap
+
+
+def mark_target_distance(
+ atom: Point,
+ label_bounds: Rect | None,
+ clearance: float,
+ direction_x: float,
+ direction_y: float,
+) -> float:
+ if label_bounds is None:
+ return 0.0
+ left, top, right, bottom = label_bounds
+ distance = ray_rect_exit_distance(
+ atom,
+ (direction_x, direction_y),
+ (left - clearance, top - clearance, right + clearance, bottom + clearance),
+ )
+ return 0.0 if distance is None else distance
+
+
+def mark_click_offset(
+ atom: Point,
+ click: Point,
+ *,
+ bond_length: float,
+ target_distance: Callable[[float, float], float],
+) -> Point:
+ dx, dy = click[0] - atom[0], click[1] - atom[1]
+ length = math.hypot(dx, dy)
+ if length <= 1e-6:
+ dx, dy = 1.0, -1.0
+ length = math.hypot(dx, dy)
+ direction_x, direction_y = dx / length, dy / length
+ target = bond_length * 0.2
+ label_target = target_distance(direction_x, direction_y)
+ if label_target > target:
+ target += (label_target - target) * 0.25
+ return direction_x * target, direction_y * target
+
+
+def shortcut_mark_offset(
+ atom: Point,
+ local_ink: Rect,
+ obstacles: Iterable[Rect],
+ *,
+ bond_length: float,
+ click_offset: Callable[[float, float], Point],
+) -> Point:
+ """Place the native charge shortcut in the first unoccupied compass direction."""
+ gap = max(0.5, bond_length * 0.04)
+ expanded = [
+ (ol - gap, ot - gap, oright + gap, ob + gap) for ol, ot, oright, ob in obstacles
+ ]
+ left, top, right, bottom = local_ink
+ for x, y in ((1, -1), (-1, -1), (1, 1), (-1, 1), (0, -1), (0, 1), (1, 0), (-1, 0)):
+ dx, dy = click_offset(atom[0] + x, atom[1] + y)
+ cx, cy = atom[0] + dx, atom[1] + dy
+ if not any(
+ left + cx < oright
+ and right + cx > ol
+ and top + cy < ob
+ and bottom + cy > ot
+ for ol, ot, oright, ob in expanded
+ ):
+ return dx, dy
+ # An occupied compass is not a four-direction cycle: place the
+ # next symbol outside existing ink, without moving saved marks.
+ extent = max(
+ (
+ math.hypot(x - atom[0], y - atom[1])
+ for ol, ot, oright, ob in expanded
+ for x, y in ((ol, ot), (oright, ot), (ol, ob), (oright, ob))
+ ),
+ default=0.0,
+ )
+ radius = extent + math.hypot(right - left, bottom - top) + gap
+ return radius / math.sqrt(2), -radius / math.sqrt(2)
+
+
__all__ = [
"Point",
"Rect",
+ "glyph_clearance_radius",
+ "glyph_contour_clip_t",
+ "glyph_convex_hull",
"line_rect_clip_t",
+ "mark_clearance",
+ "mark_click_offset",
+ "mark_target_distance",
"ray_rect_exit_distance",
"segment_intersection_t",
+ "shortcut_mark_offset",
]
diff --git a/app/chemvas/ui/canvas/canvas_group_state.py b/app/chemvas/ui/canvas/canvas_group_state.py
index a356ead3..62bd3251 100644
--- a/app/chemvas/ui/canvas/canvas_group_state.py
+++ b/app/chemvas/ui/canvas/canvas_group_state.py
@@ -4,6 +4,7 @@
from typing import Any
from chemvas.domain.document.groups import SceneGroup
+from chemvas.features.groups import group_ids_for_members
@dataclass(slots=True)
@@ -38,17 +39,11 @@ def restore_group_for(canvas: Any, group_id: int, group: SceneGroup) -> None:
def group_ids_for_members_for(
canvas: Any, atom_ids: set[int], items: list[Any]
) -> set[int]:
- state = canvas.runtime_state.group_state
- if not state.groups:
- return set()
- group_ids: set[int] = set()
- for group_id, group in state.groups.items():
- if group.atom_ids & atom_ids:
- group_ids.add(group_id)
- continue
- if any(item.data(3) in group.item_ids for item in items):
- group_ids.add(group_id)
- return group_ids
+ return group_ids_for_members(
+ canvas.runtime_state.group_state.groups,
+ atom_ids,
+ [item.data(3) for item in items],
+ )
__all__ = [
diff --git a/app/chemvas/ui/canvas/canvas_hit_testing_service.py b/app/chemvas/ui/canvas/canvas_hit_testing_service.py
index ed12893d..c40f4111 100644
--- a/app/chemvas/ui/canvas/canvas_hit_testing_service.py
+++ b/app/chemvas/ui/canvas/canvas_hit_testing_service.py
@@ -8,8 +8,20 @@
from PyQt6.QtWidgets import QGraphicsPathItem
from chemvas.domain.document import VALID_ARROW_KINDS
+from chemvas.features.selection import (
+ ARROW_PICK_SCREEN_PX,
+ bond_grid_candidates,
+ build_bond_grid,
+ distance_point_to_segment,
+ mark_precedes_atom,
+ nearest_atom_id,
+ nearest_bond_id,
+)
from chemvas.ui.canvas.graphics_items import AtomDotItem
-from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
+from chemvas.ui.canvas.pick_radius_access import (
+ STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ atom_pick_radius_for,
+)
from chemvas.ui.canvas.spatial_index_state import (
has_fresh_spatial_index_for,
mark_spatial_index_dirty_for,
@@ -21,10 +33,6 @@
from chemvas.ui.canvas.canvas_view import CanvasView
-# Input tolerance only: neither document strokes nor export bounds grow.
-ARROW_PICK_SCREEN_PX = 6.0
-
-
def scene_items_at_pos_for_canvas(canvas, pos):
# Handles keep their size on screen (ItemIgnoresTransformations), so the
# scene needs the view's transform to place their shapes for picking.
@@ -138,15 +146,17 @@ def item_at_scene_pos(self, pos: QPointF, *, prefer_marks: bool = False):
center = self.canvas.services.scene_decoration_build_service.mark_center(
mark_item
)
- if atom is not None and math.hypot(
- center.x() - pos.x(), center.y() - pos.y()
- ) < math.hypot(atom.x - pos.x(), atom.y - pos.y()):
+ if atom is not None and mark_precedes_atom(
+ (pos.x(), pos.y()), (center.x(), center.y()), (atom.x, atom.y)
+ ):
return mark_item
if atom_item is not None:
return atom_item
if bond_item is None:
nearby_bond_id = self.find_bond_near(
- pos, self.canvas.renderer.style.bond_length_px * 0.528
+ pos,
+ self.canvas.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
)
if nearby_bond_id is not None:
nearby_items = (
@@ -230,23 +240,7 @@ def rebuild_spatial_index(self, cell_size: float) -> None:
key = self.cell_coords(atom.x, atom.y, cell_size)
atom_grid.setdefault(key, set()).add(atom_id)
- bond_grid: dict[tuple[int, int], set[int]] = {}
- for bond_id, bond in enumerate(self.canvas.model.bonds):
- if bond is None:
- continue
- a = self.canvas.model.atom_for_id(bond.a)
- b = self.canvas.model.atom_for_id(bond.b)
- if a is None or b is None:
- continue
- min_x = min(a.x, b.x)
- max_x = max(a.x, b.x)
- min_y = min(a.y, b.y)
- max_y = max(a.y, b.y)
- min_ix, min_iy = self.cell_coords(min_x, min_y, cell_size)
- max_ix, max_iy = self.cell_coords(max_x, max_y, cell_size)
- for ix in range(min_ix, max_ix + 1):
- for iy in range(min_iy, max_iy + 1):
- bond_grid.setdefault((ix, iy), set()).add(bond_id)
+ bond_grid = build_bond_grid(self.canvas.model, cell_size)
set_spatial_index_for(
self.canvas,
@@ -269,30 +263,17 @@ def find_atom_near(self, x: float, y: float, max_dist: float) -> int | None:
return None
cell_radius = math.ceil(max_dist / cell_size)
ix, iy = self.cell_coords(x, y, cell_size)
- nearest_id = None
- nearest_dist_sq = max_dist * max_dist
- for cx in range(ix - cell_radius, ix + cell_radius + 1):
- for cy in range(iy - cell_radius, iy + cell_radius + 1):
- for (
- atom_id
- ) in self.canvas.runtime_state.spatial_index_state.atom_grid.get(
- (cx, cy), ()
- ):
- atom = self.canvas.model.atom_for_id(atom_id)
- if atom is None:
- continue
- dx = atom.x - x
- dy = atom.y - y
- dist_sq = dx * dx + dy * dy
- # Lowest atom id breaks exact-distance ties so the pick
- # does not depend on set iteration order.
- if dist_sq < nearest_dist_sq or (
- dist_sq == nearest_dist_sq
- and (nearest_id is None or atom_id < nearest_id)
- ):
- nearest_id = atom_id
- nearest_dist_sq = dist_sq
- return nearest_id
+ candidates = (
+ atom_id
+ for cx in range(ix - cell_radius, ix + cell_radius + 1)
+ for cy in range(iy - cell_radius, iy + cell_radius + 1)
+ for atom_id in self.canvas.runtime_state.spatial_index_state.atom_grid.get(
+ (cx, cy), ()
+ )
+ )
+ return nearest_atom_id(
+ self.canvas.model.atoms, candidates, x=x, y=y, max_dist=max_dist
+ )
def find_bond_near(self, pos: QPointF, max_dist: float) -> int | None:
if not self.canvas.model.bonds:
@@ -301,51 +282,18 @@ def find_bond_near(self, pos: QPointF, max_dist: float) -> int | None:
cell_size = spatial_cell_size_or_for(self.canvas, self.grid_cell_size())
if cell_size <= 0:
return None
- cell_radius = math.ceil(max_dist / cell_size)
- ix, iy = self.cell_coords(pos.x(), pos.y(), cell_size)
- nearest = None
- nearest_dist = max_dist
- seen: set[int] = set()
- for cx in range(ix - cell_radius, ix + cell_radius + 1):
- for cy in range(iy - cell_radius, iy + cell_radius + 1):
- for (
- bond_id
- ) in self.canvas.runtime_state.spatial_index_state.bond_grid.get(
- (cx, cy), ()
- ):
- if bond_id in seen:
- continue
- seen.add(bond_id)
- bond = self.canvas.model.bond_for_id(bond_id)
- if bond is None:
- continue
- a = self.canvas.model.atom_for_id(bond.a)
- b = self.canvas.model.atom_for_id(bond.b)
- if a is None or b is None:
- continue
- dist = self.distance_point_to_segment(
- pos,
- QPointF(a.x, a.y),
- QPointF(b.x, b.y),
- )
- if dist <= nearest_dist:
- nearest = bond_id
- nearest_dist = dist
- return nearest
+ candidates = bond_grid_candidates(
+ self.canvas.runtime_state.spatial_index_state.bond_grid,
+ pos.x(),
+ pos.y(),
+ max_dist,
+ cell_size,
+ )
+ return nearest_bond_id(
+ self.canvas.model, candidates, pos, max_dist, point_factory=QPointF
+ )
- @staticmethod
- def distance_point_to_segment(p: QPointF, a: QPointF, b: QPointF) -> float:
- abx = b.x() - a.x()
- aby = b.y() - a.y()
- apx = p.x() - a.x()
- apy = p.y() - a.y()
- ab_len_sq = abx * abx + aby * aby
- if ab_len_sq == 0:
- return math.hypot(apx, apy)
- t = max(0.0, min(1.0, (apx * abx + apy * aby) / ab_len_sq))
- cx = a.x() + abx * t
- cy = a.y() + aby * t
- return math.hypot(p.x() - cx, p.y() - cy)
+ distance_point_to_segment = staticmethod(distance_point_to_segment)
def nearest_atom_hit(self, pos: QPointF) -> tuple[int, float] | None:
atom_id = self.find_atom_near(
@@ -360,7 +308,9 @@ def nearest_atom_hit(self, pos: QPointF) -> tuple[int, float] | None:
def nearest_bond_hit(self, pos: QPointF) -> tuple[int, float] | None:
bond_id = self.find_bond_near(
- pos, self.canvas.renderer.style.bond_length_px * 0.528
+ pos,
+ self.canvas.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
)
if bond_id is None:
return None
@@ -387,7 +337,8 @@ def bond_id_from_event(self, event) -> int | None:
pos,
max(
self.canvas.renderer.style.bond_length_px * 0.35,
- self.canvas.renderer.style.bond_length_px * 0.528,
+ self.canvas.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
),
)
diff --git a/app/chemvas/ui/canvas/canvas_input_controller.py b/app/chemvas/ui/canvas/canvas_input_controller.py
index 102388c3..03d6a8f7 100644
--- a/app/chemvas/ui/canvas/canvas_input_controller.py
+++ b/app/chemvas/ui/canvas/canvas_input_controller.py
@@ -24,11 +24,16 @@
scene_pos_in_sheet_for,
)
from chemvas.ui.molecule.atom_label_access import atom_has_visible_label_for
+from chemvas.ui.scene.scene_delete_plan import hover_delete_target
from chemvas.ui.scene.scene_group_operations import (
group_selection_for,
ungroup_selection_for,
)
from chemvas.ui.selection.selection_queries import selected_scene_items_for
+from chemvas.ui.window.main_window_config import (
+ VIEW_FUNCTION_KEY_ACTIONS,
+ ZOOM_KEY_ACTIONS,
+)
if TYPE_CHECKING:
from collections.abc import Callable
@@ -161,63 +166,60 @@ def key_press_event(self, event) -> None:
def handle_view_key(self, event: QKeyEvent) -> bool:
"""Apply a zoom or fit key to the view; report whether it was one."""
- if event.matches(QKeySequence.StandardKey.ZoomIn) or (
- event.modifiers() & Qt.KeyboardModifier.ControlModifier
- and event.key() in (Qt.Key.Key_Plus, Qt.Key.Key_Equal)
- ):
- zoom_in_for(self.canvas)
- return True
- if event.matches(QKeySequence.StandardKey.ZoomOut) or (
- event.modifiers() & Qt.KeyboardModifier.ControlModifier
- and event.key() in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore)
- ):
- zoom_out_for(self.canvas)
- return True
- if (
- event.modifiers() & Qt.KeyboardModifier.ControlModifier
- and event.key() == Qt.Key.Key_0
- ):
- reset_zoom_for(self.canvas)
- return True
- if shortcut_modifiers_for(event) != Qt.KeyboardModifier.NoModifier:
+ actions = {
+ "zoom_in": zoom_in_for,
+ "zoom_out": zoom_out_for,
+ "actual_size": reset_zoom_for,
+ "fit": fit_canvas_to_view_for,
+ }
+ key = event.key()
+ action: str | None
+ if event.matches(QKeySequence.StandardKey.ZoomIn):
+ action = "zoom_in"
+ elif event.matches(QKeySequence.StandardKey.ZoomOut):
+ action = "zoom_out"
+ elif event.modifiers() & Qt.KeyboardModifier.ControlModifier:
+ action = ZOOM_KEY_ACTIONS.get(chr(key)) if 0 <= key < 128 else None
+ elif shortcut_modifiers_for(event) == Qt.KeyboardModifier.NoModifier:
+ action = next(
+ (
+ name
+ for label, name in VIEW_FUNCTION_KEY_ACTIONS.items()
+ if key == getattr(Qt.Key, f"Key_{label}")
+ ),
+ None,
+ )
+ else:
+ action = None
+ if action is None:
return False
- if event.key() == Qt.Key.Key_F5:
- reset_zoom_for(self.canvas)
- return True
- if event.key() == Qt.Key.Key_F6:
- fit_canvas_to_view_for(self.canvas)
- return True
- if event.key() == Qt.Key.Key_F7:
- zoom_in_for(self.canvas)
- return True
- if event.key() == Qt.Key.Key_F8:
- zoom_out_for(self.canvas)
- return True
- return False
+ actions[action](self.canvas)
+ return True
def _delete_hover_target(self, event) -> None:
if self._is_offsheet_structure_edit(event):
notify_error_for(self.canvas, OFF_SHEET_EDIT_GUIDANCE)
return
- hover_atom_id = self.canvas.runtime_state.hover_preview_state.atom_id
- if hover_atom_id is not None:
- # Delete strips a bonded atom's label first. A lone labelled atom
- # has nothing to fall back to: hiding its label would leave an
- # invisible carbon on the sheet, so it is deleted outright.
- if atom_has_visible_label_for(
- self.canvas, hover_atom_id
- ) and _atom_has_bond(self.canvas, hover_atom_id):
- self.atom_labels.add_or_update_atom_label(
- hover_atom_id, "C", show_carbon=False
- )
- else:
- self.hover.clear_hover_highlight()
- self.scene_delete.delete_atom(hover_atom_id, record=True)
+ hover = self.canvas.runtime_state.hover_preview_state
+ target = hover_delete_target(
+ hover.atom_id,
+ hover.bond_id,
+ bonds=self.canvas.model.bonds,
+ atom_has_visible_label=lambda atom_id: atom_has_visible_label_for(
+ self.canvas, atom_id
+ ),
+ )
+ if target is None:
return
- hover_bond_id = self.canvas.runtime_state.hover_preview_state.bond_id
- if hover_bond_id is not None:
+ kind, item_id = target
+ if kind == "label":
+ self.atom_labels.add_or_update_atom_label(item_id, "C", show_carbon=False)
+ else:
self.hover.clear_hover_highlight()
- self.scene_delete.delete_bond(hover_bond_id, record=True)
+ if kind == "atom":
+ self.scene_delete.delete_atom(item_id, record=True)
+ else:
+ self.scene_delete.delete_bond(item_id, record=True)
def _cancel_interaction(self) -> None:
if self.insert_state.template_active:
@@ -334,11 +336,3 @@ def event(self, event, *, native_gesture_event_type=QNativeGestureEvent) -> bool
event.accept()
return True
return QGraphicsView.event(self.canvas, event)
-
-
-def _atom_has_bond(canvas, atom_id: int) -> bool:
- return any(
- bond is not None
- and atom_id in (getattr(bond, "a", None), getattr(bond, "b", None))
- for bond in canvas.model.bonds
- )
diff --git a/app/chemvas/ui/canvas/canvas_mark_scene_service.py b/app/chemvas/ui/canvas/canvas_mark_scene_service.py
index 1281470e..711f786a 100644
--- a/app/chemvas/ui/canvas/canvas_mark_scene_service.py
+++ b/app/chemvas/ui/canvas/canvas_mark_scene_service.py
@@ -10,9 +10,16 @@
CompositeCommand,
history_transaction_scope,
)
+from chemvas.domain.document import unmarked_isolated_carbon_ids
from chemvas.domain.document.marks import mark_kinds_by_atom
-from chemvas.features.insertion import build_atom_annotations
+from chemvas.features.insertion import (
+ build_atom_annotations,
+ opposite_charge_mark,
+ plan_mark_rebind,
+)
+from chemvas.features.selection import choose_mark_atom
from chemvas.ui.annotations.state import mark_state_dict_for, scene_item_history_state
+from chemvas.ui.canvas.canvas_geometry_logic import shortcut_mark_offset
from chemvas.ui.canvas.canvas_hit_testing_service import scene_items_in_rect_for_canvas
from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
@@ -59,20 +66,10 @@ def __init__(
def change_charge_for_atom(self, atom_id: int, delta: int) -> None:
"""One shortcut changes charge by one, preserving other mark edits."""
- if delta not in {-1, 1}:
- raise ValueError("Charge shortcuts require a change of +1 or -1.")
+ cancel = opposite_charge_mark(self.marks.get_for_atom(atom_id) or [], delta)
atom = self.canvas.model.atom_for_id(atom_id)
if atom is None:
return
- opposite = {"minus", "circled_minus"} if delta > 0 else {"plus", "circled_plus"}
- cancel = next(
- (
- item
- for item in reversed(self.marks.get_for_atom(atom_id) or [])
- if (item.data(1) or {}).get("kind") in opposite
- ),
- None,
- )
with (
document_transaction(self.canvas, history_service=self.history),
history_transaction_scope(self.history.operations),
@@ -113,22 +110,17 @@ def reveal_unmarked_isolated_carbons(
its one history entry. Loading, low-level removal and Undo do not call
this: existing invisible carbons and ordinary atom deletion stay intact.
"""
- candidates = {
- atom_id
- for atom_id in atom_ids
- if (atom := self.canvas.model.atom_for_id(atom_id)) is not None
- and atom.element.upper() == "C"
- and not atom_has_visible_label_for(self.canvas, atom_id)
- and not self.marks.get_for_atom(atom_id)
- }
+ candidates = unmarked_isolated_carbon_ids(
+ atom_ids,
+ atoms=self.canvas.model.atoms,
+ bonds=self.canvas.model.bonds,
+ has_visible_label=lambda atom_id: atom_has_visible_label_for(
+ self.canvas, atom_id
+ ),
+ has_marks=lambda atom_id: bool(self.marks.get_for_atom(atom_id)),
+ )
if not candidates:
return []
- # Check live model bonds once for the affected candidates, not all
- # document atoms; stale adjacency must not reveal a bonded carbon.
- for bond in self.canvas.model.bonds:
- if bond is not None:
- candidates.discard(bond.a)
- candidates.discard(bond.b)
commands = []
with history_transaction_scope(self.history.operations):
for atom_id in sorted(candidates):
@@ -154,56 +146,32 @@ def _place_shortcut_mark(self, atom_id: int, item) -> None:
atom = self.canvas.model.atom_for_id(atom_id)
if atom is None:
return
- gap = max(0.5, self.canvas.renderer.style.bond_length_px * 0.04)
- obstacles = [
- self._mark_ink_rect(other).adjusted(-gap, -gap, gap, gap)
- for other in self.marks.get_for_atom(atom_id) or []
- if other is not item
- ]
origin = QPointF(atom.x, atom.y)
- ink = self._mark_ink_rect(item)
- local_ink = ink.translated(
- -self.canvas.services.scene_decoration_build_service.mark_center(item)
- )
- kind = item.data(1)["kind"]
- directions = (
- (1, -1),
- (-1, -1),
- (1, 1),
- (-1, 1),
- (0, -1),
- (0, 1),
- (1, 0),
- (-1, 0),
- )
- for x, y in directions:
+ center = self.canvas.services.scene_decoration_build_service.mark_center(item)
+ local_ink = self._mark_ink_rect(item).translated(-center)
+
+ def bounds(rect):
+ return rect.left(), rect.top(), rect.right(), rect.bottom()
+
+ def tuple_offset(x, y):
offset = self.mark_offset_from_click(
- atom_id, origin + QPointF(x, y), kind=kind
+ atom_id, QPointF(x, y), kind=item.data(1)["kind"]
)
- center = origin + offset
- if not any(
- local_ink.translated(center).intersects(rect) for rect in obstacles
- ):
- break
- else:
- # An occupied compass is not a four-direction cycle: place the
- # next symbol outside existing ink, without moving saved marks.
- extent = max(
- (
- math.hypot(point.x() - atom.x, point.y() - atom.y)
- for rect in obstacles
- for point in (
- rect.topLeft(),
- rect.topRight(),
- rect.bottomLeft(),
- rect.bottomRight(),
- )
- ),
- default=0.0,
- )
- radius = extent + math.hypot(local_ink.width(), local_ink.height()) + gap
- offset = QPointF(radius / math.sqrt(2), -radius / math.sqrt(2))
- center = origin + offset
+ return offset.x(), offset.y()
+
+ dx, dy = shortcut_mark_offset(
+ (atom.x, atom.y),
+ bounds(local_ink),
+ [
+ bounds(self._mark_ink_rect(other))
+ for other in self.marks.get_for_atom(atom_id) or []
+ if other is not item
+ ],
+ bond_length=self.canvas.renderer.style.bond_length_px,
+ click_offset=tuple_offset,
+ )
+ offset = QPointF(dx, dy)
+ center = origin + offset
self.canvas.services.scene_decoration_build_service.set_mark_center(
item, center
)
@@ -239,10 +207,12 @@ def find_atom_for_mark(
distance = math.hypot(pos.x() - atom.x, pos.y() - atom.y)
on_label = item.contains(item.mapFromScene(pos))
offset = self.mark_offset_from_click(atom_id, pos, kind=kind)
- radius = max(base_radius, math.hypot(offset.x(), offset.y()) + tolerance)
- if on_label or distance <= radius:
- candidates.append((not on_label, distance, atom_id))
- return min(candidates)[2] if candidates else None
+ candidates.append(
+ (atom_id, distance, on_label, math.hypot(offset.x(), offset.y()))
+ )
+ return choose_mark_atom(
+ candidates, base_radius=base_radius, tolerance=tolerance
+ )
def add_mark_for_atom(
self,
@@ -315,57 +285,10 @@ def rebind_mark(self, item, atom_id: int) -> bool:
or item.data(0) != "mark"
):
raise ValueError("The mark is no longer in this document.")
- atom = self.canvas.model.atom_for_id(atom_id)
- if type(atom_id) is not int or atom is None:
- raise ValueError("Choose an existing atom in this document.")
- old_id = (item.data(1) or {}).get("atom_id")
- if atom_id == old_id:
+ plan = plan_mark_rebind(self.canvas.model, item, atom_id, self.marks.by_atom)
+ if plan is None:
return False
- if old_id is not None and self.canvas.model.atom_for_id(old_id) is None:
- raise ValueError("The mark's original atom no longer exists.")
- affected_ids = {atom_id} | ({old_id} if old_id is not None else set())
- before_marks = {
- key: tuple(self.marks.get_for_atom(key) or ()) for key in affected_ids
- }
- if old_id is not None and item not in before_marks[old_id]:
- raise ValueError(
- "The mark's binding is inconsistent; reload the document before reassigning."
- )
- annotations = self.canvas.model.atom_annotations
- before_annotations = {
- key: dict(annotations[key]) for key in affected_ids if key in annotations
- }
- expected = build_atom_annotations(
- affected_ids,
- {key: key for key in affected_ids},
- {
- key: [(mark.data(1) or {})["kind"] for mark in items]
- for key, items in before_marks.items()
- },
- )
- normalized = {
- key: {k: v for k, v in value.items() if v}
- for key, value in before_annotations.items()
- }
- normalized = {key: value for key, value in normalized.items() if value}
- if normalized != expected:
- raise ValueError(
- "Atom annotations and marks disagree; resolve them before reassigning a mark."
- )
- after_marks = dict(before_marks)
- if old_id is not None:
- after_marks[old_id] = tuple(
- mark for mark in before_marks[old_id] if mark is not item
- )
- after_marks[atom_id] = (*before_marks[atom_id], item)
- after_annotations = build_atom_annotations(
- affected_ids,
- {key: key for key in affected_ids},
- {
- key: [(mark.data(1) or {})["kind"] for mark in items]
- for key, items in after_marks.items()
- },
- )
+ atom = self.canvas.model.atoms[atom_id]
before = scene_item_history_state(item, mark_state_dict_for(self.canvas, item))
center = self.canvas.services.scene_decoration_build_service.mark_center(item)
after = dict(
@@ -377,14 +300,14 @@ def rebind_mark(self, item, atom_id: int) -> bool:
after,
{
key: tuple(require_scene_record_id(mark) for mark in marks)
- for key, marks in before_marks.items()
+ for key, marks in plan.before_marks.items()
},
{
key: tuple(require_scene_record_id(mark) for mark in marks)
- for key, marks in after_marks.items()
+ for key, marks in plan.after_marks.items()
},
- before_annotations,
- after_annotations,
+ plan.before_annotations,
+ plan.after_annotations,
)
with (
document_transaction(self.canvas, history_service=self.history),
@@ -392,7 +315,7 @@ def rebind_mark(self, item, atom_id: int) -> bool:
):
command.redo(self.history.operations)
labels = self.reveal_unmarked_isolated_carbons(
- {old_id} if old_id is not None else set()
+ {plan.old_id} if plan.old_id is not None else set()
)
if labels:
command = CompositeCommand([command, *labels])
diff --git a/app/chemvas/ui/canvas/canvas_move_controller.py b/app/chemvas/ui/canvas/canvas_move_controller.py
index 146c494c..bdb5edee 100644
--- a/app/chemvas/ui/canvas/canvas_move_controller.py
+++ b/app/chemvas/ui/canvas/canvas_move_controller.py
@@ -3,17 +3,9 @@
from dataclasses import replace
from typing import TYPE_CHECKING, Any
-from PyQt6.QtCore import QPointF
-
from chemvas.domain.document import VALID_ARROW_KINDS
+from chemvas.domain.document.shapes import moved_shape
from chemvas.features.selection import translate_projected_point_3d
-from chemvas.ui.annotations.records import (
- moved_ts_bracket,
- require_shape_record_for,
- require_ts_bracket_record_for,
- set_shape_record_for,
- set_ts_bracket_record_for,
-)
from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for
from chemvas.ui.canvas.canvas_ring_fill_scene_service import rebuild_ring_fill_polygons
from chemvas.ui.molecule.atom_coords_access import set_atom_coords_3d_for_id
@@ -34,8 +26,15 @@
class CanvasMoveController:
def __init__(
- self, canvas: CanvasView, *, hit_testing_service: CanvasHitTestingService
+ self,
+ canvas: CanvasView,
+ *,
+ hit_testing_service: CanvasHitTestingService,
+ ring_polygon_rebuilder=rebuild_ring_fill_polygons,
+ point_factory=None,
) -> None:
+ self.point_factory = point_factory
+ self.ring_polygon_rebuilder = ring_polygon_rebuilder
self.canvas = canvas
self.marks = mark_registry_for(canvas)
self.hit_testing_service = hit_testing_service
@@ -90,6 +89,11 @@ def move_item(
data["dy"] = center.y() - atom.y
item.setData(1, data)
elif kind == "shape":
+ from chemvas.ui.annotations.records import (
+ require_shape_record_for,
+ set_shape_record_for,
+ )
+
# Move a shape by rebuilding its path in scene coordinates and keeping
# item.pos() at the origin. Using moveBy here would leave a non-zero
# pos that a later resize (which rebuilds the path in scene space)
@@ -98,15 +102,15 @@ def move_item(
set_shape_record_for(
self.canvas,
item,
- replace(
- shape,
- left=shape.left + dx,
- top=shape.top + dy,
- right=shape.right + dx,
- bottom=shape.bottom + dy,
- ),
+ moved_shape(shape, dx, dy),
)
elif kind == "ts_bracket":
+ from chemvas.ui.annotations.records import (
+ moved_ts_bracket,
+ require_ts_bracket_record_for,
+ set_ts_bracket_record_for,
+ )
+
# Like a shape: the path is rebuilt in scene coordinates and
# item.pos() stays at the origin.
set_ts_bracket_record_for(
@@ -247,7 +251,7 @@ def move_rings_for_atoms(
) -> None:
# A ring fill is a polygon over its atoms, so moving them refits it
# rather than translating it; the deltas are the caller's, not ours.
- rebuild_ring_fill_polygons(
+ self.ring_polygon_rebuilder(
self.canvas.model,
atom_ids,
self.canvas.runtime_state.ring_items()
@@ -321,12 +325,17 @@ def set_atom_positions(
if dot is not None:
dot.setPos(x, y)
for mark in list(self.marks.get_for_atom(atom_id) or ()):
+ point_factory = self.point_factory
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
data = mark.data(1) or {}
dx, dy = data.get("dx"), data.get("dy")
center = (
- QPointF(x + dx, y + dy)
+ point_factory(x + dx, y + dy)
if isinstance(dx, (int, float)) and isinstance(dy, (int, float))
- else QPointF(x, y)
+ else point_factory(x, y)
)
self.canvas.services.scene_decoration_build_service.set_mark_center(
mark, center
diff --git a/app/chemvas/ui/canvas/canvas_note_controller.py b/app/chemvas/ui/canvas/canvas_note_controller.py
index 92b26798..9d437015 100644
--- a/app/chemvas/ui/canvas/canvas_note_controller.py
+++ b/app/chemvas/ui/canvas/canvas_note_controller.py
@@ -62,6 +62,7 @@
capture_scene_runtime,
)
from chemvas.ui.transactions.scene_runtime_restore import restore_scene_runtime
+from chemvas.ui.window.main_window_config import TEXT_POINT_SIZE_RANGE
if TYPE_CHECKING:
from collections.abc import Callable
@@ -798,7 +799,8 @@ def stepped_format(current: QTextCharFormat) -> QTextCharFormat:
if size <= 0:
size = QFontInfo(font).pointSizeF()
fmt = QTextCharFormat()
- fmt.setFontPointSize(max(6.0, min(96.0, size + delta)))
+ low, high = TEXT_POINT_SIZE_RANGE
+ fmt.setFontPointSize(max(low, min(high, size + delta)))
return fmt
if cursor.hasSelection():
diff --git a/app/chemvas/ui/canvas/canvas_pointer_controller.py b/app/chemvas/ui/canvas/canvas_pointer_controller.py
index b4c74fd3..4f2ab791 100644
--- a/app/chemvas/ui/canvas/canvas_pointer_controller.py
+++ b/app/chemvas/ui/canvas/canvas_pointer_controller.py
@@ -7,9 +7,6 @@
from chemvas.domain.document import VALID_ARROW_KINDS
from chemvas.features.rendering import (
- DOUBLE_STYLE_CENTER,
- DOUBLE_STYLE_DEFAULT,
- DOUBLE_STYLE_OUTER,
double_position_for_style,
is_positionable_double_bond_style,
style_for_double_position,
@@ -25,6 +22,11 @@
scene_pos_in_sheet_for,
)
from chemvas.ui.dialogs.mark_reassignment_dialog import reassign_mark_with_dialog
+from chemvas.ui.window.main_window_config import (
+ DOUBLE_BOND_CONTEXT_STYLES,
+ WHEEL_ANGLE_PER_PIXEL,
+ WHEEL_ZOOM_BASE,
+)
_DRAWING_TOOL_NAMES = frozenset(
{
@@ -40,13 +42,6 @@
}
)
-DOUBLE_BOND_CONTEXT_STYLES = (
- ("Inward", DOUBLE_STYLE_DEFAULT),
- ("Centered", DOUBLE_STYLE_CENTER),
- ("Outward", DOUBLE_STYLE_OUTER),
-)
-
-
if TYPE_CHECKING:
from chemvas.ui.canvas.canvas_hit_testing_service import CanvasHitTestingService
from chemvas.ui.canvas.canvas_view import CanvasView
@@ -356,7 +351,7 @@ def wheel_event(self, event, *, base_wheel_event) -> None:
set_zoom_for(
self.canvas,
float(self.canvas.runtime_state.input_view_state.zoom)
- * (1.0015**angle),
+ * (WHEEL_ZOOM_BASE**angle),
under_mouse=True,
)
event.accept()
@@ -365,8 +360,8 @@ def wheel_event(self, event, *, base_wheel_event) -> None:
delta = event.pixelDelta()
if delta.isNull():
angle = event.angleDelta()
- dx = -int(angle.x() / 2)
- dy = -int(angle.y() / 2)
+ dx = -int(angle.x() / WHEEL_ANGLE_PER_PIXEL)
+ dy = -int(angle.y() / WHEEL_ANGLE_PER_PIXEL)
else:
dx = -delta.x()
dy = -delta.y()
diff --git a/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py b/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py
index 67158c3b..61371adb 100644
--- a/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py
+++ b/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py
@@ -2,15 +2,11 @@
from typing import TYPE_CHECKING, Any
-from PyQt6.QtCore import QPointF
-from PyQt6.QtGui import QPolygonF
-
-from chemvas.ui.annotations.materialize import create_ring_item_from_state
-from chemvas.ui.scene.scene_selectability import make_item_selectable
-
if TYPE_CHECKING:
from collections.abc import Iterable
+ from PyQt6.QtCore import QPointF
+
from chemvas.domain.document import MoleculeModel
from chemvas.ui.scene.scene_render_context import SceneRenderContext
@@ -19,6 +15,9 @@ def rebuild_ring_fill_polygons(
model: MoleculeModel,
atom_ids: set[int],
ring_items: Iterable[Any],
+ *,
+ point_factory=None,
+ polygon_factory=None,
) -> None:
"""Re-fit every ring polygon that touches ``atom_ids`` to its atoms.
@@ -31,6 +30,14 @@ def rebuild_ring_fill_polygons(
points is not a shape.
"""
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ if polygon_factory is None:
+ from PyQt6.QtGui import QPolygonF
+
+ polygon_factory = QPolygonF
for ring_item in ring_items:
ring_atom_ids = ring_item.data(2)
if not isinstance(ring_atom_ids, list):
@@ -42,9 +49,9 @@ def rebuild_ring_fill_polygons(
atom = model.atom_for_id(atom_id)
if atom is None:
continue
- points.append(QPointF(atom.x, atom.y))
+ points.append(point_factory(atom.x, atom.y))
if len(points) >= 3:
- ring_item.setPolygon(QPolygonF(points))
+ ring_item.setPolygon(polygon_factory(points))
class CanvasRingFillSceneService:
@@ -69,6 +76,9 @@ def update_ring_fills_for_atoms(
)
def create_ring_fill_item(self, points: list[QPointF], atom_ids: list[int]):
+ from chemvas.ui.annotations.materialize import create_ring_item_from_state
+ from chemvas.ui.scene.scene_selectability import make_item_selectable
+
ring_item = create_ring_item_from_state(
{
"points": [(point.x(), point.y()) for point in points],
diff --git a/app/chemvas/ui/canvas/canvas_tool_mode_controller.py b/app/chemvas/ui/canvas/canvas_tool_mode_controller.py
index 80f2fe59..6a7cf41e 100644
--- a/app/chemvas/ui/canvas/canvas_tool_mode_controller.py
+++ b/app/chemvas/ui/canvas/canvas_tool_mode_controller.py
@@ -8,7 +8,10 @@
from chemvas.ui.annotations.shape_geometry import SHAPE_KINDS, STROKE_STYLES
from chemvas.ui.annotations.state import shape_state_dict_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
-from chemvas.ui.canvas.canvas_tool_settings_state import set_tool_setting_for
+from chemvas.ui.canvas.canvas_tool_settings_state import (
+ normalized_arrow_style,
+ set_tool_setting_for,
+)
from chemvas.ui.canvas.canvas_window_access import history_service_for_canvas
from chemvas.ui.history.history_commands import (
SetAnnotationSettingsCommand,
@@ -213,12 +216,7 @@ def set_arrow_line_width(self, width: float) -> None:
self.set_arrow_style(width, self.settings.arrow_head_scale)
def set_arrow_style(self, width: float, head_scale: float) -> None:
- self._set_annotation_style(
- {
- "arrow_line_width": max(0.5, float(width)),
- "arrow_head_scale": max(0.1, min(0.8, head_scale)),
- }
- )
+ self._set_annotation_style(normalized_arrow_style(width, head_scale))
def get_arrow_line_width(self) -> float:
return self.settings.arrow_line_width
diff --git a/app/chemvas/ui/canvas/canvas_tool_settings_state.py b/app/chemvas/ui/canvas/canvas_tool_settings_state.py
index 1736a31a..b922d891 100644
--- a/app/chemvas/ui/canvas/canvas_tool_settings_state.py
+++ b/app/chemvas/ui/canvas/canvas_tool_settings_state.py
@@ -5,6 +5,16 @@
from chemvas.features.annotations import DEFAULT_BRACKET_KIND
+GRID_MODES: tuple[Literal["none", "hex", "square"], ...] = ("none", "hex", "square")
+GRID_STRENGTHS = (15, 20, 25)
+# Below this on-screen spacing the grid reads as a grey wash rather than as a
+# guide, so it is left unpainted while the snapping itself keeps working.
+MIN_GRID_SPACING_PX = 6.0
+GRID_COLOR = "#8c8c87"
+GRID_CONTROL_HINT = (
+ "Cycle grid and arrow/line snapping; open the menu for grid strength"
+)
+
@dataclass(slots=True, kw_only=True)
class CanvasToolSettingsState:
@@ -33,9 +43,24 @@ class CanvasToolSettingsState:
valence_checking: bool = True
+def grid_step_for(canvas) -> float:
+ """Grid spacing in scene units, so the grid scales with the bond length."""
+ return (
+ canvas.renderer.style.bond_length_px
+ * canvas.runtime_state.tool_settings_state.grid_snap_step
+ )
+
+
+def normalized_arrow_style(width: float, head_scale: float) -> dict[str, float]:
+ return {
+ "arrow_line_width": max(0.5, float(width)),
+ "arrow_head_scale": max(0.1, min(0.8, head_scale)),
+ }
+
+
def set_tool_setting_for(canvas: Any, name: str, value: Any) -> None:
state = canvas.runtime_state.tool_settings_state
setattr(state, name, value)
-__all__ = ["CanvasToolSettingsState", "set_tool_setting_for"]
+__all__ = ["CanvasToolSettingsState", "normalized_arrow_style", "set_tool_setting_for"]
diff --git a/app/chemvas/ui/canvas/document_scene.py b/app/chemvas/ui/canvas/document_scene.py
index dd310ba9..c5461eda 100644
--- a/app/chemvas/ui/canvas/document_scene.py
+++ b/app/chemvas/ui/canvas/document_scene.py
@@ -7,6 +7,7 @@
from PyQt6.QtCore import QPointF, Qt
from PyQt6.QtGui import QColor
+from chemvas.domain.document.perspective import perspective_from_state
from chemvas.ui.annotations.materialize import create_scene_item_from_state
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
from chemvas.ui.canvas.molecule_scene_renderer import (
@@ -98,22 +99,12 @@ def attach(item_state: dict, collection_name: str):
for item_state in state.get("images", []):
attach(item_state, "image_items")
- perspective = state.get("perspective") or {}
- context.state.atom_coords_3d_state.atom_coords_3d = {
- int(atom_id): (float(coords[0]), float(coords[1]), float(coords[2]))
- for atom_id, coords in perspective.get("atom_coords_3d", {}).items()
- }
rotation = context.state.rotation_state
- center = perspective.get("projection_center_3d")
- anchor = perspective.get("projection_anchor_2d")
- rotation.projection_center_3d = (
- (float(center[0]), float(center[1]), float(center[2]))
- if center is not None
- else None
- )
- rotation.projection_anchor_2d = (
- (float(anchor[0]), float(anchor[1])) if anchor is not None else None
- )
+ (
+ context.state.atom_coords_3d_state.atom_coords_3d,
+ rotation.projection_center_3d,
+ rotation.projection_anchor_2d,
+ ) = perspective_from_state(state.get("perspective"))
prepare_molecule_for_scene(context.model)
render_molecule(context)
diff --git a/app/chemvas/ui/canvas/graphics_items.py b/app/chemvas/ui/canvas/graphics_items.py
index 57b19b57..64453607 100644
--- a/app/chemvas/ui/canvas/graphics_items.py
+++ b/app/chemvas/ui/canvas/graphics_items.py
@@ -31,8 +31,10 @@
)
from chemvas.features.annotations import (
+ ATOM_LABEL_DOCUMENT_MARGIN,
SUB_SCALE,
LabelLayout,
+ label_bounding_rect,
parse_atom_label,
place_hydride_stack,
place_runs,
@@ -473,6 +475,9 @@ def __init__(
self._anchor_at_end = False
self._stack: tuple[str, int, bool] | None = None
self._stack_element_rect: QRectF | None = None
+ document = self.document()
+ if document is not None:
+ document.setDocumentMargin(ATOM_LABEL_DOCUMENT_MARGIN)
self._relayout()
def set_outline_mode(self, enabled: bool) -> None:
@@ -642,29 +647,13 @@ def _base_rect(self) -> QRectF:
)
return super().boundingRect()
- def _hit_rect(self) -> QRectF:
- rect = self._base_rect()
- if self._hit_radius is not None and self._hit_radius > 0.0:
- center = rect.center()
- radius = self._hit_radius
- return QRectF(
- center.x() - radius,
- center.y() - radius,
- radius * 2.0,
- radius * 2.0,
- )
- if self._hit_padding > 0.0:
- return rect.adjusted(
- -self._hit_padding,
- -self._hit_padding,
- self._hit_padding,
- self._hit_padding,
- )
- return rect
-
@override
def boundingRect(self):
- return self._base_rect().united(self._hit_rect())
+ return QRectF(
+ *label_bounding_rect(
+ self._base_rect().getRect(), self._hit_padding, self._hit_radius
+ )
+ )
def layout_scene_bounding_rect(self) -> QRectF:
"""Text layout without the pick halo, retained for annotation placement."""
diff --git a/app/chemvas/ui/canvas/input_view_access.py b/app/chemvas/ui/canvas/input_view_access.py
index b75b6ae9..217b231d 100644
--- a/app/chemvas/ui/canvas/input_view_access.py
+++ b/app/chemvas/ui/canvas/input_view_access.py
@@ -13,12 +13,13 @@
set_explicit_scene_rect,
set_explicit_view_scene_rect,
)
+from chemvas.ui.window.main_window_config import (
+ FIT_VIEW_MARGIN,
+ ZOOM_MAX,
+ ZOOM_MIN,
+ ZOOM_STEP,
+)
-# View magnification limits and the per-step multiplier shared by the toolbar
-# buttons and the Ctrl+= / Ctrl+- shortcuts. Ctrl+wheel uses a finer factor.
-ZOOM_MIN = 0.2
-ZOOM_MAX = 5.0
-ZOOM_STEP = 1.25
_MISSING_CAPTURE_ATTRIBUTE = object()
@@ -269,7 +270,7 @@ def reset_zoom_for(canvas) -> float:
return set_zoom_for(canvas, 1.0)
-def fit_canvas_to_view_for(canvas, *, margin: float = 0.92) -> float:
+def fit_canvas_to_view_for(canvas, *, margin: float = FIT_VIEW_MARGIN) -> float:
from chemvas.ui.canvas.sheet_setup_access import (
refresh_canvas_scroll_range_for,
sheet_rect_for,
@@ -393,9 +394,6 @@ def structure_edit_shortcut_matches(event, *, atom: bool, bond: bool) -> bool:
__all__ = [
- "ZOOM_MAX",
- "ZOOM_MIN",
- "ZOOM_STEP",
"CanvasSceneRectStateSnapshot",
"chemdraw_shortcut_text_for",
"fit_canvas_to_view_for",
diff --git a/app/chemvas/ui/canvas/pick_radius_access.py b/app/chemvas/ui/canvas/pick_radius_access.py
index e64b11f0..699df3a5 100644
--- a/app/chemvas/ui/canvas/pick_radius_access.py
+++ b/app/chemvas/ui/canvas/pick_radius_access.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+STRUCTURE_BOND_PICK_RADIUS_RATIO = 0.528
+
def atom_pick_radius(renderer) -> float:
base_radius = max(0.6, renderer.style.bond_line_width * 0.6)
diff --git a/app/chemvas/ui/canvas/sheet_setup_access.py b/app/chemvas/ui/canvas/sheet_setup_access.py
index 9a4bf546..ea316c9e 100644
--- a/app/chemvas/ui/canvas/sheet_setup_access.py
+++ b/app/chemvas/ui/canvas/sheet_setup_access.py
@@ -12,7 +12,9 @@
set_scene_rect_for,
)
from chemvas.ui.canvas.sheet_setup_logic import (
- SHEET_MARGIN_PX,
+ OFF_SHEET_EDIT_GUIDANCE,
+ scene_pos_in_sheet,
+ sheet_scene_bounds,
)
from chemvas.ui.canvas.sheet_setup_state import (
set_sheet_setup_state_for,
@@ -28,11 +30,6 @@
if TYPE_CHECKING:
from collections.abc import Callable
-OFF_SHEET_EDIT_GUIDANCE = (
- "Drawing and hover edits are only available inside the sheet. "
- "Move the pointer inside, or use Select to move the object onto the sheet."
-)
-
@dataclass(slots=True)
class _SheetSetupSavepoint:
@@ -126,9 +123,9 @@ def _apply_sheet_scene_rect_unchecked(canvas) -> None:
if scene is not None:
bounds = content_bounds(export_item_closure(collect_export_items(scene)))
if bounds is not None:
- scene_rect = scene_rect.united(
- bounds.adjusted(
- -SHEET_MARGIN_PX, -SHEET_MARGIN_PX, SHEET_MARGIN_PX, SHEET_MARGIN_PX
+ scene_rect = QRectF(
+ *sheet_scene_bounds(
+ sheet_rect.width(), sheet_rect.height(), bounds.getRect()
)
)
canvas.runtime_state.sheet_setup_state.rect = sheet_rect
@@ -162,9 +159,7 @@ def sheet_rect_for(canvas) -> QRectF:
def scene_pos_in_sheet_for(canvas, pos) -> bool:
rect = sheet_rect_for(canvas)
- if rect.isNull() or rect.isEmpty():
- return True
- return rect.contains(pos)
+ return scene_pos_in_sheet(pos.x(), pos.y(), rect.getRect())
def set_sheet_setup_for(
diff --git a/app/chemvas/ui/canvas/sheet_setup_logic.py b/app/chemvas/ui/canvas/sheet_setup_logic.py
index a570b096..b21873b3 100644
--- a/app/chemvas/ui/canvas/sheet_setup_logic.py
+++ b/app/chemvas/ui/canvas/sheet_setup_logic.py
@@ -7,9 +7,52 @@
validate_custom_sheet_size,
)
+SHEET_SETUP_TEXT = {
+ "title": "Canvas Size",
+ "size": "Canvas size:",
+ "orientation": "Orientation:",
+ "width": "Width:",
+ "height": "Height:",
+ "explanation": "Changing the sheet does not resize or move the drawing.",
+}
+SHEET_DIMENSION_DECIMALS = 2
+SHEET_DIMENSION_STEP_MM = 1.0
+
DEFAULT_SHEET_SIZE = "A4"
DEFAULT_SHEET_ORIENTATION = "landscape"
SHEET_MARGIN_PX = 80.0
+OFF_SHEET_EDIT_GUIDANCE = (
+ "Drawing and hover edits are only available inside the sheet. "
+ "Move the pointer inside, or use Select to move the object onto the sheet."
+)
+
+
+def sheet_scene_bounds(
+ width: float,
+ height: float,
+ content: tuple[float, float, float, float] | None = None,
+) -> tuple[float, float, float, float]:
+ left, top, right, bottom = -width / 2, -height / 2, width / 2, height / 2
+ if content is not None:
+ x, y, w, h = content
+ left, top = min(left, x), min(top, y)
+ right, bottom = max(right, x + w), max(bottom, y + h)
+ return (
+ left - SHEET_MARGIN_PX,
+ top - SHEET_MARGIN_PX,
+ right - left + 2 * SHEET_MARGIN_PX,
+ bottom - top + 2 * SHEET_MARGIN_PX,
+ )
+
+
+def scene_pos_in_sheet(
+ x: float, y: float, rect: tuple[float, float, float, float]
+) -> bool:
+ left, top, width, height = rect
+ if width <= 0 or height <= 0:
+ return True
+ return left <= x <= left + width and top <= y <= top + height
+
SHEET_ORIENTATION_OPTIONS: tuple[tuple[str, str], ...] = (
("landscape", "Landscape"),
@@ -72,11 +115,16 @@ def sheet_dimensions_px(
__all__ = [
"DEFAULT_SHEET_ORIENTATION",
"DEFAULT_SHEET_SIZE",
+ "OFF_SHEET_EDIT_GUIDANCE",
+ "SHEET_DIMENSION_DECIMALS",
+ "SHEET_DIMENSION_STEP_MM",
"SHEET_MARGIN_PX",
"SHEET_ORIENTATION_OPTIONS",
+ "SHEET_SETUP_TEXT",
"normalize_sheet_orientation",
"normalize_sheet_setup",
"normalize_sheet_size",
+ "scene_pos_in_sheet",
"sheet_dimensions_px",
"supported_sheet_sizes",
]
diff --git a/app/chemvas/ui/canvas/sheet_setup_state.py b/app/chemvas/ui/canvas/sheet_setup_state.py
index b68b3cb3..0c28989e 100644
--- a/app/chemvas/ui/canvas/sheet_setup_state.py
+++ b/app/chemvas/ui/canvas/sheet_setup_state.py
@@ -8,9 +8,9 @@
from chemvas.ui.canvas.sheet_setup_logic import (
DEFAULT_SHEET_ORIENTATION,
DEFAULT_SHEET_SIZE,
- SHEET_MARGIN_PX,
normalize_sheet_setup,
sheet_dimensions_px,
+ sheet_scene_bounds,
)
@@ -27,12 +27,7 @@ def sheet_rects(
) -> tuple[QRectF, QRectF]:
width, height = sheet_dimensions_px(size_name, orientation, custom_size_mm)
sheet_rect = QRectF(-width / 2.0, -height / 2.0, width, height)
- scene_rect = sheet_rect.adjusted(
- -SHEET_MARGIN_PX,
- -SHEET_MARGIN_PX,
- SHEET_MARGIN_PX,
- SHEET_MARGIN_PX,
- )
+ scene_rect = QRectF(*sheet_scene_bounds(width, height))
return sheet_rect, scene_rect
diff --git a/app/chemvas/ui/dialogs/arrow_label_dialog.py b/app/chemvas/ui/dialogs/arrow_label_dialog.py
index 1dcdec06..68cd0900 100644
--- a/app/chemvas/ui/dialogs/arrow_label_dialog.py
+++ b/app/chemvas/ui/dialogs/arrow_label_dialog.py
@@ -12,20 +12,12 @@
)
from chemvas.domain.document import MAX_ARROW_LABEL_CHARS
-from chemvas.features.annotations import arrow_label_html
-from chemvas.shell.palette import PALETTE
-
-LABEL_SYNTAX_HINT = (
- "Use _{...} for subscripts and ^{...} for superscripts. "
- "Examples: K_{2}CO_{3}, H_{2}SO_{4}, ΔG^{‡}.\n"
- "Without braces, _ or ^ applies until the next space, _ or ^. "
- "Braces do not nest and backslash escaping is not supported. "
- "A trailing _ or ^, or one followed by a space, is literal. "
- "Enter inserts a line break; Tab moves to the next field. "
- "Each field is limited to 200 characters; shorten longer text before OK, "
- "or use a Note. "
- "Leave a field empty to remove that label."
+from chemvas.features.annotations import (
+ ARROW_LABEL_PREVIEW_POINT_SIZE,
+ LABEL_SYNTAX_HINT,
+ arrow_label_html,
)
+from chemvas.shell.palette import PALETTE
def _label_input(
@@ -56,7 +48,7 @@ def _label_input(
preview.setWordWrap(False)
preview.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
preview_font = preview.font()
- preview_font.setPointSize(14)
+ preview_font.setPointSize(ARROW_LABEL_PREVIEW_POINT_SIZE)
preview.setFont(preview_font)
preview.setMargin(4)
preview.setMinimumHeight(preview.fontMetrics().height() + 8)
diff --git a/app/chemvas/ui/dialogs/calculation_panel.py b/app/chemvas/ui/dialogs/calculation_panel.py
index 3770ea78..6f4bc99c 100644
--- a/app/chemvas/ui/dialogs/calculation_panel.py
+++ b/app/chemvas/ui/dialogs/calculation_panel.py
@@ -22,6 +22,7 @@
from chemvas.ui.canvas.canvas_document_state import savable_calculation_plan_for
from chemvas.ui.dialogs.calculation_canvas_mapping import CalculationCanvasMapping
from chemvas.ui.dialogs.calculation_step_dialog import CalculationStepDialog
+from chemvas.ui.window.main_window_config import REACTION_MAPPING_TITLE
from chemvas.ui.window.main_window_ports import (
active_canvas_for_window,
document_session_service_for_window,
@@ -43,7 +44,7 @@
class CalculationPanel(QDockWidget):
def __init__(self, window: MainWindowLike) -> None:
- super().__init__("Reaction Mapping", window)
+ super().__init__(REACTION_MAPPING_TITLE, window)
self.setObjectName("calculationDock")
self.setAllowedAreas(
Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea
diff --git a/app/chemvas/ui/export/export_cdxml.py b/app/chemvas/ui/export/export_cdxml.py
new file mode 100644
index 00000000..1f2651e7
--- /dev/null
+++ b/app/chemvas/ui/export/export_cdxml.py
@@ -0,0 +1,1740 @@
+"""CDXML export: editable ChemDraw-compatible XML from the scene model.
+
+Reads the actual SceneRenderContext model and scene items to produce native
+CDXML fragments, nodes, bonds, graphics and text. Receives the shared
+ExportPlan for uniform coordinate/font/stroke scaling.
+
+Only constructs with a substantiated native CDXML mapping are emitted.
+Everything else raises CdxmlUnsupportedObjectError before any file is written.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+import xml.etree.ElementTree as ET
+from collections.abc import Mapping
+from typing import TYPE_CHECKING
+
+from chemvas.domain.atom_aliases import ATOM_ALIAS_DEFINITIONS
+from chemvas.domain.document import (
+ Arrow,
+ Bond,
+ MoleculeModel,
+ Shape,
+ connected_atom_components,
+)
+from chemvas.features.rendering import arrow_path_commands
+from chemvas.ui.annotations.shape_geometry import shape_outline, shape_stroke_width
+from chemvas.ui.export.export_scope import (
+ EXPORT_EXCLUDED_KINDS,
+ export_item_closure,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from PyQt6.QtGui import (
+ QFont,
+ QTextBlock,
+ QTextDocument,
+ QTextFragment,
+ QTextLayout,
+ QTransform,
+ )
+ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsTextItem
+
+ from chemvas.domain.document.groups import SceneGroup
+ from chemvas.features.export import ExportPlan
+ from chemvas.ui.scene.scene_render_context import SceneRenderContext
+
+GroupsProvider = Mapping[int, "SceneGroup"]
+
+
+# ── Element-to-atomic-number mapping (pure elements only) ──────────────
+
+_ELEMENT_TO_Z: dict[str, int] = {}
+_z = 0
+for _sym in (
+ "H He Li Be B C N O F Ne Na Mg Al Si P S Cl Ar K Ca Sc Ti V Cr Mn Fe Co Ni "
+ "Cu Zn Ga Ge As Se Br Kr Rb Sr Y Zr Nb Mo Tc Ru Rh Pd Ag Cd In Sn Sb Te I Xe "
+ "Cs Ba La Ce Pr Nd Pm Sm Eu Gd Tb Dy Ho Er Tm Yb Lu Hf Ta W Re Os Ir Pt Au Hg "
+ "Tl Pb Bi Po At Rn Fr Ra Ac Th Pa U Np Pu Am Cm Bk Cf Es Fm Md No Lr Rf Db Sg "
+ "Bh Hs Mt Ds Rg Cn Nh Fl Mc Lv Ts Og"
+).split():
+ _z += 1
+ _ELEMENT_TO_Z[_sym] = _z
+del _z, _sym
+
+
+# ── Supported bond styles → CDXML Display ─────────────────────────────
+
+_BOND_DISPLAY: dict[str, str] = {
+ "single": "Solid",
+ "double_center": "Solid",
+ "triple": "Solid",
+}
+
+_BOND_ORDER_SUPPORT = frozenset({1, 2, 3})
+
+_SUPPORTED_BOND_STYLES = frozenset(_BOND_DISPLAY)
+
+# ── Supported arrow/line kinds ─────────────────────────────────────────
+
+_SUPPORTED_LINE_KINDS = frozenset({"line"})
+_SUPPORTED_ARROW_KINDS = frozenset({"arrow"})
+
+# ── CDXML face bitmask ─────────────────────────────────────────────────
+
+_FACE_BOLD = 1
+
+# ── XML 1.0 illegal character pattern ─────────────────────────────────
+
+_XML_ILLEGAL_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
+
+
+class CdxmlUnsupportedObjectError(ValueError):
+ """An object or style cannot be faithfully serialized to CDXML."""
+
+ def __init__(self, object_kind: str, object_id: str, reason: str) -> None:
+ self.object_kind = object_kind
+ self.object_id = object_id
+ self.reason = reason
+ super().__init__(
+ f"CDXML export does not support {object_kind} {object_id}: "
+ f"{reason}. Use SVG or PDF export instead."
+ )
+
+
+# ── ID allocator ───────────────────────────────────────────────────────
+
+
+class _IdAllocator:
+ def __init__(self) -> None:
+ self._next = 1
+
+ def next(self) -> int:
+ v = self._next
+ self._next += 1
+ return v
+
+
+# ── Color table ────────────────────────────────────────────────────────
+
+
+def _color_rgb(hex_color: str) -> tuple[float, float, float]:
+ h = hex_color.lstrip("#")
+ if len(h) == 3:
+ h = "".join(c * 2 for c in h)
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
+ return r / 255.0, g / 255.0, b / 255.0
+
+
+class _ColorTable:
+ def __init__(self) -> None:
+ self._entries: list[tuple[float, float, float]] = []
+ self._index: dict[tuple[float, float, float], int] = {}
+
+ def index_for(self, hex_color: str) -> int:
+ rgb = _color_rgb(hex_color)
+ if rgb in self._index:
+ return self._index[rgb]
+ pos = len(self._entries)
+ self._entries.append(rgb)
+ self._index[rgb] = pos
+ return pos
+
+ def reference(self, hex_color: str) -> int:
+ return self.index_for(hex_color) + 2
+
+ def to_element(self) -> ET.Element:
+ el = ET.SubElement(ET.Element("_"), "colortable")
+ for r, g, b in self._entries:
+ c = ET.SubElement(el, "color")
+ c.set("r", f"{r:.6g}")
+ c.set("g", f"{g:.6g}")
+ c.set("b", f"{b:.6g}")
+ return el
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+
+# ── Font table ─────────────────────────────────────────────────────────
+
+
+class _FontTable:
+ def __init__(self) -> None:
+ self._entries: list[str] = []
+ self._index: dict[str, int] = {}
+
+ def id_for(self, family: str) -> int:
+ if family in self._index:
+ return self._index[family]
+ font_id = len(self._entries)
+ self._entries.append(family)
+ self._index[family] = font_id
+ return font_id
+
+ def to_element(self) -> ET.Element:
+ el = ET.SubElement(ET.Element("_"), "fonttable")
+ for font_id, name in enumerate(self._entries):
+ f = ET.SubElement(el, "font")
+ f.set("id", str(font_id))
+ f.set("charset", "utf-8")
+ f.set("name", name)
+ return el
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+
+# ── Preflight checks ──────────────────────────────────────────────────
+
+
+def _check_perspective_inactive(
+ context: SceneRenderContext,
+) -> None:
+ coords_3d = context.state.atom_coords_3d_state.atom_coords_3d
+ if not coords_3d:
+ return
+ model = context.model
+ for atom_id, pt3 in coords_3d.items():
+ if atom_id in model.atoms and not math.isclose(pt3[2], 0.0, abs_tol=1e-9):
+ raise CdxmlUnsupportedObjectError(
+ "perspective",
+ f"atom {atom_id}",
+ "perspective rendering changes visual geometry; "
+ "disable perspective or flatten 3D coordinates first",
+ )
+
+
+_VALID_STYLE_ORDER: dict[str, int] = {
+ "single": 1,
+ "double_center": 2,
+ "triple": 3,
+}
+
+
+def _check_bond_style(bond: Bond, bond_id: int) -> None:
+ if bond.style not in _SUPPORTED_BOND_STYLES:
+ raise CdxmlUnsupportedObjectError(
+ "bond",
+ str(bond_id),
+ f"style '{bond.style}' has no proved CDXML mapping",
+ )
+ if bond.order not in _BOND_ORDER_SUPPORT:
+ raise CdxmlUnsupportedObjectError(
+ "bond",
+ str(bond_id),
+ f"order {bond.order} is not supported",
+ )
+ expected_order = _VALID_STYLE_ORDER.get(bond.style)
+ if expected_order is not None and bond.order != expected_order:
+ raise CdxmlUnsupportedObjectError(
+ "bond",
+ str(bond_id),
+ f"style '{bond.style}' requires order {expected_order}, "
+ f"got order {bond.order}",
+ )
+
+
+def _check_element_label(element: str, atom_id: int) -> int:
+ if element in ATOM_ALIAS_DEFINITIONS:
+ raise CdxmlUnsupportedObjectError(
+ "atom",
+ str(atom_id),
+ f"label '{element}' is an atom alias (abbreviation), not a pure element",
+ )
+ z = _ELEMENT_TO_Z.get(element)
+ if z is None:
+ raise CdxmlUnsupportedObjectError(
+ "atom",
+ str(atom_id),
+ f"label '{element}' is not a pure element symbol",
+ )
+ return z
+
+
+def _check_atom_annotation(
+ atom_id: int,
+ annotation: dict[str, int] | None,
+) -> None:
+ if not annotation:
+ return
+ radical = annotation.get("radical_electrons", 0)
+ if radical != 0:
+ raise CdxmlUnsupportedObjectError(
+ "atom",
+ str(atom_id),
+ f"radical_electrons={radical} has no proved CDXML radical mapping",
+ )
+
+
+def _check_arrow(record: Arrow, index: int) -> None:
+ if record.labels:
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ "arrow labels are not yet emitted in CDXML export",
+ )
+ kind = record.kind
+ if kind in _SUPPORTED_LINE_KINDS:
+ if record.mirrored:
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ "mirrored property is not supported for lines in CDXML export",
+ )
+ return
+ if kind in _SUPPORTED_ARROW_KINDS:
+ if record.control is not None:
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ "curved arrows have no proved CurvePoints encoding",
+ )
+ if record.double:
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ "double arrow shafts are not supported",
+ )
+ if record.mirrored:
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ "mirrored property is not supported for arrows in CDXML export",
+ )
+ return
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(index),
+ f"kind '{kind}' has no proved CDXML mapping",
+ )
+
+
+def _check_shape(
+ shape: Shape,
+ index: int,
+ *,
+ stroke_color: str = "#000000",
+) -> None:
+ kind = shape.shape_kind
+ if kind not in ("rect", "circle"):
+ raise CdxmlUnsupportedObjectError(
+ "shape",
+ str(index),
+ f"shape_kind '{kind}' has no proved CDXML mapping",
+ )
+ if shape.fill_alpha is not None and shape.fill_alpha < 1.0:
+ if shape.fill is not None:
+ raise CdxmlUnsupportedObjectError(
+ "shape",
+ str(index),
+ "translucent fill has no proved CDXML encoding",
+ )
+ stroke = shape.stroke_style
+ if stroke == "none" and shape.fill is not None:
+ pass
+ elif stroke == "solid":
+ if shape.fill is not None and shape.fill != stroke_color:
+ raise CdxmlUnsupportedObjectError(
+ "shape",
+ str(index),
+ "shape has different stroke and fill colours; "
+ "CDXML supports one color attribute per graphic",
+ )
+ else:
+ raise CdxmlUnsupportedObjectError(
+ "shape",
+ str(index),
+ f"stroke_style '{stroke}' has no proved CDXML mapping",
+ )
+
+
+def _check_transform_no_rotation(
+ transform: QTransform,
+ object_kind: str,
+ object_id: str,
+) -> None:
+ m11 = transform.m11()
+ m12 = transform.m12()
+ m21 = transform.m21()
+ m22 = transform.m22()
+ if not math.isclose(m12, 0.0, abs_tol=1e-6):
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ "rotated transform (m12 != 0) has no proved RotationAngle encoding",
+ )
+ if not math.isclose(m21, 0.0, abs_tol=1e-6):
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ "rotated transform (m21 != 0) has no proved RotationAngle encoding",
+ )
+ if not (m11 > 0 and math.isclose(m11, m22, rel_tol=1e-6)):
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ "non-uniform or negative scale transform is not supported",
+ )
+
+
+def _check_note_formatting(
+ context: SceneRenderContext,
+ item: object,
+ index: int,
+) -> None:
+ from PyQt6.QtWidgets import QGraphicsTextItem
+
+ if not isinstance(item, QGraphicsTextItem):
+ raise CdxmlUnsupportedObjectError("note", str(index), "note is not a text item")
+
+ transform = item.sceneTransform()
+ _check_transform_no_rotation(transform, "note", str(index))
+
+ document = item.document()
+ if document is None:
+ return
+
+ from PyQt6.QtGui import QFont
+
+ default_font = document.defaultFont()
+ if default_font is not None:
+ if default_font.capitalization() != QFont.Capitalization.MixedCase:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"default font capitalization "
+ f"{default_font.capitalization().name} is not supported in CDXML",
+ )
+ if not math.isclose(default_font.wordSpacing(), 0.0, abs_tol=1e-9):
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "default font word spacing is not supported in CDXML",
+ )
+ df_stretch = default_font.stretch()
+ if df_stretch != 0 and df_stretch != 100:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"default font stretch {df_stretch} is not supported in CDXML",
+ )
+
+ block = document.begin()
+ while block.isValid():
+ _check_note_block(block, document, index)
+ block = block.next()
+
+
+def _check_note_block(
+ block: QTextBlock,
+ document: QTextDocument,
+ index: int,
+) -> None:
+ from PyQt6.QtCore import Qt
+ from PyQt6.QtGui import QFont, QTextCharFormat, QTextFormat
+
+ if block.textList() is not None:
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "list formatting is not supported"
+ )
+ frame = document.rootFrame()
+ if frame is not None and frame.childFrames():
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "table/frame formatting is not supported"
+ )
+ bf = block.blockFormat()
+ has_block_alignment = bf.hasProperty(QTextFormat.Property.BlockAlignment)
+ if has_block_alignment:
+ effective_alignment = bf.alignment()
+ else:
+ effective_alignment = document.defaultTextOption().alignment()
+ if effective_alignment & (
+ Qt.AlignmentFlag.AlignRight
+ | Qt.AlignmentFlag.AlignHCenter
+ | Qt.AlignmentFlag.AlignJustify
+ ):
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "non-left text alignment is not supported in CDXML",
+ )
+
+ default_font = document.defaultFont()
+
+ frag_it = block.begin()
+ while not frag_it.atEnd():
+ fragment = frag_it.fragment()
+ fmt = fragment.charFormat()
+ font = fmt.font()
+ if default_font is not None:
+ font = font.resolve(default_font)
+ if font.italic():
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "italic text has no proved CDXML face bit",
+ )
+ weight = font.weight()
+ if weight != QFont.Weight.Normal and weight != QFont.Weight.Bold:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"font weight {int(weight)} is not supported; "
+ "only Normal (400) and Bold (700) are mapped",
+ )
+ if font.underline():
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "underline text is not supported"
+ )
+ if font.overline():
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "overline text is not supported"
+ )
+ if font.strikeOut():
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "strikeout text is not supported"
+ )
+ va = fmt.verticalAlignment()
+ if va in (
+ QTextCharFormat.VerticalAlignment.AlignSubScript,
+ QTextCharFormat.VerticalAlignment.AlignSuperScript,
+ ):
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "sub/superscript in notes is not supported"
+ )
+ bg = fmt.background()
+ if bg.style() != Qt.BrushStyle.NoBrush and bg.color().alpha() > 0:
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "text background highlight is not supported"
+ )
+ spacing = fmt.fontLetterSpacing()
+ if spacing != 0.0 and spacing != 100.0:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "custom letter spacing is not supported in CDXML",
+ )
+ if fmt.textOutline().style() != Qt.PenStyle.NoPen:
+ raise CdxmlUnsupportedObjectError(
+ "note", str(index), "text outline is not supported in CDXML"
+ )
+ if fmt.isImageFormat():
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "embedded images in text are not supported in CDXML",
+ )
+ capitalization = font.capitalization()
+ if capitalization != QFont.Capitalization.MixedCase:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"font capitalization {capitalization.name} is not supported in CDXML",
+ )
+ if not math.isclose(font.wordSpacing(), 0.0, abs_tol=1e-9):
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ "custom word spacing is not supported in CDXML",
+ )
+ stretch = font.stretch()
+ if stretch != 0 and stretch != 100:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"font stretch {stretch} is not supported in CDXML",
+ )
+ underline_style = fmt.underlineStyle()
+ if underline_style not in (
+ QTextCharFormat.UnderlineStyle.NoUnderline,
+ QTextCharFormat.UnderlineStyle.SingleUnderline,
+ ):
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(index),
+ f"underline style {underline_style.name} is not supported in CDXML",
+ )
+ frag_it += 1
+
+
+def _check_note_box(
+ context: SceneRenderContext,
+ item: object,
+ index: int,
+) -> None:
+ settings = context.state.text_style_state
+ if settings.note_box_enabled or settings.note_border_enabled:
+ raise CdxmlUnsupportedObjectError(
+ "note_box",
+ str(index),
+ "note box background/border is not emitted in CDXML; "
+ "disable note boxes and borders before exporting",
+ )
+
+
+# ── Uniform scale ──────────────────────────────────────────────────────
+
+
+def _uniform_scale(plan: ExportPlan) -> float:
+ return plan.out_w_pt / plan.source_w
+
+
+def _scale_coord(
+ value: float,
+ origin: float,
+ scale: float,
+) -> float:
+ return (value - origin) * scale
+
+
+# ── Coordinate helpers ─────────────────────────────────────────────────
+
+
+def _pt_attr(x: float, y: float) -> str:
+ return f"{x:.12g} {y:.12g}"
+
+
+def _pt3_attr(x: float, y: float) -> str:
+ return f"{x:.12g} {y:.12g} 0"
+
+
+def _bbox_attr(x1: float, y1: float, x2: float, y2: float) -> str:
+ return f"{x1:.12g} {y1:.12g} {x2:.12g} {y2:.12g}"
+
+
+# ── XML text validation ──────────────────────────────────────────────
+
+
+def _validate_xml_text(
+ text: str,
+ object_kind: str,
+ object_id: str,
+) -> str:
+ if _XML_ILLEGAL_RE.search(text):
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ "text contains characters illegal in XML 1.0",
+ )
+ return text
+
+
+# ── Role preflight ───────────────────────────────────────────────────
+
+
+def _preflight_roles(
+ export_items: list[QGraphicsItem],
+) -> None:
+ for item in export_items:
+ if not item.isVisible():
+ continue
+ role = item.data(0)
+ if role is None or role in EXPORT_EXCLUDED_KINDS:
+ continue
+ if role == "arrow_label":
+ raise CdxmlUnsupportedObjectError(
+ "arrow_label",
+ str(item.data(3) or "?"),
+ "arrow labels are not yet emitted in CDXML export",
+ )
+ if role == "mark":
+ raise CdxmlUnsupportedObjectError(
+ "mark",
+ str(item.data(3) or "?"),
+ "scene marks (including charge marks) have no proved "
+ "CDXML mapping; use native atom Charge annotation instead",
+ )
+ if role in ("atom", "bond", "shape", "note", "note_box"):
+ continue
+ if role in _SUPPORTED_LINE_KINDS or role in _SUPPORTED_ARROW_KINDS:
+ continue
+ if role == "arrow":
+ continue
+ if role in ("line", "line_dashed", "line_wavy", "line_bold"):
+ if role != "line":
+ raise CdxmlUnsupportedObjectError(
+ "line",
+ str(item.data(3) or "?"),
+ f"line kind '{role}' has no proved CDXML mapping",
+ )
+ continue
+ raise CdxmlUnsupportedObjectError(
+ str(role),
+ str(item.data(1) or item.data(3) or "?"),
+ f"object role '{role}' has no proved CDXML mapping",
+ )
+
+
+# ── Fragment interleaving check ─────────────────────────────────────
+
+
+def _check_item_fragment_interleave(
+ export_items: list[QGraphicsItem],
+ frag_ranges: list[tuple[int, int, int]],
+ frag_items: dict[int, list[QGraphicsItem]],
+ scene_pos: dict[int, int],
+ export_set: set[QGraphicsItem],
+) -> None:
+ non_frag_roles = frozenset({"note", "arrow", "line", "shape"})
+ for item in export_items:
+ role = item.data(0)
+ if role not in non_frag_roles or not item.isVisible():
+ continue
+ item_rank = scene_pos.get(id(item), -1)
+ item_bounds = item.sceneBoundingRect()
+ for frag_min, frag_max, comp_idx in frag_ranges:
+ if frag_min < item_rank < frag_max:
+ for frag_item in frag_items.get(comp_idx, []):
+ if frag_item in export_set and item_bounds.intersects(
+ frag_item.sceneBoundingRect()
+ ):
+ raise CdxmlUnsupportedObjectError(
+ str(role),
+ str(item.data(3) or "?"),
+ "item interleaves with a fragment's z-order "
+ "(between its bonds and atoms); CDXML cannot "
+ "represent this stacking",
+ )
+
+
+def _check_fragment_pair_interleave(
+ frag_ranges: list[tuple[int, int, int]],
+ frag_items: dict[int, list[QGraphicsItem]],
+) -> None:
+ for i, (min_i, max_i, comp_i) in enumerate(frag_ranges):
+ for min_j, max_j, comp_j in frag_ranges[i + 1 :]:
+ if min_i < max_j and min_j < max_i:
+ for it_i in frag_items.get(comp_i, []):
+ bi = it_i.sceneBoundingRect()
+ for it_j in frag_items.get(comp_j, []):
+ if bi.intersects(it_j.sceneBoundingRect()):
+ raise CdxmlUnsupportedObjectError(
+ "fragment",
+ str(comp_i),
+ "two fragments interleave in z-order and "
+ "overlap spatially; CDXML cannot represent "
+ "this stacking",
+ )
+
+
+def _check_fragment_interleaving(
+ context: SceneRenderContext,
+ export_items: list[QGraphicsItem],
+ atom_ids_in_export: set[int],
+) -> None:
+ from PyQt6.QtCore import Qt as QtCore_Qt
+
+ model = context.model
+ bond_pairs = [
+ (bond.a, bond.b)
+ for bond in model.bonds
+ if bond is not None
+ and bond.a in atom_ids_in_export
+ and bond.b in atom_ids_in_export
+ ]
+ components = connected_atom_components(atom_ids_in_export, bond_pairs)
+ atom_to_comp: dict[int, int] = {}
+ for comp_idx, comp in enumerate(components):
+ for aid in comp:
+ atom_to_comp[aid] = comp_idx
+
+ frag_items: dict[int, list[QGraphicsItem]] = {}
+ for item in context.scene.items():
+ role = item.data(0)
+ if role == "atom":
+ aid = item.data(1)
+ if isinstance(aid, int) and aid in atom_to_comp:
+ frag_items.setdefault(atom_to_comp[aid], []).append(item)
+ elif role == "bond":
+ bid = item.data(1)
+ if isinstance(bid, int) and 0 <= bid < len(model.bonds):
+ bond = model.bonds[bid]
+ if bond is not None and bond.a in atom_to_comp:
+ frag_items.setdefault(atom_to_comp[bond.a], []).append(item)
+
+ if not frag_items:
+ return
+
+ scene_order = list(context.scene.items(QtCore_Qt.SortOrder.AscendingOrder))
+ scene_pos = {id(it): i for i, it in enumerate(scene_order)}
+
+ frag_ranges: list[tuple[int, int, int]] = []
+ for comp_idx, items_list in frag_items.items():
+ ranks = [scene_pos.get(id(it), 0) for it in items_list]
+ if ranks:
+ frag_ranges.append((min(ranks), max(ranks), comp_idx))
+
+ export_set = set(export_items)
+ _check_item_fragment_interleave(
+ export_items, frag_ranges, frag_items, scene_pos, export_set
+ )
+ _check_fragment_pair_interleave(frag_ranges, frag_items)
+
+
+# ── Main preflight ───────────────────────────────────────────────────
+
+
+def preflight_cdxml(
+ context: SceneRenderContext,
+ items: Sequence[QGraphicsItem],
+ plan: ExportPlan,
+ *,
+ background: str = "white",
+ groups: Mapping[int, SceneGroup] | None = None,
+) -> None:
+ """Validate every visible item can be faithfully serialized.
+
+ Raises CdxmlUnsupportedObjectError for the first unsupported construct.
+ Must be called before any file I/O.
+ """
+ if background not in ("white", "transparent"):
+ raise CdxmlUnsupportedObjectError(
+ "background",
+ background,
+ "only 'white' and 'transparent' backgrounds are supported",
+ )
+
+ _check_perspective_inactive(context)
+
+ model = context.model
+ export_items = export_item_closure(list(items))
+ export_set = set(export_items)
+
+ atom_ids_in_export: set[int] = set()
+ exported_record_ids: set[int] = set()
+ for item in export_items:
+ role = item.data(0)
+ if role == "atom":
+ if not item.isVisible():
+ continue
+ aid = item.data(1)
+ if isinstance(aid, int):
+ atom_ids_in_export.add(aid)
+ rec_id = item.data(3)
+ if isinstance(rec_id, int):
+ exported_record_ids.add(rec_id)
+
+ bond_color = context.renderer.bond_pen().color().name()
+
+ for atom_id, atom in model.atoms.items():
+ if atom_id not in atom_ids_in_export:
+ continue
+ _check_element_label(atom.element, atom_id)
+ annotation = model.atom_annotation_for(atom_id)
+ _check_atom_annotation(atom_id, annotation)
+
+ for bond_id, bond in enumerate(model.bonds):
+ if bond is None:
+ continue
+ if bond.a not in atom_ids_in_export and bond.b not in atom_ids_in_export:
+ continue
+ if bond.a in atom_ids_in_export and bond.b in atom_ids_in_export:
+ _check_bond_style(bond, bond_id)
+ else:
+ raise CdxmlUnsupportedObjectError(
+ "bond",
+ str(bond_id),
+ "selection cuts through a bond; export the whole molecule or "
+ "deselect atoms on both sides",
+ )
+
+ for item in export_items:
+ role = item.data(0)
+ if role == "bond":
+ bond_id_val = item.data(1)
+ if isinstance(bond_id_val, int) and 0 <= bond_id_val < len(model.bonds):
+ bond = model.bonds[bond_id_val]
+ if bond is not None:
+ if (
+ bond.a not in atom_ids_in_export
+ or bond.b not in atom_ids_in_export
+ ):
+ raise CdxmlUnsupportedObjectError(
+ "bond",
+ str(bond_id_val),
+ "bond endpoint atoms are not in the export set; "
+ "include both endpoint atoms or remove the bond",
+ )
+
+ for idx, arrow_item in enumerate(context.state.arrow_items()):
+ if arrow_item is not None and arrow_item in export_set:
+ record = context.arrows.record(arrow_item)
+ _check_arrow(record, idx)
+
+ for idx, rec_id in enumerate(context.state.shape_state.order):
+ rec = context.state.shape_state.records.get(rec_id)
+ shape_item = context.state.scene_items_state.shape_items.get(rec_id)
+ if rec is not None and shape_item is not None and shape_item in export_set:
+ _check_shape(rec, idx, stroke_color=bond_color)
+
+ for idx, note_item in enumerate(context.state.note_items()):
+ if note_item is not None and note_item in export_set:
+ _check_note_formatting(context, note_item, idx)
+ _check_note_box(context, note_item, idx)
+
+ _preflight_roles(export_items)
+
+ _check_fragment_interleaving(context, export_items, atom_ids_in_export)
+
+ if groups:
+ for group_id, group in groups.items():
+ if group.atom_ids & atom_ids_in_export:
+ raise CdxmlUnsupportedObjectError(
+ "group",
+ str(group_id),
+ "group emission is not yet implemented in CDXML export",
+ )
+ if group.item_ids and set(group.item_ids) & exported_record_ids:
+ raise CdxmlUnsupportedObjectError(
+ "group",
+ str(group_id),
+ "group with item members is not yet implemented in CDXML export",
+ )
+
+
+# ── Fragment serialization ───────────────────────────────────────────
+
+
+def _serialize_fragments(
+ components: Sequence[Sequence[int]],
+ model: MoleculeModel,
+ ids: _IdAllocator,
+ colors: _ColorTable,
+ fonts: _FontTable,
+ s: float,
+ ox: float,
+ oy: float,
+ context: SceneRenderContext,
+ scene_rank: dict[int, int],
+) -> list[tuple[int, ET.Element]]:
+ atom_cdxml_ids: dict[int, int] = {}
+
+ atom_rank_map: dict[int, int] = {}
+ for item in context.scene.items():
+ if item.data(0) == "atom":
+ aid = item.data(1)
+ if isinstance(aid, int):
+ atom_rank_map[aid] = scene_rank.get(id(item), 0)
+
+ result: list[tuple[int, ET.Element]] = []
+
+ for component in components:
+ frag_cdxml_id = ids.next()
+ frag = ET.Element("fragment")
+ frag.set("id", str(frag_cdxml_id))
+
+ frag_rank = max(atom_rank_map.get(aid, 0) for aid in component)
+
+ for atom_id in sorted(component):
+ atom = model.atoms[atom_id]
+ node_id = ids.next()
+ atom_cdxml_ids[atom_id] = node_id
+
+ ax = _scale_coord(atom.x, ox, s)
+ ay = _scale_coord(atom.y, oy, s)
+
+ n = ET.SubElement(frag, "n")
+ n.set("id", str(node_id))
+ n.set("p", _pt_attr(ax, ay))
+
+ z = _ELEMENT_TO_Z[atom.element]
+ if z != 6:
+ n.set("Element", str(z))
+
+ annotation = model.atom_annotation_for(atom_id)
+ charge = 0
+ if annotation:
+ charge = annotation.get("formal_charge", 0)
+ if charge != 0:
+ n.set("Charge", str(charge))
+
+ if atom.color != "#000000":
+ n.set("color", str(colors.reference(atom.color)))
+
+ show_label = atom.element != "C" or atom.explicit_label
+ if show_label:
+ _add_atom_label(
+ n,
+ ids,
+ fonts,
+ colors,
+ s,
+ ox,
+ oy,
+ context,
+ atom_id,
+ )
+
+ for _, bond in enumerate(model.bonds):
+ if bond is None:
+ continue
+ if bond.a not in set(component) or bond.b not in set(component):
+ continue
+ b_el = ET.SubElement(frag, "b")
+ b_el.set("id", str(ids.next()))
+ b_el.set("B", str(atom_cdxml_ids[bond.a]))
+ b_el.set("E", str(atom_cdxml_ids[bond.b]))
+
+ if bond.order != 1:
+ b_el.set("Order", str(bond.order))
+
+ display = _BOND_DISPLAY.get(bond.style, "Solid")
+ if display != "Solid":
+ b_el.set("Display", display)
+
+ if bond.order == 2 and bond.style == "double_center":
+ b_el.set("DoublePosition", "Center")
+
+ if bond.color != "#000000":
+ b_el.set("color", str(colors.reference(bond.color)))
+
+ result.append((frag_rank, frag))
+
+ return result
+
+
+# ── Main serialization ───────────────────────────────────────────────
+
+
+def serialize_cdxml(
+ context: SceneRenderContext,
+ items: Sequence[QGraphicsItem],
+ plan: ExportPlan,
+ *,
+ background: str = "white",
+ groups: Mapping[int, SceneGroup] | None = None,
+) -> bytes:
+ """Serialize the export items to a CDXML byte string.
+
+ The caller must have run preflight_cdxml first.
+ """
+ if background not in ("white", "transparent"):
+ raise CdxmlUnsupportedObjectError(
+ "background",
+ background,
+ "only 'white' and 'transparent' backgrounds are supported",
+ )
+
+ from PyQt6.QtCore import Qt as QtCore_Qt
+
+ s = _uniform_scale(plan)
+ ox, oy = plan.source_x, plan.source_y
+
+ ids = _IdAllocator()
+ colors = _ColorTable()
+ fonts = _FontTable()
+
+ model = context.model
+ export_items = export_item_closure(list(items))
+ export_set = set(export_items)
+
+ scene_rank: dict[int, int] = {}
+ for rank, scene_item in enumerate(
+ context.scene.items(QtCore_Qt.SortOrder.AscendingOrder)
+ ):
+ scene_rank[id(scene_item)] = rank
+
+ atom_ids_in_export: set[int] = set()
+ for item in export_items:
+ if item.data(0) == "atom":
+ if not item.isVisible():
+ continue
+ aid = item.data(1)
+ if isinstance(aid, int):
+ atom_ids_in_export.add(aid)
+
+ bond_pairs = []
+ for bond in model.bonds:
+ if (
+ bond is not None
+ and bond.a in atom_ids_in_export
+ and bond.b in atom_ids_in_export
+ ):
+ bond_pairs.append((bond.a, bond.b))
+ components = connected_atom_components(atom_ids_in_export, bond_pairs)
+
+ renderer = context.renderer
+ style = renderer.style
+ settings = context.state.tool_settings_state
+ line_width_scaled = renderer.bond_line_width() * s
+ bold_width_scaled = renderer.bold_bond_width() * s
+ hash_spacing_scaled = renderer.hash_spacing() * s
+ bond_length_scaled = style.bond_length_px * s
+ bond_spacing_scaled = renderer.bond_spacing() * s
+ arrow_lw_scaled = settings.arrow_line_width * s
+ shape_sw_scaled = shape_stroke_width(style.bond_line_width) * s
+
+ page_width = plan.out_w_pt
+
+ root = ET.Element("CDXML")
+ root.set("BondLength", f"{bond_length_scaled:.12g}")
+ root.set("BoldWidth", f"{bold_width_scaled:.12g}")
+ root.set("LineWidth", f"{line_width_scaled:.12g}")
+ root.set("HashSpacing", f"{hash_spacing_scaled:.12g}")
+ root.set(
+ "BondSpacing",
+ f"{bond_spacing_scaled / bond_length_scaled * 100:.12g}",
+ )
+ root.set("InterpretChemically", "yes")
+ root.set("FractionalWidths", "yes")
+ root.set("CreationProgram", "Chemvas")
+
+ if background == "white":
+ root.set("bgcolor", "2")
+
+ colors.index_for("#ffffff")
+ colors.index_for("#000000")
+
+ z_elements: list[tuple[int, ET.Element]] = []
+
+ for frag_rank, frag_el in _serialize_fragments(
+ components,
+ model,
+ ids,
+ colors,
+ fonts,
+ s,
+ ox,
+ oy,
+ context,
+ scene_rank,
+ ):
+ z_elements.append((frag_rank, frag_el))
+
+ bond_color_hex = context.renderer.bond_pen().color().name()
+
+ for _idx, arrow_item in enumerate(context.state.arrow_items()):
+ if arrow_item is None or arrow_item not in export_set:
+ continue
+ if not arrow_item.isVisible():
+ continue
+ record = context.arrows.record(arrow_item)
+ el = _emit_arrow_or_line(
+ record,
+ ids,
+ colors,
+ s,
+ ox,
+ oy,
+ arrow_lw_scaled,
+ context,
+ bond_color_hex=bond_color_hex,
+ )
+ z_elements.append((scene_rank.get(id(arrow_item), 0), el))
+
+ for rec_id in context.state.shape_state.order:
+ rec = context.state.shape_state.records.get(rec_id)
+ shape_item = context.state.scene_items_state.shape_items.get(rec_id)
+ if rec is None or shape_item is None or shape_item not in export_set:
+ continue
+ if not shape_item.isVisible():
+ continue
+ el = _emit_shape(
+ rec,
+ ids,
+ colors,
+ s,
+ ox,
+ oy,
+ shape_sw_scaled,
+ bond_color_hex=bond_color_hex,
+ )
+ z_elements.append((scene_rank.get(id(shape_item), 0), el))
+
+ for note_item in context.state.note_items():
+ if note_item is None or note_item not in export_set:
+ continue
+ if not note_item.isVisible():
+ continue
+ t_elements = _emit_note(
+ note_item,
+ context,
+ ids,
+ fonts,
+ colors,
+ s,
+ ox,
+ oy,
+ )
+ note_rank = scene_rank.get(id(note_item), 0)
+ for t_el in t_elements:
+ z_elements.append((note_rank, t_el))
+
+ z_elements.sort(key=lambda x: x[0])
+
+ color_el = colors.to_element()
+ font_el = fonts.to_element()
+
+ if len(colors) > 0:
+ root.insert(0, color_el)
+ if len(fonts) > 0:
+ root.insert(1 if len(colors) > 0 else 0, font_el)
+
+ page = ET.SubElement(root, "page")
+ page.set("id", str(ids.next()))
+ page.set("Width", f"{page_width:.12g}")
+ page.set("Height", f"{plan.out_h_pt:.12g}")
+ page.set("BoundingBox", _bbox_attr(0, 0, page_width, plan.out_h_pt))
+
+ if background == "white":
+ page.set("bgcolor", "2")
+
+ if not z_elements:
+ raise CdxmlUnsupportedObjectError(
+ "page",
+ "1",
+ "export would produce an empty page with no content",
+ )
+
+ z_counter = 1
+ for _, el in z_elements:
+ el.set("Z", str(z_counter))
+ z_counter += 1
+ page.append(el)
+
+ _indent_preserving_text(root)
+ xml_bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
+ return xml_bytes
+
+
+def _indent_preserving_text(root: ET.Element, level: int = 0) -> None:
+ indent = "\n" + " " * level
+ child_indent = "\n" + " " * (level + 1)
+ if root.tag in ("t", "s"):
+ return
+ children = list(root)
+ if children:
+ if root.text is None or not root.text.strip():
+ root.text = child_indent
+ for i, child in enumerate(children):
+ _indent_preserving_text(child, level + 1)
+ if child.tail is None or not child.tail.strip():
+ child.tail = child_indent if i < len(children) - 1 else indent
+ if not children and root.text is None:
+ root.text = None
+
+
+# ── Atom label emission ────────────────────────────────────────────────
+
+
+def _add_atom_label(
+ n: ET.Element,
+ ids: _IdAllocator,
+ fonts: _FontTable,
+ colors: _ColorTable,
+ scale: float,
+ ox: float,
+ oy: float,
+ context: SceneRenderContext,
+ atom_id: int,
+) -> None:
+ from PyQt6.QtGui import QFontInfo
+
+ renderer = context.renderer
+ atom_font = renderer.atom_font()
+ item_scale, scene_baseline_y, scene_label_x = _find_atom_item_info(
+ context,
+ atom_id,
+ )
+ pixel_size = _font_pixel_size(atom_font)
+ label_size = pixel_size * item_scale * scale
+
+ family = QFontInfo(atom_font).family()
+ font_id = fonts.id_for(family)
+
+ color_ref = "3"
+ atom_obj = context.model.atoms.get(atom_id)
+ if atom_obj is None:
+ raise CdxmlUnsupportedObjectError(
+ "atom", str(atom_id), "atom not found in model"
+ )
+ if atom_obj.color != "#000000":
+ color_ref = str(colors.reference(atom_obj.color))
+
+ face = _FACE_BOLD if atom_font.bold() else 0
+
+ if scene_label_x is None or scene_baseline_y is None:
+ raise CdxmlUnsupportedObjectError(
+ "atom",
+ str(atom_id),
+ "atom label baseline could not be determined from scene item; "
+ "the label owner or layout record is missing",
+ )
+ ax = _scale_coord(scene_label_x, ox, scale)
+ ay = _scale_coord(scene_baseline_y, oy, scale)
+
+ t = ET.SubElement(n, "t")
+ t.set("id", str(ids.next()))
+ t.set("p", _pt_attr(ax, ay))
+ t.set("Justification", "Left")
+ t.set("InterpretChemically", "no")
+ t.set("CaptionLineHeight", "1")
+
+ s_el = ET.SubElement(t, "s")
+ s_el.set("font", str(font_id))
+ s_el.set("size", f"{label_size:.12g}")
+ s_el.set("color", color_ref)
+ if face:
+ s_el.set("face", str(face))
+ s_el.text = _validate_xml_text(
+ atom_obj.element,
+ "atom",
+ str(atom_id),
+ )
+
+
+def _find_atom_item_info(
+ context: SceneRenderContext,
+ atom_id: int,
+) -> tuple[float, float | None, float | None]:
+ from PyQt6.QtWidgets import QGraphicsTextItem
+
+ item = context.state.atom_graphics_state.atom_items.get(atom_id)
+ if item is None:
+ return 1.0, None, None
+ if not item.isVisible():
+ raise CdxmlUnsupportedObjectError(
+ "atom",
+ str(atom_id),
+ "hidden atom label item cannot be faithfully exported",
+ )
+ transform = item.sceneTransform()
+ _check_transform_no_rotation(transform, "atom", str(atom_id))
+ x_s = transform.m11()
+ if x_s <= 0:
+ return 1.0, None, None
+ if not isinstance(item, QGraphicsTextItem):
+ return x_s, None, None
+ document = item.document()
+ if document is None:
+ return x_s, None, None
+ block = document.begin()
+ if not block.isValid():
+ return x_s, None, None
+ layout = block.layout()
+ if layout is None or layout.lineCount() == 0:
+ return x_s, None, None
+ line = layout.lineAt(0)
+ local_x = layout.position().x() + line.x()
+ local_baseline_y = layout.position().y() + line.y() + line.ascent()
+ scene_label_x = (
+ transform.m11() * local_x + transform.m21() * local_baseline_y + transform.dx()
+ )
+ scene_baseline_y = (
+ transform.m12() * local_x + transform.m22() * local_baseline_y + transform.dy()
+ )
+ return x_s, scene_baseline_y, scene_label_x
+
+
+def _font_pixel_size(font: object) -> float:
+ from PyQt6.QtGui import QFont, QRawFont
+
+ if not isinstance(font, QFont):
+ raise CdxmlUnsupportedObjectError("font", "unknown", "expected QFont instance")
+ raw = QRawFont.fromFont(font)
+ ps = raw.pixelSize()
+ if ps > 0:
+ return float(ps)
+ raise CdxmlUnsupportedObjectError(
+ "font",
+ font.family(),
+ "raw font pixel size is not available; cannot determine label size",
+ )
+
+
+def _check_glyph_font_consistency(
+ font: object,
+ primary_family: str,
+ text: str,
+ object_kind: str,
+ object_id: str,
+) -> None:
+ from PyQt6.QtGui import QFont, QTextLayout
+
+ if not isinstance(font, QFont):
+ return
+ layout = QTextLayout(text, font)
+ layout.beginLayout()
+ line = layout.createLine()
+ if line.isValid():
+ line.setLineWidth(1e6)
+ layout.endLayout()
+ for run in layout.glyphRuns():
+ raw = run.rawFont()
+ run_family = raw.familyName()
+ if run_family and run_family != primary_family:
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ f"text requires glyph fallback font '{run_family}' "
+ f"instead of primary font '{primary_family}'; "
+ "mixed/fallback fonts cannot be faithfully represented",
+ )
+ for glyph_index in run.glyphIndexes():
+ if glyph_index == 0:
+ raise CdxmlUnsupportedObjectError(
+ object_kind,
+ object_id,
+ "text contains glyphs that map to index 0 (.notdef); "
+ f"font '{run_family or primary_family}' is missing "
+ "required glyph outlines and would render as tofu",
+ )
+
+
+# ── Arrow / line emission ─────────────────────────────────────────────
+
+
+def _emit_arrow_or_line(
+ record: Arrow,
+ ids: _IdAllocator,
+ colors: _ColorTable,
+ s: float,
+ ox: float,
+ oy: float,
+ arrow_lw_scaled: float,
+ context: SceneRenderContext,
+ *,
+ bond_color_hex: str = "#000000",
+) -> ET.Element:
+ kind = record.kind
+
+ sx = _scale_coord(record.start[0], ox, s)
+ sy = _scale_coord(record.start[1], oy, s)
+ ex = _scale_coord(record.end[0], ox, s)
+ ey = _scale_coord(record.end[1], oy, s)
+
+ if kind == "line":
+ g = ET.Element("graphic")
+ g.set("id", str(ids.next()))
+ g.set("GraphicType", "Line")
+ g.set("ArrowType", "NoHead")
+ g.set("LineType", "Solid")
+ g.set("BoundingBox", _bbox_attr(sx, sy, ex, ey))
+ g.set("LineWidth", f"{arrow_lw_scaled:.12g}")
+ effective_color = record.color or bond_color_hex
+ if effective_color != "#000000":
+ g.set("color", str(colors.reference(effective_color)))
+ return g
+
+ renderer = context.renderer
+ settings = context.state.tool_settings_state
+ commands = arrow_path_commands(
+ start=record.start,
+ end=record.end,
+ kind="arrow",
+ bond_length=renderer.style.bond_length_px,
+ bond_spacing=renderer.bond_spacing(),
+ wave_spacing=0.0,
+ line_width=settings.arrow_line_width,
+ head_scale=settings.arrow_head_scale,
+ )
+
+ for cmd, _args in commands:
+ if cmd not in ("M", "L"):
+ raise CdxmlUnsupportedObjectError(
+ "arrow",
+ str(record.kind),
+ f"arrow path contains unsupported command '{cmd}'; "
+ "only M and L commands can be exported",
+ )
+
+ segments: list[tuple[tuple[float, float], tuple[float, float]]] = []
+ current: tuple[float, float] = (0.0, 0.0)
+ for cmd, args in commands:
+ if cmd == "M":
+ current = (args[0], args[1])
+ elif cmd == "L":
+ segments.append((current, (args[0], args[1])))
+ current = (args[0], args[1])
+
+ group = ET.Element("group")
+ group.set("id", str(ids.next()))
+
+ for seg_start, seg_end in segments:
+ seg_sx = _scale_coord(seg_start[0], ox, s)
+ seg_sy = _scale_coord(seg_start[1], oy, s)
+ seg_ex = _scale_coord(seg_end[0], ox, s)
+ seg_ey = _scale_coord(seg_end[1], oy, s)
+
+ g = ET.SubElement(group, "graphic")
+ g.set("id", str(ids.next()))
+ g.set("GraphicType", "Line")
+ g.set("ArrowType", "NoHead")
+ g.set("LineType", "Solid")
+ g.set("BoundingBox", _bbox_attr(seg_sx, seg_sy, seg_ex, seg_ey))
+ g.set("LineWidth", f"{arrow_lw_scaled:.12g}")
+
+ effective_color = record.color or bond_color_hex
+ if effective_color != "#000000":
+ color_ref = str(colors.reference(effective_color))
+ for child in group:
+ child.set("color", color_ref)
+
+ return group
+
+
+# ── Shape emission ─────────────────────────────────────────────────────
+
+
+def _emit_shape(
+ shape: Shape,
+ ids: _IdAllocator,
+ colors: _ColorTable,
+ s: float,
+ ox: float,
+ oy: float,
+ stroke_width_scaled: float,
+ *,
+ bond_color_hex: str = "#000000",
+) -> ET.Element:
+ left = _scale_coord(shape.left, ox, s)
+ top = _scale_coord(shape.top, oy, s)
+ right = _scale_coord(shape.right, ox, s)
+ bottom = _scale_coord(shape.bottom, oy, s)
+
+ _kind, geo_x, geo_y, geo_w, geo_h, _ = shape_outline(
+ left,
+ top,
+ right - left,
+ bottom - top,
+ shape.shape_kind,
+ )
+
+ g = ET.Element("graphic")
+ g.set("id", str(ids.next()))
+
+ if shape.shape_kind == "circle":
+ g.set("GraphicType", "Oval")
+ cx = geo_x + geo_w / 2
+ cy = geo_y + geo_h / 2
+ r = geo_w / 2
+ g.set("Center3D", _pt3_attr(cx, cy))
+ g.set("MajorAxisEnd3D", _pt3_attr(cx + r, cy))
+ g.set("MinorAxisEnd3D", _pt3_attr(cx, cy + r))
+ g.set("BoundingBox", _bbox_attr(cx + r, cy, cx, cy))
+
+ if shape.fill is not None:
+ g.set("OvalType", "Circle Filled")
+ else:
+ g.set("OvalType", "Circle")
+
+ elif shape.shape_kind == "rect":
+ g.set("GraphicType", "Rectangle")
+ g.set(
+ "BoundingBox",
+ _bbox_attr(geo_x + geo_w, geo_y + geo_h, geo_x, geo_y),
+ )
+ g.set(
+ "Center3D",
+ _pt3_attr(geo_x + geo_w / 2, geo_y + geo_h / 2),
+ )
+ g.set(
+ "MajorAxisEnd3D",
+ _pt3_attr(geo_x + geo_w, geo_y + geo_h / 2),
+ )
+ g.set(
+ "MinorAxisEnd3D",
+ _pt3_attr(geo_x + geo_w / 2, geo_y + geo_h),
+ )
+
+ if shape.fill is not None:
+ g.set("RectangleType", "Filled")
+ else:
+ g.set("RectangleType", "Plain")
+
+ if shape.fill is not None:
+ g.set("color", str(colors.reference(shape.fill)))
+ elif shape.stroke_style != "none" and bond_color_hex != "#000000":
+ g.set("color", str(colors.reference(bond_color_hex)))
+
+ if shape.stroke_style == "none":
+ g.set("LineWidth", "0")
+ else:
+ g.set("LineWidth", f"{stroke_width_scaled:.12g}")
+
+ return g
+
+
+# ── Note / text emission ──────────────────────────────────────────────
+
+
+def _emit_note(
+ item: object,
+ context: SceneRenderContext,
+ ids: _IdAllocator,
+ fonts: _FontTable,
+ colors: _ColorTable,
+ s: float,
+ ox: float,
+ oy: float,
+) -> list[ET.Element]:
+ from PyQt6.QtWidgets import QGraphicsTextItem
+
+ if not isinstance(item, QGraphicsTextItem):
+ return []
+
+ document = item.document()
+ if document is None:
+ return []
+
+ item.boundingRect()
+
+ item_transform = item.sceneTransform()
+ item_scale = math.hypot(item_transform.m11(), item_transform.m12())
+ if item_scale <= 0:
+ item_scale = 1.0
+
+ result: list[ET.Element] = []
+
+ block = document.begin()
+ while block.isValid():
+ layout = block.layout()
+ if layout is None:
+ block = block.next()
+ continue
+
+ block_text = block.text()
+ block_utf16 = block_text.encode("utf-16-le")
+ default_font = document.defaultFont()
+
+ for li in range(layout.lineCount()):
+ line = layout.lineAt(li)
+ if line.textLength() <= 0:
+ continue
+
+ line_x = layout.position().x() + line.position().x()
+ line_baseline_y = layout.position().y() + line.y() + line.ascent()
+
+ scene_line_x = (
+ item_transform.m11() * line_x
+ + item_transform.m21() * line_baseline_y
+ + item_transform.dx()
+ )
+ scene_line_y = (
+ item_transform.m12() * line_x
+ + item_transform.m22() * line_baseline_y
+ + item_transform.dy()
+ )
+
+ cdxml_x = _scale_coord(scene_line_x, ox, s)
+ cdxml_y = _scale_coord(scene_line_y, oy, s)
+
+ t_el = ET.Element("t")
+ t_el.set("id", str(ids.next()))
+ t_el.set("p", _pt_attr(cdxml_x, cdxml_y))
+ t_el.set("Justification", "Left")
+ t_el.set("InterpretChemically", "no")
+ t_el.set("CaptionLineHeight", "1")
+
+ line_start = line.textStart()
+ line_end = line_start + line.textLength()
+
+ frag_it = block.begin()
+ while not frag_it.atEnd():
+ fragment = frag_it.fragment()
+ frag_start = fragment.position() - block.position()
+ frag_end = frag_start + fragment.length()
+
+ overlap_start = max(frag_start, line_start)
+ overlap_end = min(frag_end, line_end)
+
+ if overlap_start < overlap_end:
+ text = block_utf16[overlap_start * 2 : overlap_end * 2].decode(
+ "utf-16-le"
+ )
+
+ if text:
+ _emit_note_run(
+ t_el,
+ text,
+ fragment,
+ layout,
+ default_font,
+ item,
+ item_scale,
+ s,
+ fonts,
+ colors,
+ )
+
+ frag_it += 1
+
+ if len(t_el) > 0:
+ result.append(t_el)
+
+ block = block.next()
+
+ return result
+
+
+def _emit_note_run(
+ t_el: ET.Element,
+ text: str,
+ fragment: QTextFragment,
+ layout: QTextLayout,
+ default_font: QFont,
+ item: QGraphicsTextItem,
+ item_scale: float,
+ s: float,
+ fonts: _FontTable,
+ colors: _ColorTable,
+) -> None:
+ from PyQt6.QtCore import Qt
+ from PyQt6.QtGui import QFontInfo
+
+ fmt = fragment.charFormat()
+ frag_font = fmt.font().resolve(default_font)
+
+ pixel_size = _font_pixel_size(frag_font)
+
+ frag_size = pixel_size * item_scale * s
+ family = QFontInfo(frag_font).family()
+ _check_glyph_font_consistency(
+ frag_font,
+ family,
+ text,
+ "note",
+ str(item.data(3) or "?"),
+ )
+ font_id = fonts.id_for(family)
+
+ brush = fmt.foreground()
+ if brush.style() == Qt.BrushStyle.NoBrush:
+ frag_color = item.defaultTextColor()
+ else:
+ frag_color = brush.color()
+
+ if frag_color.alpha() < 255:
+ raise CdxmlUnsupportedObjectError(
+ "note",
+ str(item.data(3) or "?"),
+ "translucent text colour cannot be represented in CDXML",
+ )
+
+ color_hex = frag_color.name()
+ color_ref = str(colors.reference(color_hex)) if color_hex != "#000000" else "3"
+
+ face = _FACE_BOLD if frag_font.bold() else 0
+
+ s_el = ET.SubElement(t_el, "s")
+ s_el.set("font", str(font_id))
+ s_el.set("size", f"{frag_size:.12g}")
+ s_el.set("color", color_ref)
+ if face:
+ s_el.set("face", str(face))
+ s_el.text = _validate_xml_text(text, "note", str(item.data(3) or "?"))
+
+
+# ── Public API ─────────────────────────────────────────────────────────
+
+__all__ = [
+ "CdxmlUnsupportedObjectError",
+ "GroupsProvider",
+ "preflight_cdxml",
+ "serialize_cdxml",
+]
diff --git a/app/chemvas/ui/export/figure_export_service.py b/app/chemvas/ui/export/figure_export_service.py
index 275b42c0..5f671cb1 100644
--- a/app/chemvas/ui/export/figure_export_service.py
+++ b/app/chemvas/ui/export/figure_export_service.py
@@ -20,16 +20,22 @@
)
if TYPE_CHECKING:
- from collections.abc import Callable, Sequence
+ from collections.abc import Callable, Mapping, Sequence
from PyQt6.QtWidgets import QGraphicsItem
+ from chemvas.domain.document.groups import SceneGroup
from chemvas.ui.scene.scene_render_context import SceneRenderContext
class FigureExportService:
- def __init__(self, context: SceneRenderContext) -> None:
+ def __init__(
+ self,
+ context: SceneRenderContext,
+ groups: Mapping[int, SceneGroup] | None = None,
+ ) -> None:
self.context = context
+ self._groups = groups
def _figure_export_parameters(
self,
@@ -126,6 +132,47 @@ def export_figure(
target_width_mm=target_width_mm,
)
fmt = fmt.lower()
+
+ if fmt == "cdxml":
+ from chemvas.ui.export.export_cdxml import (
+ preflight_cdxml,
+ serialize_cdxml,
+ )
+
+ if min_font_pt is not None:
+ if not supports_minimum_font_check(fmt, scope):
+ raise ValueError(
+ "Minimum font checking requires whole-canvas SVG or PNG export."
+ )
+ validate_export_budget(
+ plan,
+ output_format=fmt,
+ dpi=dpi,
+ max_height_mm=max_height_mm,
+ )
+ preflight_cdxml(
+ self.context,
+ items,
+ plan,
+ background=background,
+ groups=self._groups,
+ )
+ cdxml_bytes = serialize_cdxml(
+ self.context,
+ items,
+ plan,
+ background=background,
+ groups=self._groups,
+ )
+
+ def write_cdxml(tmp: Path) -> None:
+ tmp.write_bytes(cdxml_bytes)
+ if after_render is not None:
+ after_render(tmp)
+
+ atomic_write_via_temp(Path(path), write_cdxml)
+ return plan
+
if min_font_pt is not None:
if not supports_minimum_font_check(fmt, scope):
raise ValueError(
diff --git a/app/chemvas/ui/history/history_commands.py b/app/chemvas/ui/history/history_commands.py
index 9b57990d..1ec6fefa 100644
--- a/app/chemvas/ui/history/history_commands.py
+++ b/app/chemvas/ui/history/history_commands.py
@@ -476,6 +476,7 @@ class DeletedSceneItemOrder:
collections: dict[str, list[tuple[int, int]]]
siblings: list[list[tuple[str, int, int]]]
+ mark_bindings: dict[int, list[tuple[int, int]]] = field(default_factory=dict)
def restore(self, operations) -> None:
operations.restore_scene_item_order(self)
diff --git a/app/chemvas/ui/history/history_operations.py b/app/chemvas/ui/history/history_operations.py
index 14e44095..dc7fd889 100644
--- a/app/chemvas/ui/history/history_operations.py
+++ b/app/chemvas/ui/history/history_operations.py
@@ -294,6 +294,14 @@ def capture_scene_item_order(self, item_ids: list[int]) -> DeletedSceneItemOrder
]
for name in SCENE_ITEM_COLLECTION_ATTRS
}
+ mark_bindings = {
+ atom_id: [
+ (index, require_scene_record_id(item))
+ for index, item in enumerate(marks)
+ if require_scene_record_id(item) in ids
+ ]
+ for atom_id, marks in mark_registry_for(self.__canvas).items()
+ }
items = [find_projection(self.__canvas, key) for key in item_ids]
cohorts = {
(item.parentItem(), item.zValue())
@@ -310,7 +318,7 @@ def capture_scene_item_order(self, item_ids: list[int]) -> DeletedSceneItemOrder
for part, item in enumerate(parts):
references[id(item)] = (kind, key, part)
if not cohorts:
- return DeletedSceneItemOrder(collections, [])
+ return DeletedSceneItemOrder(collections, [], mark_bindings)
scene = self.__canvas.scene()
if scene is None:
raise RuntimeError("History stacking capture requires a scene")
@@ -324,7 +332,7 @@ def capture_scene_item_order(self, item_ids: list[int]) -> DeletedSceneItemOrder
]
for parent, z in cohorts
]
- return DeletedSceneItemOrder(collections, siblings)
+ return DeletedSceneItemOrder(collections, siblings, mark_bindings)
def _structure_stacking_items(self):
runtime = self.__canvas.runtime_state
@@ -355,6 +363,19 @@ def restore_scene_item_order(self, order: DeletedSceneItemOrder) -> None:
for index, key in entries:
ids.insert(index, key)
document.reorder(ids)
+ for atom_id, entries in order.mark_bindings.items():
+ marks = mark_registry_for(self.__canvas).get_for_atom(atom_id)
+ if not entries:
+ continue
+ if marks is None:
+ raise RuntimeError("Restored marks require an owner registry")
+ restored = [
+ (index, find_projection(self.__canvas, key)) for index, key in entries
+ ]
+ for _, item in restored:
+ marks.remove(item)
+ for index, item in restored:
+ marks.insert(index, item)
structure = dict(self._structure_stacking_items())
for siblings in order.siblings:
resolved = []
diff --git a/app/chemvas/ui/insert/insert_controller.py b/app/chemvas/ui/insert/insert_controller.py
index 01d5ead9..cb832e51 100644
--- a/app/chemvas/ui/insert/insert_controller.py
+++ b/app/chemvas/ui/insert/insert_controller.py
@@ -6,8 +6,11 @@
from PyQt6.QtWidgets import QGraphicsScene, QMessageBox
from chemvas.features.insertion import (
+ MAX_SMILES_INPUT_LENGTH,
+ SMILES_RENDER_ERROR,
TemplateInsertRequest,
TemplateInsertResolution,
+ normalized_smiles_input,
plan_smiles_commit,
plan_template_commit,
plan_template_preview,
@@ -23,10 +26,14 @@
)
from chemvas.ui.canvas.canvas_window_access import notify_error_for
from chemvas.ui.canvas.input_view_access import viewport_center_scene_pos_for
-from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
+from chemvas.ui.canvas.pick_radius_access import (
+ STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ atom_pick_radius_for,
+)
from chemvas.ui.canvas.sheet_setup_access import scene_pos_in_sheet_for
from chemvas.ui.insert.insert_commit_service import InsertCommitService
from chemvas.ui.insert.insert_mode_logic import (
+ TEMPLATE_BOND_GATE_RATIO,
InsertSessionState,
build_template_insert_request,
)
@@ -57,8 +64,6 @@
from chemvas.ui.canvas.canvas_insert_state import CanvasInsertState
from chemvas.ui.canvas.canvas_view import CanvasView
-MAX_SMILES_INPUT_LENGTH = 1024
-
class InsertController:
def __init__(
@@ -121,12 +126,12 @@ def _warn_smiles_error(self, message: str) -> None:
QMessageBox.warning(self.canvas, "SMILES Error", message)
def _reject_oversized_smiles(self, smiles: str) -> bool:
- if len(smiles) <= MAX_SMILES_INPUT_LENGTH:
- return False
- self._warn_smiles_error(
- f"SMILES input is too long (maximum {MAX_SMILES_INPUT_LENGTH} characters)."
- )
- return True
+ try:
+ normalized_smiles_input(smiles)
+ except ValueError as error:
+ self._warn_smiles_error(str(error))
+ return True
+ return False
def begin_smiles_insert(self, smiles: str) -> None:
if self.insert_state.template_active:
@@ -141,8 +146,7 @@ def begin_smiles_insert(self, smiles: str) -> None:
)
if model is None:
self._warn_smiles_error(
- getattr(self.canvas.rdkit, "last_error", None)
- or "Failed to render SMILES."
+ getattr(self.canvas.rdkit, "last_error", None) or SMILES_RENDER_ERROR
)
return
center_xy = smiles_preview_center(model)
@@ -271,7 +275,7 @@ def _template_structure_target_ids(
return None, None
return None, find_bond_near(
pos,
- self.canvas.renderer.style.bond_length_px * 0.35,
+ self.canvas.renderer.style.bond_length_px * TEMPLATE_BOND_GATE_RATIO,
)
def _direct_structure_hit(self, pos: QPointF) -> StructureHit | None:
@@ -305,14 +309,15 @@ def _preferred_nearby_structure_hit(self, pos: QPointF) -> StructureHit | None:
if bond_hit is not None
else None,
atom_pick_radius=atom_pick_radius_for(self.canvas),
- bond_pick_radius=self.canvas.renderer.style.bond_length_px * 0.528,
+ bond_pick_radius=self.canvas.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
)
def _template_nearby_bond_hit(self, pos: QPointF) -> tuple[int, float] | None:
find_bond_near = getattr(self.hit_testing_service, "find_bond_near", None)
if not callable(find_bond_near):
return None
- gate = self.canvas.renderer.style.bond_length_px * 0.35
+ gate = self.canvas.renderer.style.bond_length_px * TEMPLATE_BOND_GATE_RATIO
bond_id = find_bond_near(pos, gate)
if bond_id is None:
return None
diff --git a/app/chemvas/ui/insert/insert_mode_logic.py b/app/chemvas/ui/insert/insert_mode_logic.py
index da5145b2..95b42c96 100644
--- a/app/chemvas/ui/insert/insert_mode_logic.py
+++ b/app/chemvas/ui/insert/insert_mode_logic.py
@@ -8,6 +8,8 @@
normalize_template_ring_style,
)
+TEMPLATE_BOND_GATE_RATIO = 0.35
+
@dataclass(frozen=True, kw_only=True)
class InsertSessionState:
diff --git a/app/chemvas/ui/insert/insert_smiles_commit_service.py b/app/chemvas/ui/insert/insert_smiles_commit_service.py
index 647c8bbc..438761ff 100644
--- a/app/chemvas/ui/insert/insert_smiles_commit_service.py
+++ b/app/chemvas/ui/insert/insert_smiles_commit_service.py
@@ -2,8 +2,6 @@
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter
from chemvas.ui.molecule.structure_insert_access import (
add_or_update_insert_atom_label_for,
@@ -15,11 +13,25 @@
from chemvas.ui.scene.scene_decoration_access import materialize_mark_for_atom_for
if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import Any
+
from chemvas.features.insertion import SmilesCommitPlan
from chemvas.ui.canvas.canvas_view import CanvasView
-def apply_smiles_commit_plan(canvas: CanvasView, plan: SmilesCommitPlan | None) -> bool:
+def apply_smiles_commit_plan(
+ canvas: CanvasView,
+ plan: SmilesCommitPlan | None,
+ *,
+ point_factory: Callable[[float, float], Any] | None = None,
+ record: bool = True,
+) -> bool:
+ """Apply the native placement; a disposable candidate may own its history.
+
+ The default remains an exact recorded desktop transaction. An unrecorded
+ caller must discard its private candidate if placement fails.
+ """
if plan is None or not plan.atoms:
return False
source_atom_ids = {atom.source_atom_id for atom in plan.atoms}
@@ -32,8 +44,12 @@ def apply_smiles_commit_plan(canvas: CanvasView, plan: SmilesCommitPlan | None)
):
return False
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
committer = StructureBuildCommitter(canvas)
- snapshot = committer.begin_recorded_change()
+ snapshot = committer.begin_recorded_change() if record else None
id_map: dict[int, int] = {}
added_scene_items: list[object] = []
aborted = False
@@ -41,7 +57,8 @@ def apply_smiles_commit_plan(canvas: CanvasView, plan: SmilesCommitPlan | None)
def abort(*, original_error: BaseException | None = None) -> None:
nonlocal aborted
aborted = True
- committer.abort_recorded_change(snapshot, original_error=original_error)
+ if snapshot is not None:
+ committer.abort_recorded_change(snapshot, original_error=original_error)
try:
for atom_plan in plan.atoms:
@@ -113,16 +130,17 @@ def abort(*, original_error: BaseException | None = None) -> None:
item = materialize_mark_for_atom_for(
canvas,
mark_atom_id,
- QPointF(mark_plan.x, mark_plan.y),
+ point_factory(mark_plan.x, mark_plan.y),
kind=mark_plan.kind,
)
if item is not None:
added_scene_items.append(item)
- committer.record_additions(
- snapshot,
- added_scene_items=added_scene_items or None,
- )
+ if snapshot is not None:
+ committer.record_additions(
+ snapshot,
+ added_scene_items=added_scene_items or None,
+ )
except Exception as error:
if not aborted:
abort(original_error=error)
diff --git a/app/chemvas/ui/insert/insert_template_commit_service.py b/app/chemvas/ui/insert/insert_template_commit_service.py
index 262fa1c9..264ef89d 100644
--- a/app/chemvas/ui/insert/insert_template_commit_service.py
+++ b/app/chemvas/ui/insert/insert_template_commit_service.py
@@ -4,6 +4,7 @@
from PyQt6.QtCore import QPointF
+from chemvas.ui.insert.template_commit_logic import commit_template_ring
from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter
from chemvas.ui.molecule.structure_insert_access import (
add_insert_ring_from_points_for,
@@ -11,7 +12,6 @@
has_insert_mutation_since_for,
insert_bond_exists_for,
)
-from chemvas.ui.molecule.structure_mutation_access import add_bond_for
from chemvas.ui.scene.scene_group_operations import group_connection_allowed_for
if TYPE_CHECKING:
@@ -56,46 +56,23 @@ def apply_template_commit_resolution(
points = [QPointF(x, y) for x, y in resolution.points]
committer = StructureBuildCommitter(canvas)
snapshot = committer.begin_recorded_change()
-
+ builder = canvas.services.structure_build_service
try:
- if plan.generator in {
- "atom_regular_ring",
- "bond_regular_ring",
- "bond_template_shape",
- }:
- if plan.generator == "atom_regular_ring":
- if plan.atom_id is None:
- committer.abort_recorded_change(snapshot)
- return False
- merge = atom_merge_seed(canvas, plan.atom_id)
- elif plan.bond_id is not None:
- merge = bond_merge_seed(canvas, plan.bond_id)
- else:
- committer.abort_recorded_change(snapshot)
- return False
- if not merge:
- committer.abort_recorded_change(snapshot)
- return False
- atom_ids: list[int] = []
- for point in points:
- atom_ids.append(
- canvas.services.structure_build_service.add_atom_with_merge(
- point, "C", merge
- )
- )
- bonds_start = len(canvas.model.bonds)
- for index in range(len(atom_ids)):
- a_id = atom_ids[index]
- b_id = atom_ids[(index + 1) % len(atom_ids)]
- if insert_bond_exists_for(canvas, a_id, b_id, bond_exists=bond_exists):
- continue
- add_bond_for(canvas, a_id, b_id)
- for new_bond_id in canvas.model.bond_ids_from(bonds_start):
- canvas.bond_renderer.add_bond_graphics(new_bond_id)
- committer.add_ring_fill(points, atom_ids)
- else:
- add_insert_ring_from_points_for(canvas, points)
-
+ if not commit_template_ring(
+ canvas,
+ plan,
+ points,
+ add_atom_with_merge=builder.add_atom_with_merge,
+ add_ring_from_points=lambda ring: add_insert_ring_from_points_for(
+ canvas, ring
+ ),
+ add_ring_fill=committer.add_ring_fill,
+ bond_exists=lambda a_id, b_id: insert_bond_exists_for(
+ canvas, a_id, b_id, bond_exists=bond_exists
+ ),
+ ):
+ committer.abort_recorded_change(snapshot)
+ return False
committer.record_additions(snapshot)
except Exception as error:
committer.abort_recorded_change(snapshot, original_error=error)
@@ -130,22 +107,4 @@ def _apply_benzene_template_commit(
return True
-def bond_merge_seed(canvas: CanvasView, bond_id: int) -> list[tuple[int, float, float]]:
- bond = canvas.model.bond_for_id(bond_id)
- if bond is None:
- return []
- atom_a = canvas.model.atom_for_id(bond.a)
- atom_b = canvas.model.atom_for_id(bond.b)
- if atom_a is None or atom_b is None:
- return []
- return [(bond.a, atom_a.x, atom_a.y), (bond.b, atom_b.x, atom_b.y)]
-
-
-def atom_merge_seed(canvas: CanvasView, atom_id: int) -> list[tuple[int, float, float]]:
- atom = canvas.model.atom_for_id(atom_id)
- if atom is None:
- return []
- return [(atom_id, atom.x, atom.y)]
-
-
-__all__ = ["apply_template_commit_resolution", "atom_merge_seed", "bond_merge_seed"]
+__all__ = ["apply_template_commit_resolution"]
diff --git a/app/chemvas/ui/insert/preview_scene_renderer.py b/app/chemvas/ui/insert/preview_scene_renderer.py
index 04323771..b9b026af 100644
--- a/app/chemvas/ui/insert/preview_scene_renderer.py
+++ b/app/chemvas/ui/insert/preview_scene_renderer.py
@@ -14,6 +14,7 @@
QWidget,
)
+from chemvas.features.hover import PREVIEW_COLOR_RGBA, PREVIEW_OPACITY
from chemvas.ui.canvas.graphics_items import NoSelectLineItem
if TYPE_CHECKING:
@@ -25,7 +26,6 @@
# Every preview ghost paints at half strength so the existing drawing stays
# readable underneath it.
-PREVIEW_OPACITY = 0.5
# Above atom labels (z 3) and ring fills so the ghost is never hidden by the
# drawing it is about to join.
@@ -227,4 +227,4 @@ def preview_pen(base_pen: QPen, color: QColor) -> QPen:
def preview_color() -> QColor:
- return QColor(120, 120, 120, 140)
+ return QColor(*PREVIEW_COLOR_RGBA)
diff --git a/app/chemvas/ui/insert/ring_occupancy.py b/app/chemvas/ui/insert/ring_occupancy.py
index 2e2a1aba..ed2b26f6 100644
--- a/app/chemvas/ui/insert/ring_occupancy.py
+++ b/app/chemvas/ui/insert/ring_occupancy.py
@@ -2,13 +2,12 @@
from typing import TYPE_CHECKING, Any, cast
-from PyQt6.QtCore import QPointF, Qt
-
from chemvas.features.graph import find_rings
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
+ from PyQt6.QtCore import QPointF
from PyQt6.QtWidgets import QGraphicsPolygonItem
from chemvas.domain.document import Atom, Bond
@@ -80,6 +79,8 @@ def point_inside_any_ring(
*,
ring_items: Sequence[QGraphicsPolygonItem],
) -> bool:
+ from PyQt6.QtCore import Qt
+
for ring_item in ring_items:
try:
polygon = ring_item.polygon()
diff --git a/app/chemvas/ui/insert/template_commit_logic.py b/app/chemvas/ui/insert/template_commit_logic.py
new file mode 100644
index 00000000..125087e0
--- /dev/null
+++ b/app/chemvas/ui/insert/template_commit_logic.py
@@ -0,0 +1,79 @@
+"""Committing a resolved ring template, independent of Qt graphics."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from chemvas.ui.molecule.structure_mutation_access import add_bond_for
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+ from chemvas.features.insertion import TemplateInsertPlan
+
+# Rings grown from an existing atom or bond merge into it; others stand alone.
+ATTACHED_TEMPLATE_GENERATORS = frozenset(
+ ("atom_regular_ring", "bond_regular_ring", "bond_template_shape")
+)
+
+
+def bond_merge_seed(canvas, bond_id: int) -> list[tuple[int, float, float]]:
+ bond = canvas.model.bond_for_id(bond_id)
+ if bond is None:
+ return []
+ atom_a = canvas.model.atom_for_id(bond.a)
+ atom_b = canvas.model.atom_for_id(bond.b)
+ if atom_a is None or atom_b is None:
+ return []
+ return [(bond.a, atom_a.x, atom_a.y), (bond.b, atom_b.x, atom_b.y)]
+
+
+def atom_merge_seed(canvas, atom_id: int) -> list[tuple[int, float, float]]:
+ atom = canvas.model.atom_for_id(atom_id)
+ if atom is None:
+ return []
+ return [(atom_id, atom.x, atom.y)]
+
+
+def commit_template_ring[Point](
+ canvas,
+ plan: TemplateInsertPlan,
+ points: Sequence[Point],
+ *,
+ add_atom_with_merge: Callable[[Point, str, list], int],
+ add_ring_from_points: Callable[[Sequence[Point]], object],
+ add_ring_fill: Callable[[Sequence[Point], list[int]], object],
+ bond_exists: Callable[[int, int], bool],
+) -> bool:
+ """Add a non-benzene template's atoms, bonds and ring fill to the model."""
+ if plan.generator not in ATTACHED_TEMPLATE_GENERATORS:
+ add_ring_from_points(points)
+ return True
+ if plan.generator == "atom_regular_ring":
+ if plan.atom_id is None:
+ return False
+ merge = atom_merge_seed(canvas, plan.atom_id)
+ elif plan.bond_id is not None:
+ merge = bond_merge_seed(canvas, plan.bond_id)
+ else:
+ return False
+ if not merge:
+ return False
+ atom_ids = [add_atom_with_merge(point, "C", merge) for point in points]
+ bonds_start = len(canvas.model.bonds)
+ for index, a_id in enumerate(atom_ids):
+ b_id = atom_ids[(index + 1) % len(atom_ids)]
+ if not bond_exists(a_id, b_id):
+ add_bond_for(canvas, a_id, b_id)
+ for new_bond_id in canvas.model.bond_ids_from(bonds_start):
+ canvas.bond_renderer.add_bond_graphics(new_bond_id)
+ add_ring_fill(points, atom_ids)
+ return True
+
+
+__all__ = [
+ "ATTACHED_TEMPLATE_GENERATORS",
+ "atom_merge_seed",
+ "bond_merge_seed",
+ "commit_template_ring",
+]
diff --git a/app/chemvas/ui/insert/template_geometry_resolver_service.py b/app/chemvas/ui/insert/template_geometry_resolver_service.py
index ba2ae399..aa197419 100644
--- a/app/chemvas/ui/insert/template_geometry_resolver_service.py
+++ b/app/chemvas/ui/insert/template_geometry_resolver_service.py
@@ -2,8 +2,7 @@
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
+from chemvas.features.groups import connection_allowed, growth_anchors
from chemvas.features.insertion import (
TemplateInsertPlan,
TemplateInsertRequest,
@@ -25,10 +24,18 @@
if TYPE_CHECKING:
from collections.abc import Sequence
+ from PyQt6.QtCore import QPointF
+
class TemplateGeometryResolverService:
- def __init__(self, canvas) -> None:
+ def __init__(self, canvas, *, point_factory=None) -> None:
self.canvas = canvas
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ # The browser adapter resolves templates with its own point type.
+ self.point_factory = point_factory
def point_resolvers(self) -> TemplatePointResolvers:
return TemplatePointResolvers(
@@ -48,21 +55,18 @@ def resolve_insert(
plan: TemplateInsertPlan,
) -> TemplateInsertResolution | None:
if plan.generator == "benzene":
- from chemvas.ui.scene.scene_group_operations import (
- group_connection_allowed_for,
+ anchors = growth_anchors(
+ self.canvas.model.bonds, atom_id=plan.atom_id, bond_id=plan.bond_id
)
-
- anchors = {plan.atom_id} if plan.atom_id is not None else set()
- bond = self.canvas.model.bond_for_id(plan.bond_id)
- if bond is not None:
- anchors.update((bond.a, bond.b))
- if anchors and not group_connection_allowed_for(
- self.canvas, anchors, notify=False
+ if not connection_allowed(
+ self.canvas.runtime_state.group_state.groups,
+ self.canvas.model.bonds,
+ anchors,
):
return None
builder = self.canvas.services.structure_build_service
placement = builder.benzene_builder.plan_placement(
- QPointF(*request.cursor_pos),
+ self.point_factory(*request.cursor_pos),
plan.atom_id,
plan.bond_id,
benzene_ring_points=builder.benzene_ring_points,
@@ -82,7 +86,13 @@ def resolve_ring_points(
n: int,
radius: float | None,
) -> list[tuple[float, float]]:
- points = ring_points_for(self.canvas, QPointF(*center), n, radius=radius)
+ points = ring_points_for(
+ self.canvas,
+ self.point_factory(*center),
+ n,
+ radius=radius,
+ point_factory=self.point_factory,
+ )
return [(point.x(), point.y()) for point in points]
def resolve_regular_ring_points_for_bond(
@@ -92,7 +102,11 @@ def resolve_regular_ring_points_for_bond(
center: tuple[float, float],
) -> list[tuple[float, float]] | None:
result = regular_ring_points_for_bond_for(
- self.canvas, n, bond_id, QPointF(*center)
+ self.canvas,
+ n,
+ bond_id,
+ self.point_factory(*center),
+ point_factory=self.point_factory,
)
if result is None:
return None
@@ -103,7 +117,9 @@ def resolve_regular_ring_points_for_atom(
n: int,
atom_id: int,
) -> list[tuple[float, float]] | None:
- result = regular_ring_points_for_atom_for(self.canvas, n, atom_id)
+ result = regular_ring_points_for_atom_for(
+ self.canvas, n, atom_id, point_factory=self.point_factory
+ )
if result is None:
return None
return [(point.x(), point.y()) for point in result[0]]
@@ -111,19 +127,25 @@ def resolve_regular_ring_points_for_atom(
def resolve_chair_points(
self, center: tuple[float, float]
) -> list[tuple[float, float]]:
- points = cyclohexane_chair_points_for(self.canvas, QPointF(*center))
+ points = cyclohexane_chair_points_for(
+ self.canvas, self.point_factory(*center), point_factory=self.point_factory
+ )
return [(point.x(), point.y()) for point in points]
def resolve_chair_flipped_points(
self, center: tuple[float, float]
) -> list[tuple[float, float]]:
- points = cyclohexane_chair_flipped_points_for(self.canvas, QPointF(*center))
+ points = cyclohexane_chair_flipped_points_for(
+ self.canvas, self.point_factory(*center), point_factory=self.point_factory
+ )
return [(point.x(), point.y()) for point in points]
def resolve_boat_points(
self, center: tuple[float, float]
) -> list[tuple[float, float]]:
- points = cyclohexane_boat_points_for(self.canvas, QPointF(*center))
+ points = cyclohexane_boat_points_for(
+ self.canvas, self.point_factory(*center), point_factory=self.point_factory
+ )
return [(point.x(), point.y()) for point in points]
def resolve_template_points_for_bond(
@@ -134,21 +156,22 @@ def resolve_template_points_for_bond(
) -> list[tuple[float, float]] | None:
result = template_points_for_bond_for(
self.canvas,
- [QPointF(x, y) for x, y in points_local],
+ [self.point_factory(x, y) for x, y in points_local],
bond_id,
- QPointF(*center),
+ self.point_factory(*center),
+ point_factory=self.point_factory,
)
if result is None:
return None
return [(point.x(), point.y()) for point in result[0]]
- @staticmethod
def points_from_pairs(
+ self,
points: list[tuple[float, float]] | None,
) -> list[QPointF] | None:
if points is None:
return None
- return [QPointF(x, y) for x, y in points]
+ return [self.point_factory(x, y) for x, y in points]
__all__ = ["TemplateGeometryResolverService"]
diff --git a/app/chemvas/ui/molecule/atom_coords_access.py b/app/chemvas/ui/molecule/atom_coords_access.py
index f8217375..cef1c8d6 100644
--- a/app/chemvas/ui/molecule/atom_coords_access.py
+++ b/app/chemvas/ui/molecule/atom_coords_access.py
@@ -1,11 +1,12 @@
from __future__ import annotations
-import math
from dataclasses import dataclass, field
from typing import Any
-from chemvas.ui.molecule.bond_graphics_access import project_point_3d_for
-from chemvas.ui.scene.scene_geometry import current_atom_coords_in_scene
+from chemvas.domain.document.perspective import (
+ current_atom_coords_3d,
+ stored_coords_match_projection,
+)
AtomCoords3D = tuple[float, float, float]
@@ -41,19 +42,26 @@ def stored_atom_coords_3d_matches_projection_for(
atom = canvas.model.atom_for_id(atom_id)
if atom is None:
return False
- proj_x, proj_y = project_point_3d_for(canvas, coords)
- tolerance = max(1.0, canvas.renderer.style.bond_length_px * 0.15)
- return math.hypot(proj_x - atom.x, proj_y - atom.y) <= tolerance
+ rotation = canvas.runtime_state.rotation_state
+ return stored_coords_match_projection(
+ coords,
+ (atom.x, atom.y),
+ bond_length_px=canvas.renderer.style.bond_length_px,
+ center_3d=rotation.projection_center_3d,
+ anchor_2d=rotation.projection_anchor_2d,
+ )
def current_atom_coords_3d_for(
canvas, atom_id: int
) -> tuple[float, float, float] | None:
+ atom = canvas.model.atoms.get(atom_id)
+ if atom is None:
+ return None
rotation = canvas.runtime_state.rotation_state
- return current_atom_coords_in_scene(
- atom_id,
- model=canvas.model,
- stored_coords=canvas.runtime_state.atom_coords_3d_state.atom_coords_3d,
+ return current_atom_coords_3d(
+ (atom.x, atom.y),
+ canvas.runtime_state.atom_coords_3d_state.atom_coords_3d.get(atom_id),
bond_length_px=canvas.renderer.style.bond_length_px,
center_3d=rotation.projection_center_3d,
anchor_2d=rotation.projection_anchor_2d,
diff --git a/app/chemvas/ui/molecule/atom_label_access.py b/app/chemvas/ui/molecule/atom_label_access.py
index c935ef36..35b8bad5 100644
--- a/app/chemvas/ui/molecule/atom_label_access.py
+++ b/app/chemvas/ui/molecule/atom_label_access.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from chemvas.domain.document import atom_shows_itself
-from chemvas.ui.molecule.atom_label_renderer import uses_compact_label_hit_shape
+from chemvas.features.annotations import uses_compact_label_hit_shape
def atom_has_visible_label_for(canvas, atom_id: int) -> bool:
diff --git a/app/chemvas/ui/molecule/atom_label_merge_service.py b/app/chemvas/ui/molecule/atom_label_merge_service.py
index 7742c53d..43a52309 100644
--- a/app/chemvas/ui/molecule/atom_label_merge_service.py
+++ b/app/chemvas/ui/molecule/atom_label_merge_service.py
@@ -3,24 +3,20 @@
from typing import TYPE_CHECKING, cast
from chemvas.features.rendering import STANDARD_BOND_STYLES
-from chemvas.ui.annotations.state import atom_state_dict_for, bond_state_dict
from chemvas.ui.canvas.canvas_atom_graphics_state import (
pop_atom_dot_for,
pop_atom_item_for,
)
from chemvas.ui.canvas.canvas_bond_graphics_state import pop_bond_items_for
from chemvas.ui.molecule.atom_coords_access import pop_atom_coords_3d_for
-from chemvas.ui.scene.scene_item_access import (
- remove_item_from_canvas_scene,
- remove_items_from_canvas_scene,
-)
if TYPE_CHECKING:
from chemvas.domain.document import Bond
class AtomLabelMergeService:
- def __init__(self, canvas, *, graph_service) -> None:
+ def __init__(self, canvas, *, graph_service, capture_history: bool = True) -> None:
+ self.capture_history = capture_history
self.canvas = canvas
self.graph_service = graph_service
@@ -31,10 +27,15 @@ def merge_overlapping_atoms(self, atom_id: int) -> tuple[list[int], dict]:
merge_ids = self._overlapping_atom_ids(atom_id)
if not merge_ids:
return [], {}
- merge_info = {
- "atom_states": {
+ atom_states = {}
+ if self.capture_history:
+ from chemvas.ui.annotations.state import atom_state_dict_for
+
+ atom_states = {
mid: atom_state_dict_for(self.canvas, mid) for mid in merge_ids
- },
+ }
+ merge_info = {
+ "atom_states": atom_states,
"bond_before_states": {},
"deleted_bond_ids": [],
}
@@ -46,7 +47,8 @@ def merge_overlapping_atoms(self, atom_id: int) -> tuple[list[int], dict]:
}
if atom_coords_3d:
merge_info["atom_coords_3d"] = atom_coords_3d
- self._capture_bond_states_touching_merged_atoms(merge_ids, merge_info)
+ if self.capture_history:
+ self._capture_bond_states_touching_merged_atoms(merge_ids, merge_info)
self._remove_merged_atom_items(merge_ids)
self._retarget_bonds(merge_ids, atom_id)
self._delete_self_loop_bonds(merge_info)
@@ -76,6 +78,8 @@ def _overlapping_atom_ids(self, atom_id: int) -> list[int]:
def _capture_bond_states_touching_merged_atoms(
self, merge_ids: list[int], merge_info: dict
) -> None:
+ from chemvas.ui.annotations.state import bond_state_dict
+
for bond_id, bond in enumerate(self.canvas.model.bonds):
if bond is None:
continue
@@ -86,9 +90,17 @@ def _remove_merged_atom_items(self, merge_ids: list[int]) -> None:
for other_id in merge_ids:
label = pop_atom_item_for(self.canvas, other_id)
if label is not None:
+ from chemvas.ui.scene.scene_item_access import (
+ remove_item_from_canvas_scene,
+ )
+
remove_item_from_canvas_scene(self.canvas, label)
dot = pop_atom_dot_for(self.canvas, other_id)
if dot is not None:
+ from chemvas.ui.scene.scene_item_access import (
+ remove_item_from_canvas_scene,
+ )
+
remove_item_from_canvas_scene(self.canvas, dot)
def _retarget_bonds(self, merge_ids: list[int], atom_id: int) -> None:
@@ -129,12 +141,19 @@ def _delete_duplicate_bonds(self, merge_info: dict) -> None:
def _delete_bond(self, bond_id: int, merge_info: dict) -> None:
bond = cast("Bond", self.canvas.model.bond_for_id(bond_id))
- if bond_id not in merge_info["bond_before_states"]:
+ if self.capture_history and bond_id not in merge_info["bond_before_states"]:
+ from chemvas.ui.annotations.state import bond_state_dict
+
merge_info["bond_before_states"][bond_id] = bond_state_dict(bond)
- remove_items_from_canvas_scene(
- self.canvas,
- self.canvas.runtime_state.bond_graphics_state.bond_items.get(bond_id, []),
+ items = self.canvas.runtime_state.bond_graphics_state.bond_items.get(
+ bond_id, []
)
+ if items:
+ from chemvas.ui.scene.scene_item_access import (
+ remove_items_from_canvas_scene,
+ )
+
+ remove_items_from_canvas_scene(self.canvas, items)
pop_bond_items_for(self.canvas, bond_id)
self.canvas.model.clear_bond(bond_id)
merge_info["deleted_bond_ids"].append(bond_id)
diff --git a/app/chemvas/ui/molecule/atom_label_renderer.py b/app/chemvas/ui/molecule/atom_label_renderer.py
index 3f896d02..221aca3d 100644
--- a/app/chemvas/ui/molecule/atom_label_renderer.py
+++ b/app/chemvas/ui/molecule/atom_label_renderer.py
@@ -1,18 +1,14 @@
from __future__ import annotations
-import math
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QPen
-from chemvas.domain.atom_aliases import ATOM_ALIAS_DEFINITIONS
from chemvas.features.annotations import (
- attachment_anchor_token,
- attachment_group_at_end,
- hydride_display_text,
- reversed_display_text,
- split_hydride_label,
+ ATOM_LABEL_HIT_PADDING_RATIO,
+ atom_label_presentation,
+ uses_compact_label_hit_shape,
)
from chemvas.ui.canvas.graphics_items import AtomDotItem, AtomLabelItem
from chemvas.ui.canvas.pick_radius_access import atom_pick_radius
@@ -22,65 +18,9 @@
if TYPE_CHECKING:
from collections.abc import Iterable
- from chemvas.domain.document import MoleculeModel
from chemvas.ui.scene.scene_render_context import SceneRenderContext
-def uses_compact_label_hit_shape(text: str) -> bool:
- text = text.strip()
- if len(text) == 1:
- return text.isalpha() and text.upper() == text
- if len(text) == 2:
- return (
- text[0].isalpha()
- and text[0].upper() == text[0]
- and text[1].isalpha()
- and text[1].lower() == text[1]
- )
- return False
-
-
-def connected_atom_unit_vectors(
- model: MoleculeModel, atom_id: int
-) -> list[tuple[float, float]]:
- atom = model.atoms.get(atom_id)
- if atom is None:
- return []
- vectors = []
- for bond in model.bonds:
- if bond is None or (bond.a != atom_id and bond.b != atom_id):
- continue
- other = model.atoms.get(bond.b if bond.a == atom_id else bond.a)
- if other is None:
- continue
- dx, dy = other.x - atom.x, other.y - atom.y
- length = math.hypot(dx, dy)
- if length > 1e-9:
- vectors.append((dx / length, dy / length))
- return vectors
-
-
-def _open_direction(vectors: list[tuple[float, float]]) -> tuple[float, float]:
- """Direction toward the open side of an atom, for hydride label placement.
-
- Opposite the vector sum of the bonds (the same negative-sum rule the bond
- sprout uses at a two-bond vertex). When the bonds cancel out (a straight
- C-NH-C), fall back to the perpendicular of the first bond, flipped so its
- dominant component is positive -- a flat chain stacks its H underneath
- regardless of bond insertion order, like ChemDraw.
- """
- sum_x = sum(dx for dx, _ in vectors)
- sum_y = sum(dy for _, dy in vectors)
- if math.hypot(sum_x, sum_y) > 1e-6:
- return -sum_x, -sum_y
- if vectors:
- perp_x, perp_y = vectors[0][1], -vectors[0][0]
- if (perp_y if abs(perp_y) >= abs(perp_x) else perp_x) < 0.0:
- perp_x, perp_y = -perp_x, -perp_y
- return perp_x, perp_y
- return 1.0, 0.0
-
-
class AtomLabelRenderer:
"""Molecular labels and implicit carbon graphics without editor services."""
@@ -112,7 +52,9 @@ def draw_atom(self, atom_id: int, *, visible: bool | None = None) -> None:
if atom.element.upper() == "C":
self.ensure_carbon_dot(atom_id)
return
- label_hit_padding = self.context.renderer.style.bond_length_px * 0.12
+ label_hit_padding = (
+ self.context.renderer.style.bond_length_px * ATOM_LABEL_HIT_PADDING_RATIO
+ )
label_hit_radius = (
self.atom_pick_radius()
if uses_compact_label_hit_shape(atom.element)
@@ -230,7 +172,7 @@ def relayout_atom_label(self, atom_id: int) -> bool:
return False
before = self._label_presentation_signature(item)
display_text, anchor_element, anchor_at_end, hydrogens_below = (
- self._hydride_layout(atom_id, atom.element)
+ atom_label_presentation(self.context.model, atom_id, atom.element)
)
item.setPlainText(display_text)
if hydrogens_below is None:
@@ -314,124 +256,8 @@ def _incident_bond_ids(self, atom_ids: set[int]) -> set[int]:
if bond is not None and (bond.a in atom_ids or bond.b in atom_ids)
}
- def _hydride_layout(
- self, atom_id: int, text: str
- ) -> tuple[str, str | None, bool, bool | None]:
- # Element+hydrogen labels ("NH", "OH", "NH2", "CH3") anchor on the element
- # with the hydrogens pointing away from the bonds; other multi-part
- # labels ("CF3", "Ph3P") anchor on the token facing the bonds. Returns
- # (display_text, anchor_element, anchor_at_end, hydrogens_below);
- # hydrogens_below is None for the horizontal layouts and picks the
- # stacked line side otherwise.
- split = split_hydride_label(text)
- if split is None:
- return self._token_anchor_layout(atom_id, text)
- element, h_count = split
- if h_count <= 0:
- return self._token_anchor_layout(atom_id, text)
- atom = self.context.model.atoms.get(atom_id)
- if atom is not None and atom.explicit_label:
- return text, None, False, None
- # Put the hydrogens on the open side of the atom, quantised to the
- # dominant axis. A vertical open side (both bonds of a vertex rising,
- # or a flat C-NH-C chain) stacks the H on its own line under/over the
- # element, ChemDraw-style, instead of forcing a horizontal layout.
- vectors = connected_atom_unit_vectors(self.context.model, atom_id)
- open_x, open_y = _open_direction(vectors)
- if abs(open_y) > abs(open_x):
- hydrogens_below = open_y > 0.0
- v_direction = 1.0 if hydrogens_below else -1.0
- # Mirror of the horizontal guard below: keep full-box clearance when
- # a bond runs almost straight along the hydrogen direction.
- if any(dy * v_direction > 0.95 for _, dy in vectors):
- return text, None, False, None
- return text, element, False, hydrogens_below
- face_left = open_x < 0.0
- # Only when a bond runs almost straight along that horizontal direction
- # (within ~18 degrees) would the hydrogens sit on top of it; keep the
- # label centred with full-box clearance there. Ordinary diagonal
- # ring/chain neighbours -- a regular hexagon N-H has bonds near
- # (+-0.866, 0.5) -- stay anchored.
- h_direction = -1.0 if face_left else 1.0
- if any(dx * h_direction > 0.95 for dx, _ in vectors):
- return text, None, False, None
- display = hydride_display_text(element, h_count, face_left=face_left)
- return display, element, face_left, None
-
- def _token_anchor_layout(
- self, atom_id: int, text: str
- ) -> tuple[str, str | None, bool, bool | None]:
- # Multi-part labels ("CF3", "Ph3P", "OMe") anchor on their attachment
- # group, so that glyph sits on the atom and bonds trim to it instead of
- # clearing the whole label box. When the attachment group is typed on
- # the side away from the bonds ("CF3" or "OTs" approached from the
- # right), the display text reverses group-wise ("F3C", "TsO"),
- # ChemDraw-style, without touching the stored label. When the
- # attachment end is unknowable, the token facing the bonds anchors
- # as typed. Known reversible labels retain their attachment anchor
- # even on a vertical open side; an exactly vertical bond preserves the
- # typed order. Unknown or unreversible vertical labels, and a bond
- # running along the label body, keep the centred full-clearance layout.
- vectors = connected_atom_unit_vectors(self.context.model, atom_id)
- if not vectors:
- # With no attachment direction there is no chemical reason to
- # reverse the user's text or select one end as the bond anchor.
- return text, None, False, None
- open_x, open_y = _open_direction(vectors)
- # Compact alkyl names have no explicit attachment-carbon glyph and
- # cannot reverse as element groups. Keep their conventional spelling,
- # but align the facing terminal glyph rather than the whole word.
- if text in {"tBu", "t-Bu", "i-Pr"} and abs(open_x) >= 1e-6:
- at_end = open_x < 0.0
- body_direction = -1.0 if at_end else 1.0
- if not any(dx * body_direction > 0.95 for dx, _ in vectors):
- return text, text[-1] if at_end else text[0], at_end, None
- attachment_at_end = self._attachment_at_end(text)
- if abs(open_y) > abs(open_x) and (
- attachment_at_end is None or reversed_display_text(text) is None
- ):
- return text, None, False, None
- anchor_at_end = (
- attachment_at_end
- if attachment_at_end is not None and abs(open_x) < 1e-6
- else open_x < 0.0
- )
- display = text
- if attachment_at_end is not None and attachment_at_end != anchor_at_end:
- flipped = reversed_display_text(text)
- if flipped is not None:
- display = flipped
- token = attachment_anchor_token(display, at_end=anchor_at_end)
- if token is None:
- return text, None, False, None
- # Same guard as the horizontal hydride layout: a bond running almost
- # straight along the label body would sit under the text.
- body_direction = -1.0 if anchor_at_end else 1.0
- if any(dx * body_direction > 0.95 for dx, _ in vectors):
- return text, None, False, None
- return display, token, anchor_at_end, None
-
- @staticmethod
- def _attachment_at_end(text: str) -> bool | None:
- # The alias table is the chemistry authority: its keys are typed
- # attachment-first, so a label matching a key attaches at the start and
- # a label whose group-reversal matches a key ("Ph3P" -> "PPh3", "MeO"
- # -> "OMe") attaches at the end. Unknown labels fall back to the
- # syntactic signals; None means the end is genuinely ambiguous.
- # Generic substituent R is not an expandable molecular alias. Its
- # display still has an unambiguous oxygen attachment in OR / RO.
- if text in {"OR", "RO"}:
- return text == "RO"
- if text in ATOM_ALIAS_DEFINITIONS:
- return False
- flipped = reversed_display_text(text)
- if flipped is not None and flipped in ATOM_ALIAS_DEFINITIONS:
- return True
- return attachment_group_at_end(text)
-
__all__ = [
"AtomLabelRenderer",
- "connected_atom_unit_vectors",
"uses_compact_label_hit_shape",
]
diff --git a/app/chemvas/ui/molecule/atom_label_service.py b/app/chemvas/ui/molecule/atom_label_service.py
index f5f95877..0d8b6896 100644
--- a/app/chemvas/ui/molecule/atom_label_service.py
+++ b/app/chemvas/ui/molecule/atom_label_service.py
@@ -1,14 +1,10 @@
from __future__ import annotations
+from functools import cached_property
from typing import TYPE_CHECKING
-from PyQt6.QtWidgets import QInputDialog
-
from chemvas.core.history import history_transaction_scope
-from chemvas.ui.molecule.atom_label_history_recorder import AtomLabelHistoryRecorder
from chemvas.ui.molecule.atom_label_merge_service import AtomLabelMergeService
-from chemvas.ui.scene.scene_group_operations import group_connection_allowed_for
-from chemvas.ui.transactions.document import document_transaction
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
@@ -25,6 +21,8 @@ def __init__(
graph_service,
history_service=None,
hover_refresh: Callable[[], None] | None = None,
+ merge_service=None,
+ connection_allowed=None,
) -> None:
self.canvas = canvas
self.history = history_service
@@ -32,15 +30,27 @@ def __init__(
self.graph_service = graph_service
self._hover_refresh = hover_refresh or (lambda: None)
self.drawing = canvas.render_context.atom_labels
- self._history_recorder = AtomLabelHistoryRecorder(
- canvas,
- history_service=history_service,
+ self.merge_service = merge_service or AtomLabelMergeService(
+ canvas, graph_service=graph_service
)
- self.merge_service = AtomLabelMergeService(
- canvas,
- graph_service=graph_service,
+ if connection_allowed is None:
+ from chemvas.ui.scene.scene_group_operations import (
+ group_connection_allowed_for,
+ )
+
+ def connection_allowed(ids):
+ return group_connection_allowed_for(canvas, ids)
+
+ self.connection_allowed = connection_allowed
+
+ @cached_property
+ def _history_recorder(self):
+ from chemvas.ui.molecule.atom_label_history_recorder import (
+ AtomLabelHistoryRecorder,
)
+ return AtomLabelHistoryRecorder(self.canvas, history_service=self.history)
+
def atom_item_for_id(self, atom_id: int):
return self.drawing.atom_item_for_id(atom_id)
@@ -136,6 +146,8 @@ def add_or_update_atom_label(
literal_label=literal_label,
)
return
+ from chemvas.ui.transactions.document import document_transaction
+
with (
document_transaction(self.canvas, history_service=self.history),
history_transaction_scope(self.history.operations),
@@ -149,17 +161,24 @@ def add_or_update_atom_label(
literal_label=literal_label,
)
- def add_labelled_atom(self, text: str, x: float, y: float) -> int:
- """Add an atom drawn with ``text`` as one recorded document edit."""
+ def add_labelled_atom(
+ self, text: str, x: float, y: float, *, record: bool = True
+ ) -> int:
+ """Add a labelled atom; an unrecorded caller owns the candidate transaction."""
+ if not record:
+ atom_id = self.canvas.services.canvas_atom_mutation_service.add_atom(
+ text, x, y
+ )
+ self.add_or_update_atom_label(atom_id, text, record=False, show_carbon=True)
+ return atom_id
+ from chemvas.ui.transactions.document import document_transaction
+
with (
document_transaction(self.canvas, history_service=self.history),
history_transaction_scope(self.history.operations),
):
before_next_atom_id = int(self.canvas.model.next_atom_id)
- atom_id = self.canvas.services.canvas_atom_mutation_service.add_atom(
- text, x, y
- )
- self.add_or_update_atom_label(atom_id, text, record=False, show_carbon=True)
+ atom_id = self.add_labelled_atom(text, x, y, record=False)
self._history_recorder.record_added_atom(
atom_id, before_next_atom_id=before_next_atom_id
)
@@ -180,9 +199,7 @@ def _apply_atom_label(
atom = self.canvas.model.atoms[atom_id]
if allow_merge and text and (text.upper() != "C" or show_carbon):
merge_ids = self.merge_service._overlapping_atom_ids(atom_id)
- if merge_ids and not group_connection_allowed_for(
- self.canvas, {atom_id, *merge_ids}
- ):
+ if merge_ids and not self.connection_allowed({atom_id, *merge_ids}):
return
before_element = atom.element
before_explicit_label = atom.explicit_label
@@ -258,28 +275,38 @@ def _apply_atom_label(
merge_info,
)
- def prompt_atom_label(self, atom_id: int) -> None:
+ def atom_label_prompt_initial(self, atom_id: int) -> str | None:
atom = self.canvas.model.atom_for_id(atom_id)
if atom is None:
- return
- initial = (
+ return None
+ return (
""
if atom.element.upper() == "C" and not atom.explicit_label
else atom.element
)
+
+ def apply_atom_label_prompt(
+ self, atom_id: int, text: str, *, record: bool = True
+ ) -> None:
+ text = text.strip()
+ self.add_or_update_atom_label(
+ atom_id, text or "C", record=record, show_carbon=bool(text)
+ )
+
+ def prompt_atom_label(self, atom_id: int) -> None:
+ from PyQt6.QtWidgets import QInputDialog
+
+ initial = self.atom_label_prompt_initial(atom_id)
+ if initial is None:
+ return
text, ok = QInputDialog.getText(
self.canvas,
"Atom Label",
"Enter atom symbol:",
text=initial,
)
- if not ok:
- return
- text = text.strip()
- if not text:
- self.add_or_update_atom_label(atom_id, "C", show_carbon=False)
- return
- self.add_or_update_atom_label(atom_id, text, show_carbon=True)
+ if ok:
+ self.apply_atom_label_prompt(atom_id, text)
__all__ = ["AtomLabelService"]
diff --git a/app/chemvas/ui/molecule/bond_graphics_access.py b/app/chemvas/ui/molecule/bond_graphics_access.py
index cf5215c1..f7adccd8 100644
--- a/app/chemvas/ui/molecule/bond_graphics_access.py
+++ b/app/chemvas/ui/molecule/bond_graphics_access.py
@@ -1,7 +1,7 @@
from __future__ import annotations
+from chemvas.domain.document.perspective import project_point_3d
from chemvas.ui.molecule.bond_graphics_build_service import apply_color_to_bond_item
-from chemvas.ui.scene.scene_geometry import project_point_in_scene
def project_point_3d_for(
@@ -15,13 +15,11 @@ def project_point_3d_for(
center_3d = rotation.projection_center_3d
if center_3d is None:
return point[0], point[1]
- if anchor_2d is None:
- anchor_2d = rotation.projection_anchor_2d or (center_3d[0], center_3d[1])
- return project_point_in_scene(
+ return project_point_3d(
point,
bond_length_px=canvas.renderer.style.bond_length_px,
center_3d=center_3d,
- anchor_2d=anchor_2d,
+ anchor_2d=rotation.projection_anchor_2d if anchor_2d is None else anchor_2d,
)
diff --git a/app/chemvas/ui/molecule/bond_graphics_draw_service.py b/app/chemvas/ui/molecule/bond_graphics_draw_service.py
index a0f0dfb9..5375f159 100644
--- a/app/chemvas/ui/molecule/bond_graphics_draw_service.py
+++ b/app/chemvas/ui/molecule/bond_graphics_draw_service.py
@@ -3,9 +3,6 @@
import math
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-from PyQt6.QtGui import QPolygonF
-
from chemvas.features.rendering import (
BOLD_BOND_STYLES,
bold_double_strip_geometry,
@@ -21,11 +18,30 @@
if TYPE_CHECKING:
+ from PyQt6.QtCore import QPointF
+
from chemvas.ui.scene.scene_render_context import SceneRenderContext
class BondGraphicsDrawService:
- def __init__(self, context: SceneRenderContext, *, renderer) -> None:
+ def __init__(
+ self,
+ context: SceneRenderContext,
+ *,
+ renderer,
+ point_factory=None,
+ polygon_factory=None,
+ ) -> None:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ if polygon_factory is None:
+ from PyQt6.QtGui import QPolygonF
+
+ polygon_factory = QPolygonF
+ self.point_factory = point_factory
+ self.polygon_factory = polygon_factory
self.context = context
self.renderer = renderer
@@ -63,7 +79,7 @@ def bold_strip_polygon(
# edge (a true mitre) so the run reads as one continuous outline.
if a_id is None and b_id is None:
corners = strip_corners(x1, y1, x2, y2, nx, ny, base_width, bold_width)
- return QPolygonF([QPointF(x, y) for x, y in corners])
+ return self.polygon_factory([self.point_factory(x, y) for x, y in corners])
outer_off = -base_width / 2.0
inner_off = bold_width - base_width / 2.0
dx = x2 - x1
@@ -82,7 +98,7 @@ def bold_strip_polygon(
inner_start = self._miter_corner(
x1, y1, nx, ny, inner_off, dx, dy, a_id, start_nb, bold_width
)
- return QPolygonF([outer_start, outer_end, inner_end, inner_start])
+ return self.polygon_factory([outer_start, outer_end, inner_end, inner_start])
def _bold_strip_normal(self, bond, a, b) -> tuple[float, float]:
if is_bold_double_bond_style(bond.style, bond.order):
@@ -159,7 +175,7 @@ def _bold_neighbor(self, vertex_id, other_id, away_dx, away_dy):
def _miter_corner(
self, vx, vy, nx, ny, off, dx, dy, vertex_id, neighbor, bold_width
) -> QPointF:
- base = QPointF(vx + nx * off, vy + ny * off)
+ base = self.point_factory(vx + nx * off, vy + ny * off)
if neighbor is None or vertex_id is None:
return base
nb_a = self.context.model.atoms.get(neighbor.a)
@@ -185,7 +201,7 @@ def _miter_corner(
return base
if math.hypot(point[0] - vx, point[1] - vy) > _MITER_LIMIT * bold_width:
return base
- return QPointF(point[0], point[1])
+ return self.point_factory(point[0], point[1])
def draw_parallel_bonds(
self,
diff --git a/app/chemvas/ui/molecule/bond_length_graphics_refresh.py b/app/chemvas/ui/molecule/bond_length_graphics_refresh.py
index b58155e3..6838769b 100644
--- a/app/chemvas/ui/molecule/bond_length_graphics_refresh.py
+++ b/app/chemvas/ui/molecule/bond_length_graphics_refresh.py
@@ -3,6 +3,7 @@
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import QFont, QPen
+from chemvas.features.annotations import ATOM_LABEL_HIT_PADDING_RATIO
from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for
from chemvas.ui.canvas.graphics_items import AtomDotItem, AtomLabelItem
from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
@@ -51,7 +52,9 @@ def _refresh_atom_graphics(canvas) -> None:
return
label_service = canvas.services.atom_label_service if labels else None
font = canvas.renderer.atom_font()
- label_hit_padding = canvas.renderer.style.bond_length_px * 0.12
+ label_hit_padding = (
+ canvas.renderer.style.bond_length_px * ATOM_LABEL_HIT_PADDING_RATIO
+ )
pick_radius = atom_pick_radius_for(canvas)
dot_radius = max(0.6, canvas.renderer.style.bond_line_width * 0.6)
@@ -201,6 +204,11 @@ def refresh_bond_length_graphics_for(canvas) -> None:
_refresh_atom_graphics(canvas)
_refresh_bond_graphics(canvas)
+ for item in canvas.runtime_state.orbital_items():
+ item.refresh_lobes(
+ canvas.services.scene_decoration_build_service.build_orbital_items,
+ canvas.renderer.style.bond_length_px * 0.8,
+ )
for atom_id, marks in mark_registry_for(canvas).items():
atom = canvas.model.atoms.get(atom_id)
if atom is None:
diff --git a/app/chemvas/ui/molecule/bond_line_geometry_service.py b/app/chemvas/ui/molecule/bond_line_geometry_service.py
index a320fcfd..4524387f 100644
--- a/app/chemvas/ui/molecule/bond_line_geometry_service.py
+++ b/app/chemvas/ui/molecule/bond_line_geometry_service.py
@@ -3,8 +3,6 @@
import math
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
from chemvas.features.rendering import (
DOUBLE_STYLE_DEFAULT,
DOUBLE_STYLE_OUTER,
@@ -18,11 +16,14 @@
)
if TYPE_CHECKING:
+ from PyQt6.QtCore import QPointF
+
from chemvas.ui.scene.scene_render_context import SceneRenderContext
class BondLineGeometryService:
- def __init__(self, context: SceneRenderContext) -> None:
+ def __init__(self, context: SceneRenderContext, *, point_factory=None) -> None:
+ self._point_factory = point_factory
self.context = context
self.graph = context.state.graph_state
@@ -159,7 +160,12 @@ def _double_neighbor_target(
points.append((other.x, other.y))
if not points:
return None
- return QPointF(
+ point_factory = self._point_factory
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ return point_factory(
sum(point[0] for point in points) / len(points),
sum(point[1] for point in points) / len(points),
)
diff --git a/app/chemvas/ui/molecule/structure_benzene_build_service.py b/app/chemvas/ui/molecule/structure_benzene_build_service.py
index 7a2a3a31..d11549da 100644
--- a/app/chemvas/ui/molecule/structure_benzene_build_service.py
+++ b/app/chemvas/ui/molecule/structure_benzene_build_service.py
@@ -3,8 +3,6 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
from chemvas.ui.insert.ring_occupancy import point_inside_any_ring
from chemvas.ui.molecule.structure_benzene_logic import plan_benzene_ring_points
from chemvas.ui.molecule.structure_geometry_logic import (
@@ -15,6 +13,8 @@
if TYPE_CHECKING:
from collections.abc import Callable
+ from PyQt6.QtCore import QPointF
+
from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter
@@ -26,9 +26,22 @@ class BenzenePlacement:
class StructureBenzeneBuildService:
- def __init__(self, canvas, committer: StructureBuildCommitter) -> None:
+ def __init__(
+ self,
+ canvas,
+ committer: StructureBuildCommitter,
+ *,
+ point_factory=None,
+ center_inside_existing_ring=None,
+ ) -> None:
self.canvas = canvas
self.committer = committer
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ self.point_factory = point_factory
+ self.center_inside_existing_ring = center_inside_existing_ring
def benzene_ring_points(
self,
@@ -50,17 +63,21 @@ def benzene_ring_points(
bonds=self.canvas.model.bonds,
atoms=self.canvas.model.atoms,
bond_length=self.canvas.renderer.style.bond_length_px,
- center_inside_existing_ring=lambda: point_inside_any_ring(
- center, ring_items=self.canvas.runtime_state.ring_items()
+ center_inside_existing_ring=lambda: (
+ self.center_inside_existing_ring(center)
+ if self.center_inside_existing_ring is not None
+ else point_inside_any_ring(
+ center, ring_items=self.canvas.runtime_state.ring_items()
+ )
),
regular_ring_points_for_bond=(
lambda ring_size, bond_id, point: regular_ring_points_for_bond(
- ring_size, bond_id, QPointF(point[0], point[1])
+ ring_size, bond_id, self.point_factory(point[0], point[1])
)
),
regular_ring_points_for_atom=regular_ring_points_for_atom,
compute_free_points=compute_free_benzene_ring_points,
- make_point=QPointF,
+ make_point=self.point_factory,
)
def add_benzene_ring(
diff --git a/app/chemvas/ui/molecule/structure_bond_build_service.py b/app/chemvas/ui/molecule/structure_bond_build_service.py
index 0aeb12ca..1a884161 100644
--- a/app/chemvas/ui/molecule/structure_bond_build_service.py
+++ b/app/chemvas/ui/molecule/structure_bond_build_service.py
@@ -11,9 +11,7 @@
DOUBLE_STYLE_OUTER,
style_for_existing_bond_overlay,
)
-from chemvas.ui.annotations.state import bond_state_dict
from chemvas.ui.canvas.canvas_window_access import notify_error_for
-from chemvas.ui.scene.scene_group_operations import group_connection_allowed_for
if TYPE_CHECKING:
from PyQt6.QtCore import QPointF
@@ -30,12 +28,14 @@ def __init__(
hit_testing_service,
move_controller,
graph_service,
+ connection_allowed=None,
) -> None:
self.canvas = canvas
self.committer = committer
self.hit_testing_service = hit_testing_service
self.move_controller = move_controller
self.graph_service = graph_service
+ self.connection_allowed = connection_allowed
def add_bond_between_points(
self,
@@ -43,7 +43,10 @@ def add_bond_between_points(
end: QPointF,
style: str,
order: int,
+ *,
+ record: bool = True,
) -> tuple[int, int] | None:
+ """Build through the same rules; an unrecorded caller owns its transaction."""
snap_tol = self.canvas.renderer.style.bond_length_px * 0.1
if (
start == end
@@ -83,13 +86,18 @@ def add_bond_between_points(
)
return None
anchors = {atom_id for atom_id in (start_id, end_id) if atom_id is not None}
- if (
- existing_bond_id is None
- and anchors
- and not group_connection_allowed_for(self.canvas, anchors)
- ):
- return None
- snapshot = self.committer.begin_recorded_change()
+ if existing_bond_id is None and anchors:
+ if self.connection_allowed is None:
+ from chemvas.ui.scene.scene_group_operations import (
+ group_connection_allowed_for,
+ )
+
+ allowed = group_connection_allowed_for(self.canvas, anchors)
+ else:
+ allowed = self.connection_allowed(anchors)
+ if not allowed:
+ return None
+ snapshot = self.committer.begin_recorded_change() if record else None
try:
if start_id is None:
start_id = self.committer.add_atom("C", start.x(), start.y())
@@ -104,24 +112,37 @@ def add_bond_between_points(
order,
start_id,
end_id,
+ record=record,
)
- if result is None:
- self.committer.abort_recorded_change(snapshot)
- else:
- self.committer.release_recorded_change(snapshot)
+ if snapshot is not None:
+ if result is None:
+ self.committer.abort_recorded_change(snapshot)
+ else:
+ self.committer.release_recorded_change(snapshot)
return result
return self._add_new_bond(snapshot, start_id, end_id, style, order)
except Exception as error:
- self.committer.abort_recorded_change(snapshot, original_error=error)
+ if snapshot is not None:
+ self.committer.abort_recorded_change(snapshot, original_error=error)
raise
def _update_existing_bond(
- self, bond_id: int, style: str, order: int, start_id: int, end_id: int
+ self,
+ bond_id: int,
+ style: str,
+ order: int,
+ start_id: int,
+ end_id: int,
+ *,
+ record: bool = True,
) -> tuple[int, int] | None:
bond = self.canvas.model.bond_for_id(bond_id)
if bond is None:
return None
- before_state = bond_state_dict(bond)
+ if record:
+ from chemvas.ui.annotations.state import bond_state_dict
+
+ before_state = bond_state_dict(bond)
next_style, next_order = style_for_existing_bond_overlay(
bond.style,
bond.order,
@@ -133,12 +154,13 @@ def _update_existing_bond(
self.move_controller.redraw_bond(bond_id)
self.move_controller.redraw_connected_bonds(bond.a, skip_bond_id=bond_id)
self.move_controller.redraw_connected_bonds(bond.b, skip_bond_id=bond_id)
- after_state = bond_state_dict(bond)
- self.canvas.services.canvas_history_recording_service.record_bond_update(
- bond_id,
- before_state,
- after_state,
- )
+ if record:
+ after_state = bond_state_dict(bond)
+ self.canvas.services.canvas_history_recording_service.record_bond_update(
+ bond_id,
+ before_state,
+ after_state,
+ )
return start_id, end_id
def _add_new_bond(
@@ -152,13 +174,15 @@ def _add_new_bond(
bond_id = self.committer.add_bond(start_id, end_id, order)
bond = self.canvas.model.bond_for_id(bond_id)
if bond is None:
- self.committer.abort_recorded_change(snapshot)
+ if snapshot is not None:
+ self.committer.abort_recorded_change(snapshot)
return None
bond.style = style
self.committer.add_bond_graphics(bond_id)
self.move_controller.redraw_connected_bonds(start_id, skip_bond_id=bond_id)
self.move_controller.redraw_connected_bonds(end_id, skip_bond_id=bond_id)
- self.committer.record_additions(snapshot)
+ if snapshot is not None:
+ self.committer.record_additions(snapshot)
return start_id, end_id
diff --git a/app/chemvas/ui/molecule/structure_build_committer.py b/app/chemvas/ui/molecule/structure_build_committer.py
index 4831131b..088d90c4 100644
--- a/app/chemvas/ui/molecule/structure_build_committer.py
+++ b/app/chemvas/ui/molecule/structure_build_committer.py
@@ -5,19 +5,13 @@
from chemvas.features.graph import first_matching_bond_id
from chemvas.ui.canvas.canvas_scene_items_state import SCENE_ITEM_COLLECTION_ATTRS
-from chemvas.ui.canvas.molecule_scene_renderer import (
- prepare_molecule_for_scene,
- render_molecule,
-)
-from chemvas.ui.molecule.atom_label_access import add_or_update_atom_label
-from chemvas.ui.molecule.structure_insert_access import record_insert_additions_for
from chemvas.ui.molecule.structure_mutation_access import add_bond_for
-from chemvas.ui.transactions.document import DocumentSavepoint
if TYPE_CHECKING:
from PyQt6.QtCore import QPointF
from chemvas.ui.canvas.canvas_view import CanvasView
+ from chemvas.ui.transactions.document import DocumentSavepoint
@dataclass(slots=True, kw_only=True)
@@ -33,6 +27,8 @@ def __init__(self, canvas: CanvasView) -> None:
self.canvas = canvas
def begin_recorded_change(self) -> StructureBuildHistorySnapshot:
+ from chemvas.ui.transactions.document import DocumentSavepoint
+
return StructureBuildHistorySnapshot(
before_next_atom_id=int(self.canvas.model.next_atom_id),
before_bond_count=len(self.canvas.model.bonds),
@@ -48,6 +44,10 @@ def record_additions(
*,
added_scene_items: list | None = None,
) -> None:
+ from chemvas.ui.molecule.structure_insert_access import (
+ record_insert_additions_for,
+ )
+
kwargs: dict[str, Any] = {
"before_next_atom_id": snapshot.before_next_atom_id,
"before_bond_count": snapshot.before_bond_count,
@@ -169,12 +169,12 @@ def label_non_carbon_atoms(self, atom_ids: list[int], elements: list[str]) -> No
atom = self.canvas.model.atom_for_id(atom_id)
if atom is None:
continue
- add_or_update_atom_label(
- self.canvas,
+ self.canvas.services.atom_label_service.add_or_update_atom_label(
atom_id,
atom.element,
record=False,
allow_merge=False,
+ show_carbon=False,
)
def add_atom_with_merge(self, point: QPointF, element: str, merge: list) -> int:
@@ -329,6 +329,11 @@ def _projected_ring_double_bond_count(
return double_count
def render_model(self) -> None:
+ from chemvas.ui.canvas.molecule_scene_renderer import (
+ prepare_molecule_for_scene,
+ render_molecule,
+ )
+
context = self.canvas.render_context
prepare_molecule_for_scene(context.model)
render_molecule(context)
diff --git a/app/chemvas/ui/molecule/structure_build_service.py b/app/chemvas/ui/molecule/structure_build_service.py
index e657e8ef..27e8fcac 100644
--- a/app/chemvas/ui/molecule/structure_build_service.py
+++ b/app/chemvas/ui/molecule/structure_build_service.py
@@ -2,6 +2,7 @@
from typing import TYPE_CHECKING
+from chemvas.features.groups import growth_anchors
from chemvas.ui.molecule.structure_benzene_build_service import (
StructureBenzeneBuildService,
)
@@ -158,37 +159,26 @@ def sprout_benzene_from_atom(self, atom_id: int) -> object | None:
return self.growth_builder.sprout_benzene_from_atom(atom_id)
def sprout_acetyl_from_atom(self, atom_id: int) -> None:
- if not self._group_growth_allowed(atom_id=atom_id):
- return
self.growth_builder.sprout_acetyl_from_atom(atom_id)
def sprout_dimethyl_from_atom(self, atom_id: int) -> None:
- if not self._group_growth_allowed(atom_id=atom_id):
- return
self.growth_builder.sprout_dimethyl_from_atom(atom_id)
def sprout_regular_ring_from_atom(self, atom_id: int, n: int) -> None:
- if not self._group_growth_allowed(atom_id=atom_id):
- return
self.growth_builder.sprout_regular_ring_from_atom(atom_id, n)
def fuse_regular_ring_to_bond(self, bond_id: int, n: int) -> None:
- if not self._group_growth_allowed(bond_id=bond_id):
- return
self.growth_builder.fuse_regular_ring_to_bond(bond_id, n)
def fuse_chair_to_bond(self, bond_id: int, mirrored: bool = False) -> None:
- if not self._group_growth_allowed(bond_id=bond_id):
- return
self.growth_builder.fuse_chair_to_bond(bond_id, mirrored=mirrored)
def _group_growth_allowed(
self, *, atom_id: int | None = None, bond_id: int | None = None
) -> bool:
- anchors = {atom_id} if atom_id is not None else set()
- bond = self.canvas.model.bond_for_id(bond_id)
- if bond is not None:
- anchors.update((bond.a, bond.b))
+ anchors = growth_anchors(
+ self.canvas.model.bonds, atom_id=atom_id, bond_id=bond_id
+ )
return not anchors or group_connection_allowed_for(self.canvas, anchors)
def fuse_benzene_to_bond(self, bond_id: int) -> object | None:
diff --git a/app/chemvas/ui/molecule/structure_geometry_access.py b/app/chemvas/ui/molecule/structure_geometry_access.py
index 7858af3a..ad2bb25a 100644
--- a/app/chemvas/ui/molecule/structure_geometry_access.py
+++ b/app/chemvas/ui/molecule/structure_geometry_access.py
@@ -1,14 +1,13 @@
from __future__ import annotations
import math
+from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
+from chemvas.features.graph import connected_atom_unit_vectors
from chemvas.ui.insert.ring_occupancy import (
graph_ring_polygons_for_bond,
ring_polygon_points_for_bond,
)
-from chemvas.ui.molecule.atom_label_renderer import connected_atom_unit_vectors
from chemvas.ui.molecule.structure_geometry_logic import (
compute_regular_ring_points_for_atom,
compute_regular_ring_points_for_bond,
@@ -24,9 +23,18 @@
ring_points,
)
+if TYPE_CHECKING:
+ from PyQt6.QtCore import QPointF
+
-def qpoints_from_pairs(points: list[tuple[float, float]]) -> list[QPointF]:
- return [QPointF(x, y) for x, y in points]
+def qpoints_from_pairs(
+ points: list[tuple[float, float]], *, point_factory=None
+) -> list[QPointF]:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ return [point_factory(x, y) for x, y in points]
def point_pairs(points: list[QPointF]) -> list[tuple[float, float]]:
@@ -41,14 +49,18 @@ def point_pair(point: QPointF | None) -> tuple[float, float] | None:
def template_geometry_result(
result: tuple[list[tuple[float, float]], list[tuple[int, float, float]]] | None,
+ *,
+ point_factory=None,
) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None:
if result is None:
return None
points, merge = result
- return qpoints_from_pairs(points), merge
+ return qpoints_from_pairs(points, point_factory=point_factory), merge
def atom_point_for(canvas, atom_id: int) -> QPointF:
+ from PyQt6.QtCore import QPointF
+
atom = canvas.model.atoms[atom_id]
return QPointF(atom.x, atom.y)
@@ -70,8 +82,13 @@ def default_bond_angle_for_vectors(vectors: list[tuple[float, float]]) -> float:
def default_bond_endpoint_for(
- canvas, start: QPointF, start_atom_id: int | None
+ canvas, start: QPointF, start_atom_id: int | None, *, point_factory=None
) -> QPointF:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+
angle = 0.0
if start_atom_id is not None:
angle = default_bond_angle_for_vectors(
@@ -79,19 +96,26 @@ def default_bond_endpoint_for(
)
rad = math.radians(angle)
bond_len = canvas.renderer.style.bond_length_px
- return QPointF(
+ return point_factory(
start.x() + math.cos(rad) * bond_len, start.y() + math.sin(rad) * bond_len
)
def sprout_bond_endpoint_for(
- canvas, atom_id: int, *, cyclic: bool = False
+ canvas, atom_id: int, *, cyclic: bool = False, point_factory=None
) -> QPointF | None:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+
atom = canvas.model.atom_for_id(atom_id)
default_endpoint = None
if atom is not None and not cyclic:
- start = QPointF(atom.x, atom.y)
- endpoint = default_bond_endpoint_for(canvas, start, atom_id)
+ start = point_factory(atom.x, atom.y)
+ endpoint = default_bond_endpoint_for(
+ canvas, start, atom_id, point_factory=point_factory
+ )
default_endpoint = (endpoint.x(), endpoint.y())
point = compute_sprout_bond_endpoint(
atom_id,
@@ -103,7 +127,7 @@ def sprout_bond_endpoint_for(
)
if point is None:
return None
- return QPointF(point[0], point[1])
+ return point_factory(point[0], point[1])
def regular_ring_radius_for(canvas, n: int, bond_length: float | None = None) -> float:
@@ -116,33 +140,44 @@ def regular_ring_radius_for(canvas, n: int, bond_length: float | None = None) ->
def ring_points_for(
- canvas, center: QPointF, n: int, radius: float | None = None
+ canvas,
+ center: QPointF,
+ n: int,
+ radius: float | None = None,
+ *,
+ point_factory=None,
) -> list[QPointF]:
points = ring_points(
(center.x(), center.y()), n, radius or canvas.renderer.style.bond_length_px
)
- return qpoints_from_pairs(points)
+ return qpoints_from_pairs(points, point_factory=point_factory)
-def cyclohexane_chair_points_for(canvas, center: QPointF) -> list[QPointF]:
+def cyclohexane_chair_points_for(
+ canvas, center: QPointF, *, point_factory=None
+) -> list[QPointF]:
points = cyclohexane_chair_points(
(center.x(), center.y()), canvas.renderer.style.bond_length_px
)
- return qpoints_from_pairs(points)
+ return qpoints_from_pairs(points, point_factory=point_factory)
-def cyclohexane_chair_flipped_points_for(canvas, center: QPointF) -> list[QPointF]:
+def cyclohexane_chair_flipped_points_for(
+ canvas, center: QPointF, *, point_factory=None
+) -> list[QPointF]:
points = cyclohexane_chair_flipped_points(
(center.x(), center.y()), canvas.renderer.style.bond_length_px
)
- return qpoints_from_pairs(points)
+ return qpoints_from_pairs(points, point_factory=point_factory)
-def cyclohexane_boat_points_for(canvas, center: QPointF) -> list[QPointF]:
+def cyclohexane_boat_points_for(
+ canvas, center: QPointF, *, point_factory=None
+) -> list[QPointF]:
points = cyclohexane_boat_points(
(center.x(), center.y()), canvas.renderer.style.bond_length_px
)
- return qpoints_from_pairs(points)
+ return qpoints_from_pairs(points, point_factory=point_factory)
def ring_polygon_points_for_bond_for(
@@ -159,6 +194,8 @@ def regular_ring_points_for_atom_for(
canvas,
n: int,
atom_id: int,
+ *,
+ point_factory=None,
) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None:
result = compute_regular_ring_points_for_atom(
n,
@@ -167,7 +204,7 @@ def regular_ring_points_for_atom_for(
bonds=canvas.model.bonds,
bond_length=canvas.renderer.style.bond_length_px,
)
- return template_geometry_result(result)
+ return template_geometry_result(result, point_factory=point_factory)
def _compute_bond_template_geometry_for(
@@ -177,6 +214,7 @@ def _compute_bond_template_geometry_for(
bond_id: int,
*,
center_hint: QPointF | None = None,
+ point_factory=None,
) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None:
atoms = canvas.model.atoms
bonds = canvas.model.bonds
@@ -210,7 +248,7 @@ def _compute_bond_template_geometry_for(
for polygon in occupied
):
return None
- return template_geometry_result(result)
+ return template_geometry_result(result, point_factory=point_factory)
def regular_ring_points_for_bond_for(
@@ -218,6 +256,8 @@ def regular_ring_points_for_bond_for(
n: int,
bond_id: int,
center_hint: QPointF | None = None,
+ *,
+ point_factory=None,
) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None:
return _compute_bond_template_geometry_for(
canvas,
@@ -225,6 +265,7 @@ def regular_ring_points_for_bond_for(
n,
bond_id,
center_hint=center_hint,
+ point_factory=point_factory,
)
@@ -233,6 +274,8 @@ def template_points_for_bond_for(
points_local: list[QPointF],
bond_id: int,
center_hint: QPointF | None = None,
+ *,
+ point_factory=None,
) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None:
return _compute_bond_template_geometry_for(
canvas,
@@ -240,6 +283,7 @@ def template_points_for_bond_for(
point_pairs(points_local),
bond_id,
center_hint=center_hint,
+ point_factory=point_factory,
)
diff --git a/app/chemvas/ui/molecule/structure_growth_build_actions.py b/app/chemvas/ui/molecule/structure_growth_build_actions.py
index 0f5bcffd..8548f7df 100644
--- a/app/chemvas/ui/molecule/structure_growth_build_actions.py
+++ b/app/chemvas/ui/molecule/structure_growth_build_actions.py
@@ -24,6 +24,7 @@ def structure_growth_build_actions_for(service) -> StructureGrowthBuildActions:
add_atom=service.committer.add_atom,
add_bond=service.committer.add_bond,
add_bond_graphics=service.committer.add_bond_graphics,
+ growth_allowed=service._group_growth_allowed,
)
diff --git a/app/chemvas/ui/molecule/structure_growth_build_service.py b/app/chemvas/ui/molecule/structure_growth_build_service.py
index cf445976..6bcaab9a 100644
--- a/app/chemvas/ui/molecule/structure_growth_build_service.py
+++ b/app/chemvas/ui/molecule/structure_growth_build_service.py
@@ -3,8 +3,6 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QPointF
-
from chemvas.ui.molecule.structure_growth_geometry import (
BondPlacementContext,
mirrored_local_points,
@@ -14,7 +12,9 @@
if TYPE_CHECKING:
from collections.abc import Callable
-RingPoints = tuple[list[QPointF], list[tuple[int, float, float]]]
+ from PyQt6.QtCore import QPointF
+
+RingPoints = tuple[list["QPointF"], list[tuple[int, float, float]]]
@dataclass(frozen=True, kw_only=True)
@@ -38,11 +38,20 @@ class StructureGrowthBuildActions:
add_atom: Callable[[str, float, float], int] | None = None
add_bond: Callable[..., int] | None = None
add_bond_graphics: Callable[[int], None] | None = None
+ # Group connection preflight for shortcuts that may join molecules.
+ growth_allowed: Callable[..., bool] | None = None
class StructureGrowthBuildService:
- def __init__(self, actions: StructureGrowthBuildActions) -> None:
+ def __init__(
+ self, actions: StructureGrowthBuildActions, *, point_factory=None
+ ) -> None:
self.actions = actions
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+ self.point_factory = point_factory
def sprout_bond_from_atom(
self,
@@ -63,7 +72,15 @@ def sprout_benzene_from_atom(self, atom_id: int) -> object | None:
self.actions.atom_point(atom_id), attach_atom_id=atom_id
)
+ def _allowed(
+ self, *, atom_id: int | None = None, bond_id: int | None = None
+ ) -> bool:
+ allowed = self.actions.growth_allowed
+ return allowed is None or allowed(atom_id=atom_id, bond_id=bond_id)
+
def sprout_acetyl_from_atom(self, atom_id: int) -> None:
+ if not self._allowed(atom_id=atom_id):
+ return
start = self.actions.atom_point(atom_id)
carbon_end = self.actions.sprout_bond_endpoint(atom_id, cyclic=False)
if carbon_end is None:
@@ -131,6 +148,8 @@ def _sprout_acetyl_with_bond_builder(
self.actions.add_bond_between_points(carbon_point, methyl_end, "single", 1)
def sprout_dimethyl_from_atom(self, atom_id: int) -> None:
+ if not self._allowed(atom_id=atom_id):
+ return
start = self.actions.atom_point(atom_id)
first_end = self.actions.sprout_bond_endpoint(atom_id, cyclic=False)
if first_end is None:
@@ -175,6 +194,9 @@ def _sprout_dimethyl_with_bond_builder(
self.actions.add_bond_between_points(start, second_end, "single", 1)
def sprout_regular_ring_from_atom(self, atom_id: int, n: int) -> None:
+ if not self._allowed(atom_id=atom_id):
+ return
+
def _build() -> bool:
result = self.actions.regular_ring_points_for_atom(n, atom_id)
if result is None:
@@ -186,6 +208,9 @@ def _build() -> bool:
self.actions.run_recorded_additions_action(_build)
def fuse_regular_ring_to_bond(self, bond_id: int, n: int) -> None:
+ if not self._allowed(bond_id=bond_id):
+ return
+
def _build() -> bool:
placement = self.actions.bond_placement_context(bond_id)
if placement is None:
@@ -202,10 +227,15 @@ def _build() -> bool:
self.actions.run_recorded_additions_action(_build)
def fuse_chair_to_bond(self, bond_id: int, mirrored: bool = False) -> None:
+ if not self._allowed(bond_id=bond_id):
+ return
+
def _build() -> bool:
- local_center = QPointF(0.0, 0.0)
+ local_center = self.point_factory(0.0, 0.0)
points_local = mirrored_local_points(
- self.actions.cyclohexane_chair_points(local_center), mirrored
+ self.actions.cyclohexane_chair_points(local_center),
+ mirrored,
+ point_factory=self.point_factory,
)
placement = self.actions.bond_placement_context(bond_id)
if placement is None:
diff --git a/app/chemvas/ui/molecule/structure_growth_geometry.py b/app/chemvas/ui/molecule/structure_growth_geometry.py
index d821d776..25ec451c 100644
--- a/app/chemvas/ui/molecule/structure_growth_geometry.py
+++ b/app/chemvas/ui/molecule/structure_growth_geometry.py
@@ -2,12 +2,12 @@
import math
from dataclasses import dataclass
-from typing import TYPE_CHECKING
-
-from PyQt6.QtCore import QPointF
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
- from collections.abc import Mapping, Sequence
+ from collections.abc import Callable, Mapping, Sequence
+
+ from PyQt6.QtCore import QPointF
from chemvas.domain.document import Atom, Bond
@@ -23,6 +23,8 @@ class BondPlacementContext:
def fused_benzene_centers(
center: QPointF, step: float, count: int, mode: str = "linear"
) -> list[QPointF]:
+ from PyQt6.QtCore import QPointF
+
if count == 2:
return [
QPointF(center.x() - step / 2.0, center.y()),
@@ -67,7 +69,13 @@ def resolve_bond_placement_context(
*,
bonds: Sequence[Bond | None],
atoms: Mapping[int, Atom],
+ point_factory: Callable[[float, float], Any] | None = None,
) -> BondPlacementContext | None:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+
if not (0 <= bond_id < len(bonds)):
return None
bond = bonds[bond_id]
@@ -81,14 +89,26 @@ def resolve_bond_placement_context(
bond_id=bond_id,
atom_a_id=bond.a,
atom_b_id=bond.b,
- midpoint=QPointF((atom_a.x + atom_b.x) / 2.0, (atom_a.y + atom_b.y) / 2.0),
+ midpoint=point_factory(
+ (atom_a.x + atom_b.x) / 2.0, (atom_a.y + atom_b.y) / 2.0
+ ),
)
-def mirrored_local_points(points: Sequence[QPointF], mirrored: bool) -> list[QPointF]:
+def mirrored_local_points(
+ points: Sequence[QPointF],
+ mirrored: bool,
+ *,
+ point_factory: Callable[[float, float], Any] | None = None,
+) -> list[QPointF]:
+ if point_factory is None:
+ from PyQt6.QtCore import QPointF
+
+ point_factory = QPointF
+
if not mirrored:
- return [QPointF(point) for point in points]
- return [QPointF(point.x(), -point.y()) for point in points]
+ return [point_factory(point.x(), point.y()) for point in points]
+ return [point_factory(point.x(), -point.y()) for point in points]
def alternating_ring_bond_specs(
diff --git a/app/chemvas/ui/molecule/template_geometry.py b/app/chemvas/ui/molecule/template_geometry.py
index b07ab91b..98075577 100644
--- a/app/chemvas/ui/molecule/template_geometry.py
+++ b/app/chemvas/ui/molecule/template_geometry.py
@@ -329,7 +329,7 @@ def bond_side_is_occupied(
break
# An occupancy polygon need not share this bond; retain point containment
# for those polygons and for degenerate outlines without a winding.
- return _polygon_contains_point(center, polygon)
+ return polygon_contains_point(center, polygon)
def _distance(a: Point2D, b: Point2D) -> float:
@@ -347,7 +347,7 @@ def _points_center(points: Sequence[Point2D]) -> Point2D:
)
-def _polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool:
+def polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool:
if len(polygon) < 3:
return False
x, y = point
@@ -369,6 +369,7 @@ def _polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool:
"cyclohexane_chair_flipped_points",
"cyclohexane_chair_points",
"place_template_on_bond",
+ "polygon_contains_point",
"regular_ring_points_for_atom",
"regular_ring_points_for_bond",
"regular_ring_radius",
diff --git a/app/chemvas/ui/scene/image_actions.py b/app/chemvas/ui/scene/image_actions.py
index 9be142a9..dbaacbbb 100644
--- a/app/chemvas/ui/scene/image_actions.py
+++ b/app/chemvas/ui/scene/image_actions.py
@@ -24,7 +24,7 @@
validate_image_collection_budget,
validate_image_state,
)
-from chemvas.domain.document.images import image_to_state
+from chemvas.domain.document.images import image_to_state, inserted_image_box
from chemvas.ui.annotations.projections import resolve_projection
from chemvas.ui.canvas.canvas_document_state import document_item_lists_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
@@ -34,6 +34,7 @@
UpdateSceneItemCommand,
)
from chemvas.ui.transactions.document import document_transaction
+from chemvas.ui.window.main_window_config import IMAGE_PROPERTIES_SPEC
from chemvas.ui.window.main_window_ports import active_canvas_for_window
if TYPE_CHECKING:
@@ -77,19 +78,13 @@ def insert_image_bytes(canvas, data: bytes) -> ImageItem:
state = image_state_from_bytes(data)
viewport = canvas.viewport()
visible = canvas.mapToScene(viewport.rect()).boundingRect()
- sheet = sheet_rect_for(canvas)
- placement = visible.intersected(sheet)
- if placement.isEmpty():
- placement = sheet
- width = float(cast("float", state["width"]))
- height = float(cast("float", state["height"]))
- scale = min(1.0, placement.width() * 0.7 / width, placement.height() * 0.7 / height)
- state.update(
- width=width * scale,
- height=height * scale,
- x=placement.center().x() - width * scale / 2,
- y=placement.center().y() - height * scale / 2,
+ x, y, width, height = inserted_image_box(
+ float(cast("float", state["width"])),
+ float(cast("float", state["height"])),
+ visible.getRect(),
+ sheet_rect_for(canvas).getRect(),
)
+ state.update(width=width, height=height, x=x, y=y)
existing = canvas.runtime_state.image_state.snapshot(image_to_state)
# Incoming bytes were validated above; existing live sources were validated
# when their ImageItems were constructed and cannot be replaced in-place.
@@ -139,31 +134,34 @@ def update_image_properties(canvas, item: ImageItem, state: dict) -> bool:
class ImagePropertiesDialog(QDialog):
def __init__(self, state: dict, parent=None) -> None:
super().__init__(parent)
- self.setWindowTitle("Image Properties")
+ spec = IMAGE_PROPERTIES_SPEC
+ self.setWindowTitle(spec["title"])
self._state = dict(state)
self._ratio = state["pixel_width"] / state["pixel_height"]
layout = QFormLayout(self)
layout.addRow(
- QLabel(f"Original: {state['pixel_width']} × {state['pixel_height']} pixels")
+ QLabel(
+ spec["original"].format(
+ width=state["pixel_width"], height=state["pixel_height"]
+ )
+ )
)
self.fields: dict[str, QDoubleSpinBox] = {}
self._initial_values: dict[str, float] = {}
- for key, label in (
- ("x", "X"),
- ("y", "Y"),
- ("width", "Width"),
- ("height", "Height"),
- ):
+ for key, label in spec["fields"]:
spin = QDoubleSpinBox(self)
- spin.setDecimals(4)
+ spin.setDecimals(spec["decimals"])
spin.setRange(
- 0.0001 if key in {"width", "height"} else -1_000_000, 1_000_000
+ spec["size_minimum"]
+ if key in {"width", "height"}
+ else spec["coordinate_minimum"],
+ spec["maximum"],
)
spin.setValue(state[key])
self.fields[key] = spin
self._initial_values[key] = spin.value()
- layout.addRow(f"{label} (canvas units)", spin)
- self.lock_aspect = QCheckBox("Lock to original aspect ratio", self)
+ layout.addRow(spec["field_label"].format(label=label), spin)
+ self.lock_aspect = QCheckBox(spec["lock_aspect"], self)
self.lock_aspect.setChecked(state["lock_aspect"])
layout.addRow(self.lock_aspect)
self.fields["width"].valueChanged.connect(
@@ -177,10 +175,10 @@ def __init__(self, state: dict, parent=None) -> None:
)
self.opacity = QDoubleSpinBox(self)
self.opacity.setRange(0, 100)
- self.opacity.setDecimals(1)
- self.opacity.setSuffix(" %")
+ self.opacity.setDecimals(spec["opacity_decimals"])
+ self.opacity.setSuffix(spec["opacity_suffix"])
self.opacity.setValue(state["opacity"] * 100)
- layout.addRow("Opacity", self.opacity)
+ layout.addRow(spec["opacity"], self.opacity)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
@@ -206,7 +204,9 @@ def image_state(self) -> dict:
if spin.value() != self._initial_values[key]:
state[key] = spin.value()
state["lock_aspect"] = self.lock_aspect.isChecked()
- if self.opacity.value() != round(self._state["opacity"] * 100, 1):
+ if self.opacity.value() != round(
+ self._state["opacity"] * 100, IMAGE_PROPERTIES_SPEC["opacity_decimals"]
+ ):
state["opacity"] = self.opacity.value() / 100
return state
@@ -219,7 +219,11 @@ def image_properties_for_window(window: MainWindowLike) -> None:
if item is not None and item.isSelected()
]
if not items:
- QMessageBox.information(window, "Image Properties", "Select an image first.")
+ QMessageBox.information(
+ window,
+ IMAGE_PROPERTIES_SPEC["title"],
+ IMAGE_PROPERTIES_SPEC["none_selected"],
+ )
return
item = items[0]
if len(items) > 1:
@@ -227,13 +231,18 @@ def image_properties_for_window(window: MainWindowLike) -> None:
for index, candidate in enumerate(items, start=1):
state = candidate.image_state()
choices.append(
- f"Image {index}: {state['pixel_width']} × {state['pixel_height']} pixels, "
- f"at ({state['x']:g}, {state['y']:g})"
+ IMAGE_PROPERTIES_SPEC["choice"].format(
+ index=index,
+ width=state["pixel_width"],
+ height=state["pixel_height"],
+ x=state["x"],
+ y=state["y"],
+ )
)
choice, accepted = QInputDialog.getItem(
window,
- "Image Properties",
- "Choose an image to edit. The group stays together.",
+ IMAGE_PROPERTIES_SPEC["title"],
+ IMAGE_PROPERTIES_SPEC["choose"],
choices,
0,
False,
@@ -247,4 +256,4 @@ def image_properties_for_window(window: MainWindowLike) -> None:
try:
update_image_properties(canvas, item, dialog.image_state())
except ValueError as error:
- QMessageBox.warning(window, "Image Properties", str(error))
+ QMessageBox.warning(window, IMAGE_PROPERTIES_SPEC["title"], str(error))
diff --git a/app/chemvas/ui/scene/mark_ownership.py b/app/chemvas/ui/scene/mark_ownership.py
index a3fa2a9d..ee1d3e9b 100644
--- a/app/chemvas/ui/scene/mark_ownership.py
+++ b/app/chemvas/ui/scene/mark_ownership.py
@@ -11,6 +11,13 @@
from chemvas.ui.canvas.canvas_view import CanvasView
+DISTANT_MARK_COLOR = "#b45309"
+MARK_OWNER_GUIDANCE = (
+ "Amber means far from owner. Moving keeps this owner. "
+ "Right-click the mark to reassign it."
+)
+
+
def mark_is_distant_for(canvas: CanvasView, item: QGraphicsItem) -> bool:
data = item.data(1) or {}
atom_id = data.get("atom_id")
diff --git a/app/chemvas/ui/scene/scene_align_logic.py b/app/chemvas/ui/scene/scene_align_logic.py
index e7363532..2bd54d60 100644
--- a/app/chemvas/ui/scene/scene_align_logic.py
+++ b/app/chemvas/ui/scene/scene_align_logic.py
@@ -2,19 +2,22 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
-from PyQt6.QtCore import QRectF
+from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from collections.abc import Callable, Iterable, Sequence
+
+ from PyQt6.QtCore import QRectF
ALIGN_MODES = ("left", "center", "right", "top", "middle", "bottom")
DISTRIBUTE_AXES = ("horizontal", "vertical")
def _union(rects: Sequence[QRectF]) -> QRectF:
- overall = QRectF(rects[0])
+ overall = rects[0]
for rect in rects[1:]:
overall = overall.united(rect)
return overall
@@ -80,4 +83,66 @@ def size(rect: QRectF) -> float:
return deltas
-__all__ = ["ALIGN_MODES", "DISTRIBUTE_AXES", "align_deltas", "distribute_deltas"]
+@dataclass(frozen=True, slots=True)
+class AlignObject:
+ """One thing Align/Distribute moves as a unit.
+
+ A whole molecule (every atom of a structure that has a selected atom), a
+ standalone scene item, or a group carrying both.
+ """
+
+ rect: QRectF
+ atom_ids: frozenset[int]
+ items: tuple[object, ...]
+
+
+def alignment_objects(
+ structures: Sequence[set[int]],
+ items: list[Any],
+ *,
+ groups: Iterable[Any],
+ object_rect: Callable[[set[int], list[Any]], QRectF | None],
+) -> list[AlignObject]:
+ objects: list[AlignObject] = []
+ claimed_atoms: set[int] = set()
+ claimed_items: set[int] = set()
+ # A group is one object: its structures and items keep their layout.
+ for group in groups:
+ group_atoms: set[int] = set()
+ for structure in structures:
+ if structure & group.atom_ids:
+ group_atoms |= structure
+ group_items = [
+ item for item in items if require_scene_record_id(item) in group.item_ids
+ ]
+ if not group_atoms and not group_items:
+ continue
+ rect = object_rect(group_atoms, group_items)
+ if rect is None:
+ continue
+ objects.append(AlignObject(rect, frozenset(group_atoms), tuple(group_items)))
+ claimed_atoms |= group_atoms
+ claimed_items |= {id(item) for item in group_items}
+ for structure in structures:
+ if structure & claimed_atoms:
+ continue
+ rect = object_rect(structure, [])
+ if rect is not None:
+ objects.append(AlignObject(rect, frozenset(structure), ()))
+ for item in items:
+ if id(item) in claimed_items:
+ continue
+ rect = item.sceneBoundingRect()
+ if rect.isValid():
+ objects.append(AlignObject(rect, frozenset(), (item,)))
+ return objects
+
+
+__all__ = [
+ "ALIGN_MODES",
+ "DISTRIBUTE_AXES",
+ "AlignObject",
+ "align_deltas",
+ "alignment_objects",
+ "distribute_deltas",
+]
diff --git a/app/chemvas/ui/scene/scene_clipboard_controller.py b/app/chemvas/ui/scene/scene_clipboard_controller.py
index 232e4815..731a3ba3 100644
--- a/app/chemvas/ui/scene/scene_clipboard_controller.py
+++ b/app/chemvas/ui/scene/scene_clipboard_controller.py
@@ -9,7 +9,7 @@
validate_image_collection_budget,
validate_image_states,
)
-from chemvas.features.selection import unproject_point_3d
+from chemvas.domain.document.perspective import reprojected_pasted_coords_3d
from chemvas.ui.annotations.state import (
atom_state_dict_for,
bond_state_dict,
@@ -286,23 +286,21 @@ def _apply_pasted_perspective(
# uses the target canvas anchor, as it did before the transfer.
canvas = self.canvas
rotation = canvas.runtime_state.rotation_state
- target_center = rotation.projection_center_3d
- target_anchor = rotation.projection_anchor_2d
- stored_coords = canvas.runtime_state.atom_coords_3d_state.atom_coords_3d
- for atom_id, coords in coords_3d.items():
- atom = canvas.model.atom_for_id(atom_id)
- if atom is None:
- continue
- target_z = coords[2]
- if target_center is not None and projection_center_3d is not None:
- target_z = target_center[2] + (coords[2] - projection_center_3d[2])
- stored_coords[atom_id] = unproject_point_3d(
- (atom.x, atom.y),
- target_z,
+ atoms = canvas.model.atoms
+ canvas.runtime_state.atom_coords_3d_state.atom_coords_3d.update(
+ reprojected_pasted_coords_3d(
+ coords_3d,
+ projection_center_3d,
+ {
+ atom_id: (atoms[atom_id].x, atoms[atom_id].y)
+ for atom_id in coords_3d
+ if atom_id in atoms
+ },
bond_length_px=canvas.renderer.style.bond_length_px,
- center_3d=target_center,
- anchor_2d=target_anchor,
+ target_center_3d=rotation.projection_center_3d,
+ target_anchor_2d=rotation.projection_anchor_2d,
)
+ )
__all__ = [
diff --git a/app/chemvas/ui/scene/scene_clipboard_transaction_logic.py b/app/chemvas/ui/scene/scene_clipboard_transaction_logic.py
index 6e58f563..49f40c8c 100644
--- a/app/chemvas/ui/scene/scene_clipboard_transaction_logic.py
+++ b/app/chemvas/ui/scene/scene_clipboard_transaction_logic.py
@@ -5,7 +5,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from chemvas.ui.annotations.state import ARROW_KINDS
+from chemvas.domain.document import VALID_ARROW_KINDS
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
@@ -58,11 +58,7 @@ def build_clipboard_copy_plan(
pad = max(2.0, bond_line_width * 2.0)
source = bounds.adjusted(-pad, -pad, pad, pad)
scale = max(1.0, float(device_pixel_ratio))
- payload_json = (
- json.dumps(payload, separators=(",", ":"), sort_keys=True)
- if payload is not None
- else None
- )
+ payload_json = selection_payload_json(payload) if payload is not None else None
return ClipboardCopyPlan(
source=source,
scale=scale,
@@ -72,6 +68,11 @@ def build_clipboard_copy_plan(
)
+def selection_payload_json(payload: dict) -> str:
+ """The clipboard text of a selection payload, as every adapter copies it."""
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True)
+
+
def clipboard_copy_cache_values(payload_json: str | None) -> tuple[str | None, int]:
if payload_json is None:
return None, 0
@@ -140,7 +141,7 @@ def translated_scene_item_state(
if isinstance(translated.get("y"), (int, float)):
translated["y"] = float(translated["y"]) + dy
return translated
- if kind in ARROW_KINDS:
+ if kind in VALID_ARROW_KINDS:
translated["start"] = translated_point_value(translated.get("start"), dx, dy)
translated["end"] = translated_point_value(translated.get("end"), dx, dy)
translated["control"] = translated_point_value(
@@ -212,6 +213,7 @@ def _copy_bounds_for_items(items: Sequence[QGraphicsItem]) -> QRectF | None:
"build_clipboard_paste_plan",
"clipboard_copy_cache_values",
"clipboard_paste_offset",
+ "selection_payload_json",
"translated_point_value",
"translated_scene_item_state",
]
diff --git a/app/chemvas/ui/scene/scene_decoration_build_access.py b/app/chemvas/ui/scene/scene_decoration_build_access.py
index 57365f61..67a48879 100644
--- a/app/chemvas/ui/scene/scene_decoration_build_access.py
+++ b/app/chemvas/ui/scene/scene_decoration_build_access.py
@@ -4,14 +4,14 @@
from PyQt6.QtGui import QBrush, QColor, QPen
from chemvas.features.annotations import DEFAULT_BRACKET_KIND
+from chemvas.features.rendering import SNAP_MARK_PEN_SCREEN_PX, SNAP_MARK_SCREEN_PX
from chemvas.ui.canvas.graphics_items import NoSelectEllipseItem
from chemvas.ui.scene.scene_item_access import add_item_to_canvas_scene
-from chemvas.ui.selection.selection_handles import HANDLE_ACCENT_COLOR
from chemvas.ui.tools.endpoint_snap_access import (
- SNAP_MARK_SCREEN_PX,
scene_length_for_screen_px,
snapped_points_among_for,
)
+from chemvas.ui.window.main_window_config import HANDLE_ACCENT_COLOR
SNAP_MARK_ROLE = "snap_mark"
@@ -22,7 +22,7 @@ def build_snap_mark_for(canvas, point):
point.x() - radius, point.y() - radius, radius * 2, radius * 2
)
pen = QPen(QColor(HANDLE_ACCENT_COLOR))
- pen.setWidthF(1.6)
+ pen.setWidthF(SNAP_MARK_PEN_SCREEN_PX)
pen.setCosmetic(True)
mark.setPen(pen)
mark.setBrush(QBrush(Qt.BrushStyle.NoBrush))
diff --git a/app/chemvas/ui/scene/scene_decoration_service.py b/app/chemvas/ui/scene/scene_decoration_service.py
index 975b6bf6..4e096385 100644
--- a/app/chemvas/ui/scene/scene_decoration_service.py
+++ b/app/chemvas/ui/scene/scene_decoration_service.py
@@ -6,9 +6,10 @@
from PyQt6.QtWidgets import QGraphicsTextItem
-from chemvas.domain.document import ARROW_LABEL_SIDES, Shape, TSBracket
+from chemvas.domain.document import Shape, TSBracket
+from chemvas.domain.document.orbitals import Orbital, orbital_to_state
from chemvas.domain.transactions import run_rollback_step
-from chemvas.features.annotations import normalized_bracket_kind
+from chemvas.features.annotations import cleaned_arrow_labels, normalized_bracket_kind
from chemvas.ui.annotations.materialize import create_orbital_item_from_state
from chemvas.ui.annotations.records import (
discard_shape_record_for,
@@ -121,11 +122,7 @@ def edit_arrow_labels(self, item) -> bool:
return self.set_arrow_labels(item, selection)
def set_arrow_labels(self, item, labels: Mapping[str, str]) -> bool:
- cleaned = {
- side: text
- for side, text in labels.items()
- if side in ARROW_LABEL_SIDES and text.strip()
- }
+ cleaned = cleaned_arrow_labels(labels)
with document_transaction(self.canvas, history_service=self.history):
before = arrow_state_dict_for(self.canvas, item)
after = {key: value for key, value in before.items() if key != "labels"}
@@ -219,12 +216,12 @@ def add_shape(
def add_orbital(self, center: QPointF):
with self._scene_add_transaction() as track:
group = create_orbital_item_from_state(
- {
- "orbital_kind": self.canvas.runtime_state.tool_settings_state.active_orbital_type,
- "center": (center.x(), center.y()),
- "scale": 1.0,
- "rotation": 0.0,
- },
+ orbital_to_state(
+ Orbital(
+ self.canvas.runtime_state.tool_settings_state.active_orbital_type,
+ (center.x(), center.y()),
+ )
+ ),
document=self.canvas.render_context.state.orbital_state,
build_orbital_items=self.canvas.services.scene_decoration_build_service.build_orbital_items,
orbital_base_handle_dist=self.canvas.renderer.style.bond_length_px
diff --git a/app/chemvas/ui/scene/scene_delete_plan.py b/app/chemvas/ui/scene/scene_delete_plan.py
index 7ad9e972..a7f77c78 100644
--- a/app/chemvas/ui/scene/scene_delete_plan.py
+++ b/app/chemvas/ui/scene/scene_delete_plan.py
@@ -1,23 +1,21 @@
-"""Delete-selection classification and planning over live Qt scene items.
-
-Formerly ``scene_delete_logic``. The module consumes ``QGraphicsItem``
-instances directly (isinstance checks, ``data()`` lookups), so the Qt-free
-``*_logic`` role contract never applied; the name now says what it is.
-"""
+"""Shared deletion planning; Qt item classification stays at its input boundary."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
-from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPolygonItem, QGraphicsTextItem
-
-from chemvas.domain.document import bond_endpoint_ids, orphaned_atom_ids
-from chemvas.ui.annotations.state import ARROW_KINDS
+from chemvas.domain.document import (
+ VALID_ARROW_KINDS,
+ bond_endpoint_ids,
+ orphaned_atom_ids,
+)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
+ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPolygonItem, QGraphicsTextItem
+
from chemvas.domain.document import Bond
@@ -44,6 +42,8 @@ class DeleteSelectionPlan:
def classify_delete_selection(items: Sequence[QGraphicsItem]) -> DeleteSelectionBuckets:
+ from PyQt6.QtWidgets import QGraphicsPolygonItem, QGraphicsTextItem
+
buckets = DeleteSelectionBuckets()
for item in items:
kind = item.data(0)
@@ -63,7 +63,7 @@ def classify_delete_selection(items: Sequence[QGraphicsItem]) -> DeleteSelection
buckets.note_items.append(item)
elif kind == "mark":
buckets.mark_items.append(item)
- elif kind in ARROW_KINDS:
+ elif kind in VALID_ARROW_KINDS:
buckets.arrow_items.append(item)
elif kind == "ts_bracket":
buckets.ts_bracket_items.append(item)
@@ -154,9 +154,35 @@ def build_delete_selection_plan(
)
+def hover_delete_target(
+ atom_id: int | None,
+ bond_id: int | None,
+ *,
+ bonds: Sequence[Bond | None],
+ atom_has_visible_label: Callable[[int], bool],
+) -> tuple[str, int] | None:
+ """Native Delete strips a bonded label before removing its atom."""
+ if atom_id is not None:
+ has_bond = any(
+ bond is not None
+ and atom_id in (getattr(bond, "a", None), getattr(bond, "b", None))
+ for bond in bonds
+ )
+ # Delete strips a bonded atom's label first. A lone labelled atom
+ # has nothing to fall back to: hiding its label would leave an
+ # invisible carbon on the sheet, so it is deleted outright.
+ if atom_has_visible_label(atom_id) and has_bond:
+ return "label", atom_id
+ return "atom", atom_id
+ if bond_id is not None:
+ return "bond", bond_id
+ return None
+
+
__all__ = [
"DeleteSelectionBuckets",
"DeleteSelectionPlan",
"build_delete_selection_plan",
"classify_delete_selection",
+ "hover_delete_target",
]
diff --git a/app/chemvas/ui/scene/scene_flip_geometry.py b/app/chemvas/ui/scene/scene_flip_geometry.py
index 23dadc0d..714b36c8 100644
--- a/app/chemvas/ui/scene/scene_flip_geometry.py
+++ b/app/chemvas/ui/scene/scene_flip_geometry.py
@@ -4,6 +4,7 @@
from PyQt6.QtCore import QPointF, QRectF
+from chemvas.features.selection import reflected_point, selection_transform_center
from chemvas.ui.annotations.state import ARROW_KINDS
if TYPE_CHECKING:
@@ -15,9 +16,7 @@
def flip_point(point: QPointF, center: QPointF, horizontal: bool) -> QPointF:
- if horizontal:
- return QPointF(center.x() - (point.x() - center.x()), point.y())
- return QPointF(point.x(), center.y() - (point.y() - center.y()))
+ return QPointF(*reflected_point(point, center, horizontal))
def bounds_from_points(points: list[QPointF]) -> QRectF | None:
@@ -84,9 +83,8 @@ def flip_center_for_selection(
continue
xs.extend([bounds.left(), bounds.right()])
ys.extend([bounds.top(), bounds.bottom()])
- if not xs or not ys:
- return None
- return QPointF((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
+ center = selection_transform_center(zip(xs, ys, strict=True))
+ return None if center is None else QPointF(*center)
__all__ = [
diff --git a/app/chemvas/ui/scene/scene_flip_state.py b/app/chemvas/ui/scene/scene_flip_state.py
index ae50ef68..8eddc08e 100644
--- a/app/chemvas/ui/scene/scene_flip_state.py
+++ b/app/chemvas/ui/scene/scene_flip_state.py
@@ -12,7 +12,9 @@
ts_bracket_from_state,
ts_bracket_to_state,
)
-from chemvas.features.annotations import flip_annotation
+from chemvas.domain.document.marks import mark_state_at_position
+from chemvas.domain.document.orbitals import Orbital
+from chemvas.features.annotations import flip_annotation, mirrored_box_position
from chemvas.ui.annotations.state import ARROW_KINDS
if TYPE_CHECKING:
@@ -49,16 +51,12 @@ def flip_scene_item_state(
if kind in {"note", "image"}:
rect = item.sceneBoundingRect()
if rect.isValid():
- if horizontal:
- after_state["x"] = before_state.get("x", 0.0) + 2 * (
- center.x() - rect.center().x()
- )
- after_state["y"] = before_state.get("y", 0.0)
- else:
- after_state["x"] = before_state.get("x", 0.0)
- after_state["y"] = before_state.get("y", 0.0) + 2 * (
- center.y() - rect.center().y()
- )
+ after_state["x"], after_state["y"] = mirrored_box_position(
+ (before_state.get("x", 0.0), before_state.get("y", 0.0)),
+ (rect.x(), rect.y(), rect.width(), rect.height()),
+ center=(center.x(), center.y()),
+ horizontal=horizontal,
+ )
else:
flipped = flip_point(
QPointF(before_state.get("x", 0.0), before_state.get("y", 0.0)),
@@ -74,26 +72,26 @@ def flip_scene_item_state(
center,
horizontal,
)
- after_state["x"] = flipped.x()
- after_state["y"] = flipped.y()
- atom_id = before_state.get("atom_id")
- if isinstance(atom_id, int):
- atom_position = transformed_atom_positions.get(atom_id)
- if atom_position is None:
- atom = atoms.get(atom_id)
- if atom is not None:
- atom_position = (atom.x, atom.y)
- if atom_position is not None:
- after_state["dx"] = flipped.x() - atom_position[0]
- after_state["dy"] = flipped.y() - atom_position[1]
- return after_state
+ return mark_state_at_position(
+ before_state,
+ (flipped.x(), flipped.y()),
+ transformed_atom_positions=transformed_atom_positions,
+ atoms=atoms,
+ )
if kind == "orbital":
center_state = before_state.get("center")
+ orbital = flip_annotation(
+ Orbital(
+ kind=str(before_state.get("orbital_kind", "s")),
+ center=center_state if center_state is not None else (0.0, 0.0),
+ rotation=float(before_state.get("rotation", 0.0)),
+ ),
+ center=(center.x(), center.y()),
+ horizontal=horizontal,
+ )
if center_state is not None:
- flipped = flip_point(QPointF(*center_state), center, horizontal)
- after_state["center"] = (flipped.x(), flipped.y())
- rotation = float(before_state.get("rotation", 0.0))
- after_state["rotation"] = 180.0 - rotation if horizontal else -rotation
+ after_state["center"] = orbital.center
+ after_state["rotation"] = orbital.rotation
return after_state
if kind == "shape":
return shape_to_state(
diff --git a/app/chemvas/ui/scene/scene_geometry.py b/app/chemvas/ui/scene/scene_geometry.py
index b1af7609..e0112ebc 100644
--- a/app/chemvas/ui/scene/scene_geometry.py
+++ b/app/chemvas/ui/scene/scene_geometry.py
@@ -15,17 +15,24 @@
)
from PyQt6.QtWidgets import QGraphicsTextItem
-from chemvas.domain.document import Bond
-from chemvas.features.graph import find_rings
-from chemvas.features.selection import project_point_3d, translate_projected_point_3d
-from chemvas.ui.canvas.canvas_geometry_logic import (
- line_rect_clip_t as line_rect_clip_t_helper,
+from chemvas.domain.document.perspective import (
+ bond_offset_unit,
+ current_atom_coords_3d,
+ project_point_3d,
+ ring_center_3d,
)
+from chemvas.features.graph import build_ring_edge_index, ring_atom_ids_for_bond
+from chemvas.features.rendering import line_normal
from chemvas.ui.canvas.canvas_geometry_logic import (
- ray_rect_exit_distance as ray_rect_exit_distance_helper,
+ glyph_clearance_radius,
+ glyph_contour_clip_t,
+ glyph_convex_hull,
+ mark_clearance,
+ mark_click_offset,
+ mark_target_distance,
)
from chemvas.ui.canvas.canvas_geometry_logic import (
- segment_intersection_t as segment_intersection_t_helper,
+ line_rect_clip_t as line_rect_clip_t_helper,
)
from chemvas.ui.canvas.graphics_items import AtomLabelItem
@@ -53,29 +60,13 @@ def _glyph_clearance_path(path: QPainterPath) -> QPainterPath:
result = QPainterPath()
result.setFillRule(Qt.FillRule.WindingFill)
scale = 64.0
- points = sorted(
- {
- (point.x() / scale, point.y() / scale)
- for polygon in path.toSubpathPolygons(QTransform.fromScale(scale, scale))
- for point in (polygon.at(i) for i in range(polygon.size()))
- }
+ hulls = glyph_convex_hull(
+ (point.x() / scale, point.y() / scale)
+ for polygon in path.toSubpathPolygons(QTransform.fromScale(scale, scale))
+ for point in (polygon.at(i) for i in range(polygon.size()))
)
- if len(points) < 3:
+ if not hulls:
return result
- hulls = []
- for ordered in (points, list(reversed(points))):
- half: list[tuple[float, float]] = []
- for point in ordered:
- while len(half) >= 2:
- a, b = half[-2:]
- cross = (b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (
- point[0] - a[0]
- )
- if cross > 0:
- break
- half.pop()
- half.append(point)
- hulls.extend(half[:-1])
result.addPolygon(QPolygonF([QPointF(x, y) for x, y in hulls]))
result.closeSubpath()
return result
@@ -93,10 +84,9 @@ def _prepare_glyph_clip_geometry(
) -> _GlyphClipGeometry:
original_path = path
path = _glyph_clearance_path(path)
- gap = max(0.2, stroke_width * 0.5)
# A disk enclosing a square cap also covers round/flat bond caps. Keep
# the small flattening allowance separate from the visible clearance.
- radius = stroke_width / math.sqrt(2.0) + gap + 0.01
+ radius = glyph_clearance_radius(stroke_width)
stroker = QPainterPathStroker()
stroker.setWidth(2.0 * radius)
stroker.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
@@ -133,100 +123,23 @@ def _glyph_line_clip_t(
if prepared is None:
prepared = _prepare_glyph_clip_geometry(path, stroke_width)
scale = 64.0
- transform = QTransform.fromScale(scale, scale)
- start, end = transform.map(p1), transform.map(p2)
- hits = []
- length = math.hypot(p2.x() - p1.x(), p2.y() - p1.y())
- ux, uy = (p2.x() - p1.x()) / length, (p2.y() - p1.y()) / length
- along = [ux * x + uy * y for x, y in offsets] or [0.0]
- across = [-uy * x + ux * y for x, y in offsets] or [0.0]
- low, high = min(across), max(across)
- for outline, polygons in prepared.outlines:
- if outline.contains(p1):
- hits.append(0.0)
- if outline.contains(p2):
- hits.append(1.0)
- for polygon in polygons:
- if offsets:
- # Inspect the entire band occupied by parallel strokes or a
- # filled strip, including ink between (not only on) its edges.
- points = [
- (
- (p.x() / scale - p1.x()) * ux + (p.y() / scale - p1.y()) * uy,
- -(p.x() / scale - p1.x()) * uy + (p.y() / scale - p1.y()) * ux,
- )
- for p in (polygon.at(i) for i in range(polygon.size()))
- ]
- xs = [x for x, y in points if low <= y <= high]
- for index in range(len(points) - 1):
- x0, y0 = points[index]
- x1, y1 = points[index + 1]
- if abs(y1 - y0) < 1e-12:
- continue
- for y in (low, high):
- ratio = (y - y0) / (y1 - y0)
- if 0 <= ratio <= 1:
- xs.append(x0 + ratio * (x1 - x0))
- if xs:
- first = (min(xs) - max(along)) / length
- last = (max(xs) - min(along)) / length
- if first <= 1 and last >= 0:
- hits.extend((max(0.0, first), min(1.0, last)))
- continue
- for index in range(polygon.size() - 1):
- left, right = polygon.at(index), polygon.at(index + 1)
- hit = segment_intersection_t_helper(
- _xy(start), _xy(end), _xy(left), _xy(right)
- )
- if hit is not None:
- hits.append(hit)
- return (min(hits), max(hits)) if hits else None
-
-
-def project_point_in_scene(
- point: tuple[float, float, float],
- *,
- bond_length_px: float,
- center_3d: tuple[float, float, float] | None,
- anchor_2d: tuple[float, float] | None,
-) -> tuple[float, float]:
- if center_3d is None:
- return point[0], point[1]
- return project_point_3d(
- point,
- bond_length_px=bond_length_px,
- center_3d=center_3d,
- anchor_2d=anchor_2d or (center_3d[0], center_3d[1]),
+ return glyph_contour_clip_t(
+ _xy(p1),
+ _xy(p2),
+ (
+ tuple(
+ (point.x() / scale, point.y() / scale)
+ for point in (polygon.at(i) for i in range(polygon.size()))
+ )
+ for _outline, polygons in prepared.outlines
+ for polygon in polygons
+ ),
+ offsets,
+ start_inside=any(outline.contains(p1) for outline, _ in prepared.outlines),
+ end_inside=any(outline.contains(p2) for outline, _ in prepared.outlines),
)
-def current_atom_coords_in_scene(
- atom_id: int,
- *,
- model: MoleculeModel,
- stored_coords: dict[int, tuple[float, float, float]],
- bond_length_px: float,
- center_3d: tuple[float, float, float] | None,
- anchor_2d: tuple[float, float] | None,
-) -> tuple[float, float, float] | None:
- atom = model.atoms.get(atom_id)
- if atom is None:
- return None
- coords = stored_coords.get(atom_id)
- if coords is not None:
- projected_x, projected_y = project_point_in_scene(
- coords,
- bond_length_px=bond_length_px,
- center_3d=center_3d,
- anchor_2d=anchor_2d,
- )
- if math.hypot(projected_x - atom.x, projected_y - atom.y) <= max(
- 1.0, bond_length_px * 0.15
- ):
- return coords
- return atom.x, atom.y, 0.0
-
-
class SceneGeometry:
"""Read-only molecular geometry shared by editing and document rendering."""
@@ -242,11 +155,13 @@ def __init__(self, context: SceneRenderContext) -> None:
self._rings_by_edge: dict[tuple[int, int], list[int]] = {}
def current_atom_coords_3d(self, atom_id: int) -> tuple[float, float, float] | None:
+ atom = self.context.model.atoms.get(atom_id)
+ if atom is None:
+ return None
rotation = self.context.state.rotation_state
- return current_atom_coords_in_scene(
- atom_id,
- model=self.context.model,
- stored_coords=self.context.state.atom_coords_3d_state.atom_coords_3d,
+ return current_atom_coords_3d(
+ (atom.x, atom.y),
+ self.context.state.atom_coords_3d_state.atom_coords_3d.get(atom_id),
bond_length_px=self.context.renderer.style.bond_length_px,
center_3d=rotation.projection_center_3d,
anchor_2d=rotation.projection_anchor_2d,
@@ -256,7 +171,7 @@ def project_point_3d(
self, point: tuple[float, float, float]
) -> tuple[float, float]:
rotation = self.context.state.rotation_state
- return project_point_in_scene(
+ return project_point_3d(
point,
bond_length_px=self.context.renderer.style.bond_length_px,
center_3d=rotation.projection_center_3d,
@@ -268,36 +183,17 @@ def bond_offset_unit_3d(self, a_id: int, b_id: int, target=None):
atom_b = self.context.model.atoms.get(b_id)
if atom_a is None or atom_b is None:
return None
- dx, dy = atom_b.x - atom_a.x, atom_b.y - atom_a.y
- length = math.hypot(dx, dy)
- if length < 1e-9:
- return None
- nx, ny = -dy / length, dx / length
- if target is not None:
- tx, ty = self.project_point_3d(target)
- if (
- nx * (tx - (atom_a.x + atom_b.x) * 0.5)
- + ny * (ty - (atom_a.y + atom_b.y) * 0.5)
- < 0
- ):
- nx, ny = -nx, -ny
- return nx, ny
+ rotation = self.context.state.rotation_state
+ return bond_offset_unit(
+ (atom_a.x, atom_a.y),
+ (atom_b.x, atom_b.y),
+ target,
+ bond_length_px=self.context.renderer.style.bond_length_px,
+ center_3d=rotation.projection_center_3d,
+ anchor_2d=rotation.projection_anchor_2d,
+ )
- @staticmethod
- def line_normal(x1: float, y1: float, x2: float, y2: float, target=None):
- dx, dy = x2 - x1, y2 - y1
- length = math.hypot(dx, dy)
- if length < 1e-9:
- return 0.0, 0.0
- nx, ny = -dy / length, dx / length
- if (
- target is not None
- and nx * (target.x() - (x1 + x2) * 0.5)
- + ny * (target.y() - (y1 + y2) * 0.5)
- < 0
- ):
- return -nx, -ny
- return nx, ny
+ line_normal = staticmethod(line_normal)
def _clip_label_line(self, item, p1, p2, width, offsets):
transform = item.sceneTransform()
@@ -318,13 +214,6 @@ def _clip_label_line(self, item, p1, p2, width, offsets):
p1 - origin, p2 - origin, path, width, offsets, prepared=prepared
)
- @staticmethod
- def _ring_contains_edge(atom_ids, bond) -> bool:
- return any(
- {atom_ids[index], atom_ids[(index + 1) % len(atom_ids)]} == {bond.a, bond.b}
- for index in range(len(atom_ids))
- )
-
def invalidate_ring_cache(self) -> None:
self._ring_model = None
self._ring_graph_neighbors = None
@@ -333,10 +222,6 @@ def invalidate_ring_cache(self) -> None:
def _ring_atom_ids_for_bond(self, bond) -> list[int] | None:
document = self.context.state.ring_state
- for record_id in document.order:
- atom_ids = list(document.records[record_id].atom_ids)
- if self._ring_contains_edge(atom_ids, bond):
- return atom_ids
# Coordinates remain live; only topology is cached. Neighbor-map identity
# also changes on graph reset, whose version counter restarts at zero.
model = self.context.model
@@ -346,21 +231,15 @@ def _ring_atom_ids_for_bond(self, bond) -> list[int] | None:
or self._ring_graph_neighbors is not graph.atom_neighbors
or self._ring_graph_version != graph.graph_version
):
- topology = {
- (min(edge.a, edge.b), max(edge.a, edge.b))
- for edge in model.bonds
- if edge is not None and edge.a in model.atoms and edge.b in model.atoms
- }
- rings = find_rings(Bond(a, b) for a, b in sorted(topology))
- self._rings_by_edge = {}
- for ring in rings:
- for index, a in enumerate(ring):
- b = ring[(index + 1) % len(ring)]
- self._rings_by_edge.setdefault((min(a, b), max(a, b)), ring)
+ self._rings_by_edge = build_ring_edge_index(model.atoms, model.bonds)
self._ring_model = model
self._ring_graph_neighbors = graph.atom_neighbors
self._ring_graph_version = graph.graph_version
- return self._rings_by_edge.get((min(bond.a, bond.b), max(bond.a, bond.b)))
+ return ring_atom_ids_for_bond(
+ bond,
+ (document.records[record_id].atom_ids for record_id in document.order),
+ self._rings_by_edge,
+ )
def _padded_label_rect(self, rect: QRectF) -> QRectF:
pad = max(0.05, self.context.renderer.style.bond_line_width * 0.05)
@@ -385,27 +264,18 @@ def ring_center_3d_for_bond(
self, bond, *, screen_delta: tuple[float, float] = (0.0, 0.0)
) -> tuple[float, float, float] | None:
ring_atom_ids = self._ring_atom_ids_for_bond(bond)
- if ring_atom_ids is not None:
- coords = []
- for atom_id in ring_atom_ids:
- coord = self.current_atom_coords_3d(atom_id)
- if coord is not None:
- if screen_delta != (0.0, 0.0):
- coord = translate_projected_point_3d(
- coord,
- *screen_delta,
- bond_length_px=self.context.renderer.style.bond_length_px,
- center_3d=self.context.state.rotation_state.projection_center_3d,
- )
- coords.append(coord)
- if len(coords) < 3:
- return None
- sum_x = sum(c[0] for c in coords)
- sum_y = sum(c[1] for c in coords)
- sum_z = sum(c[2] for c in coords)
- count = len(coords)
- return (sum_x / count, sum_y / count, sum_z / count)
- return None
+ if ring_atom_ids is None:
+ return None
+ return ring_center_3d(
+ (
+ coords
+ for coords in map(self.current_atom_coords_3d, ring_atom_ids)
+ if coords is not None
+ ),
+ screen_delta=screen_delta,
+ bond_length_px=self.context.renderer.style.bond_length_px,
+ center_3d=self.context.state.rotation_state.projection_center_3d,
+ )
def label_rect_for_atom(self, atom_id: int) -> QRectF | None:
item = self.context.state.atom_graphics_state.atom_items.get(atom_id)
@@ -467,25 +337,21 @@ def line_rect_clip_t(
return line_rect_clip_t_helper(_xy(p1), _xy(p2), _bounds(rect))
def mark_clearance_for_kind(self, kind: str) -> float:
- gap = max(0.6, self.context.renderer.style.bond_length_px * 0.05)
- if kind == "radical":
- radius = max(1.2, self.context.renderer.style.bond_line_width * 0.7)
- return radius + gap
- if kind in {"plus", "minus"}:
+ font_height = symbol_width = symbol_height = 0.0
+ if kind in {"plus", "minus", "circled_plus", "circled_minus"}:
metrics = QFontMetricsF(self.context.renderer.atom_font())
- rect = metrics.boundingRect("+" if kind == "plus" else "-")
- half_diagonal = math.hypot(rect.width(), rect.height()) * 0.5
- return max(half_diagonal, metrics.height() * 0.35) + gap
- if kind in {"circled_plus", "circled_minus"}:
- radius = max(
- 4.0, QFontMetricsF(self.context.renderer.atom_font()).height() * 0.26
- )
- return (
- radius
- + max(0.9, self.context.renderer.style.bond_line_width * 0.65)
- + gap
- )
- return gap
+ font_height = metrics.height()
+ if kind in {"plus", "minus"}:
+ rect = metrics.boundingRect("+" if kind == "plus" else "-")
+ symbol_width, symbol_height = rect.width(), rect.height()
+ return mark_clearance(
+ kind,
+ bond_length=self.context.renderer.style.bond_length_px,
+ line_width=self.context.renderer.style.bond_line_width,
+ font_height=font_height,
+ symbol_width=symbol_width,
+ symbol_height=symbol_height,
+ )
def mark_target_distance_for_atom(
self,
@@ -498,16 +364,13 @@ def mark_target_distance_for_atom(
label_rect = self.visible_label_rect_for_atom(atom_id)
if atom is None or label_rect is None:
return 0.0
- clearance = self.mark_clearance_for_kind(kind)
- expanded_rect = label_rect.adjusted(
- -clearance, -clearance, clearance, clearance
- )
- distance = ray_rect_exit_distance_helper(
+ return mark_target_distance(
(atom.x, atom.y),
- (direction_x, direction_y),
- _bounds(expanded_rect),
+ _bounds(label_rect),
+ self.mark_clearance_for_kind(kind),
+ direction_x,
+ direction_y,
)
- return 0.0 if distance is None else distance
def mark_offset_from_click(
self, atom_id: int, click_pos: QPointF, *, kind: str
@@ -515,20 +378,16 @@ def mark_offset_from_click(
atom = self.context.model.atoms.get(atom_id)
if atom is None:
return QPointF(0.0, 0.0)
- dx = click_pos.x() - atom.x
- dy = click_pos.y() - atom.y
- length = math.hypot(dx, dy)
- if length <= 1e-6:
- dx, dy = 1.0, -1.0
- length = math.hypot(dx, dy)
- direction_x, direction_y = dx / length, dy / length
- target = self.context.renderer.style.bond_length_px * 0.2
- label_target = self.mark_target_distance_for_atom(
- atom_id, direction_x, direction_y, kind
+ return QPointF(
+ *mark_click_offset(
+ (atom.x, atom.y),
+ _xy(click_pos),
+ bond_length=self.context.renderer.style.bond_length_px,
+ target_distance=lambda dx, dy: self.mark_target_distance_for_atom(
+ atom_id, dx, dy, kind
+ ),
+ )
)
- if label_target > target:
- target += (label_target - target) * 0.25
- return QPointF(direction_x * target, direction_y * target)
def trim_line_for_labels(
self,
@@ -633,4 +492,4 @@ def trim_line_for_labels(
return t0, t1
-__all__ = ["SceneGeometry", "current_atom_coords_in_scene", "project_point_in_scene"]
+__all__ = ["SceneGeometry"]
diff --git a/app/chemvas/ui/scene/scene_group_operations.py b/app/chemvas/ui/scene/scene_group_operations.py
index b7f01c4d..471c8384 100644
--- a/app/chemvas/ui/scene/scene_group_operations.py
+++ b/app/chemvas/ui/scene/scene_group_operations.py
@@ -2,13 +2,15 @@
from functools import wraps
-from chemvas.features.graph import (
- adjacency_for_bonds,
- connected_components_for_nodes,
- reachable_from,
+from chemvas.features.groups import (
+ GROUP_CONNECTION_MESSAGE,
+ GROUPABLE_STANDALONE_KINDS,
+ connection_allowed,
+ group_atoms_after_merge,
+ group_extensions,
+ grouping_plan,
)
from chemvas.ui.annotations.projections import group_projections
-from chemvas.ui.annotations.state import ARROW_KINDS
from chemvas.ui.canvas.canvas_atom_graphics_state import visible_atom_item_for
from chemvas.ui.canvas.canvas_group_state import (
group_ids_for_members_for,
@@ -34,70 +36,46 @@
selected_scene_notes_for,
)
from chemvas.ui.selection.selection_style_access import (
+ GROUP_BOX_PADDING_RATIO,
selection_indicator_rect_for_atom_for,
)
from chemvas.ui.transactions.document import document_transaction
-GROUPABLE_STANDALONE_KINDS = frozenset(
- {"note", "image", "ts_bracket", "shape", "orbital"}
-) | frozenset(ARROW_KINDS)
-
-GROUP_CONNECTION_MESSAGE = (
- "These structures belong to different groups. Select both structures, "
- "use Edit > Group, then retry the connection."
-)
-
def group_connection_allowed_for(
canvas, atom_ids: set[int], *, notify: bool = True
) -> bool:
- groups = canvas.runtime_state.group_state.groups
- if len(groups) < 2:
- return True
- connected = reachable_from(atom_ids, adjacency_for_bonds(canvas.model.bonds))
- if len(group_ids_for_members_for(canvas, connected, [])) < 2:
+ if connection_allowed(
+ canvas.runtime_state.group_state.groups, canvas.model.bonds, atom_ids
+ ):
return True
if notify:
notify_error_for(canvas, GROUP_CONNECTION_MESSAGE)
return False
+def _group_update_command(canvas, group_id: int, atom_ids: set[int]):
+ previous = canvas.runtime_state.group_state.groups[group_id]
+ return GroupSceneItemsCommand(
+ atom_ids=atom_ids,
+ item_ids=list(previous.item_ids),
+ absorbed=[(group_id, previous)],
+ group_id=group_id,
+ )
+
+
def group_extensions_for_added_bonds(canvas, bond_ids) -> list[GroupSceneItemsCommand]:
- """Plan same-id membership updates only for newly connected components."""
- groups = canvas.runtime_state.group_state.groups
- if not groups:
- return []
- bonds = canvas.model.bonds
- touched = {
- atom_id
- for bond_id in bond_ids
- if (bond := bonds[bond_id]) is not None
- for atom_id in (bond.a, bond.b)
- }
- if not touched:
- return []
- adjacency = adjacency_for_bonds(bonds)
- updates: dict[int, set[int]] = {}
- while touched:
- component = reachable_from({min(touched)}, adjacency)
- touched -= component
- owners = group_ids_for_members_for(canvas, component, [])
- if len(owners) > 1:
- # GUI connection entry points preflight this before drawing. Keep
- # direct recording callers fail-closed too; their mutation is undone.
- raise ValueError(GROUP_CONNECTION_MESSAGE)
- if owners:
- owner = next(iter(owners))
- updates.setdefault(owner, set(groups[owner].atom_ids)).update(component)
+ """Plan same-id membership updates only for newly connected components.
+
+ GUI connection entry points preflight this before drawing; direct recording
+ callers fail closed too, and their mutation is undone.
+ """
+ extensions = group_extensions(
+ canvas.runtime_state.group_state.groups, canvas.model.bonds, bond_ids
+ )
return [
- GroupSceneItemsCommand(
- atom_ids=atom_ids,
- item_ids=list(groups[group_id].item_ids),
- absorbed=[(group_id, groups[group_id])],
- group_id=group_id,
- )
- for group_id, atom_ids in sorted(updates.items())
- if atom_ids != groups[group_id].atom_ids
+ _group_update_command(canvas, group_id, atom_ids)
+ for group_id, atom_ids in extensions.items()
]
@@ -105,28 +83,13 @@ def group_updates_for_atom_merge(
canvas, survivor_id: int, removed_atom_ids: set[int]
) -> list[GroupSceneItemsCommand]:
"""Retain one group's identity after a preflighted overlapping-atom merge."""
- groups = canvas.runtime_state.group_state.groups
- if not groups or not removed_atom_ids:
- return []
- component = reachable_from({survivor_id}, adjacency_for_bonds(canvas.model.bonds))
- owners = group_ids_for_members_for(canvas, component | removed_atom_ids, [])
- if len(owners) > 1:
- raise ValueError(GROUP_CONNECTION_MESSAGE)
- if not owners:
- return []
- group_id = next(iter(owners))
- previous = groups[group_id]
- atom_ids = (previous.atom_ids - removed_atom_ids) | component
- if atom_ids == previous.atom_ids:
- return []
- return [
- GroupSceneItemsCommand(
- atom_ids=atom_ids,
- item_ids=list(previous.item_ids),
- absorbed=[(group_id, previous)],
- group_id=group_id,
- )
- ]
+ update = group_atoms_after_merge(
+ canvas.runtime_state.group_state.groups,
+ canvas.model.bonds,
+ survivor_id,
+ removed_atom_ids,
+ )
+ return [] if update is None else [_group_update_command(canvas, *update)]
def _atomic_group_change(operation):
@@ -188,71 +151,29 @@ def _selected_group_members_for(canvas) -> tuple[set[int], list]:
return atom_ids, items
-def _selection_unit_count_for(canvas, atom_ids: set[int], items: list) -> int:
- components = connected_components_for_nodes(
- atom_ids,
- adjacency_for_bonds(canvas.model.bonds),
- )
- return len(components) + len(items)
-
-
@_atomic_group_change
def group_selection_for(canvas) -> bool:
atom_ids, items = _selected_group_members_for(canvas)
- if not atom_ids and not items:
- return False
- # A persistent group owns whole molecules. Literal partial-atom selection
- # remains available for direct reshaping, but must not create a group whose
- # later drag stretches bonds to ungrouped atoms in the same molecule.
- adjacency = adjacency_for_bonds(canvas.model.bonds)
- atom_ids = reachable_from(atom_ids, adjacency)
state = canvas.runtime_state.group_state
- overlapping = group_ids_for_members_for(canvas, atom_ids, items)
- if not overlapping and _selection_unit_count_for(canvas, atom_ids, items) < 2:
- notify_error_for(
- canvas,
- "Group needs at least two objects: a molecule is one object. "
- "Select its caption or another object too, then use Edit > Group.",
+ try:
+ plan = grouping_plan(
+ state.groups,
+ canvas.model.bonds,
+ atom_ids,
+ [require_scene_record_id(item) for item in items],
)
+ except ValueError as error:
+ notify_error_for(canvas, str(error))
return False
- merged_atom_ids = set(atom_ids)
- merged_items = [require_scene_record_id(item) for item in items]
- merged_item_ids = set(merged_items)
- absorbed_ids: set[int] = set()
- # Older documents may contain partial-molecule groups. Only normalize those
- # explicitly absorbed by this Group action, including groups reached when
- # their remaining molecule atoms join the new group.
- while remaining := overlapping - absorbed_ids:
- for group_id in sorted(remaining):
- group = state.groups[group_id]
- merged_atom_ids |= group.atom_ids
- for member in group.item_ids:
- if member not in merged_item_ids:
- merged_item_ids.add(member)
- merged_items.append(member)
- absorbed_ids |= remaining
- merged_atom_ids = reachable_from(merged_atom_ids, adjacency)
- overlapping = {
- key
- for key, candidate in state.groups.items()
- if candidate.atom_ids & merged_atom_ids
- or set(candidate.item_ids) & set(merged_items)
- }
- if len(overlapping) == 1:
- # Selection adds nothing beyond the one group it overlaps: no-op.
- existing = state.groups[next(iter(overlapping))]
- existing_items = set(existing.item_ids)
- if merged_atom_ids <= existing.atom_ids and all(
- item in existing_items for item in merged_items
- ):
- return False
- absorbed = [(group_id, state.groups[group_id]) for group_id in sorted(overlapping)]
+ if plan is None:
+ return False
+ absorbed = [(group_id, state.groups[group_id]) for group_id in plan.absorbed]
command = GroupSceneItemsCommand(
- atom_ids=set(merged_atom_ids), item_ids=list(merged_items), absorbed=absorbed
+ atom_ids=set(plan.atom_ids), item_ids=list(plan.item_ids), absorbed=absorbed
)
for absorbed_id, _ in absorbed:
- canvas.runtime_state.group_state.groups.pop(absorbed_id, None)
- command.group_id = register_group_for(canvas, merged_atom_ids, merged_items)
+ state.groups.pop(absorbed_id, None)
+ command.group_id = register_group_for(canvas, plan.atom_ids, plan.item_ids)
_push_group_command(canvas, command)
canvas.services.selection.expand_selection_to_groups()
canvas.services.selection.update_selection_outline()
@@ -404,7 +325,7 @@ def selected_group_rects_for(canvas) -> list:
if not group_ids:
return []
live_atom_ids = set(canvas.model.atoms)
- pad = canvas.renderer.style.bond_length_px * 0.18
+ pad = canvas.renderer.style.bond_length_px * GROUP_BOX_PADDING_RATIO
rects = []
for group_id in sorted(group_ids):
group = state.groups[group_id]
diff --git a/app/chemvas/ui/scene/scene_rotation_state.py b/app/chemvas/ui/scene/scene_rotation_state.py
index 492917e1..2d272f36 100644
--- a/app/chemvas/ui/scene/scene_rotation_state.py
+++ b/app/chemvas/ui/scene/scene_rotation_state.py
@@ -13,7 +13,11 @@
ts_bracket_from_state,
ts_bracket_to_state,
)
-from chemvas.features.annotations import rotate_annotation
+from chemvas.domain.document.marks import mark_state_at_position
+from chemvas.domain.document.notes import Note
+from chemvas.domain.document.orbitals import Orbital
+from chemvas.features.annotations import orbited_box_position, rotate_annotation
+from chemvas.features.selection import rotated_point_coordinates
from chemvas.ui.annotations.state import ARROW_KINDS
if TYPE_CHECKING:
@@ -25,14 +29,7 @@
def rotated_point(point: QPointF, center: QPointF, angle_radians: float) -> QPointF:
- cos_a = math.cos(angle_radians)
- sin_a = math.sin(angle_radians)
- dx = point.x() - center.x()
- dy = point.y() - center.y()
- return QPointF(
- center.x() + dx * cos_a - dy * sin_a,
- center.y() + dx * sin_a + dy * cos_a,
- )
+ return QPointF(*rotated_point_coordinates(point, center, angle_radians))
def rotate_scene_item_state(
@@ -59,32 +56,30 @@ def rotate_scene_item_state(
]
return after_state
if kind == "note":
- anchor = rotated_point(
- QPointF(before_state.get("x", 0.0), before_state.get("y", 0.0)),
- center,
- angle_radians,
+ note = rotate_annotation(
+ Note(
+ x=float(before_state.get("x", 0.0)),
+ y=float(before_state.get("y", 0.0)),
+ rotation=float(before_state.get("rotation", 0.0)),
+ ),
+ center=(center.x(), center.y()),
+ angle_degrees=angle_degrees,
)
- after_state["x"] = anchor.x()
- after_state["y"] = anchor.y()
- after_state["rotation"] = (
- float(before_state.get("rotation", 0.0)) + angle_degrees
- ) % 360.0
+ after_state["x"] = note.x
+ after_state["y"] = note.y
+ after_state["rotation"] = note.rotation
return after_state
if kind == "image":
rect = item.boundingRect()
if rect.isValid():
# Image pixels stay upright: orbit the block's center around the
- # pivot and carry the anchor along by the same offset. Use the captured
- # anchor, not the live scene center left by a previous preview frame.
- before_center = rect.center() + QPointF(
- before_state.get("x", 0.0), before_state.get("y", 0.0)
- )
- rotated_center = rotated_point(before_center, center, angle_radians)
- after_state["x"] = (
- before_state.get("x", 0.0) + rotated_center.x() - before_center.x()
- )
- after_state["y"] = (
- before_state.get("y", 0.0) + rotated_center.y() - before_center.y()
+ # pivot. Use the captured anchor, not the live scene center left by a
+ # previous preview frame.
+ after_state["x"], after_state["y"] = orbited_box_position(
+ (before_state.get("x", 0.0), before_state.get("y", 0.0)),
+ (rect.width(), rect.height()),
+ center=(center.x(), center.y()),
+ angle_degrees=angle_degrees,
)
else:
rotated = rotated_point(
@@ -101,27 +96,26 @@ def rotate_scene_item_state(
center,
angle_radians,
)
- after_state["x"] = rotated.x()
- after_state["y"] = rotated.y()
- atom_id = before_state.get("atom_id")
- if isinstance(atom_id, int):
- atom_position = transformed_atom_positions.get(atom_id)
- if atom_position is None:
- atom = atoms.get(atom_id)
- if atom is not None:
- atom_position = (atom.x, atom.y)
- if atom_position is not None:
- after_state["dx"] = rotated.x() - atom_position[0]
- after_state["dy"] = rotated.y() - atom_position[1]
- return after_state
+ return mark_state_at_position(
+ before_state,
+ (rotated.x(), rotated.y()),
+ transformed_atom_positions=transformed_atom_positions,
+ atoms=atoms,
+ )
if kind == "orbital":
center_state = before_state.get("center")
+ orbital = rotate_annotation(
+ Orbital(
+ kind=str(before_state.get("orbital_kind", "s")),
+ center=center_state if center_state is not None else (0.0, 0.0),
+ rotation=float(before_state.get("rotation", 0.0)),
+ ),
+ center=(center.x(), center.y()),
+ angle_degrees=angle_degrees,
+ )
if center_state is not None:
- rotated = rotated_point(QPointF(*center_state), center, angle_radians)
- after_state["center"] = (rotated.x(), rotated.y())
- after_state["rotation"] = (
- float(before_state.get("rotation", 0.0)) + angle_degrees
- ) % 360.0
+ after_state["center"] = orbital.center
+ after_state["rotation"] = orbital.rotation
return after_state
if kind == "shape":
return shape_to_state(
diff --git a/app/chemvas/ui/scene/scene_transform_controller.py b/app/chemvas/ui/scene/scene_transform_controller.py
index 49adb09d..61f8e7e5 100644
--- a/app/chemvas/ui/scene/scene_transform_controller.py
+++ b/app/chemvas/ui/scene/scene_transform_controller.py
@@ -10,7 +10,7 @@
from chemvas.core.model_commands import SetAtomPositionsCommand
from chemvas.domain.document import VALID_EQUILIBRIUM_KINDS
from chemvas.features.rendering import refresh_bond_graphics
-from chemvas.features.selection import rotated_atom_positions
+from chemvas.features.selection import rotated_atom_positions, rotation_drag_angle
from chemvas.ui.annotations.state import (
ARROW_KINDS,
bond_state_dict,
@@ -25,7 +25,14 @@
SetSceneGeometryCommand,
UpdateSceneItemCommand,
)
-from chemvas.ui.scene.scene_align_logic import align_deltas, distribute_deltas
+from chemvas.ui.scene.scene_align_logic import (
+ AlignObject as _AlignObject,
+)
+from chemvas.ui.scene.scene_align_logic import (
+ align_deltas,
+ alignment_objects,
+ distribute_deltas,
+)
from chemvas.ui.scene.scene_flip_geometry import (
bounds_from_points as bounds_from_points_logic,
)
@@ -56,7 +63,6 @@
apply_component_flip_transform,
apply_standalone_flip_transform,
)
-from chemvas.ui.selection.selection_handles import rotation_drag_angle
from chemvas.ui.selection.selection_queries import (
independent_selection_items,
selected_atom_ids_for_transform_for,
@@ -71,19 +77,6 @@
from chemvas.ui.canvas.canvas_view import CanvasView
-@dataclass(frozen=True, slots=True)
-class _AlignObject:
- """One thing Align/Distribute moves as a unit.
-
- A whole molecule (every atom of a structure that has a selected atom), a
- standalone scene item, or a group carrying both.
- """
-
- rect: QRectF
- atom_ids: frozenset[int]
- items: tuple[object, ...]
-
-
ROTATION_STATE_ITEM_KINDS = ARROW_KINDS | {
"orbital",
"mark",
@@ -491,43 +484,12 @@ def _alignment_objects(self) -> list[_AlignObject]:
)
if component & selected_atoms
]
- objects: list[_AlignObject] = []
- claimed_atoms: set[int] = set()
- claimed_items: set[int] = set()
- # A group is one object: its structures and items keep their layout.
- for group in self.canvas.runtime_state.group_state.groups.values():
- group_atoms: set[int] = set()
- for structure in structures:
- if structure & group.atom_ids:
- group_atoms |= structure
- group_items = [
- item
- for item in items
- if require_scene_record_id(item) in group.item_ids
- ]
- if not group_atoms and not group_items:
- continue
- rect = self._object_rect(group_atoms, group_items)
- if rect is None:
- continue
- objects.append(
- _AlignObject(rect, frozenset(group_atoms), tuple(group_items))
- )
- claimed_atoms |= group_atoms
- claimed_items |= {id(item) for item in group_items}
- for structure in structures:
- if structure & claimed_atoms:
- continue
- rect = self._object_rect(structure, [])
- if rect is not None:
- objects.append(_AlignObject(rect, frozenset(structure), ()))
- for item in items:
- if id(item) in claimed_items:
- continue
- rect = item.sceneBoundingRect()
- if rect.isValid():
- objects.append(_AlignObject(rect, frozenset(), (item,)))
- return objects
+ return alignment_objects(
+ structures,
+ items,
+ groups=self.canvas.runtime_state.group_state.groups.values(),
+ object_rect=self._object_rect,
+ )
def _apply_object_deltas(
self, objects: list[_AlignObject], deltas: list[tuple[float, float]]
diff --git a/app/chemvas/ui/scene/stacking_actions.py b/app/chemvas/ui/scene/stacking_actions.py
index f4426f98..65c0fb91 100644
--- a/app/chemvas/ui/scene/stacking_actions.py
+++ b/app/chemvas/ui/scene/stacking_actions.py
@@ -4,44 +4,55 @@
from typing import TYPE_CHECKING
-from PyQt6.QtWidgets import QMessageBox
-
-from chemvas.ui.annotations.state import scene_item_state_for
-from chemvas.ui.canvas.canvas_document_state import document_item_lists_for
-from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
-from chemvas.ui.history.history_commands import (
- SetSceneGeometryCommand,
- UpdateSceneItemCommand,
-)
-from chemvas.ui.transactions.document import document_transaction
-from chemvas.ui.window.main_window_ports import active_canvas_for_window
-
if TYPE_CHECKING:
from chemvas.ui.window.main_window_like import MainWindowLike
-def stack_selection(canvas, *, front: bool) -> bool:
- lists = document_item_lists_for(canvas)
+def stacked_depths(
+ depths: list[float], selected: set[int], *, front: bool
+) -> list[tuple[int, float]]:
+ """Original stable, bounded stacking bands for selected document objects."""
# Stable sorting retains the document order when default depths are equal.
- objects = sorted(
- [*lists["images"], *lists["shapes"]], key=lambda item: item.zValue()
- )
- selected = [item for item in objects if item.isSelected()]
- if not selected:
- return False
+ objects = sorted(range(len(depths)), key=depths.__getitem__)
+ chosen = [index for index in objects if index in selected]
+ if not chosen:
+ return []
remaining = [
- item
- for item in objects
- if item not in selected
- and (item.zValue() > 3.0 if front else item.zValue() < -10.0)
+ index
+ for index in objects
+ if index not in selected
+ and (depths[index] > 3.0 if front else depths[index] < -10.0)
]
- ordered = [*remaining, *selected] if front else [*selected, *remaining]
+ ordered = [*remaining, *chosen] if front else [*chosen, *remaining]
+ # Bounded bands sit beyond native content (-10 .. 3), below UI overlays.
+ # Reindex the band so repeated commands cannot exhaust depth precision.
+ return [
+ (item, (4.0 if front else -12.0) + index / len(ordered))
+ for index, item in enumerate(ordered)
+ ]
+
+
+def stack_selection(canvas, *, front: bool) -> bool:
+ from chemvas.ui.annotations.state import scene_item_state_for
+ from chemvas.ui.canvas.canvas_document_state import document_item_lists_for
+ from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
+ from chemvas.ui.history.history_commands import (
+ SetSceneGeometryCommand,
+ UpdateSceneItemCommand,
+ )
+ from chemvas.ui.transactions.document import document_transaction
+
+ lists = document_item_lists_for(canvas)
+ objects = [*lists["images"], *lists["shapes"]]
+ depths = stacked_depths(
+ [item.zValue() for item in objects],
+ {index for index, item in enumerate(objects) if item.isSelected()},
+ front=front,
+ )
commands = []
- for index, item in enumerate(ordered):
+ for index, z in depths:
+ item = objects[index]
before = scene_item_state_for(canvas, item)
- # Bounded bands sit beyond native content (-10 .. 3), below UI overlays.
- # Reindex the band so repeated commands cannot exhaust depth precision.
- z = (4.0 if front else -12.0) + index / len(ordered)
after = {**before, "z": z}
if before != after:
commands.append(
@@ -58,6 +69,10 @@ def stack_selection(canvas, *, front: bool) -> bool:
def stack_selection_for_window(window: MainWindowLike, *, front: bool) -> None:
+ from PyQt6.QtWidgets import QMessageBox
+
+ from chemvas.ui.window.main_window_ports import active_canvas_for_window
+
canvas = active_canvas_for_window(window)
try:
stack_selection(canvas, front=front)
diff --git a/app/chemvas/ui/selection/select_tool.py b/app/chemvas/ui/selection/select_tool.py
index 6524eb9c..b92e0f36 100644
--- a/app/chemvas/ui/selection/select_tool.py
+++ b/app/chemvas/ui/selection/select_tool.py
@@ -7,18 +7,19 @@
from PyQt6.QtWidgets import QApplication, QGraphicsItem, QGraphicsView
from chemvas.domain.document import VALID_ARROW_KINDS, VALID_CURVED_ARROW_KINDS
-from chemvas.features.selection import SelectionPressContext, plan_selection_press
+from chemvas.features.selection import (
+ DIRECT_SELECT_OBJECT_KINDS,
+ ROTATION_SNAP_STEP_DEGREES,
+ SelectionPressContext,
+ plan_selection_press,
+)
from chemvas.ui.annotations.state import scene_item_state_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
from chemvas.ui.history.history_commands import UpdateSceneItemCommand
from chemvas.ui.selection.selection_drag_tool import SelectionDragMixin
-from chemvas.ui.selection.selection_handles import ROTATION_HANDLE_TYPE
from chemvas.ui.selection.selection_queries import selection_snapshot_for
from chemvas.ui.tools.tool_base import Tool
-
-# Holding Shift while turning the rotation handle snaps the sweep to this
-# many degrees, so a scheme can be squared up without typing an angle.
-ROTATION_SNAP_STEP_DEGREES = 15.0
+from chemvas.ui.window.main_window_config import ROTATION_HANDLE_TYPE
class SelectTool(SelectionDragMixin, Tool):
@@ -314,8 +315,7 @@ def on_mouse_press(self, event) -> bool:
snapshot = selection_snapshot_for(self.canvas)
if (
item is not None
- and item.data(0)
- in {"note", "shape", "image", "mark", "orbital", "ts_bracket"}
+ and item.data(0) in DIRECT_SELECT_OBJECT_KINDS
and (snapshot is None or item not in snapshot.selection_items)
):
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
diff --git a/app/chemvas/ui/selection/selection_controller.py b/app/chemvas/ui/selection/selection_controller.py
index 0e2ca8b7..b77ec624 100644
--- a/app/chemvas/ui/selection/selection_controller.py
+++ b/app/chemvas/ui/selection/selection_controller.py
@@ -26,7 +26,10 @@
from chemvas.ui.canvas.canvas_group_state import group_ids_for_members_for
from chemvas.ui.canvas.canvas_scene_items_state import require_scene_record_id
from chemvas.ui.canvas.graphics_items import NoSelectRectItem
-from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
+from chemvas.ui.canvas.pick_radius_access import (
+ STRUCTURE_BOND_PICK_RADIUS_RATIO,
+ atom_pick_radius_for,
+)
from chemvas.ui.scene.scene_group_operations import (
_group_has_scene_members,
_is_groupable_standalone_item,
@@ -281,7 +284,8 @@ def preferred_structure_hit_at_scene_pos(self, pos: QPointF) -> StructureHit | N
if bond_hit is not None
else None,
atom_pick_radius=atom_pick_radius_for(self.canvas),
- bond_pick_radius=self.canvas.renderer.style.bond_length_px * 0.528,
+ bond_pick_radius=self.canvas.renderer.style.bond_length_px
+ * STRUCTURE_BOND_PICK_RADIUS_RATIO,
)
if preferred_hit is not None:
preferred_item = self.structure_item_for_hit(preferred_hit)
diff --git a/app/chemvas/ui/selection/selection_handles.py b/app/chemvas/ui/selection/selection_handles.py
index 51c1afd1..63d8ee78 100644
--- a/app/chemvas/ui/selection/selection_handles.py
+++ b/app/chemvas/ui/selection/selection_handles.py
@@ -1,7 +1,5 @@
from __future__ import annotations
-import math
-
from PyQt6.QtCore import QPointF, QRectF
from PyQt6.QtGui import QBrush, QColor, QPainterPath, QPen
from PyQt6.QtWidgets import (
@@ -11,22 +9,31 @@
QGraphicsPathItem,
)
-# The accent a handle is outlined with, and the fill of one that has taken
-# hold of another item's endpoint.
-HANDLE_ACCENT_COLOR = "#0d9488"
-# Handles are an input affordance, so their size is a distance on screen
-# rather than in the document: a corner or endpoint handle is this wide at
-# any zoom, and an edge-midpoint resize handle is the smaller one.
-HANDLE_SCREEN_PX = 8.0
-EDGE_HANDLE_SCREEN_PX = 6.0
-# The rotation knob sits this far above its selection frame, on a stem.
-ROTATION_HANDLE_STEM_PX = 14.0
-ROTATION_HANDLE_TYPE = "selection_rotate"
+from chemvas.features.rendering import (
+ clamp_curved_midpoint as clamp_curved_midpoint_coordinates,
+)
+from chemvas.features.rendering import (
+ control_from_midpoint as control_from_midpoint_coordinates,
+)
+from chemvas.features.rendering import (
+ curved_midpoint as curved_midpoint_coordinates,
+)
+from chemvas.features.selection import (
+ orbital_handle_positions as orbital_handle_coordinates,
+)
+from chemvas.ui.annotations.shape_geometry import (
+ EDGE_HANDLE_SCREEN_PX,
+ resized_shape_bounds,
+ shape_handle_positions,
+)
+from chemvas.ui.window.main_window_config import (
+ HANDLE_ACCENT_COLOR,
+ HANDLE_SCREEN_PX,
+ ROTATION_HANDLE_STEM_PX,
+ ROTATION_HANDLE_TYPE,
+)
_EDGE_HANDLE_TYPES = frozenset({"shape_n", "shape_e", "shape_s", "shape_w"})
-# A curved arrow's control handle keeps the curve's midpoint within this
-# fraction of its chord from the chord's midpoint.
-_CURVE_MIDPOINT_REACH_RATIO = 0.8
def _style_handle(handle: QAbstractGraphicsShapeItem, handle_type: str) -> None:
@@ -88,196 +95,52 @@ def mark_handle_snapped(handle: QAbstractGraphicsShapeItem) -> None:
handle.setBrush(QBrush(QColor(HANDLE_ACCENT_COLOR)))
-def rotation_drag_angle(
- center: QPointF,
- start: QPointF,
- pos: QPointF,
- *,
- snap_step: float | None = None,
-) -> float:
- """Degrees the pointer has swept around ``center`` since ``start``.
-
- Positive is clockwise on screen (y grows downward). ``snap_step``
- rounds the sweep to that many degrees, for a Shift-constrained drag.
- """
- start_angle = math.atan2(start.y() - center.y(), start.x() - center.x())
- angle = math.atan2(pos.y() - center.y(), pos.x() - center.x())
- sweep = math.degrees(angle - start_angle)
- sweep = (sweep + 180.0) % 360.0 - 180.0
- if snap_step:
- sweep = round(sweep / snap_step) * snap_step
- return sweep
-
-
-def selection_frame_applies(atom_count: int, rotatable_item_count: int) -> bool:
- """Whether a selection gets a frame with a rotation handle.
-
- Rotation only means something for two or more atoms, or for an item
- that turns about its own centre; a lone atom has nothing to rotate.
- """
- return atom_count >= 2 or rotatable_item_count >= 1
-
-
def shape_resize_handle_positions(rect: QRectF) -> list[tuple[str, QPointF]]:
"""Eight resize handles (corners + edge midpoints) around ``rect``."""
bounds = QRectF(rect).normalized()
- left, top, right, bottom = (
- bounds.left(),
- bounds.top(),
- bounds.right(),
- bounds.bottom(),
- )
- cx, cy = bounds.center().x(), bounds.center().y()
return [
- ("shape_nw", QPointF(left, top)),
- ("shape_n", QPointF(cx, top)),
- ("shape_ne", QPointF(right, top)),
- ("shape_e", QPointF(right, cy)),
- ("shape_se", QPointF(right, bottom)),
- ("shape_s", QPointF(cx, bottom)),
- ("shape_sw", QPointF(left, bottom)),
- ("shape_w", QPointF(left, cy)),
+ (name, QPointF(*point))
+ for name, point in shape_handle_positions(
+ (bounds.left(), bounds.top(), bounds.right(), bounds.bottom())
+ )
]
def resized_shape_rect(
rect: QRectF, anchor: str, pos: QPointF, *, min_size: float = 8.0
) -> QRectF:
- """Return ``rect`` with the edge/corner named by ``anchor`` moved to ``pos``."""
+ """Materialize the shared original resize calculation as a Qt rectangle."""
bounds = QRectF(rect).normalized()
- left, top, right, bottom = (
- bounds.left(),
- bounds.top(),
- bounds.right(),
- bounds.bottom(),
+ left, top, right, bottom = resized_shape_bounds(
+ (bounds.left(), bounds.top(), bounds.right(), bounds.bottom()),
+ anchor,
+ (pos.x(), pos.y()),
+ min_size=min_size,
)
- direction = anchor.removeprefix("shape_")
- if "w" in direction:
- left = min(pos.x(), right - min_size)
- if "e" in direction:
- right = max(pos.x(), left + min_size)
- if "n" in direction:
- top = min(pos.y(), bottom - min_size)
- if "s" in direction:
- bottom = max(pos.y(), top + min_size)
return QRectF(QPointF(left, top), QPointF(right, bottom)).normalized()
def orbital_handle_positions(
center: QPointF, base_dist: float
) -> tuple[QPointF, QPointF]:
- return (
- QPointF(center.x() + base_dist, center.y()),
- QPointF(center.x(), center.y() - base_dist),
- )
-
-
-def orbital_scale_factor(
- center: QPointF,
- pos: QPointF,
- base_dist: float,
- *,
- minimum_scale: float = 0.2,
-) -> float:
- safe_base_dist = max(float(base_dist), 1e-6)
- dist = math.hypot(pos.x() - center.x(), pos.y() - center.y())
- return max(minimum_scale, dist / safe_base_dist)
-
-
-def orbital_rotation_angle(
- center: QPointF,
- pos: QPointF,
- *,
- snap_enabled: bool,
- snap_step: int,
-) -> float:
- angle = math.degrees(math.atan2(pos.y() - center.y(), pos.x() - center.x()))
- if snap_enabled:
- step = max(1, int(snap_step))
- angle = round(angle / step) * step
- return angle
-
-
-def default_curved_control(start: QPointF, end: QPointF) -> QPointF:
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- length = math.hypot(dx, dy) or 1.0
- nx = -dy / length
- ny = dx / length
- return QPointF(
- start.x() + dx * 0.5 + nx * length * 0.3,
- start.y() + dy * 0.5 + ny * length * 0.3,
- )
+ scale, rotate = orbital_handle_coordinates((center.x(), center.y()), base_dist)
+ return QPointF(*scale), QPointF(*rotate)
def curved_midpoint(start: QPointF, control: QPointF, end: QPointF) -> QPointF:
return QPointF(
- 0.25 * start.x() + 0.5 * control.x() + 0.25 * end.x(),
- 0.25 * start.y() + 0.5 * control.y() + 0.25 * end.y(),
+ *curved_midpoint_coordinates(
+ (start.x(), start.y()), (control.x(), control.y()), (end.x(), end.y())
+ )
)
def control_from_midpoint(start: QPointF, end: QPointF, mid: QPointF) -> QPointF:
return QPointF(
- 2.0 * mid.x() - 0.5 * (start.x() + end.x()),
- 2.0 * mid.y() - 0.5 * (start.y() + end.y()),
- )
-
-
-def control_with_moved_end(
- anchor: QPointF, pressed_end: QPointF, moved_end: QPointF, control: QPointF
-) -> QPointF:
- """``control`` carried along as a chord's free end moves about ``anchor``.
-
- The chord from ``anchor`` to ``pressed_end`` turns and scales onto the
- chord to ``moved_end``, and the control turns and scales with it, so the
- curve keeps its shape. An unmoved end returns ``control`` bit for bit. A
- chord of zero length has no direction to turn, so its control stays put.
-
- A file can hold a bulge many times its chord, and scaling that with a
- growing chord would throw the control out by the length ratio. So the
- carried control reaches from the chord's midpoint no further than the
- larger of the reach the control handle allows on the new chord and the
- reach at the press. Every curve the control handle can draw keeps its shape.
- """
- pressed_x = pressed_end.x() - anchor.x()
- pressed_y = pressed_end.y() - anchor.y()
- length_sq = pressed_x * pressed_x + pressed_y * pressed_y
- if length_sq <= 0.0:
- return QPointF(control)
- moved_x = moved_end.x() - anchor.x()
- moved_y = moved_end.y() - anchor.y()
- # moved / pressed as complex numbers: the turn, scaled by the length ratio.
- # For an unmoved end the first numerator repeats ``length_sq`` exactly and
- # the second cancels to zero.
- scaled_cos = (moved_x * pressed_x + moved_y * pressed_y) / length_sq
- scaled_sin = (moved_y * pressed_x - moved_x * pressed_y) / length_sq
- offset_x = control.x() - anchor.x()
- offset_y = control.y() - anchor.y()
- # Add the change to ``control`` instead of rebuilding it from ``anchor``:
- # anchor + (control - anchor) can round away from control.
- carried = QPointF(
- control.x() + (scaled_cos - 1.0) * offset_x - scaled_sin * offset_y,
- control.y() + scaled_sin * offset_x + (scaled_cos - 1.0) * offset_y,
+ *control_from_midpoint_coordinates(
+ (start.x(), start.y()), (end.x(), end.y()), (mid.x(), mid.y())
+ )
)
- # The curve's midpoint lies halfway between the chord's midpoint and the
- # control, so the control may reach twice as far as the midpoint. An
- # unmoved end repeats the press-time reach bit for bit and is not limited.
- moved_mid = _chord_midpoint(anchor, moved_end)
- reach = carried - moved_mid
- reach_length = math.hypot(reach.x(), reach.y())
- pressed_reach = control - _chord_midpoint(anchor, pressed_end)
- reach_limit = max(
- 2.0 * _CURVE_MIDPOINT_REACH_RATIO * math.hypot(moved_x, moved_y),
- math.hypot(pressed_reach.x(), pressed_reach.y()),
- )
- if reach_length <= reach_limit:
- return carried
- return moved_mid + reach * (reach_limit / reach_length)
-
-
-def _chord_midpoint(start: QPointF, end: QPointF) -> QPointF:
- return QPointF((start.x() + end.x()) / 2.0, (start.y() + end.y()) / 2.0)
def clamp_curved_midpoint(
@@ -288,40 +151,25 @@ def clamp_curved_midpoint(
snap_enabled: bool,
snap_distance: float | None,
) -> QPointF:
- chord_mid = QPointF((start.x() + end.x()) / 2.0, (start.y() + end.y()) / 2.0)
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- length = math.hypot(dx, dy) or 1.0
- nx = -dy / length
- ny = dx / length
- v = QPointF(mid.x() - chord_mid.x(), mid.y() - chord_mid.y())
- offset = v.x() * nx + v.y() * ny
- if snap_enabled and snap_distance is not None and snap_distance > 0:
- offset = round(offset / snap_distance) * snap_distance
- max_offset = length * _CURVE_MIDPOINT_REACH_RATIO
- offset = max(-max_offset, min(max_offset, offset))
- return QPointF(chord_mid.x() + nx * offset, chord_mid.y() + ny * offset)
+ return QPointF(
+ *clamp_curved_midpoint_coordinates(
+ (start.x(), start.y()),
+ (end.x(), end.y()),
+ (mid.x(), mid.y()),
+ snap_enabled=snap_enabled,
+ snap_distance=snap_distance,
+ )
+ )
__all__ = [
- "EDGE_HANDLE_SCREEN_PX",
- "HANDLE_ACCENT_COLOR",
- "HANDLE_SCREEN_PX",
- "ROTATION_HANDLE_STEM_PX",
- "ROTATION_HANDLE_TYPE",
"clamp_curved_midpoint",
"control_from_midpoint",
- "control_with_moved_end",
"create_handle_item",
"create_rotation_handle_item",
"curved_midpoint",
- "default_curved_control",
"mark_handle_snapped",
"orbital_handle_positions",
- "orbital_rotation_angle",
- "orbital_scale_factor",
"resized_shape_rect",
- "rotation_drag_angle",
- "selection_frame_applies",
"shape_resize_handle_positions",
]
diff --git a/app/chemvas/ui/selection/selection_outline_items.py b/app/chemvas/ui/selection/selection_outline_items.py
index 2d9b599f..5d6ef58a 100644
--- a/app/chemvas/ui/selection/selection_outline_items.py
+++ b/app/chemvas/ui/selection/selection_outline_items.py
@@ -7,6 +7,12 @@
from chemvas.ui.canvas.graphics_items import NoSelectEllipseItem, NoSelectPathItem
from chemvas.ui.selection.selection_handles import create_rotation_handle_item
+from chemvas.ui.selection.selection_style_access import (
+ GROUP_BOX_SCREEN_PX,
+ SELECTION_OUTLINE_SCREEN_PX,
+ group_box_corner_radius,
+)
+from chemvas.ui.window.main_window_config import SELECTION_FRAME_RADIUS
if TYPE_CHECKING:
from PyQt6.QtWidgets import QGraphicsPathItem
@@ -14,7 +20,6 @@
# Every selection mark is the same thin line in the accent colour, drawn at
# this width on screen regardless of zoom, so a selection reads as one thing
# whether it is a structure, an arrow, a note or a group.
-SELECTION_OUTLINE_SCREEN_PX = 1.5
def selection_outline_pen(color: QColor) -> QPen:
@@ -29,14 +34,15 @@ def selection_group_outline_item(rect: QRectF, color: QColor) -> NoSelectPathIte
# A group box is dashed: it says "these move as a unit", which is a
# different fact from the solid selection frame around what is selected.
path = QPainterPath()
- corner = min(6.0, min(rect.width(), rect.height()) / 4.0)
+ corner = group_box_corner_radius(rect.width(), rect.height())
path.addRoundedRect(rect, corner, corner)
outline = NoSelectPathItem(path)
outline.setData(0, "selection_outline")
outline.setData(2, {"kind": "group"})
outline.setZValue(20)
pen = selection_outline_pen(color)
- pen.setWidthF(1.0)
+ pen.setWidthF(GROUP_BOX_SCREEN_PX)
+ # Qt's DashLine is GROUP_BOX_DASH_PATTERN, which the browser draws.
pen.setStyle(Qt.PenStyle.DashLine)
outline.setPen(pen)
outline.setBrush(QBrush(Qt.BrushStyle.NoBrush))
@@ -79,7 +85,7 @@ def selection_frame_outline_items(
selection; the knob alone is a handle, so the select tool can grip it.
"""
path = QPainterPath()
- path.addRoundedRect(rect, 2.0, 2.0)
+ path.addRoundedRect(rect, SELECTION_FRAME_RADIUS, SELECTION_FRAME_RADIUS)
frame = NoSelectPathItem(path)
frame.setData(0, "selection_outline")
frame.setData(2, {"kind": "frame"})
diff --git a/app/chemvas/ui/selection/selection_outline_paths.py b/app/chemvas/ui/selection/selection_outline_paths.py
index cec0c19a..c7066a85 100644
--- a/app/chemvas/ui/selection/selection_outline_paths.py
+++ b/app/chemvas/ui/selection/selection_outline_paths.py
@@ -13,6 +13,7 @@
)
from chemvas.domain.document import VALID_ARROW_KINDS
+from chemvas.ui.selection.selection_style_access import selection_arrow_overlay_width
PenWidthGetter = Callable[[QPen], float]
LineStrokePathBuilder = Callable[[QPointF, QPointF, float], QPainterPath]
@@ -109,7 +110,9 @@ def selection_path_for_object_item(
if kind in ARROW_OBJECT_KINDS and isinstance(item, QGraphicsPathItem):
return selection_path_for_bond_item(
item,
- width=max(item.pen().widthF() + pad * 1.5, atom_pick_radius * 0.7),
+ width=selection_arrow_overlay_width(
+ item.pen().widthF(), pad, atom_pick_radius
+ ),
default_width_for_pen=default_width_for_pen,
line_stroke_path=line_stroke_path,
)
@@ -129,6 +132,8 @@ def selection_path_for_object_item(
stroker.setCapStyle(Qt.PenCapStyle.RoundCap)
stroker.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
overlay = QPainterPath(shape)
+ if kind == "shape":
+ overlay.setFillRule(Qt.FillRule.WindingFill)
overlay.addPath(stroker.createStroke(shape))
return simplified_outline_path(overlay)
diff --git a/app/chemvas/ui/selection/selection_outline_service.py b/app/chemvas/ui/selection/selection_outline_service.py
index 9b80e0ee..162ddb3a 100644
--- a/app/chemvas/ui/selection/selection_outline_service.py
+++ b/app/chemvas/ui/selection/selection_outline_service.py
@@ -1,22 +1,25 @@
from __future__ import annotations
-import math
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from PyQt6.QtCore import QPointF, Qt
from PyQt6.QtGui import QColor, QPainterPath
from PyQt6.QtWidgets import QGraphicsLineItem
+from chemvas.features.selection import selection_frame_applies
from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
from chemvas.ui.scene.mark_item_access import mark_selection_radius_for
-from chemvas.ui.scene.mark_ownership import mark_is_distant_for
+from chemvas.ui.scene.mark_ownership import (
+ DISTANT_MARK_COLOR,
+ MARK_OWNER_GUIDANCE,
+ mark_is_distant_for,
+)
from chemvas.ui.scene.scene_group_operations import selected_group_rects_for
from chemvas.ui.scene.scene_item_access import (
add_item_to_canvas_scene,
remove_item_from_canvas_scene,
)
from chemvas.ui.selection.selection_center import bounding_box_center_for_atoms
-from chemvas.ui.selection.selection_handles import selection_frame_applies
from chemvas.ui.selection.selection_info_access import emit_selection_info_for
from chemvas.ui.selection.selection_outline_items import (
selection_center_outline_items,
@@ -48,8 +51,12 @@
clear_selection_outlines_for,
)
from chemvas.ui.selection.selection_style_access import (
+ IMAGE_SELECTION_PADDING,
+ SELECTION_OBJECT_PADDING_RATIO,
selection_bond_overlay_width_for,
+ selection_bond_parts,
selection_indicator_rect_for_atom_for,
+ selection_structure_ids,
)
if TYPE_CHECKING:
@@ -104,27 +111,18 @@ def update_selection_outline(self) -> None:
if not items and not group_rects:
return
explicit_atom_ids, bond_ids = selected_ids_for(self.canvas)
- atom_ids = set(explicit_atom_ids)
- for bond_id in bond_ids:
- bond = self.canvas.model.bond_for_id(bond_id)
- if bond is not None:
- atom_ids.add(bond.a)
- atom_ids.add(bond.b)
object_items = [item for item in items if item.data(0) in OBJECT_OVERLAY_KINDS]
self.clear_selection_outlines()
color = QColor(self.canvas.runtime_state.selection_state.color)
- candidate_bond_ids = set(bond_ids)
graph = getattr(self.graph_service, "graph", None)
- atom_bond_ids = getattr(graph, "atom_bond_ids", {})
- for atom_id in atom_ids:
- candidate_bond_ids.update(atom_bond_ids.get(atom_id, ()))
- overlay_bond_ids: set[int] = set()
- for bond_id in candidate_bond_ids:
- bond = self.canvas.model.bond_for_id(bond_id)
- if bond is not None and bond.a in atom_ids and bond.b in atom_ids:
- overlay_bond_ids.add(bond_id)
+ atom_ids, overlay_bond_ids = selection_structure_ids(
+ self.canvas.model,
+ explicit_atom_ids,
+ bond_ids,
+ getattr(graph, "atom_bond_ids", {}),
+ )
for component in self.graph_service.connected_components(atom_ids):
component_bond_ids = {
bond_id
@@ -199,7 +197,7 @@ def _update_mark_owner_overlay(self, outline, mark) -> None:
outline.setPath(path)
outline.setPos(0, 0)
color = (
- QColor("#b45309")
+ QColor(DISTANT_MARK_COLOR)
if mark_is_distant_for(self.canvas, mark)
else self.canvas.runtime_state.selection_state.color
)
@@ -220,10 +218,7 @@ def add_mark_owner_overlay(self, mark) -> None:
)
# Keep the tooltip invariant during a drag. Only path/pen/position,
# already covered by the move savepoint, change frame by frame.
- outline.setToolTip(
- f"Owner: {atom.element} #{atom_id}. Amber means far from owner. "
- "Moving keeps this owner. Right-click the mark to reassign it."
- )
+ outline.setToolTip(f"Owner: {atom.element} #{atom_id}. {MARK_OWNER_GUIDANCE}")
self._update_mark_owner_overlay(outline, mark)
add_item_to_canvas_scene(self.canvas, outline)
append_selection_outline_for(self.canvas, outline)
@@ -258,81 +253,52 @@ def selection_path_for_bond(self, bond_id: int) -> QPainterPath:
)
if not items:
return QPainterPath()
- ring_center = (
- self.canvas.render_context.geometry.ring_center_for_bond(bond)
- if bond.order == 2
- else None
- )
- if ring_center is not None:
- outer_path = self.selection_path_for_bond_item(items[0])
- if not outer_path.isEmpty():
- return outer_path
- line_items = [item for item in items if isinstance(item, QGraphicsLineItem)]
- if bond.order >= 2 and line_items and len(line_items) == len(items):
- atom_a = self.canvas.model.atom_for_id(bond.a)
- atom_b = self.canvas.model.atom_for_id(bond.b)
- if atom_a is not None and atom_b is not None:
- t0, t1 = self.canvas.render_context.geometry.trim_line_for_labels(
- bond.a, bond.b, atom_a.x, atom_a.y, atom_b.x, atom_b.y
+ parts: list[dict[str, Any]] = []
+ for item in items:
+ path = self.selection_path_for_bond_item(item)
+ if isinstance(item, QGraphicsLineItem):
+ line = item.line()
+ start = item.mapToScene(QPointF(line.x1(), line.y1()))
+ end = item.mapToScene(QPointF(line.x2(), line.y2()))
+ parts.append(
+ {
+ "line": (start.x(), start.y(), end.x(), end.y()),
+ "width": self._selection_bond_overlay_width(item.pen()),
+ "empty": path.isEmpty(),
+ }
)
- base_x1 = atom_a.x + (atom_b.x - atom_a.x) * t0
- base_y1 = atom_a.y + (atom_b.y - atom_a.y) * t0
- base_x2 = atom_a.x + (atom_b.x - atom_a.x) * t1
- base_y2 = atom_a.y + (atom_b.y - atom_a.y) * t1
- dx = base_x2 - base_x1
- dy = base_y2 - base_y1
- length = math.hypot(dx, dy)
- if length > 1e-6:
- nx = -dy / length
- ny = dx / length
- base_mid = QPointF(
- (base_x1 + base_x2) * 0.5, (base_y1 + base_y2) * 0.5
- )
- offsets = []
- widths = []
- for item in line_items:
- line = item.line()
- mid = item.mapToScene(
- QPointF(
- (line.x1() + line.x2()) * 0.5,
- (line.y1() + line.y2()) * 0.5,
- )
- )
- offsets.append(
- (mid.x() - base_mid.x()) * nx
- + (mid.y() - base_mid.y()) * ny
- )
- widths.append(
- selection_bond_overlay_width_for(self.canvas, item.pen())
- )
- # A ring double bond keeps one line on the atom axis and
- # shortens the other inside the ring; its band stays on the
- # axis so it meets the neighbouring bands at the vertex.
- # Only a symmetric pair (C=O) is centred between its lines.
- on_axis_tolerance = (
- self.canvas.renderer.style.bond_spacing_px * 0.25
- )
- if any(abs(offset) <= on_axis_tolerance for offset in offsets):
- axis_shift = 0.0
- else:
- axis_shift = (min(offsets) + max(offsets)) * 0.5
- overlay_width = max(widths)
- return self.selection_line_stroke_path(
- QPointF(base_x1 + nx * axis_shift, base_y1 + ny * axis_shift),
- QPointF(base_x2 + nx * axis_shift, base_y2 + ny * axis_shift),
- overlay_width,
+ else:
+ parts.append({"shape": path, "empty": path.isEmpty()})
+ parts = selection_bond_parts(
+ parts,
+ bond=bond,
+ atom_a=self.canvas.model.atom_for_id(bond.a),
+ atom_b=self.canvas.model.atom_for_id(bond.b),
+ trim_line=lambda *args: (
+ self.canvas.render_context.geometry.trim_line_for_labels(*args)
+ ),
+ spacing=self.canvas.renderer.style.bond_spacing_px,
+ in_ring=bond.order == 2
+ and self.canvas.render_context.geometry.ring_center_for_bond(bond)
+ is not None,
+ )
+ path = QPainterPath()
+ path.setFillRule(Qt.FillRule.WindingFill)
+ for part in parts:
+ if "line" in part:
+ x1, y1, x2, y2 = part["line"]
+ path.addPath(
+ self.selection_line_stroke_path(
+ QPointF(x1, y1), QPointF(x2, y2), part["width"]
)
- bond_path = QPainterPath()
- bond_path.setFillRule(Qt.FillRule.WindingFill)
- for item in items:
- item_path = self.selection_path_for_bond_item(item)
- if not item_path.isEmpty():
- bond_path.addPath(item_path)
- return bond_path
+ )
+ else:
+ path.addPath(part["shape"])
+ return path
def selection_path_for_object_item(self, item) -> QPainterPath:
kind = item.data(0)
- pad = self.canvas.renderer.style.bond_length_px * 0.12
+ pad = self.canvas.renderer.style.bond_length_px * SELECTION_OBJECT_PADDING_RATIO
mark_center = (
self.canvas.services.scene_decoration_build_service.mark_center(item)
if kind == "mark"
@@ -353,7 +319,13 @@ def selection_path_for_object_item(self, item) -> QPainterPath:
def add_selection_object_overlay(self, item, color: QColor) -> None:
if item.data(0) == "image":
outline = selection_group_outline_item(
- item.sceneBoundingRect().adjusted(-2.0, -2.0, 2.0, 2.0), color
+ item.sceneBoundingRect().adjusted(
+ -IMAGE_SELECTION_PADDING,
+ -IMAGE_SELECTION_PADDING,
+ IMAGE_SELECTION_PADDING,
+ IMAGE_SELECTION_PADDING,
+ ),
+ color,
)
outline.setData(2, {"kind": "object", "object_kind": "image"})
add_item_to_canvas_scene(self.canvas, outline)
@@ -404,7 +376,7 @@ def selection_frame_rect(self, atom_ids: set[int], items: list):
rect = item_rect if rect is None else rect.united(item_rect)
if rect is None:
return None
- pad = self.canvas.renderer.style.bond_length_px * 0.12
+ pad = self.canvas.renderer.style.bond_length_px * SELECTION_OBJECT_PADDING_RATIO
return rect.adjusted(-pad, -pad, pad, pad)
def add_selection_component_overlay(
diff --git a/app/chemvas/ui/selection/selection_queries.py b/app/chemvas/ui/selection/selection_queries.py
index d98ce33c..a8ded471 100644
--- a/app/chemvas/ui/selection/selection_queries.py
+++ b/app/chemvas/ui/selection/selection_queries.py
@@ -3,7 +3,10 @@
from PyQt6 import sip
from PyQt6.QtCore import QObject, QPointF, Qt
-from chemvas.features.selection import build_selection_snapshot
+from chemvas.features.selection import (
+ build_selection_snapshot,
+ independent_selection_items,
+)
from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for
from chemvas.ui.scene.scene_item_access import item_is_in_scene
@@ -212,25 +215,6 @@ def selected_items_for_transform_for(canvas) -> list:
)
-def independent_selection_items(selection_items: list, atom_ids: set[int]) -> list:
- items: list = []
- seen = set()
- for item in selection_items:
- if item is None or item in seen:
- continue
- seen.add(item)
- kind = item.data(0)
- if kind in {"atom", "bond", "ring"}:
- continue
- if kind == "mark":
- data = item.data(1) or {}
- atom_id = data.get("atom_id")
- if isinstance(atom_id, int) and atom_id in atom_ids:
- continue
- items.append(item)
- return items
-
-
def selection_items_for_copy_for(canvas) -> list:
selected = selected_scene_items_for(
canvas, excluded_kinds=COPY_SELECTION_EXCLUDED_KINDS
diff --git a/app/chemvas/ui/selection/selection_style_access.py b/app/chemvas/ui/selection/selection_style_access.py
index 57632172..31aa9f50 100644
--- a/app/chemvas/ui/selection/selection_style_access.py
+++ b/app/chemvas/ui/selection/selection_style_access.py
@@ -1,19 +1,52 @@
from __future__ import annotations
-from PyQt6.QtCore import QPointF, QRectF
+from typing import TYPE_CHECKING, Any
+from chemvas.features.selection import selected_atom_ids_with_bond_endpoints
from chemvas.ui.canvas.canvas_atom_graphics_state import visible_atom_item_for
from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for
+if TYPE_CHECKING:
+ from collections.abc import Callable, Mapping, Sequence
+
+SELECTION_OUTLINE_SCREEN_PX = 1.5
+SELECTION_OBJECT_PADDING_RATIO = 0.12
+# A group's dashed box: padding per bond length, cosmetic pen width and Qt's
+# DashLine pattern in pen widths.
+GROUP_BOX_PADDING_RATIO = 0.18
+GROUP_BOX_SCREEN_PX = 1.0
+GROUP_BOX_DASH_PATTERN = (4.0, 2.0)
+# A selected image's dashed box sits this far outside the image, in scene units.
+IMAGE_SELECTION_PADDING = 2.0
+
+
+def group_box_corner_radius(width: float, height: float) -> float:
+ return min(6.0, min(width, height) / 4.0)
+
+
+def selection_arrow_overlay_width(
+ pen_width: float, pad: float, atom_radius: float
+) -> float:
+ return max(pen_width + pad * 1.5, atom_radius * 0.7)
+
+
+def selection_bond_overlay_width(
+ pen_width: float, spacing: float, radius: float
+) -> float:
+ return max(pen_width + spacing * 1.05, radius * 0.75)
+
def selection_bond_overlay_width_for(canvas, base_pen) -> float:
- return max(
- base_pen.widthF() + canvas.renderer.style.bond_spacing_px * 1.05,
- atom_pick_radius_for(canvas) * 0.75,
+ return selection_bond_overlay_width(
+ base_pen.widthF(),
+ canvas.renderer.style.bond_spacing_px,
+ atom_pick_radius_for(canvas),
)
def atom_center_point_for(canvas, atom_id: int):
+ from PyQt6.QtCore import QPointF
+
atom = canvas.model.atom_for_id(atom_id)
if atom is None:
return None
@@ -21,36 +54,128 @@ def atom_center_point_for(canvas, atom_id: int):
def selection_indicator_rect_for_atom_for(canvas, atom_id: int):
+ from PyQt6.QtCore import QRectF
+
atom = canvas.model.atom_for_id(atom_id)
if atom is None:
return None
- radius = atom_pick_radius_for(canvas)
- rect = QRectF(
- atom.x - radius,
- atom.y - radius,
- radius * 2.0,
- radius * 2.0,
- )
item = visible_atom_item_for(canvas, atom_id)
+ label_rect = None
if item is not None:
try:
- label_rect = item.sceneBoundingRect()
+ rect = item.sceneBoundingRect()
+ label_rect = (rect.x(), rect.y(), rect.width(), rect.height())
except (RuntimeError, AttributeError):
- label_rect = None
- # Short element labels keep their circular indicator; only long free text
- # (multi-character labels that clearly overflow the circle) widen it so the
- # highlight covers the whole string.
- if (
- label_rect is not None
- and not label_rect.isEmpty()
- and label_rect.width() > rect.width() * 3.0
- ):
- rect = rect.united(label_rect)
- return rect
+ pass
+ return QRectF(
+ *selection_atom_rect(atom.x, atom.y, atom_pick_radius_for(canvas), label_rect)
+ )
+
+
+def selection_atom_rect(
+ x: float, y: float, radius: float, label_rect: Sequence[float] | None = None
+) -> tuple[float, float, float, float]:
+ """The selection indicator for an atom at ``(x, y)``."""
+ left, top, width, height = x - radius, y - radius, radius * 2.0, radius * 2.0
+ # Short element labels keep their circular indicator; only long free text
+ # (multi-character labels that clearly overflow the circle) widen it so the
+ # highlight covers the whole string.
+ if label_rect is not None and label_rect[2] > width * 3.0 and label_rect[3] > 0:
+ lx, ly, lw, lh = label_rect
+ right, bottom = max(left + width, lx + lw), max(top + height, ly + lh)
+ left, top = min(left, lx), min(top, ly)
+ width, height = right - left, bottom - top
+ return left, top, width, height
+
+
+def selection_structure_ids(
+ model: Any,
+ atom_ids: set[int],
+ bond_ids: set[int],
+ atom_bond_ids: Mapping[int, set[int]],
+) -> tuple[set[int], set[int]]:
+ atom_ids = selected_atom_ids_with_bond_endpoints(
+ atom_ids, bond_ids, bonds=model.bonds
+ )
+ candidates = set(bond_ids)
+ for atom_id in atom_ids:
+ candidates.update(atom_bond_ids.get(atom_id, ()))
+ overlay_bonds = {
+ bond_id
+ for bond_id in candidates
+ if (bond := model.bond_for_id(bond_id)) is not None
+ and bond.a in atom_ids
+ and bond.b in atom_ids
+ }
+ return atom_ids, overlay_bonds
+
+
+def selection_bond_parts(
+ parts: list[dict[str, Any]],
+ *,
+ bond: Any,
+ atom_a: Any,
+ atom_b: Any,
+ trim_line: Callable[..., tuple[float, float]],
+ spacing: float,
+ in_ring: bool,
+) -> list[dict[str, Any]]:
+ """Choose the native selection band over already materialized bond parts."""
+ import math
+
+ if not parts:
+ return []
+ if in_ring and not parts[0]["empty"]:
+ return parts[:1]
+ if bond.order >= 2 and all("line" in part for part in parts):
+ if atom_a is not None and atom_b is not None:
+ t0, t1 = trim_line(bond.a, bond.b, atom_a.x, atom_a.y, atom_b.x, atom_b.y)
+ x1 = atom_a.x + (atom_b.x - atom_a.x) * t0
+ y1 = atom_a.y + (atom_b.y - atom_a.y) * t0
+ x2 = atom_a.x + (atom_b.x - atom_a.x) * t1
+ y2 = atom_a.y + (atom_b.y - atom_a.y) * t1
+ length = math.hypot(x2 - x1, y2 - y1)
+ if length > 1e-6:
+ nx, ny = -(y2 - y1) / length, (x2 - x1) / length
+ mid_x, mid_y = (x1 + x2) * 0.5, (y1 + y2) * 0.5
+ offsets = [
+ ((part["line"][0] + part["line"][2]) * 0.5 - mid_x) * nx
+ + ((part["line"][1] + part["line"][3]) * 0.5 - mid_y) * ny
+ for part in parts
+ ]
+ # A ring double bond keeps one line on the atom axis and
+ # shortens the other inside the ring; its band stays on the
+ # axis so it meets the neighbouring bands at the vertex.
+ # Only a symmetric pair (C=O) is centred between its lines.
+ shift = (
+ 0.0
+ if any(abs(offset) <= spacing * 0.25 for offset in offsets)
+ else (min(offsets) + max(offsets)) * 0.5
+ )
+ return [
+ {
+ "line": (
+ x1 + nx * shift,
+ y1 + ny * shift,
+ x2 + nx * shift,
+ y2 + ny * shift,
+ ),
+ "width": max(part["width"] for part in parts),
+ "empty": False,
+ }
+ ]
+ return parts
__all__ = [
+ "SELECTION_OBJECT_PADDING_RATIO",
+ "SELECTION_OUTLINE_SCREEN_PX",
"atom_center_point_for",
+ "selection_arrow_overlay_width",
+ "selection_atom_rect",
+ "selection_bond_overlay_width",
"selection_bond_overlay_width_for",
+ "selection_bond_parts",
"selection_indicator_rect_for_atom_for",
+ "selection_structure_ids",
]
diff --git a/app/chemvas/ui/tools/bond_tool.py b/app/chemvas/ui/tools/bond_tool.py
index f19e3d5d..1c1b5c0a 100644
--- a/app/chemvas/ui/tools/bond_tool.py
+++ b/app/chemvas/ui/tools/bond_tool.py
@@ -2,13 +2,8 @@
from typing import override
-from PyQt6.QtCore import QLineF, QPointF, Qt
+from PyQt6.QtCore import QPointF, Qt
-from chemvas.features.rendering import (
- BOLD_BOND_STYLES,
- is_dotted_double_bond_style,
- style_for_existing_bond_overlay,
-)
from chemvas.ui.canvas.canvas_window_access import notify_error_for
from chemvas.ui.insert.preview_scene_renderer import clear_scene_items
from chemvas.ui.molecule.bond_preview_access import (
@@ -20,6 +15,10 @@
from chemvas.ui.molecule.structure_mutation_access import add_bond_between_points_for
from chemvas.ui.selection.selection_queries import scene_selected_items_for
from chemvas.ui.tools.bond_tool_logic import (
+ BOND_PICK_RADIUS_RATIO,
+ BOND_SNAP_RADIUS_RATIO,
+ apply_active_bond_style,
+ is_short_bond_gesture,
resolve_bond_endpoint_target,
resolve_bond_press_target,
resolve_bond_snap_target,
@@ -96,58 +95,17 @@ def _set_preview_items(self, start: QPointF, end: QPointF) -> None:
self._preview_signature = signature
def _apply_active_style_to_bond(self, bond_id: int) -> bool:
- bond = self.canvas.model.bond_for_id(bond_id)
- if bond is None:
- return False
settings = self.canvas.runtime_state.tool_settings_state
- active_bond_style = settings.active_bond_style
- if bond.style == "double_either" and (
- active_bond_style in BOLD_BOND_STYLES or active_bond_style == "dotted"
- ):
- notify_error_for(
- self.canvas,
- "This appearance change would erase unknown double-bond stereo. "
- "Choose Double (2) first to clear it explicitly.",
- )
- return True
- if active_bond_style in {"wedge", "hash"}:
- self.context.apply_bond_style(bond_id, active_bond_style, 1)
- return True
- if active_bond_style in BOLD_BOND_STYLES:
- next_style, next_order = style_for_existing_bond_overlay(
- bond.style, bond.order, active_bond_style, settings.active_bond_order
- )
- self.context.apply_bond_style(bond_id, next_style, next_order)
- return True
- if active_bond_style == "dotted":
- next_style, next_order = style_for_existing_bond_overlay(
- bond.style,
- bond.order,
- "dotted",
- 1,
- )
- if bond.order == 2 and not is_dotted_double_bond_style(
- next_style, next_order
- ):
- notify_error_for(
- self.canvas,
- "Dotted overlay needs an inner or outer plain double bond. "
- "Choose Double, then its position, and try Dotted again.",
- )
- return True
- self.context.apply_bond_style(bond_id, next_style, next_order)
- return True
- if active_bond_style in {"single", "double", "triple"}:
- if (bond.style, bond.order) != (
- active_bond_style,
- settings.active_bond_order,
- ):
- self.context.apply_bond_style(
- bond_id, active_bond_style, settings.active_bond_order
- )
- return True
- self.context.cycle_bond_style(bond_id)
- return True
+ return apply_active_bond_style(
+ self.canvas.model.bond_for_id(bond_id),
+ settings.active_bond_style,
+ settings.active_bond_order,
+ apply_style=lambda style, order: self.context.apply_bond_style(
+ bond_id, style, order
+ ),
+ cycle_style=lambda: self.context.cycle_bond_style(bond_id),
+ notify_error=lambda message: notify_error_for(self.canvas, message),
+ )
def _clear_existing_selection(self) -> None:
if scene_selected_items_for(self.canvas):
@@ -164,7 +122,7 @@ def on_mouse_press(self, event) -> bool:
atom_id = self.context.find_atom_near(
press_pos.x(),
press_pos.y(),
- self.canvas.renderer.style.bond_length_px * 0.35,
+ self.canvas.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO,
)
item = self.context.item_at_event(event)
if item is None:
@@ -173,7 +131,7 @@ def on_mouse_press(self, event) -> bool:
if atom_id is None:
nearby_bond_id = self.context.find_bond_near(
press_pos,
- self.canvas.renderer.style.bond_length_px * 0.35,
+ self.canvas.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO,
)
press_bond_id = resolve_bond_press_target(
atom_id=atom_id,
@@ -201,7 +159,9 @@ def on_mouse_move(self, event) -> bool:
self._set_preview_items(self._start_pos, snapped)
# Atom targets keep their exact geometry; they are not angle snaps.
target_atom = self.context.find_atom_near(
- snapped.x(), snapped.y(), self.canvas.renderer.style.bond_length_px * 0.35
+ snapped.x(),
+ snapped.y(),
+ self.canvas.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO,
)
self._angle_guide = (
((self._start_pos.x(), self._start_pos.y()), (snapped.x(), snapped.y()))
@@ -218,11 +178,13 @@ def on_mouse_release(self, event) -> bool:
release_pos = self.context.scene_pos_from_event(event)
end_pos = self._snap_to_atom(release_pos, ignore_start=True)
end_pos = self._snap_endpoint(self._start_pos, end_pos)
- if self._press_scene_pos is not None:
- dist = QLineF(self._press_scene_pos, release_pos).length()
- else:
- dist = 0.0
- if dist < self.canvas.renderer.style.bond_length_px * 0.1:
+ if is_short_bond_gesture(
+ (self._press_scene_pos.x(), self._press_scene_pos.y())
+ if self._press_scene_pos is not None
+ else None,
+ (release_pos.x(), release_pos.y()),
+ self.canvas.renderer.style.bond_length_px,
+ ):
end_pos = default_bond_endpoint_for(
self.canvas, self._start_pos, self._start_atom_id
)
@@ -244,12 +206,12 @@ def _snap_to_atom(self, pos, ignore_start: bool = False):
atom_id = self.context.find_atom_near(
pos.x(),
pos.y(),
- self.canvas.renderer.style.bond_length_px * 0.35,
+ self.canvas.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO,
)
bond_id = None
if atom_id is None:
bond_id = self.context.find_bond_near(
- pos, self.canvas.renderer.style.bond_length_px * 0.2
+ pos, self.canvas.renderer.style.bond_length_px * BOND_SNAP_RADIUS_RATIO
)
target = resolve_bond_snap_target(
self.canvas.model,
@@ -266,7 +228,7 @@ def _snap_endpoint(self, start, end):
atom_id = self.context.find_atom_near(
end.x(),
end.y(),
- self.canvas.renderer.style.bond_length_px * 0.35,
+ self.canvas.renderer.style.bond_length_px * BOND_PICK_RADIUS_RATIO,
)
target = resolve_bond_endpoint_target(
self.canvas.model,
diff --git a/app/chemvas/ui/tools/bond_tool_logic.py b/app/chemvas/ui/tools/bond_tool_logic.py
index a5670ba8..0bc1adf4 100644
--- a/app/chemvas/ui/tools/bond_tool_logic.py
+++ b/app/chemvas/ui/tools/bond_tool_logic.py
@@ -4,8 +4,26 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
+from chemvas.features.rendering import (
+ BOLD_BOND_STYLES,
+ DOTTED_DOUBLE_STYLE_DEFAULT,
+ DOUBLE_STYLE_CENTER,
+ DOUBLE_STYLE_DEFAULT,
+ DOUBLE_STYLE_OUTER,
+ bold_double_style_for_style,
+ is_dotted_double_bond_style,
+ style_for_double_position,
+ style_for_existing_bond_overlay,
+)
+
if TYPE_CHECKING:
- from chemvas.domain.document import MoleculeModel
+ from collections.abc import Callable
+
+ from chemvas.domain.document import Bond, MoleculeModel
+
+
+BOND_PICK_RADIUS_RATIO = 0.35
+BOND_SNAP_RADIUS_RATIO = 0.2
@dataclass(frozen=True)
@@ -14,6 +32,19 @@ class BondSnapTarget:
start_atom_id: int | None
+def is_short_bond_gesture(
+ press: tuple[float, float] | None,
+ release: tuple[float, float],
+ bond_length: float,
+) -> bool:
+ distance = (
+ math.hypot(press[0] - release[0], press[1] - release[1])
+ if press is not None
+ else 0.0
+ )
+ return distance < bond_length * 0.1
+
+
def resolve_bond_press_target(
*,
atom_id: int | None,
@@ -105,8 +136,120 @@ def resolve_bond_endpoint_target(
)
+def apply_active_bond_style(
+ bond: Bond | None,
+ active_bond_style: str,
+ active_bond_order: int,
+ *,
+ apply_style: Callable[[str, int], None],
+ cycle_style: Callable[[], None],
+ notify_error: Callable[[str], object],
+) -> bool:
+ """Apply the existing bond-tool click policy through the presentation adapter."""
+ if bond is None:
+ return False
+ if bond.style == "double_either" and (
+ active_bond_style in BOLD_BOND_STYLES or active_bond_style == "dotted"
+ ):
+ notify_error(
+ "This appearance change would erase unknown double-bond stereo. "
+ "Choose Double (2) first to clear it explicitly.",
+ )
+ return True
+ if active_bond_style in {"wedge", "hash"}:
+ apply_style(active_bond_style, 1)
+ return True
+ if active_bond_style in BOLD_BOND_STYLES:
+ next_style, next_order = style_for_existing_bond_overlay(
+ bond.style, bond.order, active_bond_style, active_bond_order
+ )
+ apply_style(next_style, next_order)
+ return True
+ if active_bond_style == "dotted":
+ next_style, next_order = style_for_existing_bond_overlay(
+ bond.style,
+ bond.order,
+ "dotted",
+ 1,
+ )
+ if bond.order == 2 and not is_dotted_double_bond_style(next_style, next_order):
+ notify_error(
+ "Dotted overlay needs an inner or outer plain double bond. "
+ "Choose Double, then its position, and try Dotted again.",
+ )
+ return True
+ apply_style(next_style, next_order)
+ return True
+ if active_bond_style in {"single", "double", "triple"}:
+ if (bond.style, bond.order) != (
+ active_bond_style,
+ active_bond_order,
+ ):
+ apply_style(active_bond_style, active_bond_order)
+ return True
+ cycle_style()
+ return True
+
+
+BOND_STYLE_HOTKEYS = {
+ "1": ("single", 1),
+ "2": ("double", 2),
+ "3": ("triple", 3),
+ "b": ("bold_in", 1),
+ "w": ("wedge", 1),
+ "h": ("hash", 1),
+ "d": ("dotted", 1),
+ "H": ("hash", 1),
+ "D": (DOTTED_DOUBLE_STYLE_DEFAULT, 2),
+}
+BOND_POSITION_HOTKEYS = {
+ "c": DOUBLE_STYLE_CENTER,
+ "l": DOUBLE_STYLE_DEFAULT,
+ "r": DOUBLE_STYLE_OUTER,
+}
+BOND_SHORTCUT_KEYS = (
+ frozenset(BOND_STYLE_HOTKEYS) | frozenset(BOND_POSITION_HOTKEYS) | {"B"}
+)
+
+
+def bond_shortcut_style(bond: Bond, text: str) -> tuple[str, int] | None:
+ """The native hover-bond shortcut decision, shared by both input adapters."""
+ if bond.style == "double_either" and text in {
+ "b",
+ "d",
+ "B",
+ "D",
+ *BOND_POSITION_HOTKEYS,
+ }:
+ raise ValueError(
+ "This appearance change would erase unknown double-bond stereo. "
+ "Choose Double (2) first to clear it explicitly."
+ )
+ if text == "B":
+ # 'b' applies a bold single; Shift+B upgrades to a bold double
+ # (order 2 renders via the bold multi-line path).
+ return bold_double_style_for_style(bond.style, bond.order), 2
+ if text in BOND_POSITION_HOTKEYS:
+ if bond.order != 2:
+ return None
+ position_style = BOND_POSITION_HOTKEYS[text]
+ target_style = style_for_double_position(bond.style, bond.order, position_style)
+ # Preserve the previous shortcut behavior for other order-2 styles:
+ # l/c/r converts those bonds back to an ordinary double.
+ return target_style or position_style, 2
+ return BOND_STYLE_HOTKEYS.get(text)
+
+
__all__ = [
+ "BOND_PICK_RADIUS_RATIO",
+ "BOND_POSITION_HOTKEYS",
+ "BOND_SHORTCUT_KEYS",
+ "BOND_SNAP_RADIUS_RATIO",
+ "BOND_STYLE_HOTKEYS",
"BondSnapTarget",
+ "apply_active_bond_style",
+ "bond_shortcut_style",
+ "is_short_bond_gesture",
"resolve_bond_endpoint_target",
"resolve_bond_press_target",
"resolve_bond_snap_target",
diff --git a/app/chemvas/ui/tools/edit_tools.py b/app/chemvas/ui/tools/edit_tools.py
index ea69a244..d5f2c5f3 100644
--- a/app/chemvas/ui/tools/edit_tools.py
+++ b/app/chemvas/ui/tools/edit_tools.py
@@ -15,6 +15,10 @@
)
from chemvas.ui.tools.tool_base import Tool
from chemvas.ui.tools.tool_overlay_logic import activate_tool_no_drag
+from chemvas.ui.window.main_window_config import (
+ COLOR_TARGET_KINDS,
+ COLOR_TOOL_MESSAGES,
+)
if TYPE_CHECKING:
from chemvas.core.history import HistoryCommand
@@ -46,7 +50,7 @@ def on_mouse_press(self, event) -> bool:
if event.button() != Qt.MouseButton.LeftButton:
return False
if self._last_color is None:
- notify_error_for(self.canvas, "Color: choose a swatch before painting.")
+ notify_error_for(self.canvas, COLOR_TOOL_MESSAGES["choose"])
return True
item = self.context.item_at_event(event)
targets = []
@@ -56,9 +60,7 @@ def on_mouse_press(self, event) -> bool:
targets = [
sel
for sel in self.context.selected_scene_items(excluded_kinds=set())
- if sel.data(0)
- in {"bond", "atom", "ring", "shape", "note", "mark", "ts_bracket"}
- | VALID_ARROW_KINDS
+ if sel.data(0) in COLOR_TARGET_KINDS | VALID_ARROW_KINDS
]
if not targets:
return True
diff --git a/app/chemvas/ui/tools/endpoint_snap_access.py b/app/chemvas/ui/tools/endpoint_snap_access.py
index 0af0e082..65926f07 100644
--- a/app/chemvas/ui/tools/endpoint_snap_access.py
+++ b/app/chemvas/ui/tools/endpoint_snap_access.py
@@ -1,24 +1,16 @@
from __future__ import annotations
-import math
-
from PyQt6.QtCore import QPointF
from chemvas.domain.document import VALID_ARROW_KINDS
from chemvas.features.rendering import (
- nearest_endpoint,
- snapped_to_grid,
- snapped_to_hex_grid,
+ ENDPOINT_SNAP_SCREEN_PX,
+ SNAP_MARK_SCREEN_PX,
+ endpoint_connection,
+ points_on_endpoints,
+ snapped_drawing_point,
)
-
-# Snapping is an input affordance, so its reach is a distance on screen
-# rather than in the document: an endpoint this many pixels from the cursor
-# is caught, at any zoom. A dashed connector then meets an energy level
-# exactly, and cycle arcs share corners, without aiming at a few pixels.
-ENDPOINT_SNAP_SCREEN_PX = 12.0
-# Diameter of the ring that says an end has been caught. It has to clear
-# a bold line's own width to be seen at all.
-SNAP_MARK_SCREEN_PX = 16.0
+from chemvas.ui.canvas.canvas_tool_settings_state import grid_step_for
def scene_length_for_screen_px(canvas, pixels: float) -> float:
@@ -48,46 +40,12 @@ def arrow_endpoints_for(canvas, *, exclude=None) -> list[tuple[float, float]]:
def snapped_points_among_for(canvas, points, *, exclude=None):
- """The ``points`` that are sitting exactly on an existing endpoint.
-
- A gesture takes an endpoint by copying it, so equality is the whole
- test; this is what the ring is drawn from, rather than a record of
- which stage of the funnel answered.
- """
- endpoints = set(arrow_endpoints_for(canvas, exclude=exclude))
- return [
- point
- for point in points
- if point is not None and (point.x(), point.y()) in endpoints
- ]
-
-
-def grid_step_for(canvas) -> float:
- """Grid spacing in scene units, so the grid scales with the bond length."""
- return (
- canvas.renderer.style.bond_length_px
- * canvas.runtime_state.tool_settings_state.grid_snap_step
- )
-
-
-def snap_to_endpoint_for(canvas, pos: QPointF, *, exclude=None, avoid=None):
- """The endpoint ``pos`` should take, or ``None`` when none applies.
-
- ``avoid`` names a point the result must not be, so a gesture cannot be
- collapsed onto the end it started from.
- """
- candidates = arrow_endpoints_for(canvas, exclude=exclude)
- if not candidates:
- return None
- found = nearest_endpoint(
- (pos.x(), pos.y()),
- candidates,
- radius=endpoint_snap_radius_for(canvas),
+ """The ``points`` that are sitting exactly on an existing endpoint."""
+ caught = points_on_endpoints(
+ [(point.x(), point.y()) for point in points if point is not None],
+ arrow_endpoints_for(canvas, exclude=exclude),
)
- if found is None:
- return None
- point = QPointF(*found)
- return None if point == avoid else point
+ return [QPointF(*point) for point in caught]
def _item_endpoints(canvas, item) -> list[QPointF]:
@@ -116,53 +74,50 @@ def connection_for(canvas, items):
]
if not targets:
return None
- radius = endpoint_snap_radius_for(canvas)
- best: tuple[float, QPointF, QPointF] | None = None
- for item in moving:
- for point in _item_endpoints(canvas, item):
- found = nearest_endpoint((point.x(), point.y()), targets, radius=radius)
- if found is None:
- continue
- shift = QPointF(found[0] - point.x(), found[1] - point.y())
- distance = math.hypot(shift.x(), shift.y())
- if best is None or distance < best[0]:
- best = (distance, shift, QPointF(*found))
- return None if best is None else (best[1], best[2])
-
-
-def snap_to_grid_for(canvas, pos: QPointF) -> QPointF:
- """``pos`` on the grid, or unchanged when the grid is off."""
- if not canvas.runtime_state.tool_settings_state.grid_snap_enabled:
- return pos
- snap = (
- snapped_to_hex_grid
- if canvas.runtime_state.tool_settings_state.grid_style == "hex"
- else snapped_to_grid
+ connection = endpoint_connection(
+ [
+ (point.x(), point.y())
+ for item in moving
+ for point in _item_endpoints(canvas, item)
+ ],
+ targets,
+ radius=endpoint_snap_radius_for(canvas),
)
- x, y = snap((pos.x(), pos.y()), step=grid_step_for(canvas))
- return QPointF(x, y)
+ if connection is None:
+ return None
+ shift, meeting_point = connection
+ return QPointF(*shift), QPointF(*meeting_point)
-def snap_drawing_point_for(canvas, pos: QPointF, *, exclude=None, avoid=None):
+def snap_drawing_point_for(
+ canvas, pos: QPointF, *, exclude=None, avoid=None, angle_step=None
+):
"""Where a drawing gesture should put ``pos``.
An endpoint is the more specific target, so it wins; the grid catches
everything else, and with both off the point passes through unchanged.
"""
- endpoint = snap_to_endpoint_for(canvas, pos, exclude=exclude, avoid=avoid)
- return endpoint if endpoint is not None else snap_to_grid_for(canvas, pos)
+ settings = canvas.runtime_state.tool_settings_state
+ endpoints = arrow_endpoints_for(canvas, exclude=exclude)
+ return QPointF(
+ *snapped_drawing_point(
+ (pos.x(), pos.y()),
+ endpoints,
+ radius=endpoint_snap_radius_for(canvas) if endpoints else 0.0,
+ avoid=None if avoid is None else (avoid.x(), avoid.y()),
+ angle_step=angle_step,
+ grid_step=grid_step_for(canvas) if settings.grid_snap_enabled else 0.0,
+ grid_style=settings.grid_style,
+ )
+ )
__all__ = [
- "ENDPOINT_SNAP_SCREEN_PX",
"SNAP_MARK_SCREEN_PX",
"arrow_endpoints_for",
"connection_for",
"endpoint_snap_radius_for",
- "grid_step_for",
"scene_length_for_screen_px",
"snap_drawing_point_for",
- "snap_to_endpoint_for",
- "snap_to_grid_for",
"snapped_points_among_for",
]
diff --git a/app/chemvas/ui/tools/handle_mutation_service.py b/app/chemvas/ui/tools/handle_mutation_service.py
index 46e082d4..dd5e4c61 100644
--- a/app/chemvas/ui/tools/handle_mutation_service.py
+++ b/app/chemvas/ui/tools/handle_mutation_service.py
@@ -1,12 +1,17 @@
from __future__ import annotations
-import math
from dataclasses import replace
from typing import TYPE_CHECKING
from PyQt6.QtCore import QPointF
-from chemvas.domain.document import VALID_CURVED_ARROW_KINDS
+from chemvas.features.rendering import arrow_with_moved_endpoint
+from chemvas.features.selection import (
+ orbital_rotation_angle as orbital_rotation_angle_helper,
+)
+from chemvas.features.selection import (
+ orbital_scale_factor as orbital_scale_factor_helper,
+)
from chemvas.ui.annotations.records import (
require_shape_record_for,
set_shape_record_for,
@@ -15,13 +20,6 @@
)
from chemvas.ui.selection.selection_handles import (
control_from_midpoint,
- control_with_moved_end,
-)
-from chemvas.ui.selection.selection_handles import (
- orbital_rotation_angle as orbital_rotation_angle_helper,
-)
-from chemvas.ui.selection.selection_handles import (
- orbital_scale_factor as orbital_scale_factor_helper,
)
from chemvas.ui.selection.selection_handles import (
resized_shape_rect as resized_shape_rect_helper,
@@ -35,11 +33,6 @@
from chemvas.domain.document import Arrow
from chemvas.ui.canvas.canvas_view import CanvasView
-# An endpoint drag stops here rather than collapsing an arrow or line into a
-# dot, which renders as a bare arrow head or a wavy blob. An arrow already
-# shorter than this stops at its length when the drag began instead.
-MIN_ARROW_LENGTH_BOND_LENGTHS = 0.1
-
class HandleMutationService:
def __init__(self, canvas: CanvasView) -> None:
@@ -80,33 +73,15 @@ def update_arrow_endpoint(
arrows = self.canvas.render_context.arrows
record = arrows.record(item)
moved = snap_drawing_point_for(self.canvas, pos, exclude=item)
- anchor, pressed_end = (
- (pressed.end, pressed.start)
- if endpoint == "start"
- else (pressed.start, pressed.end)
- )
- min_length = min(
- self.canvas.renderer.style.bond_length_px * MIN_ARROW_LENGTH_BOND_LENGTHS,
- math.hypot(pressed_end[0] - anchor[0], pressed_end[1] - anchor[1]),
- )
- if math.hypot(moved.x() - anchor[0], moved.y() - anchor[1]) < min_length:
- return
- point = (moved.x(), moved.y())
- updated = replace(
+ updated = arrow_with_moved_endpoint(
record,
- start=point if endpoint == "start" else anchor,
- end=point if endpoint == "end" else anchor,
+ pressed,
+ (moved.x(), moved.y()),
+ endpoint,
+ bond_length=self.canvas.renderer.style.bond_length_px,
)
- if updated.kind in VALID_CURVED_ARROW_KINDS:
- # ArrowRenderer.set_record gives every curved record its control.
- assert pressed.control is not None
- control = control_with_moved_end(
- QPointF(*anchor),
- QPointF(*pressed_end),
- moved,
- QPointF(*pressed.control),
- )
- updated = replace(updated, control=(control.x(), control.y()))
+ if updated is record:
+ return
arrows.set_record(item, updated)
self.canvas.services.selection.update_selection_outline()
diff --git a/app/chemvas/ui/tools/hover.py b/app/chemvas/ui/tools/hover.py
index bf3327c6..bb038724 100644
--- a/app/chemvas/ui/tools/hover.py
+++ b/app/chemvas/ui/tools/hover.py
@@ -6,6 +6,7 @@
from PyQt6.QtGui import QCursor
from chemvas.features.hover import (
+ ATOM_HOVER_RADIUS_RATIO,
HoverState,
HoverUpdatePlan,
plan_structure_hover_update,
@@ -169,7 +170,7 @@ def add_atom_hover_indicator(self, atom_id: int) -> None:
atom = self._atom_for_id(atom_id)
if atom is None:
return
- radius = self.canvas.renderer.style.bond_length_px * 0.25
+ radius = self.canvas.renderer.style.bond_length_px * ATOM_HOVER_RADIUS_RATIO
indicator = build_atom_hover_indicator_item(QPointF(atom.x, atom.y), radius)
self._scene().addItem(indicator)
self._state().items.append(indicator)
diff --git a/app/chemvas/ui/tools/hover_rendering.py b/app/chemvas/ui/tools/hover_rendering.py
index b9a1ca8a..f55e3a8f 100644
--- a/app/chemvas/ui/tools/hover_rendering.py
+++ b/app/chemvas/ui/tools/hover_rendering.py
@@ -11,6 +11,13 @@
QGraphicsTextItem,
)
+from chemvas.features.hover import (
+ ATOM_HOVER_BRUSH_RGBA,
+ ATOM_HOVER_PEN_RGBA,
+ ATOM_HOVER_Z,
+ HOVER_PREVIEW_OPACITY,
+ HOVER_PREVIEW_Z,
+)
from chemvas.ui.insert.preview_scene_renderer import clear_scene_items, preview_color
if TYPE_CHECKING:
@@ -30,7 +37,7 @@ def build_atom_hover_indicator(
*,
pen_color: QColor | None = None,
brush_color: QColor | None = None,
- z_value: float = 5.0,
+ z_value: float = ATOM_HOVER_Z,
) -> QGraphicsEllipseItem:
circle = QGraphicsEllipseItem(
center.x() - radius,
@@ -72,8 +79,8 @@ def add_hover_preview_items(
items: Sequence[QGraphicsItem],
*,
color: QColor | None = None,
- opacity: float = 0.55,
- z_value: float = 4.5,
+ opacity: float = HOVER_PREVIEW_OPACITY,
+ z_value: float = HOVER_PREVIEW_Z,
) -> list[QGraphicsItem]:
fill_color = preview_color() if color is None else QColor(color)
added_items: list[QGraphicsItem] = []
@@ -105,11 +112,11 @@ def _apply_preview_style(item: QGraphicsItem, color: QColor) -> None:
# same accent, faint, so what the pointer would pick previews what picking
# it will show.
def _indicator_pen_color() -> QColor:
- return QColor(13, 148, 136, 150)
+ return QColor(*ATOM_HOVER_PEN_RGBA)
def _indicator_brush_color() -> QColor:
- return QColor(13, 148, 136, 30)
+ return QColor(*ATOM_HOVER_BRUSH_RGBA)
__all__ = [
diff --git a/app/chemvas/ui/tools/line_tool.py b/app/chemvas/ui/tools/line_tool.py
index 4d6907ae..3f033d8a 100644
--- a/app/chemvas/ui/tools/line_tool.py
+++ b/app/chemvas/ui/tools/line_tool.py
@@ -4,21 +4,16 @@
from PyQt6.QtCore import QPointF, Qt
-from chemvas.features.rendering import snapped_line_end
+from chemvas.features.rendering import (
+ LINE_ANGLE_STEP_DEGREES,
+ line_click_endpoint,
+)
from chemvas.ui.scene.scene_decoration_build_access import mark_snapped_points_for
from chemvas.ui.tools.endpoint_snap_access import (
- snap_to_endpoint_for,
- snap_to_grid_for,
+ snap_drawing_point_for,
)
from chemvas.ui.tools.preview_tools import PreviewDragTool
-# Shift locks the drag to multiples of this angle so energy-diagram levels and
-# connectors come out exactly horizontal, vertical or diagonal.
-LINE_ANGLE_STEP_DEGREES = 15.0
-# A click on empty canvas places a horizontal level this many bond lengths
-# long, starting at the (snapped) press point, in the active line style.
-LEVEL_PRESET_BOND_LENGTHS = 2.0
-
class LineTool(PreviewDragTool):
snap_start_point = True
@@ -41,17 +36,12 @@ def _end_point(self, current_pos: QPointF) -> QPointF:
# this drag started from, or a short drag would collapse. Shift is the
# user's explicit direction, so it outranks the grid, which catches
# everything else.
- endpoint = snap_to_endpoint_for(self.canvas, current_pos, avoid=self._start_pos)
- if endpoint is not None:
- return endpoint
- if self._angle_locked and self._start_pos is not None:
- x, y = snapped_line_end(
- (self._start_pos.x(), self._start_pos.y()),
- (current_pos.x(), current_pos.y()),
- step_degrees=LINE_ANGLE_STEP_DEGREES,
- )
- return QPointF(x, y)
- return snap_to_grid_for(self.canvas, current_pos)
+ return snap_drawing_point_for(
+ self.canvas,
+ current_pos,
+ avoid=self._start_pos,
+ angle_step=LINE_ANGLE_STEP_DEGREES if self._angle_locked else None,
+ )
@override
def on_mouse_move(self, event) -> bool:
@@ -76,17 +66,17 @@ def _build_preview(self, current_pos):
def _commit_drag(self, end_pos) -> None:
end = self._end_point(end_pos)
if end == self._start_pos:
- if self.context.item_at_scene_pos(end) is not None:
- # A click on an existing object is a selection or a
- # double-click gesture, never a request for a new level.
- return
- length = (
- self.canvas.renderer.style.bond_length_px * LEVEL_PRESET_BOND_LENGTHS
+ point = line_click_endpoint(
+ (self._start_pos.x(), self._start_pos.y()),
+ bond_length=self.canvas.renderer.style.bond_length_px,
+ occupied=self.context.item_at_scene_pos(end) is not None,
)
- end = QPointF(self._start_pos.x() + length, self._start_pos.y())
+ if point is None:
+ return
+ end = QPointF(*point)
self.canvas.services.scene_decoration_service.add_arrow(
self._start_pos, end, self._line_kind()
)
-__all__ = ["LEVEL_PRESET_BOND_LENGTHS", "LINE_ANGLE_STEP_DEGREES", "LineTool"]
+__all__ = ["LineTool"]
diff --git a/app/chemvas/ui/tools/text_tool.py b/app/chemvas/ui/tools/text_tool.py
index bbe2c831..3de71b54 100644
--- a/app/chemvas/ui/tools/text_tool.py
+++ b/app/chemvas/ui/tools/text_tool.py
@@ -11,6 +11,7 @@
add_or_update_atom_label,
)
from chemvas.ui.tools.text_tool_logic import (
+ apply_text_input,
normalize_text_symbol,
plan_text_input,
resolve_text_tool_target,
@@ -74,25 +75,15 @@ def on_mouse_press(self, event) -> bool:
if not ok:
return True
text = normalize_text_symbol(text)
- if atom_id is not None and not text and existing_element.upper() != "C":
- # An empty label would leave this atom drawn as a bare skeleton
- # vertex while the model and every export still carry the
- # element — the drawing and the chemistry would silently
- # disagree. Refuse it like other unrepresentable input. Carbon
- # matching is case-insensitive because the dot fallback that
- # keeps a cleared carbon findable follows the same contract.
- notify_error_for(
- self.canvas,
- "Cannot hide the symbol of a non-carbon atom. Enter C to "
- "make it carbon, or delete the atom instead.",
- )
- return True
- if atom_id is None:
- if text:
- add_labelled_atom_for(self.canvas, cast("str", text), pos.x(), pos.y())
- return True
- add_or_update_atom_label(
- self.canvas, atom_id, cast("str", text), show_carbon=True
+ apply_text_input(
+ target,
+ cast("str", text),
+ existing_element,
+ add_atom=lambda text, x, y: add_labelled_atom_for(self.canvas, text, x, y),
+ update_label=lambda atom_id, text, **kwargs: add_or_update_atom_label(
+ self.canvas, atom_id, text, **kwargs
+ ),
+ notify_error=lambda message: notify_error_for(self.canvas, message),
)
return True
diff --git a/app/chemvas/ui/tools/text_tool_logic.py b/app/chemvas/ui/tools/text_tool_logic.py
index 7a846271..6fffa4bb 100644
--- a/app/chemvas/ui/tools/text_tool_logic.py
+++ b/app/chemvas/ui/tools/text_tool_logic.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -101,9 +101,39 @@ def _bond(bonds: list[Bond | None], bond_id: int | None) -> Bond | None:
return bonds[bond_id]
+def apply_text_input(
+ target: TextToolTarget,
+ text: str,
+ existing_element: str,
+ *,
+ add_atom: Any,
+ update_label: Any,
+ notify_error: Any,
+) -> None:
+ """Apply the text tool's existing carbon and insertion rules through its ports."""
+ if target.atom_id is not None and not text and existing_element.upper() != "C":
+ # An empty label would leave this atom drawn as a bare skeleton
+ # vertex while the model and every export still carry the
+ # element — the drawing and the chemistry would silently
+ # disagree. Refuse it like other unrepresentable input. Carbon
+ # matching is case-insensitive because the dot fallback that
+ # keeps a cleared carbon findable follows the same contract.
+ notify_error(
+ "Cannot hide the symbol of a non-carbon atom. Enter C to "
+ "make it carbon, or delete the atom instead."
+ )
+ return
+ if target.atom_id is None:
+ if text:
+ add_atom(text, *target.pos)
+ return
+ update_label(target.atom_id, text, show_carbon=True)
+
+
__all__ = [
"TextInputPlan",
"TextToolTarget",
+ "apply_text_input",
"normalize_text_symbol",
"plan_text_input",
"resolve_text_tool_target",
diff --git a/app/chemvas/ui/window/main_window_canvas_logic.py b/app/chemvas/ui/window/main_window_canvas_logic.py
index 3649ff41..199b1230 100644
--- a/app/chemvas/ui/window/main_window_canvas_logic.py
+++ b/app/chemvas/ui/window/main_window_canvas_logic.py
@@ -10,34 +10,14 @@
set_history_change_callback_for,
)
from chemvas.ui.canvas.sheet_setup_access import set_sheet_setup_for, sheet_setup_for
+from chemvas.ui.window.main_window_config import (
+ CANVAS_TEMPLATE_TEXT_FIELDS,
+ CANVAS_TEMPLATE_TOOL_FIELDS,
+)
if TYPE_CHECKING:
from collections.abc import Sequence
-CANVAS_TEMPLATE_TOOL_FIELDS = (
- "arrow_line_width",
- "arrow_head_scale",
- "orbital_phase_enabled",
- "mark_kind",
-)
-
-CANVAS_TEMPLATE_TEXT_FIELDS = (
- "text_font_family",
- "text_font_size",
- "text_font_weight",
- "text_italic",
- "text_color",
- "text_alignment",
- "text_line_spacing",
- "note_box_enabled",
- "note_box_color",
- "note_box_alpha",
- "note_border_enabled",
- "note_border_color",
- "note_border_width",
- "note_padding",
-)
-
def resolve_active_canvas(
current_widget,
@@ -117,8 +97,6 @@ def bind_active_canvas_callbacks(
__all__ = [
- "CANVAS_TEMPLATE_TEXT_FIELDS",
- "CANVAS_TEMPLATE_TOOL_FIELDS",
"active_canvas_index",
"active_canvas_tab_index",
"bind_active_canvas_callbacks",
diff --git a/app/chemvas/ui/window/main_window_config.py b/app/chemvas/ui/window/main_window_config.py
index 01bf8aad..475b30ff 100644
--- a/app/chemvas/ui/window/main_window_config.py
+++ b/app/chemvas/ui/window/main_window_config.py
@@ -1,6 +1,191 @@
from __future__ import annotations
+from typing import Any
+
from chemvas.features.annotations import BRACKET_MENU_SPECS
+from chemvas.features.rendering import (
+ DOUBLE_STYLE_CENTER,
+ DOUBLE_STYLE_DEFAULT,
+ DOUBLE_STYLE_OUTER,
+)
+
+SMILES_ENTRY_SPEC = {
+ "label": "SMILES",
+ "placeholder": "CC(=O)Oc1ccccc1C(=O)O",
+ "tooltip": "SMILES: Enter to preview and place a structure (requires RDKit)",
+ "button_label": "Insert",
+ "button_tooltip": "Preview and place the typed SMILES structure",
+}
+
+FLIP_ACTION_SPECS = (
+ ("flip_horizontal_button", "icon_flip_h", "Flip Horizontal", "Ctrl+Shift+H", True),
+ ("flip_vertical_button", "icon_flip_v", "Flip Vertical", "Ctrl+Shift+V", False),
+)
+
+# The accent a handle is outlined with, and the fill of one that has taken
+# hold of another item's endpoint.
+HANDLE_ACCENT_COLOR = "#0d9488"
+# Handles are an input affordance, so their size is a distance on screen
+# rather than in the document: a corner or endpoint handle is this wide at
+# any zoom, and an edge-midpoint resize handle is the smaller one.
+HANDLE_SCREEN_PX = 8.0
+
+# View magnification limits and the per-step multiplier shared by the toolbar
+# buttons and the Ctrl+= / Ctrl+- shortcuts. Ctrl+wheel uses a finer factor.
+ZOOM_MIN = 0.2
+ZOOM_MAX = 5.0
+ZOOM_STEP = 1.25
+WHEEL_ZOOM_BASE = 1.0015
+WHEEL_ANGLE_PER_PIXEL = 2.0
+# The context bar's bond length field; stepper arrows move one step.
+BOND_LENGTH_INPUT_SPEC: dict[str, int | float | str] = {
+ "decimals": 1,
+ "step": 1.0,
+ "tooltip": "Bond length: rescale molecular geometry and attached marks",
+ "status_tip": (
+ "Set bond length in pixels and rescale molecular geometry and attached marks; "
+ "free annotations keep their positions"
+ ),
+ "up_tooltip": "Increase bond length",
+ "down_tooltip": "Decrease bond length",
+}
+# Canvas view keys: Control with a zoom key, or an unmodified function key.
+ZOOM_KEY_ACTIONS = {
+ "+": "zoom_in",
+ "=": "zoom_in",
+ "-": "zoom_out",
+ "_": "zoom_out",
+ "0": "actual_size",
+}
+VIEW_FUNCTION_KEY_ACTIONS = {
+ "F5": "actual_size",
+ "F6": "fit",
+ "F7": "zoom_in",
+ "F8": "zoom_out",
+}
+# Selection keyboard transforms: Alt+arrow turns by degrees, Shift+arrow moves.
+ARROW_KEY_ROTATION_DEGREES = {"Up": -15.0, "Down": 15.0, "Left": -1.0, "Right": 1.0}
+ARROW_KEY_NUDGE = {
+ "Up": (0.0, -10.0),
+ "Down": (0.0, 10.0),
+ "Left": (-10.0, 0.0),
+ "Right": (10.0, 0.0),
+}
+# A new canvas continues these settings of the active one (with its bond
+# length and sheet setup).
+CANVAS_TEMPLATE_TOOL_FIELDS = (
+ "arrow_line_width",
+ "arrow_head_scale",
+ "orbital_phase_enabled",
+ "mark_kind",
+)
+
+CANVAS_TEMPLATE_TEXT_FIELDS = (
+ "text_font_family",
+ "text_font_size",
+ "text_font_weight",
+ "text_italic",
+ "text_color",
+ "text_alignment",
+ "text_line_spacing",
+ "note_box_enabled",
+ "note_box_color",
+ "note_box_alpha",
+ "note_border_enabled",
+ "note_border_color",
+ "note_border_width",
+ "note_padding",
+)
+
+# Fit to Window leaves this fraction of the viewport for the sheet.
+FIT_VIEW_MARGIN = 0.92
+
+ORBITAL_PHASE_SPECS = (("Phase Off", False), ("Phase On", True))
+ORBITAL_MO_TEXT = {"mo_bonding": "MO+", "mo_antibonding": "MO−"}
+ARROW_SLIDER_LABELS = {
+ "arrow_line_width": "Arrow line width",
+ "arrow_head_scale": "Arrow head size",
+}
+MOLECULE_INFO_TITLE = "Molecule Info"
+REACTION_MAPPING_TITLE = "Reaction Mapping"
+
+# Shift+letter switches to a tool and resets that tool's kind to its default.
+SHIFT_TOOL_HOTKEYS = {"T": "ts_bracket", "G": "orbital", "E": "mark"}
+
+TOOL_HOTKEYS = {
+ " ": "select",
+ "x": "bond",
+ "a": "text",
+ "t": "note",
+ "e": "arrow",
+ "j": "benzene",
+}
+
+# Right-click positions for a double bond; bold doubles keep their family.
+DOUBLE_BOND_CONTEXT_STYLES = (
+ ("Inward", DOUBLE_STYLE_DEFAULT),
+ ("Centered", DOUBLE_STYLE_CENTER),
+ ("Outward", DOUBLE_STYLE_OUTER),
+)
+
+# The context bar page each canvas tool shows; both selection tools act on a
+# selection (flip, rotate, align, distribute).
+TOOL_CONTEXT_PAGE_KEYS = {
+ "select": "select",
+ "perspective": "select",
+ "bond": "bond",
+ "arrow": "arrow",
+ "line": "line",
+ "ts_bracket": "bracket",
+ "text": "atom",
+ "note": "text",
+ "mark": "mark",
+ "benzene": "ring",
+ "color": "color",
+ "orbital": "orbital",
+ "shape": "shape",
+}
+
+# The caption at the start of each context bar page.
+CONTEXT_PAGE_CAPTIONS = {
+ "select": "Select",
+ "bond": "Bond",
+ "ring": "Ring",
+ "mark": "Mark",
+ "arrow": "Arrow",
+ "bracket": "Bracket",
+ "atom": "Atom",
+ "text": "Text",
+ "orbital": "Orbital",
+ "shape": "Shape",
+ "line": "Line",
+ "color": "Color",
+ "ring_fill": "Ring Fill",
+}
+
+# The Text page: size steps, then format groups separated by dividers.
+TEXT_SIZE_ACTION_SPECS = (
+ ("icon_text_size_decrease", "Decrease font size", -1),
+ ("icon_text_size_increase", "Increase font size", 1),
+)
+TEXT_FORMAT_ACTION_GROUPS = (
+ (
+ ("bold", "icon_text_bold", "Bold the selected text"),
+ ("italic", "icon_text_italic", "Italicize the selected text"),
+ ),
+ (
+ ("superscript", "icon_text_superscript", "Superscript the selected text"),
+ ("subscript", "icon_text_subscript", "Subscript the selected text"),
+ ),
+ (
+ ("left", "icon_align_left", "Align left"),
+ ("center", "icon_align_center", "Align center"),
+ ("right", "icon_align_right", "Align right"),
+ ),
+)
+# Font size steps keep each run within these point sizes.
+TEXT_POINT_SIZE_RANGE = (6.0, 96.0)
+TEXT_FORMAT_TARGET_MESSAGE = "Select a note or edit its text to use Text formatting."
ARROW_MENU_SPECS: list[tuple[str, str]] = [
("Reaction", "reaction"),
@@ -17,7 +202,48 @@
("Arc 270°", "arc_270_left"),
]
+# Arrow kinds folded into the "More arrows" menu: the equilibrium variants,
+# inhibition and the three arcs are drawn far less often than the rest.
+MORE_ARROW_KINDS: frozenset[str] = frozenset(
+ {
+ "equilibrium_forward",
+ "equilibrium_reverse",
+ "inhibit",
+ "arc_90_left",
+ "arc_180_left",
+ "arc_270_left",
+ }
+)
+
+
+LINE_KIND_SPECS = [
+ ("line", "Line"),
+ ("line_dashed", "Dashed line"),
+ ("line_wavy", "Wavy line"),
+ ("line_bold", "Bold line"),
+]
+
+
ARROW_PRESET_SPECS: list[str] = ["Default", "Bold", "Fine"]
+ARROW_SLIDER_PAGE_STEP = 10
+ARROW_SLIDER_RANGES = {
+ "arrow_line_width": (5, 60, 10),
+ "arrow_head_scale": (10, 80, 100),
+}
+
+RING_FILL_GUIDANCE = "Ring Fill: select a complete ring (all its atoms or bonds) first."
+
+# Items the Color tool recolors; arrows of every kind join these.
+COLOR_TARGET_KINDS = frozenset(
+ ("bond", "atom", "ring", "note", "shape", "mark", "ts_bracket")
+)
+COLOR_TOOL_MESSAGES = {
+ "choose": "Color: choose a swatch before painting.",
+ "hidden": "Color stored for implicit carbon; hidden carbon vertices stay hidden. "
+ "Color the bonds or show an explicit atom label for visible color.",
+ "ts_bracket": "TS brackets and daggers use the document bond color; "
+ "per-item color is not supported.",
+}
COLOR_PALETTE_SPECS: list[tuple[str, str]] = [
("Black", "#000000"),
@@ -156,18 +382,160 @@
"Verdana",
)
+
+TOOL_HINTS: dict[str, str] = {
+ "select": "Select: double-click arrows/lines for labels",
+ "bond": "Bond: click-drag to draw",
+ "text": "Atom / Text: click to place label",
+ "mark": "Mark: click atom or label",
+ "benzene": "Ring: click to place template",
+ "arrow": "Arrow: drag to draw; double-click for labels",
+ "line": "Line: double-click for labels; Shift locks angle",
+ "note": "Text: click to add/edit; Esc to finish",
+ "ts_bracket": "Brackets: drag around selection",
+ "orbital": "Orbital: click to place",
+ "perspective": "Perspective: drag selection to rotate",
+ "color": "Color: choose a swatch",
+ "ring_fill": "Ring Fill: select a complete ring, then choose a fill color",
+}
+
+
+BOND_ORDER_SEGMENTS = [
+ ("Single", "icon_bond", "Single bond (1)"),
+ ("Double", "icon_bond_double", "Double bond (2)"),
+ ("Triple", "icon_bond_triple", "Triple bond (3)"),
+]
+
+
+BOND_MODIFIERS = [
+ ("Bold", "icon_bond_bold", "Bold bond (B)"),
+ ("Wedge", "icon_bond_wedge", "Wedge bond (W)"),
+ ("Hash", "icon_bond_hash", "Hash bond (Shift+H)"),
+ ("Dotted", "icon_bond_dotted", "Dotted bond"),
+]
+
+
+ATOM_INPUT_SPEC: dict[str, str | int] = {
+ "placeholder": "Atom",
+ "tooltip": "Atom Symbol",
+ "status": "Set the atom symbol used by atom and bond tools",
+ "min_width": 60,
+ "max_width": 240,
+ "max_length": 255,
+}
+
+IMAGE_PROPERTIES_SPEC: dict[str, Any] = {
+ "title": "Image Properties",
+ "original": "Original: {width} \u00d7 {height} pixels",
+ "fields": (("x", "X"), ("y", "Y"), ("width", "Width"), ("height", "Height")),
+ "field_label": "{label} (canvas units)",
+ "decimals": 4,
+ "size_minimum": 0.0001,
+ "coordinate_minimum": -1_000_000,
+ "maximum": 1_000_000,
+ "lock_aspect": "Lock to original aspect ratio",
+ "opacity": "Opacity",
+ "opacity_decimals": 1,
+ "opacity_suffix": " %",
+ "choose": "Choose an image to edit. The group stays together.",
+ "choice": "Image {index}: {width} \u00d7 {height} pixels, at ({x:g}, {y:g})",
+ "none_selected": "Select an image first.",
+}
+
+SHAPE_KIND_SPECS = [
+ ("circle", "Circle"),
+ ("ellipse", "Ellipse"),
+ ("rounded_rect", "Rounded rectangle"),
+ ("rect", "Rectangle"),
+]
+
+SHAPE_STROKE_SPECS = [
+ ("solid", "Solid outline"),
+ ("dashed", "Dashed outline"),
+ ("dotted", "Dotted outline"),
+ ("none", "No outline"),
+]
+
+ROTATE_ANGLE_RANGE = (-180, 180)
+ROTATE_ANGLE_DEFAULT = 15
+
+# The rotation knob sits this far above its selection frame, on a stem.
+ROTATION_HANDLE_STEM_PX = 14.0
+ROTATION_HANDLE_TYPE = "selection_rotate"
+SELECTION_FRAME_RADIUS = 2.0
+
+
+ALIGN_SPECS: tuple[tuple[str, str], ...] = (
+ ("left", "Align left edges"),
+ ("center", "Align horizontal centres"),
+ ("right", "Align right edges"),
+ ("top", "Align top edges"),
+ ("middle", "Align vertical centres"),
+ ("bottom", "Align bottom edges"),
+)
+DISTRIBUTE_SPECS: tuple[tuple[str, str], ...] = (
+ ("horizontal", "Distribute horizontally with equal gaps"),
+ ("vertical", "Distribute vertically with equal gaps"),
+)
+
+
+ALIGN_MENU_SPECS: tuple[tuple[str, str], ...] = (
+ ("Left", "left"),
+ ("Center", "center"),
+ ("Right", "right"),
+ ("Top", "top"),
+ ("Middle", "middle"),
+ ("Bottom", "bottom"),
+)
+DISTRIBUTE_MENU_SPECS: tuple[tuple[str, str], ...] = (
+ ("Horizontally", "horizontal"),
+ ("Vertically", "vertical"),
+)
+
+
__all__ = [
+ "ARROW_KEY_NUDGE",
+ "ARROW_KEY_ROTATION_DEGREES",
"ARROW_MENU_SPECS",
"ARROW_PRESET_SPECS",
+ "ARROW_SLIDER_PAGE_STEP",
+ "ARROW_SLIDER_RANGES",
+ "BOND_LENGTH_INPUT_SPEC",
+ "BOND_MODIFIERS",
+ "BOND_ORDER_SEGMENTS",
"BOND_TOOL_ACTION_SPECS",
"BRACKET_MENU_SPECS",
+ "CANVAS_TEMPLATE_TEXT_FIELDS",
+ "CANVAS_TEMPLATE_TOOL_FIELDS",
"COLOR_PALETTE_SPECS",
+ "COLOR_TARGET_KINDS",
+ "CONTEXT_PAGE_CAPTIONS",
+ "DOUBLE_BOND_CONTEXT_STYLES",
+ "FIT_VIEW_MARGIN",
+ "HANDLE_ACCENT_COLOR",
+ "HANDLE_SCREEN_PX",
"MARK_TOOL_ACTION_SPECS",
"RING_FILL_TOOL_ACTION_SPEC",
+ "ROTATE_ANGLE_DEFAULT",
+ "ROTATE_ANGLE_RANGE",
+ "ROTATION_HANDLE_STEM_PX",
+ "ROTATION_HANDLE_TYPE",
+ "SELECTION_FRAME_RADIUS",
+ "SHAPE_KIND_SPECS",
+ "SHAPE_STROKE_SPECS",
+ "SMILES_ENTRY_SPEC",
"TEMPLATE_ENTRY_SPECS",
"TEXT_FONT_FAMILY_CHOICES",
+ "TEXT_FORMAT_ACTION_GROUPS",
+ "TEXT_FORMAT_TARGET_MESSAGE",
+ "TEXT_POINT_SIZE_RANGE",
+ "TEXT_SIZE_ACTION_SPECS",
"TOOLBAR_PRIMARY_TOOL_GROUP",
"TOOLBAR_TOOL_ACTION_ORDER",
"TOOLBAR_TOOL_GROUPS",
"TOOL_ACTION_SPECS",
+ "TOOL_CONTEXT_PAGE_KEYS",
+ "TOOL_HINTS",
+ "VIEW_FUNCTION_KEY_ACTIONS",
+ "ZOOM_KEY_ACTIONS",
]
diff --git a/app/chemvas/ui/window/main_window_context_bar_page_factories.py b/app/chemvas/ui/window/main_window_context_bar_page_factories.py
index 8654d3ae..31ff65b7 100644
--- a/app/chemvas/ui/window/main_window_context_bar_page_factories.py
+++ b/app/chemvas/ui/window/main_window_context_bar_page_factories.py
@@ -15,12 +15,31 @@
)
from chemvas.ui.window.main_window_config import (
+ ALIGN_SPECS,
ARROW_MENU_SPECS,
ARROW_PRESET_SPECS,
+ ARROW_SLIDER_LABELS,
+ ARROW_SLIDER_PAGE_STEP,
+ ARROW_SLIDER_RANGES,
BRACKET_MENU_SPECS,
COLOR_PALETTE_SPECS,
+ CONTEXT_PAGE_CAPTIONS,
+ DISTRIBUTE_SPECS,
+ FLIP_ACTION_SPECS,
+ LINE_KIND_SPECS,
MARK_TOOL_ACTION_SPECS,
+ MORE_ARROW_KINDS,
+ ORBITAL_MO_TEXT,
+ ORBITAL_PHASE_SPECS,
+ SHAPE_KIND_SPECS,
+ SHAPE_STROKE_SPECS,
TEMPLATE_ENTRY_SPECS,
+ TEXT_FORMAT_ACTION_GROUPS,
+ TEXT_SIZE_ACTION_SPECS,
+)
+from chemvas.ui.window.main_window_config import BOND_MODIFIERS as _BOND_MODIFIERS
+from chemvas.ui.window.main_window_config import (
+ BOND_ORDER_SEGMENTS as _BOND_ORDER_SEGMENTS,
)
from chemvas.ui.window.main_window_context_bar_widgets import (
BondLengthSpinBox,
@@ -46,19 +65,6 @@
from chemvas.ui.window.main_window_like import MainWindowLike
-_BOND_ORDER_SEGMENTS = [
- ("Single", "icon_bond", "Single bond (1)"),
- ("Double", "icon_bond_double", "Double bond (2)"),
- ("Triple", "icon_bond_triple", "Triple bond (3)"),
-]
-
-_BOND_MODIFIERS = [
- ("Bold", "icon_bond_bold", "Bold bond (B)"),
- ("Wedge", "icon_bond_wedge", "Wedge bond (W)"),
- ("Hash", "icon_bond_hash", "Hash bond (Shift+H)"),
- ("Dotted", "icon_bond_dotted", "Dotted bond"),
-]
-
_LABEL_BY_STYLE = {value: label for label, value in BOND_STYLE_BY_LABEL.items()}
@@ -140,20 +146,6 @@ def build_empty_page() -> QWidget:
return page
-ALIGN_SPECS: tuple[tuple[str, str], ...] = (
- ("left", "Align left edges"),
- ("center", "Align horizontal centres"),
- ("right", "Align right edges"),
- ("top", "Align top edges"),
- ("middle", "Align vertical centres"),
- ("bottom", "Align bottom edges"),
-)
-DISTRIBUTE_SPECS: tuple[tuple[str, str], ...] = (
- ("horizontal", "Distribute horizontally with equal gaps"),
- ("vertical", "Distribute vertically with equal gaps"),
-)
-
-
def build_select_page(
window: MainWindowLike,
*,
@@ -165,21 +157,10 @@ def build_select_page(
"""Everything that acts on the current selection: flip, rotate, align, distribute."""
icons = window.ui_references.require_icon_factory()
page, layout = new_context_page()
- layout.addWidget(hint_label("Select"))
- for object_name, icon, tooltip, horizontal in (
- (
- "flip_horizontal_button",
- icons.icon_flip_h(),
- f"Flip Horizontal ({QKeySequence('Ctrl+Shift+H').toString(QKeySequence.SequenceFormat.NativeText)})",
- True,
- ),
- (
- "flip_vertical_button",
- icons.icon_flip_v(),
- f"Flip Vertical ({QKeySequence('Ctrl+Shift+V').toString(QKeySequence.SequenceFormat.NativeText)})",
- False,
- ),
- ):
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["select"]))
+ for object_name, icon_name, label, shortcut, horizontal in FLIP_ACTION_SPECS:
+ icon = getattr(icons, icon_name)()
+ tooltip = f"{label} ({QKeySequence(shortcut).toString(QKeySequence.SequenceFormat.NativeText)})"
button = icon_button(icon, tooltip)
button.setObjectName(object_name)
button.setStatusTip(tooltip)
@@ -235,7 +216,7 @@ def build_bond_page(
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Bond"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["bond"]))
group = QButtonGroup(page)
group.setExclusive(True)
buttons: dict[str, QToolButton] = {}
@@ -272,7 +253,7 @@ def build_template_page(
) -> TemplateContextPage:
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Ring"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["ring"]))
group = QButtonGroup(page)
group.setExclusive(True)
buttons: dict[tuple[int, str], QToolButton] = {}
@@ -301,7 +282,7 @@ def build_mark_page(window: MainWindowLike, tool_state_service) -> ButtonGroupPa
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Mark"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["mark"]))
group = QButtonGroup(page)
group.setExclusive(True)
buttons: dict[str, QToolButton] = {}
@@ -325,27 +306,13 @@ def build_mark_page(window: MainWindowLike, tool_state_service) -> ButtonGroupPa
return ButtonGroupPage(page=page, group=group, buttons=buttons)
-# Arrow kinds folded into the "More arrows" menu: the equilibrium variants,
-# inhibition and the three arcs are drawn far less often than the rest.
-MORE_ARROW_KINDS: frozenset[str] = frozenset(
- {
- "equilibrium_forward",
- "equilibrium_reverse",
- "inhibit",
- "arc_90_left",
- "arc_180_left",
- "arc_270_left",
- }
-)
-
-
def build_arrow_page(
window: MainWindowLike, tool_mode_controller, tool_state_service
) -> ArrowContextPage:
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Arrow"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["arrow"]))
group = QButtonGroup(page)
group.setExclusive(True)
buttons: dict[str, QToolButton] = {}
@@ -398,29 +365,37 @@ def build_arrow_page(
layout.addWidget(divider())
width = QSlider(Qt.Orientation.Horizontal)
width.setTracking(False)
- width.setMinimum(5)
- width.setMaximum(60)
- width.setValue(round(tool_mode_controller.get_arrow_line_width() * 10))
+ width.setPageStep(ARROW_SLIDER_PAGE_STEP)
+ width_min, width_max, width_factor = ARROW_SLIDER_RANGES["arrow_line_width"]
+ width.setMinimum(width_min)
+ width.setMaximum(width_max)
+ width.setValue(round(tool_mode_controller.get_arrow_line_width() * width_factor))
width.valueChanged.connect(
- lambda v: tool_mode_controller.set_arrow_line_width(v / 10.0)
+ lambda v: tool_mode_controller.set_arrow_line_width(v / width_factor)
)
layout.addWidget(
slider_dropdown_button(
- icon_factory.icon_arrow_width(), "Arrow line width", width
+ icon_factory.icon_arrow_width(),
+ ARROW_SLIDER_LABELS["arrow_line_width"],
+ width,
)
)
head = QSlider(Qt.Orientation.Horizontal)
head.setTracking(False)
- head.setMinimum(10)
- head.setMaximum(80)
- head.setValue(round(tool_mode_controller.get_arrow_head_scale() * 100))
+ head.setPageStep(ARROW_SLIDER_PAGE_STEP)
+ head_min, head_max, head_factor = ARROW_SLIDER_RANGES["arrow_head_scale"]
+ head.setMinimum(head_min)
+ head.setMaximum(head_max)
+ head.setValue(round(tool_mode_controller.get_arrow_head_scale() * head_factor))
head.valueChanged.connect(
- lambda v: tool_mode_controller.set_arrow_head_scale(v / 100.0)
+ lambda v: tool_mode_controller.set_arrow_head_scale(v / head_factor)
)
layout.addWidget(
slider_dropdown_button(
- icon_factory.icon_arrow_head_scale(), "Arrow head size", head
+ icon_factory.icon_arrow_head_scale(),
+ ARROW_SLIDER_LABELS["arrow_head_scale"],
+ head,
)
)
@@ -434,7 +409,7 @@ def build_bracket_page(window: MainWindowLike, tool_state_service) -> ButtonGrou
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Bracket"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["bracket"]))
group = QButtonGroup(page)
group.setExclusive(True)
buttons: dict[str, QToolButton] = {}
@@ -460,7 +435,7 @@ def build_bracket_page(window: MainWindowLike, tool_state_service) -> ButtonGrou
def build_atom_page(current_symbol: str, set_atom_symbol) -> AtomContextPage:
page, layout = new_context_page()
- layout.addWidget(hint_label("Atom"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["atom"]))
atom_input = atom_symbol_input(
current_symbol,
set_atom_symbol,
@@ -489,61 +464,32 @@ def build_text_page(
) -> TextContextPage:
icons = window.ui_references.require_icon_factory()
page, layout = new_context_page()
- layout.addWidget(hint_label("Text"))
- layout.addWidget(
- _text_icon_button(
- icons.icon_text_size_decrease(),
- "Decrease font size",
- lambda: adjust_size(-1),
- )
- )
- layout.addWidget(
- _text_icon_button(
- icons.icon_text_size_increase(),
- "Increase font size",
- lambda: adjust_size(1),
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["text"]))
+ for icon, tip, delta in TEXT_SIZE_ACTION_SPECS:
+ layout.addWidget(
+ _text_icon_button(
+ getattr(icons, icon)(), tip, lambda delta=delta: adjust_size(delta)
+ )
)
- )
+ handlers = {
+ "bold": toggle_bold,
+ "italic": toggle_italic,
+ "superscript": toggle_superscript,
+ "subscript": toggle_subscript,
+ **{
+ name: lambda name=name: set_alignment(name)
+ for name in ("left", "center", "right")
+ },
+ }
buttons = {}
- for key, icon, tip, handler in (
- ("bold", icons.icon_text_bold(), "Bold the selected text", toggle_bold),
- (
- "italic",
- icons.icon_text_italic(),
- "Italicize the selected text",
- toggle_italic,
- ),
- (
- "superscript",
- icons.icon_text_superscript(),
- "Superscript the selected text",
- toggle_superscript,
- ),
- (
- "subscript",
- icons.icon_text_subscript(),
- "Subscript the selected text",
- toggle_subscript,
- ),
- ("left", icons.icon_align_left(), "Align left", lambda: set_alignment("left")),
- (
- "center",
- icons.icon_align_center(),
- "Align center",
- lambda: set_alignment("center"),
- ),
- (
- "right",
- icons.icon_align_right(),
- "Align right",
- lambda: set_alignment("right"),
- ),
- ):
- if key in {"bold", "superscript", "left"}:
- layout.addWidget(divider())
- button = _text_icon_button(icon, tip, handler, checkable=True)
- buttons[key] = button
- layout.addWidget(button)
+ for group in TEXT_FORMAT_ACTION_GROUPS:
+ layout.addWidget(divider())
+ for key, icon, tip in group:
+ button = _text_icon_button(
+ getattr(icons, icon)(), tip, handlers[key], checkable=True
+ )
+ buttons[key] = button
+ layout.addWidget(button)
layout.addStretch(1)
return TextContextPage(page, buttons)
@@ -553,7 +499,7 @@ def build_orbital_page(
) -> AnnotationContextPage:
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Orbital"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["orbital"]))
kind_group = QButtonGroup(page)
kinds: dict[str | bool, QToolButton] = {}
phases: dict[str | bool, QToolButton] = {}
@@ -564,8 +510,8 @@ def build_orbital_page(
)
kinds[kind] = button
kind_group.addButton(button)
- if kind in {"mo_bonding", "mo_antibonding"}:
- button.setText("MO+" if kind == "mo_bonding" else "MO−")
+ if kind in ORBITAL_MO_TEXT:
+ button.setText(ORBITAL_MO_TEXT[kind])
button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
button.setFixedWidth(40)
button.clicked.connect(
@@ -575,7 +521,7 @@ def build_orbital_page(
)
layout.addWidget(button)
layout.addWidget(divider())
- for label, enabled in (("Phase Off", False), ("Phase On", True)):
+ for label, enabled in ORBITAL_PHASE_SPECS:
button = icon_button(
icon_factory.icon_orbital_phase(enabled), label, checkable=True
)
@@ -593,32 +539,17 @@ def build_orbital_page(
)
-_SHAPE_KIND_SPECS = [
- ("circle", "Circle"),
- ("ellipse", "Ellipse"),
- ("rounded_rect", "Rounded rectangle"),
- ("rect", "Rectangle"),
-]
-
-_SHAPE_STROKE_SPECS = [
- ("solid", "Solid outline"),
- ("dashed", "Dashed outline"),
- ("dotted", "Dotted outline"),
- ("none", "No outline"),
-]
-
-
def build_shape_page(
window: MainWindowLike, tool_state_service
) -> AnnotationContextPage:
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Shape"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["shape"]))
kinds: dict[str | bool, QToolButton] = {}
kind_group = QButtonGroup(page)
kind_group.setExclusive(True)
- for kind, tip in _SHAPE_KIND_SPECS:
+ for kind, tip in SHAPE_KIND_SPECS:
button = icon_button(icon_factory.icon_shape_kind(kind), tip, checkable=True)
kinds[kind] = button
button.clicked.connect(
@@ -633,7 +564,7 @@ def build_shape_page(
strokes: dict[str | bool, QToolButton] = {}
stroke_group = QButtonGroup(page)
stroke_group.setExclusive(True)
- for style, tip in _SHAPE_STROKE_SPECS:
+ for style, tip in SHAPE_STROKE_SPECS:
button = icon_button(icon_factory.icon_shape_stroke(style), tip, checkable=True)
strokes[style] = button
button.clicked.connect(
@@ -650,25 +581,17 @@ def build_shape_page(
)
-_LINE_KIND_SPECS = [
- ("line", "Line"),
- ("line_dashed", "Dashed line"),
- ("line_wavy", "Wavy line"),
- ("line_bold", "Bold line"),
-]
-
-
def build_line_page(
window: MainWindowLike, tool_state_service
) -> AnnotationContextPage:
page, layout = new_context_page()
icon_factory = window.ui_references.require_icon_factory()
- layout.addWidget(hint_label("Line"))
+ layout.addWidget(hint_label(CONTEXT_PAGE_CAPTIONS["line"]))
kinds: dict[str | bool, QToolButton] = {}
kind_group = QButtonGroup(page)
kind_group.setExclusive(True)
- for kind, tip in _LINE_KIND_SPECS:
+ for kind, tip in LINE_KIND_SPECS:
button = icon_button(icon_factory.icon_line_kind(kind), tip, checkable=True)
kinds[kind] = button
button.clicked.connect(
diff --git a/app/chemvas/ui/window/main_window_context_bar_pages.py b/app/chemvas/ui/window/main_window_context_bar_pages.py
index eb9618bc..3940140b 100644
--- a/app/chemvas/ui/window/main_window_context_bar_pages.py
+++ b/app/chemvas/ui/window/main_window_context_bar_pages.py
@@ -3,6 +3,10 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
+from chemvas.ui.window.main_window_config import (
+ CONTEXT_PAGE_CAPTIONS,
+ TEXT_FORMAT_TARGET_MESSAGE,
+)
from chemvas.ui.window.main_window_context_bar_page_factories import (
AnnotationContextPage,
TextContextPage,
@@ -82,9 +86,7 @@ def _note_command(self, window: MainWindowLike, method_name: str, *args) -> None
if controller is None:
return
if not controller.text_format_targets():
- status_bar_for(window).showMessage(
- "Select a note or edit its text to use Text formatting.", 6000
- )
+ status_bar_for(window).showMessage(TEXT_FORMAT_TARGET_MESSAGE, 6000)
return
getattr(controller, method_name)(*args)
finally:
@@ -136,7 +138,7 @@ def build(self, window: MainWindowLike) -> ContextBarPages:
),
)
color_page = build_color_palette_page(
- tooltip_prefix="Color",
+ tooltip_prefix=CONTEXT_PAGE_CAPTIONS["color"],
apply_preset=lambda value: self._tool_routing.apply_color_preset(
window, value
),
@@ -166,7 +168,7 @@ def build(self, window: MainWindowLike) -> ContextBarPages:
**{key: value.page for key, value in annotations.items()},
"color": color_page.page,
"ring_fill": build_color_palette_page(
- tooltip_prefix="Ring Fill",
+ tooltip_prefix=CONTEXT_PAGE_CAPTIONS["ring_fill"],
apply_preset=lambda value: self._tool_routing.apply_ring_fill_preset(
window, value
),
diff --git a/app/chemvas/ui/window/main_window_context_bar_service.py b/app/chemvas/ui/window/main_window_context_bar_service.py
index 86c21d02..269e0e36 100644
--- a/app/chemvas/ui/window/main_window_context_bar_service.py
+++ b/app/chemvas/ui/window/main_window_context_bar_service.py
@@ -18,6 +18,7 @@
TOOLBAR_ICON_SIZE,
TOOLBAR_THICKNESS,
)
+from chemvas.ui.window.main_window_config import TOOL_CONTEXT_PAGE_KEYS
from chemvas.ui.window.main_window_context_bar_pages import bond_label_for_state
from chemvas.ui.window.main_window_context_bar_widgets import (
KindMenuButton,
@@ -38,26 +39,6 @@
from chemvas.ui.window.main_window_like import MainWindowLike
-# Maps the active canvas tool name to the context page key shown in the bar.
-_TOOL_PAGE_KEYS = {
- # Both selection tools act on a selection; the page holds what a selection
- # can be done to (flip, rotate, align, distribute).
- "select": "select",
- "perspective": "select",
- "bond": "bond",
- "arrow": "arrow",
- "line": "line",
- "ts_bracket": "bracket",
- "text": "atom",
- "note": "text",
- "mark": "mark",
- "benzene": "ring",
- "color": "color",
- "orbital": "orbital",
- "shape": "shape",
-}
-
-
class _TextOptionsObserver(QObject):
"""Refresh after native cursor/selection input, without stealing focus."""
@@ -167,7 +148,7 @@ def refresh(
) -> None:
if self._stack is None:
return
- key = page_key or _TOOL_PAGE_KEYS.get(tool or "", "empty")
+ key = page_key or TOOL_CONTEXT_PAGE_KEYS.get(tool or "", "empty")
page = self._pages.get(key, self._pages["empty"])
self._stack.setCurrentWidget(page)
self.reflect_ring_state(window)
diff --git a/app/chemvas/ui/window/main_window_context_bar_widgets.py b/app/chemvas/ui/window/main_window_context_bar_widgets.py
index 679d9c29..551dad4c 100644
--- a/app/chemvas/ui/window/main_window_context_bar_widgets.py
+++ b/app/chemvas/ui/window/main_window_context_bar_widgets.py
@@ -32,6 +32,13 @@
TOOLBAR_BUTTON_STYLE,
)
from chemvas.shell.toolbar_buttons import CornerMenuButton
+from chemvas.ui.window.main_window_config import (
+ ATOM_INPUT_SPEC,
+ BOND_LENGTH_INPUT_SPEC,
+ ROTATE_ANGLE_DEFAULT,
+ ROTATE_ANGLE_RANGE,
+ SMILES_ENTRY_SPEC,
+)
class _StepArrowButton(QToolButton):
@@ -203,19 +210,19 @@ def smiles_entry(begin_smiles_insert) -> QWidget:
entry, layout = new_context_page()
entry.setObjectName("quickSmilesEntry")
entry.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
- layout.addWidget(hint_label("SMILES"))
+ layout.addWidget(hint_label(SMILES_ENTRY_SPEC["label"]))
input_box = QLineEdit()
input_box.setObjectName("contextSmilesInput")
- input_box.setPlaceholderText("CC(=O)Oc1ccccc1C(=O)O")
+ input_box.setPlaceholderText(SMILES_ENTRY_SPEC["placeholder"])
input_box.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
input_box.setMinimumWidth(120)
input_box.setMaximumWidth(250)
input_box.setFixedHeight(CONTEXT_BAR_BUTTON_HEIGHT)
- input_box.setToolTip(
- "SMILES: Enter to preview and place a structure (requires RDKit)"
+ input_box.setToolTip(SMILES_ENTRY_SPEC["tooltip"])
+ input_box.setAccessibleName(SMILES_ENTRY_SPEC["label"])
+ button = action_button(
+ SMILES_ENTRY_SPEC["button_label"], SMILES_ENTRY_SPEC["button_tooltip"]
)
- input_box.setAccessibleName("SMILES")
- button = action_button("Insert", "Preview and place the typed SMILES structure")
button.setObjectName("smiles_render_button")
button.setStyleSheet(
CONTEXT_ACTION_BUTTON_STYLE
@@ -319,15 +326,15 @@ def slider_dropdown_button(icon, tooltip: str, slider: QSlider) -> QToolButton:
def atom_symbol_input(current_symbol: str, set_symbol) -> QLineEdit:
input_box = QLineEdit()
input_box.setObjectName("atomInput")
- input_box.setPlaceholderText("Atom")
- input_box.setMinimumWidth(60)
- input_box.setMaximumWidth(240)
+ input_box.setPlaceholderText(str(ATOM_INPUT_SPEC["placeholder"]))
+ input_box.setMinimumWidth(int(ATOM_INPUT_SPEC["min_width"]))
+ input_box.setMaximumWidth(int(ATOM_INPUT_SPEC["max_width"]))
input_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
input_box.setFixedHeight(CONTEXT_BAR_BUTTON_HEIGHT)
- input_box.setMaxLength(255)
+ input_box.setMaxLength(int(ATOM_INPUT_SPEC["max_length"]))
input_box.setText(current_symbol)
- input_box.setToolTip("Atom Symbol")
- input_box.setStatusTip("Set the atom symbol used by atom and bond tools")
+ input_box.setToolTip(str(ATOM_INPUT_SPEC["tooltip"]))
+ input_box.setStatusTip(str(ATOM_INPUT_SPEC["status"]))
input_box.textChanged.connect(set_symbol)
return input_box
@@ -376,8 +383,8 @@ def rotate_angle_input() -> tuple[QWidget, QSpinBox]:
spin = QSpinBox()
spin.setObjectName("rotateAngleInput")
- spin.setRange(-180, 180)
- spin.setValue(15)
+ spin.setRange(*ROTATE_ANGLE_RANGE)
+ spin.setValue(ROTATE_ANGLE_DEFAULT)
spin.setSuffix("°")
spin.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
spin.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
@@ -451,19 +458,16 @@ def bond_length_input(
spin = BondLengthSpinBox()
spin.setObjectName("bondLengthInput")
- spin.setDecimals(1)
+ spin.setDecimals(int(BOND_LENGTH_INPUT_SPEC["decimals"]))
spin.setRange(0.0, float(MAX_BOND_LENGTH_PX))
- spin.setSingleStep(1.0)
+ spin.setSingleStep(float(BOND_LENGTH_INPUT_SPEC["step"]))
spin.setSuffix(" px")
spin.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons)
spin.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
spin.setFixedWidth(64)
spin.setFixedHeight(CONTEXT_BAR_BUTTON_HEIGHT + 4)
- spin.setToolTip("Bond length: rescale molecular geometry and attached marks")
- spin.setStatusTip(
- "Set bond length in pixels and rescale molecular geometry and attached marks; "
- "free annotations keep their positions"
- )
+ spin.setToolTip(str(BOND_LENGTH_INPUT_SPEC["tooltip"]))
+ spin.setStatusTip(str(BOND_LENGTH_INPUT_SPEC["status_tip"]))
spin.sync_value(current_px)
def commit() -> None:
@@ -485,8 +489,8 @@ def step(delta: float) -> None:
layout.addWidget(
_stepper_frame(
"bondLengthStepper",
- up_tooltip="Increase bond length",
- down_tooltip="Decrease bond length",
+ up_tooltip=str(BOND_LENGTH_INPUT_SPEC["up_tooltip"]),
+ down_tooltip=str(BOND_LENGTH_INPUT_SPEC["down_tooltip"]),
on_step_up=lambda _checked=False: step(spin.singleStep()),
on_step_down=lambda _checked=False: step(-spin.singleStep()),
)
diff --git a/app/chemvas/ui/window/main_window_document_action_service.py b/app/chemvas/ui/window/main_window_document_action_service.py
index 4ccb70ce..944b9cce 100644
--- a/app/chemvas/ui/window/main_window_document_action_service.py
+++ b/app/chemvas/ui/window/main_window_document_action_service.py
@@ -38,6 +38,7 @@
from chemvas.ui.session.open_document_lookup import (
document_path_has_multiple_links,
find_open_document,
+ paths_refer_to_same_document,
resolved_document_path,
)
from chemvas.ui.window.main_window_document_dialogs import (
@@ -242,7 +243,7 @@ def save_canvas_to_path(
):
return False
current_path = self.current_file_path(window, canvas=target)
- if current_path and resolved_document_path(current_path) == write_path:
+ if current_path and paths_refer_to_same_document(current_path, write_path):
expected = target.runtime_state.document_metadata_state.source_sha256
try:
with open(write_path, "rb") as source:
diff --git a/app/chemvas/ui/window/main_window_document_dialogs.py b/app/chemvas/ui/window/main_window_document_dialogs.py
index 40763d5c..d4f78709 100644
--- a/app/chemvas/ui/window/main_window_document_dialogs.py
+++ b/app/chemvas/ui/window/main_window_document_dialogs.py
@@ -36,7 +36,10 @@
)
from chemvas.shell.toolbar_buttons import ArrowButton
from chemvas.ui.canvas.sheet_setup_logic import (
+ SHEET_DIMENSION_DECIMALS,
+ SHEET_DIMENSION_STEP_MM,
SHEET_ORIENTATION_OPTIONS,
+ SHEET_SETUP_TEXT,
supported_sheet_sizes,
)
@@ -338,11 +341,11 @@ def prompt_sheet_setup(
current_custom_size_mm: tuple[float, float] | None = None,
) -> SheetSetupSelection | None:
dialog = QDialog(window)
- dialog.setWindowTitle("Canvas Size")
+ dialog.setWindowTitle(SHEET_SETUP_TEXT["title"])
dialog.setStyleSheet(window.styleSheet())
layout = QVBoxLayout(dialog)
- layout.addWidget(QLabel("Canvas size:"))
+ layout.addWidget(QLabel(SHEET_SETUP_TEXT["size"]))
size_combo = QComboBox()
size_combo.setObjectName("sheetSizeCombo")
@@ -352,7 +355,7 @@ def prompt_sheet_setup(
size_combo.setCurrentIndex(size_index)
layout.addWidget(size_combo)
- layout.addWidget(QLabel("Orientation:"))
+ layout.addWidget(QLabel(SHEET_SETUP_TEXT["orientation"]))
orientation_combo = QComboBox()
orientation_combo.setObjectName("sheetOrientationCombo")
@@ -369,12 +372,13 @@ def prompt_sheet_setup(
height.setObjectName("sheetHeightSpin")
for spin in (width, height):
spin.setRange(MIN_SHEET_MM, MAX_SHEET_MM)
- spin.setDecimals(2)
+ spin.setDecimals(SHEET_DIMENSION_DECIMALS)
+ spin.setSingleStep(SHEET_DIMENSION_STEP_MM)
spin.setSuffix(" mm")
- dimensions.addRow("Width:", width)
- dimensions.addRow("Height:", height)
+ dimensions.addRow(SHEET_SETUP_TEXT["width"], width)
+ dimensions.addRow(SHEET_SETUP_TEXT["height"], height)
layout.addLayout(dimensions)
- explanation = QLabel("Changing the sheet does not resize or move the drawing.")
+ explanation = QLabel(SHEET_SETUP_TEXT["explanation"])
explanation.setWordWrap(True)
layout.addWidget(explanation)
diff --git a/app/chemvas/ui/window/main_window_menu_bar.py b/app/chemvas/ui/window/main_window_menu_bar.py
index 2a84cf5c..967a1647 100644
--- a/app/chemvas/ui/window/main_window_menu_bar.py
+++ b/app/chemvas/ui/window/main_window_menu_bar.py
@@ -17,6 +17,7 @@
from chemvas.ui.scene.stacking_actions import stack_selection_for_window
from chemvas.ui.session.session_recovery_service import recover_unsaved_work_for_window
from chemvas.ui.window.main_window_about_dialog import GITHUB_URL, show_about_dialog
+from chemvas.ui.window.main_window_config import ALIGN_MENU_SPECS, DISTRIBUTE_MENU_SPECS
from chemvas.ui.window.main_window_document_dialogs import prompt_sheet_setup
from chemvas.ui.window.main_window_ports import (
active_canvas_for_window,
@@ -82,20 +83,6 @@ def run_sheet_setup_dialog(window: MainWindowLike) -> None:
)
-ALIGN_MENU_SPECS: tuple[tuple[str, str], ...] = (
- ("Left", "left"),
- ("Center", "center"),
- ("Right", "right"),
- ("Top", "top"),
- ("Middle", "middle"),
- ("Bottom", "bottom"),
-)
-DISTRIBUTE_MENU_SPECS: tuple[tuple[str, str], ...] = (
- ("Horizontally", "horizontal"),
- ("Vertically", "vertical"),
-)
-
-
def _add_action(
menu,
window: MainWindowLike,
@@ -192,7 +179,7 @@ def _build_file_menu(
file_menu,
window,
"Export Figure...",
- status_tip="Export the drawing as SVG, PDF, or high-resolution PNG/TIFF",
+ status_tip="Export the drawing as SVG, PDF, high-resolution PNG/TIFF, or experimental CDXML",
triggered=lambda: callbacks.export_figure(window),
)
_add_action(
diff --git a/app/chemvas/ui/window/main_window_preview_window.py b/app/chemvas/ui/window/main_window_preview_window.py
index 3345ec6e..bf517bde 100644
--- a/app/chemvas/ui/window/main_window_preview_window.py
+++ b/app/chemvas/ui/window/main_window_preview_window.py
@@ -14,6 +14,7 @@
from chemvas.shell.icon_factory import MainWindowIconFactory
from chemvas.shell.theme import TOOLBAR_BUTTON_SIZE, TOOLBAR_ICON_SIZE
+from chemvas.ui.window.main_window_config import MOLECULE_INFO_TITLE
if TYPE_CHECKING:
from chemvas.ui.window.main_window_like import MainWindowLike
@@ -21,7 +22,7 @@
class MoleculeInspectorDock(QDockWidget):
def __init__(self, parent, *, preview_widget) -> None:
- super().__init__("Molecule Info", parent)
+ super().__init__(MOLECULE_INFO_TITLE, parent)
self.setObjectName("inspectorDock")
self.setAllowedAreas(
Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea
diff --git a/app/chemvas/ui/window/main_window_state.py b/app/chemvas/ui/window/main_window_state.py
index 22f186a0..9a5f4663 100644
--- a/app/chemvas/ui/window/main_window_state.py
+++ b/app/chemvas/ui/window/main_window_state.py
@@ -6,6 +6,9 @@
if TYPE_CHECKING:
from chemvas.ui.window.main_window_document_dialogs import FigureExportOptions
+# New canvases are numbered per window, without a file extension.
+CANVAS_NAME_FORMAT = "Canvas {}"
+
@dataclass
class MainWindowState:
@@ -23,7 +26,7 @@ def set_context_bar_page_override(self, page_key: str | None) -> None:
def next_canvas_name(self) -> str:
self.canvas_name_counter += 1
- return f"Canvas {self.canvas_name_counter}"
+ return CANVAS_NAME_FORMAT.format(self.canvas_name_counter)
-__all__ = ["MainWindowState"]
+__all__ = ["CANVAS_NAME_FORMAT", "MainWindowState"]
diff --git a/app/chemvas/ui/window/main_window_status_service.py b/app/chemvas/ui/window/main_window_status_service.py
index 08c3069a..a70053ab 100644
--- a/app/chemvas/ui/window/main_window_status_service.py
+++ b/app/chemvas/ui/window/main_window_status_service.py
@@ -16,6 +16,11 @@
from chemvas.shell.toolbar_buttons import CornerMenuButton
from chemvas.shell.toolbar_styles import TOOLBAR_MENU_BUTTON_STYLE
+from chemvas.ui.canvas.canvas_tool_settings_state import (
+ GRID_CONTROL_HINT,
+ GRID_MODES,
+ GRID_STRENGTHS,
+)
from chemvas.ui.scene.mark_ownership import mark_is_distant_for, mark_owner_text_for
from chemvas.ui.selection.selection_queries import (
scene_selected_items_for,
@@ -36,7 +41,10 @@
zoom_in_for_window,
zoom_out_for_window,
)
-from chemvas.ui.window.main_window_toolbar_logic import tool_display_name
+from chemvas.ui.window.main_window_toolbar_logic import (
+ tool_display_name,
+ tool_hint_text,
+)
if TYPE_CHECKING:
from collections.abc import Callable
@@ -106,23 +114,6 @@ def mouseDoubleClickEvent(self, event) -> None:
self._on_double()
-TOOL_HINTS: dict[str, str] = {
- "select": "Select: double-click arrows/lines for labels",
- "bond": "Bond: click-drag to draw",
- "text": "Atom / Text: click to place label",
- "mark": "Mark: click atom or label",
- "benzene": "Ring: click to place template",
- "arrow": "Arrow: drag to draw; double-click for labels",
- "line": "Line: double-click for labels; Shift locks angle",
- "note": "Text: click to add/edit; Esc to finish",
- "ts_bracket": "Brackets: drag around selection",
- "orbital": "Orbital: click to place",
- "perspective": "Perspective: drag selection to rotate",
- "color": "Color: choose a swatch",
- "ring_fill": "Ring Fill: select a complete ring, then choose a fill color",
-}
-
-
# A paused Quit or autosave is painted first, so recovery guidance never hides it.
_NOTICE_ORDER = ("quit", "autosave", "recovery")
# QStatusBar places this much space between two visible items.
@@ -354,15 +345,12 @@ def _build_grid_control(self, window: MainWindowLike) -> QToolButton:
button.setObjectName("statusGridButton")
button.setStyleSheet(TOOLBAR_MENU_BUTTON_STYLE)
button.setAutoRaise(True)
- button.setToolTip(
- "Cycle grid and arrow/line snapping; open the menu for grid strength"
- )
+ button.setToolTip(GRID_CONTROL_HINT)
button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
button.clicked.connect(lambda _checked=False: self._cycle_grid(window))
menu = QMenu(button)
group = QActionGroup(menu)
- modes: tuple[Literal["none", "hex", "square"], ...] = ("none", "hex", "square")
- for mode in modes:
+ for mode in GRID_MODES:
action = QAction(mode.title(), menu)
action.setCheckable(True)
group.addAction(action)
@@ -373,7 +361,7 @@ def _build_grid_control(self, window: MainWindowLike) -> QToolButton:
self._grid_actions[mode] = action
menu.addSeparator()
opacity_group = QActionGroup(menu)
- for percent in (15, 20, 25):
+ for percent in GRID_STRENGTHS:
action = QAction(f"Strength {percent}%", menu)
action.setCheckable(True)
opacity_group.addAction(action)
@@ -613,7 +601,7 @@ def active_tool_status_text(self, window: MainWindowLike) -> str:
def active_tool_hint_text(self, window: MainWindowLike) -> str:
page_override = window.runtime_state.context_bar_page_override
if page_override == "ring_fill":
- return TOOL_HINTS["ring_fill"]
+ return tool_hint_text("select", page=page_override)
canvas = active_canvas_or_none_for_window(window)
if canvas is None:
return "No active canvas"
@@ -621,11 +609,11 @@ def active_tool_hint_text(self, window: MainWindowLike) -> str:
if not tool_name:
return "Choose a drawing tool"
key = str(tool_name)
+ color = None
if key == "color":
tool = color_tool_for_window(window)
- if tool is not None and tool.current_color is not None:
- return f"Color: {tool.current_color} — click an item or choose a swatch"
- return TOOL_HINTS.get(key, f"{tool_display_name(key)}: ready")
+ color = None if tool is None else tool.current_color
+ return tool_hint_text(key, color=color)
def show_active_tool_hint(self, window: MainWindowLike) -> None:
status_bar_for(window).showMessage(self.active_tool_hint_text(window))
diff --git a/app/chemvas/ui/window/main_window_tool_routing_service.py b/app/chemvas/ui/window/main_window_tool_routing_service.py
index 8d5535c7..73b71795 100644
--- a/app/chemvas/ui/window/main_window_tool_routing_service.py
+++ b/app/chemvas/ui/window/main_window_tool_routing_service.py
@@ -8,6 +8,7 @@
from chemvas.domain.document import VALID_ARROW_KINDS
from chemvas.features.session import is_quit_pending
+from chemvas.ui.window.main_window_config import COLOR_TARGET_KINDS
from chemvas.ui.window.main_window_ports import (
color_mutation_service_for_window,
color_tool_for_window,
@@ -53,9 +54,7 @@ def apply_color() -> None:
items = [
item
for item in self._selected_scene_items(window)
- if item.data(0)
- in {"bond", "atom", "ring", "note", "shape", "mark", "ts_bracket"}
- | VALID_ARROW_KINDS
+ if item.data(0) in COLOR_TARGET_KINDS | VALID_ARROW_KINDS
]
color_service.apply_color_to_items(items, color)
diff --git a/app/chemvas/ui/window/main_window_tool_state_service.py b/app/chemvas/ui/window/main_window_tool_state_service.py
index 074c12d5..fb1cf9c4 100644
--- a/app/chemvas/ui/window/main_window_tool_state_service.py
+++ b/app/chemvas/ui/window/main_window_tool_state_service.py
@@ -2,6 +2,7 @@
from typing import TYPE_CHECKING
+from chemvas.ui.window.main_window_config import ORBITAL_PHASE_SPECS
from chemvas.ui.window.main_window_ports import (
active_tool_name_for_window,
tool_mode_controller_for_window,
@@ -90,7 +91,7 @@ def set_orbital_type(self, window: MainWindowLike, value: str) -> None:
def set_orbital_phase(self, window: MainWindowLike, value: str) -> None:
self._tool_mode_controller(window).set_orbital_phase_enabled(
- value == "Phase On"
+ dict(ORBITAL_PHASE_SPECS)[value]
)
def set_shape_type(self, window: MainWindowLike, value: str) -> None:
diff --git a/app/chemvas/ui/window/main_window_toolbar_logic.py b/app/chemvas/ui/window/main_window_toolbar_logic.py
index aa0df5a0..362132ec 100644
--- a/app/chemvas/ui/window/main_window_toolbar_logic.py
+++ b/app/chemvas/ui/window/main_window_toolbar_logic.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from chemvas.ui.window.main_window_config import TOOL_HINTS
+
BOND_STYLE_BY_LABEL: dict[str, tuple[str, int]] = {
"Single": ("single", 1),
"Double": ("double", 2),
@@ -30,6 +32,7 @@
TOOL_DISPLAY_NAMES: dict[str, str] = {
"select": "Select",
+ "delete": "Eraser",
"bond": "Bond",
"text": "Atom",
"note": "Text",
@@ -62,9 +65,21 @@ def tool_display_name(tool: str) -> str:
return TOOL_DISPLAY_NAMES.get(tool, tool.capitalize())
+def tool_hint_text(
+ tool: str, *, page: str | None = None, color: str | None = None
+) -> str:
+ """The status bar hint for the active tool, its page and chosen colour."""
+ if page == "ring_fill":
+ return TOOL_HINTS["ring_fill"]
+ if tool == "color" and color is not None:
+ return f"Color: {color} — click an item or choose a swatch"
+ return TOOL_HINTS.get(tool, f"{tool_display_name(tool)}: ready")
+
+
__all__ = [
"arrow_preset_from_label",
"bond_style_from_label",
"orbital_type_from_label",
"tool_display_name",
+ "tool_hint_text",
]
diff --git a/app/chemvas/web/app.mjs b/app/chemvas/web/app.mjs
new file mode 100644
index 00000000..1d67c0d9
--- /dev/null
+++ b/app/chemvas/web/app.mjs
@@ -0,0 +1,2419 @@
+import {SessionClient, sessionDrawing} from './transport.mjs';
+import {ChemistryClipboard, copySelection, isSelectionText} from './clipboard.mjs';
+import {sceneMarkup, AtomLabelCache, clampView, itemKey, zoomView, wheelView, pointInSheet, marqueeSelection, measureDocumentLineHeight, measureNoteFont, layoutNoteText, styleNoteText, serializeNoteEditor, noteBlocks, noteBlocksHtml, noteTextOffset, noteTextPosition, formatNoteBlocks, noteFormatState, selectionFrameMarkup, gridMarkup, groupUnit, expandToGroups, groupBoxesMarkup, smilesPreviewMarkup, valenceWarningMarkup} from './scene.mjs';
+
+const $ = id => document.getElementById(id);
+const editor = new SessionClient(request => sessionRequest(request));
+const chemistryClipboard = new ChemistryClipboard({clipboard: navigator.clipboard, ClipboardItem: globalThis.ClipboardItem});
+// A reloading page closes its session as it leaves; the next page makes sure.
+const closedSessionKey = 'chemvas-closed-session';
+const canvas = $('canvas');
+const labelCache = new AtomLabelCache();
+const fontContext = document.createElement('canvas').getContext('2d');
+const fontProbe = document.createElement('span');
+fontProbe.style.cssText = 'position:fixed;visibility:hidden;white-space:pre;pointer-events:none';
+document.body.append(fontProbe);
+function measureLineHeight(font, text) {
+ fontProbe.style.font = font;
+ fontProbe.textContent = text;
+ return fontProbe.getBoundingClientRect().height;
+}
+const fragment = new URLSearchParams(location.hash.slice(1));
+const token = fragment.get('token') ?? sessionStorage.getItem('chemvas-browser-token') ?? '';
+if (fragment.has('token')) {
+ sessionStorage.setItem('chemvas-browser-token', token);
+ history.replaceState(null, '', location.pathname);
+}
+let tool = 'bond', orbitalKind = null, markKind = null, bracketKind = null, ringTemplate = null, selection = new Set(), gesture = null, preview = null, loading = false;
+let view = {x: -25, y: -25, width: 645, height: 892};
+const gridMode = () => grid.enabled ? grid.style : 'none';
+const viewScale = () => Math.min(canvas.clientWidth / view.width, canvas.clientHeight / view.height);
+let ui = null, bondStyle = null, arrowStyle = null, lineStyle = null, shapeStyle = null, shapeStroke = null, paintColor = null, ringFillColor = '#000000', contextPage = null, pointerPosition = null;
+let grid = null;
+let valenceChecking = true;
+const markHover = {request:null, result:null, pending:false};
+let chargeEdits = null;
+let smilesInsert = null, smilesPreviewPending = null, smilesGeneration = 0;
+let previewInfo = null, previewSerial = 0, previewPending = null, handleTarget = null;
+let outlineRequest = null, outlinePending = false, outlineResult = {key:null,components:[],frame:null,groups:[]};
+const supportedTools = new Set(['select', 'bond', 'benzene', 'delete', 'text', 'note', 'arrow', 'line', 'shape', 'color', 'ring_fill', 'mark', 'orbital', 'ts_bracket']);
+
+// Embedded image pixels, fetched once per content reference as Blob URLs.
+const imageUrls = new Map();
+function imageUrl(ref) {
+ const known = imageUrls.get(ref);
+ if (typeof known === 'string') return known;
+ if (!known && editor.info?.session) {
+ const request = fetch(`/api/image?session=${encodeURIComponent(editor.info.session)}&ref=${encodeURIComponent(ref)}`, {headers: {'Authorization': `Bearer ${token}`}})
+ .then(response => response.ok ? response.blob() : Promise.reject(new Error('The image could not be loaded.')))
+ .then(blob => { imageUrls.set(ref, URL.createObjectURL(blob)); render(); })
+ .catch(() => imageUrls.delete(ref));
+ imageUrls.set(ref, request);
+ }
+ return null;
+}
+
+async function api(path, body) {
+ const response = await fetch(`/api/${path}`, {
+ method: body === undefined ? 'GET' : 'POST',
+ headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
+ body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
+ });
+ const value = await response.json();
+ if (!response.ok) throw Object.assign(new Error(value.error ?? 'The request failed.'), {status: response.status});
+ return value;
+}
+
+const arrowProbe = document.createElement('div');
+arrowProbe.className = 'arrow-label rich-probe';
+const noteProbe = document.createElement('div');
+noteProbe.className = 'note-text rich-probe';
+document.body.append(arrowProbe, noteProbe);
+const noteFonts = new Map();
+// The Text tool's in-place editor: the note's own rich text, over the canvas.
+const noteEditorElement = document.createElement('div');
+noteEditorElement.className = 'note-editor';
+noteEditorElement.contentEditable = 'true';
+noteEditorElement.spellcheck = false;
+noteEditorElement.hidden = true;
+document.body.append(noteEditorElement);
+let noteEditor = null, noteCommit = Promise.resolve();
+function noteFont(font) {
+ const key = JSON.stringify(font);
+ if (!noteFonts.has(key)) noteFonts.set(key, measureNoteFont(fontProbe, fontContext, font));
+ return noteFonts.get(key);
+}
+function styleArrowLabel(element, spec) {
+ element.style.fontFamily = spec.family;
+ element.style.fontSize = `${spec.pixels}px`;
+ element.style.fontWeight = spec.weight;
+ element.style.fontStyle = spec.italic ? 'italic' : 'normal';
+ element.style.color = spec.color;
+ fontContext.font = `${spec.italic ? 'italic ' : ''}${spec.weight} ${spec.pixels}px ${JSON.stringify(spec.family)}`;
+ element.style.lineHeight = `${measureDocumentLineHeight(fontProbe, fontContext.font, 'H')}px`;
+ const measured = fontContext.measureText('H');
+ const height = measured.fontBoundingBoxAscent + measured.fontBoundingBoxDescent;
+ element.querySelectorAll('sub, sup').forEach(run => {
+ run.style.fontSize = `${spec.script_pixels}px`;
+ run.style.top = `${run.tagName.toLowerCase() === 'sub' ? height / 6 : -height / 2}px`;
+ });
+}
+function measureLabels(spec) {
+ const font = labelCache.measure(spec, fontContext, (font, text) => measureDocumentLineHeight(fontProbe, font, text));
+ font.label_boxes = {};
+ for (const label of spec.arrow_labels ?? []) {
+ if (font.label_boxes[label.key]) continue;
+ if (label.pixels > 4096) throw new Error('Arrow label font is too large to display in the browser.');
+ arrowProbe.innerHTML = label.html;
+ styleArrowLabel(arrowProbe, label);
+ const box = arrowProbe.getBoundingClientRect();
+ font.label_boxes[label.key] = [box.width, box.height];
+ }
+ arrowProbe.replaceChildren();
+ for (const note of spec.notes ?? []) {
+ if (font.label_boxes[note.key]) continue;
+ if (note.pixels > 4096) throw new Error('Note font is too large to display in the browser.');
+ noteProbe.innerHTML = note.html;
+ layoutNoteText(noteProbe, note, noteFont);
+ const box = noteProbe.getBoundingClientRect();
+ font.label_boxes[note.key] = [box.width, box.height];
+ }
+ noteProbe.replaceChildren();
+ return font;
+}
+function sessionRequest(request) {
+ return sessionDrawing(request, value => api('session', value),
+ measureLabels);
+}
+
+// Each notice replaces the last; an owner may clear only a notice still its own.
+let noticeSerial = 0, recoveryProblem = null;
+function notice(text = '', error = false) {
+ noticeSerial++;
+ $('notice').textContent = text;
+ $('notice').hidden = !text;
+ $('notice').classList.toggle('error', error);
+}
+
+function render() {
+ const busy = editor.busy || loading;
+ if (smilesInsert && !currentSmilesInsert(smilesInsert)) smilesInsert = null;
+ document.querySelectorAll('[data-idle]').forEach(item => { item.disabled = busy; });
+ document.querySelectorAll('[data-editable]').forEach(item => { item.disabled = busy || editor.readOnly; });
+ document.querySelectorAll('[data-tool]').forEach(item => {
+ if (item.dataset.tool === 'ring_fill') item.removeAttribute('aria-pressed');
+ else item.setAttribute('aria-pressed', String(item.dataset.tool === tool));
+ item.disabled = !supportedTools.has(item.dataset.tool) || busy || (editor.readOnly && item.dataset.tool !== 'select');
+ });
+ $('undo').disabled = busy || !editor.canUndo;
+ $('redo').disabled = busy || !editor.canRedo;
+ $('delete').disabled = busy || editor.readOnly || !selection.size;
+ // The Edit menu's clipboard acts on the canvas; opening it ends note editing.
+ $('copy').disabled = busy || !selection.size;
+ $('cut').disabled = busy || editor.readOnly || !selection.size;
+ $('paste').disabled = busy || editor.readOnly || !editor.document;
+ const recovery = editor.info?.recovery;
+ $('recover').disabled = busy || !recovery?.available;
+ $('recovery-status').textContent = {saved: 'Recovery draft saved', failed: 'Recovery draft not saved', off: recovery?.message ? 'Recovery off' : ''}[recovery?.state] ?? '';
+ $('recovery-status').title = recovery?.message ?? '';
+ // A draft that could not be written is reported once; the footer keeps it.
+ if (recovery?.state === 'failed' && recovery.message !== recoveryProblem) notice(recovery.message, true);
+ recoveryProblem = recovery?.state === 'failed' ? recovery.message : null;
+ if (!editor.document) return;
+ // A preview of another session or revision never mixes with the accepted drawing.
+ if (previewInfo && (previewInfo.session !== editor.info.session || previewInfo.revision !== editor.info.revision)) previewInfo = null;
+ view = clampView(view, {width:canvas.clientWidth, height:canvas.clientHeight}, editor.info.drawing.scene_rect);
+ // The new view is in place before any layer reads the screen transform.
+ canvas.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`);
+ const state = editor.document.state;
+ // The desktop tab marks unsaved changes and keeps the file name as opened.
+ document.title = `${editor.dirty ? `${ui.title.unsaved_marker} ` : ''}${editor.name} — ${ui.title.suffix}`;
+ $('canvas-status').textContent = `Canvas: ${editor.name}`;
+ $('selection').textContent = `Selection: ${selection.size}`;
+ $('tool-status').textContent = `Tool: ${ui.tool_names[tool] ?? tool}`;
+ const page = contextPage ?? ui.context_pages[tool] ?? 'empty';
+ document.querySelectorAll('[data-context]').forEach(item => { item.hidden = item.dataset.context !== page; });
+ document.querySelectorAll('[data-bond]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.bond === bondStyle)));
+ document.querySelectorAll('[data-template]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.template === `${ringTemplate.size}:${ringTemplate.style}`)));
+ document.querySelectorAll('[data-arrow]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.arrow === arrowStyle)));
+ document.querySelectorAll('[data-line]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.line === lineStyle)));
+ document.querySelectorAll('[data-bracket-kind]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.bracketKind === bracketKind)));
+ document.querySelectorAll('[data-orbital-kind]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.orbitalKind === orbitalKind)));
+ document.querySelectorAll('[data-orbital-phase]').forEach(item => item.setAttribute('aria-pressed', String((item.dataset.orbitalPhase === 'true') === state.settings.orbital_phase_enabled)));
+ document.querySelectorAll('[data-mark-kind]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.markKind === markKind)));
+ document.querySelectorAll('[data-shape]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.shape === shapeStyle)));
+ document.querySelectorAll('[data-color]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.color === paintColor)));
+ document.querySelectorAll('[data-stroke]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.stroke === shapeStroke)));
+ // A loaded setting beyond a slider's default range widens it instead of being
+ // clamped, and the range returns to its defaults for ordinary values.
+ document.querySelectorAll('[data-setting]').forEach(item => {
+ const current = state.settings[item.dataset.setting], value = Math.round(current * Number(item.dataset.factor));
+ item.min = Math.min(Number(item.dataset.minimum), value); item.max = Math.max(Number(item.dataset.maximum), value);
+ item.value = value; item.title = String(current);
+ });
+ $('bond-length').value = state.settings.bond_length_px;
+ const mode = gridMode();
+ $('grid-mode').textContent = `Grid: ${mode[0].toUpperCase()+mode.slice(1)}`;
+ $('grid-toggle').setAttribute('aria-pressed',String(grid.enabled));
+ $('valence-toggle').setAttribute('aria-pressed',String(valenceChecking));
+ document.querySelectorAll('[data-grid]').forEach(item => item.setAttribute('aria-checked',String(item.dataset.grid === mode)));
+ document.querySelectorAll('[data-grid-strength]').forEach(item => item.setAttribute('aria-checked',String(Number(item.dataset.gridStrength) === Math.round(grid.opacity*100))));
+ $('grid').innerHTML = gridMarkup(editor.info.sheet,grid,ui.grid,state.settings.bond_length_px,viewScale());
+ if (tool !== 'select' || !selection.has(handleTarget)) handleTarget = null;
+ outlineRequest = selection.size && !previewInfo ? {session:editor.info.session, revision:editor.info.revision, action:'selection', selection:selectedItems()} : null;
+ const outlineKey = JSON.stringify(outlineRequest);
+ $('drawing').innerHTML = sceneMarkup(previewInfo?.document ?? editor.document, {selection, components: previewInfo?.selection_components ?? (outlineResult.key === outlineKey ? outlineResult.components : []), preview: scenePreview(), drawing: previewInfo?.drawing ?? editor.info.drawing, handleTarget, handleStyle: ui.handles, snapMarks: previewInfo?.snap_marks, snapMarkStyle: ui.snap_mark, showMarkOwners: tool === 'select', markPreview: tool === 'mark' && !busy && markHover.result?.revision === editor.info.revision ? markHover.result : null, markHoverStyle: ui.mark_hover, scale: viewScale(), imageUrl});
+ $('valence-feedback').innerHTML = valenceFeedback();
+ for (const label of (previewInfo?.drawing ?? editor.info.drawing).arrow_labels ?? []) {
+ const element = document.querySelector(`[data-arrow-label="${label.id}:${label.side}"]`);
+ if (element) styleArrowLabel(element, label);
+ }
+ for (const note of (previewInfo?.drawing ?? editor.info.drawing).notes ?? []) {
+ const element = document.querySelector(`[data-note-text="${note.id}"]`);
+ if (!element) continue;
+ layoutNoteText(element, note, noteFont);
+ // The open editor replaces the note it edits.
+ if (noteEditor?.id === note.id) element.closest('[data-item]').setAttribute('visibility', 'hidden');
+ }
+ positionNoteEditor();
+ refreshTextFormatState();
+ const insertPreview = templateHover.result;
+ $('insert-preview').innerHTML = insertPreview && tool === 'benzene' ? (() => {
+ const [r, g, b, a] = insertPreview.color, color = `rgba(${r},${g},${b},${a / 255})`;
+ return `${insertPreview.segments.map(([x1, y1, x2, y2]) => ``).join('')}${insertPreview.dots.map(([x, y, w, h]) => ``).join('')}`;
+ })() : '';
+ $('smiles-preview').innerHTML = smilesInsert?.info ? smilesPreviewMarkup(smilesInsert.info, editor.document, ui.smiles.preview_opacity) : '';
+ const frame = selectionFrameMarkup(previewInfo?.selection_frame ?? (outlineResult.key === outlineKey ? outlineResult.frame : null), previewInfo?.drawing ?? editor.info.drawing, ui.handles, viewScale());
+ const groupBoxes = previewInfo?.selection_groups ?? (outlineResult.key === outlineKey ? outlineResult.groups : []);
+ $('selection-frame').innerHTML = frame.outline + groupBoxesMarkup(groupBoxes, previewInfo?.drawing ?? editor.info.drawing);
+ $('rotation-handle').innerHTML = editor.readOnly ? '' : frame.handle;
+ canvas.dataset.tool = tool;
+ const [sheetWidth, sheetHeight] = editor.info.sheet;
+ for (const [name, value] of Object.entries({x: -sheetWidth / 2, y: -sheetHeight / 2, width: sheetWidth, height: sheetHeight})) $('paper').setAttribute(name, value);
+ $('zoom-level').textContent = `${Math.round((canvas.getScreenCTM()?.a ?? 1) * 100)}%`;
+ $('status').textContent = busy ? 'Applying edit…' : smilesInsert ? 'SMILES: click to place, Esc to cancel' : editor.readOnly ? 'Read-only · incomplete preview' : (contextPage === 'ring_fill' ? ui.ring_fill_hint : tool === 'color' && paintColor !== null ? ui.color_hint.replace('{color}', paintColor) : ui.hints[tool] ?? `${ui.tool_names[tool] ?? tool}: ready`);
+ if (!busy) void refreshSelectionOutline();
+}
+
+// Fit to Window: the desktop's margin of the limiting axis, clamped zoom, sheet centred.
+function fitPage() {
+ if (!editor.info) return;
+ const [width, height] = editor.info.sheet, viewport = {width: canvas.clientWidth, height: canvas.clientHeight};
+ if (width <= 0 || height <= 0 || viewport.width <= 0 || viewport.height <= 0) return;
+ const {min, max, fit_margin: margin} = ui.navigation;
+ const zoom = Math.max(min, Math.min(max, Math.min(viewport.width / width, viewport.height / height) * margin));
+ view = {x: -viewport.width / zoom / 2, y: -viewport.height / zoom / 2, width: viewport.width / zoom, height: viewport.height / zoom};
+ render();
+ // As zoom() does: a held move asks again for the view now shown.
+ if (gesture?.kind === 'move' && preview && !gesture.released && pointerPosition) {
+ preview = {kind: 'move', end: point(pointerPosition)}; previewSerial++;
+ void refreshGesturePreview();
+ }
+}
+
+function zoom(factor) {
+ if (!ui || !editor.info) return;
+ view = zoomView(view, {width: canvas.clientWidth, height: canvas.clientHeight}, factor, ui.navigation);
+ render(); refreshHover();
+ // A held move joins ends within an on-screen reach: ask again at the new zoom,
+ // from where the held pointer now is; a reply asked before it is not shown.
+ if (gesture?.kind === 'move' && preview && !gesture.released && pointerPosition) {
+ preview = {kind: 'move', end: point(pointerPosition)}; previewSerial++;
+ void refreshGesturePreview();
+ }
+}
+
+function point(event) {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(canvas.getScreenCTM().inverse());
+ return {x: p.x, y: p.y};
+}
+
+function hoverPoint() {
+ const item = document.elementFromPoint(pointerPosition.clientX, pointerPosition.clientY)?.closest('[data-item]');
+ const [kind, id] = (item && canvas.contains(item) ? item.dataset.item : '').split(':');
+ // The server resolves the desktop's hover target from the full hit stack.
+ return {...point(pointerPosition), atom_id: kind === 'atom' ? Number(id) : null,
+ hits: hitsAt(pointerPosition.clientX, pointerPosition.clientY), scale: viewScale()};
+}
+
+function cancelGesture() {
+ cancelSmilesInsert();
+ const pointer = gesture?.pointer;
+ if (gesture?.kind === 'marquee' && !gesture.accepted) selection = new Set(gesture.initialSelection);
+ gesture = preview = previewInfo = null;
+ markHover.request = markHover.result = null;
+ previewSerial++;
+ if (pointer !== undefined && canvas.hasPointerCapture(pointer)) canvas.releasePointerCapture(pointer);
+ render();
+}
+
+function groupUnits() { return editor.info?.drawing?.groups ?? []; }
+
+// Shift-click toggles a group as one unit: off if any member is selected.
+function toggleUnit(key) {
+ const unit = groupUnit(key, groupUnits());
+ if (unit.some(member => selection.has(member))) unit.forEach(member => selection.delete(member));
+ else unit.forEach(member => selection.add(member));
+}
+
+async function edit(change) {
+ if (loading || editor.busy || editor.readOnly) return;
+ cancelSmilesInsert();
+ notice();
+ const pending = editor.perform(change);
+ render();
+ try { await pending; if (change.kind === 'bond_length') handleTarget = null; if (editor.info.edit_notice) notice(editor.info.edit_notice); return true; }
+ catch (error) { notice(error.message, true); return false; }
+ finally { render(); refreshHover(); }
+}
+
+async function loadDocument(infoPromise, name) {
+ closeNoteEditor();
+ loading = true;
+ selection = new Set();
+ cancelGesture();
+ render();
+ try {
+ const info = await infoPromise;
+ await editor.load(info, name);
+ resetForDocument();
+ notice(info.unsupported.length ? `Incomplete, read-only preview: ${info.unsupported.join(', ')}. These elements are not faithfully displayed. Save copy preserves their data, and Export MOL writes selected structures; use the desktop app to edit this drawing or export a figure.` : '');
+ actualSize();
+ } catch (error) { notice(error.message, true); }
+ finally { loading = false; render(); }
+}
+
+// Each opened drawing starts with the desktop's per-canvas tool and view state.
+function resetForDocument() {
+ tool = 'bond'; paintColor = null; contextPage = null;
+ grid = {enabled:false,style:ui.grid.style,opacity:ui.grid.opacity};
+ valenceChecking = true;
+}
+
+// File > Recover Unsaved Work: drafts the server kept for closed or earlier
+// windows, across reloads and restarts. Recovering takes that one draft over,
+// so it never becomes a second copy; it replaces this window's drawing only
+// after the usual unsaved-changes question, and an open window holding the
+// draft is closed only after its own confirmation.
+function describeDraft(entry) {
+ if (entry.problem) return entry.problem;
+ const saved = entry.saved_at ? `Saved ${new Date(entry.saved_at * 1000).toLocaleString()}.` : '';
+ if (!entry.open) return saved;
+ const idle = entry.idle_seconds === null ? '' : `, last active ${Math.round(entry.idle_seconds / 60)} min ago`;
+ return `${saved} Still open in another browser window${idle}.`;
+}
+
+function fillRecoveryDialog(listing) {
+ $('recover-summary').textContent = !listing.available
+ ? listing.message ?? 'Automatic recovery is not available in this window.'
+ : listing.drafts.length ? 'Recovering replaces the drawing in this window. A discarded draft cannot be restored.' : 'There is no unsaved work to recover.';
+ $('recover-list').replaceChildren(...listing.drafts.map(entry => {
+ const row = document.createElement('li'), title = document.createElement('strong'), detail = document.createElement('span');
+ title.textContent = entry.name ?? 'Unreadable draft';
+ detail.textContent = describeDraft(entry);
+ const recover = document.createElement('button'), discard = document.createElement('button');
+ recover.type = discard.type = 'button';
+ recover.textContent = 'Recover'; discard.textContent = 'Discard';
+ recover.disabled = Boolean(entry.problem);
+ discard.disabled = entry.open;
+ recover.onclick = () => { $('recover-dialog').close('cancel'); void recoverDraft(entry); };
+ discard.onclick = () => void discardDraft(entry);
+ row.append(title, detail, recover, discard);
+ return row;
+ }));
+}
+
+async function showRecovery() {
+ if (!editor.info?.session || editor.busy || loading) return;
+ try { fillRecoveryDialog(await api('session', {session: editor.info.session, action: 'drafts'})); }
+ catch (error) { notice(error.message, true); return; }
+ void openDialog($('recover-dialog'));
+}
+
+async function discardDraft(entry) {
+ if (!confirm(`Permanently discard the unsaved work in “${entry.name ?? 'this unreadable draft'}”?`)) return;
+ try { fillRecoveryDialog(await api('session', {session: editor.info.session, action: 'discard_draft', draft: entry.id})); }
+ catch (error) { notice(error.message, true); }
+}
+
+async function recoverDraft(entry) {
+ if (entry.open && !confirm(`“${entry.name}” is still open in another browser window. Recover it here and close it there?`)) return;
+ if (!mayReplace()) return;
+ closeNoteEditor();
+ loading = true;
+ selection = new Set();
+ cancelGesture();
+ render();
+ try {
+ await editor.recover(entry.id, entry.open);
+ resetForDocument();
+ notice(`Recovered ${editor.name}. It stays unsaved until you save a copy.`);
+ actualSize();
+ } catch (error) { notice(error.message, true); }
+ finally { loading = false; render(); }
+}
+
+// After startup, say truthfully whether earlier work waits for recovery; the
+// drawing in this window is never replaced without the user's choice.
+async function offerRecovery() {
+ const previous = sessionStorage.getItem(closedSessionKey);
+ sessionStorage.removeItem(closedSessionKey);
+ if (previous && previous !== editor.info?.session) await api('session', {session: previous, action: 'close'}).catch(() => {});
+ if (!editor.info?.session) return;
+ let listing;
+ try { listing = await api('session', {session: editor.info.session, action: 'drafts'}); }
+ catch (error) { notice(error.message, true); return; }
+ if (!listing.available) { if (listing.message) notice(listing.message, true); return; }
+ // Drafts held by windows still open are theirs, not earlier work.
+ const ready = listing.drafts.filter(entry => !entry.problem && !entry.open).length;
+ if (ready && $('notice').hidden) notice(`Unsaved work from ${ready === 1 ? 'an earlier window' : `${ready} earlier windows`} can be recovered. Choose File > Recover Unsaved Work… to restore or discard it.`);
+}
+
+// Edit > Copy and Cut of the canvas selection. The server's copy reply is
+// requested inside the user gesture, so the system clipboard write starts
+// before it arrives; Cut then deletes through the usual one-step edit.
+async function copyChemistry(cut) {
+ if (!editor.document || editor.busy || loading || gesture || !selection.size || (cut && editor.readOnly)) return;
+ const items = selectedItems(), {session, revision} = editor.info;
+ const payload = api('session', {session, revision, action: 'copy', selection: items}).then(reply => reply.payload);
+ const isCurrent = () => !editor.busy && !loading && editor.info?.session === session && editor.info?.revision === revision;
+ let outcome;
+ try {
+ outcome = await copySelection({store: chemistryClipboard, payload, cut, isCurrent, remove: () => edit({kind: 'delete_selection', selection: items})});
+ } catch (error) { notice(error.message, true); return; }
+ if (outcome.removed) { selection.clear(); render(); }
+ if (outcome.stale) notice('The drawing changed before Cut finished. The selection was copied but not removed.', true);
+ else if ((!cut || outcome.removed) && !outcome.copied.system) notice('Copied for pasting in this browser window only: the browser did not allow access to the system clipboard.');
+}
+
+// Edit > Paste: a Chemvas selection from the system clipboard, else this
+// window's own copy (see pasteText). The server validates, offsets and remaps
+// it in one Undo/Redo step, and the pasted items become the selection.
+async function pasteChemistry(systemText) {
+ if (!editor.document || editor.busy || loading) return;
+ if (editor.readOnly) { notice('This drawing is read-only in the browser adapter.', true); return; }
+ const text = chemistryClipboard.textFor(systemText);
+ if (text === null) {
+ notice(systemText === null ? 'The browser did not allow reading the clipboard. Press ⌘/Ctrl+V on the canvas to paste.' : 'The clipboard does not contain a Chemvas selection.', true);
+ return;
+ }
+ if (new Blob([text]).size > ui.max_clipboard_bytes) { notice('The Chemvas clipboard selection is too large to paste.', true); return; }
+ cancelGesture();
+ if (await edit({kind: 'paste', payload: text})) {
+ selection = new Set((editor.info.pasted ?? []).map(item => `${item.target}:${item.id}`));
+ render();
+ }
+}
+
+// Edit > Paste reads the system clipboard first, which can wait on the
+// browser's permission prompt; it pastes only into the drawing it was chosen
+// for, never into one loaded, recovered or edited meanwhile.
+async function pasteFromMenu() {
+ if (!editor.document || editor.busy || loading) return;
+ const {session, revision} = editor.info;
+ const systemText = await chemistryClipboard.read();
+ if (editor.info?.session !== session || editor.info?.revision !== revision) {
+ notice('The drawing changed while the clipboard was being read. Nothing was pasted.', true);
+ return;
+ }
+ await pasteChemistry(systemText);
+}
+
+// The canvas takes clipboard keys and events unless text is being edited or
+// selected outside it, so fields, notes and page text keep the browser's own
+// clipboard. Label text dragged over inside the drawing is not a text selection.
+function canvasOwnsClipboard(target) {
+ if (!editor.document || noteEditor || document.querySelector('dialog[open]')) return false;
+ if (target instanceof Element && target.closest('input, textarea, select, [contenteditable]')) return false;
+ const text = getSelection();
+ return !text || text.isCollapsed || canvas.contains(text.anchorNode);
+}
+for (const kind of ['copy', 'cut']) {
+ document.addEventListener(kind, event => {
+ if (!selection.size || !canvasOwnsClipboard(event.target)) return;
+ event.preventDefault();
+ void copyChemistry(kind === 'cut');
+ });
+}
+document.addEventListener('paste', event => {
+ if (!canvasOwnsClipboard(event.target)) return;
+ event.preventDefault();
+ void pasteChemistry(event.clipboardData?.getData('text/plain') ?? null);
+});
+
+// Whether the open note holds text its commit would save, or an emptied note it
+// would delete, read as finishNoteEdit reads it; nothing is committed or closed.
+function pendingNoteChanges() {
+ if (!noteEditor) return false;
+ const active = noteEditor, empty = !noteEditorElement.textContent.trim();
+ const typed = active.typing && typedBlocks(active.typing);
+ const html = noteBlocksHtml(typed || noteBlocks(noteEditorElement, active.style), active.style);
+ return html !== active.original && !(active.id === null && empty);
+}
+
+function mayReplace() {
+ return !editor.busy && !loading && (!(editor.dirty || pendingNoteChanges()) || confirm('Discard unsaved changes? Their recovery draft is removed too. Save a copy first if you want to keep them.'));
+}
+
+function download(text, name, type) {
+ const url = URL.createObjectURL(new Blob([text], {type}));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
+}
+
+async function atomInput(change) {
+ loading = true;
+ render();
+ let text;
+ try {
+ const plan = await api('session', {session: editor.info.session, revision: editor.info.revision, action: 'atom_input', edit: change, symbol: $('atom-symbol').value});
+ if (change.kind === 'atom_prompt') {
+ if (!plan.needs_prompt) return;
+ change = {...change, atom_id: plan.atom_id};
+ }
+ text = plan.text;
+ if (plan.needs_prompt) {
+ const dialog = $('atom-dialog');
+ $('atom-label').value = plan.initial;
+ const closed = openDialog(dialog);
+ $('atom-label').focus();
+ $('atom-label').select();
+ text = await closed === 'ok' ? $('atom-label').value : null;
+ }
+ } catch (error) { notice(error.message, true); }
+ finally { loading = false; render(); }
+ if (text !== null && text !== undefined) await edit({...change, text});
+}
+
+// Resolves with the dialog's return value; only the OK submit returns 'ok'.
+function openDialog(dialog) {
+ dialog.returnValue = 'cancel';
+ const closed = new Promise(resolve => dialog.addEventListener('close', () => resolve(dialog.returnValue), {once: true}));
+ dialog.showModal();
+ return closed;
+}
+
+function hitsAt(clientX, clientY) {
+ return selectedItems(new Set(document.elementsFromPoint(clientX, clientY)
+ .filter(element => canvas.contains(element)).map(itemKey).filter(Boolean)));
+}
+
+function selectedItems(keys = selection) {
+ return [...keys].map(key => {
+ const [target, id] = key.split(':');
+ return {target, id: Number(id)};
+ });
+}
+
+async function deleteSelection(allowHover = false) {
+ if (!selection.size && (!allowHover || !pointerPosition)) return;
+ cancelGesture();
+ const items = selectedItems();
+ const change = items.length ? {kind: 'delete_selection', selection: items} : {kind: 'delete_hover', ...hoverPoint()};
+ if (await edit(change)) selection.clear();
+ render();
+}
+
+function selectAll() {
+ if (!editor.document || editor.readOnly || editor.busy || loading) return;
+ cancelGesture();
+ tool = 'select';
+ const model = editor.document.state.model;
+ selection = new Set([
+ ...Object.keys(model.atoms).map(id => `atom:${id}`),
+ ...model.bonds.flatMap((bond, id) => bond ? [`bond:${id}`] : []),
+ ...editor.document.state.arrows.map((_, id) => `arrow:${id}`),
+ ...editor.document.state.shapes.map((_, id) => `shape:${id}`),
+ ...editor.document.state.ring_fills.map((_, id) => `ring:${id}`),
+ ...editor.document.state.marks.map((_, id) => `mark:${id}`),
+ ...editor.document.state.orbitals.map((_, id) => `orbital:${id}`),
+ ...editor.document.state.ts_brackets.map((_, id) => `ts_bracket:${id}`),
+ ...editor.document.state.notes.map((_, id) => `note:${id}`),
+ ...(editor.document.state.images ?? []).map((_, id) => `image:${id}`),
+ ]);
+ render();
+}
+
+// A shape or bracket guide styles the preview document's newest item, so it
+// waits for that document; the committed drawing keeps its own colours.
+function scenePreview() {
+ if (gesture?.kind === 'bond' && previewInfo) return null;
+ if (['shape', 'ts_bracket'].includes(preview?.kind) && !previewInfo) return null;
+ return preview;
+}
+
+// Native tool hotkeys reset the tool's kind; Shift variants name their default.
+function switchByHotkey(next, shifted) {
+ if (shifted) {
+ if (shifted.tool !== next) return;
+ if (next === 'ts_bracket') bracketKind = shifted.value;
+ else if (next === 'orbital') orbitalKind = shifted.value;
+ else if (next === 'mark') markKind = shifted.value;
+ } else if (next === 'bond') bondStyle = ui.default_bond_style;
+ else if (next === 'arrow') arrowStyle = ui.default_arrow_style;
+ setTool(next);
+}
+
+function setTool(next) { if (supportedTools.has(next)) { if (next !== 'note') finishNoteEdit(); if (next === 'benzene') ringTemplate = ui.templates[0]; handleTarget = null; cancelGesture(); contextPage = next === 'ring_fill' ? next : null; tool = next === 'ring_fill' ? 'select' : next; render(); refreshHover(); } }
+
+canvas.addEventListener('pointerdown', event => {
+ if (!editor.document || editor.busy || loading || gesture || event.button !== 0
+ || (ui.navigation.zoom_modifier === 'meta' && event.ctrlKey)) return;
+ canvas.focus();
+ pointerPosition = {clientX: event.clientX, clientY: event.clientY};
+ const p = point(event);
+ if (smilesInsert) {
+ event.preventDefault();
+ void commitSmilesInsert(p);
+ return;
+ }
+ const item = event.target.closest('[data-item]')?.dataset.item ?? null;
+ const [kind, rawId] = item?.split(':') ?? [];
+ const id = Number(rawId);
+ const hits = hitsAt(event.clientX, event.clientY);
+ const scale = viewScale();
+ const handle = event.target.closest('[data-handle]');
+ if (tool === 'select' && handle?.dataset.handle === ui.handles.rotation_type && !editor.readOnly) {
+ gesture = {kind:'rotate', start:p, shift:event.shiftKey, selection:selectedItems(), pointer:event.pointerId,
+ session:editor.info.session, revision:editor.info.revision};
+ } else if (tool === 'select' && handle && !editor.readOnly) {
+ gesture = {kind: 'handle', target: handle.dataset.orbitalId !== undefined ? 'orbital' : handle.dataset.shapeId === undefined ? 'arrow' : 'shape', id: Number(handle.dataset.orbitalId ?? handle.dataset.shapeId ?? handle.dataset.arrowId), handle: handle.dataset.handle,
+ pointer: event.pointerId, end: p, previous: null, moved: false, scale,
+ session: editor.info.session, revision: editor.info.revision};
+ } else if (tool === 'select') {
+ gesture = {kind: 'pick', start: p, end: p, pointer: event.pointerId, shift: event.shiftKey, additive: ui.navigation.zoom_modifier === 'meta' ? event.metaKey : event.ctrlKey, initialSelection: [...selection], hits, scale,
+ session: editor.info.session, revision: editor.info.revision, released: false};
+ void resolveSelection(gesture);
+ } else if (!editor.readOnly) {
+ if (!['delete', 'color'].includes(tool) && !pointInSheet(p, editor.info.sheet)) {
+ notice(ui.off_sheet_guidance, true);
+ return;
+ }
+ if (tool === 'color') {
+ if (paintColor === null) notice(ui.color_messages.choose);
+ else void edit({kind: 'color', color: paintColor, selection: selectedItems(), x: p.x, y: p.y, hits, scale});
+ } else if (tool === 'delete') { selection.clear(); void edit({kind: 'erase', x: p.x, y: p.y, hits, scale}); }
+ else if (tool === 'text') void atomInput({kind: 'atom', x: p.x, y: p.y, atom_id: kind === 'atom' ? id : null});
+ else if (tool === 'note') void noteToolPress(event);
+ else if (tool === 'benzene') void edit({kind: 'ring', x: p.x, y: p.y, atom_id: kind === 'atom' ? id : null, size: ringTemplate.size, style: ringTemplate.style});
+ else if (tool === 'orbital') void edit({kind:'orbital',x:p.x,y:p.y,orbital_kind:orbitalKind});
+ else if (tool === 'mark') void editWithMarkMeasurements({kind:'mark',x:p.x,y:p.y,mark_kind:markKind,hits,scale});
+ else {
+ gesture = {kind: tool, start: p, pointer: event.pointerId, pressX: event.clientX, pressY: event.clientY, dragged: false, shift: event.shiftKey, style: tool === 'shape' ? shapeStyle : tool === 'line' ? lineStyle : tool === 'ts_bracket' ? bracketKind : arrowStyle, stroke: shapeStroke, scale, hits, session: editor.info.session, revision: editor.info.revision};
+ }
+ }
+ if (gesture) canvas.setPointerCapture(event.pointerId);
+ render();
+});
+
+function textFormatButton(spec, action, checkable = false) {
+ const button = document.createElement('button');
+ button.innerHTML = spec.icon; button.title = spec.tip;
+ button.setAttribute('aria-label', spec.tip); button.dataset.editable = '';
+ if (checkable) { button.dataset.textFormat = action.key; button.setAttribute('aria-pressed', 'false'); }
+ // Keep the note editor focused, as the desktop's NoFocus tool buttons do.
+ button.addEventListener('pointerdown', event => event.preventDefault());
+ button.onclick = () => void applyTextFormat(action.key && ['left', 'center', 'right'].includes(action.key) ? {align: action.key} : action);
+ return button;
+}
+
+// The editor selection as document offsets, anchor first.
+function editorSelection() {
+ const selected = getSelection();
+ if (!noteEditor || !selected.rangeCount || !noteEditorElement.contains(selected.anchorNode)) return null;
+ const a = noteTextOffset(noteEditorElement, noteEditor.style, selected.anchorNode, selected.anchorOffset);
+ const b = noteTextOffset(noteEditorElement, noteEditor.style, selected.focusNode, selected.focusOffset);
+ return [a, b];
+}
+
+function editorRange() {
+ const offsets = editorSelection();
+ return offsets && [Math.min(...offsets), Math.max(...offsets)];
+}
+
+// A new note's placeholder line break is not text: selecting it is the caret.
+function editorCaret() {
+ const range = editorRange();
+ return range && !noteEditorElement.textContent ? [range[0], range[0]] : range;
+}
+
+function selectedNoteBlocks() {
+ const style = {...editor.info.drawing.note_style, color: editor.document.state.settings.text_color};
+ return [...selection].filter(key => key.startsWith('note:')).map(key => {
+ const id = Number(key.split(':')[1]);
+ const note = editor.info.drawing.notes?.find(item => item.id === id);
+ if (!note) return null;
+ const probe = document.createElement('div');
+ probe.innerHTML = note.html;
+ styleNoteText(probe, style, noteFont);
+ return {id, style, blocks: noteBlocks(probe, style)};
+ }).filter(Boolean);
+}
+
+const documentLength = blocks => blocks.reduce((total, block, index) => total + (index ? 1 : 0) + block.runs.reduce((n, run) => n + (run.br ? 1 : run.text.length), 0), 0);
+
+// The desktop formats the open editor's selection, else each selected note whole.
+async function applyTextFormat(action) {
+ if (editor.readOnly || editor.busy || loading) return;
+ if (noteEditor) {
+ if (noteEditor.composing) return;
+ const range = editorCaret();
+ if (!range) return;
+ const [start, end] = range;
+ const blocks = noteBlocks(noteEditorElement, noteEditor.style);
+ if (start === end && !action.align) {
+ // QTextCursor::mergeCharFormat without a selection: toggles and size steps
+ // accumulate on the format the next typed text takes, and no text changes.
+ const base = caretCharFormat(blocks, start);
+ const [low, high] = ui.text_format.size_range;
+ const next = {...base};
+ if (action.delta) next.pt = Math.max(low, Math.min(high, base.pt + action.delta));
+ else if (action.key === 'bold') next.bold = !base.bold;
+ else if (action.key === 'italic') next.italic = !base.italic;
+ else { const sc = action.key === 'superscript' ? 'super' : 'sub'; next.script = base.script === sc ? null : sc; }
+ noteEditor.caretFormat = next;
+ noteEditor.caretAt = start;
+ noteEditor.run = null;
+ refreshTextFormatState();
+ return;
+ }
+ // The selection takes its format in the editor at once; note_markup only
+ // renders it, so input made meanwhile keeps its text and this format.
+ const before = noteSnapshot();
+ formatNoteBlocks(blocks, start, end, action, ui.text_format.size_range);
+ showNoteBlocks(blocks, editorSelection());
+ commitNoteStep(before);
+ refreshTextFormatState();
+ return;
+ }
+ const targets = selectedNoteBlocks();
+ if (!targets.length) { notice(ui.text_format.target_message); return; }
+ const notes = targets.map(({id, style, blocks}) => {
+ formatNoteBlocks(blocks, 0, documentLength(blocks), action, ui.text_format.size_range);
+ return {id, html: noteBlocksHtml(blocks, style)};
+ });
+ await edit({kind: 'note_format', notes});
+}
+
+function refreshTextFormatState() {
+ if (!ui || tool !== 'note') return;
+ const align = editor.document?.state.settings.text_alignment ?? 'left';
+ let state = null;
+ if (noteEditor) {
+ const range = editorCaret();
+ if (range) {
+ const blocks = noteBlocks(noteEditorElement, noteEditor.style);
+ state = noteFormatState(blocks, ...range, align);
+ // A caret shows the format typed text would take.
+ if (range[0] === range[1]) {
+ const format = caretCharFormat(blocks, range[0]);
+ state = {...state, bold: format.bold, italic: format.italic, superscript: format.script === 'super', subscript: format.script === 'sub'};
+ }
+ }
+ } else {
+ const states = selectedNoteBlocks().map(({blocks}) => noteFormatState(blocks, 0, documentLength(blocks), align));
+ if (states.length) state = Object.fromEntries(Object.keys(states[0]).map(key => [key, states.every(item => item[key])]));
+ }
+ document.querySelectorAll('[data-text-format]').forEach(button => button.setAttribute('aria-pressed', String(Boolean(state?.[button.dataset.textFormat]))));
+}
+document.addEventListener('selectionchange', () => {
+ if (!noteEditor) return;
+ // Moving the caret forgets the typing format, as QTextCursor::setPosition does;
+ // restoring the same caret after the editor re-renders keeps it. An IME moves
+ // the caret over its own uncommitted text.
+ const range = editorCaret(), at = noteEditor.caretAt;
+ if (noteEditor.caretFormat && !noteEditor.composing && !(range?.[0] === at && range[1] === at)) noteEditor.caretFormat = noteEditor.typing = null;
+ refreshTextFormatState();
+});
+
+function positionNoteEditor() {
+ if (!noteEditor) { noteEditorElement.hidden = true; return; }
+ // Screen transform of the note: canvas CTM, then its position and rotation.
+ const m = canvas.getScreenCTM(), angle = noteEditor.rotation * Math.PI / 180;
+ const cos = Math.cos(angle), sin = Math.sin(angle);
+ noteEditorElement.style.transform = `matrix(${m.a * cos + m.c * sin},${m.b * cos + m.d * sin},${m.c * cos - m.a * sin},${m.d * cos - m.b * sin},${m.a * noteEditor.x + m.c * noteEditor.y + m.e},${m.b * noteEditor.x + m.d * noteEditor.y + m.f})`;
+ noteEditorElement.hidden = false;
+}
+
+function beginNoteEdit(id, x, y) {
+ const drawing = editor.info.drawing, style = {...drawing.note_style, color: editor.document.state.settings.text_color};
+ const note = id === null ? null : drawing.notes?.find(item => item.id === id);
+ if (id !== null && !note) return;
+ noteEditor = {id, x: note?.x ?? x, y: note?.y ?? y, rotation: note?.rotation ?? 0, style,
+ session: editor.info.session, revision: editor.info.revision, caretFormat: null, normalized: true,
+ undoSteps: [], redoSteps: [], run: null};
+ noteEditorElement.innerHTML = note ? note.html : '
';
+ styleNoteText(noteEditorElement, style, noteFont);
+ const base = noteFont(style);
+ noteEditorElement.style.lineHeight = `${Math.ceil(base.ascent + base.descent + base.leading) * style.line_spacing}px`;
+ noteEditor.original = serializeNoteEditor(noteEditorElement, style);
+ selection = id === null ? new Set() : new Set([`note:${id}`]);
+ render();
+ document.execCommand('defaultParagraphSeparator', false, 'p');
+ noteEditorElement.focus();
+ // The desktop selects the whole note when editing begins.
+ const range = document.createRange();
+ range.selectNodeContents(noteEditorElement);
+ getSelection().removeAllRanges();
+ getSelection().addRange(range);
+}
+
+function closeNoteEditor() {
+ noteEditor = null;
+ noteEditorElement.hidden = true;
+ noteEditorElement.replaceChildren();
+ noteEditorElement.removeAttribute('style');
+}
+
+// NoteItem's focus out: save changed text once, delete an emptied note, and
+// drop a new note that never received text. The commit resolves false when the
+// text was not saved: the note then reopens with it where that is still safe.
+function finishNoteEdit() {
+ if (!noteEditor) return noteCommit;
+ const active = noteEditor, empty = !noteEditorElement.textContent.trim();
+ // Text an IME is still composing is saved with its pending format; the
+ // editor itself is not rewritten under the IME.
+ const typed = active.typing && typedBlocks(active.typing);
+ const blocks = typed || noteBlocks(noteEditorElement, active.style);
+ const html = noteBlocksHtml(blocks, active.style), kept = noteEditorMarkup(blocks), offsets = editorSelection();
+ closeNoteEditor();
+ render();
+ if (html === active.original || (active.id === null && empty)) return noteCommit;
+ if (editor.info.session !== active.session || editor.info.revision !== active.revision) {
+ notice('The drawing changed while the note was open; its text was not saved.', true);
+ return trackNoteCommit(Promise.resolve(false));
+ }
+ const change = active.id === null ? {kind: 'note_text', id: null, x: active.x, y: active.y, html} : {kind: 'note_text', id: active.id, html};
+ return trackNoteCommit(edit(change).then(saved => {
+ if (saved === true) return true;
+ reopenNoteEditor(active, kept, offsets);
+ return false;
+ }));
+}
+
+// Callers arriving while a commit is pending share its outcome; once it settles,
+// later callers start from a settled success rather than that failure.
+function trackNoteCommit(outcome) {
+ const commit = outcome.then(saved => {
+ if (noteCommit === commit) noteCommit = Promise.resolve(true);
+ return saved;
+ });
+ noteCommit = commit;
+ return commit;
+}
+
+// A note whose text was not saved reopens holding that text, selection, original
+// and Undo/Redo steps, as a fresh editor, so work still bound to the closed one
+// (its normalization reply or error) cannot act on it. It never reopens over a
+// load, a newer note, SMILES insertion or a changed drawing: a revision change
+// may mean the text was applied after all. A tool switch that ended the note
+// returns to the Text tool, and focus stays where the user put it.
+function reopenNoteEditor(active, markup, offsets) {
+ if (noteEditor || loading || smilesInsert || editor.info?.session !== active.session || editor.info?.revision !== active.revision) return;
+ const {id, x, y, rotation, style, session, revision, original} = active;
+ noteEditor = {id, x, y, rotation, style, session, revision, original, caretFormat: null, normalized: true,
+ undoSteps: [...active.undoSteps], redoSteps: [...active.redoSteps], run: null};
+ if (tool !== 'note') { tool = 'note'; contextPage = null; }
+ showNoteHtml(markup, offsets);
+ const base = noteFont(style);
+ noteEditorElement.style.lineHeight = `${Math.ceil(base.ascent + base.descent + base.leading) * style.line_spacing}px`;
+ selection = id === null ? new Set() : new Set([`note:${id}`]);
+ render();
+}
+
+async function noteToolPress(event) {
+ // A note that could not be saved stays open instead of a new one opening.
+ if (await finishNoteEdit() === false) return;
+ if (smilesInsert) return;
+ if (editor.busy || loading || editor.readOnly || tool !== 'note') return;
+ const p = point(event), session = editor.info.session, revision = editor.info.revision, generation = smilesGeneration;
+ let target = null;
+ try {
+ ({target} = await api('session', {session, revision, action: 'pick', ...p,
+ hits: hitsAt(event.clientX, event.clientY), scale: viewScale(), preferred: false}));
+ } catch (error) { notice(error.message, true); return; }
+ if (generation !== smilesGeneration || smilesInsert || editor.info.session !== session || editor.info.revision !== revision || tool !== 'note') return;
+ if (target?.target === 'note') {
+ const key = `note:${target.id}`;
+ const toggle = ui.navigation.zoom_modifier === 'meta' ? event.metaKey : event.ctrlKey;
+ if (toggle) { toggleUnit(key); render(); }
+ else if (event.shiftKey) { groupUnit(key, groupUnits()).forEach(member => selection.add(member)); render(); }
+ else beginNoteEdit(target.id);
+ return;
+ }
+ beginNoteEdit(null, p.x, p.y);
+}
+noteEditorElement.addEventListener('focusout', event => {
+ // Moving focus inside the editor, or back to it, keeps the session open.
+ if (!noteEditorElement.contains(event.relatedTarget)) void finishNoteEdit();
+});
+// A copied Chemvas selection is clipboard text too; it belongs on the canvas,
+// never inside a note as raw data. Other pasted text stays the browser's.
+noteEditorElement.addEventListener('paste', event => {
+ if (!isSelectionText(event.clipboardData?.getData('text/plain'))) return;
+ event.preventDefault();
+ notice('A copied Chemvas selection pastes onto the canvas, not into a note. Finish the note, then paste.', true);
+});
+
+// QTextCursor::charFormat: the pending typing format at its caret, else the
+// character before the caret, or after it at the start of a nonempty paragraph.
+function caretCharFormat(blocks, position) {
+ if (noteEditor.caretFormat && noteEditor.caretAt === position) return noteEditor.caretFormat;
+ let offset = 0;
+ for (const [index, block] of blocks.entries()) {
+ if (index) offset += 1;
+ const length = documentLength([block]);
+ if (position <= offset + length) {
+ const at = position === offset && length ? position + 1 : position;
+ for (const run of block.runs) {
+ const size = run.br ? 1 : run.text.length;
+ if (!run.br && at > offset && at <= offset + size) return run.format;
+ offset += size;
+ }
+ break;
+ }
+ offset += length;
+ }
+ const style = noteEditor.style;
+ return {pt: style.point_size, bold: Number(style.weight) > 400, italic: style.italic, script: null};
+}
+
+// One character format over document offsets [start, end), splitting runs.
+function setRunsFormat(blocks, start, end, format) {
+ let offset = 0;
+ blocks.forEach((block, index) => {
+ if (index) offset += 1;
+ const runs = [];
+ for (const run of block.runs) {
+ const length = run.br ? 1 : run.text.length, from = offset, to = offset + length;
+ offset = to;
+ if (run.br || to <= start || from >= end) { runs.push(run); continue; }
+ const a = Math.max(start, from) - from, b = Math.min(end, to) - from;
+ if (a > 0) runs.push({text: run.text.slice(0, a), format: run.format});
+ runs.push({text: run.text.slice(a, b), format: {...format}});
+ if (b < length) runs.push({text: run.text.slice(b), format: run.format});
+ }
+ block.runs = runs;
+ });
+}
+
+// Replace the editor's markup, keeping its selection by document offset. A
+// placeholder keeps an empty or break-ended line editable, as the browser does.
+function showNoteHtml(html, offsets) {
+ noteEditorElement.innerHTML = html.replace(/(
]*>| )<\/p>/g, '$1
');
+ styleNoteText(noteEditorElement, noteEditor.style, noteFont);
+ if (offsets) getSelection().setBaseAndExtent(...noteTextPosition(noteEditorElement, offsets[0]), ...noteTextPosition(noteEditorElement, offsets[1]));
+}
+
+// The adapter's Qt pixel sizes (browser_font_pixels, qt_script_pixels): QFont's
+// rounded 96-dpi em, and QTextEngine's two-thirds script size.
+const notePixels = point => Math.max(1, Math.floor(point * 96 / 72 + 0.5));
+const noteScriptPixels = point => notePixels(Math.max(1, Math.floor(Math.floor(point + 0.5) * 2 / 3)));
+
+// noteBlocksHtml's flat runs in the markup note_markup returns for them, so
+// sizes and script offsets show before its reply as they will after it.
+function noteEditorMarkup(blocks) {
+ const style = noteEditor.style;
+ return noteBlocksHtml(blocks, style).replace(/<(p|span) style="([^"]*)"/g, (_, tag, css) => {
+ const declarations = css ? css.split('; ') : [];
+ const pt = declarations.map(item => /^font-size:([\d.]+)pt$/.exec(item)?.[1]).find(Boolean);
+ const script = declarations.map(item => /^vertical-align:(sub|super)$/.exec(item)?.[1]).find(Boolean);
+ const kept = declarations.filter(item => !/^(font-size|vertical-align):/.test(item)), size = Number(pt ?? style.point_size);
+ if (script) kept.push(`font-size:${noteScriptPixels(size)}px`);
+ else if (pt) kept.push(`font-size:${notePixels(size)}px`);
+ return `<${tag}${kept.length ? ` data-style="${kept.join('; ')}"` : ''}`
+ + (script ? ` data-script="${script}" data-base-pixels="${notePixels(size)}"` : '') + (pt ? ` data-pt="${pt}"` : '');
+ });
+}
+
+// The editor holds formatted runs at once; note_markup then only confirms them.
+function showNoteBlocks(blocks, offsets) {
+ showNoteHtml(noteEditorMarkup(blocks), offsets);
+ noteEditor.normalized = false;
+ void normalizeNoteEditor(noteEditor);
+}
+
+// This note-edit session's own Undo and Redo. The browser's native history
+// cannot follow the editor re-rendering formatted runs, so every change is
+// recorded here as the editor's markup and selection before it. A step is one
+// change, except that contiguous typing, or contiguous deletion in one
+// direction, extends the step before it; a caret move, format action,
+// composition, Undo or Redo ends that grouping. Caret formats, cancelled
+// compositions and note_markup replies change no content and record nothing.
+const noteRunKinds = {insertText: 'text', deleteContentBackward: 'back', deleteContentForward: 'forward'};
+function noteSnapshot() {
+ return {html: noteEditorMarkup(noteBlocks(noteEditorElement, noteEditor.style)), selection: editorSelection()};
+}
+
+function commitNoteStep(before) {
+ const active = noteEditor, range = editorRange();
+ const grouped = before.kind && active.run?.kind === before.kind && active.run.at === before.at;
+ if (!grouped) {
+ if (before.html === noteSnapshot().html) { active.run = null; return; }
+ active.undoSteps.push({html: before.html, selection: before.selection});
+ }
+ active.redoSteps = [];
+ active.run = before.kind && range && range[0] === range[1] ? {kind: before.kind, at: range[0]} : null;
+}
+
+function stepNoteHistory(redo) {
+ const active = noteEditor;
+ if (!active || active.composing) return;
+ // Undo and Redo end any input still waiting for its own input event.
+ active.before = active.typing = null;
+ const step = (redo ? active.redoSteps : active.undoSteps).pop();
+ if (!step) return;
+ (redo ? active.undoSteps : active.redoSteps).push(noteSnapshot());
+ active.run = active.caretFormat = null;
+ active.normalized = true;
+ showNoteHtml(step.html, step.selection);
+ refreshTextFormatState();
+}
+
+// note_markup renders the editor's runs. Input while a reply is pending is
+// already in the runs, so an obsolete reply is dropped and the current runs
+// are rendered instead; no reply replaces text or formats made after it.
+async function normalizeNoteEditor(active) {
+ if (active.normalizing) return;
+ active.normalizing = true;
+ try {
+ while (noteEditor === active && !active.normalized && !active.composing) {
+ const html = serializeNoteEditor(noteEditorElement, active.style);
+ let markup;
+ try {
+ ({html: markup} = await api('session', {session: active.session, revision: active.revision, action: 'note_markup', html}));
+ } catch (error) { if (noteEditor === active) notice(error.message, true); return; }
+ if (noteEditor !== active || active.composing || serializeNoteEditor(noteEditorElement, active.style) !== html) continue;
+ active.normalized = true;
+ showNoteHtml(markup, editorSelection());
+ refreshTextFormatState();
+ }
+ } finally { active.normalizing = false; }
+}
+
+// Qt types with the caret's format: inserted text takes the pending format in
+// the editor at once, and later input continues inside it. IME text takes it
+// once committed; the editor is never rewritten while an IME composes.
+function beginTyping() {
+ if (!noteEditor) return;
+ const format = noteEditor.caretFormat, range = editorCaret(), at = noteEditor.caretAt;
+ noteEditor.typing = format && range?.[0] === at && range[1] === at ? {format, start: at} : null;
+}
+
+// The editor's runs with the text its input reported inserted given the pending
+// format, or null when nothing was inserted or that format is no longer pending.
+// A later caret never decides what gets formatted.
+function typedBlocks(typing) {
+ if (noteEditor.caretFormat !== typing.format || !(typing.end > typing.start)) return null;
+ const blocks = noteBlocks(noteEditorElement, noteEditor.style);
+ setRunsFormat(blocks, typing.start, typing.end, typing.format);
+ return blocks;
+}
+
+function finishTyping() {
+ const typing = noteEditor?.typing;
+ if (!typing) return;
+ noteEditor.typing = null;
+ const blocks = typedBlocks(typing);
+ if (!blocks) return;
+ noteEditor.caretFormat = null;
+ showNoteBlocks(blocks, editorSelection());
+}
+
+noteEditorElement.addEventListener('compositionstart', () => {
+ if (!noteEditor) return;
+ noteEditor.composing = true;
+ // A committed composition is one step; a cancelled one is none. It owns that
+ // step, so a beforeinput still waiting for its input event ends here.
+ noteEditor.compositionBefore = noteSnapshot();
+ noteEditor.run = noteEditor.before = null;
+ beginTyping();
+});
+noteEditorElement.addEventListener('compositionend', event => {
+ if (!noteEditor) return;
+ noteEditor.composing = false;
+ // The committed text, or none when the IME cancelled.
+ if (noteEditor.typing && typeof event.data === 'string') noteEditor.typing.end = noteEditor.typing.start + event.data.length;
+ finishTyping();
+ const before = noteEditor.compositionBefore;
+ noteEditor.compositionBefore = null;
+ if (before) commitNoteStep(before);
+ void normalizeNoteEditor(noteEditor);
+});
+noteEditorElement.addEventListener('beforeinput', event => {
+ if (!noteEditor || event.isComposing || noteEditor.composing) return;
+ noteEditor.before = null;
+ if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') {
+ // The browser's own Undo and Redo, from any menu, take this session's steps.
+ event.preventDefault();
+ stepNoteHistory(event.inputType === 'historyRedo');
+ return;
+ }
+ // Only typing at the caret takes the pending format; a replacement or any
+ // other edit may change a range that does not start there.
+ if (['insertText', 'insertParagraph', 'insertLineBreak'].includes(event.inputType)) beginTyping();
+ else noteEditor.typing = null;
+ const range = editorRange();
+ noteEditor.before = {...noteSnapshot(), kind: noteRunKinds[event.inputType], at: range && range[0] === range[1] ? range[0] : null};
+});
+noteEditorElement.addEventListener('input', event => {
+ if (!noteEditor) return;
+ const typing = noteEditor.typing;
+ // The inserted text ends where this input's own data says, as the input
+ // happens; an IME's data is its whole uncommitted text. Without data the
+ // range is unknown, and nothing is formatted.
+ if (typing && typeof event.data === 'string') typing.end = typing.start + event.data.length;
+ if (event.isComposing || noteEditor.composing) return;
+ const before = noteEditor.before;
+ noteEditor.before = null;
+ if (typing && event.inputType === 'insertText') finishTyping();
+ else if (typing && ['insertParagraph', 'insertLineBreak'].includes(event.inputType)) {
+ // A new line keeps the pending format for the text typed on it.
+ noteEditor.typing = null;
+ noteEditor.caretAt = editorCaret()?.[0];
+ } else {
+ // Pasted, dropped, deleted and replaced content keeps the browser's own
+ // runs; any other edit ends the typing format rather than restyling it.
+ noteEditor.typing = null;
+ forgetCaretFormat();
+ }
+ if (before) commitNoteStep(before);
+ else {
+ // An input without a beforeinput of its own has no state recorded before
+ // it: it records no step, and Redo can no longer restore over it.
+ noteEditor.redoSteps = [];
+ noteEditor.run = null;
+ }
+});
+// Navigation keys and pointer presses place the caret even where its offset
+// ends up unchanged, and each forgets the typing format as
+// QTextCursor::setPosition does. An IME owns navigation while it composes.
+const noteNavigationKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']);
+function forgetCaretFormat() {
+ if (!noteEditor || noteEditor.composing) return;
+ noteEditor.typing = null;
+ if (!noteEditor.caretFormat) return;
+ noteEditor.caretFormat = null;
+ refreshTextFormatState();
+}
+// Undo and Redo keys take this session's steps rather than the browser's own
+// history; an input method keeps every key while it composes.
+noteEditorElement.addEventListener('keydown', event => {
+ if (!noteEditor || event.isComposing || noteEditor.composing) return;
+ const key = event.key.toLowerCase(), command = (event.ctrlKey || event.metaKey) && !event.altKey;
+ if (command && (key === 'z' || (key === 'y' && event.ctrlKey && !event.shiftKey))) {
+ event.preventDefault();
+ stepNoteHistory(key === 'y' || event.shiftKey);
+ } else if (noteNavigationKeys.has(event.key)) {
+ noteEditor.run = null;
+ forgetCaretFormat();
+ }
+});
+noteEditorElement.addEventListener('pointerdown', () => {
+ if (noteEditor) noteEditor.run = null;
+ forgetCaretFormat();
+});
+
+// SMILES insertion is one disposable server candidate, separate from tool state.
+function currentSmilesInsert(active) {
+ return smilesInsert === active && active.session === editor.info?.session
+ && active.revision === editor.info?.revision && !editor.readOnly;
+}
+
+function cancelSmilesInsert() { smilesInsert = null; smilesGeneration++; }
+
+async function beginSmilesInsert() {
+ if (!ui || !editor.document || loading || editor.busy || editor.readOnly) return;
+ const smiles = $('smiles-input').value.trim();
+ if (!smiles) return;
+ const session = editor.info.session;
+ const finishing = finishNoteEdit(), generation = smilesGeneration;
+ const saved = await finishing;
+ if (saved === false || generation !== smilesGeneration || session !== editor.info.session || loading || editor.busy || editor.readOnly) return;
+ cancelGesture();
+ templateHover.request = templateHover.result = null;
+ notice();
+ const [x, y, width, height] = visibleSceneRect();
+ const center = {x: x + width / 2, y: y + height / 2};
+ smilesInsert = {smiles, session: editor.info.session, revision: editor.info.revision,
+ position: pointInSheet(center, editor.info.sheet) ? center : null, info: null, committing: false};
+ canvas.focus(); render();
+ void refreshSmilesPreview();
+}
+
+function smilesRequest(active, position) {
+ return {kind: 'smiles', smiles: active.smiles, x: position.x, y: position.y};
+}
+
+function moveSmilesPreview(position) {
+ const active = smilesInsert;
+ if (!active || active.committing) return;
+ active.position = position && pointInSheet(position, editor.info.sheet) ? position : null;
+ // A previous position must not linger while a newer candidate is pending.
+ active.info = null;
+ render();
+ void refreshSmilesPreview();
+}
+
+async function refreshSmilesPreview() {
+ if (smilesPreviewPending) return;
+ const run = async () => {
+ while (smilesInsert?.position && !smilesInsert.committing && !loading && !editor.busy) {
+ const active = smilesInsert, position = active.position;
+ if (!currentSmilesInsert(active)) { cancelSmilesInsert(); render(); break; }
+ try {
+ const info = await sessionRequest({session: active.session, revision: active.revision,
+ action: 'preview', edit: smilesRequest(active, position)});
+ if (currentSmilesInsert(active) && !active.committing && active.position === position) {
+ active.info = info; render(); break;
+ }
+ } catch (error) {
+ if (currentSmilesInsert(active) && !active.committing) {
+ cancelSmilesInsert(); render(); notice(error.message, true); break;
+ }
+ }
+ }
+ };
+ smilesPreviewPending = run();
+ try { await smilesPreviewPending; }
+ finally { smilesPreviewPending = null; }
+}
+
+async function commitSmilesInsert(position) {
+ const active = smilesInsert;
+ if (!active || active.committing || loading || editor.busy || !currentSmilesInsert(active)) return;
+ if (!pointInSheet(position, editor.info.sheet)) {
+ moveSmilesPreview(null); notice(ui.off_sheet_guidance, true); return;
+ }
+ active.committing = true; active.info = null; render();
+ // A preview may be completing the bounded font exchange. Never race it with
+ // commit, and never replay an edit after a lost response.
+ // Cancelled gesture and mark previews may still replace the session's font
+ // set. Drain those older requests before preparing this insertion's glyphs.
+ await Promise.allSettled([smilesPreviewPending, previewPending, markHover.pending]);
+ if (!currentSmilesInsert(active)) return;
+ loading = true; render();
+ const change = smilesRequest(active, position);
+ let prepared = false;
+ try {
+ await sessionRequest({session: active.session, revision: active.revision, action: 'preview', edit: change});
+ prepared = currentSmilesInsert(active);
+ } catch (error) {
+ if (currentSmilesInsert(active)) notice(error.message, true);
+ } finally { loading = false; }
+ if (!currentSmilesInsert(active)) { render(); return; }
+ cancelSmilesInsert();
+ if (prepared) await edit(change);
+ else render();
+}
+
+$('smiles-insert').onclick = () => void beginSmilesInsert();
+$('smiles-input').onkeydown = event => {
+ if (event.isComposing) return;
+ if (event.key === 'Enter') { event.preventDefault(); void beginSmilesInsert(); }
+ else if (event.key === 'Escape' && smilesInsert) { event.preventDefault(); cancelSmilesInsert(); render(); canvas.focus(); }
+};
+
+// Mark placement also needs the H metrics and +/- ink the labels may not use.
+function markFont(spec) {
+ const queries = [...new Map([...spec.queries, ...spec.mark_queries].map(query => [query.key, query])).values()];
+ return measureLabels({...spec, queries});
+}
+
+async function editWithMarkMeasurements(change) {
+ if (loading || editor.busy || editor.readOnly) return;
+ loading = true; render();
+ try {
+ await api('session',{session:editor.info.session,revision:editor.info.revision,action:'measure',font:markFont(editor.info.drawing.label_measurements)});
+ } catch (error) { notice(error.message,true); return; }
+ finally { loading = false; render(); }
+ return await edit(change);
+}
+
+function queueChargeEdit(change) {
+ if (editor.readOnly || (!chargeEdits && (loading || editor.busy))) return Promise.resolve(false);
+ const session = editor.info.session;
+ const pending = (chargeEdits ?? Promise.resolve(true)).then(ok =>
+ ok && editor.info.session === session ? editWithMarkMeasurements(change) : false
+ ).catch(error => { notice(error.message,true); return false; });
+ chargeEdits = pending;
+ void pending.then(() => { if (chargeEdits === pending) chargeEdits = null; });
+ return pending;
+}
+
+// The Ring tool's template preview under the pointer, as InsertController draws it.
+const templateHover = {request: null, result: null, pending: false};
+async function refreshTemplateHover() {
+ if (!pointerPosition || tool !== 'benzene' || !editor.document || editor.readOnly || loading || editor.busy || gesture) {
+ const visible = Boolean(templateHover.result);
+ templateHover.request = templateHover.result = null; if (visible) render(); return;
+ }
+ const hit = document.elementFromPoint(pointerPosition.clientX, pointerPosition.clientY)?.closest('[data-item^="atom:"]');
+ templateHover.request = {session: editor.info.session, revision: editor.info.revision, action: 'template_preview', ...point(pointerPosition),
+ atom_id: hit && canvas.contains(hit) ? Number(hit.dataset.item.split(':')[1]) : null, size: ringTemplate.size, style: ringTemplate.style};
+ if (templateHover.pending) return;
+ templateHover.pending = true;
+ try {
+ while (templateHover.request) {
+ const current = templateHover.request;
+ let result = null;
+ try { ({preview: result} = await api('session', current)); } catch { result = null; }
+ if (templateHover.request === current && current.revision === editor.info.revision && tool === 'benzene') { templateHover.result = result; render(); }
+ if (templateHover.request === current) break;
+ }
+ } finally { templateHover.pending = false; }
+}
+
+// Hover previews follow the pointer for the Mark and Ring tools.
+function refreshHover() {
+ if (smilesInsert) {
+ markHover.request = markHover.result = templateHover.request = templateHover.result = null;
+ moveSmilesPreview(pointerPosition ? point(pointerPosition) : null);
+ return;
+ }
+ void refreshMarkHover();
+ void refreshTemplateHover();
+}
+
+async function refreshMarkHover() {
+ if (!pointerPosition || tool !== 'mark' || !editor.document || editor.readOnly || loading || editor.busy || gesture || !pointInSheet(point(pointerPosition),editor.info.sheet)) {
+ const visible = Boolean(markHover.result);
+ markHover.request = markHover.result = null; if (visible) render(); return;
+ }
+ const p = point(pointerPosition);
+ const hits = hitsAt(pointerPosition.clientX, pointerPosition.clientY);
+ const request = {session:editor.info.session,revision:editor.info.revision,action:'mark_preview',x:p.x,y:p.y,kind:markKind,hits,scale:viewScale()};
+ markHover.request = request;
+ if (markHover.pending) return;
+ const run = async () => {
+ while (markHover.request) {
+ const current = markHover.request;
+ let result;
+ try { result = await api('session',{...current,font:markFont(editor.info.drawing.label_measurements)}); }
+ catch { if (markHover.request === current) { markHover.request = markHover.result = null; render(); break; } else continue; }
+ if (markHover.request && current.session === editor.info.session && current.revision === editor.info.revision && current.kind === markKind && tool === 'mark' && !loading && !editor.busy) {
+ markHover.result = result; render();
+ }
+ if (markHover.request === current) break;
+ }
+ };
+ markHover.pending = run();
+ try { await markHover.pending; }
+ finally { markHover.pending = false; }
+}
+
+async function resolveSelection(active) {
+ try {
+ const result = await api('session', {session: active.session, revision: active.revision,
+ action: 'pick', x: active.start.x, y: active.start.y, hits: active.hits, scale: active.scale, preferred: !active.shift});
+ if (gesture !== active) return;
+ if (editor.info.session !== active.session || editor.info.revision !== active.revision) {
+ cancelGesture();
+ return;
+ }
+ const item = result.target ? `${result.target.target}:${result.target.id}` : null;
+ // Control on an unselected object or arrow toggles it, with no drag.
+ const itemKind = result.target?.target;
+ if (item && active.additive && !active.shift && !selection.has(item)
+ && (ui.direct_select_kinds.includes(itemKind) || itemKind === 'arrow')) {
+ groupUnit(item, groupUnits()).forEach(key => selection.add(key));
+ cancelGesture();
+ return;
+ }
+ active.toggleHandle = !active.shift && (item?.startsWith('shape:') || ((item?.startsWith('arrow:') || item?.startsWith('orbital:')) && selection.has(item))) ? item : null;
+ if (active.toggleHandle === null) handleTarget = null;
+ if (!item) {
+ if (!active.additive) selection.clear();
+ } else if (active.shift) {
+ toggleUnit(item);
+ } else if (!selection.has(item)) selection = new Set(groupUnit(item, groupUnits()));
+ if (!item) {
+ active.kind = 'marquee';
+ updateMarquee(active, active.end);
+ if (active.released) { active.accepted = true; cancelGesture(); }
+ return;
+ }
+ if (active.shift || editor.readOnly) { cancelGesture(); return; }
+ active.kind = 'move'; active.selection = selectedItems(); active.hasArrows = active.selection.some(item => item.target === 'arrow');
+ if (active.released) {
+ finishSelection(active, active.end);
+ } else {
+ if (selectionGestureMoved(active, active.end)) {
+ preview = {kind: 'move', end: active.end}; previewSerial++;
+ void refreshGesturePreview();
+ }
+ render();
+ }
+ } catch (error) {
+ if (gesture === active) { cancelGesture(); notice(error.message, true); }
+ }
+}
+
+canvas.addEventListener('pointerenter', event => {
+ pointerPosition = {clientX: event.clientX, clientY: event.clientY};
+ refreshHover();
+});
+canvas.addEventListener('pointermove', event => {
+ pointerPosition = {clientX: event.clientX, clientY: event.clientY};
+ if (!editor.document) return;
+ const p = point(event);
+
+ if (!gesture) { refreshHover(); return; }
+ if (gesture.kind === 'pick') {
+ gesture.end = p;
+ return;
+ }
+ if (gesture.kind === 'marquee') {
+ updateMarquee(gesture, p);
+ } else if (gesture.kind === 'rotate') {
+ gesture.shift = event.shiftKey;
+ preview = {kind:'rotate', end:p}; previewSerial++;
+ void refreshGesturePreview();
+ } else if (gesture.kind === 'handle') {
+ if (gesture.released) return;
+ gesture.end = p; gesture.moved = true;
+ preview = {kind: 'handle', end: p}; previewSerial++;
+ void refreshGesturePreview();
+ } else if (gesture.kind === 'move') {
+ if (!selectionGestureMoved(gesture, p)) return;
+ handleTarget = null;
+ preview = {kind: 'move', end: p};
+ previewSerial++;
+ void refreshGesturePreview();
+ } else if (['bond', 'arrow', 'line', 'shape', 'ts_bracket'].includes(gesture.kind)) {
+ if (!pointInSheet(p, editor.info.sheet)) {
+ cancelGesture();
+ notice(ui.off_sheet_guidance, true);
+ return;
+ }
+ gesture.dragged ||= Math.abs(event.clientX - gesture.pressX) + Math.abs(event.clientY - gesture.pressY) >= ui.drag_distance;
+ gesture.shift = event.shiftKey;
+ preview = {kind: gesture.kind === 'bond' ? 'line' : ['shape', 'ts_bracket'].includes(gesture.kind) ? gesture.kind : 'arrow', start: gesture.start, end: p};
+ previewSerial++;
+ void refreshGesturePreview();
+ }
+ render();
+});
+
+canvas.addEventListener('pointerup', event => {
+ if (!gesture) return;
+ const completed = gesture, p = point(event);
+ if (completed.kind === 'pick') { completed.end = p; completed.released = true; return; }
+ if (completed.kind === 'marquee') { updateMarquee(completed, p); completed.accepted = true; cancelGesture(); return; }
+ if (completed.kind === 'rotate') {
+ completed.shift = event.shiftKey;
+ cancelGesture();
+ if (editor.info.session === completed.session && editor.info.revision === completed.revision) void edit(rotationRequest(completed,p));
+ return;
+ }
+ if (completed.kind === 'handle') { void finishHandle(completed); return; }
+ if (completed.kind === 'move') { finishSelection(completed, p); return; }
+ cancelGesture();
+ if (completed.kind === 'shape') {
+ void edit(shapeRequest(completed, p));
+ } else if (completed.kind === 'ts_bracket') {
+ // Native brackets commit on release; a click places the default size.
+ void edit(bracketRequest(completed, p));
+ } else if (completed.kind === 'arrow' || completed.kind === 'line') {
+ completed.dragged ||= Math.abs(event.clientX - completed.pressX) + Math.abs(event.clientY - completed.pressY) >= ui.drag_distance;
+ completed.shift = event.shiftKey;
+ // Native clicks on existing items do not publish an arrow/line edit.
+ // Keep the second click available for the label dialog.
+ if (!completed.dragged && (completed.kind === 'arrow' || completed.hits.length)) return;
+ void edit(arrowRequest(completed, p));
+ } else if (completed.kind === 'bond') {
+ void edit(bondRequest(completed, p));
+ }
+});
+// Qt opens labels on the second press. Rendering can replace an SVG child
+// before release, so the later browser dblclick event is not reliable here.
+canvas.addEventListener('mousedown', async event => {
+ if (smilesInsert || event.detail !== 2 || event.button !== 0 || !['select', 'arrow', 'line'].includes(tool) || editor.readOnly || editor.busy || loading
+ || (ui.navigation.zoom_modifier === 'meta' && event.ctrlKey)) return;
+ cancelGesture();
+ const p = point(event);
+ const hits = hitsAt(event.clientX, event.clientY);
+ const session = editor.info.session, revision = editor.info.revision;
+ loading = true; render();
+ try {
+ const result = await api('session', {session, revision, action: 'pick', ...p, hits,
+ preferred: false, scale: viewScale()});
+ if (result.target?.target !== 'arrow' || editor.info.revision !== revision) return;
+ const id = result.target.id, original = editor.document.state.arrows[id].labels ?? {};
+ const initial = {};
+ for (const side of ['above', 'below']) {
+ const field = $(`arrow-label-${side}`);
+ field.value = original[side] ?? '';
+ initial[side] = field.value;
+ }
+ const values = () => Object.fromEntries(['above', 'below'].map(side => [side,
+ $(`arrow-label-${side}`).value === initial[side] ? original[side] ?? '' : $(`arrow-label-${side}`).value]));
+ const dialog = $('arrow-label-dialog');
+ let serial = 0;
+ const update = async () => {
+ const current = ++serial, labels = values();
+ let over = false;
+ for (const side of ['above', 'below']) {
+ const count = Array.from(labels[side]).length;
+ over ||= count > ui.arrow_labels.limit;
+ $(`arrow-label-${side}-count`).textContent = `${count}/${ui.arrow_labels.limit} characters${count > ui.arrow_labels.limit ? ' — shorten before OK' : ''}`;
+ }
+ $('arrow-label-ok').disabled = over;
+ try {
+ const response = await api('session', {session, revision, action: 'label_preview', labels});
+ if (serial !== current) return;
+ for (const side of ['above', 'below']) {
+ const element = $(`arrow-label-${side}-preview`);
+ element.innerHTML = response.html[side] || 'No label';
+ styleArrowLabel(element, {...ui.arrow_labels.preview, family: getComputedStyle(document.documentElement).fontFamily});
+ element.style.background = ui.arrow_labels.preview.background;
+ }
+ } catch (error) { if (serial === current) notice(error.message, true); }
+ };
+ for (const side of ['above', 'below']) $(`arrow-label-${side}`).oninput = update;
+ $('arrow-label-hint').textContent = ui.arrow_labels.hint;
+ const closed = openDialog(dialog);
+ $('arrow-label-above').focus();
+ void update();
+ const accepted = await closed === 'ok';
+ serial++;
+ const labels = values();
+ loading = false;
+ if (accepted) await edit({kind: 'arrow_labels', id, labels});
+ } catch (error) { notice(error.message, true); }
+ finally { loading = false; render(); canvas.focus(); }
+});
+$('arrow-label-cancel').onclick = () => $('arrow-label-dialog').close('cancel');
+canvas.addEventListener('contextmenu', event => {
+ event.preventDefault();
+ if (editor.readOnly || editor.busy || loading || gesture || smilesInsert) return;
+ const stack = document.elementsFromPoint(event.clientX,event.clientY)
+ .filter(element => canvas.contains(element));
+ if (stack.some(element => element.closest('[data-handle]'))) return;
+ const mark = stack.map(element => element.closest('[data-item^="mark:"]')).find(Boolean);
+ if (!mark) { void showBondMenu(event); return; }
+ const id = Number(mark.dataset.item.split(':')[1]);
+ const session = editor.info.session, revision = editor.info.revision;
+ const menu = $('mark-menu');
+ menu.hidden = false;
+ menu.style.left = `${Math.min(event.clientX, innerWidth - menu.offsetWidth)}px`;
+ menu.style.top = `${Math.min(event.clientY, innerHeight - menu.offsetHeight)}px`;
+ $('reassign-mark').onclick = async () => {
+ menu.hidden = true;
+ if (editor.info.session !== session || editor.info.revision !== revision) return;
+ const state = editor.document.state, owner = state.marks[id].atom_id;
+ const atoms = state.model.atoms, field = $('mark-owner-atom');
+ field.replaceChildren();
+ if (owner === null) field.add(new Option('Free mark (unchanged)', ''));
+ for (const [atomId, atom] of Object.entries(atoms).sort(([a],[b]) => Number(a)-Number(b))) {
+ field.add(new Option(`${atom.element} #${atomId} (${Number(atom.x).toFixed(2)}, ${Number(atom.y).toFixed(2)})${Number(atomId) === owner ? ' — current owner' : ''}`, atomId));
+ }
+ field.value = owner === null ? '' : String(owner);
+ $('mark-owner-current').textContent = editor.info.drawing.mark_owners[id].text;
+ const originalView = {...view};
+ const highlight = () => {
+ $('mark-candidate').replaceChildren();
+ render();
+ if (field.value === '') return;
+ const rect = editor.info.drawing.mark_owner_rects[field.value];
+ if (!rect) return;
+ const [x,y,w,h] = rect;
+ const scale = viewScale();
+ const margin = 80/scale, left = view.x, top = view.y;
+ // QGraphicsView ensureVisible rounds each requested scrollbar value.
+ if (x <= left+margin) view.x = Math.trunc(x*scale-80-0.5)/scale;
+ if (x+w >= left+view.width-margin) view.x = Math.trunc((x+w-view.width)*scale+80+0.5)/scale;
+ if (y <= top+margin) view.y = Math.trunc(y*scale-80-0.5)/scale;
+ if (y+h >= top+view.height-margin) view.y = Math.trunc((y+h-view.height)*scale+80+0.5)/scale;
+ view = clampView(view, {width:canvas.clientWidth, height:canvas.clientHeight}, editor.info.drawing.scene_rect);
+ canvas.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`);
+ const ellipse = document.createElementNS('http://www.w3.org/2000/svg','ellipse');
+ for (const [key,value] of Object.entries({cx:x+w/2,cy:y+h/2,rx:w/2+2,ry:h/2+2,fill:'none',stroke:'#a21caf','stroke-width':editor.info.drawing.selection_style.screen_width,'vector-effect':'non-scaling-stroke','pointer-events':'none'})) ellipse.setAttribute(key,String(value));
+ $('mark-candidate').append(ellipse);
+ };
+ const dialog = $('mark-owner-dialog');
+ loading = true;
+ field.onchange = highlight;
+ const closed = openDialog(dialog);
+ highlight(); field.focus();
+ const accepted = await closed === 'ok';
+ $('mark-candidate').replaceChildren();
+ view = originalView; loading = false;
+ if (accepted && field.value !== '') await edit({kind:'mark_owner',id,atom_id:Number(field.value)});
+ render(); canvas.focus();
+ };
+ $('reassign-mark').focus();
+});
+$('mark-owner-cancel').onclick = () => $('mark-owner-dialog').close('cancel');
+// The desktop's double-bond position menu, for the bond the native context hit finds.
+async function showBondMenu(event) {
+ const session = editor.info.session, revision = editor.info.revision, generation = smilesGeneration;
+ let menu;
+ try {
+ ({menu} = await api('session', {session, revision, action: 'bond_menu', ...point(event),
+ hits: hitsAt(event.clientX, event.clientY), scale: viewScale()}));
+ } catch (error) { notice(error.message, true); return; }
+ if (!menu || generation !== smilesGeneration || smilesInsert || editor.info.session !== session || editor.info.revision !== revision || editor.busy || gesture) return;
+ const element = $('bond-menu');
+ element.replaceChildren(...menu.entries.map(entry => {
+ const button = document.createElement('button');
+ button.setAttribute('role', 'menuitemradio');
+ button.setAttribute('aria-checked', String(entry.checked));
+ button.textContent = entry.label;
+ button.onclick = () => {
+ element.hidden = true;
+ if (editor.info.revision === revision && !entry.checked) void edit({kind: 'double_position', id: menu.bond, position: entry.position});
+ };
+ return button;
+ }));
+ element.hidden = false;
+ element.style.left = `${Math.min(event.clientX, innerWidth - element.offsetWidth)}px`;
+ element.style.top = `${Math.min(event.clientY, innerHeight - element.offsetHeight)}px`;
+ element.querySelector('button')?.focus();
+}
+document.addEventListener('pointerdown', event => {
+ for (const id of ['mark-menu', 'bond-menu']) if (!$(id).contains(event.target)) $(id).hidden = true;
+});
+document.addEventListener('keydown', event => { if (event.key === 'Escape') { $('mark-menu').hidden = true; $('bond-menu').hidden = true; } });
+
+canvas.addEventListener('pointerleave', () => { pointerPosition = null; refreshHover(); });
+canvas.addEventListener('pointercancel', cancelGesture);
+canvas.addEventListener('lostpointercapture', () => { if (gesture && !(['pick', 'handle'].includes(gesture.kind) && gesture.released)) cancelGesture(); });
+canvas.addEventListener('wheel', event => {
+ event.preventDefault();
+ if (gesture || !ui || !editor.info) return;
+ const rect = canvas.getBoundingClientRect();
+ const deltaMode = event.deltaMode;
+ view = wheelView(view, {width: canvas.clientWidth, height: canvas.clientHeight}, {
+ deltaX: event.deltaX, deltaY: event.deltaY, deltaMode, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
+ position: {x: event.clientX - rect.left, y: event.clientY - rect.top},
+ }, ui.navigation, measureLineHeight(getComputedStyle(canvas).font, 'M'));
+ render(); refreshHover();
+}, {passive: false});
+window.addEventListener('blur', cancelGesture);
+document.addEventListener('pointerdown', event => {
+ const control = event.target.closest('button, summary');
+ if (event.button !== 0 || !control || control.closest('dialog')) return;
+ // Match native pointer focus policies without changing keyboard activation.
+ event.preventDefault();
+ if (control.closest('.menus, footer')) canvas.focus();
+});
+
+
+function setGrid(mode) {
+ cancelGesture();
+ if (mode !== 'none') grid.style = mode;
+ grid.enabled = mode !== 'none';
+ render();
+}
+$('grid-mode').onclick = () => setGrid(ui.grid.modes[(ui.grid.modes.indexOf(gridMode())+1)%ui.grid.modes.length]);
+$('grid-toggle').onclick = () => setGrid(grid.enabled ? 'none' : grid.style);
+// View > Valence Checking: the desktop's per-canvas view switch, on for each
+// opened drawing. Its ids come only from the accepted drawing, placed where the
+// canvas shows those atoms, so a move preview carries them while a drawing
+// preview adds none until it is accepted. It changes no document or history.
+function valenceFeedback() {
+ if (!valenceChecking) return '';
+ return valenceWarningMarkup(previewInfo?.document ?? editor.document, editor.info.drawing.valence_warnings, previewInfo?.drawing ?? editor.info.drawing, ui.valence_warning, viewScale(), visibleSceneRect());
+}
+$('valence-toggle').onclick = () => { valenceChecking = !valenceChecking; render(); };
+
+function updateSheetFields() {
+ const custom = $('sheet-size').value === ui.sheet_setup.custom;
+ $('sheet-orientation').disabled = custom;
+ for (const axis of ['width','height']) $(`sheet-${axis}`).disabled = !custom;
+ document.querySelectorAll('[data-sheet-step]').forEach(button => { button.disabled = !custom; });
+ if (!custom) {
+ let [width,height] = ui.sheet_setup.dimensions[$('sheet-size').value];
+ if ($('sheet-orientation').value === 'landscape') [width,height] = [height,width];
+ $('sheet-width').value = width.toFixed(ui.sheet_setup.decimals);
+ $('sheet-height').value = height.toFixed(ui.sheet_setup.decimals);
+ }
+}
+
+function stepSheetDimension(input, direction) {
+ if (input.disabled) return;
+ const spec = ui.sheet_setup, value = Number(input.value);
+ input.value = Math.max(spec.minimum,Math.min(spec.maximum,value+direction*spec.step)).toFixed(spec.decimals);
+}
+
+$('sheet-setup').onclick = async () => {
+ if (loading || editor.busy || editor.readOnly) return;
+ cancelGesture(); loading = true; render();
+ const settings = editor.document.state.settings, dialog = $('sheet-dialog');
+ $('sheet-size').value = settings.sheet_size;
+ $('sheet-orientation').value = settings.sheet_orientation;
+ if (settings.sheet_custom_size_mm) {
+ const [width,height] = settings.sheet_custom_size_mm;
+ $('sheet-width').value = width.toFixed(ui.sheet_setup.decimals);
+ $('sheet-height').value = height.toFixed(ui.sheet_setup.decimals);
+ }
+ updateSheetFields();
+ const closed = openDialog(dialog);
+ $('sheet-size').focus();
+ const accepted = await closed === 'ok';
+ loading = false;
+ if (accepted) await edit({kind:'sheet_setup',size:$('sheet-size').value,orientation:$('sheet-orientation').value,custom_size_mm:$('sheet-size').value === ui.sheet_setup.custom ? ['width','height'].map(axis=>Number(Number($(`sheet-${axis}`).value).toFixed(ui.sheet_setup.decimals))) : null});
+ render(); canvas.focus();
+};
+$('sheet-size').onchange = $('sheet-orientation').onchange = updateSheetFields;
+$('sheet-cancel').onclick = () => $('sheet-dialog').close('cancel');
+
+let canvasCount = 0;
+const newCanvasName = () => ui.canvas_name.replace('{}', ++canvasCount);
+// Like a new desktop canvas, continue the active drawing's settings.
+$('new').onclick = () => {
+ if (!mayReplace()) return;
+ const current = editor.document?.state.settings;
+ void loadDocument(api('new').then(info => {
+ for (const key of ui.new_canvas_settings) {
+ if (current && key in current) info.document.state.settings[key] = current[key];
+ else if (current) delete info.document.state.settings[key];
+ }
+ return info;
+ }), newCanvasName());
+};
+$('open').onclick = () => { if (mayReplace()) $('file').click(); };
+$('file').onchange = async () => {
+ const file = $('file').files[0];
+ $('file').value = '';
+ if (!file) return;
+ if (file.size > ui.max_document_bytes) { notice(`The browser adapter opens files up to ${ui.max_document_bytes / 1048576} MiB.`, true); return; }
+ await loadDocument(file.text().then(text => api('open', text)), file.name);
+};
+// File > Save copy: the session's document, as a download. An open note is
+// committed first, as leaving it would, and a note that was not saved stops the
+// copy; one copy runs at a time, and a reply for a document since replaced,
+// renamed, edited or busy is not its copy.
+let savingCopy = false;
+$('save').onclick = async () => {
+ if (savingCopy || !editor.document || editor.busy || loading) return;
+ savingCopy = true;
+ try {
+ if (await finishNoteEdit() === false) return;
+ if (!editor.document || editor.busy || loading) return;
+ const {session, revision} = editor.info, name = editor.name;
+ const current = () => !editor.busy && !loading && editor.info?.session === session && editor.info?.revision === revision && editor.name === name;
+ // The session holds embedded images; the browser's copy carries references.
+ try {
+ const {document} = await api('session', {session, revision, action: 'export'});
+ if (current()) {
+ download(JSON.stringify(document, null, 2) + '\n', name.replace(/\.chemvas$/i, '') + '-web-copy.chemvas', 'application/json');
+ notice('Save copy requested. Check your downloads before closing; the original file has not changed.');
+ }
+ } catch (error) { if (current()) notice(error.message, true); }
+ } finally { savingCopy = false; }
+};
+// File > Export Figure: the whole canvas as the desktop's plain SVG figure, as a
+// download. Like Export MOL, an open note is committed first and a note that was
+// not saved stops the export; one export runs at a time, a reply for a document
+// since replaced, renamed, edited or busy is not its figure, and a download
+// clears only the export's own refusal while that is still the notice shown.
+let exportingFigure = false, figureRefusal = null;
+$('export-figure').onclick = async () => {
+ if (exportingFigure || !editor.document || editor.busy || loading) return;
+ exportingFigure = true;
+ try {
+ if (await finishNoteEdit() === false) return;
+ if (!editor.document || editor.busy || loading) return;
+ const {session, revision} = editor.info, name = editor.name;
+ const current = () => !editor.busy && !loading && editor.info?.session === session && editor.info?.revision === revision && editor.name === name;
+ try {
+ const {svg} = await api('session', {session, revision, action: 'export_figure', format: 'svg', scope: 'sheet'});
+ if (current()) {
+ download(svg, name.replace(/\.chemvas$/i, '') + '.svg', 'image/svg+xml');
+ if (figureRefusal === noticeSerial) notice();
+ figureRefusal = null;
+ }
+ } catch (error) { if (current()) { notice(error.message, true); figureRefusal = noticeSerial; } }
+ } finally { exportingFigure = false; }
+};
+// File > Export MOL: the desktop's selected-only Molfile, as a download. An open
+// note is committed first, as leaving it would, and a note that was not saved
+// stops the export; one export runs at a time, and a reply for a document since
+// replaced, renamed, edited or busy is not its MOL. A download clears only the
+// export's own refusal, and only while that is still the notice shown.
+let exportingMol = false, molRefusal = null;
+$('export-mol').onclick = async () => {
+ if (exportingMol || !editor.document || editor.busy || loading) return;
+ exportingMol = true;
+ try {
+ if (await finishNoteEdit() === false) return;
+ if (!editor.document || editor.busy || loading) return;
+ const {session, revision} = editor.info, name = editor.name;
+ const current = () => !editor.busy && !loading && editor.info?.session === session && editor.info?.revision === revision && editor.name === name;
+ try {
+ const {molfile} = await api('session', {session, revision, action: 'export_mol', selection: selectedItems()});
+ if (current()) {
+ download(molfile, name.replace(/\.chemvas$/i, '') + '.mol', 'chemical/x-mdl-molfile');
+ if (molRefusal === noticeSerial) notice();
+ molRefusal = null;
+ }
+ } catch (error) { if (current()) { notice(error.message, true); molRefusal = noticeSerial; } }
+ } finally { exportingMol = false; }
+};
+for (const action of ['undo', 'redo']) $(action).onclick = async () => {
+ cancelGesture(); const pending = editor[action](); render();
+ try { await pending; selection = new Set(); } catch (error) { notice(error.message, true); } finally { render(); refreshHover(); }
+};
+$('delete').onclick = () => void deleteSelection();
+$('select-all').onclick = selectAll;
+// The menu reads the clipboard through the browser's permission prompt; the
+// keyboard's paste event needs none.
+$('copy').onclick = () => void copyChemistry(false);
+$('cut').onclick = () => void copyChemistry(true);
+$('paste').onclick = () => void pasteFromMenu();
+$('recover').onclick = () => void showRecovery();
+// The scene area the canvas shows, for the desktop's image placement.
+function visibleSceneRect() {
+ const box = canvas.getBoundingClientRect(), matrix = canvas.getScreenCTM()?.inverse();
+ if (!matrix) return [view.x, view.y, view.width, view.height];
+ const corner = (x, y) => new DOMPoint(x, y).matrixTransform(matrix);
+ const a = corner(box.left, box.top), b = corner(box.right, box.bottom);
+ return [Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y)];
+}
+function base64Bytes(buffer) {
+ const bytes = new Uint8Array(buffer), chunks = [];
+ for (let start = 0; start < bytes.length; start += 0x8000) chunks.push(String.fromCharCode(...bytes.subarray(start, start + 0x8000)));
+ return btoa(chunks.join(''));
+}
+$('insert-image').onclick = () => { if (!editor.readOnly) $('image-file').click(); };
+$('image-file').onchange = async event => {
+ const [file] = event.target.files;
+ event.target.value = '';
+ if (!file) return;
+ if (file.size > ui.max_image_bytes) { notice(`The image exceeds the ${ui.max_image_bytes / 1048576} MiB limit.`, true); return; }
+ const data = base64Bytes(await file.arrayBuffer());
+ if (await edit({kind: 'insert_image', data_base64: data, view: visibleSceneRect()})) {
+ // The desktop selects the new image and returns to Select.
+ setTool('select');
+ selection = new Set([`image:${editor.document.state.images.length - 1}`]);
+ render();
+ }
+};
+$('image-properties').onclick = async () => {
+ const spec = ui.image_properties, images = editor.document?.state.images ?? [];
+ const selected = images.flatMap((_, id) => selection.has(`image:${id}`) ? [id] : []);
+ if (!selected.length) { notice(spec.none_selected); return; }
+ const dialog = $('image-dialog'), choice = $('image-choice'), round = (value, digits) => Number(value.toFixed(digits));
+ $('image-title').textContent = spec.title;
+ $('image-choice-label').textContent = spec.choose;
+ $('image-choice-row').hidden = selected.length < 2;
+ choice.replaceChildren(...selected.map((id, index) => {
+ const image = images[id];
+ const format = spec.choice.replace('{index}', index + 1).replace('{width}', image.pixel_width).replace('{height}', image.pixel_height).replace('{x:g}', image.x).replace('{y:g}', image.y);
+ return new Option(format, String(id));
+ }));
+ $('image-lock-label').textContent = spec.lock_aspect;
+ $('image-opacity-label').textContent = spec.opacity;
+ $('image-opacity-suffix').textContent = spec.opacity_suffix;
+ const fields = $('image-fields');
+ fields.replaceChildren(...spec.fields.map(([key, label]) => {
+ const row = document.createElement('label'), input = document.createElement('input');
+ input.type = 'number'; input.id = `image-${key}`; input.step = String(10 ** -spec.decimals);
+ input.min = String(['width', 'height'].includes(key) ? spec.size_minimum : spec.coordinate_minimum); input.max = String(spec.maximum);
+ row.append(spec.field_label.replace('{label}', label), input);
+ return row;
+ }));
+ let initial = {};
+ const fill = () => {
+ const image = images[Number(choice.value)];
+ $('image-original').textContent = spec.original.replace('{width}', image.pixel_width).replace('{height}', image.pixel_height);
+ initial = Object.fromEntries(spec.fields.map(([key]) => [key, round(image[key], spec.decimals)]));
+ for (const [key, value] of Object.entries(initial)) $(`image-${key}`).value = value;
+ $('image-lock').checked = image.lock_aspect;
+ $('image-opacity').value = round(image.opacity * 100, spec.opacity_decimals);
+ };
+ // The desktop's aspect lock: width drives height through the pixel ratio.
+ const sync = changed => {
+ const image = images[Number(choice.value)];
+ if (!$('image-lock').checked) return;
+ const ratio = image.pixel_width / image.pixel_height, value = Number($(`image-${changed}`).value);
+ if (changed === 'width') $('image-height').value = round(value / ratio, spec.decimals);
+ else $('image-width').value = round(value * ratio, spec.decimals);
+ };
+ choice.onchange = fill;
+ $('image-width').oninput = () => sync('width');
+ $('image-height').oninput = () => sync('height');
+ $('image-lock').onchange = () => { if ($('image-lock').checked) sync('width'); };
+ fill();
+ if (await openDialog(dialog) !== 'ok') return;
+ const image = images[Number(choice.value)], changes = {lock_aspect: $('image-lock').checked};
+ // Accepting unchanged fields is an exact no-op despite display rounding.
+ for (const [key] of spec.fields) {
+ const value = Number($(`image-${key}`).value);
+ if (value !== initial[key]) changes[key] = value;
+ }
+ const opacity = Number($('image-opacity').value);
+ if (opacity !== round(image.opacity * 100, spec.opacity_decimals)) changes.opacity = opacity / 100;
+ void edit({kind: 'image_properties', id: Number(choice.value), changes});
+};
+$('image-cancel').onclick = () => $('image-dialog').close('cancel');
+for (const [id, kind] of [['group', 'group'], ['ungroup', 'ungroup']]) {
+ $(id).onclick = async () => {
+ if (!selection.size) return;
+ if (await edit({kind, selection: selectedItems()})) { selection = expandToGroups(selection, groupUnits()); render(); }
+ };
+}
+for (const [id,horizontal] of [['flip-horizontal',true],['flip-vertical',false]]) $(id).onclick = () => void edit({kind:'flip',selection:selectedItems(),horizontal});
+$('rotate-menu').onclick = () => { setTool('select'); $('rotate-angle').focus(); $('rotate-angle').select(); };
+$('rotate-up').onclick = () => $('rotate-angle').stepUp();
+$('rotate-down').onclick = () => $('rotate-angle').stepDown();
+function rotateSelected() {
+ const input = $('rotate-angle');
+ if (input.value && input.reportValidity()) void edit({kind:'rotate',selection:selectedItems(),value:Number(input.value)});
+}
+$('rotate-apply').onclick = rotateSelected;
+$('rotate-angle').onkeydown = event => { if(event.key === 'Enter') { event.preventDefault(); rotateSelected(); } };
+$('bring-front').onclick = () => void edit({kind: 'stack', selection: selectedItems(), front: true});
+$('send-back').onclick = () => void edit({kind: 'stack', selection: selectedItems(), front: false});
+$('zoom-in').onclick = () => zoom(1 / ui.navigation.step);
+$('zoom-out').onclick = () => zoom(ui.navigation.step);
+$('fit').onclick = fitPage;
+$('fit-menu').onclick = fitPage;
+$('actual-size').onclick = $('zoom-level').onclick = actualSize;
+$('zoom-in-menu').onclick = () => zoom(1 / ui.navigation.step);
+$('zoom-out-menu').onclick = () => zoom(ui.navigation.step);
+$('save-as').onclick = () => $('save').click();
+$('more-colors').onclick = () => { $('custom-color').value = paintColor ?? '#000000'; $('custom-color').click(); };
+$('custom-color').onchange = () => chooseColor($('custom-color').value);
+$('ring-more-colors').onclick = () => { $('custom-ring-color').value = ringFillColor; $('custom-ring-color').click(); };
+$('custom-ring-color').onchange = () => chooseRingFill($('custom-ring-color').value);
+// The desktop field commits a changed positive value and restores anything else.
+function commitBondLength(value) {
+ const field = $('bond-length'), current = editor.document?.state.settings.bond_length_px;
+ const rounded = Number(value.toFixed(ui.bond_length_input.decimals));
+ if (!Number.isFinite(rounded) || rounded <= 0 || rounded > ui.bond_length_input.max || rounded === current) { field.value = current; return; }
+ void edit({kind: 'bond_length', value: rounded});
+}
+$('bond-length').onchange = () => commitBondLength(Number($('bond-length').value));
+$('bond-length-up').onclick = () => commitBondLength(Number($('bond-length').value) + ui.bond_length_input.step);
+$('bond-length-down').onclick = () => commitBondLength(Number($('bond-length').value) - ui.bond_length_input.step);
+$('atom-label-cancel').onclick = () => $('atom-dialog').close('cancel');
+$('help').onclick = () => $('help-dialog').showModal();
+$('close-help').onclick = () => $('help-dialog').close();
+window.addEventListener('beforeunload', event => { if (editor.dirty || editor.busy || pendingNoteChanges()) { event.preventDefault(); event.returnValue = ''; } });
+document.addEventListener('keydown', event => {
+ if (event.isComposing || document.querySelector('dialog[open]')) return;
+ const popup = document.querySelector('.arrow-popup[open], #grid-options[open]');
+ if (event.key === 'Escape' && popup) { event.preventDefault(); popup.open = false; canvas.focus(); return; }
+ if (event.target.matches('input, textarea, select')) return;
+ // The note editor owns its keys; Escape ends it like the desktop Text tool.
+ if (noteEditorElement.contains(event.target) && event.key !== 'Escape') return;
+ // Focused controls own activation, even while the pointer stays over the canvas.
+ if (['Enter', ' '].includes(event.key) && event.target.closest('button, summary, a[href]')) return;
+ if (event.key === 'Escape') { event.preventDefault(); setTool('select'); return; }
+ const queuedCharge = chargeEdits && !event.ctrlKey && !event.metaKey && !event.altKey && ['+','-'].includes(event.key);
+ if ((editor.busy || loading) && !queuedCharge) return;
+ const key = event.key.toLowerCase(), command = event.ctrlKey || event.metaKey;
+ // Copy and Cut start here, inside the key gesture; Paste arrives as the
+ // browser's paste event, the only way to read the clipboard without asking.
+ if (command && !event.altKey && !event.shiftKey && ['c', 'x'].includes(key) && selection.size && canvasOwnsClipboard(event.target)) {
+ event.preventDefault(); void copyChemistry(key === 'x');
+ }
+ else if (command && key === 'a') { event.preventDefault(); selectAll(); }
+ else if (command && event.shiftKey && !event.altKey && ['h','v'].includes(key)) { event.preventDefault(); if (!editor.readOnly) $(key === 'h' ? 'flip-horizontal' : 'flip-vertical').click(); }
+ else if (command && key === 'n') { event.preventDefault(); $('new').click(); }
+ else if (command && key === 'g' && !event.altKey) { event.preventDefault(); if (!editor.readOnly) $(event.shiftKey ? 'ungroup' : 'group').click(); }
+ // The desktop's view keys: Control with a zoom key, or a bare function key.
+ else if ((command && ui.navigation.zoom_keys[event.key]) || (!command && !event.shiftKey && !event.altKey && ui.navigation.function_keys[event.key])) {
+ event.preventDefault();
+ const action = command ? ui.navigation.zoom_keys[event.key] : ui.navigation.function_keys[event.key];
+ $({actual_size: 'actual-size', fit: 'fit', zoom_in: 'zoom-in', zoom_out: 'zoom-out'}[action]).click();
+ }
+ else if (command && key === 'z') { event.preventDefault(); $(event.shiftKey ? 'redo' : 'undo').click(); }
+ // Qt's standard Redo is also Ctrl+Y where Control is the command key.
+ else if (command && key === 'y' && ui.navigation.zoom_modifier === 'control') { event.preventDefault(); $('redo').click(); }
+ // Selection keys: Alt+arrow turns, Shift+arrow moves by the desktop's steps.
+ else if (event.key.startsWith('Arrow') && !command && (event.altKey !== event.shiftKey)) {
+ const name = event.key.slice('Arrow'.length);
+ event.preventDefault();
+ if (editor.readOnly || !selection.size) return;
+ if (event.altKey) void edit({kind: 'rotate', value: ui.navigation.rotate_keys[name], selection: selectedItems()});
+ else {
+ const [dx, dy] = ui.navigation.nudge_keys[name];
+ void edit({kind: 'move', selection: selectedItems(), dx, dy});
+ }
+ }
+ else if (command && key === 's') { event.preventDefault(); $('save').click(); }
+ else if (command && key === 'o') { event.preventDefault(); $('open').click(); }
+ else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); if (!editor.readOnly) void deleteSelection(true); }
+ else if (event.key === 'Enter' && !command && !event.altKey && !editor.readOnly && pointerPosition) {
+ event.preventDefault(); cancelGesture();
+ const {hits, scale, ...target} = hoverPoint();
+ void atomInput({kind: 'atom_prompt', ...target});
+ }
+ else if (!command && !event.altKey) {
+ const text = event.shiftKey ? event.key.toUpperCase() : key;
+ if (pointerPosition && !editor.readOnly && ui.hover_shortcuts.includes(text)) {
+ event.preventDefault();
+ cancelGesture();
+ const apply = ['+','-'].includes(text) ? queueChargeEdit : edit;
+ void apply({kind: 'hover_shortcut', ...hoverPoint(), key: text}).then(ok => {
+ const tool = editor.info.shortcut_tool;
+ if (ok && tool) switchByHotkey(tool, event.shiftKey ? ui.shift_tool_hotkeys[text] : null);
+ });
+ return;
+ }
+ // Qt Shift+T/G/E: switch tool and reset its kind, after hover shortcuts.
+ const shifted = event.shiftKey ? ui.shift_tool_hotkeys[event.key.toUpperCase()] : null;
+ if (shifted && !editor.readOnly) {
+ event.preventDefault();
+ switchByHotkey(shifted.tool, shifted);
+ return;
+ }
+ const next = ui.tool_hotkeys[key];
+ if (next && !event.shiftKey && (!editor.readOnly || next === 'select')) {
+ event.preventDefault();
+ switchByHotkey(next, null);
+ }
+ }
+});
+new ResizeObserver(() => render()).observe(canvas);
+try { ui = await api('ui'); buildControls(); await loadDocument(api('new'), newCanvasName()); void offerRecovery(); } catch (error) { notice(error.message, true); }
+
+
+function actualSize() {
+ if (!editor.info) return;
+ view = {x: -canvas.clientWidth / 2, y: -canvas.clientHeight / 2, width: canvas.clientWidth, height: canvas.clientHeight};
+ render();
+ // As zoom() does: a held move asks again for the view now shown.
+ if (gesture?.kind === 'move' && preview && !gesture.released && pointerPosition) {
+ preview = {kind: 'move', end: point(pointerPosition)}; previewSerial++;
+ void refreshGesturePreview();
+ }
+}
+
+function chooseRingFill(value) {
+ ringFillColor = value;
+ void edit({kind: 'ring_fill', color: value, selection: selectedItems()});
+}
+
+function chooseColor(value) {
+ paintColor = value;
+ setTool('color');
+ if (selection.size) void edit({kind: 'color', color: value, selection: selectedItems()});
+}
+
+function buildControls() {
+ $('smiles-label').textContent = ui.smiles.label;
+ $('smiles-input').placeholder = ui.smiles.placeholder;
+ $('smiles-input').title = ui.smiles.tooltip;
+ $('smiles-input').maxLength = ui.smiles.maximum_length;
+ $('smiles-insert').textContent = ui.smiles.button_label;
+ $('smiles-insert').title = ui.smiles.button_tooltip;
+ $('grid-mode').title = $('grid-options').title = ui.grid.hint;
+ for (const mode of ui.grid.modes) {
+ const button = document.createElement('button'); button.dataset.grid = mode; button.dataset.idle = '';
+ button.textContent = mode[0].toUpperCase()+mode.slice(1); button.setAttribute('role','menuitemradio');
+ button.onclick = () => { setGrid(mode); $('grid-options').open = false; };
+ $('grid-menu').append(button);
+ }
+ $('grid-menu').append(document.createElement('hr'));
+ for (const percent of ui.grid.strengths) {
+ const button = document.createElement('button'); button.dataset.gridStrength = percent; button.dataset.idle = '';
+ button.textContent = `Strength ${percent}%`; button.setAttribute('role','menuitemradio');
+ button.onclick = () => { grid.opacity = percent/100; $('grid-options').open = false; render(); };
+ $('grid-menu').append(button);
+ }
+ const sheet = ui.sheet_setup;
+ for (const [key,text] of Object.entries(sheet.text)) $(['title','explanation'].includes(key) ? `sheet-${key}` : `sheet-${key}-label`).textContent = text;
+ $('sheet-dialog').setAttribute('aria-label',sheet.text.title);
+ for (const size of sheet.sizes) $('sheet-size').add(new Option(size,size));
+ for (const [value,label] of sheet.orientations) $('sheet-orientation').add(new Option(label,value));
+ for (const axis of ['width','height']) {
+ const input = $(`sheet-${axis}`); input.min = sheet.minimum; input.max = sheet.maximum;
+ input.onchange = () => { if (input.value && input.checkValidity()) input.value = Number(input.value).toFixed(sheet.decimals); };
+ input.onkeydown = event => { if (['ArrowUp','ArrowDown'].includes(event.key)) { event.preventDefault(); stepSheetDimension(input,event.key === 'ArrowUp' ? 1 : -1); } };
+ }
+ document.querySelectorAll('[data-sheet-step]').forEach(button => {
+ const [axis,direction] = button.dataset.sheetStep.split(':');
+ button.onclick = () => stepSheetDimension($(`sheet-${axis}`),Number(direction));
+ });
+ for (const [kind,actions] of Object.entries(ui.arrange_actions)) {
+ for (const action of actions) {
+ for (const surface of ['menu','options']) {
+ const button = document.createElement('button');
+ button.dataset.editable = ''; button.title = action.tip;
+ button.setAttribute('aria-label',surface === 'menu' ? action.label : action.tip);
+ if (surface === 'menu') button.textContent = action.label; else button.innerHTML = action.icon;
+ button.onclick = () => void edit({kind,selection:selectedItems(),mode:action.mode});
+ $(`${kind}-${surface}`).append(button);
+ }
+ }
+ }
+ // Each page's caption is the desktop's.
+ document.querySelectorAll('[data-context] > .caption').forEach(caption => { caption.textContent = ui.context_captions[caption.parentElement.dataset.context] ?? ''; });
+ // The desktop Text page: size steps, then format groups between dividers.
+ const textFormat = $('text-format');
+ for (const size of ui.text_format.sizes) textFormat.append(textFormatButton(size, {delta: size.delta}));
+ for (const group of ui.text_format.groups) {
+ const divider = document.createElement('span'); divider.className = 'context-divider'; textFormat.append(divider);
+ for (const action of group) textFormat.append(textFormatButton(action, {key: action.key}, true));
+ }
+ for (const action of ui.flip_actions) {
+ const button = document.createElement('button');
+ button.innerHTML = action.icon; button.title = `${action.label} (${action.shortcut.replace('Ctrl','⌘/Ctrl')})`;
+ button.setAttribute('aria-label',action.label); button.dataset.editable = '';
+ button.onclick = () => void edit({kind:'flip',selection:selectedItems(),horizontal:action.horizontal});
+ $('flip-options').append(button);
+ }
+ $('rotate-angle').min = ui.rotation.minimum;
+ $('rotate-angle').max = ui.rotation.maximum;
+ $('rotate-angle').value = ui.rotation.default;
+ const lengthField = $('bond-length'), lengthSpec = ui.bond_length_input;
+ lengthField.step = lengthSpec.step; lengthField.max = lengthSpec.max; lengthField.title = lengthSpec.tooltip;
+ $('bond-length-up').title = lengthSpec.up_tooltip; $('bond-length-down').title = lengthSpec.down_tooltip;
+ const atomInput = $('atom-symbol');
+ atomInput.value = ui.atom_input.value;
+ atomInput.placeholder = ui.atom_input.placeholder;
+ atomInput.title = ui.atom_input.tooltip;
+ atomInput.maxLength = $('atom-label').maxLength = ui.atom_input.max_length;
+ atomInput.style.minWidth = `${ui.atom_input.min_width}px`;
+ atomInput.style.maxWidth = `${ui.atom_input.max_width}px`;
+ // Existing desktop declarations drive order, naming and artwork. This is a DOM adapter.
+ function button(spec) {
+ const element = document.createElement('button');
+ element.title = spec.tip ?? spec.label;
+ element.setAttribute('aria-label', spec.label);
+ element.innerHTML = spec.icon;
+ return element;
+ }
+ for (const entries of ui.groups) {
+ const group = document.createElement('div');
+ group.className = 'tool-group';
+ for (const spec of entries) {
+ const element = button(spec);
+ element.dataset.tool = spec.key;
+ element.onclick = () => {
+ // A toolbar click resets Bond and Mark to their defaults, as on the desktop.
+ if (spec.key === 'bond') bondStyle = ui.tool_defaults.bond;
+ if (spec.key === 'mark') markKind = ui.tool_defaults.mark;
+ setTool(spec.key);
+ };
+ group.append(element);
+ }
+ $('tools').append(group);
+ }
+ const spacer = document.createElement('span'); spacer.className = 'spacer'; $('tools').append(spacer);
+ for (const spec of ui.panels) { const element = button(spec); element.disabled = true; $('tools').append(element); }
+ for (const entries of ui.bond_groups) {
+ const group = document.createElement('div'); group.className = 'segments';
+ for (const spec of entries) {
+ const element = button(spec);
+ element.dataset.bond = spec.key;
+ element.dataset.editable = '';
+ element.onclick = () => { bondStyle = spec.key; render(); };
+ group.append(element);
+ }
+ $('bond-options').append(group);
+ }
+ for (const mode of ['color', 'ring-fill']) {
+ for (const spec of ui.color_palette) {
+ const element = document.createElement('button');
+ const prefix = mode === 'color' ? 'Color' : 'Ring Fill';
+ element.className = 'color-swatch'; element.title = `${prefix}: ${spec.label}`;
+ element.setAttribute('aria-label', spec.label);
+ if (mode === 'color') element.dataset.color = spec.color;
+ element.dataset.editable = ''; element.style.setProperty('--swatch-color', spec.color);
+ element.onclick = () => mode === 'color' ? chooseColor(spec.color) : chooseRingFill(spec.color);
+ $(`${mode}-options`).append(element);
+ }
+ }
+ for (const spec of ui.line_options) {
+ const element = button(spec); element.dataset.line = spec.key; element.dataset.editable = '';
+ element.onclick = () => { lineStyle = spec.key; render(); };
+ $('line-options').append(element);
+ }
+ bracketKind = ui.default_bracket_kind;
+ ({bond: bondStyle, mark: markKind, orbital: orbitalKind, line: lineStyle, shape: shapeStyle, stroke: shapeStroke} = ui.tool_defaults);
+ arrowStyle = ui.default_arrow_style;
+ ringTemplate = ui.templates[0];
+ for (const spec of ui.bracket_options) {
+ const element = button(spec); element.dataset.bracketKind = spec.key; element.dataset.editable = '';
+ element.onclick = () => { bracketKind = spec.key; render(); };
+ $('bracket-options').append(element);
+ }
+ for (const spec of ui.orbital_options) {
+ const element = button(spec); element.dataset.orbitalKind = spec.key; element.dataset.editable = '';
+ if (spec.text) { element.textContent = spec.text; element.style.width = '40px'; }
+ element.onclick = () => { orbitalKind = spec.key; render(); };
+ $('orbital-options').append(element);
+ }
+ for (const spec of ui.orbital_phases) {
+ const element = button(spec); element.dataset.orbitalPhase = String(spec.key); element.dataset.editable = '';
+ element.onclick = () => void edit({kind:'orbital_phase',enabled:spec.key});
+ $('orbital-phases').append(element);
+ }
+ for (const spec of ui.mark_options) {
+ const element = button(spec); element.dataset.markKind = spec.key; element.dataset.editable = '';
+ element.onclick = () => { markKind = spec.key; markHover.result = null; render(); refreshHover(); };
+ $('mark-options').append(element);
+ }
+ for (const [specs, attribute] of [[ui.shape_options, 'shape'], [ui.shape_strokes, 'stroke']]) {
+ const group = document.createElement('div'); group.className = 'segments';
+ for (const spec of specs) {
+ const element = button(spec); element.dataset[attribute] = spec.key; element.dataset.editable = '';
+ element.onclick = () => { if (attribute === 'shape') shapeStyle = spec.key; else shapeStroke = spec.key; render(); };
+ group.append(element);
+ }
+ $('shape-options').append(group);
+ }
+ const arrows = document.createElement('div'); arrows.className = 'segments';
+ const more = document.createElement('details'); more.id = 'arrow-more'; more.className = 'arrow-popup';
+ const summary = document.createElement('summary'); summary.title = 'More arrows'; summary.setAttribute('aria-label', 'More arrows');
+ const menu = document.createElement('div'); menu.className = 'menu';
+ more.append(summary, menu);
+ for (const spec of ui.arrow_options) {
+ const element = button(spec); element.dataset.arrow = spec.key; element.dataset.editable = '';
+ element.onclick = () => {
+ arrowStyle = spec.key;
+ if (spec.more) { summary.innerHTML = spec.icon; summary.dataset.arrow = spec.key; more.open = false; }
+ render();
+ };
+ if (spec.more) {
+ const label = document.createElement('span'); label.textContent = spec.label; element.append(label); menu.append(element);
+ if (!summary.dataset.arrow) { summary.innerHTML = spec.icon; summary.dataset.arrow = spec.key; }
+ } else arrows.append(element);
+ }
+ more.addEventListener('toggle', () => {
+ if (more.open) { const rect = summary.getBoundingClientRect(); menu.style.left = `${Math.max(0, Math.min(rect.left, innerWidth - 240))}px`; menu.style.top = `${rect.bottom}px`; }
+ });
+ $('arrow-options').append(arrows, more);
+ for (const [index, spec] of ui.arrow_style_controls.entries()) {
+ if (index === 0 || (spec.setting && ui.arrow_style_controls[index - 1].preset)) {
+ const divider = document.createElement('span'); divider.className = 'context-divider'; $('arrow-options').append(divider);
+ }
+ if (spec.preset) {
+ const element = button(spec); element.dataset.editable = '';
+ element.onclick = () => void edit({kind: 'arrow_style', preset: spec.preset});
+ $('arrow-options').append(element);
+ } else {
+ const popup = document.createElement('details'); popup.className = 'arrow-popup arrow-slider';
+ const trigger = document.createElement('summary'); trigger.title = spec.label; trigger.setAttribute('aria-label', spec.label); trigger.innerHTML = spec.icon;
+ const panel = document.createElement('div'); panel.className = 'menu';
+ const slider = document.createElement('input'); slider.type = 'range'; slider.min = spec.minimum; slider.max = spec.maximum; slider.step = 1;
+ slider.dataset.setting = spec.setting; slider.dataset.factor = spec.factor; slider.dataset.minimum = spec.minimum; slider.dataset.maximum = spec.maximum; slider.dataset.editable = ''; slider.setAttribute('aria-label', spec.label);
+ slider.onchange = async () => {
+ const focused = document.activeElement === slider;
+ await edit({kind: 'arrow_style', setting: spec.setting, value: Number(slider.value)});
+ if (focused && popup.open && document.activeElement === document.body) slider.focus();
+ };
+ const stepSlider = delta => {
+ slider.value = Math.max(Number(slider.min), Math.min(Number(slider.max), Number(slider.value) + delta));
+ void slider.onchange();
+ };
+ slider.onkeydown = event => {
+ if (event.key === 'PageUp' || event.key === 'PageDown') { event.preventDefault(); stepSlider(event.key === 'PageUp' ? spec.page_step : -spec.page_step); }
+ };
+ slider.onpointerdown = event => {
+ if (event.button !== 0) return;
+ const rect = slider.getBoundingClientRect();
+ const thumb = rect.left + 6 + (rect.width - 12) * (Number(slider.value) - Number(slider.min)) / (Number(slider.max) - Number(slider.min));
+ if (Math.abs(event.clientX - thumb) > 6) {
+ event.preventDefault(); slider.focus(); stepSlider(event.clientX > thumb ? spec.page_step : -spec.page_step);
+ }
+ };
+ panel.append(slider); popup.append(trigger, panel); $('arrow-options').append(popup);
+ popup.addEventListener('toggle', () => {
+ if (popup.open) { const rect = trigger.getBoundingClientRect(); panel.style.left = `${Math.max(0, Math.min(rect.left, innerWidth - 138))}px`; panel.style.top = `${rect.bottom}px`; }
+ });
+ }
+ }
+ // The desktop Ring page: one button per template; the session starts on benzene.
+ for (const template of ui.templates) {
+ const element = button({key: template.label, label: template.label, icon: template.icon});
+ element.dataset.template = `${template.size}:${template.style}`; element.dataset.editable = '';
+ element.onclick = () => { ringTemplate = template; render(); };
+ $('ring-options').append(element);
+ }
+ document.querySelectorAll('.menus details, .arrow-popup, #grid-options').forEach(menu => {
+ menu.addEventListener('toggle', () => {
+ if (!menu.open) return;
+ document.querySelectorAll('.menus details, .arrow-popup, #grid-options').forEach(other => { if (other !== menu && !other.contains(menu) && !menu.contains(other)) other.open = false; });
+ if (menu.classList.contains('submenu')) {
+ const rect = menu.querySelector('summary').getBoundingClientRect(), popup = menu.querySelector('.menu');
+ popup.style.left = `${Math.min(rect.right,innerWidth-popup.offsetWidth)}px`;
+ popup.style.top = `${Math.min(rect.top,innerHeight-popup.offsetHeight)}px`;
+ }
+ });
+ menu.querySelectorAll('button').forEach(item => item.addEventListener('click', () => { menu.open = false; }));
+ });
+ document.addEventListener('pointerdown', event => { if (!event.target.closest('.menus, .arrow-popup, .grid-control')) document.querySelectorAll('.menus details, .arrow-popup, #grid-options').forEach(menu => { menu.open = false; }); });
+}
+
+// The closed window's draft stays on the server for explicit recovery; a
+// reload of this tab finishes this close if the request did not arrive.
+window.addEventListener('pagehide', () => { if (editor.info?.session) sessionStorage.setItem(closedSessionKey, editor.info.session); });
+window.addEventListener('pagehide', () => { if (editor.info?.session) fetch('/api/session', {method: 'POST', headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'}, body: JSON.stringify({session: editor.info.session, action: 'close'}), keepalive: true}).catch(() => {}); });
+
+
+function bondRequest(active, end) {
+ return {kind: 'bond', start: [active.start.x, active.start.y], end: [end.x, end.y], style: bondStyle};
+}
+
+// The snap reach is on screen, so each request takes the zoom shown when it is made.
+function arrowRequest(active, end) {
+ return {kind: active.kind, grid: gridMode(), start: [active.start.x, active.start.y], end: [end.x, end.y], style: active.style, dragged: active.dragged, shift: active.shift, scale: viewScale(), ...(active.kind === 'line' ? {hits: active.hits} : {})};
+}
+
+function bracketRequest(active, end) {
+ return {kind: 'ts_bracket', start: [active.start.x, active.start.y], end: [end.x, end.y], style: active.style};
+}
+
+function shapeRequest(active, end) {
+ return {kind: 'shape', start: [active.start.x, active.start.y], end: [end.x, end.y], style: active.style, stroke: active.stroke};
+}
+
+async function refreshGesturePreview() {
+ if (previewPending || !['bond', 'move', 'arrow', 'line', 'shape', 'ts_bracket', 'handle', 'rotate'].includes(gesture?.kind) || gesture.released || !preview) return;
+ // A preview belongs to its gesture and to the accepted session and revision
+ // that gesture began on: it is asked for that base, and its reply counts only
+ // while the same gesture and base are current and the reply carries them. A
+ // gesture whose base has gone is never asked for against a newer one.
+ const serial = previewSerial, projected = preview, active = gesture, {session, revision} = active;
+ if (editor.info.session !== session || editor.info.revision !== revision) return;
+ const change = gesture.kind === 'rotate' ? rotationRequest(gesture,projected.end) : gesture.kind === 'handle' ? handleRequest(gesture, projected.end) : gesture.kind === 'move' ? moveRequest(gesture, projected.end) : ['arrow', 'line'].includes(gesture.kind) ? arrowRequest(gesture, projected.end) : gesture.kind === 'shape' ? shapeRequest(gesture, projected.end) : gesture.kind === 'ts_bracket' ? bracketRequest(gesture, projected.end) : bondRequest(gesture, projected.end);
+ try {
+ previewPending = sessionRequest({session, revision, action: 'preview', edit: change, selection: selectedItems()});
+ const info = await previewPending;
+ if (gesture !== active || editor.info.session !== session || editor.info.revision !== revision || info.session !== session || info.revision !== revision) return;
+ // A Line or Arrow snap reach is on screen: a reply asked at another zoom is stale.
+ if (['arrow', 'line'].includes(active.kind) && change.scale !== arrowRequest(active, projected.end).scale) return;
+ // A reply after a later move still carries the handle point its release waits for.
+ if (active.kind === 'handle' && active.target === 'arrow') {
+ active.previous = info.drawing.arrows[active.id].handles.find(item => item.handle === active.handle).point;
+ }
+ if (serial === previewSerial) { previewInfo = info; render(); }
+ } catch { /* A release reports errors through the committed edit path. */ }
+ finally {
+ previewPending = null;
+ // A moved or new gesture asks again, entirely from its own state.
+ if ((serial !== previewSerial || gesture !== active) && gesture) void refreshGesturePreview();
+ }
+}
+
+
+function rotationRequest(active,end) {
+ return {kind:'rotate', selection:active.selection, start:[active.start.x,active.start.y], end:[end.x,end.y], shift:active.shift};
+}
+
+// A moved arrow end joins another within an on-screen reach, at the zoom shown now.
+function moveRequest(active, end) {
+ return {kind: 'move', selection: active.selection, dx: end.x - active.start.x, dy: end.y - active.start.y, scale: viewScale()};
+}
+
+function updateMarquee(active, end) {
+ active.end = end;
+ active.dragged ||= (Math.abs(end.x - active.start.x) + Math.abs(end.y - active.start.y)) * active.scale >= ui.drag_distance;
+ if (!active.dragged) { render(); return; }
+ const selected = marqueeSelection(canvas, active.start, end, active.initialSelection, active.additive);
+ if (selected === null) {
+ cancelGesture();
+ notice('Area selection is not supported in this browser. Use Shift-click or Select All, or open this document in the desktop app.', true);
+ return;
+ }
+ selection = expandToGroups(selected, groupUnits());
+ preview = {kind: 'marquee', start: active.start, end};
+ render();
+}
+
+function selectionGestureMoved(active, end) {
+ active.dragged ||= active.hasArrows
+ ? (Math.abs(end.x - active.start.x) + Math.abs(end.y - active.start.y)) * active.scale >= ui.drag_distance
+ : end.x !== active.start.x || end.y !== active.start.y;
+ return active.dragged;
+}
+
+function finishSelection(active, end) {
+ const moved = selectionGestureMoved(active, end);
+ if (moved) handleTarget = null;
+ else if (active.toggleHandle !== null) handleTarget = handleTarget === active.toggleHandle ? null : active.toggleHandle;
+ cancelGesture();
+ // A move held across another edit or a document change belongs to a document
+ // that is gone, as a rotation's does.
+ if (moved && editor.info.session === active.session && editor.info.revision === active.revision) void edit(moveRequest(active, end));
+}
+
+function handleRequest(active, end) {
+ if (['shape', 'orbital'].includes(active.target)) return {kind: `${active.target}_handle`, id: active.id, handle: active.handle, position: [end.x, end.y]};
+ return {kind: 'arrow_handle', grid: gridMode(), id: active.id, handle: active.handle,
+ position: [end.x, end.y], previous: active.previous, scale: active.scale};
+}
+
+async function finishHandle(active) {
+ active.released = true;
+ // An in-flight preview owns the last valid endpoint; a too-short final
+ // frame must retain that endpoint, as the native handle mutation does.
+ try { await previewPending; } catch { /* The final edit reports failures. */ }
+ if (gesture !== active) return;
+ cancelGesture();
+ if (active.moved && editor.info.session === active.session && editor.info.revision === active.revision) {
+ void edit(handleRequest(active, active.end));
+ }
+}
+
+
+async function refreshSelectionOutline() {
+ const request = outlineRequest, key = JSON.stringify(request);
+ if (outlinePending || !request || outlineResult.key === key) return;
+ outlinePending = true;
+ try {
+ const result = await api('session', request);
+ if (JSON.stringify(outlineRequest) === key) {
+ outlineResult = {key, components:result.components, frame:result.frame, groups:result.groups ?? []};
+ render();
+ }
+ } catch (error) {
+ if (JSON.stringify(outlineRequest) === key) {
+ outlineResult = {key, components:[], frame:null, groups:[]};
+ if (!editor.busy && !loading) notice(error.message, true);
+ }
+ } finally {
+ outlinePending = false;
+ if (outlineRequest && JSON.stringify(outlineRequest) !== outlineResult.key && !editor.busy && !loading) void refreshSelectionOutline();
+ }
+}
diff --git a/app/chemvas/web/clipboard.mjs b/app/chemvas/web/clipboard.mjs
new file mode 100644
index 00000000..bf5d8b5a
--- /dev/null
+++ b/app/chemvas/web/clipboard.mjs
@@ -0,0 +1,74 @@
+// Chemistry clipboard of the browser adapter. A copy is the desktop's
+// chemvas-selection payload, built and validated by the server. It travels as
+// system clipboard text where the browser allows that, and this window keeps
+// it too, so a refused or unavailable system clipboard still leaves a usable
+// copy here. Pastes go back to the server, which validates them again.
+
+export const SELECTION_FORMAT = 'chemvas-selection';
+
+// Whether clipboard text claims to be a Chemvas selection. Only recognition:
+// the server still validates every paste in full.
+export function isSelectionText(text) {
+ if (typeof text !== 'string' || !text.trimStart().startsWith('{')) return false;
+ try {
+ const value = JSON.parse(text);
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && value.format === SELECTION_FORMAT;
+ } catch { return false; }
+}
+
+// The text a paste uses. A selection on the system clipboard wins. This
+// window's own copy is used when the system clipboard could not be read
+// (systemText null) or never received that copy; otherwise the system
+// clipboard holds something newer that is not chemistry, and nothing pastes.
+export function pasteText(systemText, local) {
+ if (isSelectionText(systemText)) return systemText;
+ if (local && (systemText === null || !local.system)) return local.text;
+ return null;
+}
+
+// `payload` is the server's copy reply, requested inside the user gesture.
+// The system write starts before any await, as browsers require, with the
+// reply as a pending clipboard item; without ClipboardItem it falls back to
+// writeText once the reply arrives. A refused reply rejects: nothing copied.
+export async function writeSelection(payload, {clipboard, ClipboardItem}) {
+ let system = null;
+ if (clipboard?.write && ClipboardItem) {
+ const blob = payload.then(text => new Blob([text], {type: 'text/plain'}));
+ // A refused reply is reported below; the pending item must not also be.
+ blob.catch(() => {});
+ try {
+ system = clipboard.write([new ClipboardItem({'text/plain': blob})]).then(() => true, () => false);
+ } catch { system = null; }
+ }
+ const text = await payload;
+ if (system === null) system = clipboard?.writeText ? clipboard.writeText(text).then(() => true, () => false) : Promise.resolve(false);
+ return {text, system: await system};
+}
+
+export class ChemistryClipboard {
+ #local = null;
+ #env;
+ constructor(env) { this.#env = env; }
+ get local() { return this.#local; }
+ // A failed copy keeps the previous one, as a refused desktop copy does.
+ async copy(payload) {
+ const copied = await writeSelection(payload, this.#env);
+ this.#local = copied;
+ return copied;
+ }
+ // The system clipboard text, or null when the browser does not allow reading it.
+ async read() {
+ try { return await this.#env.clipboard.readText(); } catch { return null; }
+ }
+ textFor(systemText) { return pasteText(systemText, this.#local); }
+}
+
+// Edit > Copy and Cut. Cut removes the selection only after a usable copy
+// exists, and only while the drawing it was copied from is still current; a
+// newer or replaced drawing keeps everything.
+export async function copySelection({store, payload, cut = false, isCurrent, remove}) {
+ const copied = await store.copy(payload);
+ if (!cut) return {copied, removed: false, stale: false};
+ if (!isCurrent()) return {copied, removed: false, stale: true};
+ return {copied, removed: Boolean(await remove()), stale: false};
+}
diff --git a/app/chemvas/web/index.html b/app/chemvas/web/index.html
new file mode 100644
index 00000000..b09dd276
--- /dev/null
+++ b/app/chemvas/web/index.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ Canvas 1 — Chemvas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/chemvas/web/scene.mjs b/app/chemvas/web/scene.mjs
new file mode 100644
index 00000000..8761e329
--- /dev/null
+++ b/app/chemvas/web/scene.mjs
@@ -0,0 +1,823 @@
+// Display primitives supplied by the existing Chemvas rendering services.
+function escapeText(value) {
+ return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
+}
+
+const number = value => Number(value).toFixed(4);
+const line = (x1, y1, x2, y2, extra = '') => ``;
+
+export function measureDocumentLineHeight(probe, font, text) {
+ probe.style.font = font;
+ const pixels = parseFloat(probe.style.fontSize);
+ // A small CSS line box is already pixel-rounded. Measure the font's normal
+ // line (including its gap) at a large em before applying Qt's final ceiling.
+ probe.style.fontSize = '2048px';
+ probe.textContent = text;
+ return Math.ceil(probe.getBoundingClientRect().height * pixels / 2048);
+}
+
+const fixed = value => Math.round(value * 64) / 64;
+
+export function measureNoteFont(probe, context, {italic, weight, pixels, family}) {
+ // Measure at a large em, as QFontEngine reports unrounded 1/64 metrics.
+ const font = `${italic ? 'italic ' : ''}${weight} 2048px ${family}`;
+ context.font = font;
+ const measured = context.measureText('H');
+ probe.style.font = font;
+ probe.textContent = 'H';
+ const scale = pixels / 2048;
+ const ascent = fixed(measured.fontBoundingBoxAscent * scale);
+ const descent = fixed(measured.fontBoundingBoxDescent * scale);
+ return {ascent, descent, leading: Math.max(0, fixed(probe.getBoundingClientRect().height * scale) - ascent - descent)};
+}
+
+// Qt sizes each note line from the fonts of its own runs and adds proportional
+// spacing below the text, where CSS would divide it around the text. A zero-width
+// strut gives every line Qt's height and baseline; the runs themselves take no
+// line height, and scripts shift relatively like QTextLine's baseline offsets.
+const noteFontOf = element => {
+ const style = getComputedStyle(element);
+ return {italic: style.fontStyle !== 'normal', weight: style.fontWeight, pixels: parseFloat(style.fontSize), family: style.fontFamily};
+};
+
+// The document font, the server's validated declarations and script offsets.
+export function styleNoteText(root, spec, metrics) {
+ Object.assign(root.style, {fontFamily: JSON.stringify(spec.family), fontSize: `${spec.pixels}px`, fontWeight: String(spec.weight), fontStyle: spec.italic ? 'italic' : 'normal', color: spec.color, textAlign: spec.align});
+ // The CSP ignores style attributes; apply the declarations through CSSOM.
+ for (const element of root.querySelectorAll('[data-style]')) {
+ for (const declaration of element.dataset.style.split(';')) {
+ const split = declaration.indexOf(':');
+ if (split > 0) element.style.setProperty(declaration.slice(0, split).trim(), declaration.slice(split + 1).trim());
+ }
+ }
+ for (const run of root.querySelectorAll('[data-script]')) {
+ const base = metrics({...noteFontOf(run.parentElement), pixels: Number(run.dataset.basePixels)});
+ const height = base.ascent + base.descent;
+ run.style.top = `${run.dataset.script === 'sub' ? height / 6 : -height / 2}px`;
+ }
+}
+
+export function layoutNoteText(root, spec, metrics) {
+ const fontOf = noteFontOf;
+ root.querySelectorAll('.note-strut').forEach(strut => strut.remove());
+ styleNoteText(root, spec, metrics);
+ for (const block of root.children) {
+ const lines = [{after: null, fonts: []}];
+ const walk = node => {
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ if (child.data) lines.at(-1).fonts.push(fontOf(child.parentElement));
+ } else if (child.nodeName === 'BR') lines.push({after: child, fonts: []});
+ else walk(child);
+ }
+ };
+ walk(block);
+ for (const line of lines) {
+ const measured = (line.fonts.length ? line.fonts : [fontOf(block)]).map(metrics);
+ const ascent = Math.max(...measured.map(font => font.ascent));
+ const height = Math.ceil(ascent + Math.max(...measured.map(font => font.descent)) + Math.max(...measured.map(font => font.leading)));
+ const pitch = fixed(height * spec.line_spacing);
+ const strut = document.createElement('span');
+ strut.className = 'note-strut';
+ Object.assign(strut.style, {height: `${pitch}px`, verticalAlign: `${ascent - pitch}px`});
+ if (line.after) line.after.after(strut);
+ else block.prepend(strut);
+ }
+ }
+}
+
+// The note editor's DOM as QTextDocument blocks of formatted runs: one block
+// per paragraph, runs carrying point size, weight, slant, decoration, colour and
+// script alignment. is a run of one character, as Qt's line separator.
+export function noteBlocks(root, spec) {
+ const blocks = [];
+ let block = null;
+ const open = element => {
+ block = {style: element?.dataset?.style ?? 'margin-top:0px; margin-bottom:0px', align: element?.getAttribute('align') ?? null, runs: []};
+ blocks.push(block);
+ };
+ const walk = (node, format) => {
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ if (!block) open(null);
+ if (child.data) block.runs.push({text: child.data, format});
+ continue;
+ }
+ if (child.nodeType !== Node.ELEMENT_NODE || child.classList.contains('note-strut')) continue;
+ const tag = child.nodeName.toLowerCase();
+ if (tag === 'br') {
+ if (!block) open(null);
+ block.runs.push({br: true});
+ continue;
+ }
+ if (tag === 'p' || tag === 'div') {
+ open(child);
+ walk(child, {...format});
+ block = null;
+ continue;
+ }
+ const next = {...format}, style = child.style;
+ if (child.dataset.pt) next.pt = Number(child.dataset.pt);
+ // QTextCharFormat is bold above QFont::Normal (400).
+ if (tag === 'b' || tag === 'strong' || Number(style.fontWeight) > 400 || style.fontWeight === 'bold') next.bold = true;
+ if (style.fontWeight === 'normal' || Number(style.fontWeight) && Number(style.fontWeight) <= 400) next.bold = false;
+ if (tag === 'i' || tag === 'em' || style.fontStyle === 'italic') next.italic = true;
+ if (style.fontStyle === 'normal') next.italic = false;
+ if (tag === 'u' || style.textDecorationLine?.includes('underline')) next.underline = true;
+ if (tag === 's' || tag === 'strike' || style.textDecorationLine?.includes('line-through')) next.strike = true;
+ if (child.dataset.script) next.script = child.dataset.script;
+ if (tag === 'sub') next.script = 'sub';
+ if (tag === 'sup') next.script = 'super';
+ if (style.color) next.color = style.color;
+ if (tag === 'font' && child.getAttribute('color')) next.color = child.getAttribute('color');
+ walk(child, next);
+ }
+ };
+ walk(root, {pt: spec.point_size, bold: Number(spec.weight) > 400, italic: spec.italic, script: null});
+ return blocks;
+}
+
+export function noteBlocksHtml(blocks, spec) {
+ const escape = text => text.replace(/[&<>]/g, char => ({'&': '&', '<': '<', '>': '>'}[char]));
+ const baseBold = Number(spec.weight) > 400;
+ const declarations = format => [
+ format.pt !== spec.point_size && `font-size:${format.pt}pt`,
+ format.bold !== baseBold && `font-weight:${format.bold ? 700 : 400}`,
+ format.italic !== spec.italic && `font-style:${format.italic ? 'italic' : 'normal'}`,
+ (format.underline || format.strike) && `text-decoration:${[format.underline && 'underline', format.strike && 'line-through'].filter(Boolean).join(' ')}`,
+ format.script && `vertical-align:${format.script}`,
+ format.color && `color:${format.color}`,
+ ].filter(Boolean).join('; ');
+ return blocks.map(({style, align, runs}) => {
+ // A browser keeps a trailing as a placeholder; Qt would add a line.
+ const kept = runs.at(-1)?.br ? runs.slice(0, -1) : runs;
+ const parts = [];
+ for (const run of kept) {
+ if (run.br) { parts.push({html: ' '}); continue; }
+ const css = declarations(run.format), last = parts.at(-1);
+ if (last && last.css === css) last.text += run.text;
+ else parts.push({css, text: run.text});
+ }
+ const body = parts.map(part => part.html ?? (part.css ? `${escape(part.text)}` : escape(part.text))).join('');
+ return `
${body}
`;
+ }).join('');
+}
+
+export function serializeNoteEditor(root, spec) {
+ return noteBlocksHtml(noteBlocks(root, spec), spec);
+}
+
+const blockLength = block => block.runs.reduce((length, run) => length + (run.br ? 1 : run.text.length), 0);
+
+// A DOM position as a document offset: blocks are separated by one position.
+export function noteTextOffset(root, spec, node, offset) {
+ const range = document.createRange();
+ range.setStart(root, 0);
+ range.setEnd(node, offset);
+ return noteBlocks(range.cloneContents(), spec).reduce((total, block, index) => total + blockLength(block) + (index ? 1 : 0), 0);
+}
+
+export function noteTextPosition(root, offset) {
+ let remaining = offset;
+ for (const [index, block] of [...root.children].entries()) {
+ if (index) {
+ if (remaining === 0) return [block, 0];
+ remaining -= 1;
+ }
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
+ let node = walker.nextNode();
+ if (remaining === 0 && !node) return [block, 0];
+ for (; node; node = walker.nextNode()) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (remaining <= node.data.length) return [node, remaining];
+ remaining -= node.data.length;
+ } else if (node.nodeName === 'BR') {
+ if (remaining === 0) return [node.parentNode, [...node.parentNode.childNodes].indexOf(node)];
+ remaining -= 1;
+ }
+ }
+ if (remaining === 0) return [block, block.childNodes.length];
+ }
+ return [root, root.childNodes.length];
+}
+
+// QTextCursor formatting over [start, end): toggles follow the format before the
+// cursor end, sizes step each run, and alignment applies to touched blocks.
+export function formatNoteBlocks(blocks, start, end, action, [low, high]) {
+ const spans = [];
+ let offset = 0;
+ blocks.forEach((block, index) => {
+ if (index) offset += 1;
+ const from = offset;
+ offset += blockLength(block);
+ if (from <= end && offset >= start) spans.push(block);
+ });
+ if (action.align) {
+ for (const block of spans) {
+ block.align = action.align;
+ block.style = block.style.split(';').filter(item => !/^\s*text-align\s*:/.test(item)).join(';').trim();
+ }
+ return;
+ }
+ const current = noteFormatAt(blocks, end) ?? noteFormatAt(blocks, start + 1);
+ const mutate = format => {
+ const next = {...format};
+ if (action.delta) next.pt = Math.max(low, Math.min(high, format.pt + action.delta));
+ else if (action.key === 'bold') next.bold = !current?.bold;
+ else if (action.key === 'italic') next.italic = !current?.italic;
+ else {
+ const script = action.key === 'superscript' ? 'super' : 'sub';
+ next.script = current?.script === script ? null : script;
+ }
+ return next;
+ };
+ offset = 0;
+ blocks.forEach((block, index) => {
+ if (index) offset += 1;
+ const runs = [];
+ for (const run of block.runs) {
+ const length = run.br ? 1 : run.text.length, from = offset, to = offset + length;
+ offset = to;
+ if (run.br || to <= start || from >= end) { runs.push(run); continue; }
+ const a = Math.max(start, from) - from, b = Math.min(end, to) - from;
+ if (a > 0) runs.push({text: run.text.slice(0, a), format: run.format});
+ runs.push({text: run.text.slice(a, b), format: mutate(run.format)});
+ if (b < length) runs.push({text: run.text.slice(b), format: run.format});
+ }
+ block.runs = runs;
+ });
+}
+
+// The format of the character before a document offset.
+function noteFormatAt(blocks, position) {
+ let offset = 0;
+ for (const [index, block] of blocks.entries()) {
+ if (index) offset += 1;
+ for (const run of block.runs) {
+ const length = run.br ? 1 : run.text.length;
+ if (!run.br && position > offset && position <= offset + length) return run.format;
+ offset += length;
+ }
+ }
+ return null;
+}
+
+// Buttons are checked only when the whole target shares a format.
+export function noteFormatState(blocks, start, end, defaultAlign) {
+ const formats = [], aligns = [];
+ let offset = 0;
+ blocks.forEach((block, index) => {
+ if (index) offset += 1;
+ const from = offset;
+ for (const run of block.runs) {
+ const length = run.br ? 1 : run.text.length;
+ if (!run.br && (start === end ? start > offset && start <= offset + length : offset < end && offset + length > start)) formats.push(run.format);
+ offset += length;
+ }
+ if (from <= end && offset >= start) aligns.push(block.align ?? defaultAlign);
+ });
+ const all = test => formats.length > 0 && formats.every(test);
+ return {
+ bold: all(format => format.bold), italic: all(format => format.italic),
+ superscript: all(format => format.script === 'super'), subscript: all(format => format.script === 'sub'),
+ ...Object.fromEntries(['left', 'center', 'right'].map(name => [name, aligns.length > 0 && aligns.every(align => align === name)])),
+ };
+}
+
+export function measureAtomLabels(spec, context, measureLineHeight) {
+ return Object.fromEntries(spec.queries.map(({key, text, pixels}) => {
+ context.font = `${pixels}px ${JSON.stringify(spec.family)}`;
+ const measured = context.measureText(text);
+ const capital = context.measureText('H');
+ return [key, {width: measured.width, bounding_width: measured.actualBoundingBoxLeft + measured.actualBoundingBoxRight, ascent: measured.fontBoundingBoxAscent, descent: measured.fontBoundingBoxDescent, cap_height: capital.actualBoundingBoxAscent, line_height: measureLineHeight(context.font, text)}];
+ }));
+}
+
+// Only the font engine boundary is browser-specific; hulls and bond clipping stay in Python.
+export function measureGlyphInk(context, family, {text, pixels}) {
+ const font = `${pixels}px ${JSON.stringify(family)}`;
+ context.font = font;
+ const box = context.measureText(text);
+ const width = box.actualBoundingBoxLeft + box.actualBoundingBoxRight;
+ const height = box.actualBoundingBoxAscent + box.actualBoundingBoxDescent;
+ if (!width || !height) return [];
+ // Bound raster work even for very long labels or imported large font sizes.
+ const scale = Math.min(8, 2048 / (width + 4), 512 / (height + 4));
+ const ox = (2 + box.actualBoundingBoxLeft) * scale, oy = (2 + box.actualBoundingBoxAscent) * scale;
+ context.canvas.width = Math.ceil((width + 4) * scale);
+ context.canvas.height = Math.ceil((height + 4) * scale);
+ context.font = font;
+ context.setTransform(scale, 0, 0, scale, ox, oy);
+ context.fillText(text, 0, 0);
+ const w = context.canvas.width, h = context.canvas.height;
+ const {data} = context.getImageData(0, 0, w, h);
+ const points = [];
+ for (let y = 0; y < h; y++) {
+ let left = 0, right = w - 1;
+ // Half coverage excludes the faint antialias fringe outside the glyph outline.
+ while (left < w && data[(y * w + left) * 4 + 3] < 128) left++;
+ if (left === w) continue;
+ while (right > left && data[(y * w + right) * 4 + 3] < 128) right--;
+ for (const [x, row] of [[left, y], [right + 1, y], [left, y + 1], [right + 1, y + 1]]) {
+ // Raster hinting can cross the font engine's reported ink bounds.
+ points.push([
+ Math.max(-box.actualBoundingBoxLeft, Math.min(box.actualBoundingBoxRight, (x - ox) / scale)),
+ Math.max(-box.actualBoundingBoxAscent, Math.min(box.actualBoundingBoxDescent, (row - oy) / scale)),
+ ]);
+ }
+ }
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ return points;
+}
+
+export class AtomLabelCache {
+ #metrics = new Map();
+ #ink = new Map();
+
+ measure(spec, context, measureLineHeight) {
+ const keyFor = query => JSON.stringify([spec.family, query.key, query.pixels]);
+ const missing = spec.queries.filter(query => !this.#metrics.has(keyFor(query)));
+ const measured = measureAtomLabels({...spec, queries: missing}, context, measureLineHeight);
+ const metrics = new Map(), ink = new Map(), result = {family: spec.family, metrics: {}, ink: {}};
+ for (const query of spec.queries) {
+ const key = keyFor(query), glyph = `${query.pixels}:${query.text}`;
+ const glyphKey = JSON.stringify([spec.family, glyph]);
+ const value = this.#metrics.get(key) ?? measured[query.key];
+ const points = ink.get(glyphKey) ?? this.#ink.get(glyphKey) ?? measureGlyphInk(context, spec.family, query);
+ metrics.set(key, value);
+ ink.set(glyphKey, points);
+ result.metrics[query.key] = value;
+ result.ink[glyph] = points;
+ }
+ // Replace, rather than accumulate, fonts from discarded drawings.
+ this.#metrics = metrics;
+ this.#ink = ink;
+ return result;
+ }
+}
+
+function arrowSelectionMarkup(geometry, index, style, scale) {
+ // Qt strokes each native subpath separately. Keep the head/stem overlap:
+ // one mask for the whole arrow would erase those intersecting boundaries.
+ const subpaths = [];
+ for (const command of geometry.path) {
+ if (command[0] === 'M') subpaths.push([]);
+ subpaths.at(-1).push(command);
+ }
+ const edge = style.screen_width / scale;
+ return subpaths.map((commands, part) => {
+ if (commands.length < 2) return '';
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const [, values] of commands) for (let i = 0; i < values.length; i += 2) {
+ minX = Math.min(minX, values[i]); maxX = Math.max(maxX, values[i]);
+ minY = Math.min(minY, values[i + 1]); maxY = Math.max(maxY, values[i + 1]);
+ }
+ const pad = (geometry.selection_width + edge) / 2;
+ const bounds = `x="${number(minX - pad)}" y="${number(minY - pad)}" width="${number(maxX - minX + pad * 2)}" height="${number(maxY - minY + pad * 2)}"`;
+ const path = commands.map(([command, values]) => `${command}${values.map(number).join(' ')}`).join(' ');
+ const id = `arrow-selection-${index}-${part}`;
+ const inner = geometry.selection_width - edge;
+ return `${inner > 0 ? `` : ''}`;
+ }).join('');
+}
+
+function shapeMarkup(shape, attributes) {
+ if (!shape.width || !shape.height) return ``;
+ if (shape.kind === 'ellipse') return ``;
+ return ``;
+}
+
+function selectionComponentsMarkup(components, style, scale, prefix) {
+ const edge = style.screen_width / (2 * scale);
+ return components.map((parts, index) => {
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
+ const bounds = (x, y, pad = 0) => {
+ left = Math.min(left,x-pad); top = Math.min(top,y-pad);
+ right = Math.max(right,x+pad); bottom = Math.max(bottom,y+pad);
+ };
+ const shapes = [];
+ for (const part of parts) {
+ if (part.empty) continue;
+ const shape = part.shape ?? part;
+ if (shape.outline) {
+ const outline = shape.outline;
+ bounds(outline.x,outline.y,shape.width/2);
+ bounds(outline.x+outline.width,outline.y+outline.height,shape.width/2);
+ const attributes = `stroke-width="${number(shape.width)}" stroke-linecap="round" stroke-linejoin="round"`;
+ shapes.push(shapeMarkup(outline,`fill="black" stroke="black" ${attributes}`));
+ } else if (shape.line) {
+ const [x1,y1,x2,y2] = shape.line;
+ bounds(x1,y1,shape.width/2); bounds(x2,y2,shape.width/2);
+ shapes.push(line(x1,y1,x2,y2,`stroke="black" stroke-width="${number(shape.width)}" stroke-linecap="round"`));
+ } else if (shape.rect) {
+ const [x,y,w,h] = shape.rect;
+ bounds(x,y); bounds(x+w,y+h);
+ shapes.push(``);
+ } else if (shape.polygon) {
+ for (const [x,y] of shape.polygon) bounds(x,y,(shape.width ?? 0)/2);
+ shapes.push(``);
+ } else if (shape.path) {
+ for (const [command, coordinates] of shape.path) for (let i = 0; i < coordinates.length; i += 2) bounds(coordinates[i],coordinates[i+1],shape.width/2);
+ const d = shape.path.map(([command, coordinates]) => `${command}${coordinates.map(number).join(' ')}`).join(' ');
+ shapes.push(``);
+ } else if (shape.text) {
+ const [x,y,w,h] = shape.bounds ?? [shape.text.x, shape.text.y - shape.text.pixels, shape.text.pixels, shape.text.pixels];
+ bounds(x,y,shape.width); bounds(x+w,y+h,shape.width);
+ shapes.push(`${escapeText(shape.text.text)}`);
+ } else if (shape.dots) {
+ for (const [x,y] of shape.dots) {
+ bounds(x,y,shape.radius);
+ shapes.push(``);
+ }
+ }
+ }
+ if (!shapes.length) return '';
+ const id = `${prefix}-${index}`;
+ return `${shapes.join('')}`;
+ }).join('');
+}
+
+export function selectionFrameMarkup(frame, drawing, handles, scale) {
+ if (!frame) return {outline:'', handle:''};
+ const rects = [...frame.rects];
+ const nonempty = rects.filter(rect => rect[2] || rect[3]);
+ const boxes = nonempty.length ? nonempty : rects.slice(-1);
+ if (!boxes.length) return {outline:'', handle:''};
+ const left = Math.min(...boxes.map(r=>r[0]))-frame.padding, top = Math.min(...boxes.map(r=>r[1]))-frame.padding;
+ const right = Math.max(...boxes.map(r=>r[0]+r[2]))+frame.padding, bottom = Math.max(...boxes.map(r=>r[1]+r[3]))+frame.padding;
+ const x = (left+right)/2, stem = handles.rotation_stem/scale, radius = handles.size/(2*scale);
+ const style = `fill="none" stroke="${escapeText(drawing.selection_style.color)}" stroke-width="${drawing.selection_style.screen_width}" vector-effect="non-scaling-stroke"`;
+ return {
+ outline:``,
+ handle:``,
+ };
+}
+
+// The selection keys a group selects together; an ungrouped key is its own unit.
+export function groupUnit(key, units = []) {
+ return units.find(unit => unit.includes(key)) ?? [key];
+}
+
+// Complete every group the selection touches, as the desktop expands a selection.
+export function expandToGroups(selection, units = []) {
+ const expanded = new Set(selection);
+ for (const unit of units) if (unit.some(key => expanded.has(key))) unit.forEach(key => expanded.add(key));
+ return expanded;
+}
+
+// Dashed group boxes: the members move as a unit, unlike the solid selection frame.
+export function groupBoxesMarkup(boxes = [], drawing) {
+ const style = drawing.selection_style, width = style.group_screen_width;
+ const dash = (style.group_dash ?? []).map(length => number(length * width)).join(' ');
+ return boxes.map(box => ``).join('');
+}
+
+// The circle and bars of a circled charge, drawn once and reused as its hit stroke.
+function circledMarkPaths(mark) {
+ return `${line(-mark.extent,0,mark.extent,0)}${mark.kind === 'circled_plus' ? line(0,-mark.extent,0,mark.extent) : ''}`;
+}
+
+function markGlyphMarkup(mark, family) {
+ if (mark.kind === 'radical') return ``;
+ if (mark.kind.startsWith('circled_')) return `${circledMarkPaths(mark)}`;
+ return mark.runs.map(run => `${escapeText(run.text)}`).join('');
+}
+
+// Python's round(), which the desktop painter's step count uses: halves to even.
+const roundHalfEven = value => {
+ const floor = Math.floor(value), rest = value - floor;
+ return rest > 0.5 || (rest === 0.5 && floor % 2) ? floor + 1 : floor;
+};
+
+// The scene rect of an atom's visible item, as the desktop's valence feedback
+// reads it: a label's selection rect (AtomLabelItem.boundingRect), or a hidden
+// carbon's transparent dot, whose hit padding reaches the pick radius. A label
+// not measured yet has none, so nothing is drawn rather than guessed.
+export function valenceWarningBounds(document, drawing, id) {
+ const atom = document.state.model.atoms[id];
+ if (!atom || drawing.needs_measurements) return null;
+ const rect = drawing.atom_selection_rects?.[id];
+ if (rect) return [...rect];
+ if (drawing.atom_labels?.[id] !== undefined) return null;
+ const pick = drawing.atom_hit_radii?.[id];
+ if (pick === undefined || pick === null) return [atom.x - 3, atom.y - 3, 6, 6];
+ const radius = Math.max(pick, 0.6, drawing.line_width * 0.6);
+ return [atom.x - radius, atom.y - radius, 2 * radius, 2 * radius];
+}
+
+// The desktop's valence feedback at screen size, like its painter: the item
+// rect widened by 3 px, lowered by 3 px, then a 2 px zigzag along its bottom,
+// drawn with a 1 px pen. As the painter draws only items meeting the exposed
+// area, an item outside the visible scene rect draws nothing, and a zigzag
+// keeps only its steps over the visible width and one beyond each edge, counted
+// from the item's own left edge so panning never shifts them. One call draws at
+// most MAX_WARNING_POINTS points, and warnings after that in the given atom
+// order are left out until fewer are in view. A scale or view that is not
+// finite and non-empty draws nothing, nor does an atom whose rect or points are
+// not finite or whose steps cannot be counted exactly. The caller's layer takes
+// no pointer input.
+const MAX_WARNING_POINTS = 50000;
+export function valenceWarningMarkup(document, ids, drawing, style, scale, viewport) {
+ if (!style || !Number.isFinite(scale) || scale <= 0 || !Array.isArray(viewport) || viewport.length !== 4) return '';
+ const [viewX, viewY, viewWidth, viewHeight] = viewport, viewRight = viewX + viewWidth, viewBottom = viewY + viewHeight;
+ if (![viewX, viewY, viewRight, viewBottom].every(Number.isFinite) || !(viewWidth > 0 && viewHeight > 0)) return '';
+ const paths = [];
+ let budget = MAX_WARNING_POINTS;
+ for (const id of ids ?? []) {
+ const rect = valenceWarningBounds(document, drawing, id);
+ if (!rect?.every(Number.isFinite) || rect[2] < 0 || rect[3] < 0) continue;
+ const [x, y, width, height] = rect;
+ if (!(x < viewRight && x + width > viewX && y < viewBottom && y + height > viewY)) continue;
+ const left = x - 3 / scale, bottom = y + height + 3 / scale, span = Math.max(4, roundHalfEven(width * scale + 6));
+ // Even steps from the item's left edge, the first and last just past the view.
+ const first = Math.max(0, 2 * Math.floor((viewX - left) * scale / 2) - 2);
+ const last = Math.min(span, 2 * Math.ceil((viewRight - left) * scale / 2) + 2);
+ if (![left, bottom, bottom + 2 / scale, left + last / scale].every(Number.isFinite) || !(first <= last && last <= Number.MAX_SAFE_INTEGER)) continue;
+ const count = Math.floor((last - first) / 2) + 1;
+ if (count > budget) break;
+ budget -= count;
+ const points = [];
+ for (let step = first; step <= last; step += 2) points.push(`${step === first ? 'M' : 'L'}${number(left + step / scale)} ${number(bottom + (step % 4 ? 2 : 0) / scale)}`);
+ paths.push(``);
+ }
+ return paths.join('');
+}
+
+export function sceneMarkup(document, {selection = new Set(), components = [], preview = null, drawing, handleTarget = null, handleStyle = null, snapMarks = null, snapMarkStyle = null, scale = 1, showMarkOwners = true, markPreview = null, markHoverStyle = null, imageUrl = () => null, overlays = true} = {}) {
+ const state = document.state;
+ const atoms = {...state.model.atoms};
+ let parts = [];
+ const layers = [];
+ const arrowOutlines = [];
+ const finishLayer = (z, order = 0) => { layers.push({z, order, html: parts.join('')}); parts = []; };
+ (drawing.images ?? []).forEach((image, index) => {
+ // Pixels arrive by reference; the box stays pickable while they load.
+ const url = imageUrl(image.ref);
+ parts.push(`${url ? `` : ''}`);
+ finishLayer(image.z, 0);
+ });
+ (drawing.shapes ?? []).forEach((shape, index) => {
+ const key = `shape:${index}`;
+ const guide = preview?.kind === 'shape' && index === drawing.shapes.length - 1;
+ const stroke = guide && shape.stroke === 'none' ? 'dashed' : shape.stroke;
+ const color = guide ? '#787878' : shape.color;
+ const attributes = `data-item="${key}" fill="${escapeText(shape.fill ?? 'transparent')}" fill-opacity="${number(shape.alpha ?? 1)}" stroke="${stroke === 'none' ? 'none' : escapeText(color)}" stroke-width="${number(shape.line_width)}" stroke-linecap="round" stroke-linejoin="round" pointer-events="all"${guide ? ' stroke-opacity="0.7059"' : ''}${stroke === 'dashed' ? ` stroke-dasharray="${number(shape.line_width * 4)} ${number(shape.line_width * 2)}"` : stroke === 'dotted' ? ` stroke-dasharray="${number(shape.line_width)} ${number(shape.line_width * 2)}"` : ''}`;
+ parts.push(shapeMarkup(shape, attributes));
+ finishLayer(shape.z ?? -10, 1);
+ });
+ for (const [index, ring] of (state.ring_fills ?? []).entries()) {
+ parts.push(``);
+ }
+ finishLayer(-5);
+ state.model.bonds.forEach((bond, index) => {
+ if (!bond) return;
+ const key = `bond:${index}`;
+ parts.push(``);
+ parts.push(`Bond ${bond.a}–${bond.b}, ${escapeText(bond.style)}`);
+ // No bond/ring algorithm lives here: the desktop planner supplied these primitives.
+ for (const primitive of drawing.bonds[index] ?? []) {
+ if (primitive.line) {
+ const [x1, y1, x2, y2] = primitive.line;
+ if (x1 !== x2 || y1 !== y2) parts.push(line(x1, y1, x2, y2));
+ }
+ else if (primitive.dots) {
+ for (const [x, y] of primitive.dots) parts.push(``);
+ }
+ else if (primitive.polygon) parts.push(``);
+ }
+ parts.push('');
+ });
+ finishLayer(0);
+ for (const [id, atom] of Object.entries(atoms)) {
+ const key = `atom:${id}`;
+ parts.push(``);
+ parts.push(`Atom ${id}: ${escapeText(atom.element)}`);
+ const runs = drawing.atom_layouts?.[id] ?? [];
+ for (const run of runs) {
+ parts.push(`${escapeText(run.text)}`);
+ }
+ const rect = drawing.atom_hit_rects?.[id];
+ if (rect) parts.push(``);
+ const offset = runs.length ? drawing.label_measurements.offset : 0;
+ const radius = drawing.atom_hit_radii[id];
+ if (radius !== null) parts.push(``);
+ parts.push('');
+ }
+ finishLayer(3);
+ for (const mark of drawing.marks ?? []) {
+ parts.push(``);
+ const owner = drawing.mark_owners?.[mark.id];
+ // Qt shows the ownership guidance only on a selected mark's owner guide.
+ const guided = showMarkOwners && selection.has(`mark:${mark.id}`) && owner?.tooltip;
+ if (owner) parts.push(`${escapeText(guided ? owner.tooltip : owner.text)}`);
+ parts.push(markGlyphMarkup(mark, drawing.label_measurements.family));
+ if (mark.kind.startsWith('circled_')) {
+ const width = Math.max(mark.stroke, (mark.hit_radius - mark.radius) * 2);
+ parts.push(`${circledMarkPaths(mark)}`);
+ } else {
+ const radius = Math.max(mark.hit_radius, mark.radius ?? 0);
+ parts.push(``);
+ if (mark.hit_rect) {
+ const [x,y,w,h] = mark.hit_rect;
+ parts.push(``);
+ }
+ }
+ parts.push('');
+ }
+ finishLayer(0);
+ if (markPreview && markHoverStyle) {
+ const rgba = ([r,g,b,a]) => `rgba(${r},${g},${b},${a/255})`;
+ const color = rgba(markHoverStyle.color);
+ parts.push(`${markGlyphMarkup({...markPreview.mark,color},drawing.label_measurements.family)}`);
+ finishLayer(markHoverStyle.z);
+ if (markPreview.atom) {
+ const [x,y,r] = markPreview.atom;
+ parts.push(``);
+ finishLayer(markHoverStyle.atom_z);
+ }
+ }
+
+ state.arrows.forEach((arrow, index) => {
+ const geometry = drawing.arrows[index], color = escapeText(geometry.color);
+ const path = geometry.path.map(([command, coordinates]) => `${command}${coordinates.map(number).join(' ')}`).join(' ');
+ parts.push(``);
+ if (selection.has(`arrow:${index}`)) arrowOutlines.push(arrowSelectionMarkup(geometry, index, drawing.selection_style, scale));
+ parts.push(``);
+ parts.push(``);
+ parts.push('');
+ });
+ for (const label of drawing.arrow_labels ?? []) {
+ parts.push(`
${label.html}
`);
+ }
+ finishLayer(0);
+ for (const note of drawing.notes ?? []) {
+ // A QGraphicsTextItem rotates about its position; its box is a child item.
+ parts.push(``);
+ const box = drawing.note_box;
+ if (box) parts.push(``);
+ parts.push(`
${note.html}
`);
+ if (selection.has(`note:${note.id}`)) {
+ // The note's own padded selection box, a fixed-width screen stroke.
+ const pad = drawing.note_padding;
+ parts.push(``);
+ }
+ parts.push('');
+ }
+ finishLayer(0);
+ // Qt populates brackets after arrows and before orbitals at the same depth.
+ for (const [index, bracket] of (drawing.brackets ?? []).entries()) {
+ // A dragged bracket previews in the native translucent grey.
+ const color = preview?.kind === 'ts_bracket' && index === drawing.brackets.length - 1 ? 'rgba(120,120,120,0.549)' : escapeText(bracket.color);
+ parts.push(`Bracket ${escapeText(bracket.kind)}`);
+ if (bracket.symbol) {
+ const symbol = bracket.symbol;
+ parts.push(`${escapeText(symbol.text)}`);
+ if (bracket.bounds) {
+ const [x,y,w,h] = bracket.bounds;
+ parts.push(``);
+ }
+ } else {
+ const path = bracket.path.map(([command, coordinates]) => `${command}${coordinates.map(number).join(' ')}`).join(' ');
+ parts.push(``);
+ }
+ parts.push('');
+ }
+ finishLayer(0);
+ for (const [index, orbital] of (drawing.orbitals ?? []).entries()) {
+ const [cx,cy] = orbital.center;
+ parts.push(``);
+ parts.push(`Orbital ${escapeText(orbital.kind)}`);
+ const [left,top,width,height] = orbital.hit_rect;
+ parts.push(``);
+ for (const [x,y,width,height,positive] of orbital.ellipses) {
+ parts.push(``);
+ }
+ if (orbital.node) parts.push(line(...orbital.node));
+ parts.push('');
+ }
+ finishLayer(0);
+ if (arrowOutlines.length) parts.push(`${arrowOutlines.join('')}`);
+ if (components.length) parts.push(`${selectionComponentsMarkup(components, drawing.selection_style, scale, 'molecule-selection')}`);
+ const shapeComponents = (drawing.shapes ?? []).flatMap((shape,index) => selection.has(`shape:${index}`) ? [[shape.selection]] : []);
+ if (shapeComponents.length) parts.push(`${selectionComponentsMarkup(shapeComponents, drawing.selection_style, scale, 'shape-selection')}`);
+ if (showMarkOwners) for (const [id, owner] of Object.entries(drawing.mark_owners ?? {})) {
+ if (!selection.has(`mark:${id}`) || !owner.rect) continue;
+ const [x,y,w,h] = owner.rect, width = drawing.selection_style.screen_width;
+ parts.push(`${line(...owner.line, 'vector-effect="non-scaling-stroke"')}`);
+ }
+ finishLayer(19);
+ // A selected image's dashed box, like the desktop's group outline.
+ (drawing.images ?? []).forEach((image, index) => { if (selection.has(`image:${index}`)) parts.push(groupBoxesMarkup([image.selection], drawing)); });
+ if (overlays) parts.push('');
+ finishLayer(20);
+ const [handleKind, handleId] = handleTarget?.split(':') ?? [];
+ if (preview?.kind === 'line') parts.push(line(preview.start.x, preview.start.y, preview.end.x, preview.end.y, 'stroke="#0d9488" stroke-width="1.5" stroke-dasharray="3 2" pointer-events="none"'));
+ // The server's snap rings: children of the drawing preview, sized on screen.
+ if (snapMarkStyle && Number.isFinite(scale) && scale > 0) {
+ for (const [x, y] of snapMarks ?? []) parts.push(``);
+ }
+ const collection = {arrow: 'arrows', shape: 'shapes', orbital: 'orbitals'}[handleKind];
+ const handleOwner = collection && handleStyle ? drawing[collection][handleId] : null;
+ for (const {handle, point, snapped} of handleOwner?.handles ?? []) {
+ const size = ['shape_n', 'shape_e', 'shape_s', 'shape_w'].includes(handle) ? handleStyle.edge_size : handleStyle.size;
+ parts.push(``);
+ }
+ if (preview?.kind === 'marquee') {
+ const {start, end} = preview;
+ parts.push(``);
+ }
+ if (overlays) parts.push('');
+ finishLayer(30);
+ return layers.sort((a, b) => a.z - b.z || a.order - b.order).map(layer => layer.html).join('');
+}
+
+// Display only the inserted part of a server candidate. Geometry and chemistry
+// still come from the existing renderer; the committed scene is never replaced.
+// One group opacity composites overlaps once, as the native preview picture does.
+export function smilesPreviewMarkup(candidate, committed, opacity) {
+ const source = candidate.document.state, original = committed.state;
+ const state = {...source, model: {...source.model,
+ atoms: Object.fromEntries(Object.entries(source.model.atoms).filter(([id]) => !Object.hasOwn(original.model.atoms, id))),
+ bonds: source.model.bonds.map((bond, index) => index < original.model.bonds.length ? null : bond),
+ }, arrows: [], ring_fills: []};
+ const drawing = {...candidate.drawing, images: [], shapes: [], arrows: [], arrow_labels: [], notes: [], orbitals: [], brackets: [],
+ marks: (candidate.drawing.marks ?? []).filter(mark => mark.id >= (original.marks ?? []).length)};
+ return `${sceneMarkup({...candidate.document, state}, {drawing, overlays: false, showMarkOwners: false})}`;
+}
+
+// QGraphicsView's rubber band intersects item shapes; SVG owns the same
+// operation on the materialized native geometry, including transparent targets.
+// Targets the server's selection_buckets accepts; other SVG children only draw.
+export function itemKey(element) {
+ const key = element.closest('[data-item]')?.dataset.item;
+ return key && /^(atom|bond|arrow|shape|ring|mark|orbital|ts_bracket|note|image):/.test(key) ? key : null;
+}
+
+export function marqueeSelection(svg, start, end, initial = [], additive = false) {
+ // Firefox does not implement this SVG operation. Distinguish unsupported
+ // geometry from an empty result so the caller can keep the prior selection.
+ if (typeof svg.getIntersectionList !== 'function') return null;
+ const matrix = svg.getCTM();
+ const first = {x: matrix.a * start.x + matrix.c * start.y + matrix.e, y: matrix.b * start.x + matrix.d * start.y + matrix.f};
+ const last = {x: matrix.a * end.x + matrix.c * end.y + matrix.e, y: matrix.b * end.x + matrix.d * end.y + matrix.f};
+ const rect = svg.createSVGRect();
+ rect.x = Math.min(first.x, last.x); rect.y = Math.min(first.y, last.y);
+ rect.width = Math.abs(last.x - first.x); rect.height = Math.abs(last.y - first.y);
+ const selected = new Set(additive ? initial : []);
+ if (rect.width && rect.height) {
+ for (const element of svg.getIntersectionList(rect, svg.querySelector('#drawing'))) {
+ const key = itemKey(element);
+ if (key) selected.add(key);
+ }
+ }
+ return selected;
+}
+
+// SVG viewBox is the browser representation of the native view transform.
+export function clampView(view, viewport, rect) {
+ if (!rect || !viewport.width || !viewport.height) return view;
+ const scale = Math.min(viewport.width / view.width, viewport.height / view.height);
+ const width = viewport.width / scale, height = viewport.height / scale;
+ const result = {x:view.x-(width-view.width)/2, y:view.y-(height-view.height)/2, width, height};
+ for (const [axis, size, offset] of [['x','width',0],['y','height',1]]) {
+ const minimum = rect[offset], extent = rect[offset+2];
+ result[axis] = extent < result[size]
+ ? minimum + (extent-result[size])/2
+ : Math.max(minimum, Math.min(minimum+extent-result[size], result[axis]));
+ }
+ return result;
+}
+
+export function zoomView(view, viewport, factor, policy, position = {x: viewport.width / 2, y: viewport.height / 2}) {
+ const scale = Math.min(viewport.width / view.width, viewport.height / view.height);
+ const next = Math.max(policy.min, Math.min(policy.max, scale / factor));
+ const left = view.x - (viewport.width / scale - view.width) / 2;
+ const top = view.y - (viewport.height / scale - view.height) / 2;
+ return {x: left + position.x / scale - position.x / next, y: top + position.y / scale - position.y / next, width: viewport.width / next, height: viewport.height / next};
+}
+
+export function wheelView(view, viewport, event, policy, lineHeight) {
+ const dx = event.deltaX * (event.deltaMode === 1 ? lineHeight : event.deltaMode === 2 ? viewport.width : 1);
+ const dy = event.deltaY * (event.deltaMode === 1 ? lineHeight : event.deltaMode === 2 ? viewport.height : 1);
+ if (event.ctrlKey || (policy.zoom_modifier === 'meta' && event.metaKey)) {
+ if (!dy) return view;
+ // Browser deltas have the opposite sign to Qt's wheel deltas.
+ return zoomView(view, viewport, policy.wheel_base ** (dy * policy.angle_per_pixel), policy, event.position);
+ }
+ const scale = Math.min(viewport.width / view.width, viewport.height / view.height);
+ return {...view, x: view.x + dx / scale, y: view.y + dy / scale};
+}
+
+// Adapt the centered sheet rectangle to browser pointer coordinates.
+export function pointInSheet({x, y}, [width, height]) {
+ return x >= -width / 2 && x <= width / 2 && y >= -height / 2 && y <= height / 2;
+}
+
+
+export function gridMarkup(sheet, grid, spec, bondLength, scale) {
+ const step = bondLength * spec.step;
+ if (!grid.enabled || step * scale < spec.minimum_spacing) return '';
+ const tile = spec.tiles[grid.style];
+ const lines = tile.lines.map(([x1,y1,x2,y2]) => ``).join('');
+ return `${lines}`;
+}
diff --git a/app/chemvas/web/style.css b/app/chemvas/web/style.css
new file mode 100644
index 00000000..199611ad
--- /dev/null
+++ b/app/chemvas/web/style.css
@@ -0,0 +1,166 @@
+/* Values are served from the existing Qt palette and toolbar_styles module. */
+:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; color: var(--text); background: var(--surface-app); }
+* { box-sizing: border-box; }
+body { margin: 0; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
+button, input, select { font: inherit; color: inherit; }
+button { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 0 9px; height: 26px; cursor: default; white-space: nowrap; }
+button:hover:not(:disabled), summary:hover { background: var(--hover); }
+button:active:not(:disabled) { background: var(--pressed); }
+button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 1px solid var(--accent); }
+button:disabled { color: var(--text-faint); }
+button:disabled svg { opacity: .4; }
+button[aria-pressed="true"] { background: var(--checked-bg); border-color: var(--checked-border); color: var(--checked-text); }
+input, select { border: 1px solid var(--border-strong); border-radius: 5px; padding: 3px 6px; background: var(--surface-input); height: 26px; }
+input { width: 90px; }
+input:disabled { color: var(--text-faint); }
+label { display: flex; align-items: center; gap: 6px; }
+[hidden] { display: none !important; }
+.spacer { flex: 1; }
+.menus { display: flex; align-items: center; height: 25px; flex: none; background: var(--surface-bar); border-bottom: 1px solid var(--border); z-index: 5; }
+summary { list-style: none; padding: 4px 10px; cursor: default; }
+summary::-webkit-details-marker { display: none; }
+.menu { position: absolute; min-width: 230px; background: var(--surface-pop); border: 1px solid var(--border); border-radius: 6px; padding: 5px; box-shadow: 0 5px 16px #0002; max-height: calc(100dvh - 40px); overflow: auto; }
+.menu button { display: flex; width: 100%; text-align: left; align-items: center; justify-content: space-between; gap: 22px; }
+kbd { font: inherit; font-size: 11px; color: var(--text-muted); }
+hr { border: 0; border-top: 1px solid var(--border); margin: 5px; }
+.tools { height: var(--toolbar-thickness); flex: none; display: flex; align-items: center; padding: 0 6px; gap: 12px; background: var(--surface-bar); border-bottom: 1px solid var(--border); overflow-x: auto; }
+.tool-group { display: flex; gap: 4px; flex: none; position: relative; }
+.tool-group + .tool-group::before { content: ""; position: absolute; left: -7px; top: 7px; width: 1px; height: 18px; background: var(--border); }
+.tools button { width: var(--toolbar-button-size); height: var(--toolbar-button-size); padding: 0; display: grid; place-items: center; flex: none; }
+button svg { width: var(--toolbar-icon-size); height: var(--toolbar-icon-size); padding: 1px; }
+.options { display: flex; align-items: center; min-height: var(--toolbar-thickness); flex: none; padding: 0 8px; gap: 12px; background: var(--surface-context); border-bottom: 1px solid var(--border); overflow-x: auto; }
+.context { display: flex; align-items: center; gap: 3px; flex: none; height: var(--context-bar-content-height); }
+.options button[aria-pressed="true"] { background: var(--surface-input); border-color: var(--border-soft); color: var(--checked-text); }
+.context .segments { display: flex; align-items: center; gap: 1px; padding: 2px; border: 1px solid var(--border-soft); background: var(--surface-app); border-radius: 6px; }
+.segments button { border-radius: 4px; height: var(--context-bar-button-height); width: var(--context-bar-button-height); padding: 0; display: grid; place-items: center; }
+.context[data-context="orbital"] { gap: 3px; padding: 0 2px; }
+.context[data-context="orbital"] .segments { gap: 3px; padding: 0; border: 0; background: transparent; }
+.segments button svg { width: var(--context-bar-icon-size); height: var(--context-bar-icon-size); }
+.caption { font-size: 10px; font-weight: 600; color: var(--text-muted); letter-spacing: .5px; }
+.length { color: var(--text-muted); gap: 2px; }
+.length input { width: 65px; }
+.smiles { font-size: 11px; color: var(--text-muted); flex: none; }
+.smiles input { width: 150px; }
+#smiles-preview, #smiles-preview * { pointer-events: none !important; }
+button.primary { background: var(--accent); color: var(--accent-contrast); }
+button.primary:disabled { opacity: .45; }
+#notice { padding: 7px 10px; background: #fff6df; color: #735011; font-size: 12px; line-height: 1.5; flex: none; }
+#notice.error { background: var(--danger-bg); color: var(--danger-text); }
+.workspace { flex: 1; min-height: 0; min-width: 0; overflow: hidden; background: #e7e7e6; }
+#canvas { width: 100%; height: 100%; display: block; touch-action: none; user-select: none; outline: none; }
+#canvas[data-tool="bond"], #canvas[data-tool="benzene"] { cursor: crosshair; }
+#paper { filter: drop-shadow(0 1px 2px #0002); }
+footer { height: 28px; flex: none; display: flex; align-items: center; padding: 0 6px; gap: 8px; border-top: 1px solid var(--border); background: var(--surface-bar); font-size: 11px; color: var(--text-muted); white-space: nowrap; }
+#status { overflow: hidden; text-overflow: ellipsis; }
+.zoom { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 6px; height: 24px; background: var(--surface-input); }
+.zoom button { height: 22px; font-size: 11px; padding: 0 7px; }
+#zoom-level { min-width: 44px; }
+dialog { width: min(500px, 90vw); padding: 24px; border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 12px 60px #0002; color: inherit; }
+dialog::backdrop { background: #0003; }
+dialog h2 { margin: 0 0 16px; font-size: 18px; }
+dialog p { line-height: 1.6; color: var(--text-muted); }
+@media (max-width: 720px) { #canvas-status, #tool-status { display: none; } }
+
+#canvas[data-tool="arrow"] { cursor: crosshair; }
+#arrow-options summary { border: 1px solid transparent; border-radius: 4px; padding: 0; width: var(--context-bar-button-height); height: var(--context-bar-button-height); display: grid; place-items: center; }
+#arrow-options summary svg, #arrow-options button svg { width: var(--context-bar-icon-size); height: var(--context-bar-icon-size); }
+#arrow-more summary[aria-pressed="true"] { background: var(--surface-input); border-color: var(--border-soft); }
+.arrow-popup .menu { position: fixed; z-index: 10; }
+
+.arrow-slider .menu { min-width: 0; padding: 6px 8px; background: var(--surface-input); }
+.arrow-slider input { appearance: none; width: 120px; height: var(--context-bar-button-height); padding: 0; margin: 0; border: 0; background: transparent; }
+.arrow-slider input::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--border-strong); }
+.arrow-slider input::-webkit-slider-thumb { appearance: none; width: 12px; height: 12px; margin-top: -4px; border: 0; border-radius: 6px; background: var(--accent); }
+.arrow-slider input::-moz-range-track { height: 4px; border-radius: 2px; background: var(--border-strong); }
+.arrow-slider input::-moz-range-thumb { width: 12px; height: 12px; border: 0; border-radius: 6px; background: var(--accent); }
+.arrow-slider input::-webkit-slider-thumb:hover { background: var(--checked-text); }
+.arrow-slider input::-moz-range-thumb:hover { background: var(--checked-text); }
+#canvas[data-tool="line"] { cursor: crosshair; }
+
+[data-context="select"], [data-context="arrow"], [data-context="line"], #arrow-options, #line-options { gap: 3px; }
+#flip-options, .arrange-options, .text-format { display: flex; align-items: center; gap: 3px; }
+#flip-options button svg, .arrange-options button svg, .text-format button svg { width: var(--context-bar-icon-size); height: var(--context-bar-icon-size); }
+#flip-options > button, .arrange-options > button, .text-format > button, #arrow-options > button, #line-options > button { width: var(--context-bar-button-height); height: var(--context-bar-button-height); padding: 0; display: grid; place-items: center; }
+.context-divider { width: 1px; height: 18px; background: var(--border-soft); }
+
+.arrow-label { width: max-content; padding: 4px; line-height: normal; white-space: nowrap; user-select: none; }
+.arrow-label sub, .arrow-label sup, .label-preview sub, .label-preview sup { position: relative; vertical-align: baseline; line-height: 0; }
+/* QTextDocument: 4px margin, ideal width, Qt's 12px paragraph margins without the
+ document's outer ones; layoutNoteText supplies line heights. */
+.note-text { width: max-content; padding: 4px; line-height: 0; white-space: nowrap; user-select: none; }
+.note-text * { line-height: 0 !important; }
+.note-text p { margin: 12px 0; }
+.note-text > p:first-child { margin-top: 0 !important; }
+.note-text > p:last-child { margin-bottom: 0 !important; }
+.note-text [data-script] { position: relative; vertical-align: baseline; }
+.note-strut { display: inline-block; width: 0; }
+.note-editor { position: fixed; left: 0; top: 0; z-index: 20; transform-origin: 0 0; width: max-content; min-width: 8px; padding: 4px; white-space: nowrap; outline: 1px dashed #0d9488; background: transparent; cursor: text; }
+.note-editor p { margin: 12px 0; }
+.note-editor > p:first-child { margin-top: 0 !important; }
+.note-editor > p:last-child { margin-bottom: 0 !important; }
+.note-editor [data-script] { position: relative; vertical-align: baseline; line-height: 0; }
+.rich-probe { position: fixed; visibility: hidden; pointer-events: none; }
+#arrow-label-dialog { width: min(592px, 90vw); min-width: min(432px, 90vw); }
+#arrow-label-dialog textarea { display: block; width: 100%; resize: none; margin-top: 4px; }
+#arrow-label-dialog label { display: block; margin-top: 12px; }
+#arrow-label-dialog p { white-space: pre-line; font-size: 12px; line-height: 1.4; }
+.label-preview { padding: 4px; min-height: 36px; max-height: 100px; overflow: auto; border: 1px solid var(--border); }
+.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
+/* QTextDocument retains the final empty line and a trailing collapsed space. */
+.arrow-label::after, .label-preview::after { content: "\200b"; }
+
+.options .color-swatch { width: var(--toolbar-button-size); height: var(--toolbar-button-size); padding: 0; background: var(--swatch-color); border: 1px solid var(--icon-muted); border-radius: 5px; cursor: pointer; }
+.options .color-swatch:hover { background: var(--swatch-color); border: 2px solid var(--accent); }
+.options .color-swatch:active { background: var(--swatch-color); border: 2px solid var(--accent-pressed); }
+.options .color-swatch[aria-pressed="true"] { background: var(--swatch-color); border: 3px solid var(--text); }
+[data-context="color"], #color-options, [data-context="ring_fill"], #ring-fill-options { gap: 3px; }
+
+.rotation-angle { gap: 0; background: var(--surface-input); border: 1px solid var(--border-soft); border-radius: 4px; padding-right: 4px; height: calc(var(--context-bar-button-height) + 4px); }
+.rotation-angle input { width: 44px; text-align: right; border: 0; background: transparent; appearance: textfield; }
+.rotation-angle input::-webkit-inner-spin-button, .rotation-angle input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
+.rotation-stepper { display: flex; flex-direction: column; margin-left: -8px; }
+.rotation-stepper button { padding: 0 4px; height: 12px; line-height: 10px; font-size: 12px; color: var(--text-muted); }
+
+.menu .submenu > summary { display: flex; justify-content: space-between; }
+.menu .submenu > summary::after { content: "›"; }
+.submenu > .menu { position: fixed; z-index: 6; }
+
+#sheet-dialog { width: min(370px, 90vw); padding: 16px; }
+#sheet-dialog > form > label { display: block; margin: 10px 0 4px; }
+#sheet-dialog select { width: 100%; }
+.sheet-dimensions { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; align-items: center; margin-top: 12px; }
+.sheet-dimension { display: flex; align-items: center; gap: 4px; }
+.sheet-dimension input { width: 100%; min-width: 0; appearance: textfield; }
+.sheet-dimension input::-webkit-inner-spin-button, .sheet-dimension input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
+#sheet-explanation { font-size: 13px; color: var(--text); line-height: 1.4; }
+
+
+.grid-control { display: flex; align-items: center; }
+#grid-options { position: relative; }
+#grid-options > summary { cursor: pointer; list-style: none; padding: 4px; }
+#grid-options > summary::-webkit-details-marker { display: none; }
+#grid-menu { top: auto; bottom: 100%; left: auto; right: 0; }
+#grid-menu button::before { content: ""; display: inline-block; width: 16px; }
+#grid-menu button[aria-checked="true"]::before { content: "✓"; }
+#grid-toggle[aria-pressed="true"]::before { content: "✓ "; }
+
+#mark-menu, #bond-menu { position: fixed; z-index: 30; }
+#bond-menu button::before { content: ""; display: inline-block; width: 16px; }
+#bond-menu button[aria-checked="true"]::before { content: "✓"; }
+#mark-owner-dialog { margin: 110px 12px 0 auto; width: min(440px, 90vw); }
+#mark-owner-atom { width: 100%; margin-bottom: 16px; }
+.stepper { display: inline-flex; flex-direction: column; gap: 1px; }
+.stepper button { width: 16px; height: 11px; min-height: 0; padding: 0; font-size: 7px; line-height: 1; }
+#image-dialog { width: min(420px, 90vw); }
+#image-dialog .image-row, #image-dialog .image-fields label { display: grid; grid-template-columns: 1fr 160px; align-items: center; gap: 8px; margin: 6px 0; }
+#image-dialog .image-check { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
+#image-dialog .image-check input { width: auto; height: auto; margin: 0; }
+#image-dialog input[type="number"] { width: 100%; }
+#image-dialog #image-opacity { width: 120px; }
+#recover-dialog { width: min(520px, 90vw); }
+.recover-list { list-style: none; margin: 0 0 16px; padding: 0; max-height: 50vh; overflow: auto; }
+.recover-list li { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 8px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border-soft); }
+.recover-list strong { overflow-wrap: anywhere; }
+.recover-list span { grid-column: 1; grid-row: 2; font-size: 12px; color: var(--text-muted); }
+.recover-list button { grid-row: 1 / span 2; }
+#recovery-status { color: var(--text-muted); }
diff --git a/app/chemvas/web/transport.mjs b/app/chemvas/web/transport.mjs
new file mode 100644
index 00000000..dcdf6be0
--- /dev/null
+++ b/app/chemvas/web/transport.mjs
@@ -0,0 +1,79 @@
+// Complete presentation without replaying an accepted edit or sending its document back.
+export async function sessionDrawing(request, send, measure) {
+ const result = await send(request);
+ if (!result.drawing?.needs_measurements) return result;
+ try {
+ const rendered = await send({
+ session: result.session, revision: result.revision, action: 'measure',
+ font: measure(result.drawing.label_measurements),
+ ...(request.action === 'preview' ? {edit: request.edit, ...(request.selection ? {selection:request.selection} : {})} : {}),
+ });
+ if (rendered.drawing?.needs_measurements) throw new Error('The font measurements did not complete the drawing.');
+ return {...rendered, shortcut_tool: result.shortcut_tool, edit_notice: result.edit_notice, pasted: result.pasted};
+ } catch (error) {
+ // The initial request succeeded; resynchronize its document, never repeat it.
+ error.uncertain = true;
+ error.session = result.session;
+ throw error;
+ }
+}
+
+// Browser transport only. Chemvas's Python CanvasHistoryService owns undo/redo.
+export class SessionClient {
+ #info = null;
+ #busy = false;
+ #send;
+ #syncSession = null;
+
+ constructor(send) { this.#send = send; }
+ get info() { return this.#info; }
+ get name() { return this.info?.name ?? 'Canvas 1.chemvas'; }
+ get document() { return this.info?.document ?? null; }
+ get busy() { return this.#busy; }
+ get readOnly() { return Boolean(this.info?.unsupported.length); }
+ get canUndo() { return !this.#busy && Boolean(this.info?.can_undo); }
+ get canRedo() { return !this.#busy && Boolean(this.info?.can_redo); }
+ get dirty() { return Boolean(this.info?.dirty); }
+
+ async #dispatch(action, extra = {}) {
+ if (this.#busy) throw new Error('Wait for the current edit to finish.');
+ this.#busy = true;
+ try {
+ if (this.#syncSession) {
+ this.#info = await this.#send({session: this.#syncSession, action: 'read'});
+ this.#syncSession = null;
+ throw new Error('Connection restored. Check the drawing before editing again.');
+ }
+ try {
+ this.#info = await this.#send({session: this.info?.session, revision: this.info?.revision ?? 0, action, ...extra});
+ } catch (error) {
+ if (error.status && error.status < 500 && error.status !== 409 && !error.uncertain) throw error;
+ const session = this.info?.session ?? error.session;
+ if (!session) throw error;
+ this.#syncSession = session;
+ try {
+ this.#info = await this.#send({session, action: 'read'});
+ this.#syncSession = null;
+ } catch {
+ throw new Error(`${error.message} The current drawing could not be refreshed; the next action will only reconnect.`);
+ }
+ throw new Error(`${error.message} The current drawing has been refreshed. Check it before editing again.`);
+ }
+ } finally { this.#busy = false; }
+ }
+ async load(info, name = 'Canvas 1.chemvas') {
+ await this.#dispatch('load', {document: info.document, name});
+ }
+ async perform(edit) {
+ if (this.readOnly) throw new Error('This drawing is read-only in the browser adapter.');
+ await this.#dispatch('edit', {edit});
+ }
+ // File > Recover Unsaved Work: this window takes over a draft. With takeover,
+ // the server closes another window that still holds it, only once this
+ // request and the draft are valid and that window's draft is up to date.
+ async recover(draft, takeover = false) {
+ await this.#dispatch('recover_draft', {draft, ...(takeover ? {takeover: true} : {})});
+ }
+ async undo() { if (this.canUndo) await this.#dispatch('undo'); }
+ async redo() { if (this.canRedo) await this.#dispatch('redo'); }
+}
diff --git a/contracts/machine-observation/LICENSE b/contracts/machine-observation/LICENSE
new file mode 100644
index 00000000..bf2b813a
--- /dev/null
+++ b/contracts/machine-observation/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 daehyupsohn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/contracts/machine-observation/NOTICE.md b/contracts/machine-observation/NOTICE.md
new file mode 100644
index 00000000..56dd09fc
--- /dev/null
+++ b/contracts/machine-observation/NOTICE.md
@@ -0,0 +1,50 @@
+# Contract Asset Provenance
+
+These files are a project-scoped snapshot of the factory/machine-observation
+contract, used for standalone validation of Chemvas's machine.json output.
+
+## Source
+
+- Repository: https://github.com/dhsohn/machine-contracts
+- Commit: 38581a7737cd0521bb3d50b59115eefe3c9254ca
+- License: MIT (Copyright (c) 2026 daehyupsohn)
+
+## Scope
+
+This snapshot includes the envelope schema, the semantic validator, and the
+payload schemas and registry routes applicable to the `chemvas` producer:
+
+- `chemistry/elementary-step` v1 and v2
+
+Routes and schemas for other producers (orca_auto, ollama_bot, llmdocx) are
+excluded. The registry validates chemvas observations identically to the full
+upstream registry for all chemvas routes and rejects observations from
+unregistered producers.
+
+## Copied files (exact upstream content)
+
+Per-file SHA-256 digests are recorded in `PROVENANCE.json` and verified by
+`tests/test_contract_compatibility.py`. The digests were taken from the pin
+commit above.
+
+| Local path | Upstream path |
+| --- | --- |
+| `scripts/validate.py` | `scripts/validate.py` |
+| `schemas/machine-observation-v1.schema.json` | `schemas/machine-observation-v1.schema.json` |
+| `schemas/payloads/chemistry-elementary-step-v1.schema.json` | `schemas/payloads/chemistry-elementary-step-v1.schema.json` |
+| `schemas/payloads/chemistry-elementary-step-v2.schema.json` | `schemas/payloads/chemistry-elementary-step-v2.schema.json` |
+| `LICENSE` | `LICENSE` |
+| `fixtures/valid/chemvas-*.json` | `fixtures/chemvas-*.json` |
+
+## Derived file
+
+| Local path | Description |
+| --- | --- |
+| `registry.json` | Project-scoped derivative: chemvas routes and payload contracts only, all upstream `artifact_roles` retained |
+
+## Compatibility
+
+The v1 envelope is frozen per the upstream COMPATIBILITY.md. Interior objects in
+the payload schemas are open for producer-side growth. The validator, schemas,
+and registry together define the verdict; the JSON Schema alone is strictly
+weaker.
diff --git a/contracts/machine-observation/PROVENANCE.json b/contracts/machine-observation/PROVENANCE.json
new file mode 100644
index 00000000..88a520f2
--- /dev/null
+++ b/contracts/machine-observation/PROVENANCE.json
@@ -0,0 +1,49 @@
+{
+ "pin": "38581a7737cd0521bb3d50b59115eefe3c9254ca",
+ "source": "https://github.com/dhsohn/machine-contracts",
+ "copied_files": {
+ "scripts/validate.py": {
+ "upstream": "scripts/validate.py",
+ "sha256": "ec9eda23eb908939afddad0f58ffe18e8e6c54b10a8b3d2f3f2e65de269759e1"
+ },
+ "schemas/machine-observation-v1.schema.json": {
+ "upstream": "schemas/machine-observation-v1.schema.json",
+ "sha256": "deda0ca05d46a68382b20a43601a1ddc2826d6f15a62b8b6273c672d39f8de1b"
+ },
+ "schemas/payloads/chemistry-elementary-step-v1.schema.json": {
+ "upstream": "schemas/payloads/chemistry-elementary-step-v1.schema.json",
+ "sha256": "a0390f2cbe19cdca6b3909e21fd36ac3148e8604ba10acf55148cc9f56155923"
+ },
+ "schemas/payloads/chemistry-elementary-step-v2.schema.json": {
+ "upstream": "schemas/payloads/chemistry-elementary-step-v2.schema.json",
+ "sha256": "9a696aa280e83922604da2ec3fdca8d2af1140a43ba4598b83e338bf3c03cc84"
+ },
+ "LICENSE": {
+ "upstream": "LICENSE",
+ "sha256": "25a57a182f456e94b38019bc404e069e86e28b3c4a0196e6498e4f5c7ae03382"
+ },
+ "fixtures/valid/chemvas-ready.json": {
+ "upstream": "fixtures/chemvas-ready.json",
+ "sha256": "96c950f71d476f607a182b742598e15a46501155f36ab812c49cd660cee2fb48"
+ },
+ "fixtures/valid/chemvas-blocked.json": {
+ "upstream": "fixtures/chemvas-blocked.json",
+ "sha256": "326ad7e99257685fc3ac473c16080d7254c5441023385b4280796812deb9b0d7"
+ },
+ "fixtures/valid/chemvas-v2-ready.json": {
+ "upstream": "fixtures/chemvas-v2-ready.json",
+ "sha256": "2922c6586257e9c010bd6060353e45867f2c9d243897e46af91c655508672d72"
+ },
+ "fixtures/valid/chemvas-v2-blocked.json": {
+ "upstream": "fixtures/chemvas-v2-blocked.json",
+ "sha256": "f843224418d2ad9a0730c874f4f7532c095f472859a4459dd7b57e9bd8375caa"
+ }
+ },
+ "derived_files": {
+ "registry.json": {
+ "description": "Project-scoped derivative: chemvas routes and payload contracts only, all upstream artifact_roles retained",
+ "sha256": "720ecf05c8400a9d7809e2c65297a4d513e5766e6014f608143fb875f8157769",
+ "scope": "Contains only routes for chemvas producer, payload contracts for chemistry/elementary-step v1 and v2, and the full upstream artifact_roles list. Producers list is [chemvas] only. Operation kinds narrowed to [chemistry/elementary-step-export]."
+ }
+ }
+}
diff --git a/contracts/machine-observation/fixtures/valid/chemvas-blocked.json b/contracts/machine-observation/fixtures/valid/chemvas-blocked.json
new file mode 100644
index 00000000..6a280ec3
--- /dev/null
+++ b/contracts/machine-observation/fixtures/valid/chemvas-blocked.json
@@ -0,0 +1,62 @@
+{
+ "contract": {
+ "name": "factory/machine-observation",
+ "version": 1
+ },
+ "producer": {
+ "name": "chemvas",
+ "version": "1.0.0"
+ },
+ "operation": {
+ "id": "step-S02-a1fce4363854ff88",
+ "kind": "chemistry/elementary-step-export"
+ },
+ "lifecycle": {
+ "phase": "finished",
+ "outcome": "succeeded",
+ "codes": []
+ },
+ "handoff": {
+ "status": "blocked",
+ "codes": [
+ "chemvas/multicomponent_precomplex_geometry_not_provided"
+ ]
+ },
+ "delivery": {
+ "status": "complete",
+ "codes": []
+ },
+ "artifacts": {},
+ "lineage": {
+ "trace_id": null,
+ "upstream": []
+ },
+ "payload": {
+ "contract": {
+ "name": "chemistry/elementary-step",
+ "version": 1
+ },
+ "data": {
+ "step_id": "S02",
+ "source": {},
+ "reactant": {
+ "state_id": "R02"
+ },
+ "product": {
+ "state_id": "P02"
+ },
+ "atom_correspondence": {},
+ "bond_changes": {
+ "step_id": "S02",
+ "entries": []
+ },
+ "mapping_validation": "complete_source_and_generated_geometry_bijection",
+ "endpoint_pair": null,
+ "geometry_scope": {
+ "reactant_component_count": 2,
+ "product_component_count": 1,
+ "interaction_geometry_guarantee": "not_provided"
+ }
+ }
+ }
+}
diff --git a/contracts/machine-observation/fixtures/valid/chemvas-ready.json b/contracts/machine-observation/fixtures/valid/chemvas-ready.json
new file mode 100644
index 00000000..839d326a
--- /dev/null
+++ b/contracts/machine-observation/fixtures/valid/chemvas-ready.json
@@ -0,0 +1,72 @@
+{
+ "contract": {
+ "name": "factory/machine-observation",
+ "version": 1
+ },
+ "producer": {
+ "name": "chemvas",
+ "version": "1.0.0"
+ },
+ "operation": {
+ "id": "step-S01-2d711642b726b044",
+ "kind": "chemistry/elementary-step-export"
+ },
+ "lifecycle": {
+ "phase": "finished",
+ "outcome": "succeeded",
+ "codes": []
+ },
+ "handoff": {
+ "status": "ready",
+ "codes": []
+ },
+ "delivery": {
+ "status": "complete",
+ "codes": []
+ },
+ "artifacts": {},
+ "lineage": {
+ "trace_id": null,
+ "upstream": []
+ },
+ "payload": {
+ "contract": {
+ "name": "chemistry/elementary-step",
+ "version": 1
+ },
+ "data": {
+ "step_id": "S01",
+ "source": {
+ "document_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "document_bytes": 1200,
+ "chemvas_document_version": 4
+ },
+ "reactant": {
+ "state_id": "R01"
+ },
+ "product": {
+ "state_id": "P01"
+ },
+ "atom_correspondence": {
+ "source_entries": [],
+ "geometry_entries": []
+ },
+ "bond_changes": {
+ "step_id": "S01",
+ "entries": []
+ },
+ "mapping_validation": "complete_source_and_generated_geometry_bijection",
+ "endpoint_pair": {
+ "ordering": {
+ "canonical_side": "reactant"
+ },
+ "endpoints": {}
+ },
+ "geometry_scope": {
+ "reactant_component_count": 1,
+ "product_component_count": 1,
+ "interaction_geometry_guarantee": "not_provided"
+ }
+ }
+ }
+}
diff --git a/contracts/machine-observation/fixtures/valid/chemvas-v2-blocked.json b/contracts/machine-observation/fixtures/valid/chemvas-v2-blocked.json
new file mode 100644
index 00000000..a52908e5
--- /dev/null
+++ b/contracts/machine-observation/fixtures/valid/chemvas-v2-blocked.json
@@ -0,0 +1,62 @@
+{
+ "contract": {
+ "name": "factory/machine-observation",
+ "version": 1
+ },
+ "producer": {
+ "name": "chemvas",
+ "version": "1.0.0"
+ },
+ "operation": {
+ "id": "step-S04-9b3f2d6a04e1c875",
+ "kind": "chemistry/elementary-step-export"
+ },
+ "lifecycle": {
+ "phase": "finished",
+ "outcome": "succeeded",
+ "codes": []
+ },
+ "handoff": {
+ "status": "blocked",
+ "codes": [
+ "chemvas/endpoint_charge_mismatch"
+ ]
+ },
+ "delivery": {
+ "status": "complete",
+ "codes": []
+ },
+ "artifacts": {},
+ "lineage": {
+ "trace_id": null,
+ "upstream": []
+ },
+ "payload": {
+ "contract": {
+ "name": "chemistry/elementary-step",
+ "version": 2
+ },
+ "data": {
+ "step_id": "S04",
+ "source": {},
+ "reactant": {
+ "state_id": "R04"
+ },
+ "product": {
+ "state_id": "P04"
+ },
+ "atom_correspondence": {},
+ "bond_changes": {
+ "step_id": "S04",
+ "entries": []
+ },
+ "mapping_validation": "complete_source_and_generated_geometry_bijection",
+ "endpoint_geometry": null,
+ "geometry_scope": {
+ "reactant_component_count": 2,
+ "product_component_count": 2,
+ "interaction_geometry_guarantee": "not_provided"
+ }
+ }
+ }
+}
diff --git a/contracts/machine-observation/fixtures/valid/chemvas-v2-ready.json b/contracts/machine-observation/fixtures/valid/chemvas-v2-ready.json
new file mode 100644
index 00000000..567c284a
--- /dev/null
+++ b/contracts/machine-observation/fixtures/valid/chemvas-v2-ready.json
@@ -0,0 +1,79 @@
+{
+ "contract": {
+ "name": "factory/machine-observation",
+ "version": 1
+ },
+ "producer": {
+ "name": "chemvas",
+ "version": "1.0.0"
+ },
+ "operation": {
+ "id": "step-S03-5c0e7a91d2b84f16",
+ "kind": "chemistry/elementary-step-export"
+ },
+ "lifecycle": {
+ "phase": "finished",
+ "outcome": "succeeded",
+ "codes": []
+ },
+ "handoff": {
+ "status": "ready",
+ "codes": []
+ },
+ "delivery": {
+ "status": "complete",
+ "codes": []
+ },
+ "artifacts": {},
+ "lineage": {
+ "trace_id": null,
+ "upstream": []
+ },
+ "payload": {
+ "contract": {
+ "name": "chemistry/elementary-step",
+ "version": 2
+ },
+ "data": {
+ "step_id": "S03",
+ "source": {
+ "document_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "document_bytes": 1200,
+ "chemvas_document_version": 4
+ },
+ "reactant": {
+ "state_id": "R03"
+ },
+ "product": {
+ "state_id": "P03"
+ },
+ "atom_correspondence": {
+ "source_entries": [],
+ "geometry_entries": []
+ },
+ "bond_changes": {
+ "step_id": "S03",
+ "entries": []
+ },
+ "mapping_validation": "complete_source_and_generated_geometry_bijection",
+ "endpoint_geometry": {
+ "ordering": {
+ "canonical_side": "reactant"
+ },
+ "sides": {
+ "reactant": {
+ "components": []
+ },
+ "product": {
+ "components": []
+ }
+ }
+ },
+ "geometry_scope": {
+ "reactant_component_count": 2,
+ "product_component_count": 2,
+ "interaction_geometry_guarantee": "not_provided"
+ }
+ }
+ }
+}
diff --git a/contracts/machine-observation/registry.json b/contracts/machine-observation/registry.json
new file mode 100644
index 00000000..1b622551
--- /dev/null
+++ b/contracts/machine-observation/registry.json
@@ -0,0 +1,82 @@
+{
+ "contract": {
+ "name": "factory/machine-observation",
+ "version": 1
+ },
+ "artifact_roles": [
+ "document",
+ "geometry",
+ "human-report",
+ "log",
+ "source",
+ "supporting-information"
+ ],
+ "operation_kinds": [
+ "chemistry/elementary-step-export"
+ ],
+ "payload_contracts": [
+ {
+ "name": "chemistry/elementary-step",
+ "schema": "schemas/payloads/chemistry-elementary-step-v1.schema.json",
+ "version": 1,
+ "required_keys": [
+ "step_id",
+ "source",
+ "reactant",
+ "product",
+ "atom_correspondence",
+ "bond_changes",
+ "mapping_validation",
+ "endpoint_pair",
+ "geometry_scope"
+ ],
+ "ready_requirements": [
+ {
+ "path": ["endpoint_pair"],
+ "operator": "not_null"
+ }
+ ]
+ },
+ {
+ "name": "chemistry/elementary-step",
+ "schema": "schemas/payloads/chemistry-elementary-step-v2.schema.json",
+ "version": 2,
+ "required_keys": [
+ "step_id",
+ "source",
+ "reactant",
+ "product",
+ "atom_correspondence",
+ "bond_changes",
+ "mapping_validation",
+ "endpoint_geometry",
+ "geometry_scope"
+ ],
+ "ready_requirements": [
+ {
+ "path": ["endpoint_geometry"],
+ "operator": "not_null"
+ }
+ ]
+ }
+ ],
+ "producers": [
+ "chemvas"
+ ],
+ "routes": [
+ {
+ "producer": "chemvas",
+ "operation_kind": "chemistry/elementary-step-export",
+ "payload_contract": "chemistry/elementary-step",
+ "payload_version": 1,
+ "requirements": []
+ },
+ {
+ "producer": "chemvas",
+ "operation_kind": "chemistry/elementary-step-export",
+ "payload_contract": "chemistry/elementary-step",
+ "payload_version": 2,
+ "requirements": []
+ }
+ ]
+}
diff --git a/contracts/machine-observation/schemas/machine-observation-v1.schema.json b/contracts/machine-observation/schemas/machine-observation-v1.schema.json
new file mode 100644
index 00000000..06619f51
--- /dev/null
+++ b/contracts/machine-observation/schemas/machine-observation-v1.schema.json
@@ -0,0 +1,516 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://factory.local/schemas/machine-observation-v1.schema.json",
+ "title": "Factory Machine Observation v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "contract",
+ "producer",
+ "operation",
+ "lifecycle",
+ "handoff",
+ "delivery",
+ "artifacts",
+ "lineage",
+ "payload"
+ ],
+ "properties": {
+ "contract": {
+ "$ref": "#/$defs/envelopeContract"
+ },
+ "producer": {
+ "$ref": "#/$defs/producer"
+ },
+ "operation": {
+ "$ref": "#/$defs/operation"
+ },
+ "lifecycle": {
+ "$ref": "#/$defs/lifecycle"
+ },
+ "handoff": {
+ "$ref": "#/$defs/handoff"
+ },
+ "delivery": {
+ "$ref": "#/$defs/delivery"
+ },
+ "artifacts": {
+ "type": "object",
+ "propertyNames": {
+ "$ref": "#/$defs/id"
+ },
+ "additionalProperties": {
+ "$ref": "#/$defs/artifactReceipt"
+ }
+ },
+ "lineage": {
+ "$ref": "#/$defs/lineage"
+ },
+ "payload": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/$defs/payload"
+ }
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "lifecycle": {
+ "properties": {
+ "phase": {
+ "enum": [
+ "queued",
+ "running"
+ ]
+ }
+ },
+ "required": [
+ "phase"
+ ]
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "lifecycle": {
+ "properties": {
+ "outcome": {
+ "const": "pending"
+ }
+ }
+ },
+ "handoff": {
+ "properties": {
+ "status": {
+ "const": "pending"
+ }
+ }
+ },
+ "delivery": {
+ "properties": {
+ "status": {
+ "const": "pending"
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "lifecycle": {
+ "properties": {
+ "phase": {
+ "const": "finished"
+ }
+ },
+ "required": [
+ "phase"
+ ]
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "lifecycle": {
+ "properties": {
+ "outcome": {
+ "enum": [
+ "succeeded",
+ "failed",
+ "cancelled",
+ "uncertain"
+ ]
+ }
+ }
+ },
+ "handoff": {
+ "properties": {
+ "status": {
+ "enum": [
+ "ready",
+ "blocked",
+ "not_applicable"
+ ]
+ }
+ }
+ },
+ "delivery": {
+ "properties": {
+ "status": {
+ "enum": [
+ "complete",
+ "incomplete",
+ "not_applicable"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "handoff": {
+ "properties": {
+ "status": {
+ "const": "ready"
+ }
+ },
+ "required": [
+ "status"
+ ]
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "lifecycle": {
+ "properties": {
+ "phase": {
+ "const": "finished"
+ },
+ "outcome": {
+ "const": "succeeded"
+ }
+ }
+ },
+ "delivery": {
+ "properties": {
+ "status": {
+ "const": "complete"
+ }
+ }
+ },
+ "payload": {
+ "$ref": "#/$defs/payload"
+ }
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200,
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
+ },
+ "code": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 200,
+ "pattern": "^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$"
+ },
+ "codes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/code"
+ },
+ "uniqueItems": true
+ },
+ "envelopeContract": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "const": "factory/machine-observation"
+ },
+ "version": {
+ "const": 1
+ }
+ }
+ },
+ "producer": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/id"
+ },
+ "version": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ }
+ }
+ },
+ "operation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "kind"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "kind": {
+ "$ref": "#/$defs/id"
+ }
+ }
+ },
+ "lifecycle": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "phase",
+ "outcome",
+ "codes"
+ ],
+ "properties": {
+ "phase": {
+ "enum": [
+ "queued",
+ "running",
+ "finished"
+ ]
+ },
+ "outcome": {
+ "enum": [
+ "pending",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "uncertain"
+ ]
+ },
+ "codes": {
+ "$ref": "#/$defs/codes"
+ }
+ }
+ },
+ "handoff": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "status",
+ "codes"
+ ],
+ "properties": {
+ "status": {
+ "enum": [
+ "pending",
+ "ready",
+ "blocked",
+ "not_applicable"
+ ]
+ },
+ "codes": {
+ "$ref": "#/$defs/codes"
+ }
+ }
+ },
+ "delivery": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "status",
+ "codes"
+ ],
+ "properties": {
+ "status": {
+ "enum": [
+ "pending",
+ "complete",
+ "incomplete",
+ "not_applicable"
+ ]
+ },
+ "codes": {
+ "$ref": "#/$defs/codes"
+ }
+ }
+ },
+ "artifactReceipt": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "status",
+ "required",
+ "role",
+ "path",
+ "media_type",
+ "bytes",
+ "byte_sha256"
+ ],
+ "properties": {
+ "status": {
+ "enum": [
+ "pending",
+ "available",
+ "missing",
+ "invalid"
+ ]
+ },
+ "required": {
+ "type": "boolean"
+ },
+ "role": {
+ "$ref": "#/$defs/id"
+ },
+ "path": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1000,
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
+ }
+ ]
+ },
+ "media_type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bytes": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "byte_sha256": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ }
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "status": {
+ "const": "available"
+ }
+ },
+ "required": [
+ "status"
+ ]
+ },
+ "then": {
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "media_type": {
+ "type": "string",
+ "minLength": 1
+ },
+ "bytes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "byte_sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "lineage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "trace_id",
+ "upstream"
+ ],
+ "properties": {
+ "trace_id": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/$defs/id"
+ }
+ ]
+ },
+ "upstream": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "producer",
+ "operation_id",
+ "byte_sha256"
+ ],
+ "properties": {
+ "producer": {
+ "$ref": "#/$defs/producer"
+ },
+ "operation_id": {
+ "$ref": "#/$defs/id"
+ },
+ "byte_sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ }
+ }
+ }
+ }
+ }
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "contract",
+ "data"
+ ],
+ "properties": {
+ "contract": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/id"
+ },
+ "version": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "data": {
+ "type": "object"
+ }
+ }
+ }
+ }
+}
diff --git a/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v1.schema.json b/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v1.schema.json
new file mode 100644
index 00000000..d59807f4
--- /dev/null
+++ b/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v1.schema.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://factory.local/schemas/payloads/chemistry-elementary-step-v1.schema.json",
+ "title": "Chemistry Elementary Step Payload v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "step_id",
+ "source",
+ "reactant",
+ "product",
+ "atom_correspondence",
+ "bond_changes",
+ "mapping_validation",
+ "endpoint_pair",
+ "geometry_scope"
+ ],
+ "properties": {
+ "step_id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "source": {
+ "type": "object"
+ },
+ "reactant": {
+ "type": "object"
+ },
+ "product": {
+ "type": "object"
+ },
+ "atom_correspondence": {
+ "type": "object"
+ },
+ "bond_changes": {
+ "type": "object"
+ },
+ "mapping_validation": {
+ "type": "string",
+ "minLength": 1
+ },
+ "endpoint_pair": {
+ "type": [
+ "object",
+ "null"
+ ]
+ },
+ "geometry_scope": {
+ "type": "object"
+ }
+ }
+}
diff --git a/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v2.schema.json b/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v2.schema.json
new file mode 100644
index 00000000..57c6341b
--- /dev/null
+++ b/contracts/machine-observation/schemas/payloads/chemistry-elementary-step-v2.schema.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://factory.local/schemas/payloads/chemistry-elementary-step-v2.schema.json",
+ "title": "Chemistry Elementary Step Payload v2",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "step_id",
+ "source",
+ "reactant",
+ "product",
+ "atom_correspondence",
+ "bond_changes",
+ "mapping_validation",
+ "endpoint_geometry",
+ "geometry_scope"
+ ],
+ "properties": {
+ "step_id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "source": {
+ "type": "object"
+ },
+ "reactant": {
+ "type": "object"
+ },
+ "product": {
+ "type": "object"
+ },
+ "atom_correspondence": {
+ "type": "object"
+ },
+ "bond_changes": {
+ "type": "object"
+ },
+ "mapping_validation": {
+ "type": "string",
+ "minLength": 1
+ },
+ "endpoint_geometry": {
+ "type": [
+ "object",
+ "null"
+ ]
+ },
+ "geometry_scope": {
+ "type": "object"
+ }
+ }
+}
diff --git a/contracts/machine-observation/scripts/validate.py b/contracts/machine-observation/scripts/validate.py
new file mode 100644
index 00000000..8b764924
--- /dev/null
+++ b/contracts/machine-observation/scripts/validate.py
@@ -0,0 +1,467 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+from jsonschema import Draft202012Validator
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class ContractError(ValueError):
+ def __init__(self, *args: object, hint: str | None = None) -> None:
+ super().__init__(*args)
+ self.hint = hint
+
+
+REQUIREMENT_OPERATORS = frozenset({"equals", "not_null"})
+
+
+def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise ContractError(f"duplicate JSON key: {key}")
+ result[key] = value
+ return result
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ payload = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_object)
+ if not isinstance(payload, dict):
+ raise ContractError(f"{path}: expected a JSON object")
+ return payload
+
+
+def _registry() -> dict[str, Any]:
+ return _load_json(ROOT / "registry.json")
+
+
+def _payload_registry(registry: dict[str, Any]) -> dict[tuple[str, int], dict[str, Any]]:
+ return {
+ (str(item["name"]), int(item["version"])): item for item in registry["payload_contracts"]
+ }
+
+
+def _validate_schema(instance: dict[str, Any], schema_path: Path) -> None:
+ schema = _load_json(schema_path)
+ errors = sorted(
+ Draft202012Validator(schema).iter_errors(instance),
+ key=lambda error: tuple(str(part) for part in error.absolute_path),
+ )
+ if not errors:
+ return
+ first = errors[0]
+ location = "/".join(str(part) for part in first.absolute_path) or ""
+ raise ContractError(f"{schema_path.name}:{location}: {first.message}")
+
+
+def _value_at(data: Any, path: list[str], *, location: str) -> Any:
+ current = data
+ for part in path:
+ if not isinstance(current, dict) or part not in current:
+ joined = "/".join(path)
+ raise ContractError(f"{location} references missing payload path: {joined}")
+ current = current[part]
+ return current
+
+
+def _values_at(data: Any, path: list[str], *, location: str) -> list[Any]:
+ if not path:
+ return [data]
+ part, *remaining = path
+ if part == "*":
+ if not isinstance(data, list):
+ raise ContractError(f"{location} wildcard expects an array")
+ values: list[Any] = []
+ for item in data:
+ values.extend(_values_at(item, remaining, location=location))
+ return values
+ if not isinstance(data, dict) or part not in data:
+ joined = "/".join(path)
+ raise ContractError(f"{location} references missing payload path: {joined}")
+ return _values_at(data[part], remaining, location=location)
+
+
+def _check_requirements(
+ data: dict[str, Any], requirements: list[dict[str, Any]], *, location: str
+) -> None:
+ for requirement in requirements:
+ path = [str(part) for part in requirement["path"]]
+ value = _value_at(data, path, location=location)
+ operator = requirement["operator"]
+ joined = "/".join(path)
+ if operator == "equals":
+ if value != requirement.get("value"):
+ raise ContractError(f"{location} requires {joined}={requirement.get('value')!r}")
+ elif operator == "not_null":
+ if value is None:
+ raise ContractError(f"{location} requires non-null {joined}")
+ else:
+ raise ContractError(f"{location} uses an unimplemented operator: {operator}")
+
+
+def _reference_values(
+ data: dict[str, Any], descriptor: dict[str, Any], *, location: str
+) -> set[str]:
+ values = _values_at(
+ data,
+ [str(part) for part in descriptor["path"]],
+ location=location,
+ )
+ references: list[Any] = []
+ if descriptor["many"]:
+ for value in values:
+ if not isinstance(value, list):
+ raise ContractError(f"{location} expects an artifact id array")
+ references.extend(value)
+ else:
+ references.extend(values)
+ if any(not isinstance(item, str) or not item for item in references):
+ raise ContractError(f"{location} contains an invalid artifact id")
+ return set(references)
+
+
+def _validate_artifact_references(
+ data: dict[str, Any], payload_entry: dict[str, Any], artifacts: dict[str, Any]
+) -> None:
+ contract_name = str(payload_entry["name"])
+ reference_contract = payload_entry.get("artifact_references")
+ if reference_contract is None:
+ return
+ declared = _reference_values(
+ data,
+ reference_contract["declared"],
+ location=f"{contract_name}.artifact_references.declared",
+ )
+ missing = sorted(declared - set(artifacts))
+ if missing:
+ raise ContractError(f"payload references unknown artifacts: {', '.join(missing)}")
+ nested: set[str] = set()
+ for descriptor in reference_contract["nested"]:
+ nested.update(
+ _reference_values(
+ data,
+ descriptor,
+ location=f"{contract_name}.artifact_references.nested",
+ )
+ )
+ unlisted = sorted(nested - declared)
+ if unlisted:
+ raise ContractError(
+ f"payload contains unlisted nested artifact refs: {', '.join(unlisted)}"
+ )
+
+
+def _validate_semantics(document: dict[str, Any]) -> None:
+ lifecycle = document["lifecycle"]
+ handoff = document["handoff"]
+ delivery = document["delivery"]
+ artifacts = document["artifacts"]
+ payload = document["payload"]
+
+ if lifecycle["phase"] == "finished" and handoff["status"] == "pending":
+ raise ContractError("finished observations cannot have pending handoff")
+ if lifecycle["phase"] == "finished" and delivery["status"] == "pending":
+ raise ContractError("finished observations cannot have pending delivery")
+ if lifecycle["outcome"] != "succeeded" and handoff["status"] == "ready":
+ raise ContractError("only succeeded observations can be ready")
+
+ required_statuses = [receipt["status"] for receipt in artifacts.values() if receipt["required"]]
+ if delivery["status"] == "complete" and any(
+ status != "available" for status in required_statuses
+ ):
+ raise ContractError("complete delivery contains an unavailable required artifact")
+ if delivery["status"] == "incomplete" and not any(
+ status != "available" for status in required_statuses
+ ):
+ raise ContractError("incomplete delivery needs an unavailable required artifact")
+
+ upstream_keys = [
+ (item["producer"]["name"], item["operation_id"], item["byte_sha256"])
+ for item in document["lineage"]["upstream"]
+ ]
+ if len(upstream_keys) != len(set(upstream_keys)):
+ raise ContractError("lineage contains a duplicate upstream envelope")
+
+ registry = _registry()
+ producer_operation = (
+ document["producer"]["name"],
+ document["operation"]["kind"],
+ )
+ matching_routes = [
+ route
+ for route in registry["routes"]
+ if (route["producer"], route["operation_kind"]) == producer_operation
+ ]
+ if not matching_routes:
+ raise ContractError("producer and operation route is not registered")
+
+ if payload is None:
+ if handoff["status"] == "ready":
+ raise ContractError("ready handoff needs a payload")
+ return
+
+ contract = payload["contract"]
+ payload_key = (contract["name"], contract["version"])
+ payload_entry = _payload_registry(registry).get(payload_key)
+ if payload_entry is None:
+ raise ContractError("payload contract is not registered")
+ route = next(
+ (
+ item
+ for item in matching_routes
+ if (item["payload_contract"], item["payload_version"]) == payload_key
+ ),
+ None,
+ )
+ if route is None:
+ raise ContractError("producer, operation, and payload route is not registered")
+
+ payload_schema = ROOT / str(payload_entry["schema"])
+ _validate_schema(payload["data"], payload_schema)
+ _check_requirements(
+ payload["data"],
+ route["requirements"],
+ location="route",
+ )
+ _validate_artifact_references(payload["data"], payload_entry, artifacts)
+ if handoff["status"] == "ready":
+ _check_requirements(
+ payload["data"],
+ payload_entry["ready_requirements"],
+ location=f"ready {contract['name']}",
+ )
+
+
+def validate_document(document: dict[str, Any]) -> None:
+ _validate_schema(document, ROOT / "schemas" / "machine-observation-v1.schema.json")
+ _validate_semantics(document)
+
+
+def validate_path(path: str | Path) -> None:
+ _validated_path(path, machine=False)
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _artifact_path(root: Path, raw: str) -> Path:
+ relative = PurePosixPath(raw)
+ target = (root / Path(*relative.parts)).resolve()
+ try:
+ if os.path.commonpath((str(root), str(target))) != str(root):
+ raise ContractError(
+ f"artifact path escapes the generation: {raw}",
+ hint="Do not consume this package; obtain artifacts contained in the generation.",
+ )
+ except ValueError as exc:
+ raise ContractError(
+ f"artifact path is on another volume: {raw}",
+ hint="Do not consume this package; obtain artifacts contained in the generation.",
+ ) from exc
+ return target
+
+
+def _validated_path(path: str | Path, *, machine: bool) -> dict[str, Any]:
+ candidate = Path(path).resolve() if machine else Path(path)
+ if machine and candidate.name != "machine.json":
+ raise ContractError(
+ "public machine metadata basename must be machine.json",
+ hint="Pass the actual generation/machine.json, not a renamed envelope or fixture.",
+ )
+ document = _load_json(candidate)
+ validate_document(document)
+ if not machine:
+ return document
+ root = candidate.parent
+ for artifact_id, receipt in document["artifacts"].items():
+ if receipt["status"] != "available":
+ continue
+ target = _artifact_path(root, receipt["path"])
+ if not target.is_file():
+ raise ContractError(
+ f"artifact file is missing: {artifact_id}",
+ hint="Obtain the intact generation with its declared artifacts; preserve receipts.",
+ )
+ if target.stat().st_size != receipt["bytes"]:
+ raise ContractError(
+ f"artifact byte count mismatch: {artifact_id}",
+ hint="Do not consume these bytes or rewrite receipts; obtain an intact generation.",
+ )
+ if _sha256(target) != receipt["byte_sha256"]:
+ raise ContractError(
+ f"artifact sha256 mismatch: {artifact_id}",
+ hint="Do not consume these bytes or rewrite receipts; obtain an intact generation.",
+ )
+ return document
+
+
+def validate_machine_path(path: str | Path) -> None:
+ _validated_path(path, machine=True)
+
+
+def _error_hint(error: Exception) -> str:
+ if isinstance(error, ContractError):
+ return error.hint or (
+ "Inspect the reported rule against the pinned schemas/registry; "
+ "do not change observed statuses just to pass."
+ )
+ if isinstance(error, json.JSONDecodeError):
+ return "Check JSON syntax at the reported location in the input or validator resource."
+ if isinstance(error, UnicodeError):
+ return "Check that the reported input or validator resource is UTF-8 encoded."
+ return (
+ "Check the reported path, availability and permissions, "
+ "including validator schema/registry resources."
+ )
+
+
+def _validation_result(path: Path, *, machine: bool) -> tuple[dict[str, Any], str | None]:
+ result: dict[str, Any] = {
+ "path": str(path),
+ "valid": False,
+ "observation": None,
+ "error": None,
+ }
+ try:
+ document = _validated_path(path, machine=machine)
+ except (ContractError, json.JSONDecodeError, UnicodeError, OSError) as exc:
+ if isinstance(exc, ContractError):
+ code = "contract_error"
+ elif isinstance(exc, json.JSONDecodeError):
+ code = "json_error"
+ elif isinstance(exc, UnicodeError):
+ code = "encoding_error"
+ else:
+ code = "io_error"
+ result["error"] = {"code": code, "message": str(exc)}
+ return result, _error_hint(exc)
+
+ result["valid"] = True
+ result["observation"] = {
+ key: document[key]
+ for key in ("contract", "producer", "operation", "lifecycle", "handoff", "delivery")
+ }
+ payload = document["payload"]
+ result["observation"]["payload_contract"] = None if payload is None else payload["contract"]
+ return result, None
+
+
+def _display(value: object) -> str:
+ """Keep untrusted fields on one terminal line, including accepted lone surrogates."""
+ return "".join(char if char.isprintable() else ascii(char)[1:-1] for char in str(value))
+
+
+def _handoff_hint(status: str, *, machine: bool) -> str:
+ if status == "blocked":
+ return "Do not continue downstream; inspect the producer's handoff codes and evidence."
+ if status == "pending":
+ return "Wait for a terminal observation; do not consume a pending handoff."
+ if status == "not_applicable":
+ return "No downstream handoff is declared."
+ if not machine:
+ return "Artifact files are unverified; check the actual generation with --machine."
+ return (
+ "Readiness is declared; still check the expected producer/operation/payload, "
+ "product acceptance and execution authorization."
+ )
+
+
+def _print_text_result(result: dict[str, Any], hint: str | None, *, machine: bool) -> None:
+ print(f"[{'VALID' if result['valid'] else 'FAIL'}] {_display(result['path'])}")
+ observation = result["observation"]
+ if observation is None:
+ error = result["error"]
+ print(f" Error: {error['code']}: {_display(error['message'])}")
+ print(f" Next: {_display(hint)}")
+ return
+ producer = observation["producer"]
+ operation = observation["operation"]
+ payload = observation["payload_contract"]
+ print(f" Producer: {_display(producer['name'])} {_display(producer['version'])}")
+ print(f" Operation: {_display(operation['kind'])} ({_display(operation['id'])})")
+ payload_text = "none" if payload is None else f"{payload['name']} v{payload['version']}"
+ print(f" Payload: {_display(payload_text)}")
+ for axis in ("lifecycle", "delivery", "handoff"):
+ state = observation[axis]
+ value = (
+ f"{state['phase']} / {state['outcome'] or 'none'}"
+ if axis == "lifecycle"
+ else state["status"]
+ )
+ print(f" {axis.capitalize()}: {_display(value)}")
+ if state["codes"]:
+ print(f" {axis.capitalize()} codes: {_display(', '.join(state['codes']))}")
+ print(f" Next: {_handoff_hint(observation['handoff']['status'], machine=machine)}")
+
+
+def main(argv: list[str] | None = None) -> int:
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description="Validate explicit machine observation paths without launching downstream work.",
+ epilog=(
+ "Default: readable results for every input; artifact files are NOT checked.\n"
+ "Use --machine for package checks and --json for automation.\n"
+ "Exit codes: 0 = all valid; 1 = validation/read failure; 2 = argument error.\n"
+ "Validation is not execution success or authorization for downstream work.\n\n"
+ "Example: python3 scripts/validate.py --machine path/to/generation/machine.json"
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--machine",
+ action="store_true",
+ help="require machine.json basename and verify available artifact bytes",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="report every input in one JSON document; valid does not imply ready handoff",
+ )
+ parser.add_argument(
+ "paths", nargs="+", type=Path, help="explicit input paths, checked in order"
+ )
+ args = parser.parse_args(argv)
+ checked = [_validation_result(candidate, machine=args.machine) for candidate in args.paths]
+ results = [result for result, _ in checked]
+ valid = all(result["valid"] for result in results)
+ if args.json:
+ print(
+ json.dumps(
+ {
+ "report_version": 1,
+ "scope": "package" if args.machine else "envelope",
+ "valid": valid,
+ "results": results,
+ },
+ sort_keys=True,
+ )
+ )
+ return 0 if valid else 1
+ scope = (
+ "package (available artifact files checked for valid inputs)"
+ if args.machine
+ else "envelope (artifact files NOT checked)"
+ )
+ print(f"Validation scope: {scope}")
+ for result, hint in checked:
+ print()
+ _print_text_result(result, hint, machine=args.machine)
+ count = sum(result["valid"] for result in results)
+ print(f"\nSummary: {count} valid, {len(results) - count} failed ({len(results)} inputs).")
+ print("Validation is not execution success or authorization for downstream work.")
+ return 0 if valid else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/AGENT_CLI.ko.md b/docs/AGENT_CLI.ko.md
index b54c51b7..caa58e47 100644
--- a/docs/AGENT_CLI.ko.md
+++ b/docs/AGENT_CLI.ko.md
@@ -132,7 +132,7 @@ chemvas insert-template scheme.chemvas --request ring.json --output ring-added.c
## 헤드리스 문서 렌더링
-GUI 창을 실행하지 않고 터미널에서 고해상도 SVG, PDF, PNG를 바로 생성합니다:
+GUI 창을 실행하지 않고 터미널에서 고해상도 SVG, PDF, PNG 또는 실험적 CDXML을 바로 생성합니다:
```bash
chemvas render-document scheme.chemvas --output scheme.svg
diff --git a/docs/AGENT_CLI.md b/docs/AGENT_CLI.md
index 0159599f..9c9fcfc8 100644
--- a/docs/AGENT_CLI.md
+++ b/docs/AGENT_CLI.md
@@ -132,7 +132,7 @@ Supported styles: `regular` (sizes 3–12), `benzene`, `chair`, `chair_flip`, an
## Headless document rendering
-Render publication-ready figures directly to SVG, PDF, or PNG without launching the desktop GUI:
+Render publication-ready figures directly to SVG, PDF, PNG, or experimental CDXML without launching the desktop GUI:
```bash
chemvas render-document scheme.chemvas --output scheme.svg
diff --git a/docs/ARCHITECTURE.ko.md b/docs/ARCHITECTURE.ko.md
index ec249345..3dbab789 100644
--- a/docs/ARCHITECTURE.ko.md
+++ b/docs/ARCHITECTURE.ko.md
@@ -1,5 +1,12 @@
# 아키텍처
+브라우저 어댑터는 [사용 안내](WEB_ADAPTER.ko.md)와
+[ADR 0029](adr/0029-browser-adapter.md)를 따른다. Python 세션은
+기존 문서·기능 API와 CanvasHistoryService를 재사용한다. 브라우저는 승인된
+상태를 표시하고 UI 정의·그림은 기존 소유자에서 읽는다. SVG 표시와 이벤트 연결은
+각각 응집된 연결 코드로 유지한다.
+아래 Qt 구조는 기존 기본 데스크톱 편집기에 해당한다.
+
[English](ARCHITECTURE.md)
## 패키지별 책임
@@ -280,7 +287,7 @@ flowchart LR
- **지원되는 작용기 약어**: `ATOM_ALIAS_DEFINITIONS`에 정의된 정규 별칭:
`Me`, `Et`, `OH`, `NH2`, `SH`, `Ph`, `PPh3`, `OMe`, `Boc`, `CO2Me`, `t-Bu`, `tBu`, `i-Pr`, `CF3`, `OTs`, `Ts`, `OMs`, `Ms`, `OTf`, `Tf`, `Ns`, `OAc`, `Ac`.
- **입체화학**: 쐐기/해시 결합은 단일 결합에만 적용됩니다.
-- **형식 호환성**: Chemvas는 문서 버전 7 및 8을 지원하며, 저장 시 버전 8(스키마 1)로 기록합니다.
+- **형식 호환성**: Chemvas는 문서 버전 7, 8, 9를 읽으며, 저장 시 버전 9(스키마 1)로 기록합니다.
## 아키텍처 결정 기록 (ADR)
@@ -329,6 +336,16 @@ Qt-free record 변환을 사용하며, History의 상태 코덱은 UI 경계에
문서 교체 스냅샷은 테스트 전용 대체 장면 대신 실제 Qt 장면 계약을 사용합니다.
계산 handoff는 별도의 가변 오류 슬롯 대신 호출별 `RDKitResult`를 받습니다.
+브라우저 화학 클립보드는 기존 선택 페이로드 빌더와 붙여넣기 계획기를 재사용하여
+별도의 화학 스키마 없이 원자 ID 재매핑, 그룹 및 주석 변환을 보존합니다. 브라우저
+자동 복구 드래프트는 Qt 복구와 분리되어 `bootstrap/web_drafts.py`가 단일 서버
+프로세스 잠금과 봉투 저장을 소유합니다
+([ADR 0032](adr/0032-browser-clipboard-and-drafts.md)).
+
- [ADR 0025: Shared atom position mutation](adr/0025-shared-atom-position-mutation.md)
- [ADR 0026: Endpoint selection draft](adr/0026-endpoint-selection-draft.md)
- [ADR 0027: Record-based annotation transforms](adr/0027-record-based-annotation-transforms.md)
+- [ADR 0028: 설정 가능한 용지 크기](adr/0028-configurable-paper-dimensions.md)
+- [ADR 0029: 기존 편집 소유자 기반의 브라우저 화면 어댑터](adr/0029-browser-adapter.md)
+- [ADR 0031: 독립 계약 검증](adr/0031-standalone-contract-validation.md)
+- [ADR 0032: 브라우저 화학 클립보드와 복구 드래프트](adr/0032-browser-clipboard-and-drafts.md)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2faf8694..5caeae91 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,5 +1,11 @@
# Architecture
+The browser presentation adapter is described in [WEB_ADAPTER.md](WEB_ADAPTER.md)
+and [ADR 0029](adr/0029-browser-adapter.md). Its Python session
+reuses existing document and feature APIs and CanvasHistoryService. The browser
+mirrors accepted state; toolbar definitions and artwork come from the existing
+desktop owners. SVG materialization and event wiring are cohesive adapters. The Qt architecture below remains the default desktop editor.
+
[한국어](ARCHITECTURE.ko.md)
## Package Responsibilities
@@ -301,7 +307,7 @@ while explicit cleared mappings and inactive entries retain their existing meani
- **Supported Aliases**: Canonical aliases defined in `ATOM_ALIAS_DEFINITIONS`:
`Me`, `Et`, `OH`, `NH2`, `SH`, `Ph`, `PPh3`, `OMe`, `Boc`, `CO2Me`, `t-Bu`, `tBu`, `i-Pr`, `CF3`, `OTs`, `Ts`, `OMs`, `Ms`, `OTf`, `Tf`, `Ns`, `OAc`, and `Ac`.
- **Stereochemistry**: Wedge and hash stereochemistry maps only to single bonds.
-- **Format Compatibility**: Chemvas reads document versions 7 and 8, and writes version 8 (schema 1).
+- **Format Compatibility**: Chemvas reads document versions 7, 8, and 9, and writes version 9 (schema 1).
## Architecture Decision Records (ADR)
@@ -352,6 +358,16 @@ Qt scene contract rather than alternate test-only scene representations. Calcula
handoff consumes the existing `RDKitResult` per call instead of reading a separate
mutable error slot.
+Browser chemical clipboard operations reuse the canonical selection payload builder
+and paste planner, preserving atom ID remapping, groups and annotation transforms
+without a parallel chemistry schema. Automatic browser drafts are managed
+independently of Qt recovery by `bootstrap/web_drafts.py`, using single-server
+process locking and envelope storage ([ADR 0032](adr/0032-browser-clipboard-and-drafts.md)).
+
- [ADR 0025: Shared atom position mutation](adr/0025-shared-atom-position-mutation.md)
- [ADR 0026: Endpoint selection draft](adr/0026-endpoint-selection-draft.md)
- [ADR 0027: Record-based annotation transforms](adr/0027-record-based-annotation-transforms.md)
+- [ADR 0028: Configurable paper dimensions](adr/0028-configurable-paper-dimensions.md)
+- [ADR 0029: Browser presentation adapter over existing editing owners](adr/0029-browser-adapter.md)
+- [ADR 0031: Standalone contract validation](adr/0031-standalone-contract-validation.md)
+- [ADR 0032: Browser chemical clipboard and recovery drafts](adr/0032-browser-clipboard-and-drafts.md)
diff --git a/docs/CDXML_EXPORT.ko.md b/docs/CDXML_EXPORT.ko.md
new file mode 100644
index 00000000..8f234ed1
--- /dev/null
+++ b/docs/CDXML_EXPORT.ko.md
@@ -0,0 +1,70 @@
+# CDXML 내보내기
+
+[English](CDXML_EXPORT.md)
+
+Chemvas는 파일 > 그림 내보내기(`.cdxml` 형식)와 CLI
+`render-document --output file.cdxml`로 편집 가능한 CDXML(ChemDraw XML)을
+내보냅니다.
+
+**이 출력은 ChemDraw에서 검증되지 않았습니다.** CDXML 가져오기는 지원하지
+않으며, `.chemvas` 형식이 문서 원본입니다.
+
+## 지원
+
+| 영역 | 출력 내용 |
+|--------|----------------------------------------------------------|
+| 원자 | 순수 원소(주기율표), 색상, 명시적 C, Charge 주석 |
+| 결합 | single/1, double_center/2, triple/3 |
+| 직선 | 머리 없는 실선, 끝점 순서 BoundingBox |
+| 화살표 | 그룹 선 구성요소(축 + 머리), M/L 경로 명령만 |
+| 도형 | rect, circle; 선만, 채움만, 또는 같은 색 |
+| 노트 | 줄별 ``, 실행별 실제 폰트 패밀리, 크기, 굵기, 색상 |
+
+## 거부 (파일 작성 전 오류 발생)
+
+| 영역 | 거부 내용 |
+|--------|----------------------------------------------------------|
+| 원자 | 별명(Ac/Ts 포함), 라디칼, 장면 마크; 숨겨진 원자는 제외되고 해당 결합은 거부됨 |
+| 결합 | 쐐기, 해시, 굵은, 점선, double_either, 고리 옆; 스타일/순서 불일치 |
+| 직선 | 파선, 물결, 굵은; 미러 |
+| 화살표 | 곡선, 이중, 기타 종류, 라벨, 미러 |
+| 도형 | 타원, 둥근 사각형, 파선/점선 획, 반투명 채움, 혼합 색상 |
+| 노트 | 이탤릭, 밑줄(대시/점/물결 포함 모든 스타일), 윗줄, 취소선, 아래/위 첨자, 대소문자 변환(대문자, 작은 대문자), 사용자 지정 단어 간격, 기본값 외 폰트 확장, 목록, 표, 회전, 400/700 이외 두께, 왼쪽 정렬 아닌 경우, 상자/테두리, 글리프 대체 폰트(CJK/이모지 포함) |
+| 겹침 | 프래그먼트 레이어 교차, 겹치는 교차 프래그먼트. 기본 z값에서 바운딩 렉트가 결합된 분자의 항목에 닿는 노트·직선·화살표는 거부되며, 항목 경계가 닿는 두 결합 분자(픽 헤일로 포함)도 거부됨 |
+| 기타 | 고리 채움, 괄호, 이미지, 오비탈, 원근법, 그룹, 빈 페이지 |
+
+## 축척
+
+하나의 균일 축척 `s = out_w_pt / source_w`가 모든 좌표, 폰트 크기, 선 너비,
+기하에 적용됩니다. `--width-mm`을 지정하지 않으면 CLI는 결합 길이 기준으로
+크기를 정합니다(고정 페이지 너비 없음). GUI 사용자 지정 너비 기본값은
+84 mm입니다. 170 mm 페이지는 `--width-mm 170`으로 지정합니다.
+수치 정밀도는 유효 숫자 12자리입니다.
+
+## 폰트 처리
+
+폰트 패밀리는 요청된 이름이 아닌 실제 해석된 폰트(`QFontInfo`)입니다.
+텍스트 실행의 글리프가 다른 폰트로 대체되면(`QTextLayout.glyphRuns()`로 감지)
+대체 폰트와 기본 폰트를 명시하는 오류와 함께 내보내기를 거부합니다.
+
+## GUI 및 CLI
+
+- **GUI**: 파일 > 그림 내보내기 > CDXML 형식. 대화상자 라벨:
+ "CDXML - ChemDraw XML (not verified in ChemDraw)".
+- **CLI**: `chemvas render-document doc.chemvas --output out.cdxml`
+ (`--width-mm`, `--background`도 사용 가능). `--min-font-pt`는
+ CDXML 출력에서 지원하지 않습니다.
+
+## 원본 형식
+
+`.chemvas` 원본 문서는 내보내기로 수정되지 않습니다. CDXML 가져오기는
+거부되며, 왕복 변환은 없습니다.
+
+## 제한 사항
+
+- 그룹화된 화살표와 도형은 네이티브 ChemDraw 반응 도구가 아닌 CDXML
+ 그래픽으로 내보내집니다.
+- 소비자 상호운용성 검증은 수행되지 않았습니다.
+- DTD는 포함되지 않으며 DTD 유효성은 주장하지 않습니다.
+- 폰트 charset, U+2028 소프트 줄바꿈, 탭 문자, 둥근 끝/연결, 수소 추론,
+ `CaptionLineHeight`는 출력되지만 소비자 동작은 검증되지 않았습니다.
diff --git a/docs/CDXML_EXPORT.md b/docs/CDXML_EXPORT.md
new file mode 100644
index 00000000..cea04ea2
--- /dev/null
+++ b/docs/CDXML_EXPORT.md
@@ -0,0 +1,71 @@
+# CDXML Export
+
+[한국어](CDXML_EXPORT.ko.md)
+
+Chemvas exports editable CDXML (ChemDraw XML) through File > Export Figure
+(`.cdxml` format) and the CLI `render-document --output file.cdxml`.
+
+**This output is not verified in ChemDraw.** CDXML import is not supported;
+the `.chemvas` format remains the document truth.
+
+## Supported
+
+| Area | What is emitted |
+|--------|----------------------------------------------------------|
+| Atoms | Pure elements (periodic table), colour, explicit C, Charge annotation |
+| Bonds | single/1, double_center/2, triple/3 |
+| Lines | Headless solid lines, endpoint-order BoundingBox |
+| Arrows | Arrow as grouped line components (shaft + head), M/L path commands only |
+| Shapes | rect, circle; stroke-only, fill-only, or same colour |
+| Notes | Per-line `` with per-run resolved font family, size, bold face, colour |
+
+## Refused (with error before any file is written)
+
+| Area | What is refused |
+|----------|---------------------------------------------------------|
+| Atoms | Aliases (incl. Ac/Ts), radicals, scene marks; hidden atoms are excluded and bonds to them are refused |
+| Bonds | Wedge, hash, bold, dotted, double_either, ring-side; mismatched style/order |
+| Lines | Dashed, wavy, bold; mirrored |
+| Arrows | Curved, double, other kinds, labels, mirrored |
+| Shapes | Ellipse, rounded_rect, dashed/dotted stroke, translucent fill, mixed colours |
+| Notes | Italic, underline (all styles including dash/dot/wave), overline, strikeout, sub/superscript, non-mixed-case capitalization (uppercase, small caps), custom word spacing, non-default font stretch, lists, tables, rotation, non-400/700 weights, non-left alignment, boxes/borders, glyph fallback fonts (including CJK/emoji when the primary font cannot render them) |
+| Stacking | Fragment layer interleaving, overlapping interleaved fragments. Under default z-values, any note, line, or arrow whose bounding rect touches a bonded molecule's items is refused, as are two bonded molecules with touching item bounds (pick halos included) |
+| Other | Ring fills, brackets, images, orbitals, perspective, groups, empty pages |
+
+## Scale
+
+One uniform scale `s = out_w_pt / source_w` applies to all coordinates,
+font sizes, line widths, and geometry. When no `--width-mm` is given, the
+CLI sizes by bond length (no fixed page width); the GUI custom-width
+default is 84 mm. Use `--width-mm 170` for a 170 mm page.
+Numeric precision is 12 significant digits.
+
+## Font handling
+
+Font family is the actual resolved font (`QFontInfo`), not the requested
+name. If any glyph in a text run falls back to a different font (detected
+via `QTextLayout.glyphRuns()`), export refuses with an error naming the
+fallback font and the primary font.
+
+## GUI and CLI
+
+- **GUI**: File > Export Figure > CDXML format. The dialog label reads
+ "CDXML - ChemDraw XML (not verified in ChemDraw)".
+- **CLI**: `chemvas render-document doc.chemvas --output out.cdxml`
+ (also accepts `--width-mm`, `--background`). `--min-font-pt` is not
+ supported for CDXML output.
+
+## Source format
+
+The `.chemvas` source document is never modified by export. CDXML import
+is refused; there is no round-trip.
+
+## Limitations
+
+- Grouped arrows and shapes are exported as CDXML graphics, not as native
+ ChemDraw reaction tools.
+- No consumer interoperability verification has been performed.
+- The DTD is not included and DTD validity is not claimed.
+- Font charset, U+2028 soft breaks, tab characters, round caps/joins,
+ hydrogen inference, and `CaptionLineHeight` are emitted but their
+ consumer behaviour is unproved.
diff --git a/docs/WEB_ADAPTER.ko.md b/docs/WEB_ADAPTER.ko.md
new file mode 100644
index 00000000..afad4572
--- /dev/null
+++ b/docs/WEB_ADAPTER.ko.md
@@ -0,0 +1,420 @@
+# 브라우저 어댑터
+
+[English](WEB_ADAPTER.md)
+
+기존 Chemvas 편집기의 화면·입력을 브라우저 어댑터로 옮기는 작업이다.
+Qt 앱을 함께 유지하며, 전체 사용 흐름과 화면의 동등성을 목표로 개발 중이다.
+
+## 실행
+
+```bash
+python -m pip install -e .
+chemvas --ui web
+chemvas --ui qt
+```
+
+`make check` 이후 macOS/Linux는 `.venv/bin/chemvas`, Windows는
+`.venv/Scripts/chemvas.exe`를 사용한다. Python 3.12+가 필요하다.
+`chemvas --ui web --no-browser`는 실행 URL을 출력한다. 터미널을 유지하고
+Ctrl+C로 서버를 종료한다. URL의 세션 인증 정보는 로컬에서만 사용한다.
+프런트엔드 빌드·CDN·Node 런타임은 필요하지 않다. 브라우저 서버는 Qt를 import하지
+않으며, 통합 패키지는 데스크톱 앱을 위해 Qt를 계속 설치한다.
+File > Export Figure만 데스크톱의 Qt 그림 내보내기를 짧게 실행되는 별도 프로세스에서
+실행하므로 그 Qt 설치가 필요하다.
+
+## 연결된 기존 구현
+
+- 도구 순서·이름·툴팁·결합 옵션·상태 안내는 기존 `main_window_config`에서 읽는다.
+ SVG 아이콘, 색상, 버튼 크기와 ACS1996 그리기 기본값도 양쪽이 공유한다.
+ 옵션바는 Qt의 흰색 선택 박스·옅은 테두리·그룹 간격을 사용한다.
+- 결합 생성은 `StructureBondBuildService`와 `StructureBuildCommitter`가 실행한다.
+ 결합 입력은 장면 좌표를 보내고 기존 원자 우선 판정과 공통 반경을 쓴다.
+ 결합 위에 놓으면 기존 끝 원자 스냅 규칙을 적용하며 각도 간격은
+ `CanvasToolSettingsState`에서 읽는다. 기존 원자·결합 재사용은 원래 생성기가
+ 담당한다. 웹은 손을 놓을 때 확정하고 Qt는 결합을 누르는 즉시 스타일을 바꾼다.
+- 벤젠 삽입은 `StructureBenzeneBuildService`와 같은 커미터를 호출하고, Ring 페이지의
+ 다른 템플릿은 데스크톱의 템플릿 계획, `TemplateGeometryResolverService`,
+ `commit_template_ring`을 그대로 쓴다.
+ 클릭 대상은 Qt의 원자·결합 거리 우선순위와 삽입 허용 반경으로 판정하고,
+ 원자 선택 원도 기존 반경을 쓴다. 붙이기, 융합, 원자 합치기, 결합 차수와
+ 고리 기록을 그대로 사용한다. 라벨 클릭 영역은 측정한 글자 외곽 사각형이다.
+ 짧은 기호에만 기존 Qt 판정 함수를 통해 선택 원을 붙인다.
+ 글꼴 측정에 따른 작은 경계 차이는 남는다.
+ 삭제는 항목 수에 관계없이 같은 선택 삭제 요청으로 기존 삭제 계획을 실행한다.
+- 굵은 결합(Bold)은 기존 `BondGraphicsDrawService`의 다각형을 표시한다.
+ 이중·삼중 결합의 차수, 고리 방향과 인접 굵은 결합 접합 계산을 그대로 쓴다.
+- 점선 결합(Dotted)의 점 위치·반지름·접합부 간격은 기존
+ `BondLineGeometryService.dotted_bond_dots`를 사용한다. 이중 결합에 적용할 때도
+ 원래 차수와 안쪽/바깥쪽 규칙을 유지한다. SVG는 전달받은 원만 표시한다.
+- 원자 라벨의 방향·별칭 기준점·아래첨자·수소 세로 배치는 기존 함수를 쓴다.
+ 브라우저는 글자 폭과 줄 높이를 측정해 Python 배치 함수에 전달하고,
+ 계산된 글자 위치와 Qt의 96 DPI 규칙으로 환산한 정수 픽셀 크기로 표시한다.
+ 세션마다 크기를 제한한 글꼴 측정값과 글자 외곽선 한 세트를 보관한다.
+ 문서·Undo/Redo에는 넣지 않으며, 편집·미리보기·이력 복원 시 기존 배치 함수와
+ 결합 렌더러가 바로 사용한다. 이미 측정한 글자는 응답·렌더링 한 번으로 끝난다.
+ 새 글자가 나타날 때만 응답의 리비전에 맞춰 측정값을 보내며, 문서를 다시 보내거나
+ 확정된 편집을 재실행하지 않는다. 미리보기도 세션·리비전·편집 정보만 전송한다.
+ 잘못되거나 오래된 측정값은 문서·이력·기존 측정값을 바꾸지 않는다.
+ 측정 세트를 교체하면 이전 세트는 버린다. 볼록 외곽선·여백 반경·평행선 영역의
+ 교차 계산은 Qt와 공유하며 고정 간격 자르기와 흰색 라벨 덧칠은 제거했다.
+ 브라우저는 제한된 크기의 캔버스에서 최대 8배 해상도로 글자를 측정한다.
+ 절반 이상 덮인 픽셀로 윤곽을 잡고 글꼴 엔진의 실제 경계 안으로 제한해,
+ 흐릿한 외곽 픽셀이 글자 크기를 늘리지 않도록 한다.
+ 원형 여백은 64각형으로 근사한다. 글꼴 엔진과 측정 오차로 작은 차이는 남을 수
+ 있으며 모든 플랫폼의 픽셀이 동일하다는 의미는 아니다.
+- 저장된 전하·라디칼 표시는 원본 원자 귀속 좌표, 원·점 크기와 텍스트 배치를
+ 공유하여 웹에서도 표시한다. 사용자 지정 색과 일반 문구의 줄바꿈을 유지하며,
+ 글꼴 엔진의 반올림 차이는 남을 수 있다. 표시 생성·이동·귀속 변경·전하
+ 단축키는 데스크톱과 같은 서비스를 거친다.
+- 괄호와 대거 기호는 데스크톱의 획 명령과 글리프 배치를 쓰고, 경계는 데스크톱이
+ 채우는 외곽선을 따른다. 대거 경계는 브라우저가 측정한 글리프 잉크에서 얻는다.
+- SVG 결합 선은 기존 `BondGeometryPlanService`, `BondLineGeometryService`,
+ `BondRingDoubleGeometryService`의 결과를 표시한다. 고리 중심 선택도 공유한다.
+ 양쪽 모두 현재 문서의 고리 순서를 캐시된 그래프 고리보다 우선한다.
+- 화살표·선 경로는 기존 Qt 계산을 `line_geometry`로 옮겨 함께 쓴다.
+ Qt는 경로 명령을 `QPainterPath`로, 웹은 같은 명령을 SVG로 표시한다.
+ 19종의 굵기·점선 간격·끝 모양과 평형 반전·길이 차이·곡선 제어점을 유지한다.
+ 문서의 화살표 경로는 50만 점까지 처리한다.
+ 라벨 없는 화살표·선은 기존 이동 컨트롤러와 삭제 계획기로 선택·드래그·전체 선택·삭제·Undo/Redo를 처리한다. 화살표 생성은 기존 끝점 스냅·기본 곡선·Shift 원호 반전을 사용하고, 기존 아이콘과 More arrows 구성을 연결했다. 선 4종은 기존 끝점·Shift 규칙과 빈 곳 클릭 시 길이를 사용한다. 화살표 프리셋과 굵기·머리 크기 슬라이더도 기존 값과 제한을 공유한다.
+ 선택된 화살표를 다시 클릭하면 끝점 핸들을 여닫고, 곡선은 중앙 핸들도 표시한다.
+ 최소 길이·끝점 이동 시 곡선 유지·중앙점 제한은 기존 공용 기하 계산을 호출한다.
+ 다른 끝점에 스냅된 핸들은 채워지고, 핸들 크기는 배율과 무관하게 유지된다.
+ 미리보기는 마지막 유효 끝점을 유지하며, 놓을 때 한 번만 기록하고 Escape로 취소한다.
+ 화살표 라벨은 기존 구문·배치 계산을 사용하며 더블 클릭으로 위·아래 라벨을 편집한다.
+- Enter·Delete·원자/성장 단축키도 현재 라벨의 실제 클릭 영역을 먼저 확인한다.
+ 긴 라벨의 끝에서도 같은 원자를 찾고, 직접 닿지 않으면 기존 거리 판정을 쓴다.
+ 선택 강조 표시는 클릭 영역을 넓히지 않는다.
+- 원자 입력은 Qt의 호버 대상을 먼저 고르고, 이후 가까운 결합 끝점과 원자를
+ 찾는다. 라벨 직접 클릭의 우선순위도 유지하며 SVG 결합 ID로 원자를 고르지 않는다.
+- 원자 도구는 기존 `AtomLabelService`와 `AtomLabelMergeService`를 호출한다.
+ 탄소 표시, 겹친 원자와 중복 결합의 병합, 비탄소 기호 숨김 거부 규칙을 공유한다.
+ 입력창 속성과 클릭 대상·입력 계획도 Qt 소유자에서 가져온다.
+- 선택 항목 이동은 기존 `CanvasMoveController`를 호출한다. 결합 끝점은 기존
+ 선택 확장 함수로 중복 없이 계산하고, 고리 채움은 기존 polygon 갱신기로 맞춘다.
+ 드래그 미리보기와 확정 편집은 같은 서버 경로를 쓴다.
+- Undo/Redo는 기존 `CanvasHistoryService`와 `CanvasHistoryState`가 담당한다.
+ 서버가 기존 연산으로 임시 후보를 만들고 검증·기록한 뒤 승인된 상태를 전달한다.
+ 기록에는 문서만 저장하고 Undo/Redo 때 기존 렌더러로 화면을 다시 계산한다.
+ 재계산 실패 시 기존 이력 트랜잭션이 문서와 기록을 유지한다.
+ JavaScript는 승인된 상태를 표시한다. 응답이 끊기면 편집을 재전송하지 않고
+ 서버의 현재 문서를 읽는다. 읽기도 실패하면 다음 동작은 재연결만 수행하고,
+ 갱신된 문서를 확인한 뒤 다시 편집하도록 안내한다.
+
+A로 원자 도구를 선택한다. 상단에 기호를 입력한 뒤 클릭하면 해당 기호를 적용하고,
+입력창이 비어 있으면 기존 원자 기호를 담은 대화상자를 연다. 취소는 문서를 바꾸지 않는다.
+X로 결합을 그리고 J로 벤젠을 놓거나 기존 원자·결합에 붙인다.
+결합 위에서 1/2/3, b/w/h/d, Shift+B/H/D, l/c/r을 누르면 기존 Qt 결합 스타일 규칙을 적용한다.
+빈 곳에서는 이 키가 그리기 옵션을 바꾸지 않는다. 원자 위에서는 기존 라벨 문자와
+0–9/z/v/u 성장 단축키를 사용한다. 소문자 a는 원자에 벤젠을 붙이거나 결합에 융합한다.
+결합 위의 4–8은 일반 고리, 9/0은 두 방향의 의자형 고리를 융합한다.
+호버 동작을 먼저 처리하므로 A는 해당 구조가 키를 소비하지 않을 때만 원자 도구를 선택한다.
+X는 빈 곳에서 결합 도구를 Single로 되돌리고 원자 위에서는 X 라벨을 적용한다.
+전하(+/−)는 미연결 안내를 표시한다. 원자 위의 Enter는 현재 도구 기호와 무관하게
+기존 라벨 대화상자를 연다. 빈 입력을 확인하면 암시적 탄소로 되돌리고 취소는 문서와
+이력을 바꾸지 않는다. 결합 위나 빈 곳의 Enter는 아무것도 바꾸지 않는다.
+툴바를 마우스로 눌러도 Qt의 NoFocus처럼 기존 포커스를 유지한다.
+키보드로 버튼이나 메뉴 제목에 포커스를 주면 Enter·Space는 그 컨트롤을 실행한다.
+캔버스에서는 Space로 선택하고 드래그해 이동한다. Shift+클릭으로 선택을 추가·해제하고,
+⌘/Ctrl+A로 전체 구조를 선택한다. Delete는 선택 전체를 한 명령으로 삭제한다.
+선택이 없으면 Delete/Backspace는 결합된 원자의 보이는 라벨을 먼저 암시적 탄소로
+되돌리고, 그 외에는 포인터 아래 원자나 결합을 삭제한다. 고립된 라벨 원자는 즉시 삭제한다.
+지우개는 클릭한 항목을 삭제한다. Escape는 미리보기를 취소하고 Select로 돌아간다. 도구 전환·포커스 이탈도 미리보기를 취소한다. 결합을 클릭하면 선택한 스타일을
+적용하고, 결합 도구의 짧은 클릭은 기존 기본 방향으로 결합을 만든다.
+제스처 하나를 Undo 한 번으로 취소한다.
+일반 휠은 스크롤하고 macOS는 Cmd+휠, 다른 플랫폼은 Ctrl+휠로 포인터 중심 확대를 한다.
+브라우저의 Control·핀치 확대도 유지한다. 배율 제한(20–500%)과
+버튼 간격은 데스크톱 선언을 공유한다. 브라우저 픽셀 입력은 Qt의 각도/픽셀 비율로
+변환하고 줄·페이지 입력은 글줄 높이·화면 크기를 사용한다. 브라우저는 Qt 각도 입력을
+제공하지 않으므로 실제 장치의 휠 감도는 다를 수 있다.
+F5–F8로 확대를 조절한다.
+
+File → Open으로 `.chemvas`를 열고 Save로 사본을 다운로드한다.
+버전 7–9의 문서 버전과 데이터를 유지하며 Qt에서도 사본을 열 수 있다.
+다운로드 시작만으로 디스크 저장 완료를 확인할 수 없어 변경 표시와 종료 경고는
+유지하며, Save 다운로드가 변경 표시를 지우거나 복구 드래프트를 삭제하지 않는다.
+
+자동 복구 드래프트는 데스크톱 Qt 복구와 별개로 미저장 그림을 보호한다. OS
+프로세스 잠금으로 한 번에 하나의 실행 중인 서버만 드래프트 폴더를 소유하며,
+동시에 실행된 두 번째 서버는 복구 기능이 꺼진 상태로 실행되고 사용자에게 이를
+알린다. 미저장 문서마다 고유한 무작위 드래프트 식별자를 부여한다. 승인된 변경마다
+문서 이름, 저장 시각, `.chemvas` 페이로드를 담은 드래프트 봉투에 완전한 그림을
+원자적으로 기록하며, 실행 토큰, origin, 세션 식별자는 저장하지 않는다. 파일은
+로컬 사용자 애플리케이션 저장소(macOS: `~/Library/Application
+Support/Chemvas/browser-drafts`, Windows: `%LOCALAPPDATA%/Chemvas/browser-drafts` 또는
+`~/AppData/Local/Chemvas/browser-drafts`, Linux: `$XDG_DATA_HOME/Chemvas/browser-drafts`
+절대경로 또는 `~/.local/share/Chemvas/browser-drafts`), 또는 CLI 실행 시 `--drafts-dir`로
+지정한 폴더에 암호화되지 않은 파일로 저장된다. 서버가 다른 루프백 포트로 재시작되거나
+브라우저를 새로 고쳐도 드래프트는 유지된다.
+
+File > Recover Unsaved Work로 드래프트 목록을 확인하고 복구한다. 복구는 기존
+미저장 변경 확인을 거친 뒤 현재 그림을 대체한다. 복구된 그림은 파일 증식 없이
+기존 드래프트 식별자를 이어받으며, 저장되지 않은 상태로 유지되어 Save를 눌러도
+사본을 다운로드할 뿐 변경 표시나 드래프트를 지우지 않는다. 폐기 확인 후 다른
+문서를 열거나 교체하거나 깨끗한 기준선으로 실행 취소하면 드래프트가 삭제된다. 탭을
+닫거나 서버를 중단해도 드래프트는 디스크에 유지된다. 다른 창에서 열려 있는 드래프트를
+복구하려면 명시적 확인이 필요하며 이전 창의 세션은 종료된다. 해당 창이
+제한 대기 시간을 초과해 응답하지 못하거나 최신 승인 변경이 드래프트에
+기록되지 못한 경우 인수가 거부되며, 상대 창은 열린 채 유지되므로 해당 창으로
+전환해 사본을 저장하거나 쓰기 실패를 해결해야 한다. 서버가 실행 중인 상태에서
+비정상 종료된 탭은 명시적으로 드래프트를 인수하거나 서버를 재시작하기 전까지
+계속 열린 창으로 표시될 수 있다. 최대 16개(각각 96 MiB
+문서 한도 + 64 KiB 봉투)까지 유지되며 기간 만료로 삭제되지 않는다. 쓰기 실패 시
+디스크의 이전 정상 사본을 유지하고 오류를 표시하므로 최신 메모리 편집은 아직
+복구되지 않을 수 있다. 손상되거나 호환되지 않는 드래프트 파일은 명시적 폐기를 위해
+디스크에 보관된다. 파일시스템 오류로 드래프트 폴더를 읽거나 쓸 수 없으면 해당 실패를
+보고한다.
+
+화학 구조 복사·잘라내기·붙여넣기(Edit 메뉴 및 ⌘/Ctrl+C, ⌘/Ctrl+X, ⌘/Ctrl+V)는
+별도의 화학 스키마 없이 데스크톱의 표준 v3 선택 페이로드 형식을 사용한다. 지원되는 선택
+항목에 대해 원자, 결합, 완전한 고리, 귀속 및 선택된 전자 주석, 화살표, 도형, 오비탈,
+텍스트 노트, 그룹(재매핑된 소속 유지), 포함된 이미지(이미지 용량 한도 내), 원근 3D
+좌표(대상 카메라를 통해 원근 깊이 점만 재투영)를 보존하며, 기존 검증기는 지원되지
+않거나 손상되었거나 용량을 초과한 페이로드를 원자적으로 거부할 수 있다. 원자 ID는
+기존 ID 다음으로 재매핑되고, 반복 붙여넣기는 결합 길이만큼 연속 배치된다. 잘라내기는
+유효한 복사본이 확보되고 원본 그림이 최신일 때만 선택 항목을 삭제한다. 붙여넣기는
+1회의 실행 취소/다시 실행 편집으로 기록된다. 브라우저 API 기능은 다양하며, 이 어댑터는
+브라우저 권한과 사용자 제스처가 허용될 때 비동기 일반 텍스트(`text/plain`) 클립보드
+전송을 사용한다. 클립보드 접근이 불가능하거나 거부되면 현재 창의 로컬 메모리에만
+유효한 사본을 보관하며 명시적 안내를 표시한다(새로 고침 시 소멸). 네이티브 Qt 커스텀
+MIME은 `application/x-chemvas-selection+json`이며, 어댑터 간 직접 구현된 OS
+클립보드 교환은 없다. 열린 노트 편집기는 네이티브 텍스트 편집 단축키를 유지하며,
+Edit 메뉴를 열면 노트 편집을 마치고 캔버스에 클립보드 명령을 적용한다. 클립보드 접근
+권한 및 동작은 브라우저마다 다르다.
+
+용지는 사용자 지정 크기까지 Qt의 치수 계산과 중심 좌표계를 그대로 쓴다.
+용지 밖에서는 그리기 시작·완료와 원자 입력을 거부하고, 결합 드래그 중 밖으로
+나갔다 돌아와도 취소한다. 용지 밖 구조의 포인터 단축키 편집은 Qt와 같은 안내를
+표시한다. Select 이동·삭제와 지우개는 밖의 객체를 정리할 수 있도록 허용한다.
+
+## SMILES 삽입
+
+SMILES 입력란과 Insert 버튼은 데스크톱의 선택적 RDKit 백엔드를 사용한다.
+이 기능이 필요할 때만 기존 `rdkit` extra를 설치한다
+(`python -m pip install -e ".[rdkit]"`). RDKit이 없으면 기존 데스크톱과 같은
+백엔드 오류를 표시하며, 일반 드로잉에는 RDKit이 필요하지 않다.
+
+문자열 입력 후 Insert 또는 Enter를 누르면 반투명 구조가 포인터를 따라다닌다.
+시트 안을 클릭하면 한 번 삽입하며 기존 배치 계획·커밋 경로를 그대로 사용한다.
+Escape, 도구 전환, 포커스 이탈, 문서 교체 또는 다른 편집은 미리보기를 취소한다.
+시트 밖에서는 잔상을 숨기고 다시 들어오면 표시한다. 원자 라벨·결합 스타일·
+전자 주석을 보존하고 삽입 하나가 Undo/Redo 한 단계가 된다. 저장 형식과 지원하지
+않는 입체화학·동위원소 입력의 거부 규칙은 바꾸지 않았다.
+세션은 현재 입력과 결합 길이에 대한 파싱 모델 하나만 보관한다. 미리보기와 글꼴
+측정은 문서나 히스토리를 변경하지 않으며, 취소 뒤 늦은 응답도 잔상을 복구하지 않는다.
+글꼴 엔진에 따른 렌더링 차이는 남아 있다.
+
+File > Export MOL은 데스크톱 메뉴처럼 선택한 분자 구조를 문서 이름의
+`.mol` 파일로 내려받는다. 전체 화학 구조는 Select All로 선택한다. 원자에
+붙은 전자 주석에서 전하와 라디칼을 가져온다. 비어 있거나 화학 구조가 없는
+선택은 거부하며, 공통 MOL 작성기가 지원하지 않는 구조와 V2000 제한을
+알려 준다. 선택적 RDKit이 있으면 약어 라벨을 확장한다. 내보내기는 문서와
+Undo/Redo 이력을 바꾸지 않는다.
+
+File > Export Figure는 시트 전체를 문서 이름의 일반 SVG 파일로 내려받으며,
+이름 끝의 `.chemvas`는 `.svg`로 바꾼다.
+브라우저가 이 그림을 직접 그리지 않는다. 서버는 내보낼 때마다 별도 Python
+프로세스를 시작하고, 이 프로세스가 현재 문서의 비공개 사본을 데스크톱의
+오프스크린 캔버스에서 열어 데스크톱의 기존 SVG 그림 내보내기를 실행한다. 이
+프로세스는 Qt를 사용하고, 서버가 그 내보내기를 위해 만든 비공개 임시 폴더
+안에만 쓰며, 내보내기가 끝나면 종료한다. 서버는 프로세스가 끝난 뒤 그 폴더를
+지우며, 내보내기가 실패하거나 중단된 경우에도 지운다. 브라우저는 결과를 일반 다운로드로 저장하며, 서버가 다른 위치에
+쓰도록 하는 옵션은 없다. 열린 노트를 먼저 확정하며, 이 확정은 문서와 이력을
+바꿀 수 있고 노트가 저장되지 않으면 내보내기를 멈춘다. 내보내기는 한 번에
+하나만 실행하고, 내보내는 동안 바뀌거나 이름이 바뀌거나 교체되거나 작업 중·
+로딩 중이 된 문서의 결과는 버린다. "There is nothing to export." 같은
+데스크톱 내보내기의 메시지는 그대로 표시한다. 120초를 넘는 내보내기는 중단하고,
+데스크톱 명령줄 렌더링의 크기 제한을 넘는 출력은 거부하며, Qt가 시작되지 않는
+경우를 포함해 내보내기 프로세스가 실패하면 그림을 내보낼 수 없다고 알린다.
+이 경우 모두 아무것도 내려받지 않는다. 내보내기 자체는 문서, 리비전과
+Undo/Redo 이력을 바꾸지 않는다. 선택 영역만 내보내기, PDF·PNG·TIFF, 편집 가능한 SVG와
+데스크톱 내보내기 대화상자의 옵션은 브라우저에서 제공하지 않는다.
+
+View > Valence Checking은 데스크톱과 같은 규칙으로 전하에 비해 일반 결합이
+너무 많은 원자에 밑줄을 긋는다. 수소·붕소·탄소·질소·산소·플루오린의 일반적인
+전하 상태를 원자에 저장된 전하·라디칼 주석으로 검사하며, 이 주석은 표시를
+더하거나 지울 때 함께 갱신된다. 약어 라벨·라디칼·점선 결합·
+금속 배위는 검사하지 않는다. 밑줄은 끌어 옮기는 원자를 따라가고, 새 경고는 편집이
+반영된 뒤 나타난다. 열린 그림마다 켜진 상태로 시작하는 보기 설정이며 저장이나
+내보내기를 막지 않고, 밑줄은 그림이나 저장·내보내기 파일에 들어가지 않는다.
+데스크톱처럼 보이는 영역에 걸친 경고만 보이는 너비만큼 그린다. 한 화면의
+지그재그 점은 최대 50,000개이며, 이를 넘으면 뒤쪽 원자의 경고는 보이는
+경고가 줄 때까지 생략된다.
+
+## 남은 연결 작업
+
+다른 개체의 핸들, 패널과 나머지 출판 출력은 기존 기능의 연결이 남아 있다.
+원래 도구·메뉴 자리는 유지하고 미연결 동작은 비활성으로 표시한다.
+이 기능을 대신하는 별도 간이 편집기와 그림 렌더러는 두지 않는다.
+
+텍스트 노트와 노트 상자는 데스크톱처럼 표시되고 선택·이동·삭제·회전·
+뒤집기·정렬할 수 있다. Text 도구(T)로 클릭한 곳에 노트를 추가하거나, 클릭한
+노트를 전체 선택 상태로 편집한다. Esc나 바깥 클릭으로 끝내며 비운 노트는 삭제된다.
+Text 페이지는 선택한 글자, 또는 편집 중이 아니면 선택한 노트 전체에 글자 크기
+단계와 굵게·기울임·위첨자·아래첨자·정렬을 적용하고, Color 도구는 노트 전체의 색을
+바꾼다. 선택 없이 커서만 있을 때는 데스크톱처럼 다음에 입력할 글자의 서식을 정하며,
+커서를 옮기거나 붙여넣기·삭제를 하면 해제되고 서식만 바꾼 것은 저장하지 않는다.
+붙여넣은 글자는 브라우저의 서식을 유지하고, 빈 줄은 문서 기본 서식을 쓴다.
+입력기와 커서 위치 동작은 아직 브라우저·플랫폼별 확인이 필요하다. 열린 노트에서
+실행 취소는 Ctrl+Z 또는 Cmd+Z, 다시 실행은 Ctrl+Shift+Z, Cmd+Shift+Z 또는
+Ctrl+Y를 쓴다. 연속 입력이나 삭제, 새 문단, 서식 변경, 입력기로 확정한 텍스트를
+각각 한 단계로 처리한다. 브라우저의 우클릭 메뉴는 자체 편집 기록에 따라
+활성화되므로 실행 취소·다시 실행 메뉴가 비활성 상태일 수 있다. 맞춤법 교정은
+브라우저의 서식을 유지한다. 목록이나
+pt가 아닌 글자 크기를 쓴 노트는 평문으로 읽기 전용으로 연다.
+
+바깥쪽 이중결합과 either 이중결합을 포함해 모든 결합 스타일을 표시한다.
+이중결합을 우클릭하면 데스크톱과 같은 Inward·Centered·Outward 위치를 고를 수 있다.
+
+계산 계획이 있는 문서도 편집할 수 있다. 계획의 성분 참조가 어긋나는 편집은 데스크톱
+저장과 같은 경고와 함께 그 버전에서 계획을 빼며, 실행 취소로 되돌릴 수 있다.
+
+이미지·그룹·원근 보기가 있는 문서도 편집할 수 있다. 이미지는 선택·이동·삭제·회전·
+뒤집기를 할 수 있고(데스크톱처럼 픽셀은 똑바로 두고 상자만 움직인다), 도형과 함께
+맨 앞으로·맨 뒤로 보내기, 정렬·분배, 그룹 만들기에 쓸 수 있다. Insert Image와
+Image Properties는 데스크톱의 검증·용량 한도·배치·항목을 쓴다. 포함된 이미지
+원본이 있으면 문서는 96 MiB 문서 용량 한도까지 받을 수 있고, 이미지 원본을
+뺀 그림은 2 MiB 이내여야 한다. 그룹은 데스크톱의 그룹 상태를 유지한다. Edit >
+Group·Ungroup(Ctrl+G, Ctrl+Shift+G), 그룹 전체로 채워지는 선택, 점선 그룹 상자,
+두 그룹의 분자를 잇는 연결의 거부를 데스크톱과 같이 처리한다. 원근 보기는 저장된
+깊이 점을 데스크톱 규칙으로 읽고, 반영된 편집마다 남아 있는 원자에 투영되는 점만
+유지한다. Perspective Rotation 도구는 아직 연결하지 않았다. 저장 사본에는 문서 데이터를 모두 유지한다. 글꼴 렌더링, 라벨 선택 영역, 결합 접합, 서식 텍스트와
+메뉴·파일 대화상자·클립보드는 브라우저·플랫폼별 연결이 필요하다.
+전체 편집 기능은 Qt에서 제공한다.
+
+서버는 loopback에만 바인딩하고 Host·Origin·실행 인증 정보를 검사한다.
+정해진 정적 파일만 제공하며 메모리 세션은 최대 16개다. 한도에 닿으면
+30분 이상 쓰지 않은 창의 세션을 닫아 자리를 만든다.
+이미지를 뺀 그림 2 MiB, 이미지 포함 전체 문서 96 MiB, 열기·세션 HTTP 요청
+98 MiB 제한을 적용한다. 원자 2,000개·결합 3,000개, 기존 히스토리 100개 명령
+제한을 쓴다. 전체 프로세스 메모리 제한은 아니다. 복구 지속성은 서버의 편집 승인과 성공적인 드래프트 쓰기를 모두 필요로 하며, 쓰기 실패 시 디스크의 이전 정상 사본이 유지되므로 최신 메모리 편집은 복구되지 않을 수 있다. 입력 중인 노트 텍스트나 종료 시점에 아직 승인되지 않은 편집은 유실될 수 있으며 페이지 이탈 경고는 유지된다.
+
+## 유지보수와 검증
+
+`bootstrap/web_adapter.py`는 HTTP·세션·기존 서비스 연결을 담당한다.
+브라우저 모듈은 통신·SVG 표시·DOM 이벤트를 맡고, 편집 알고리즘은 기존 소유자에 둔다.
+Qt에 묶인 경계는 점 생성 함수를 받거나 외부 트랜잭션의 후보 편집을 허용하되,
+기존 Qt 기본 동작은 유지한다. 별도 편집 엔진, 별도 그림 렌더러, 도구별 전달 파일,
+복사한 롤백 블록을 추가하지 않는다. [ADR 0029](adr/0029-browser-adapter.md)를 따른다.
+
+`make check`는 Qt 없는 import, 실제 HTTP, 제거한 시험판 동작 거부,
+편집 실패 시 상태 보존, Undo/Redo, 문서 보존과 JavaScript를 검사한다.
+테스트에는 Node.js 20+가 필요하다. `tests/test_web_clipboard.py`,
+`tests/test_web_drafts.py`, `tests/web_adapter.test.mjs`로 클립보드 페이로드,
+붙여넣기 계획, 드래프트 잠금, 봉투 저장, 인수 및 세션 클라이언트 복구 동작을
+검증한다. 실제 Qt와 웹에 같은 결합 생성·클릭 방향·벤젠 붙이기·융합·삭제·결합 스타일·
+원자 표기·겹친 원자 병합·원자/결합 이동을 실행해 결과를 비교한다.
+두 가지 결합 길이와 고리 기록 유무별 선 좌표, 웹 → Qt 저장 → 웹 편집·Undo →
+Qt 재열기도 검사한다. 제스처와 배치는 실제 브라우저로 확인한다.
+클립보드 동작은 브라우저와 플랫폼에 따라 차이가 있으며, 전체 화면·복구·출력의
+동등성은 아직 검증되지 않았고 Qt 종료는 별도 인수 검증 뒤 결정한다.
+
+브라우저의 화살표 드래그 기준은 화면상 맨해튼 거리 10px이다. 브라우저는 데스크톱 시스템 드래그 거리 설정을 읽을 수 없으며 Qt는 기존 시스템 설정을 유지한다. 선택한 화살표·선을 옮길 때 움직인 끝점이 현재 배율에서 화면상 12px 안에 다른 화살표·선의 끝점에 닿으면, 데스크톱의 선택 드래그처럼 그 끝점에 붙고 선택 전체가 그만큼 이동한다. 이동 미리보기와 놓을 때 같은 규칙을 쓴다. 데스크톱이 이 드래그 중에 보여 주는 연결 표시는 아직 브라우저에 없다.
+
+선택은 문서 버전에 묶인 세션 조회로 그래픽 히트와 장면 좌표를 판정하며, 문서 변경이나 재렌더링을 하지 않는다. 일반 선택은 기존 구조 우선순위 정책을 사용하고, Shift 선택·지우개는 실제 원자/결합 히트와 기존 근접 결합 판정을 사용한다. 고정된 웹 결합 클릭 폭은 제거했다. 응답 전 마우스를 놓아도 최종 좌표를 보존하며 취소·문서 변경 뒤의 늦은 응답은 버린다. 지우개 판정과 삭제는 하나의 후보 편집으로 처리한다. Qt의 화살표 주변 6 화면 픽셀 보조 판정과 투명한 고리 내부 선택도 연결했다.
+
+
+Ring Fill은 기존 완전한 고리 판별과 불투명 파스텔 혼합을 사용한다.
+고리 내부, 고리의 모든 원자 또는 모든 결합을 선택한 뒤 채우기 색상을 고른다.
+일부 원자와 일부 결합을 합쳐 새 고리를 만들지 않는다. Ring Fill 팔레트를 열어도
+Select 도구는 유지된다. 투명한 고리 내부도 Shift 선택·이동·Color·지우기의 대상이다.
+Color는 고리의 원자와 결합을 칠하고, 고리 내부 지우기는 채우기만 제거한다.
+Undo로 이전 상태를 복구한다. 같은 거리의 결합도 기존 공간 검색 순서로 선택한다.
+
+
+빈 캔버스를 드래그하면 방향에 관계없이 영역에 닿은 도형을 선택한다.
+macOS의 Command, 다른 플랫폼의 Control을 누르면 기존 선택에 추가한다.
+Qt에서 가져온 실제 SVG 도형의 교차 판정을 사용하므로 투명한 고리와 타원 내부도
+선택되며, 외접 사각형만 겹친 경우는 선택하지 않는다. 선택 자체는 히스토리에 기록하지
+않고 이후 이동·삭제는 기존 경로를 쓴다. Escape·포커스 이탈·도구/문서 변경은 진행 중인
+선택을 취소하며 늦은 서버 응답을 무시한다. 드래그 사각형은 브라우저의 시스템 Highlight
+색상을 쓰므로 Qt의 플랫폼별 드래그 사각형 외관과 차이가 있을 수 있다.
+현재 영역 선택은 SVG 교차 API가 있는 브라우저에서 지원한다. Firefox처럼 이 API가
+없는 브라우저에서는 드래그를 취소하고 이전 선택을 복원한 뒤 안내를 표시한다.
+이 경우 Shift 클릭·전체 선택을 사용하거나 Qt 버전에서 영역을 선택할 수 있다.
+
+선택한 화살표·선은 원래 경로와 Qt 선택 폭을 사용하며, 그림 위에 화면 기준
+1.5px 외곽선을 표시한다. 머리와 몸통의 부분 경로가 겹치는 경계도 유지한다.
+SVG 마스크가 선의 경계를 표현하므로 Qt나 별도 화살표 기하 알고리즘을 쓰지 않는다.
+곡선·접합부에서는 SVG와 Qt 경로 스트로커 및 안티앨리어싱 차이가 남을 수 있으며,
+픽셀 단위로 같은 렌더링을 보장하지 않는다.
+
+분자 선택은 기존 결합 띠·원자 표시 범위·연결 컴포넌트 계산을 사용한다. 문서 버전에
+묶인 읽기 전용 조회로 현재 선택의 도형을 받고, 이동 미리보기는 같은 후보 문서에서
+선택 도형도 반환한다. 선택 변경 뒤 도착한 이전 응답은 버린다. 고리 내부 선택은
+외곽선을 위해 고리의 원자로 확장하고, 연결된 무표시 탄소에는 개별 색칠 원을 그리지
+않는다. SVG morphology가 컴포넌트의 합쳐진 알파 영역 경계를 표시하므로 브라우저에
+그래프·결합 알고리즘을 복제하지 않는다. 래스터 커널은 모서리·작은 배율에서 Qt의
+벡터 합집합과 차이가 있다. 라벨 선택에는 기존 레이아웃 사각형·문서 여백·원형 또는
+확장 여백 계산을 공유한다. 잉크 영역은 글자 클릭 판정에만 사용한다. 기존의 원 지름
+3배 기준으로 원형과 확장 상자를 결정하고, 측정 완료와 미리보기에도 해당 후보의
+레이아웃 범위를 함께 전달한다. 글꼴 엔진 측정 차이는 남아 있다.
+일반 원자·화살표 라벨의 행 높이는 브라우저의 기본 글꼴 행을 2048픽셀 em에서
+측정한 뒤 크기를 다시 맞추고 기존 문서의 올림 계산을 적용한다. 작은 HTML span의
+반 픽셀 반올림 없이 글꼴의 행 간격을 유지한다. Arial, Times New Roman,
+Courier New의 8–64픽셀, 일반·굵게·기울임에 대한 기존 브라우저 측정 비교 513건에서
+macOS 문서 높이와 일치한 것으로 기록되어 있다. 모든 설치 글꼴이나 대체 글꼴의
+동일성을 보장하지 않으며, 휠의 줄 단위 delta는 일반 CSS 행 높이를 계속 쓴다.
+
+Select의 각도 입력란과 Rotate 버튼은 원래의 수치 회전 명령을 쓴다. Edit → Rotate는
+그 입력란으로 포커스를 옮긴다. 기본값은 15도, 범위는 -180–180도이며 1도 단위 증감
+버튼이 있고 Enter로 입력한 각도를 적용한다. 선택한 결합과 고리 채움은 원자 ID로
+확장한다. 회전 중심은 원자 위치, 화살표 끝점·제어점 범위와 데스크톱의 도형 범위를
+쓰고 라벨 범위는 넣지 않는다. 원자 좌표, 주석 기록과 고리 다각형은 원래의 변환·이동
+서비스를 쓴다. 도형은 Qt처럼 중심만 회전 중심을 따라 돌고 자신은 똑바로 유지한다.
+한 번 적용하면 Undo/Redo 항목 하나가 생기며, 빈 선택이나 0도는 항목을 만들지 않는다.
+미리보기와 거부된 편집은 문서를 바꾸지 않는다. 좌우·상하 뒤집기도 같은 선택 전체
+회전 중심과 원래의 주석 변환을 쓴다. 데스크톱의 Select 아이콘, Edit 메뉴 동작과
+Command/Control+Shift+H/V 단축키를 연결했다. 평형 화살표 반전과 위·아래 라벨 교환,
+원호 방향, 도형 범위는 원래 변환을 따른다. 길이가 0인 평형 화살표가 있으면 어떤
+변경도 하기 전에 편집 전체를 거부한다. 반복해서 뒤집으면 데스크톱 직렬화기가
+생략하던 false 반전 표시를 지우면서 기록의 동일성은 유지한다. 뒤집기마다 Undo/Redo
+항목이 하나 생기며, 빈 선택과 바뀌지 않는 선택은 항목을 만들지 않는다.
+
+정렬과 같은 간격 분배는 원래 데스크톱의 객체 묶음과 사각형 차이 계산을 쓴다. 원자,
+결합, 고리 중 하나를 선택하면 연결된 분자 전체가 내부 형태를 바꾸지 않고 움직인다.
+정렬 아이콘 6개, 분배 아이콘 2개와 Edit 하위 메뉴는 원래 정의를 쓴다. 분배할 때 양
+끝 객체는 고정되며, 객체가 2개(분배는 3개)보다 적으면 아무것도 하지 않는다. 범위에는
+원자 라벨 배치 전체와 데스크톱 경로 항목의 선 범위가 들어가며, Qt처럼 화살표
+라벨은 부모 항목의 정렬 상자를 키우지 않는다. 글꼴 측정값이 없으면 편집을 거부한다.
+후보 미리보기, 반영, Undo/Redo는 기존 문서·이력 경로를 쓴다. 그룹은 하나의
+객체로 정렬·분배한다.
+
+대각선의 둥근 선 끝은 이제 범위에 Qt의 3차 제어점 외곽을 넣어, 칠한 원의 상자를
+쓰던 때의 작은 정렬 오차를 고쳤다. 곡선 화살표 범위는 아래에 적은 근사를 유지하므로
+그 정렬에 문서화된 좌표 오차가 남을 수 있다.
+
+선택 프레임과 회전 손잡이는 데스크톱의 표시 조건, 여백, 모서리 반경, 줄기와 손잡이
+크기를 쓴다. 프레임은 원자 라벨 배치 전체와 화살표 라벨 블록을 포함한다. Qt처럼
+원자 두 개를 선택하거나 화살표·선 하나를 선택하면 프레임이 생기고, 도형만 선택하면
+생기지 않는다. 수치 회전의 회전 중심에 선택한 도형이 들어가도 도형 범위는 보이는
+프레임에서 뺀다. 프레임은 외곽선 위, 객체 손잡이 아래에 놓이고 회전 손잡이는 화면에서
+같은 크기로 유지된다.
+
+회전 손잡이를 끌면 포인터 위치를 원래의 회전 각도 함수에 보내고, Shift를 누르면
+데스크톱처럼 15도 단위로 맞춘다. 미리보기마다 직전 미리보기가 아니라 누를 때 확정된
+상태를 변환한다. 놓으면 이력 명령 하나를 적용하고, Escape나 취소는 미리보기를 버리며
+누른 위치로 돌아오면 정확히 아무것도 바꾸지 않는다. 늦게 온 미리보기는 끝났거나 취소한
+드래그를 덮어쓰지 못한다.
+
+Qt의 경로 항목 범위는 선을 그은 3차 제어점 외곽을 쓴다. 브라우저는 2차 명령을
+제한된 횟수로 나눈 오프셋 제어점 범위로 바꾸며, 그리는 화살표 경로는 바꾸지 않는다.
+선 끝·접합 외곽과 양쪽을 따로 나누는 계산은 근사이며, 데스크톱과 비교한 684개
+경우의 최대 좌표·크기 오차는 둥근 끝 보정 뒤 6단위 펜에서 문서 단위 1.93이다.
+처음의 나누지 않은 제어점 범위가 심하게 굽은 곡선에서 커지던 문제(약 195단위)는
+고쳤다. 모든 곡선에서 Qt 프레임 형태와 정확히 같다거나 픽셀이 같다는 뜻은 아니다.
+
+도형 선택은 기존 타원·사각형 경로와 Qt 선택 여백을 사용한다. 테두리 없는 도형과
+납작한 도형도 포함한다. 분자와 같은 SVG 경계 렌더러로 개별 외곽선을 내용 위,
+크기 조절 핸들 아래에 표시하며 이동·크기 조절 미리보기는 후보 문서의 경로를 쓴다.
+Qt 도형 선택에도 winding 채움 규칙을 명시해 테두리 없는 사각형 안쪽에 생기던
+구멍을 고쳤다. SVG에서 두 단계의 선 폭을 합쳐 그리는 방식은 납작한 끝부분에서
+약간 다르다. Qt 영역 표본은 경계에서 0.25문서 단위 밖에서 일치하며, SVG 모서리와
+안티앨리어싱 차이도 남아 있다. 픽셀 단위의 동일성은 아직 보장하지 않는다.
+
+상태 표시줄의 격자 컨트롤은 None → Hex → Square → None 순서로 바뀌며 데스크톱의
+15%, 20%, 25% 농도를 제공한다. View → Snap to Grid는 현재 형식을 켜고 끈다.
+데스크톱의 배경 선분을 장면 원점 기준의 제한된 SVG 타일로 만들며, 간격은 현재 결합
+길이의 절반이다. 화면에서 6픽셀보다 좁으면 격자는 숨기지만 스냅은 유지한다.
+화살표·선 그리기와 끝점 핸들은 원래의 끝점 → 명시적 각도 → 격자 순서를 쓰고, 분자,
+도형, 곡선 중앙점은 기존 동작을 유지한다. 격자 선택은 잠시 쓰는 도구 상태이며 문서
+설정이나 Undo 항목으로 저장하지 않는다. SVG와 Qt의 안티앨리어싱은 다를 수 있다.
diff --git a/docs/WEB_ADAPTER.md b/docs/WEB_ADAPTER.md
new file mode 100644
index 00000000..95f421a3
--- /dev/null
+++ b/docs/WEB_ADAPTER.md
@@ -0,0 +1,507 @@
+# Browser adapter
+
+[한국어](WEB_ADAPTER.ko.md)
+
+Chemvas is migrating its existing editor to a browser presentation adapter while
+keeping the Qt application. The browser adapter is in development; full workflow
+and visual parity remain the acceptance target.
+
+## Run
+
+```bash
+python -m pip install -e .
+chemvas --ui web
+chemvas --ui qt
+```
+
+After `make check`, use `.venv/bin/chemvas` on macOS/Linux or
+`.venv/Scripts/chemvas.exe` on Windows. Python 3.12+ is required.
+`chemvas --ui web --no-browser` prints the local launch URL. Keep the terminal
+open; Ctrl+C stops the server. The URL contains a session credential and stays
+local. No frontend build, CDN or Node runtime is needed. The browser server imports
+no Qt; the combined package still installs Qt for the desktop application.
+File > Export Figure alone runs the desktop's Qt figure export, in a separate
+short-lived process, so it needs that Qt installation.
+
+## SMILES insertion
+
+The SMILES field and Insert button use the desktop's optional RDKit backend.
+Install the existing `rdkit` extra (`python -m pip install -e ".[rdkit]"`) only
+if you need this feature. Without it, the field reports the same missing-backend
+error as the desktop; drawing does not require RDKit.
+
+Enter a SMILES string, choose Insert (or press Enter), then click on the sheet to
+place the translucent structure once. The preview follows the pointer. Escape,
+a tool change, focus loss, document replacement or another edit cancels it;
+moving outside the sheet hides the ghost until re-entry. The existing insertion
+planner and committer preserve atom labels, bond styles and electronic annotations.
+A placement is one Undo/Redo command and saved copies use the existing document
+format. Unsupported stereochemistry and isotope inputs keep the native errors.
+The session keeps one parsed model for the current text and bond length. Preview
+and font measurements never publish a document or add history, and late replies
+cannot bring back a cancelled preview. Font-engine rendering differences remain.
+
+## Connected implementations
+
+- Toolbar order, names, tooltips, bond options and status hints come from the
+ existing `main_window_config`. Both adapters use `shell.icon_design` artwork,
+ the existing palette, dimensions and ACS1996 rendering metrics. Context options
+ use the native white checked box, soft border and segmented-group spacing.
+- Bond creation executes `StructureBondBuildService` and `StructureBuildCommitter`.
+ Bond presses send raw scene coordinates and use the existing atom-first picking
+ policy and shared radii. Releases onto bonds use the native nearest-endpoint
+ snapping rule; angle steps come from `CanvasToolSettingsState`. Existing atoms
+ and bonds are reused by the builder. Browser gestures commit on release; Qt
+ restyles a pressed bond immediately.
+- Benzene insertion executes `StructureBenzeneBuildService` and the same committer;
+ the Ring page's other templates use the desktop's template planning,
+ `TemplateGeometryResolverService` and `commit_template_ring`.
+ Ring clicks use the native atom/bond distance preference and insertion gate;
+ implicit atom hit circles use the native pick radius. Attachment, fusion, atom
+ merging, bond orders and ring records keep their existing owners. Label click targets use the measured ink bounding rectangle, adding an offset
+ anchor circle only for compact labels through the native predicate. Font sampling can
+ still shift their edges slightly. Single and multiple deletion use one request
+ to the existing deletion planner.
+- Bold polygons come from `BondGraphicsDrawService`, preserving bond order, ring
+ orientation and adjacent bold-bond mitres. Only point/polygon construction changes.
+- Dotted bonds use `BondLineGeometryService.dotted_bond_dots` for their centers,
+ radius and junction spacing; SVG only draws those circles. Double-bond overlays
+ retain the native order and inner/outer policy.
+- Atom input resolves the native hover target before nearby bond endpoints and
+ fallback atoms. Direct label hits retain desktop item priority; SVG bond IDs
+ no longer determine which atom is edited.
+- Atom labels use the native label direction, alias anchor, subscript and stacked
+ hydrogen layout. The browser measures font advances and line heights, sends
+ them to the existing Python layout functions, and displays the positioned runs
+ at the integer pixel sizes resolved by the desktop’s pinned 96-DPI font policy.
+ Each session retains one bounded set of measured font metrics and glyph ink,
+ outside document data and Undo/Redo. Native run placement and the original bond
+ planner use that set directly when rendering edits, previews and history replay.
+ Known glyphs need one response and one render; newly encountered glyphs request
+ a measurement update bound to the returned revision, without sending the document
+ back or replaying a committed edit. Previews send the session/revision and edit
+ only. Invalid or stale measurements cannot change document/history or replace the
+ accepted font data. Replacement discards the preceding measurement set.
+ The planner shares the native convex-hull, clearance radius and contour-band
+ intersection calculations;
+ fixed-distance trimming and white label masking are removed. Browser ink is
+ sampled at up to 8× resolution in a bounded raster. Half-covered pixels define
+ the contour, constrained to the font engine's actual ink bounds so antialiasing
+ does not add an outer pixel. Clearance uses a 64-sided round envelope.
+ Font engines and sampling can produce small differences;
+ this is not a claim of pixel-identical rendering on every platform.
+- Imported charge and radical marks share native attachment coordinates, circle/dot
+ dimensions and text-run placement, including custom colors and plain multiline
+ text. SVG displays the resulting primitives; font-engine rounding differences
+ remain possible. Marks are created, moved, reassigned and changed with the
+ charge shortcuts through the same services as the desktop.
+- Brackets and dagger symbols use the desktop's stroke commands and glyph
+ layout. Their bounds follow the desktop's filled outline; dagger bounds come
+ from the browser's measured glyph ink.
+- SVG bond primitives come from `BondGeometryPlanService`,
+ `BondLineGeometryService` and `BondRingDoubleGeometryService`, including the
+ shared ring-edge selection policy. Live document ring order takes precedence
+ over cached graph cycles in both adapters.
+- Arrow and line paths share the existing native calculations in `line_geometry`.
+ Qt consumes move/line/quadratic commands as `QPainterPath`; SVG consumes the same
+ commands with native widths, dash spacing, caps and joins. All 19 kinds are
+ covered, including mirrored/favored equilibrium and stored curve controls.
+ A drawing is limited to 500,000 arrow path points.
+ Arrows and lines support selection, dragging, mixed Select All, deletion and Undo/Redo through the native move controller and deletion planner. Arrow creation reuses the native endpoint snap funnel, default curve and Shift arc mirroring. The original arrow kinds/icons and More arrows grouping are available. The four Line kinds use the native endpoint/Shift rules and blank-click level length. Arrow presets and width/head sliders share native values and clamps.
+ Clicking a selected arrow again toggles its endpoint handles; curved arrows also
+ show the curve midpoint handle. Native endpoint minimum length, curve carry and
+ midpoint limits run in the shared geometry owner. A snapped endpoint handle is
+ filled and all handles keep their screen size. Previews retain the last valid
+ endpoint, while release publishes one edit; Escape discards pending previews.
+ Arrow labels share the original mini-syntax, HTML escaping, blank-label cleaning
+ and placement calculation. Double-click in Select, Arrow or Line to edit Above
+ and Below, with the original syntax hint, previews and 200-character counters.
+ Untouched CRLF/CR text is preserved; OK records one edit and Cancel records none.
+ Browser rich-text boxes join the bounded, revision-bound font measurement exchange,
+ outside document history. Both adapters retain their font engines; small glyph
+ width and baseline differences remain possible.
+- Decorative shapes reuse native circle/ellipse/rectangle bounds, rounded-corner
+ radii, click defaults, stroke widths and option definitions. Creation, previews,
+ interior picking, movement, deletion and Undo/Redo are connected. Borderless
+ previews use the native dashed grey guide; committed transparent interiors remain
+ clickable. Shape movement shares the original record transform. Imported fills
+ are retained. Eight resize handles share native positions, screen sizes and the
+ original minimum-size clamp; a drag records one history command. As in Qt, a
+ shape-only selection does not receive a rotation frame. Bring to Front and Send
+ to Back share the
+ native stable ordering and bounded depth bands. SVG depth order and foreground
+ shape picking preserve those values, including imported custom depths.
+- Color uses the native 16-swatch palette and original opaque pastel calculation
+ for shape fills. Existing atom, bond, arrow and shape records receive colors;
+ swatches apply to the current selection, and painting uses the native hit target
+ before falling back to that selection. Preview/history and hidden-carbon guidance
+ remain connected. Custom color picking uses the browser platform dialog; its
+ appearance can differ from Qt. Notes, marks and ring-color expansion await their
+ object adapters.
+- `CanvasHistoryService` and `CanvasHistoryState` own Undo/Redo. The server builds
+ a private candidate using the original operations, validates it, records one
+ command, then publishes it. Commands retain documents only; Undo/Redo regenerates
+ drawing primitives through the same renderer. Failed replay uses the existing
+ history transaction owner to retain the document and stacks. JavaScript mirrors
+ accepted state. A lost response triggers a read of the current session without
+ repeating the edit. If that read also fails, the next action only reconnects and
+ asks the user to check the refreshed drawing before editing again.
+
+Draw bonds with X, place or attach benzene with J, select with Space and delete
+with Delete or the eraser. Click a bond to apply its selected style. A short bond
+click uses the original default direction. Hover a bond and press 1/2/3, b/w/h/d,
+Shift+B/H/D or l/c/r to apply the native bond-style shortcut. These keys do not
+change the active drawing style on empty space. Hover an atom to use the native
+label letters and 0–9/z/v/u growth keys. A lowercase `a` attaches benzene to an
+atom or fuses it to a bond; over a bond, 4–8 fuse regular rings and 9/0 fuse the
+two chair orientations. Hover handling precedes tool selection, so A selects the
+Atom tool only when no structure consumes it. X resets the Bond tool to Single
+on empty space; over an atom, it applies the native X label. Charge marks (+/−)
+report that they are not yet connected.
+Undo/Redo treats each gesture as one edit. Plain wheel scrolls; Cmd+wheel on macOS
+and Ctrl+wheel elsewhere zoom around the pointer. Browser Control/pinch zoom
+remains available. Zoom limits (20–500%)
+and button steps come from the desktop declarations. Browser pixel deltas map to
+the native angle/pixel ratio; line/page deltas use the measured line height or
+viewport extent. Physical wheel sensitivity can differ because browsers do not
+expose Qt angle deltas. F5–F8 controls zoom.
+
+The paper uses the native sheet dimensions and centered scene coordinates,
+including custom sizes. File → Canvas Size connects the native paper list,
+orientation, millimetre limits and dimension calculation. It preserves drawing
+coordinates and records one undoable change; Custom disables orientation and
+preset sizes display their dimensions without allowing edits. Drawing starts/releases and atom input must be inside
+the sheet. Leaving it cancels a bond drag, even if the pointer returns before
+release. Hover edits on off-sheet structures report the native guidance;
+Select movement/deletion and the eraser remain available to recover those objects.
+
+Pointer-selected toolbar buttons preserve focus, matching native NoFocus.
+Keyboard-focused buttons and menu headings keep Enter/Space activation, even with the
+pointer over the canvas. Space on the canvas selects the selection tool. Drag selected atoms/bonds to move them,
+Shift-click to add or remove selections, and use Command/Ctrl+A to select the
+whole structure. Drag empty canvas to select intersecting rendered shapes in either
+direction. Command on macOS or Control elsewhere adds the area to the selection.
+Escape, focus loss and tool/document changes cancel a pending area gesture;
+late server picks cannot overwrite the selection. Area selection creates no
+history command; subsequent movement or deletion uses the existing selection
+workflow. SVG performs the item-shape intersection on the native geometry,
+including transparent rings and shapes, rather than selecting bounding boxes.
+The rubber band uses the browser's system Highlight color; platform styling can
+differ from Qt's native rubber band. Delete removes the selection in one command. Escape returns to
+Select and cancels the preview. Tool changes and focus loss cancel the preview.
+With no selection, Delete/Backspace follows the native hover rule: first clear a
+bonded atom’s visible label to implicit carbon; otherwise delete the hovered atom
+or bond. A lone labelled atom is removed outright. Keyboard hover checks the
+current SVG label hit shape before the native scene-distance fallback, so long
+labels remain targets beyond their atom-center radius. Selection highlights do
+not enlarge that hit shape. Enter over an atom opens the
+native-label prompt, independent of the Atom tool’s current symbol; Enter over a
+bond or empty space does nothing. Accepting an empty prompt resets implicit carbon,
+while Cancel leaves the document and history unchanged.
+
+A selects the Atom tool. Enter a symbol in the context bar and click to apply it;
+an empty context field opens the existing-symbol prompt. Cancel leaves the document unchanged.
+
+File → Open reads `.chemvas`; Save downloads a copy that Qt can open. Document
+versions 7–9 retain their version and data. Download initiation cannot prove a
+completed disk save, so the dirty marker and close warning remain visible, and
+downloading Save never clears the dirty marker or removes a recovery draft.
+
+Automatic recovery drafts protect unsaved work separately from desktop Qt
+recovery. An OS process lock ensures only one running server owns a drafts
+folder at a time; a second server started concurrently runs with recovery
+disabled and notifies the user. Each unsaved document receives one stable random
+draft identifier. After each accepted change, the server atomically writes the
+complete drawing in a draft envelope storing the document name, save timestamp,
+and `.chemvas` payload, without storing launch tokens, origins, or session
+credentials. Files are stored unencrypted in local per-user application storage
+(`~/Library/Application Support/Chemvas/browser-drafts` on macOS;
+`%LOCALAPPDATA%/Chemvas/browser-drafts` or `~/AppData/Local/Chemvas/browser-drafts`
+on Windows; `$XDG_DATA_HOME/Chemvas/browser-drafts` if `$XDG_DATA_HOME` is an
+absolute path, otherwise `~/.local/share/Chemvas/browser-drafts` on Linux), or in a
+custom folder specified at CLI launch via `--drafts-dir`. Drafts survive browser
+reloads and server restarts, even when the server binds to a different loopback
+port.
+
+File > Recover Unsaved Work lists available drafts. Recovering replaces the
+current drawing only after the existing unsaved-changes confirmation. The
+recovered drawing adopts the existing draft identifier without multiplying
+files; it is unsaved, and downloading Save downloads a copy while keeping the
+drawing dirty and retaining the draft. Opening another document or replacing the
+drawing after discard confirmation, or undoing to a known-clean baseline, removes
+the draft. Closing a tab or stopping the server preserves drafts on disk.
+Recovering a draft currently open in another window requires explicit
+confirmation and ends that other window's session. Takeover is refused if the
+holding window is busy beyond a bounded wait or its latest accepted changes
+failed to reach the recovery draft; the other window stays open so you can
+switch to it and save a copy or resolve draft-write failure. A crashed tab whose
+server remains running can still be listed as open until you explicitly take
+over its draft or restart the server. Up to 16 drafts (each up to
+the 96 MiB document budget plus 64 KiB envelope) are retained without age-based
+expiration. Draft write failures retain the last good copy on disk and show an
+error notice, so the latest in-memory edits may not yet be recoverable. Damaged
+or incompatible draft files are kept on disk for explicit discard. If a draft
+folder cannot be read or written due to filesystem errors, the failure is
+reported.
+
+Chemistry Copy, Cut and Paste (Edit menu and ⌘/Ctrl+C, ⌘/Ctrl+X, ⌘/Ctrl+V) use
+the canonical Chemvas v3 selection payload format without a parallel chemistry
+schema. For supported selections, atoms, bonds, complete rings, attached and
+selected marks, arrows, decorative shapes, orbitals, text notes, groups
+(keeping remapped membership), embedded images (subject to image budgets),
+and perspective 3D coordinates (where only perspective depth points reproject
+through the target camera) are preserved; the existing validator may refuse
+unsupported, corrupt, or oversized payloads atomically. Atom IDs are
+remapped past existing IDs, and repeated pastes cascade with bond-length
+offsets. Cut removes the selection only after a usable copy is created and the
+source drawing is current. Each paste records as a single Undo/Redo edit.
+Browser API capabilities vary; this adapter uses an asynchronous plain-text
+clipboard transport (`text/plain`) where browser permissions and user gestures
+allow; otherwise a validated copy is kept in the current window with an explicit
+notice, disappearing on page reload. Native Qt custom MIME is
+`application/x-chemvas-selection+json`, with no direct implemented
+cross-adapter OS clipboard exchange. An open note editor retains native text
+editing shortcuts; opening the Edit menu ends note editing and applies
+clipboard actions to the canvas. Clipboard access and permission prompts vary
+by browser.
+
+Ring Fill reuses the native complete-cycle selection and opaque pastel blend.
+Select a ring interior, all of its atoms, or all of its bonds, then choose a fill
+swatch. Partial atom and bond selections are not combined to invent a cycle.
+Ring Fill opens its palette while Select remains active. Transparent ring
+interiors also support Shift selection, dragging, Color and erasing. Color paints
+the ring's atoms and bonds; erasing a ring interior removes only its fill. Undo
+restores the previous state. Equal-distance bond hits use the native grid order.
+
+File > Export MOL downloads the selected structure as a `.mol` file named
+after the document, matching the desktop menu. Use Select All to export all
+chemistry. Attached marks supply charges and radicals. Empty or nonchemical
+selections are refused, and the shared MOL writer reports unsupported
+structures or V2000 limits. Optional RDKit expands abbreviation labels when
+available. Export leaves the document and its history unchanged.
+
+File > Export Figure downloads the whole sheet as a plain SVG file named after
+the document, with a trailing `.chemvas` replaced by `.svg`. The browser does not draw this figure. For each export the server
+starts a separate Python process that opens a private copy of the current
+document in the desktop's offscreen canvas and runs the desktop's existing SVG
+figure export; that process uses Qt, writes only inside a private temporary
+folder the server creates for that export, and ends with the export. The server
+removes that folder once the process has ended, including after a failed export
+or a stopped one. The browser saves the result as an ordinary
+download; the server has no option to write it anywhere else. An open note is
+committed first, which can itself change the document and its history, and a
+note that is not saved stops the export. One export runs at a time, and the
+result for a document changed, renamed, replaced, busy or loading while the
+export ran is dropped. The desktop export's own messages, such as
+"There is nothing to export.", are shown unchanged. An export that runs longer
+than 120 seconds is stopped, output over the size limit of the desktop's
+command-line rendering is refused, and an export process that fails, including
+one where Qt cannot start, reports that the figure could not be exported. In
+each case nothing is downloaded. The export itself leaves the document, its
+revision and its Undo/Redo history unchanged. Selection-only export, PDF, PNG, TIFF, editable
+SVG and the options of the desktop's export dialog are not available in the
+browser.
+
+View > Valence Checking underlines atoms with more ordinary bonds than their
+charge allows, using the desktop's rules: hydrogen, boron, carbon, nitrogen,
+oxygen and fluorine in their common charge states, read from each atom's stored
+charge and radical annotations, which mark edits keep in sync. Abbreviation
+labels, radicals, dotted bonds and metal coordination are not assessed.
+Underlines follow atoms you drag, and a
+change's new warnings appear once it is accepted. It is on for each opened
+drawing, is a view setting only and never blocks saving or exporting; the
+underline is not part of the drawing or of any saved or exported file.
+As on the desktop, only warnings on items in the visible area are drawn, each
+along just the visible width. One view draws at most 50,000 zigzag points; the
+warnings past that, in atom order, are left out until fewer are in view.
+
+## Connections still in progress
+
+Remaining object handles,
+panels and the rest of publication export await their existing workflow
+adapters. Their original toolbar/menu positions remain visible with
+unconnected actions disabled. The browser has no separate simplified
+editors or figure renderer for these actions.
+
+Text notes and note boxes display and select, move, delete, rotate, flip and
+align like the desktop. The Text tool (T) adds a note where you click or edits
+the note you click, starting with all its text selected; Esc or clicking away
+finishes, and an emptied note is removed. The Text page steps the font size and
+toggles bold, italic, superscript, subscript and alignment for the selected text,
+or for every selected note when none is open, and the Color tool recolors whole
+notes. With only a caret, a button sets the format of the text you type next, as
+on the desktop; moving the caret, pasting or deleting clears it, and a caret
+format alone saves nothing. Pasted text keeps the browser's formatting, and an
+empty line uses the document's default format. Input methods and caret placement
+still need checks across browsers and platforms. Undo (Ctrl+Z or Cmd+Z) and Redo
+(Ctrl+Shift+Z, Cmd+Shift+Z or Ctrl+Y) inside the open note step through its own
+changes: a run of typing or deletion, a new paragraph, a formatting change or
+committed input-method text is one step. The browser's context-menu Undo and Redo
+may stay unavailable there, since the browser enables them from its own
+history, and a spelling correction keeps the browser's formatting. Notes with
+lists or non-point
+font sizes open read-only as plain text.
+
+Every bond style displays, including outward and either doubles. Right-clicking a
+double bond offers the desktop's Inward, Centered and Outward positions.
+
+Documents with a calculation plan stay editable. An edit that leaves the plan's
+components behind drops it from that version with the desktop's save warning;
+Undo brings it back.
+
+Images, groups and perspective views are editable. Images can be selected,
+moved, deleted, rotated and flipped (the pixels stay upright while their box
+moves, as on the desktop), brought to front or sent to back together with
+shapes, aligned, distributed and grouped; Insert Image and Image Properties use
+the desktop's validation, budget, placement and fields. Embedded image sources
+may take a document up to the 96 MiB full document budget, while the drawing
+without them stays within 2 MiB. Groups keep the desktop's group state: Edit >
+Group and Ungroup (Ctrl+G, Ctrl+Shift+G), selections that complete to whole
+groups, the dashed group box, and the desktop's refusal to join two groups'
+molecules. Perspective views read their stored depth points through the
+desktop's rules, and each accepted edit keeps the points that still project onto
+a live atom; the Perspective Rotation tool is not connected. Downloaded
+copies keep all document data. Font rasterization, label hit shapes, bond junctions,
+rich text, menus, file dialogs and clipboard behavior still require browser and
+platform work. Qt remains the complete editor.
+
+The server binds to loopback, verifies Host/Origin and the launch credential, and
+serves an explicit asset allowlist. It stores up to 16 in-memory sessions (at that limit, windows
+idle for 30 minutes are closed to make room), accepts
+image-stripped drawings up to 2 MiB, full documents including embedded images
+up to 96 MiB, and open/session HTTP requests up to 98 MiB. It accepts documents
+up to 2,000 atoms/3,000 bonds and uses the existing 100-command history limit.
+These are per-request/session limits, not an overall process memory cap. Recovery durability requires an accepted edit and a successful draft write; on a failed write, the previous good copy remains on disk and latest in-memory edits may not be recoverable. Uncommitted note text and edits not yet accepted at unload may be lost, and the browser's leave-page warning remains in place.
+
+## Maintenance and verification
+
+`bootstrap/web_adapter.py` owns HTTP/session and existing-service connections.
+The browser modules contain transport, SVG materialization and DOM/event wiring.
+Editing algorithms remain in their existing owners. A Qt-specific boundary may
+accept a point factory or an unrecorded candidate caller; it must preserve the
+native default behavior. Do not add a second edit engine, a second figure
+renderer, per-tool forwarding files or copied rollback blocks. See
+[ADR 0029](adr/0029-browser-adapter.md).
+
+`make check` covers Qt-free imports, actual HTTP requests, rejected retired actions,
+failed-edit atomicity, Undo/Redo, document preservation and JavaScript checks.
+Tests require Node.js 20+. `tests/test_web_clipboard.py`, `tests/test_web_drafts.py`
+and `tests/web_adapter.test.mjs` verify clipboard payload creation, paste planning,
+draft locking, envelope persistence, takeover and session client recovery actions.
+Differential tests compare actual Qt and browser bond creation, click direction,
+benzene attachment/fusion, deletion and bond styles. They also compare ring line
+coordinates at two bond lengths with and without ring records, and exercise web → Qt
+save → web edit/Undo → Qt reopening. Real browser checks cover gestures and layout.
+Clipboard behavior varies across browsers and platforms; these checks do not establish
+complete UI, recovery or output parity, and Qt retirement needs separate acceptance.
+
+Browser arrow gestures use a 10-screen-pixel Manhattan drag threshold. The browser cannot read the desktop system drag-distance preference; Qt continues to use that preference. Moving selected arrows or lines joins a moved end to another arrow's or line's end when it comes within 12 screen pixels at the current zoom, as the desktop's selection drag does: the whole selection shifts by that amount, and the move preview and the release use the same rule. The connect mark the desktop shows during that drag is not shown in the browser yet.
+
+Select resolves graphics hits and scene coordinates through a revision-bound session pick, without changing or rendering the document. Ordinary selection uses the existing preferred structure policy; Shift selection and eraser use direct atom/bond hits and the native near-bond fallback. The fixed browser bond hit stroke is removed. A selection drag retains release coordinates while its pick is pending; cancellation and document changes discard late results. Eraser resolves and deletes in one candidate edit. Native arrow-near tolerance (six screen pixels beyond direct hits) and transparent ring-interior picking are connected.
+Area selection currently requires the SVG intersection API. In browsers without
+it, including Firefox, a drag is cancelled, the prior selection is restored and
+an explicit notice is shown. Use Shift-click or Select All there, or select the
+area in the Qt version.
+
+Selected arrows and lines use the original path commands and native selection
+width, with the same 1.5-screen-pixel outline above drawing content. Separate
+head/stem subpaths retain their overlapping boundaries. SVG luminance masks
+materialize the stroke boundary without Qt or a second arrow geometry algorithm.
+SVG stroking/antialiasing can differ from Qt's path stroker, especially around
+curves and joins; this is not pixel-identical rendering.
+
+Molecular selection uses the native bond-band choice, atom-indicator rule and
+connected-component calculation. A read-only, revision-bound query returns the
+parts for the current selection. Move previews return those parts from the same
+candidate document; selection changes discard older replies. Selected ring
+interiors expand to their atoms for the outline, and unlabeled bonded carbons no
+longer get separate filled circles. SVG morphology draws the boundary of each
+component's combined alpha shape, without a second graph or bond algorithm.
+Its raster kernel can differ from Qt's vector union at corners and small zoom
+levels. Label selection now uses the native layout rectangle, document margin
+and shared circular/padded bounds. Ink rectangles remain exclusively for glyph
+picking. The original three-radius-width threshold decides whether a label keeps
+a circle or receives an expanded box; measurement completion and previews carry
+those bounds with their candidate. Font-engine metric differences remain.
+Plain atom and arrow label line heights measure the browser's normal font line
+at a 2048-pixel em, then rescale before the native document ceiling. This retains
+the font's line gap without the half-pixel rounding of a small HTML span. Arial,
+Times New Roman and Courier New at 8–64 pixels, regular/bold/italic, match the
+native macOS document heights in 513 browser-measured comparisons. This does not
+establish equivalence for every installed or fallback font. Wheel line deltas
+continue to use their ordinary CSS line height.
+
+Select's angle field and Rotate button connect the original numeric rotation
+command. Edit → Rotate focuses that field. Its default is 15 degrees, range
+-180 through 180, with one-degree step buttons; Enter applies the entered angle.
+Selected bonds and ring fills expand to their atom IDs. The pivot uses atom
+positions, arrow endpoint/control bounds and native shape bounds, without label
+extents. Atom coordinates, annotation records and ring polygons use the original
+transform and move services. Shapes remain upright while their centers orbit the
+pivot, matching Qt. One application creates one Undo/Redo entry; an empty selection
+or zero angle creates none. Preview and rejected edits leave the document intact.
+Horizontal and vertical flip reuse the same whole-selection pivot and original
+annotation transforms. The native Select icons, Edit menu actions and
+Command/Control+Shift+H/V shortcuts are connected. Equilibrium mirroring and
+above/below label exchange, arc handedness and shape bounds follow the original
+transform. A zero-length equilibrium arrow rejects the whole edit before any
+mutation. Repeated flips remove the native serializer’s omitted false mirror flag
+while preserving record identity. Each flip has one Undo/Redo entry; empty and
+unchanged selections create none.
+
+Alignment and equal-gap distribution use the original native object grouping and
+rectangle-delta calculations. Selecting any atom, bond or ring moves its complete
+connected molecule without changing its internal geometry. Six alignment icons,
+two distribution icons and their Edit submenus reuse the original definitions.
+Outer objects stay fixed during distribution; fewer than two/three objects are
+no-ops. Bounds include full atom-label layout and native path-item stroke bounds;
+arrow labels do not enlarge their parent item’s alignment box, matching Qt.
+Missing font measurements reject the edit. Candidate preview, publication and
+Undo/Redo use the existing document/history path. A group aligns and
+distributes as one object.
+
+Diagonal round line caps now include Qt’s cubic control envelope in bounds,
+correcting the small alignment offset from using a painted-circle box. Curved
+arrow bounds retain the approximation described below, so their alignment can
+inherit that documented coordinate error.
+
+Selection frames and their rotation knobs reuse the native eligibility, padding,
+corner radius, stem and handle sizes. Frames include full atom-label layout bounds
+and arrow-label blocks. Like Qt, two selected atoms or a selected arrow/line get a
+frame; shapes alone do not. Shape bounds are excluded from the visual frame even
+when numeric rotation's pivot includes the selected shapes. The frame lives above
+outlines and below object handles; its knob stays the same size on screen.
+
+Dragging the knob sends pointer positions to the original rotation-angle function,
+with the native 15-degree Shift step. Each preview transforms the committed press
+state, rather than the preceding preview. Release applies one history command;
+Escape/cancellation discards previews, and returning to the press position is an
+exact no-op. A late preview cannot overwrite a finished or cancelled drag.
+
+Qt's path-item bounds use stroked cubic control envelopes. The browser adapts
+quadratic commands into offset control bounds with bounded subdivision; painted
+arrow paths are unchanged. Cap/join envelopes and independent-side subdivision
+are approximate: the 684-case native comparison has a maximum coordinate/size
+error of 1.93 document units at a six-unit pen after the round-cap correction. Extreme-bend oversizing from the
+initial unsplit control bounds (about 195 units) is corrected. This is not exact
+Qt frame geometry for every curve or a claim of pixel parity.
+
+Decorative shape selection uses the existing ellipse/rectangle paths and native
+selection padding, including borderless and collapsed shapes. The same SVG
+component-boundary renderer used for molecules draws each selected shape above
+content and below resize handles; move/resize previews consume candidate geometry.
+Qt shape selection now explicitly uses winding fill to avoid holes caused by
+an inherited odd-even rule in borderless rectangles. Combining the two native
+stroke widths in SVG differs slightly at flattened caps: native region sampling
+matches outside a 0.25-document-unit boundary fringe, and SVG rasterization still
+has the corner/antialiasing limitations above. This is not pixel parity.
+
+
+The status-bar grid control cycles None → Hex → Square → None and exposes the
+native 15%, 20% and 25% strengths. View → Snap to Grid toggles the current style.
+Native background segments form bounded SVG tiles at the scene origin, spaced
+by half the current bond length. Below six screen pixels the grid is hidden,
+while snapping stays enabled. Arrow/Line drawing and endpoint handles use the
+original endpoint → explicit angle → grid funnel; molecules, shapes and curve
+midpoints retain their existing behavior. Grid choices are transient tool state,
+not saved document settings or Undo entries. SVG and Qt antialiasing can differ.
diff --git a/docs/adr/0029-browser-adapter.md b/docs/adr/0029-browser-adapter.md
new file mode 100644
index 00000000..6e5dfcc0
--- /dev/null
+++ b/docs/adr/0029-browser-adapter.md
@@ -0,0 +1,366 @@
+# ADR 0029: Browser presentation adapter over existing editing owners
+
+- Status: Extended by [ADR 0032](0032-browser-clipboard-and-drafts.md)
+- Date: 2026-09-28
+- Extends: [ADR 0005](0005-responsibility-based-editor-boundaries.md)
+
+## Problem
+
+The browser must preserve Chemvas editing rules while the Qt application remains
+available. Independent browser commands, label/movement policies, annotation
+editors and a browser-drawn SVG download path would introduce a second set of
+behaviors.
+ADR 0005 requires one owner for each mutation rule and rejects obligatory
+access/port/service layers.
+
+## Decision
+
+Select presentation through `chemvas --ui qt` or `chemvas --ui web`; Qt is the
+default. Use browser-native modules and SVG without a frontend build framework.
+Reuse the existing toolbar declarations, artwork, palette and dimensions.
+Keep HTTP/session, DOM wiring and SVG materialization cohesive; do not split
+by tool or impose file-length targets.
+
+`BrowserStructureAdapter` connects the existing `StructureBondBuildService`,
+`StructureBenzeneBuildService` and `StructureBuildCommitter` to document records.
+The Ring tool is the desktop's template session: it starts on benzene, the Ring
+page chooses among `TEMPLATE_ENTRY_SPECS`, and a click runs the same request,
+plan, `TemplateGeometryResolverService` (now given the browser's point type) and
+`commit_template_ring` (now Qt-free, shared with the desktop commit). The hover
+preview is a read-only `template_preview` query that runs the same plan and
+resolver (benzene through the builder's placement plan) and returns the preview
+geometry of `plan_template_preview_update`, drawn with the shared preview color
+and opacity.
+Atom picking, endpoint geometry, bond-click policy and deletion planning use
+shared existing owners. Existing model serialization preserves document data.
+Rendering services supply SVG bond primitives, including ring topology order.
+Qt imports stay at native materialization or transaction boundaries. Injected
+point factories and unrecorded build calls preserve native defaults.
+
+The Python session owns a committed document. An edit runs against a private
+candidate, validates the whole result, records it through the existing
+`CanvasHistoryService`, then publishes it. JavaScript has no history stack.
+Gesture previews are disposable candidates using the same edit path. Revision
+checks reject stale writes; reads can recover the current revision without
+replaying an uncertain edit. If recovery fails, the next action only reconnects.
+Document names travel with the authoritative session so replacement-response loss
+cannot retain the previous file name. Each existing session serializes its own
+requests; registration uses the server inventory lock. History commands retain
+only documents and regenerate drawing on replay, using the existing history
+transaction port to preserve state if rendering fails. The adapter adds no copied
+try/restore sequence.
+
+Atom-label direction and anchor decisions live with the existing pure label
+layout functions. The browser measures font advances, ascent, descent, line height
+and glyph ink at the desktop's integer pixel sizes under pinned 96 DPI. Each
+session owns one replaceable `BrowserFontMeasurements` value, separate from the
+committed document and history. The font family is fixed by the native style;
+metric/ink key counts, glyph point counts and HTTP body size are bounded.
+The browser also retains only its most recently requested measurement set.
+
+Edits, previews and history replay use the registered measurements to call
+`place_runs` / `place_hydride_stack` and the original bond planner in one pass.
+Missing glyphs return a measurement request instead of rendering a discarded,
+unclipped scene. A revision-bound measurement action completes the accepted
+snapshot, or reconstructs a disposable preview from its edit. It carries no
+document and never repeats a committed mutation. Validation and rendering must
+succeed before the new font value replaces the old one. Measurement updates and
+previews do not advance revisions, mark documents dirty or create history entries.
+A failed presentation triggers read-only resynchronization using the accepted
+session identity, including on first load. Stale measurement/preview requests are
+rejected before any font change. The old stateless label, drawing and preview HTTP
+routes are removed; pure label placement remains the shared rendering boundary.
+The Qt renderer consumes the same direction, placement and clipping owners.
+
+Arrow path calculation moves from `ArrowRenderer` into the existing Qt-free
+`line_geometry` module. Its move/line/quadratic commands feed both QPainterPath
+and SVG, including native equilibrium shortening, arc tangents and default curve
+controls. The native renderer retains records, pens, item lifetime and label
+children. Internal per-kind construction methods collapse into one path adapter;
+the curved-handle entry point remains. The provisional browser single-head path
+is removed. Browser arrow items expose canonical records to the existing
+CanvasMoveController; selection buckets feed the existing deletion planner.
+Mixed graph/arrow changes remain one private candidate and one history command.
+Arrow creation adapts pointer coordinates and modifiers into the native endpoint
+snap funnel in line_geometry, then constructs the existing Arrow record.
+Native LineTool also consumes this funnel for endpoint/Shift/grid priority.
+The desktop retains its system drag-distance setting; the browser uses 10 CSS
+pixels because the setting is unavailable there. Arrow kinds, More arrows
+grouping and icons reuse desktop declarations. Line creation and arrow style controls reuse native rules, presets and slider
+ranges. Arrow endpoint mutation and curve midpoint/carry calculations move to
+this same geometry owner; the Qt services retain point conversion and scene updates.
+SVG displays server-supplied handle positions with native screen size and snapped
+fill. A handle request carries the current pointer and the last accepted preview
+position, so a rejected short endpoint frame retains its preceding valid position.
+Each preview is disposable; release waits for an in-flight preview and publishes
+one existing history command. Cancellation or revision changes discard the gesture.
+A move edit may carry the view scale. After the existing translation, the
+browser offers the moved arrows' and lines' ends to the other arrows' and lines'
+ends through `endpoint_connection` in line_geometry, the rule the desktop's
+`connection_for` now calls on each selection-drag frame: the smallest shift that
+puts a moved end on another end within `ENDPOINT_SNAP_SCREEN_PX` at that scale
+moves the whole selection once more, in the same candidate and history command.
+A move without a scale only translates. The desktop's on-drag connect mark is
+not connected.
+Arrow labels are edited through an `arrow_labels` edit and previewed through a
+read-only `label_preview` query, using the shared label syntax and placement.
+Arrow and Line drawing previews return the ends the endpoint funnel caught
+(`points_on_endpoints`), which the browser draws as the desktop's snap rings at
+their on-screen size.
+The browser limits a drawing to 500,000 arrow path points before publishing a
+candidate, including read-only loads.
+
+Unconnected tools stay at their original UI positions and remain disabled.
+Mark graphics adapt document records to the existing move controller, deletion
+planner and independent-selection policy. Native placement, mark/atom priority,
+transform offsets and bond-length offset scaling are shared calculations. Selection
+uses the native hit shapes and circular indicator; alignment receives measured
+native item bounds. These editing ports are compared against actual Qt commands,
+and consistent marked documents can now be edited. The original Mark options
+connect creation; the context menu connects explicit owner reassignment using the
+same annotation validation and before/after plan as Qt. Candidate highlighting and
+cancellation do not change the document. Inconsistent mark/annotation records and
+isotopes remain read-only. Charge shortcuts use the existing shortcut service with
+native opposite-mark cancellation and compass/overflow placement shared by both
+adapters. Font measurements carry advance width, rendered bounding width and
+glyph ink separately: mark clearance consumes bounding width, while collision
+checks consume ink. Neither substitutes for the other. The session retains native per-atom mark binding order through edits,
+measurement completion and the existing history commands. This transient order is
+not written to `.chemvas` files; opening a file initializes it from document order,
+as Qt does. Native deletion undo also restores the previous binding indices, so
+undoing a deletion cannot change the next opposite-charge cancellation. Selected
+marks use native owner text and distance checks for the owner guide and amber
+warning. Mark hover calls the same placement calculation as insertion and renders
+a transient free glyph without updating the live document or electronic annotations.
+Its color, opacity, stacking and atom indicator reuse native style values. Pointer
+requests coalesce while a response is pending; leaving the canvas or changing the
+revision cannot publish an obsolete preview. Mark measurement requests reject
+concurrent direct edits. Charge keypresses retain their pointer target and session
+and await the preceding charge edit before invoking that same measurement/edit
+path. A rejected edit stops its pending chain; a replaced session cannot receive
+keypresses from the previous document. These
+limits and platform font geometry differences still prevent mark-editing parity. Bond-length changes use the existing model scaling operation and preserve
+free annotation positions.
+Scrollable bounds combine the sheet and persistent drawing with the native sheet
+margin through the shared `sheet_scene_bounds` calculation. Accepted snapshots
+supply these bounds; previews do not replace them. The browser clamps navigation
+and candidate highlighting to that range and centers axes smaller than the
+viewport. Candidate scrolling uses the native margin and integer conversion.
+Qt scrollbar quantization can differ from the continuous SVG boundary by one
+screen pixel. Arrow bounds retain the existing half-pen approximation of Qt's
+stroke controls; rich arrow labels currently use their measured layout box
+rather than Qt's exported glyph outline. These remain geometry differences.
+Brackets use the shared stroke commands and dagger layout that the desktop fills.
+Insertion, drag preview, selection, movement, deletion, rotation, flipping,
+alignment and scroll range use the bounds of Qt's flat-cap, miter-join outline;
+the browser samples curves along exact normals, which agrees with Qt within
+0.001 scene units, including flat brackets whose zero-length sides Qt drops and
+whose reversing joins it clips at the miter limit. Dagger bounds come from the
+browser's measured glyph ink. A dagger's hit target is that ink box and its
+six-pixel near search measures to the ink's convex hull, which can catch a point
+beside a crossbar slightly earlier than Qt's glyph outline. Near picking of
+bracket strokes measures to the same outline ring. The Color tool keeps the desktop notice and does not recolor
+brackets.
+Text notes render the saved note HTML that `sanitize_note_html` accepts. The
+server rewrites it into a closed browser subset (paragraphs, breaks and inline
+runs), resolves point sizes to Qt's integer pixels and scales script runs from
+the integer point size, as QTextEngine does. Because the page's CSP ignores style
+attributes, the browser applies those validated declarations through CSSOM. It
+then gives every line a zero-width strut: Qt sizes a line from its own runs'
+ascent, descent and leading, ceils it, and places proportional spacing below the
+text, where CSS would split it around the text. Measured note boxes join the
+existing rich-text box measurements; heights and baselines agree with Qt, widths
+within the browser's glyph advance rounding. Notes rotate about their position
+and flip by mirroring their scene box's centre; both rules now live in
+`rotate_annotation` and `mirrored_box_position`, which the desktop scene code
+also calls. Picking follows the native foreground-note rule, and the selection
+box is the padded layout rectangle with the native screen-width stroke. Scroll
+bounds use the layout rectangle rather than Qt's glyph outline. Every selected
+note shows that box, including marquee selections, where Qt paints its own
+dashed frame instead. The Text tool follows `NoteTool`: a click on a note edits
+it with its text selected (Ctrl toggles and Shift extends the selection
+instead), and a click elsewhere on the sheet starts a new note there. The
+editor is a contenteditable overlay transformed with the note, carrying the
+note's validated rich text; while it is open it uses the document line pitch
+rather than the per-line struts. Ending the edit serializes the blocks and
+runs into the saved subset (Qt point sizes from `data-pt`, weight, slant,
+decoration, color and script alignment) and sends one `note_text` edit, like
+NoteItem's focus out: unchanged text records nothing, a new empty note is
+dropped and an emptied note is deleted. The server sanitizes the HTML, derives
+the plain text as `toPlainText` does and rejects formatting it could not render
+back. The browser context bar uses the desktop's tool-to-page map, so the note
+tool shows the desktop Text page, built from the same size and format
+declarations. Formatting follows `CanvasNoteController`: the target is the open
+editor's selection, else each selected note whole; bold, italic and script
+toggles follow the format before the cursor end, sizes step each run by one
+point within 6-96, alignment applies to touched blocks, and a button is checked
+only when the whole target shares its format. The editor applies the change to
+its block and run model at once, in the markup `note_markup` returns for those
+runs, so text typed meanwhile keeps its content and format. A reply is applied
+only while the editor still holds the runs it was sent, and the server remains
+the owner of that accepted rendering; before it arrives, the editor's transient
+pixel and script sizes mirror `browser_font_pixels` and `qt_script_pixels`,
+which a differential test compares. Selected notes change together in one
+`note_format` edit that may not alter their text. The Color tool
+recolors whole notes inside the same color edit as the rest of the selection:
+like `apply_note_color`'s whole-document merge, every character run takes the
+color and an empty paragraph's own character format keeps it, which restores
+to the same Qt character formats. A caret without a selection follows Qt's
+typing format: toggles and size steps accumulate on a format held only by the
+open editor, starting from the character before the caret, or after it at the
+start of a nonempty paragraph, and change neither the document nor history.
+Text typed at that caret, including committed input-method text, takes that
+format in the editor at once, and a new paragraph started there keeps it;
+navigation keys, pointer presses and other caret moves drop it. Ending the edit
+still sends the single `note_text` edit above. Undo (Ctrl+Z or Cmd+Z) and Redo
+(Ctrl+Shift+Z, Cmd+Shift+Z or Ctrl+Y) inside the open editor, and the browser's
+own `historyUndo` and `historyRedo` input, restore the editor's own steps,
+because the browser's native history cannot follow the editor re-rendering
+formatted runs; Ctrl+Alt (AltGr) combinations stay with the keyboard. Each
+change is one step: contiguous typing, or contiguous deletion in one direction,
+extends the step before it, while a new paragraph and a committed composition
+are one step each, and caret formats, cancelled compositions and `note_markup`
+replies record none. This grouping is the editor's own rule, not a measured
+copy of Qt's, and steps are not limited in number. An input with no
+`beforeinput` of its own after the last input, Undo, Redo or composition
+records no step and clears Redo. The browser decides whether its context-menu
+Undo and Redo are available from its own native history, which the editor does
+not feed, so those items can be unavailable while the editor has steps; the
+editor does not enable them. Pasted, dropped, deleted and replaced content,
+such as a spelling correction, keeps the browser's own runs and drops the
+pending format; the
+desktop's plain-text paste with a pending format has not been compared. An
+empty paragraph, or a position after a line break, with nothing pending uses
+the document's default format rather than Qt's stored format. Input-method
+composition, font rendering and caret placement at run boundaries still need
+checks in real browsers and platforms. Lists and non-point font sizes are not
+connected; notes with them keep the document read-only and display as plain
+text.
+There is no separate graph-patch editing endpoint, plain-note dialog, simplified
+arrow editor or browser-drawn figure export. Every document bond style now goes
+through `BondGeometryPlanService`, so no bond style keeps a document read-only.
+The double-bond context menu is a read-only `bond_menu` query that takes the
+native context target (the picked bond, else the nearest bond within the wider of
+0.35 bond lengths and the structure pick radius) and lists
+`DOUBLE_BOND_CONTEXT_STYLES` with `style_for_double_position`; choosing an entry
+applies the resulting style at order 2, as the desktop menu does. A calculation
+plan does not draw on the canvas, so it travels with edits; after each accepted
+edit `calculation_plan_save_warning`, shared with the desktop snapshot, decides
+whether the plan still matches the graph, and a stale plan is left out of that
+version with the desktop's warning while history keeps the prior version.
+Atom annotations that no mark implies stay as they are, as on the desktop,
+which resynchronizes an atom's annotation only when its marks change.
+Perspective views are editable: the drawing reads the stored depth points
+through the desktop's 3D geometry ports, whose rules live in
+`domain/document/perspective.py`, so ring double bonds project the same way.
+The shared move controller carries a moved atom's point, a bond length change
+rescales the points as the desktop does, and each accepted edit saves only the
+points that still project onto a live atom. The Perspective Rotation tool is not
+connected yet. Groups are editable: the adapter keeps the desktop's group state
+with candidate record identities as scene record ids, so deletes and reordering
+carry references the way the desktop snapshot does. Group rules live in
+`features/groups`: Edit > Group and Ungroup (Ctrl+G, Ctrl+Shift+G) plan the
+same merge, connection preflights refuse joining two groups' molecules with the
+desktop's message, new bonds and label merges extend the owning group, and
+Align and Distribute treat a group as one object. The drawing lists each
+group's selection keys, and the browser completes a selection to whole groups
+and toggles a group as one unit; the dashed group box uses the desktop's
+padding, corner radius and dash pattern. Images are editable: select, move,
+delete, rotate and flip (the pixels stay upright and the box moves as on the
+desktop), Bring to Front and Send to Back over images and shapes together,
+Align and Distribute, and grouping. Embedded image sources travel only when a
+document is opened and in the `export` answer that Save downloads; every other
+session response replaces `data_base64` with a SHA-256 `data_ref`, and the
+browser fetches each source once from an authorized `/api/image` request as a
+Blob URL. Open and session requests accept a document up to the desktop's
+document budget, while the drawing without image sources stays within 2 MiB.
+File > Export MOL is an `export_mol` session query under the same revision
+check. Like the desktop's selected-only export, it takes the selected atoms,
+bonds and ring-fill atoms, or else the owners of selected charges and
+radicals, builds the shared `build_3d_conversion_payload`, and writes it
+through `export_molfile_block`, the policy the desktop's `export_mol` now
+calls: the V2000 writer, its hard limits, and the optional RDKit expansion of
+abbreviations. The answer is Molfile text that the browser downloads; the
+query changes no document, revision or history, and the browser drops a reply
+for a document replaced or edited meanwhile.
+File > Export Figure is an `export_figure` session query under the same
+revision check, accepting only `format` `svg`. The browser sends `scope`
+`sheet` and downloads the returned `svg` text as plain SVG named after the
+document, a trailing `.chemvas` replaced by `.svg`. The desktop export paints a Qt scene, so the threaded server does not
+call it in-process and still imports no Qt: `figure_svg` runs one short-lived
+child of the same interpreter (`-I`, this checkout's `app` path) per request.
+The child receives a JSON copy of the accepted document on stdin, creates its
+QApplication, opens the document with `offscreen_canvas` and calls the existing
+`export_figure(fmt="svg")` into a private `TemporaryDirectory` that
+`figure_svg` creates per request and passes as a child argument, then returns
+the text. `figure_svg` owns that folder: it removes it after the child exits,
+whether the export succeeded, was refused or failed, and after a timeout once
+`subprocess.run` has killed and waited for the child. The browser never
+supplies a server path.
+The native export is the only SVG writer and its file output is the test
+oracle; there is no browser figure renderer and no SVG post-processing. Bounds:
+120 seconds per export, the `MAX_OUTPUT_BYTES` limit of the command-line
+renderer, and a fixed refusal when the child exits abnormally, as when Qt
+cannot start. The desktop export's own `ValueError` messages pass through
+unchanged. The query itself changes no document, revision or history. The
+browser first commits an open note, which can record its own edit, and a note
+that is not saved stops the export; it runs one export at a time and drops a
+reply for a document replaced, renamed, edited, busy or loading meanwhile. A non-empty selection is
+refused, and selection-only scope, PDF, PNG, TIFF, editable SVG and the export
+dialog's options are not connected.
+Atom input is a revision-bound session query, so no request sends the document
+back. Insert Image sends the chosen file's bytes with the visible scene rect,
+and the adapter applies the desktop's validation, budget and placement
+(`inserted_image_box`); the new image is selected and Select becomes active.
+Image Properties follows `IMAGE_PROPERTIES_SPEC`: the dialog sends only the
+fields that changed, so accepting it unchanged is a no-op, and its aspect lock
+follows the image's pixel ratio.
+
+SMILES placement now connects the existing optional `RDKitAdapter`,
+`plan_smiles_commit` and `apply_smiles_commit_plan`. The commit owner accepts a
+point factory and an unrecorded private candidate, keeping its default Qt
+transaction unchanged. Its label access imports the existing Qt-free shape
+policy directly instead of importing the Qt renderer. There is no second
+insertion algorithm and no required RDKit dependency. Input bounds and entry
+labels are shared with the desktop. Each browser session retains one parsed model
+keyed by normalized text and bond length; preparation never changes the committed
+document. Charged and radical candidates request the existing bounded font
+measurements before using the native label-aware mark offsets. Materializing
+those marks preserves parsed annotations rather than applying a new charge edit.
+The browser renders only additions from a disposable candidate at one group
+opacity. Pointer requests coalesce, cancellation and session/revision changes
+discard late replies, and a click completes preview measurements before the
+single existing history edit. Missing RDKit and unsupported chemistry retain the
+native failures. Undo/Redo and save/reopen keep the ordinary document path.
+
+Open uploads a selected file; Save, Export MOL and Export Figure download.
+There is no filesystem write API, client-chosen server path or persistent
+document store. The server binds to loopback, verifies its own
+Host/Origin and fresh launch credential, and serves a fixed asset allowlist.
+Qt remains supported until full workflow, recovery, document and output parity
+is demonstrated and a separate retirement decision is made.
+
+## Verification and limits
+
+`tests/test_web_adapter.py` checks Qt-free imports, actual HTTP authorization,
+malformed input, document versions 7–9, failed candidate publication, history and
+rejection of retired demo actions. It invokes `tests/web_adapter.test.mjs` for
+transport failure, concurrency, text escaping and non-mutating gesture display.
+
+Differential tests compare actual Qt and browser bonds, benzene attachment/fusion,
+deletion, styles and line coordinates, plus document reopening across adapters.
+Native tests retain recorded-build rollback and ring-cache behavior. Real browser
+checks cover gestures and layout; these are not complete visual-parity evidence.
+
+Atom labels and merging reuse `AtomLabelService` and `AtomLabelMergeService`;
+selected-atom/bond dragging uses `CanvasMoveController`. Shift-click and Select All
+provide multiple selection. Bold polygons reuse `BondGraphicsDrawService`.
+Annotations, marquee selection, other object handles, recovery and the rest of
+publication export remain migration work. Browser glyph sampling still differs slightly from Qt
+font outlines; bond junctions and platform input also remain incomplete. Requests are limited
+to 2 MiB and documents to 2,000 atoms/3,000 bonds, with at most 16 memory sessions
+and the existing history limit. At that limit, sessions idle for 30 minutes are
+closed so a crashed tab cannot hold a slot until the server restarts. These bounds do not cap total process memory.
+The existing Qt document and scientific contracts remain in effect.
+
+Selection picking is a revision-bound, read-only session action that returns a target without rendering the document or entering history. The browser supplies all direct graphics hits, including atoms underneath arrow ink; the adapter applies native role priority and the existing preferred structure picker. Shift selection and eraser retain raw graphics/near-bond semantics. Eraser picking stays inside its candidate edit. Pending pointer selection stores release coordinates and discards results after cancellation or a document revision change. No new HTTP route or separate selection history is introduced.
diff --git a/docs/adr/0030-cdxml-export.md b/docs/adr/0030-cdxml-export.md
new file mode 100644
index 00000000..011f69b2
--- /dev/null
+++ b/docs/adr/0030-cdxml-export.md
@@ -0,0 +1,186 @@
+# ADR 0030: CDXML export
+
+- Status: Proposed
+- Date: 2026-10-03
+- Depends on: ADR 0005 (responsibility-based editor boundaries)
+
+## Context
+
+Chemvas documents contain molecular structures and annotations that users may
+need to edit in ChemDraw or exchange with tools that consume the CDXML format.
+Project 1 evidence shows that native editable CDXML with graphics and text is
+feasible using filled rectangles, circles, lines and styled text runs. No
+prior converter source exists; only output evidence at 170 mm page width.
+
+## Decision
+
+1. **Export only.** CDXML import remains refused. The existing `.chemvas`
+ format is the document truth; CDXML is an export representation.
+
+2. **One owner module.** `app/chemvas/ui/export/export_cdxml.py` reads the
+ `SceneRenderContext` model and scene items, receives the shared
+ `ExportPlan`, and serializes CDXML bytes. No new domain wrapper package.
+
+3. **Uniform scale.** One scale `s = out_w_pt / source_w` applies to all
+ coordinates, font sizes, line widths, bold widths, hash spacings, arrow
+ geometry, and bond lengths. Origin is the plan source (x, y). The page
+ width is `out_w_pt` in points, matching the 170 mm fractional-point
+ contract from the evidence. Numeric precision uses 12 significant digits
+ (`.12g` format) where coordinates, page widths, fonts, and line attributes
+ are emitted.
+
+4. **Native fragment/node/bond for pure elements.** Atom labels map to
+ `` only when the label is a recognized element symbol with a
+ known atomic number. Alias labels (including Ac and Ts, which collide
+ with acetyl and tosyl abbreviations), multi-element abbreviations, and
+ any label not in the periodic table refuse with
+ `CdxmlUnsupportedObjectError`.
+
+5. **Bond style/order consistency.** Only single/1, double_center/2 and
+ triple/3 combinations are supported. All other bond styles (wedge, hash,
+ bold, dotted, double_either, double ring-side, dotted_double) refuse.
+ A mismatched style and order (e.g. `Bond(order=2, style="single")`)
+ refuses explicitly.
+
+6. **Formal charge: native atom annotation only.** The `Charge` attribute
+ maps directly from `atom_annotation["formal_charge"]`. Scene marks
+ (including `plus`/`minus` charge marks) have no proved CDXML mapping and
+ are explicitly refused. The annotation value is emitted as a native atom
+ attribute; it does not depend on the mark being visible.
+
+7. **Graphics: evidenced constructs only.**
+ - Line (NoHead, Solid): headless straight lines, endpoint-order
+ BoundingBox.
+ - Filled Rectangle, Plain Rectangle: rect shapes with/without fill.
+ - Circle Filled, Circle (Oval): circle shapes via Center/MajorAxis/
+ MinorAxis with axis-end-first BoundingBox.
+ - Arrow as grouped line components (shaft + head lines). Line
+ components carry BoundingBox only (no Head3D/Tail3D). Only M and L
+ path commands are supported; any other command refuses. The
+ `mirrored` property on arrows is explicitly refused.
+ - Shape stroke width uses the source `shape_stroke_width(style.
+ bond_line_width)`, not the renderer-scaled metric.
+ - Stroke-only shapes and uncoloured arrows emit `style.bond_color`
+ when it is non-black.
+
+8. **Text: per-run preservation.** Walk QTextDocument blocks and layout
+ lines. Each laid-out line becomes a `` with per-fragment `` runs
+ carrying family, scaled size, bold face bit, and color. Font family
+ uses the actual resolved font (`QFontInfo(font).family()`), not the
+ requested family. Font size uses `QRawFont` pixel size scaled once.
+ Glyph fallback is detected via `QTextLayout.glyphRuns()` and refused
+ when any run's raw font family differs from the primary resolved family.
+ Italic, underline (all styles including dash), overline, strikeout,
+ sub/superscript, non-mixed-case capitalization (uppercase, small caps),
+ custom word spacing, non-default font stretch, backgrounds, lists,
+ tables, bitmap fonts, and non-400/700 weights refuse. Non-left
+ alignment (detected via `blockFormat().hasProperty(BlockAlignment)` then
+ document `defaultTextOption` alignment) refuses. Note boxes (both
+ fill-enabled and border-only) refuse. No whitespace is injected inside
+ ``/`` text content by indentation.
+
+9. **Preflight before write.** `preflight_cdxml` checks every visible item
+ before any file I/O. `CdxmlUnsupportedObjectError` names the object kind,
+ id, and reason, and suggests SVG/PDF as alternatives. The atomic write path
+ preserves destination bytes on failure. Hidden items (including hidden atom
+ items) in the export set are excluded. Bond items whose endpoints are not
+ both in the exported atom set are refused.
+
+10. **Transform checks.** Both note and atom label items are checked for
+ rotation: `m12 approx 0`, `m21 approx 0`, and `m11 approx m22 > 0`.
+ This catches pure rotations, `setTransform`-applied rotations, and
+ factory items with no record. The check does not depend on any note
+ record's rotation field.
+
+11. **Color table indices.** Reference index = table position + 2 (implicit
+ indices 0 and 1). White and black are always the first two entries.
+
+12. **Groups.** Group emission is not yet implemented. `FigureExportService`
+ accepts a typed `groups` provider. The GUI passes
+ `runtime_state.group_state.groups`; the CLI restores from
+ `state["groups"]` using the existing `restored_groups` helper and the
+ single-owner `GROUP_COLLECTION_STATES` dict. Groups overlapping with
+ exported atoms via `atom_ids` refuse. Groups with `item_ids` that
+ intersect the exported items' `data(3)` record ids refuse; groups
+ whose items are entirely outside the export scope are ignored.
+
+13. **Z-order.** Every page child carries a `Z` attribute with monotonically
+ increasing integers. Sort order uses `scene.items(AscendingOrder)` rank,
+ which reflects actual scene stacking including z-value ties. Fragment
+ layer interleaving (a non-fragment item whose scene rank falls between a
+ fragment's lowest and highest item, overlapping spatially) and two
+ overlapping fragments that interleave in z-order are both refused.
+
+14. **Atom labels.** Label baseline and x-coordinate are read from the
+ actual glyph origin: `item.sceneTransform()` applied to
+ `layout.position() + line.x()` for x, and `layout.position() + line.y()
+ + line.ascent()` for the baseline y. Missing owner, layout, or record
+ refuses. Font family is the resolved `QFontInfo(atom_font()).family()`.
+ Hydrogen counts are not emitted; `InterpretChemically="yes"` on the root
+ delegates inference to the consumer. This is documented as unproved.
+
+15. **Empty page refusal.** Serialization refuses if the page would contain
+ zero children (e.g. a bond-only selection with no atoms).
+
+16. **No ChemDraw interoperability claim.** The dialog label reads
+ "CDXML - ChemDraw XML (not verified in ChemDraw)". The vendor DTD has
+ known lexical limitations (`AS` attribute type for `NMTOKEN` values).
+ This export does not claim DTD validity.
+
+## Scope and consequences
+
+CDXML appears in `EXPORT_FORMATS`, `_SUFFIX`, `_FILTER`, and
+`_FORMAT_SUFFIXES` in the dialog module. The CLI `render-document` accepts
+`.cdxml` output. `FigureExportService.export_figure` branches for the `cdxml`
+format key and runs preflight before the atomic write. `validate_export_budget`
+is called before serialization for `max_height_mm` validation.
+
+Existing SVG, PDF, PNG, and TIFF export contracts are unchanged. CDXML does
+not support `--min-font-pt` (no font measurement contract for XML output).
+
+Perspective rendering refuses rather than silently omitting depth. The root
+element carries `FractionalWidths="yes"`.
+
+### Supported
+
+| Area | Emitted |
+|-----------|-------------------------------------------------------|
+| Atoms | Pure elements, colour, explicit C, Charge annotation |
+| Bonds | Orders 1-3 as single/1, double_center/2, triple/3 |
+| Lines | Headless solid lines, endpoint-order BoundingBox |
+| Arrows | Arrow as grouped line components (M/L only, no Head3D)|
+| Shapes | rect, circle; stroke-only, fill-only, or same colour; bond_color emitted for stroke-only |
+| Notes | Per-line `` with per-run resolved family, size, bold, color |
+
+### Refused
+
+| Area | Refused |
+|-----------|--------------------------------------------------------|
+| Atoms | Aliases (incl. Ac/Ts), radicals, any scene mark; hidden atoms are excluded and bonds to them are refused |
+| Bonds | Wedge, hash, bold, dotted, double_either, ring-side; mismatched style/order |
+| Lines | Dashed, wavy, bold lines; mirrored lines |
+| Arrows | Curved, double, other kinds, labels, mirrored; non-M/L path commands |
+| Shapes | Ellipse, rounded_rect, dashed/dotted stroke, translucent fill, mixed colours |
+| Notes | Italic, underline (all styles), overline, strikeout, scripts, non-mixed-case capitalization, custom word spacing, non-default font stretch, lists, tables, rotation (m12/m21 != 0), non-400/700 weights, non-left alignment, boxes/borders, glyph fallback fonts |
+| Stacking | Fragment layer interleaving, overlapping interleaved fragments |
+| Other | Ring fills, brackets, images, orbitals, perspective, groups, empty pages |
+
+### Unproved / consumer-dependent
+
+- **Hydrogen counts**: not emitted; consumer infers from element and bond
+ count. This is documented as unproved; there is no evidence tying the
+ consumer inference to the Chemvas model for all cases.
+- **`CaptionLineHeight="1"`**: emitted on node labels; consumer behaviour
+ is unproved.
+- **`charset="utf-8"`**: emitted on font table entries; consumer behaviour
+ with non-ASCII text is unproved.
+- **`InterpretChemically="no"` on node labels under a root `"yes"`**:
+ consumer behaviour is unproved.
+- **U+2028 soft breaks**: may appear inside `` text; consumer behaviour
+ is unproved.
+- **Tab characters**: may appear inside `` text; the tab advance width
+ is consumer-dependent and unproved.
+- **Round caps and joins**: CDXML defaults may differ from the scene
+ rendering; consumer interpretation is unproved.
+- **No DOCTYPE**: the output has no DOCTYPE declaration; DTD validity is
+ not claimed.
diff --git a/docs/adr/0031-standalone-contract-validation.md b/docs/adr/0031-standalone-contract-validation.md
new file mode 100644
index 00000000..0da47ed3
--- /dev/null
+++ b/docs/adr/0031-standalone-contract-validation.md
@@ -0,0 +1,79 @@
+# ADR 0031: Standalone contract validation
+
+- Status: Accepted
+- Date: 2026-10-03
+
+## Problem
+
+Building and testing Chemvas required cloning the external `machine-contracts`
+repository into `~/machine_contracts` (or setting `FACTORY_MACHINE_CONTRACT_REPO`),
+and CI checked out that repository at a pinned SHA in every job that ran
+contract-validating tests. A fresh clone or worktree could not run `make check`
+without this prerequisite, and CI depended on a second-repository checkout for
+every pull request and push.
+
+The coupling points were:
+
+- `scripts/check.sh` lines 170-177: failed when the external clone was absent.
+- `.github/workflows/ci.yml`: two jobs checked out `dhsohn/machine-contracts`.
+- `.github/workflows/platform.yml`: set `FACTORY_MACHINE_CONTRACT_REPO` at the
+ gate step.
+- `tests/calculation_workflow_support.py`: read `FACTORY_MACHINE_CONTRACT_VALIDATOR`
+ and failed when unset.
+- `CONTRIBUTING.md` / `CONTRIBUTING.ko.md`: documented the clone as a mandatory
+ setup step.
+
+## Decision
+
+The contract assets required for Chemvas validation are copied into the
+repository as a project-local snapshot under `contracts/machine-observation/`.
+
+The snapshot is scoped to the `chemvas` producer: it carries the envelope schema,
+the two `chemistry/elementary-step` payload schemas (v1 and v2), the semantic
+validator (exact copy), and a project-scoped derivative of the registry
+containing only chemvas routes. Routes and schemas for other producers are
+excluded.
+
+Provenance is recorded in `contracts/machine-observation/NOTICE.md` with the
+upstream repository URL, the pinned commit SHA
+(`38581a7737cd0521bb3d50b59115eefe3c9254ca`), and a file-level mapping between
+local and upstream paths. The upstream MIT license is included verbatim.
+
+The gate, CI, and test helper now resolve the validator from the project-local
+path unconditionally. No environment variable, external clone, or CI checkout
+of `machine-contracts` is required.
+
+Contract changes are owned by this project's maintainers; upstream landing is
+not a prerequisite. Local changes must record provenance and type, include
+schema/semantic regression coverage, and follow ADR/versioning for public
+contract surface changes. Syncing a newer upstream pin is an optional
+compatibility path: copy the updated files, then update `NOTICE.md` (commit
+SHA), `PROVENANCE.json` (per-file SHA-256 digests), and
+`PIN_HASHES`/`REGISTRY_HASH` in `tests/test_contract_compatibility.py`,
+preserving original schema/semantic compatibility and full source-pin
+provenance for unchanged copied files.
+
+## Verification and limits
+
+- The focused gate (Ruff/format/mypy and tests) passes in a fresh checkout
+ with no `~/machine_contracts` directory and no `FACTORY_MACHINE_CONTRACT_REPO`
+ environment variable. Full gate and CI have not yet been verified at the time
+ of this ADR.
+- `tests/test_contract_compatibility.py` validates all chemvas reference
+ fixtures against the local validator, tests schema/route rejection semantics,
+ duplicate-key rejection, machine-path artifact verification, delivery
+ consistency semantics, and proves the validator runs standalone without
+ external paths. Per-file SHA-256 digests anchored to the pin commit are
+ asserted against `PROVENANCE.json`.
+- `tests/test_check_entrypoint.py` verifies the gate finds the local validator
+ without `FACTORY_MACHINE_CONTRACT_REPO`.
+- The project-scoped registry validates chemvas observations identically to the
+ full upstream registry for all chemvas routes and rejects unregistered
+ producers. Error message text may differ from upstream (e.g. "payload contract
+ is not registered" locally vs. "producer, operation, and payload route is not
+ registered" upstream for a payload name registered only upstream); verdict
+ equivalence holds, but message text is not frozen.
+- Interior objects in the payload schemas remain open for producer-side growth
+ without a contract update, matching upstream COMPATIBILITY.md.
+- This decision does not cover automated upstream drift detection: comparing the
+ local snapshot against a newer upstream commit is a manual step.
diff --git a/docs/adr/0032-browser-clipboard-and-drafts.md b/docs/adr/0032-browser-clipboard-and-drafts.md
new file mode 100644
index 00000000..2107be3b
--- /dev/null
+++ b/docs/adr/0032-browser-clipboard-and-drafts.md
@@ -0,0 +1,124 @@
+# ADR 0032: Browser chemical clipboard and recovery drafts
+
+- Status: Accepted
+- Date: 2026-10-03
+- Extends: [ADR 0005](0005-responsibility-based-editor-boundaries.md), [ADR 0029](0029-browser-adapter.md)
+
+## Problem
+
+The browser presentation adapter previously had no automatic draft or recovery
+mechanism and lacked chemical clipboard operations:
+
+1. **Recovery across loopback restarts**: Chemvas binds its local web server to
+ an ephemeral loopback port. Origin-bound browser storage (`localStorage`,
+ `IndexedDB`) can persist through server restart, but a different port makes
+ it inaccessible to the new origin. Storing drafts in browser-origin storage
+ would isolate unsaved work whenever the server is relaunched on a new port.
+2. **Clipboard consistency without parallel schemas**: Chemical Copy, Cut and
+ Paste require atom ID remapping, cascade offsets, group preservation, image
+ budget enforcement, and perspective coordinate reprojection. Implementing
+ a browser-specific serialization or mutation path would violate ADR 0005, which
+ mandates a single mutation owner and prohibits parallel editing logic. In
+ addition, browser API capabilities vary, and this adapter uses an
+ asynchronous plain-text clipboard transport subject to browser permissions
+ and user gestures; native Qt custom MIME is
+ `application/x-chemvas-selection+json`, with no direct implemented
+ cross-adapter OS clipboard exchange.
+
+## Decision
+
+### Canonical chemistry clipboard over existing services (extending ADR 0005)
+
+The browser adapter reuses the desktop's v3 selection payload format
+(`chemvas-selection`) and canonical services without introducing a parallel
+chemistry schema:
+
+- **Payload generation and validation**: For supported selections, Copy and Cut
+ invoke `build_selection_clipboard_payload` and validate selections through
+ `decode_clipboard_selection_payload`. The payload encompasses atoms, bonds,
+ complete rings, attached and isolated marks, arrows, decorative shapes, orbitals,
+ text notes, groups, embedded images (respecting collection budgets), and
+ perspective 3D points; the existing validator may refuse unsupported, corrupt,
+ or oversized payloads atomically. Cut deletes the selection as a single edit
+ only after a valid payload is confirmed and the source document revision matches.
+- **Paste execution**: Incoming JSON text is decoded by
+ `decode_clipboard_selection_payload`, planned with
+ `build_clipboard_paste_plan`, and applied via `apply_paste_payload`. Atom IDs
+ remap past the target document's `next_atom_id`, repeated pastes cascade with
+ bond-length offsets, groups keep remapped membership, and only perspective
+ depth points reproject through the target camera. A paste executes as
+ exactly one Undo/Redo command.
+- **Transport and fallback**: When permitted by the browser, selections travel as
+ plain text (`text/plain`) on the system clipboard. If the browser denies or
+ lacks clipboard permission, a validated copy is retained in window-local memory
+ with an explicit notification; this fallback copy is discarded on page reload.
+- **MIME boundary**: Because native Qt uses `application/x-chemvas-selection+json`
+ and the web adapter uses system text, direct OS clipboard exchange between Qt
+ and web windows is not implemented despite format compatibility.
+- **Text note editing**: Active text note editing retains native text shortcut
+ semantics. Opening the Edit menu concludes note editing and routes clipboard
+ commands to the canvas.
+
+### Durable recovery drafts with single-server ownership (extending ADR 0029)
+
+To ensure drafts survive port changes on server restart without introducing Qt
+dependencies, draft persistence is owned by `bootstrap/web_drafts.py`:
+
+- **Single-server folder lock**: Drafts reside in a per-user application
+ directory (or a CLI-specified `--drafts-dir`). A single running server process
+ owns the drafts folder by holding an OS lock on `owner.lock` (`msvcrt.locking`
+ on Windows, `fcntl.flock` on POSIX). A second server launched concurrently runs
+ with automatic recovery disabled and reports this status to the user.
+- **Envelope format**: After each accepted edit, the complete `.chemvas` drawing
+ is written atomically inside a versioned JSON envelope containing `format`
+ (`chemvas-browser-draft`), `version` (`1`), `name`, `saved_at`, and the
+ `document` payload. Drafts store no launch tokens, origins, or session
+ identifiers. Files are stored unencrypted and are capped at a 96 MiB document
+ budget plus a 64 KiB envelope.
+- **Stable draft identifier**: Each unsaved document is assigned a stable random
+ draft file ID. Upon recovery via File > Recover Unsaved Work, the recovering
+ window adopts the existing draft file ID without creating duplicate files.
+ Recovered drawings remain unsaved; downloading a copy via File > Save does not
+ guarantee a disk write and never clears dirty state or removes a draft. A draft
+ is removed only when its document is discarded, replaced after discard
+ confirmation, or undone to a clean baseline. Closing a window or stopping the
+ server keeps drafts on disk.
+- **Window takeover**: Recovering a draft currently open in another window
+ requires explicit confirmation, which claims the draft and terminates the other
+ window's session. Takeover is refused if the holder is busy beyond the
+ bounded wait (5 seconds) or if its latest accepted changes failed to reach
+ the recovery draft; the other window remains open so the user can switch to it
+ and save a copy or resolve draft-write failure. A crashed tab whose server
+ remains running can still be listed as open until the user explicitly takes
+ over its draft or restarts the server, as idle session cleanup occurs only when
+ the session cap is reached.
+- **Capacity and write failures**: Up to 16 drafts are retained without
+ age-based expiration. A failed atomic write preserves the previous valid draft
+ file on disk and reports an error, meaning in-memory edits from a failed write
+ are not yet recoverable. Damaged or incompatible draft files are retained for
+ explicit user discard. Filesystem and folder access failures are reported
+ directly.
+
+## Verification and limits
+
+- **Automated tests**: `tests/test_web_clipboard.py` verifies selection payload
+ construction, round-trip decoding, atom ID remapping, cascade offsets, group and
+ mark preservation, and single-step Undo/Redo. `tests/test_web_drafts.py`
+ validates process folder locking, multi-server lockout, restart persistence,
+ takeover semantics, clean-baseline deletion, and corrupt-envelope handling.
+ `tests/web_adapter.test.mjs` verifies client-side clipboard fallbacks, paste text
+ handling, and SessionClient recovery operations.
+- **Test scope distinction**: Python tests create actual server objects and HTTP
+ requests to verify persistence and session contracts, rather than simulating
+ physical crashes or browser reloads. Client test cases verify client actions and
+ recovery workflows without rendering full browser dialogs or testing native OS
+ permission prompts.
+- **Lifecycle limits**: Recovery durability requires an accepted edit and a
+ successful draft write. On a failed write, the previous valid draft file
+ remains on disk, and latest in-memory edits may not be recoverable.
+ Uncommitted text in an open note and edits in flight that have not been accepted
+ by the server at page unload may be lost; the browser leave-page warning
+ (`beforeunload`) remains active.
+- **Platform limits**: Direct physical OS clipboard exchange between Qt and web
+ is not supported. Clipboard permission behavior varies by browser. Full UI
+ parity and Qt retirement are not claimed.
diff --git a/pyproject.toml b/pyproject.toml
index 04f1753c..eea51b6c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -70,7 +70,7 @@ include = ["chemvas*"]
namespaces = false
[tool.setuptools.package-data]
-chemvas = ["assets/icon/*.svg", "assets/icon/*.png"]
+chemvas = ["assets/icon/*.svg", "assets/icon/*.png", "web/*.html", "web/*.css", "web/*.mjs"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -97,6 +97,7 @@ target-version = "py312"
# first-party classification (and therefore I001 ordering) a property of this
# file rather than of ruff's directory inference.
src = ["app"]
+extend-exclude = ["contracts/machine-observation/scripts"]
[tool.ruff.lint]
select = [
diff --git a/scripts/check.sh b/scripts/check.sh
index 80c8356b..db71950d 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -163,19 +163,14 @@ if ! "$PYTHON" -c 'import sys' >/dev/null 2>&1; then
exit 1
fi
-# Without the environment variable the machine.json assertion in
-# tests/test_calculation_step_cli.py passes while validating nothing, so the
-# gate resolves the canonical validator itself and exports it for the whole
-# test pass.
-CONTRACT_REPO="${FACTORY_MACHINE_CONTRACT_REPO:-${HOME:-}/machine_contracts}"
-CONTRACT_VALIDATOR="$CONTRACT_REPO/scripts/validate.py"
+# The project-local contract validator runs unconditionally so machine.json
+# conformance assertions never pass while validating nothing.
+CONTRACT_VALIDATOR="$ROOT/contracts/machine-observation/scripts/validate.py"
if [[ ! -f "$CONTRACT_VALIDATOR" ]]; then
echo "[check] ERROR: contract validator not found at $CONTRACT_VALIDATOR." >&2
- echo "[check] Clone dhsohn/machine-contracts there, or set FACTORY_MACHINE_CONTRACT_REPO." >&2
+ echo "[check] The project-local contract assets are missing from this checkout." >&2
exit 1
fi
-export FACTORY_MACHINE_CONTRACT_VALIDATOR="$CONTRACT_VALIDATOR"
-
if ! "$PYTHON" -c 'import jsonschema, coverage' >/dev/null 2>&1; then
echo "[check] ERROR: required test dependencies (jsonschema, coverage) are missing." >&2
echo "[check] Install the development dependencies with: $PYTHON -m pip install -e '.[dev]'" >&2
@@ -203,11 +198,6 @@ case "$platform" in
esac
echo "[check] Platform/dependency skips are reported by pytest; native packaging and RDKit have dedicated CI jobs."
-pinned="$(sed -n 's/^ *ref: *\([0-9a-f]\{40\}\).*/\1/p' .github/workflows/ci.yml | head -1)"
-local_head="$(git -C "$CONTRACT_REPO" rev-parse HEAD 2>/dev/null || echo unknown)"
-if [[ -n "$pinned" && "$pinned" != "$local_head" ]]; then
- echo "[check] NOTE: contract clone is at ${local_head:0:7}, CI pins ${pinned:0:7}."
-fi
echo "[check] Ruff"
"$PYTHON" -m ruff check .
@@ -279,6 +269,7 @@ case "$platform" in
test_scheme_layout_canvas.py
test_startup_fresh_workspace.py
test_ui_audit_regressions.py
+ test_web_adapter.py
)
;;
esac
diff --git a/tests/calculation_workflow_support.py b/tests/calculation_workflow_support.py
index 53846c92..c91b5452 100644
--- a/tests/calculation_workflow_support.py
+++ b/tests/calculation_workflow_support.py
@@ -4,7 +4,6 @@
import hashlib
import json
-import os
import subprocess
import sys
from pathlib import Path
@@ -26,14 +25,24 @@
from collections.abc import Mapping
+_CONTRACT_VALIDATOR = (
+ Path(__file__).resolve().parents[1]
+ / "contracts"
+ / "machine-observation"
+ / "scripts"
+ / "validate.py"
+)
+
+
def _validate_common_machine(path: Path) -> None:
- validator = os.environ.get("FACTORY_MACHINE_CONTRACT_VALIDATOR")
- if not validator:
+ if not _CONTRACT_VALIDATOR.is_file():
pytest.fail(
- "FACTORY_MACHINE_CONTRACT_VALIDATOR is required for machine.json "
- "conformance assertions"
+ f"Contract validator not found at {_CONTRACT_VALIDATOR}; "
+ "the project-local contract assets are missing"
)
- subprocess.run([sys.executable, validator, "--machine", str(path)], check=True)
+ subprocess.run(
+ [sys.executable, str(_CONTRACT_VALIDATOR), "--machine", str(path)], check=True
+ )
def _write_document_with_plan(path: Path, *, complete_mapping: bool = True) -> None:
diff --git a/tests/gui_workflow_support.py b/tests/gui_workflow_support.py
index ba1ee9a0..ec6f9843 100644
--- a/tests/gui_workflow_support.py
+++ b/tests/gui_workflow_support.py
@@ -15,7 +15,7 @@
from chemvas.ui.annotations.state import scene_item_state_for
from chemvas.ui.history.history_commands import AddSceneItemsCommand
from chemvas.ui.molecule.structure_mutation_access import add_bond_between_points_for
-from chemvas.ui.selection.selection_handles import ROTATION_HANDLE_TYPE
+from chemvas.ui.window.main_window_config import ROTATION_HANDLE_TYPE
from chemvas.ui.window.main_window_ports import (
active_canvas_for_window,
set_zoom_percent_for_window,
diff --git a/tests/test_application_cli.py b/tests/test_application_cli.py
index 2640d1f9..d75417c3 100644
--- a/tests/test_application_cli.py
+++ b/tests/test_application_cli.py
@@ -255,9 +255,13 @@ def test_unknown_command_never_loads_qt_in_a_fresh_process(tmp_path: Path) -> No
("drawing.svg", 96),
],
)
+@pytest.mark.parametrize(
+ "platform", ["offscreen", "windows"] if sys.platform == "win32" else ["offscreen"]
+)
def test_qt_options_are_consumed_before_desktop_document_selection(
document: str | None,
logical_dpi: int,
+ platform: str,
) -> None:
script = textwrap.dedent("""
import sys
@@ -271,6 +275,12 @@ def test_qt_options_are_consumed_before_desktop_document_selection(
opened = []
def desktop_boundary(app):
assert app.testAttribute(Qt.ApplicationAttribute.AA_Use96Dpi)
+ # Windows offscreen uses FreeType rather than the desktop engine.
+ if sys.platform != "win32" or app.platformName() == "windows":
+ from PyQt6.QtGui import QFont, QRawFont
+ raw_font = QRawFont.fromFont(QFont("Arial", 12))
+ assert raw_font.isValid()
+ assert raw_font.pixelSize() == 16, raw_font.pixelSize()
assert sys.argv[1:] == expected, (sys.argv[1:], expected)
assert app.style().objectName() == 'fusion'
assert opened == expected[:1], (opened, expected[:1])
@@ -285,11 +295,11 @@ def desktop_boundary(app):
""")
env = os.environ.copy()
env["PYTHONPATH"] = str(APP_ROOT)
- env["QT_QPA_PLATFORM"] = "offscreen"
+ env["QT_QPA_PLATFORM"] = platform
env["QT_FONT_DPI"] = str(logical_dpi)
arguments = [
"-platform",
- "offscreen",
+ platform,
"-style",
"Fusion",
"-qwindowgeometry",
diff --git a/tests/test_atom_label_service.py b/tests/test_atom_label_service.py
index cc195dd9..3d3f6f7f 100644
--- a/tests/test_atom_label_service.py
+++ b/tests/test_atom_label_service.py
@@ -241,7 +241,7 @@ def test_prompt_atom_label_applies_dialog_result(self) -> None:
service.add_or_update_atom_label = Mock()
with patch(
- "chemvas.ui.molecule.atom_label_service.QInputDialog.getText",
+ "PyQt6.QtWidgets.QInputDialog.getText",
side_effect=[
(" N ", True),
(" ", True),
@@ -257,8 +257,8 @@ def test_prompt_atom_label_applies_dialog_result(self) -> None:
service.add_or_update_atom_label.assert_has_calls(
[
- call(2, "N", show_carbon=True),
- call(1, "C", show_carbon=False),
+ call(2, "N", record=True, show_carbon=True),
+ call(1, "C", record=True, show_carbon=False),
]
)
self.assertEqual(get_text.call_args_list[-1].kwargs["text"], "")
diff --git a/tests/test_canvas_arrow_build_service.py b/tests/test_canvas_arrow_build_service.py
index 0b27b73e..a358d940 100644
--- a/tests/test_canvas_arrow_build_service.py
+++ b/tests/test_canvas_arrow_build_service.py
@@ -2,7 +2,6 @@
import os
import unittest
from types import SimpleNamespace
-from unittest import mock
from tests.runtime_state import canvas_runtime_state
from tests.scene_render_context import attach_scene_render_context
@@ -10,9 +9,10 @@
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QPointF, Qt
-from PyQt6.QtGui import QColor, QPainterPath, QPen
+from PyQt6.QtGui import QColor, QPen
from PyQt6.QtWidgets import QApplication
+from chemvas.features.rendering import arrow_head_polylines
from chemvas.ui.annotations.arrows import ArrowRenderer
from chemvas.ui.canvas.canvas_tool_settings_state import CanvasToolSettingsState
@@ -70,20 +70,10 @@ def test_build_double_head_and_dotted_arrow_preserve_metadata_and_pen_style(
start = QPointF(0.0, 0.0)
end = QPointF(12.0, 0.0)
- with mock.patch.object(
- service, "add_arrow_head", wraps=service.add_arrow_head
- ) as add_arrow_head:
- double_head = service.build_double_head_arrow(start, end)
- dotted = service.build_dotted_arrow(start, end)
-
- self.assertEqual(
- add_arrow_head.call_args_list[:3],
- [
- mock.call(mock.ANY, start, end, double=False),
- mock.call(mock.ANY, end, start, double=False),
- mock.call(mock.ANY, start, end, double=False),
- ],
- )
+ double_head = service.build_arrow_item(start, end, "resonance")
+ dotted = service.build_arrow_item(start, end, "dotted")
+ self.assertEqual(double_head.path().elementCount(), 8)
+ self.assertEqual(dotted.path().elementCount(), 5)
self.assertEqual(dotted.pen().style(), Qt.PenStyle.DashLine)
self.assertNotEqual(double_head.pen().style(), dotted.pen().style())
self.assertFalse(double_head.path().isEmpty())
@@ -96,8 +86,8 @@ def test_build_inhibition_and_equilibrium_items_cover_specialized_paths(
start = QPointF(0.0, 0.0)
end = QPointF(10.0, 0.0)
- inhibition = service.build_inhibition_arrow(start, end)
- equilibrium = service.build_equilibrium_item(start, end)
+ inhibition = service.build_arrow_item(start, end, "inhibit")
+ equilibrium = service.build_arrow_item(start, end, "equilibrium")
inhibition_path = inhibition.path()
self.assertEqual(inhibition_path.elementCount(), 4)
@@ -151,7 +141,7 @@ def test_build_equilibrium_item_draws_outward_facing_harpoons(self) -> None:
start = QPointF(0.0, 0.0)
end = QPointF(10.0, 0.0)
- path = service.build_equilibrium_item(start, end).path()
+ path = service.build_arrow_item(start, end, "equilibrium").path()
# Two harpoons, each a shaft (move + line) and one barb (move + line).
# A full arrow head would add a second barb segment to every line.
@@ -174,7 +164,7 @@ def test_build_equilibrium_item_keeps_barbs_outward_when_drawn_diagonally(
start = QPointF(-3.0, 2.0)
end = QPointF(4.0, -6.0)
- path = service.build_equilibrium_item(start, end).path()
+ path = service.build_arrow_item(start, end, "equilibrium").path()
dx = end.x() - start.x()
dy = end.y() - start.y()
@@ -194,18 +184,15 @@ def offset_from_axis(element) -> float:
self.assertGreater(offset_from_axis(path.elementAt(6)), reverse_shaft)
def test_add_arrow_head_supports_double_offset_heads(self) -> None:
- service, _ = self._make_service()
- path = QPainterPath()
-
- service.add_arrow_head(path, QPointF(0.0, 0.0), QPointF(10.0, 0.0), double=True)
-
- self.assertEqual(path.elementCount(), 6)
- tip_a = path.elementAt(1)
- tip_b = path.elementAt(4)
- self.assertAlmostEqual(tip_a.x, 10.0)
- self.assertAlmostEqual(tip_b.x, 10.0)
- self.assertLess(tip_a.y, 0.0)
- self.assertGreater(tip_b.y, 0.0)
+ heads = arrow_head_polylines(
+ (0.0, 0.0), (10.0, 0.0), head_len=6.0, line_width=2.5, double=True
+ )
+ self.assertEqual([len(points) for points in heads], [3, 3])
+ tip_a, tip_b = heads[0][1], heads[1][1]
+ self.assertAlmostEqual(tip_a[0], 10.0)
+ self.assertAlmostEqual(tip_b[0], 10.0)
+ self.assertLess(tip_a[1], 0.0)
+ self.assertGreater(tip_b[1], 0.0)
def test_arrow_pen_applies_line_width_and_optional_dash(self) -> None:
service, _ = self._make_service()
diff --git a/tests/test_canvas_geometry_logic.py b/tests/test_canvas_geometry_logic.py
index 9ca5d992..0f2ad574 100644
--- a/tests/test_canvas_geometry_logic.py
+++ b/tests/test_canvas_geometry_logic.py
@@ -1,7 +1,12 @@
import math
+import subprocess
+import sys
import unittest
+from pathlib import Path
from chemvas.ui.canvas.canvas_geometry_logic import (
+ glyph_contour_clip_t,
+ glyph_convex_hull,
line_rect_clip_t,
ray_rect_exit_distance,
segment_intersection_t,
@@ -63,3 +68,71 @@ def test_ray_rect_exit_distance_handles_inside_outside_and_zero_direction(
self.assertTrue(
math.isinf(ray_rect_exit_distance((0.0, 0.0), (0.0, 0.0), rect))
)
+
+ def test_glyph_hull_closes_gaps_and_discards_duplicate_interior_points(self):
+ points = [(0, 0), (4, 0), (4, 2), (0, 2), (1, 1), (2, 1), (4, 0)]
+ self.assertEqual(glyph_convex_hull(points), [(0, 0), (4, 0), (4, 2), (0, 2)])
+ self.assertEqual(glyph_convex_hull([]), [])
+ self.assertEqual(glyph_convex_hull([(1, 1), (1, 1)]), [])
+
+ def test_glyph_contours_include_the_whole_parallel_band(self):
+ contour = ((2, 1), (4, 1), (4, 2), (2, 2), (2, 1))
+ self.assertIsNone(glyph_contour_clip_t((0, 0), (10, 0), [contour]))
+ # Neither edge crosses the ink: the full band still clears it.
+ self.assertEqual(
+ glyph_contour_clip_t((0, 0), (10, 0), [contour], ((0, -3), (0, 3))),
+ (0.2, 0.4),
+ )
+ self.assertEqual(
+ glyph_contour_clip_t((0, 0), (10, 0), [contour], ((-1, -3), (2, 3))),
+ (0.0, 0.5),
+ )
+
+ def test_glyph_contours_keep_outermost_crossings_and_containment(self):
+ left = ((2, -1), (4, -1), (4, 1), (2, 1), (2, -1))
+ right = tuple((x + 4, y) for x, y in left)
+ self.assertEqual(
+ glyph_contour_clip_t((0, 0), (10, 0), [left, right]), (0.2, 0.8)
+ )
+ self.assertEqual(
+ glyph_contour_clip_t((10, 0), (0, 0), [left, right]), (0.2, 0.8)
+ )
+ self.assertEqual(
+ glyph_contour_clip_t((3, 0), (10, 0), [left], start_inside=True),
+ (0.0, 1 / 7),
+ )
+ self.assertEqual(
+ glyph_contour_clip_t(
+ (3, 0), (3.5, 0), [left], start_inside=True, end_inside=True
+ ),
+ (0.0, 1.0),
+ )
+ self.assertIsNone(glyph_contour_clip_t((0, 0), (0, 0), [left]))
+ self.assertIsNone(
+ glyph_contour_clip_t(
+ (3, 0), (3, 0), [left], start_inside=True, end_inside=True
+ )
+ )
+
+ def test_glyph_math_executes_without_qt_or_site_packages(self):
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-I",
+ "-S",
+ "-B",
+ "-c",
+ (
+ "import sys; sys.path.insert(0, sys.argv[1]); "
+ "from chemvas.ui.canvas.canvas_geometry_logic import glyph_convex_hull, glyph_contour_clip_t; "
+ "hull = glyph_convex_hull([(2,-1),(4,-1),(4,1),(2,1),(3,0)]); "
+ "assert glyph_contour_clip_t((0,0),(10,0),[hull+[hull[0]]]) == (.2,.4); "
+ "assert not any(n.split('.')[0] == 'PyQt6' for n in sys.modules)"
+ ),
+ str(Path(__file__).resolve().parents[1] / "app"),
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/tests/test_canvas_view_handle_helpers.py b/tests/test_canvas_view_handle_helpers.py
index 31156b87..da5eb15b 100644
--- a/tests/test_canvas_view_handle_helpers.py
+++ b/tests/test_canvas_view_handle_helpers.py
@@ -116,7 +116,6 @@ def _make_proxy(
),
)
arrow_builder = ArrowRenderer(attach_scene_render_context(view))
- arrow_builder.add_arrow_head = mock.Mock(wraps=arrow_builder.add_arrow_head)
view.services.arrow_build_service = arrow_builder
view.services.selection.update_selection_outline = view.refresh_selection_outline
view.clear_handles = lambda: view.services.handle_overlay_service.clear_handles()
diff --git a/tests/test_cdxml_export.py b/tests/test_cdxml_export.py
new file mode 100644
index 00000000..3c9104df
--- /dev/null
+++ b/tests/test_cdxml_export.py
@@ -0,0 +1,2027 @@
+"""Tests for CDXML export.
+
+Synthetic in-test documents only. The vendor DTD is not committed; vocabulary
+closure is verified structurally.
+"""
+
+from __future__ import annotations
+
+import os
+import xml.etree.ElementTree as ET
+
+import pytest
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from chemvas.bootstrap.document_cli_shared import offscreen_document_scene
+from chemvas.core.document_io import write_document
+from chemvas.domain.document import (
+ CANVAS_FILE_VERSION,
+ Atom,
+ Bond,
+ MoleculeModel,
+ serialize_model_state,
+ serialize_settings,
+)
+from chemvas.features.export import points_for_mm
+from chemvas.ui.export.export_cdxml import (
+ CdxmlUnsupportedObjectError,
+ preflight_cdxml,
+ serialize_cdxml,
+)
+from chemvas.ui.export.figure_export_service import FigureExportService
+
+pytestmark = pytest.mark.usefixtures("qt_application")
+
+
+# ── Helpers ────────────────────────────────────────────────────────────
+
+
+def _settings(**overrides):
+ defaults = dict(
+ bond_length_px=20.0,
+ arrow_line_width=1.5,
+ arrow_head_scale=0.4,
+ orbital_phase_enabled=True,
+ text_font_size=12,
+ text_font_weight=400,
+ text_italic=False,
+ sheet_size="A4",
+ sheet_orientation="portrait",
+ )
+ defaults.update(overrides)
+ return serialize_settings(**defaults)
+
+
+def _simple_state(
+ atoms=None,
+ bonds=None,
+ notes=None,
+ arrows=None,
+ shapes=None,
+ atom_annotations=None,
+ settings_overrides=None,
+ marks=None,
+ groups=None,
+):
+ if atoms is None:
+ atoms = {0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)}
+ if bonds is None:
+ bonds = [Bond(0, 1)]
+ model = MoleculeModel(atoms=atoms, bonds=bonds)
+ if atom_annotations:
+ for aid, ann in atom_annotations.items():
+ model.set_atom_annotation(aid, ann)
+ result = {
+ "model": serialize_model_state(model),
+ "ring_fills": [],
+ "notes": notes or [],
+ "marks": marks or [],
+ "arrows": arrows or [],
+ "ts_brackets": [],
+ "shapes": shapes or [],
+ "orbitals": [],
+ "settings": _settings(**(settings_overrides or {})),
+ "last_smiles_input": None,
+ }
+ if groups is not None:
+ result["groups"] = groups
+ return result
+
+
+def _export_cdxml(state, *, target_width_mm=170.0, scope="sheet", groups=None):
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ service = FigureExportService(context, groups=groups)
+ items, plan = service._resolve_figure_export(
+ scope=scope,
+ selection=None,
+ sizing="custom",
+ target_width_mm=target_width_mm,
+ )
+ preflight_cdxml(context, items, plan, groups=groups)
+ return serialize_cdxml(context, items, plan, groups=groups), plan
+
+
+def _parse_cdxml(xml_bytes):
+ return ET.fromstring(xml_bytes)
+
+
+# ── Closed vocabulary ──────────────────────────────────────────────────
+
+_ALLOWED_ELEMENTS = frozenset(
+ {
+ "CDXML",
+ "colortable",
+ "color",
+ "fonttable",
+ "font",
+ "page",
+ "fragment",
+ "n",
+ "b",
+ "t",
+ "s",
+ "graphic",
+ "group",
+ }
+)
+
+_ALLOWED_ATTRS_BY_ELEMENT = {
+ "CDXML": {
+ "BondLength",
+ "BoldWidth",
+ "LineWidth",
+ "HashSpacing",
+ "BondSpacing",
+ "InterpretChemically",
+ "FractionalWidths",
+ "CreationProgram",
+ "bgcolor",
+ },
+ "colortable": set(),
+ "color": {"r", "g", "b"},
+ "fonttable": set(),
+ "font": {"id", "charset", "name"},
+ "page": {"id", "Width", "Height", "BoundingBox", "bgcolor"},
+ "fragment": {"id", "Z"},
+ "n": {"id", "p", "Element", "Charge", "color"},
+ "b": {"id", "B", "E", "Order", "Display", "DoublePosition", "color"},
+ "t": {"id", "p", "Z", "Justification", "InterpretChemically", "CaptionLineHeight"},
+ "s": {"font", "size", "color", "face"},
+ "graphic": {
+ "id",
+ "Z",
+ "GraphicType",
+ "ArrowType",
+ "LineType",
+ "BoundingBox",
+ "LineWidth",
+ "color",
+ "OvalType",
+ "RectangleType",
+ "Center3D",
+ "MajorAxisEnd3D",
+ "MinorAxisEnd3D",
+ },
+ "group": {"id", "Z"},
+}
+
+
+def test_vocabulary_closure():
+ state = _simple_state()
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+
+ for el in root.iter():
+ tag = el.tag
+ assert tag in _ALLOWED_ELEMENTS, f"unexpected element: {tag}"
+ allowed = _ALLOWED_ATTRS_BY_ELEMENT.get(tag, set())
+ for attr in el.attrib:
+ assert attr in allowed, f"unexpected attribute {attr} on <{tag}>"
+
+
+# ── Chemistry: connectivity, charge, element ───────────────────────────
+
+
+def test_fragment_nodes_and_bonds():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+
+ fragments = root.findall(".//fragment")
+ assert len(fragments) == 1
+
+ nodes = fragments[0].findall("n")
+ assert len(nodes) == 2
+
+ elements = {n.get("Element") for n in nodes}
+ assert "7" in elements
+ assert "8" in elements
+
+ bonds = fragments[0].findall("b")
+ assert len(bonds) == 1
+
+
+def test_carbon_default_element():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ nodes = root.findall(".//n")
+ for n in nodes:
+ assert n.get("Element") is None
+
+
+def test_formal_charge():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ atom_annotations={0: {"formal_charge": 1}},
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ nodes = root.findall(".//n")
+ charged = [n for n in nodes if n.get("Charge") is not None]
+ assert len(charged) == 1
+ assert charged[0].get("Charge") == "1"
+
+
+def test_bond_orders():
+ state = _simple_state(
+ atoms={
+ 0: Atom("C", 0.0, 0.0),
+ 1: Atom("C", 20.0, 0.0),
+ 2: Atom("N", 40.0, 0.0),
+ },
+ bonds=[
+ Bond(0, 1, order=2, style="double_center"),
+ Bond(1, 2, order=3, style="triple"),
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ bonds = root.findall(".//b")
+ orders = {b.get("Order") for b in bonds}
+ assert "2" in orders
+ assert "3" in orders
+
+
+def test_double_center_position():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, order=2, style="double_center")],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ bond = root.find(".//b")
+ assert bond is not None
+ assert bond.get("DoublePosition") == "Center"
+
+
+def test_multiple_components():
+ state = _simple_state(
+ atoms={
+ 0: Atom("C", 0.0, 0.0),
+ 1: Atom("O", 20.0, 0.0),
+ 2: Atom("N", 50.0, 0.0),
+ 3: Atom("S", 70.0, 0.0),
+ },
+ bonds=[Bond(0, 1), Bond(2, 3)],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ fragments = root.findall(".//fragment")
+ assert len(fragments) == 2
+
+
+# ── Geometry: 170 mm scaling ───────────────────────────────────────────
+
+
+def test_170mm_page_width():
+ state = _simple_state()
+ xml_bytes, plan = _export_cdxml(state, target_width_mm=170.0)
+ root = _parse_cdxml(xml_bytes)
+ page = root.find("page")
+ assert page is not None
+ width = float(page.get("Width"))
+ expected = points_for_mm(170.0)
+ assert width == pytest.approx(expected, rel=1e-9)
+
+
+def test_second_width_exact_ratio():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "line",
+ "start": [5.0, 100.0],
+ "end": [25.0, 100.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ bytes_170, plan_170 = _export_cdxml(state, target_width_mm=170.0)
+ bytes_84, plan_84 = _export_cdxml(state, target_width_mm=84.0)
+
+ root_170 = _parse_cdxml(bytes_170)
+ root_84 = _parse_cdxml(bytes_84)
+
+ page_170 = root_170.find("page")
+ page_84 = root_84.find("page")
+ w170 = float(page_170.get("Width"))
+ w84 = float(page_84.get("Width"))
+
+ expected_ratio = points_for_mm(170.0) / points_for_mm(84.0)
+ actual_ratio = w170 / w84
+ assert actual_ratio == pytest.approx(expected_ratio, rel=1e-9)
+
+ nodes_170 = root_170.findall(".//n")
+ nodes_84 = root_84.findall(".//n")
+
+ assert len(nodes_170) >= 2, "expected at least 2 nodes at 170 mm"
+ assert len(nodes_84) >= 2, "expected at least 2 nodes at 84 mm"
+
+ def node_x(n):
+ return float(n.get("p").split()[0])
+
+ span_170 = abs(node_x(nodes_170[1]) - node_x(nodes_170[0]))
+ span_84 = abs(node_x(nodes_84[1]) - node_x(nodes_84[0]))
+ assert span_84 > 0, "84 mm span must be positive"
+ coord_ratio = span_170 / span_84
+ assert coord_ratio == pytest.approx(expected_ratio, rel=1e-9)
+
+ bl_170 = float(root_170.get("BondLength"))
+ bl_84 = float(root_84.get("BondLength"))
+ assert bl_170 / bl_84 == pytest.approx(expected_ratio, rel=1e-9)
+
+ lw_170 = float(root_170.get("LineWidth"))
+ lw_84 = float(root_84.get("LineWidth"))
+ assert lw_170 / lw_84 == pytest.approx(expected_ratio, rel=1e-9)
+
+ def line_bbox_span(root):
+ for g in root.findall(".//graphic"):
+ if g.get("GraphicType") == "Line":
+ vals = [float(v) for v in g.get("BoundingBox").split()]
+ return abs(vals[2] - vals[0])
+ return None
+
+ line_span_170 = line_bbox_span(root_170)
+ line_span_84 = line_bbox_span(root_84)
+ assert line_span_170 is not None and line_span_84 is not None
+ assert line_span_84 > 0
+ assert line_span_170 / line_span_84 == pytest.approx(expected_ratio, rel=1e-9)
+
+ def first_font_size(root):
+ s_el = root.find(".//s")
+ return float(s_el.get("size")) if s_el is not None else None
+
+ size_170 = first_font_size(root_170)
+ size_84 = first_font_size(root_84)
+ assert size_170 is not None and size_84 is not None and size_84 > 0
+ assert size_170 / size_84 == pytest.approx(expected_ratio, rel=1e-9)
+
+
+def test_font_size_scales_with_width():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ bytes_170, _ = _export_cdxml(state, target_width_mm=170.0)
+ bytes_84, _ = _export_cdxml(state, target_width_mm=84.0)
+
+ def first_font_size(xml_bytes):
+ root = _parse_cdxml(xml_bytes)
+ s = root.find(".//s")
+ return float(s.get("size")) if s is not None else None
+
+ size_170 = first_font_size(bytes_170)
+ size_84 = first_font_size(bytes_84)
+ assert size_170 is not None, "expected font size at 170 mm"
+ assert size_84 is not None and size_84 > 0, "expected positive font size at 84 mm"
+ ratio = size_170 / size_84
+ expected = points_for_mm(170.0) / points_for_mm(84.0)
+ assert ratio == pytest.approx(expected, rel=1e-9)
+
+
+# ── Unicode and XML characters ─────────────────────────────────────────
+
+
+def test_xml_special_characters_in_note():
+ state = _simple_state(
+ notes=[{"text": '<&"testé>', "x": 0.0, "y": 30.0}],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ xml_text = xml_bytes.decode("utf-8")
+ assert "&" in xml_text, "expected & encoding in raw XML"
+ assert "<" in xml_text, "expected < encoding in raw XML"
+ root = ET.fromstring(xml_bytes)
+ page = root.find("page")
+ assert page is not None
+ note_ts = [t for t in page.findall("t") if t.get("InterpretChemically") == "no"]
+ assert len(note_ts) >= 1, "expected at least one note "
+ s_elements = note_ts[0].findall("s")
+ texts = [s.text for s in s_elements if s.text]
+ full = "".join(texts)
+ assert full == '<&"testé>', (
+ f"parsed note text must equal exact source, got: {full!r}"
+ )
+
+
+def test_unicode_element_labels():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("N", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ ET.fromstring(xml_bytes)
+
+
+# ── Unique IDs ─────────────────────────────────────────────────────────
+
+
+def test_unique_ids():
+ state = _simple_state(
+ atoms={
+ 0: Atom("C", 0.0, 0.0),
+ 1: Atom("N", 20.0, 0.0),
+ 2: Atom("O", 40.0, 0.0),
+ },
+ bonds=[Bond(0, 1), Bond(1, 2)],
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 30.0,
+ "shape_kind": "rect",
+ "stroke_style": "none",
+ "fill": "#ff0000",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ ids = []
+ for el in root.iter():
+ eid = el.get("id")
+ if eid is not None:
+ ids.append(eid)
+ assert len(ids) == len(set(ids)), f"duplicate IDs: {ids}"
+
+
+# ── Unsupported objects: atomic refusal ────────────────────────────────
+
+
+def test_refuse_alias_label():
+ state = _simple_state(
+ atoms={0: Atom("CO2Me", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="pure element"):
+ _export_cdxml(state)
+
+
+def test_refuse_wedge_bond():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, style="wedge")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_hash_bond():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, style="hash")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_bold_bond():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, style="bold_in")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_dotted_bond():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, style="dotted")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_double_either_bond():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, order=2, style="double_either")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_ring_side_double():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, order=2, style="double")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_refuse_radical():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ atom_annotations={0: {"radical_electrons": 1}},
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="radical"):
+ _export_cdxml(state)
+
+
+def test_refuse_rounded_rect():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 0.0,
+ "top": 0.0,
+ "right": 20.0,
+ "bottom": 20.0,
+ "shape_kind": "rounded_rect",
+ "stroke_style": "solid",
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="rounded_rect"):
+ _export_cdxml(state)
+
+
+def test_refuse_ellipse():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 0.0,
+ "top": 0.0,
+ "right": 20.0,
+ "bottom": 20.0,
+ "shape_kind": "ellipse",
+ "stroke_style": "solid",
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="ellipse"):
+ _export_cdxml(state)
+
+
+def test_refuse_translucent_fill():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 0.0,
+ "top": 0.0,
+ "right": 20.0,
+ "bottom": 20.0,
+ "shape_kind": "rect",
+ "stroke_style": "none",
+ "fill": "#ff0000",
+ "fill_alpha": 0.5,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="translucent"):
+ _export_cdxml(state)
+
+
+def test_refuse_dashed_stroke():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 0.0,
+ "top": 0.0,
+ "right": 20.0,
+ "bottom": 20.0,
+ "shape_kind": "rect",
+ "stroke_style": "dashed",
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="dashed"):
+ _export_cdxml(state)
+
+
+# ── Arrow / line ───────────────────────────────────────────────────────
+
+
+def test_refuse_curved_arrow():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "curved_single",
+ "start": [0.0, 0.0],
+ "end": [20.0, 0.0],
+ "control": [10.0, -10.0],
+ "double": False,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="curved"):
+ _export_cdxml(state)
+
+
+def test_refuse_equilibrium():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "equilibrium",
+ "start": [0.0, 0.0],
+ "end": [20.0, 0.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+def test_line_exports_as_graphic():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "line",
+ "start": [5.0, 100.0],
+ "end": [25.0, 100.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ graphics = root.findall(".//graphic")
+ line_graphics = [g for g in graphics if g.get("GraphicType") == "Line"]
+ assert len(line_graphics) >= 1
+ assert line_graphics[0].get("ArrowType") == "NoHead"
+
+
+def test_arrow_exports_as_grouped_lines():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "arrow",
+ "start": [5.0, 100.0],
+ "end": [25.0, 100.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ groups = root.findall(".//group")
+ assert len(groups) >= 1
+ graphics = groups[0].findall("graphic")
+ assert len(graphics) == 3
+
+
+# ── Shapes ─────────────────────────────────────────────────────────────
+
+
+def test_filled_rect():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 25.0,
+ "shape_kind": "rect",
+ "stroke_style": "none",
+ "fill": "#ff0000",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ graphics = root.findall(".//graphic")
+ rects = [g for g in graphics if g.get("GraphicType") == "Rectangle"]
+ assert len(rects) == 1
+ assert rects[0].get("RectangleType") == "Filled"
+ assert rects[0].get("LineWidth") == "0"
+
+
+def test_stroke_rect():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 25.0,
+ "shape_kind": "rect",
+ "stroke_style": "solid",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ graphics = root.findall(".//graphic")
+ rects = [g for g in graphics if g.get("GraphicType") == "Rectangle"]
+ assert len(rects) == 1
+ assert rects[0].get("RectangleType") == "Plain"
+ lw = float(rects[0].get("LineWidth"))
+ assert lw > 0
+
+
+def test_filled_circle():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 30.0,
+ "shape_kind": "circle",
+ "stroke_style": "none",
+ "fill": "#00ff00",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ graphics = root.findall(".//graphic")
+ ovals = [g for g in graphics if g.get("GraphicType") == "Oval"]
+ assert len(ovals) == 1
+ assert ovals[0].get("OvalType") == "Circle Filled"
+ assert ovals[0].get("Center3D") is not None
+ assert ovals[0].get("MajorAxisEnd3D") is not None
+ assert ovals[0].get("MinorAxisEnd3D") is not None
+
+
+def test_circle_axes_geometry():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 30.0,
+ "shape_kind": "circle",
+ "stroke_style": "none",
+ "fill": "#0000ff",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ oval = root.find(".//graphic[@GraphicType='Oval']")
+ assert oval is not None
+
+ cx, cy, *_ = [float(v) for v in oval.get("Center3D").split()]
+ mx, my, *_ = [float(v) for v in oval.get("MajorAxisEnd3D").split()]
+ nx, ny, *_ = [float(v) for v in oval.get("MinorAxisEnd3D").split()]
+
+ r_major = ((mx - cx) ** 2 + (my - cy) ** 2) ** 0.5
+ r_minor = ((nx - cx) ** 2 + (ny - cy) ** 2) ** 0.5
+ assert abs(r_major - r_minor) < 0.1
+
+
+# ── Color table ────────────────────────────────────────────────────────
+
+
+def test_color_table_indices():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0, color="#ff0000"), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+
+ ct = root.find("colortable")
+ assert ct is not None
+ colors_in_table = ct.findall("color")
+ assert len(colors_in_table) >= 2
+
+ nodes = root.findall(".//n")
+ colored = [n for n in nodes if n.get("color") is not None]
+ assert len(colored) >= 1
+ color_ref = int(colored[0].get("color"))
+ assert color_ref >= 2
+
+
+# ── Atomic write safety ───────────────────────────────────────────────
+
+
+def test_no_file_on_unsupported_object(tmp_path):
+ dest = tmp_path / "should_not_exist.cdxml"
+ state = _simple_state(
+ atoms={0: Atom("CO2Me", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ service = FigureExportService(context)
+ with pytest.raises(CdxmlUnsupportedObjectError):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ assert not dest.exists()
+
+
+def test_existing_file_preserved_on_failure(tmp_path):
+ dest = tmp_path / "existing.cdxml"
+ original_content = b"original content"
+ dest.write_bytes(original_content)
+ state = _simple_state(
+ atoms={0: Atom("CO2Me", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ service = FigureExportService(context)
+ with pytest.raises(CdxmlUnsupportedObjectError):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ assert dest.read_bytes() == original_content
+
+
+# ── Headless CLI ───────────────────────────────────────────────────────
+
+
+def test_cli_cdxml_output(tmp_path):
+ from chemvas.bootstrap import document_render as cli
+
+ source = tmp_path / "test.chemvas"
+ state = _simple_state()
+ write_document(source, state, CANVAS_FILE_VERSION)
+ output = tmp_path / "test.cdxml"
+
+ report = cli._render_document(
+ source,
+ output=output,
+ background="white",
+ dpi=300,
+ width_mm=170.0,
+ )
+ assert output.exists()
+ assert report["output_format"] == "cdxml"
+ assert report["written"] is True
+
+ xml_bytes = output.read_bytes()
+ root = ET.fromstring(xml_bytes)
+ assert root.tag == "CDXML"
+
+
+def test_cli_cdxml_nonoverwrite(tmp_path):
+ from chemvas.bootstrap import document_render as cli
+
+ source = tmp_path / "test.chemvas"
+ state = _simple_state()
+ write_document(source, state, CANVAS_FILE_VERSION)
+ output = tmp_path / "test.cdxml"
+ existing_content = b"existing content must survive"
+ output.write_bytes(existing_content)
+
+ with pytest.raises(ValueError, match="already exists"):
+ cli._render_document(
+ source,
+ output=output,
+ background="white",
+ dpi=300,
+ width_mm=170.0,
+ )
+ assert output.read_bytes() == existing_content, (
+ "existing file bytes must be preserved after failed overwrite"
+ )
+
+
+def test_cli_cdxml_min_font_rejected(tmp_path):
+ from chemvas.bootstrap import document_render as cli
+
+ source = tmp_path / "test.chemvas"
+ state = _simple_state()
+ write_document(source, state, CANVAS_FILE_VERSION)
+ output = tmp_path / "test.cdxml"
+
+ with pytest.raises(ValueError, match="min-font-pt"):
+ cli._render_document(
+ source,
+ output=output,
+ background="white",
+ dpi=300,
+ width_mm=170.0,
+ min_font_pt=6.0,
+ )
+
+
+# ── GUI dialog format wiring ──────────────────────────────────────────
+
+
+def test_gui_format_entry():
+ from chemvas.features.export import (
+ EXPORT_FORMATS,
+ file_filter_for_format,
+ suffix_for_format,
+ )
+
+ fmt_keys = {f[1] for f in EXPORT_FORMATS}
+ assert "cdxml" in fmt_keys
+
+ assert suffix_for_format("cdxml") == ".cdxml"
+ assert "*.cdxml" in file_filter_for_format("cdxml")
+
+
+def test_gui_canvas_export_figure_cdxml(tmp_path):
+ from chemvas.bootstrap.document_cli_shared import offscreen_canvas
+
+ state = _simple_state()
+ dest = tmp_path / "gui.cdxml"
+ with offscreen_canvas(state, command="test-cdxml") as (canvas, service):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ assert dest.exists()
+ root = ET.fromstring(dest.read_bytes())
+ assert root.tag == "CDXML"
+ fragments = root.findall(".//fragment")
+ assert len(fragments) >= 1
+
+
+def test_gui_canvas_refuses_group_via_runtime_state(tmp_path):
+ from chemvas.bootstrap.document_cli_shared import offscreen_canvas
+ from chemvas.ui.canvas.canvas_group_state import register_group_for
+
+ state = _simple_state()
+ dest = tmp_path / "gui_group.cdxml"
+ with offscreen_canvas(state, command="test-cdxml") as (canvas, service):
+ register_group_for(canvas, {0, 1}, [])
+ with pytest.raises(CdxmlUnsupportedObjectError, match="group"):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ assert not dest.exists()
+
+
+def test_normalize_path_retargets_suffix():
+ from chemvas.features.export import normalize_export_path
+
+ result = normalize_export_path("figure.svg", "cdxml")
+ assert result is not None
+ assert result.endswith(".cdxml")
+
+
+# ── Source model unchanged ─────────────────────────────────────────────
+
+
+def test_source_state_unchanged():
+ import json
+
+ state = _simple_state()
+ before = json.dumps(state, sort_keys=True)
+ _export_cdxml(state)
+ after = json.dumps(state, sort_keys=True)
+ assert before == after
+
+
+# ── Well-formed XML ────────────────────────────────────────────────────
+
+
+def test_valid_xml_declaration():
+ state = _simple_state()
+ xml_bytes, _ = _export_cdxml(state)
+ assert xml_bytes.startswith(b"= 1, "expected at least one note record id"
+ real_id = note_record_ids[0]
+
+ groups = {1: SceneGroup(atom_ids=set(), item_ids=[real_id])}
+ service = FigureExportService(context, groups=groups)
+ items, plan = service._resolve_figure_export(
+ scope="sheet",
+ selection=None,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="group"):
+ preflight_cdxml(context, items, plan, groups=groups)
+
+
+def test_refuse_group_via_cli(tmp_path):
+ from chemvas.bootstrap import document_render as cli
+
+ source = tmp_path / "grouped.chemvas"
+ state = _simple_state(
+ notes=[{"text": "grouped note", "x": 0.0, "y": 30.0}],
+ groups=[{"atoms": [], "items": [["notes", 0]]}],
+ )
+ write_document(source, state, CANVAS_FILE_VERSION)
+ output = tmp_path / "grouped.cdxml"
+ with pytest.raises(CdxmlUnsupportedObjectError, match="item members"):
+ cli._render_document(
+ source,
+ output=output,
+ background="white",
+ dpi=300,
+ width_mm=170.0,
+ )
+
+
+# ── Shape stroke width at bond_length_px=40 ─────────────────────────
+
+
+def test_shape_stroke_width_at_bond_length_40():
+ from pytest import approx
+
+ from chemvas.ui.annotations.shape_geometry import shape_stroke_width
+
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 25.0,
+ "shape_kind": "rect",
+ "stroke_style": "solid",
+ }
+ ],
+ settings_overrides={"bond_length_px": 40.0},
+ )
+ xml_bytes, plan = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ rects = [
+ g for g in root.findall(".//graphic") if g.get("GraphicType") == "Rectangle"
+ ]
+ assert len(rects) == 1
+ exported_lw = float(rects[0].get("LineWidth"))
+ s = plan.out_w_pt / plan.source_w
+ expected_lw = shape_stroke_width(1.5) * s
+ assert exported_lw == approx(expected_lw, rel=1e-9), (
+ f"exported stroke {exported_lw} should be "
+ f"shape_stroke_width(1.5) * scale = {expected_lw}"
+ )
+
+
+# ── Note font properties: absolute family, size, default italic/weight ──
+
+
+def test_refuse_note_default_italic():
+ state = _simple_state(
+ notes=[{"text": "test note", "x": 0.0, "y": 30.0}],
+ settings_overrides={"text_italic": True},
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="italic"):
+ _export_cdxml(state)
+
+
+def test_refuse_note_weight_500():
+ state = _simple_state(
+ notes=[{"text": "test note", "x": 0.0, "y": 30.0}],
+ settings_overrides={"text_font_weight": 500},
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="weight"):
+ _export_cdxml(state)
+
+
+def test_note_font_family_from_document():
+ from PyQt6.QtGui import QFontInfo, QRawFont
+
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ notes=[{"text": "test note", "x": 0.0, "y": 30.0}],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ doc_font = context.state.note_items()[0].document().defaultFont()
+ resolved_family = QFontInfo(doc_font).family()
+ raw = QRawFont.fromFont(doc_font)
+ raw_pixel_size = raw.pixelSize()
+
+ xml_bytes, plan = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ t_elements = root.findall(".//t")
+ note_t = [t for t in t_elements if t.get("InterpretChemically") == "no"]
+ note_with_text = [t for t in note_t if t.findall("s")]
+ assert len(note_with_text) >= 1
+ s_el = note_with_text[-1].find("s")
+ assert s_el is not None
+ font_id = int(s_el.get("font"))
+ font_table = root.find("fonttable")
+ assert font_table is not None
+ fonts = font_table.findall("font")
+ font_name = None
+ for f in fonts:
+ if int(f.get("id")) == font_id:
+ font_name = f.get("name")
+ break
+ assert font_name == resolved_family, (
+ f"font name in CDXML should be resolved '{resolved_family}', got '{font_name}'"
+ )
+
+ exported_size = float(s_el.get("size"))
+ s = plan.out_w_pt / plan.source_w
+ expected_size = raw_pixel_size * s
+ assert exported_size == pytest.approx(expected_size, rel=1e-9), (
+ f"font size {exported_size} should be raw pixel size {raw_pixel_size} "
+ f"* scale {s} = {expected_size}"
+ )
+
+ assert s_el.get("face") is None, (
+ "default weight 400 note should have no face attribute"
+ )
+
+
+# ── Atom label origin: baseline position from layout + sceneTransform ──
+
+
+def test_atom_label_origin_matches_scene_layout():
+ from PyQt6.QtWidgets import QGraphicsTextItem
+
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 40.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ atom_items = context.state.atom_graphics_state.atom_items
+ label_origins: dict[int, tuple[float, float]] = {}
+ for aid, item in atom_items.items():
+ if not isinstance(item, QGraphicsTextItem) or not item.isVisible():
+ continue
+ doc = item.document()
+ if doc is None:
+ continue
+ block = doc.begin()
+ if not block.isValid():
+ continue
+ layout = block.layout()
+ if layout is None or layout.lineCount() == 0:
+ continue
+ line = layout.lineAt(0)
+ local_x = layout.position().x() + line.x()
+ local_baseline_y = layout.position().y() + line.y() + line.ascent()
+ transform = item.sceneTransform()
+ scene_x = (
+ transform.m11() * local_x
+ + transform.m21() * local_baseline_y
+ + transform.dx()
+ )
+ scene_y = (
+ transform.m12() * local_x
+ + transform.m22() * local_baseline_y
+ + transform.dy()
+ )
+ label_origins[aid] = (scene_x, scene_y)
+
+ service = FigureExportService(context)
+ items, plan = service._resolve_figure_export(
+ scope="sheet",
+ selection=None,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ preflight_cdxml(context, items, plan)
+ xml_bytes = serialize_cdxml(context, items, plan)
+
+ assert len(label_origins) >= 1, "expected at least one visible atom label"
+ s = plan.out_w_pt / plan.source_w
+ ox, oy = plan.source_x, plan.source_y
+
+ root = _parse_cdxml(xml_bytes)
+ label_ts = [t for t in root.findall(".//t") if t.get("InterpretChemically") == "no"]
+ assert len(label_ts) == len(label_origins), (
+ f"expected {len(label_origins)} atom label , got {len(label_ts)}"
+ )
+
+ actual_positions = []
+ for t_el in label_ts:
+ p = t_el.get("p")
+ assert p is not None, "atom label must have p attribute"
+ parts = p.split()
+ actual_positions.append((float(parts[0]), float(parts[1])))
+
+ expected_positions = []
+ for aid in sorted(label_origins):
+ sx, sy = label_origins[aid]
+ expected_positions.append(((sx - ox) * s, (sy - oy) * s))
+
+ used = [False] * len(expected_positions)
+ for ax, ay in actual_positions:
+ best_i, best_d = -1, float("inf")
+ for i, (ex, ey) in enumerate(expected_positions):
+ if used[i]:
+ continue
+ d = (ax - ex) ** 2 + (ay - ey) ** 2
+ if d < best_d:
+ best_d = d
+ best_i = i
+ assert best_i >= 0, f"no expected position matches actual ({ax}, {ay})"
+ used[best_i] = True
+ ex, ey = expected_positions[best_i]
+ assert ax == pytest.approx(ex, rel=1e-9), (
+ f"atom label x {ax} should match expected {ex}"
+ )
+ assert ay == pytest.approx(ey, rel=1e-9), (
+ f"atom label y {ay} should match expected {ey}"
+ )
+
+
+# ── Note alignment ──────────────────────────────────────────────────
+
+
+def test_refuse_note_center_alignment():
+ state = _simple_state(
+ notes=[{"text": "line 1\nline 2", "x": 0.0, "y": 30.0}],
+ settings_overrides={"text_alignment": "center"},
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="alignment"):
+ _export_cdxml(state)
+
+
+# ── Note border-only box ─────────────────────────────────────────────
+
+
+def test_refuse_note_border_only():
+ state = _simple_state(
+ notes=[{"text": "boxed", "x": 0.0, "y": 30.0}],
+ settings_overrides={"note_border_enabled": True},
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="note box"):
+ _export_cdxml(state)
+
+
+# ── Oval BoundingBox endpoint order ──────────────────────────────────
+
+
+def test_oval_bbox_axis_end_then_centre():
+ state = _simple_state(
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 10.0,
+ "top": 10.0,
+ "right": 30.0,
+ "bottom": 30.0,
+ "shape_kind": "circle",
+ "stroke_style": "solid",
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ oval = root.find(".//graphic[@GraphicType='Oval']")
+ assert oval is not None
+ bbox = [float(v) for v in oval.get("BoundingBox").split()]
+ cx, cy, _ = [float(v) for v in oval.get("Center3D").split()]
+ mx, my, _ = [float(v) for v in oval.get("MajorAxisEnd3D").split()]
+ assert abs(bbox[0] - mx) < 0.01, "BoundingBox x1 should be axis end, not centre"
+ assert abs(bbox[2] - cx) < 0.01, "BoundingBox x3 should be centre"
+
+
+# ── Negative-slope line BoundingBox ──────────────────────────────────
+
+
+def test_negative_slope_line_preserves_endpoint_order():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "line",
+ "start": [5.0, 125.0],
+ "end": [25.0, 105.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ graphics = root.findall(".//graphic")
+ lines = [g for g in graphics if g.get("GraphicType") == "Line"]
+ assert len(lines) >= 1
+ bbox = [float(v) for v in lines[0].get("BoundingBox").split()]
+ assert bbox[1] > bbox[3], (
+ f"BoundingBox y1={bbox[1]} should be > y2={bbox[3]} for negative slope "
+ "(endpoint order preserved, not min/max)"
+ )
+
+
+# ── Z-order: page children reflect scene ascending order ────────────
+
+
+def test_z_order_matches_scene_ascending_order():
+ from PyQt6.QtCore import Qt as QtCore_Qt
+
+ state = _simple_state(
+ notes=[{"text": "z note", "x": 0.0, "y": 120.0}],
+ arrows=[
+ {
+ "kind": "line",
+ "start": [5.0, 120.0],
+ "end": [25.0, 120.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ scene_items_asc = list(context.scene.items(QtCore_Qt.SortOrder.AscendingOrder))
+ scene_roles_ordered = []
+ for item in scene_items_asc:
+ role = item.data(0)
+ if role in ("note", "line"):
+ scene_roles_ordered.append(role)
+
+ service = FigureExportService(context)
+ items, plan = service._resolve_figure_export(
+ scope="sheet",
+ selection=None,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ preflight_cdxml(context, items, plan)
+ xml_bytes = serialize_cdxml(context, items, plan)
+
+ assert len(scene_roles_ordered) >= 2, (
+ "expected at least note + line in scene ascending order"
+ )
+
+ root = _parse_cdxml(xml_bytes)
+ page = root.find("page")
+ assert page is not None
+ z_values = []
+ cdxml_roles_ordered = []
+ for child in page:
+ z = child.get("Z")
+ assert z is not None, f"<{child.tag}> missing Z attribute"
+ z_values.append(int(z))
+ if child.tag == "t" and child.get("InterpretChemically") == "no":
+ cdxml_roles_ordered.append("note")
+ elif child.tag == "graphic" and child.get("GraphicType") == "Line":
+ cdxml_roles_ordered.append("line")
+ assert z_values == sorted(z_values), "Z values must be monotonically increasing"
+ assert len(z_values) == len(set(z_values)), "Z values must be unique"
+ assert len(z_values) >= 3, (
+ "expected at least fragment + note + line as page children"
+ )
+ assert cdxml_roles_ordered == scene_roles_ordered, (
+ f"CDXML page child order {cdxml_roles_ordered} must match "
+ f"scene ascending order {scene_roles_ordered}"
+ )
+
+
+# ── Fragment interleaving negative regressions ─────────────────────
+
+
+def test_refuse_note_over_bonded_fragment():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ notes=[{"text": "overlapping", "x": 10.0, "y": 0.0}],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="interleave"):
+ _export_cdxml(state)
+
+
+def test_refuse_line_over_bonded_fragment():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ arrows=[
+ {
+ "kind": "line",
+ "start": [0.0, 0.0],
+ "end": [20.0, 0.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="interleave"):
+ _export_cdxml(state)
+
+
+def test_refuse_two_overlapping_bonded_fragments():
+ state = _simple_state(
+ atoms={
+ 0: Atom("C", 0.0, 0.0),
+ 1: Atom("O", 20.0, 0.0),
+ 2: Atom("N", 5.0, 0.0),
+ 3: Atom("S", 15.0, 0.0),
+ },
+ bonds=[Bond(0, 1), Bond(2, 3)],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="interleave"):
+ _export_cdxml(state)
+
+
+# ── Arrow components: no Head3D/Tail3D ───────────────────────────────
+
+
+def test_arrow_components_no_head3d_tail3d():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "arrow",
+ "start": [5.0, 100.0],
+ "end": [25.0, 100.0],
+ "control": None,
+ "double": False,
+ }
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ groups = root.findall(".//group")
+ assert len(groups) >= 1
+ for g in groups:
+ for graphic in g.findall("graphic"):
+ assert graphic.get("Head3D") is None, (
+ "arrow component should not have Head3D"
+ )
+ assert graphic.get("Tail3D") is None, (
+ "arrow component should not have Tail3D"
+ )
+
+
+# ── Ac/Ts alias refusal ─────────────────────────────────────────────
+
+
+def test_refuse_ac_alias():
+ state = _simple_state(
+ atoms={0: Atom("Ac", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="alias"):
+ _export_cdxml(state)
+
+
+def test_refuse_ts_alias():
+ state = _simple_state(
+ atoms={0: Atom("Ts", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="alias"):
+ _export_cdxml(state)
+
+
+# ── Formal charge with mark refuses ──────────────────────────────────
+
+
+def test_refuse_formal_charge_with_mark():
+ state = _simple_state(
+ atoms={0: Atom("N", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ atom_annotations={0: {"formal_charge": 1}},
+ marks=[
+ {
+ "kind": "plus",
+ "atom_id": 0,
+ "text": "+",
+ "x": 2.0,
+ "y": -2.0,
+ "dx": 2.0,
+ "dy": -2.0,
+ "_auto_position": True,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="mark"):
+ _export_cdxml(state)
+
+
+# ── Service: max_height_mm budget and overwrite+after_render ─────────
+
+
+def test_service_max_height_mm_budget(tmp_path):
+ from chemvas.features.export.errors import MaximumHeightError
+
+ state = _simple_state()
+ dest = tmp_path / "budget.cdxml"
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ service = FigureExportService(context)
+ with pytest.raises(MaximumHeightError):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ max_height_mm=0.01,
+ )
+ assert not dest.exists()
+
+
+def test_service_overwrite_with_after_render(tmp_path):
+ state = _simple_state()
+ dest = tmp_path / "export.cdxml"
+ dest.write_bytes(b"old content")
+ rendered_sizes: list[int] = []
+
+ def after_render(tmp_path_arg):
+ rendered_sizes.append(tmp_path_arg.stat().st_size)
+
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ service = FigureExportService(context)
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ after_render=after_render,
+ )
+ assert dest.exists()
+ assert dest.read_bytes() != b"old content"
+ assert len(rendered_sizes) == 1
+ assert rendered_sizes[0] > 0
+
+
+# ── Source state unchanged: bytes, model, scene ──────────────────────
+
+
+def test_source_bytes_and_model_unchanged(tmp_path):
+ import json
+
+ from chemvas.bootstrap import document_render as cli
+
+ state = _simple_state()
+ source_path = tmp_path / "source.chemvas"
+ write_document(source_path, state, CANVAS_FILE_VERSION)
+ before_bytes = source_path.read_bytes()
+ before_json = json.dumps(state, sort_keys=True)
+
+ output_path = tmp_path / "output.cdxml"
+ cli._render_document(
+ source_path,
+ output=output_path,
+ background="white",
+ dpi=300,
+ width_mm=170.0,
+ )
+ assert output_path.exists(), "CLI should have written the output file"
+
+ after_bytes = source_path.read_bytes()
+ after_json = json.dumps(state, sort_keys=True)
+
+ assert before_bytes == after_bytes, ".chemvas bytes mutated during export"
+ assert before_json == after_json, "state dict mutated during export"
+
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ model_before = json.dumps(serialize_model_state(context.model), sort_keys=True)
+ items_before = [
+ (item.data(0), item.isVisible(), item.pos().x(), item.pos().y())
+ for item in context.scene.items()
+ ]
+
+ service = FigureExportService(context)
+ items, plan = service._resolve_figure_export(
+ scope="sheet",
+ selection=None,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ preflight_cdxml(context, items, plan)
+ serialize_cdxml(context, items, plan)
+
+ model_after = json.dumps(serialize_model_state(context.model), sort_keys=True)
+ items_after = [
+ (item.data(0), item.isVisible(), item.pos().x(), item.pos().y())
+ for item in context.scene.items()
+ ]
+
+ assert model_before == model_after, "model mutated during export"
+ assert items_before == items_after, "scene item positions/visibility changed"
+
+
+# ── Vocabulary closure: non-vacuous with all element types ───────────
+
+
+def test_vocabulary_closure_with_all_supported_types():
+ state = _simple_state(
+ atoms={
+ 0: Atom("N", 0.0, 0.0),
+ 1: Atom("O", 20.0, 0.0),
+ 2: Atom("C", 40.0, 0.0),
+ },
+ bonds=[Bond(0, 1), Bond(1, 2, order=2, style="double_center")],
+ notes=[{"text": "mixed content", "x": 0.0, "y": 30.0}],
+ arrows=[
+ {
+ "kind": "line",
+ "start": [5.0, 50.0],
+ "end": [25.0, 50.0],
+ "control": None,
+ "double": False,
+ },
+ {
+ "kind": "arrow",
+ "start": [5.0, 60.0],
+ "end": [25.0, 60.0],
+ "control": None,
+ "double": False,
+ },
+ ],
+ shapes=[
+ {
+ "kind": "shape",
+ "left": 50.0,
+ "top": 10.0,
+ "right": 70.0,
+ "bottom": 30.0,
+ "shape_kind": "rect",
+ "stroke_style": "solid",
+ },
+ {
+ "kind": "shape",
+ "left": 80.0,
+ "top": 10.0,
+ "right": 100.0,
+ "bottom": 30.0,
+ "shape_kind": "circle",
+ "stroke_style": "none",
+ "fill": "#0000ff",
+ },
+ ],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+
+ found_tags = {el.tag for el in root.iter()}
+ assert "fragment" in found_tags, "expected fragment element"
+ assert "n" in found_tags, "expected node element"
+ assert "b" in found_tags, "expected bond element"
+ assert "t" in found_tags, "expected text element"
+ assert "s" in found_tags, "expected styled run element"
+ assert "graphic" in found_tags, "expected graphic element"
+ assert "group" in found_tags, "expected group element for arrow"
+ assert "fonttable" in found_tags, "expected fonttable"
+ assert "colortable" in found_tags, "expected colortable"
+
+ for el in root.iter():
+ tag = el.tag
+ assert tag in _ALLOWED_ELEMENTS, f"unexpected element: {tag}"
+ allowed = _ALLOWED_ATTRS_BY_ELEMENT.get(tag, set())
+ for attr in el.attrib:
+ assert attr in allowed, f"unexpected attribute {attr} on <{tag}>"
+
+ root_el = root
+ assert root_el.get("FractionalWidths") == "yes"
+
+ fragments = root.findall(".//fragment")
+ assert len(fragments) >= 1
+ nodes = root.findall(".//n")
+ assert any(n.get("Element") == "7" for n in nodes), "expected nitrogen"
+ assert any(n.get("Element") == "8" for n in nodes), "expected oxygen"
+ bonds = root.findall(".//b")
+ assert any(b.get("Order") == "2" for b in bonds), "expected double bond"
+
+
+# ── FractionalWidths attribute ───────────────────────────────────────
+
+
+def test_fractional_widths_present():
+ state = _simple_state()
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ assert root.get("FractionalWidths") == "yes"
+
+
+# ── ET.indent whitespace: no whitespace inside text runs ─────────────
+
+
+def test_no_whitespace_inside_text_runs():
+ state = _simple_state(
+ notes=[{"text": " hello world ", "x": 0.0, "y": 30.0}],
+ )
+ xml_bytes, _ = _export_cdxml(state)
+ root = _parse_cdxml(xml_bytes)
+ page = root.find("page")
+ assert page is not None
+ note_ts = [t for t in page.findall("t") if t.get("InterpretChemically") == "no"]
+ assert len(note_ts) >= 1, "expected at least one note on page"
+ found_text = False
+ for t in note_ts:
+ assert t.text is None, f" text content should be None, got: {t.text!r}"
+ s_elements = t.findall("s")
+ texts = []
+ for s in s_elements:
+ assert s.tail is None, f" tail should be None, got: {s.tail!r}"
+ if s.text is not None:
+ found_text = True
+ assert not s.text.startswith("\n"), (
+ f"s text starts with newline (indent leak): {s.text!r}"
+ )
+ texts.append(s.text)
+ full = "".join(texts)
+ assert full == " hello world ", (
+ f"concatenated note text must equal exact source, got: {full!r}"
+ )
+ assert found_text, "expected at least one with text content"
+
+
+# ── Rotation refusal ─────────────────────────────────────────────────
+
+
+def test_refuse_rotated_note_no_record():
+ from PyQt6.QtGui import QTransform
+
+ state = _simple_state(
+ notes=[{"text": "rotated", "x": 0.0, "y": 30.0}],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ note_item = context.state.note_items()[0]
+ angle = 45.0
+ t = QTransform()
+ t.rotate(angle)
+ note_item.setTransform(t)
+
+ service = FigureExportService(context)
+ items, plan = service._resolve_figure_export(
+ scope="sheet",
+ selection=None,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="rotated"):
+ preflight_cdxml(context, items, plan)
+
+
+# ── Bond-only selection refusal ──────────────────────────────────────
+
+
+def test_refuse_bond_only_selection():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ bond_items = [item for item in context.scene.items() if item.data(0) == "bond"]
+ assert len(bond_items) >= 1, "expected at least one bond item"
+ service = FigureExportService(context)
+ plan = service.plan_figure_export(
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError):
+ preflight_cdxml(context, bond_items, plan)
+
+
+# ── Mismatched bond style/order refusal ─────────────────────────────
+
+
+def test_refuse_mismatched_bond_style_order():
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("C", 20.0, 0.0)},
+ bonds=[Bond(0, 1, order=2, style="single")],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="requires order"):
+ _export_cdxml(state)
+
+
+# ── Unrelated group does not block export ────────────────────────────
+
+
+def test_unrelated_group_does_not_block_export():
+ from chemvas.domain.document.groups import SceneGroup
+
+ state = _simple_state(
+ atoms={0: Atom("C", 0.0, 0.0), 1: Atom("O", 20.0, 0.0)},
+ bonds=[Bond(0, 1)],
+ notes=[{"text": "excluded note", "x": 200.0, "y": 200.0}],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ note_items = context.state.note_items()
+ assert len(note_items) >= 1
+ note_record_id = note_items[0].data(3)
+ assert isinstance(note_record_id, int)
+
+ groups = {1: SceneGroup(atom_ids=set(), item_ids=[note_record_id])}
+
+ atom_items = [item for item in context.scene.items() if item.data(0) == "atom"]
+ bond_items = [item for item in context.scene.items() if item.data(0) == "bond"]
+ selection_items = atom_items + bond_items
+
+ service = FigureExportService(context, groups=groups)
+ items, plan = service._resolve_figure_export(
+ scope="selection",
+ selection=selection_items,
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ preflight_cdxml(context, items, plan, groups=groups)
+ xml_bytes = serialize_cdxml(context, items, plan, groups=groups)
+
+ root = _parse_cdxml(xml_bytes)
+ assert root.tag == "CDXML"
+ fragments = root.findall(".//fragment")
+ assert len(fragments) >= 1
+
+
+# ── Fallback glyph refusal ──────────────────────────────────────────
+
+
+def test_refuse_fallback_glyph_note():
+ from PyQt6.QtGui import QFontInfo, QTextLayout
+
+ emoji = "\U0001f600"
+ state = _simple_state(
+ notes=[{"text": emoji, "x": 0.0, "y": 30.0}],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ note_item = context.state.note_items()[0]
+ doc_font = note_item.document().defaultFont()
+ primary_family = QFontInfo(doc_font).family()
+
+ layout = QTextLayout(emoji, doc_font)
+ layout.beginLayout()
+ line = layout.createLine()
+ if line.isValid():
+ line.setLineWidth(1e6)
+ layout.endLayout()
+
+ has_fallback = False
+ has_glyph_zero = False
+ for run in layout.glyphRuns():
+ raw = run.rawFont()
+ run_family = raw.familyName()
+ if run_family and run_family != primary_family:
+ has_fallback = True
+ for glyph_index in run.glyphIndexes():
+ if glyph_index == 0:
+ has_glyph_zero = True
+
+ assert has_fallback or has_glyph_zero, (
+ f"emoji '{emoji}' should trigger either glyph fallback or glyph index 0 "
+ f"under font '{primary_family}'; if neither occurs, the test premise "
+ "is invalid on this platform"
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="fallback|glyph"):
+ _export_cdxml(state)
+
+
+def test_refuse_glyph_zero_in_note():
+ from PyQt6.QtGui import QFontInfo, QTextLayout
+
+ test_char = "\U0001f600"
+ state = _simple_state(
+ notes=[{"text": test_char, "x": 0.0, "y": 30.0}],
+ )
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ note_item = context.state.note_items()[0]
+ doc_font = note_item.document().defaultFont()
+
+ layout = QTextLayout(test_char, doc_font)
+ layout.beginLayout()
+ line = layout.createLine()
+ if line.isValid():
+ line.setLineWidth(1e6)
+ layout.endLayout()
+
+ has_glyph_zero = False
+ for run in layout.glyphRuns():
+ for glyph_index in run.glyphIndexes():
+ if glyph_index == 0:
+ has_glyph_zero = True
+
+ if not has_glyph_zero:
+ pytest.skip(
+ f"font '{QFontInfo(doc_font).family()}' renders the test character "
+ "without glyph index 0; cannot test .notdef refusal on this platform"
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="glyph"):
+ _export_cdxml(state)
+
+
+# ── Arrow mirrored refusal ──────────────────────────────────────────
+
+
+def test_refuse_mirrored_arrow():
+ state = _simple_state(
+ arrows=[
+ {
+ "kind": "equilibrium",
+ "start": [5.0, 100.0],
+ "end": [25.0, 100.0],
+ "control": None,
+ "double": False,
+ "mirrored": True,
+ }
+ ],
+ )
+ with pytest.raises(CdxmlUnsupportedObjectError, match="no proved"):
+ _export_cdxml(state)
+
+
+# ── Live note unsupported style refusal ────────────────────────────
+
+
+@pytest.mark.parametrize(
+ "style_id",
+ [
+ "capitalization_uppercase",
+ "capitalization_smallcaps",
+ "word_spacing",
+ "font_stretch",
+ "underline_dash",
+ ],
+)
+def test_refuse_unsupported_live_note_style(tmp_path, style_id):
+ from PyQt6.QtGui import QFont, QTextCharFormat, QTextCursor
+
+ fmt = QTextCharFormat()
+ if style_id == "capitalization_uppercase":
+ fmt.setFontCapitalization(QFont.Capitalization.AllUppercase)
+ elif style_id == "capitalization_smallcaps":
+ fmt.setFontCapitalization(QFont.Capitalization.SmallCaps)
+ elif style_id == "word_spacing":
+ fmt.setFontWordSpacing(5.0)
+ elif style_id == "font_stretch":
+ fmt.setFontStretch(150)
+ elif style_id == "underline_dash":
+ fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.DashUnderline)
+
+ state = _simple_state(
+ notes=[{"text": "styled note", "x": 0.0, "y": 30.0}],
+ )
+ dest = tmp_path / f"styled_{style_id}.cdxml"
+ with offscreen_document_scene(state, command="test-cdxml") as context:
+ note_item = context.state.note_items()[0]
+ doc = note_item.document()
+ cursor = QTextCursor(doc)
+ cursor.select(QTextCursor.SelectionType.Document)
+ cursor.mergeCharFormat(fmt)
+
+ expected_match = {
+ "capitalization_uppercase": "capitalization",
+ "capitalization_smallcaps": "capitalization",
+ "word_spacing": "word spacing",
+ "font_stretch": "stretch",
+ "underline_dash": "underline style",
+ }[style_id]
+
+ service = FigureExportService(context)
+ with pytest.raises(CdxmlUnsupportedObjectError, match=expected_match):
+ service.export_figure(
+ str(dest),
+ fmt="cdxml",
+ scope="sheet",
+ sizing="custom",
+ target_width_mm=170.0,
+ )
+ assert not dest.exists(), (
+ f"no file should be written for unsupported style {style_id}"
+ )
diff --git a/tests/test_check_entrypoint.py b/tests/test_check_entrypoint.py
index 5635726c..3050366c 100644
--- a/tests/test_check_entrypoint.py
+++ b/tests/test_check_entrypoint.py
@@ -55,7 +55,9 @@ def _run_probe_gate(tmp_path, platform, failing=None, *, arguments=()):
workflow = tmp_path / ".github" / "workflows" / "ci.yml"
workflow.parent.mkdir(parents=True)
workflow.write_text("jobs: {}\n", encoding="utf-8")
- validator = tmp_path / "contract" / "scripts" / "validate.py"
+ validator = (
+ tmp_path / "contracts" / "machine-observation" / "scripts" / "validate.py"
+ )
validator.parent.mkdir(parents=True)
validator.touch()
for name in _PROBE_TESTS:
@@ -108,7 +110,6 @@ def _run_probe_gate(tmp_path, platform, failing=None, *, arguments=()):
env={
**os.environ,
"PYTHON_BIN": interpreter.as_posix(),
- "FACTORY_MACHINE_CONTRACT_REPO": str(validator.parents[1]),
"CHECK_JOBS": "2",
"GATE_PROBE_OUTPUT": str(output),
"GATE_PROBE_PLATFORM": platform,
@@ -292,7 +293,7 @@ def _bootstrap_tree(tmp_path: Path, versions: dict[str, str]):
workflow.write_text("jobs: {}\n", encoding="utf-8")
(tree / "tests").mkdir()
(tree / "tests" / "test_probe.py").touch()
- validator = tmp_path / "contract" / "scripts" / "validate.py"
+ validator = tree / "contracts" / "machine-observation" / "scripts" / "validate.py"
validator.parent.mkdir(parents=True)
validator.touch()
probe = tmp_path / "python_probe.py"
@@ -312,7 +313,6 @@ def _bootstrap_tree(tmp_path: Path, versions: dict[str, str]):
environment.update(
PATH=os.pathsep.join((bin_dir.as_posix(), os.environ.get("PATH", ""))),
HOME=home.as_posix(),
- FACTORY_MACHINE_CONTRACT_REPO=validator.parents[1].as_posix(),
CHECK_JOBS="1",
)
@@ -534,3 +534,15 @@ def test_gate_gives_the_recovery_hint_when_installing_fails(tmp_path):
"If .venv is damaged, remove it so the gate can recreate it, "
"or set PYTHON_BIN." in result.stderr
)
+
+
+def test_gate_fails_when_local_contract_validator_is_absent(tmp_path):
+ tree, _, run = _bootstrap_tree(tmp_path, dict.fromkeys(_NAMES, "3.13"))
+ validator = tree / "contracts" / "machine-observation" / "scripts" / "validate.py"
+ validator.unlink()
+
+ result, calls = run()
+ assert result.returncode == 1
+ assert "contract validator not found" in result.stderr
+ assert "project-local contract assets are missing" in result.stderr
+ assert not [call for call in calls if call["args"][:2] == ["-m", "pytest"]]
diff --git a/tests/test_ci_windows_coverage.py b/tests/test_ci_windows_coverage.py
index 81e6327b..42441dee 100644
--- a/tests/test_ci_windows_coverage.py
+++ b/tests/test_ci_windows_coverage.py
@@ -59,7 +59,6 @@ def test_macos_and_windows_run_the_full_host_gate_on_demand() -> None:
)
assert step
assert shlex.split(dedent(step.group(1))) == [
- "FACTORY_MACHINE_CONTRACT_REPO=$PWD/.machine-contracts",
"bash",
"scripts/check.sh",
]
diff --git a/tests/test_contract_compatibility.py b/tests/test_contract_compatibility.py
new file mode 100644
index 00000000..0c63794b
--- /dev/null
+++ b/tests/test_contract_compatibility.py
@@ -0,0 +1,601 @@
+"""Validate project-local contract assets and emitter conformance.
+
+The validator, schemas, and registry under contracts/machine-observation/ are a
+project-scoped snapshot of the upstream machine-contracts repository. These
+tests prove they accept valid chemvas observations, reject invalid ones with
+the same semantics as the upstream validator, and work standalone without any
+external clone or environment variable.
+"""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+CONTRACT_ROOT = ROOT / "contracts" / "machine-observation"
+
+_spec = importlib.util.spec_from_file_location(
+ "contract_validate",
+ CONTRACT_ROOT / "scripts" / "validate.py",
+)
+assert _spec is not None and _spec.loader is not None
+_mod = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_mod)
+
+ContractError = _mod.ContractError
+REQUIREMENT_OPERATORS = _mod.REQUIREMENT_OPERATORS
+validate_document = _mod.validate_document
+validate_machine_path = _mod.validate_machine_path
+validate_path = _mod.validate_path
+
+
+def _fixture(name: str) -> dict[str, Any]:
+ text = (CONTRACT_ROOT / "fixtures" / "valid" / name).read_text(encoding="utf-8")
+ return json.loads(text)
+
+
+# --- Valid fixtures ---
+
+
+class TestValidFixtures:
+ def test_all_valid_fixtures_conform(self) -> None:
+ fixtures = sorted((CONTRACT_ROOT / "fixtures" / "valid").glob("*.json"))
+ assert len(fixtures) >= 4
+ for fixture in fixtures:
+ validate_path(fixture)
+
+ def test_v1_ready(self) -> None:
+ validate_document(_fixture("chemvas-ready.json"))
+
+ def test_v1_blocked(self) -> None:
+ validate_document(_fixture("chemvas-blocked.json"))
+
+ def test_v2_ready(self) -> None:
+ validate_document(_fixture("chemvas-v2-ready.json"))
+
+ def test_v2_blocked(self) -> None:
+ validate_document(_fixture("chemvas-v2-blocked.json"))
+
+
+# --- Registry scope ---
+
+
+class TestRegistry:
+ @pytest.fixture()
+ def registry(self) -> dict[str, Any]:
+ return json.loads((CONTRACT_ROOT / "registry.json").read_text(encoding="utf-8"))
+
+ def test_contains_only_chemvas_producer(self, registry: dict[str, Any]) -> None:
+ assert registry["producers"] == ["chemvas"]
+
+ def test_routes_only_reference_chemvas(self, registry: dict[str, Any]) -> None:
+ assert all(r["producer"] == "chemvas" for r in registry["routes"])
+
+ def test_payload_keys_match_schemas(self, registry: dict[str, Any]) -> None:
+ for payload in registry["payload_contracts"]:
+ schema = json.loads(
+ (CONTRACT_ROOT / payload["schema"]).read_text(encoding="utf-8")
+ )
+ assert payload["required_keys"] == schema["required"]
+
+ def test_producers_agree_with_routes(self, registry: dict[str, Any]) -> None:
+ route_producers = {r["producer"] for r in registry["routes"]}
+ assert route_producers == set(registry["producers"])
+
+ def test_operation_kinds_agree_with_routes(self, registry: dict[str, Any]) -> None:
+ route_ops = {r["operation_kind"] for r in registry["routes"]}
+ assert route_ops == set(registry["operation_kinds"])
+
+ def test_requirements_use_implemented_operators(
+ self, registry: dict[str, Any]
+ ) -> None:
+ for payload in registry["payload_contracts"]:
+ for req in payload.get("ready_requirements", []):
+ assert req["operator"] in REQUIREMENT_OPERATORS
+ if req["operator"] == "equals":
+ assert "value" in req
+ for route in registry["routes"]:
+ for req in route.get("requirements", []):
+ assert req["operator"] in REQUIREMENT_OPERATORS
+ if req["operator"] == "equals":
+ assert "value" in req
+
+ def test_unimplemented_operator_rejected(self) -> None:
+ check_requirements = _mod._check_requirements
+ with pytest.raises(ContractError, match="unimplemented operator"):
+ check_requirements(
+ {"step_id": "S01"},
+ [{"path": ["step_id"], "operator": "bogus"}],
+ location="test",
+ )
+
+ def test_routes_are_unique(self, registry: dict[str, Any]) -> None:
+ keys = [
+ (
+ r["producer"],
+ r["operation_kind"],
+ r["payload_contract"],
+ r["payload_version"],
+ )
+ for r in registry["routes"]
+ ]
+ assert len(keys) == len(set(keys))
+
+ def test_payload_contracts_reachable_from_routes(
+ self, registry: dict[str, Any]
+ ) -> None:
+ registered = [(p["name"], p["version"]) for p in registry["payload_contracts"]]
+ assert len(registered) == len(set(registered))
+ routed = {
+ (r["payload_contract"], r["payload_version"]) for r in registry["routes"]
+ }
+ assert sorted(set(registered)) == sorted(routed)
+
+
+# --- Route/producer rejections ---
+
+
+class TestRouteRejections:
+ def test_unregistered_producer_is_rejected(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["producer"]["name"] = "orca_auto"
+ with pytest.raises(ContractError, match="route"):
+ validate_document(doc)
+
+ def test_v1_payload_on_v2_route(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"]["contract"]["version"] = 2
+ with pytest.raises(ContractError, match="endpoint_pair"):
+ validate_document(doc)
+
+ def test_v2_payload_on_v1_route(self) -> None:
+ doc = _fixture("chemvas-v2-ready.json")
+ doc["payload"]["contract"]["version"] = 1
+ with pytest.raises(ContractError, match="endpoint_geometry"):
+ validate_document(doc)
+
+ def test_unregistered_payload_contract_name(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"]["contract"]["name"] = "chemistry/results-bundle"
+ with pytest.raises(ContractError, match="not registered"):
+ validate_document(doc)
+
+ def test_incompatible_producer_operation(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["operation"]["kind"] = "chemistry/orca-run"
+ with pytest.raises(ContractError, match="route"):
+ validate_document(doc)
+
+
+# --- Schema rejections ---
+
+
+class TestSchemaRejections:
+ def test_missing_required_payload_key(self) -> None:
+ doc = _fixture("chemvas-v2-blocked.json")
+ doc["payload"]["data"].pop("endpoint_geometry")
+ with pytest.raises(ContractError, match="endpoint_geometry"):
+ validate_document(doc)
+
+ def test_extra_payload_key(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"]["data"]["unknown_field"] = "extra"
+ with pytest.raises(ContractError, match="unknown_field"):
+ validate_document(doc)
+
+ def test_wrong_type_in_payload(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"]["data"]["step_id"] = 42
+ with pytest.raises(ContractError, match="step_id"):
+ validate_document(doc)
+
+ def test_missing_required_envelope_key(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc.pop("lineage")
+ with pytest.raises(ContractError):
+ validate_document(doc)
+
+ def test_extra_envelope_key(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["extra_field"] = "not allowed"
+ with pytest.raises(ContractError):
+ validate_document(doc)
+
+
+# --- Ready predicate rejections ---
+
+
+class TestReadyPredicates:
+ def test_v2_ready_requires_non_null_endpoint_geometry(self) -> None:
+ doc = _fixture("chemvas-v2-ready.json")
+ doc["payload"]["data"]["endpoint_geometry"] = None
+ with pytest.raises(ContractError, match="endpoint_geometry"):
+ validate_document(doc)
+
+ def test_v1_ready_requires_non_null_endpoint_pair(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"]["data"]["endpoint_pair"] = None
+ with pytest.raises(ContractError, match="endpoint_pair"):
+ validate_document(doc)
+
+ def test_v2_endpoint_geometry_key_required(self) -> None:
+ doc = _fixture("chemvas-v2-blocked.json")
+ validate_document(doc)
+ doc["payload"]["data"].pop("endpoint_geometry")
+ with pytest.raises(ContractError, match="endpoint_geometry"):
+ validate_document(doc)
+
+
+# --- Semantic rejections ---
+
+
+class TestSemanticRejections:
+ def test_finished_pending_handoff(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["handoff"]["status"] = "pending"
+ with pytest.raises(ContractError, match="pending"):
+ validate_document(doc)
+
+ def test_finished_pending_delivery(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["delivery"]["status"] = "pending"
+ with pytest.raises(ContractError):
+ validate_document(doc)
+
+ def test_non_succeeded_cannot_be_ready(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["lifecycle"]["outcome"] = "failed"
+ with pytest.raises(ContractError):
+ validate_document(doc)
+
+ def test_ready_requires_payload(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["payload"] = None
+ with pytest.raises(ContractError):
+ validate_document(doc)
+
+ def test_complete_delivery_with_missing_required_artifact(self) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-blocked.json"))
+ doc["artifacts"]["mandatory"] = {
+ "status": "missing",
+ "required": True,
+ "role": "document",
+ "path": None,
+ "media_type": None,
+ "bytes": None,
+ "byte_sha256": None,
+ }
+ doc["delivery"]["status"] = "complete"
+ with pytest.raises(ContractError, match="unavailable required artifact"):
+ validate_document(doc)
+
+ def test_incomplete_delivery_with_all_available(self) -> None:
+ doc = _fixture("chemvas-blocked.json")
+ doc["delivery"]["status"] = "incomplete"
+ with pytest.raises(ContractError, match="incomplete delivery needs"):
+ validate_document(doc)
+
+ def test_blocked_accepts_null_payload(self) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-blocked.json"))
+ doc["payload"] = None
+ doc["handoff"]["status"] = "blocked"
+ validate_document(doc)
+
+ def test_duplicate_lineage_entries(self) -> None:
+ doc = _fixture("chemvas-ready.json")
+ entry = {
+ "producer": {"name": "upstream", "version": "1.0"},
+ "operation_id": "op-1",
+ "byte_sha256": "a" * 64,
+ }
+ doc["lineage"]["upstream"] = [entry, entry]
+ with pytest.raises(ContractError, match="duplicate"):
+ validate_document(doc)
+
+
+# --- Duplicate JSON keys ---
+
+
+class TestDuplicateKeys:
+ def test_duplicate_json_keys_are_rejected(self, tmp_path: Path) -> None:
+ raw = (CONTRACT_ROOT / "fixtures" / "valid" / "chemvas-ready.json").read_text(
+ encoding="utf-8"
+ )
+ raw = raw.replace(
+ '"step_id": "S01"',
+ '"step_id": "S01", "step_id": "duplicate"',
+ )
+ path = tmp_path / "duplicate-keys.json"
+ path.write_text(raw, encoding="utf-8")
+ with pytest.raises(ContractError, match="duplicate JSON key"):
+ validate_path(path)
+
+
+# --- Machine-path validation (artifact bytes, basename, path traversal) ---
+
+
+class TestMachineValidation:
+ def test_machine_basename_required(self, tmp_path: Path) -> None:
+ doc = _fixture("chemvas-ready.json")
+ path = tmp_path / "observation.json"
+ path.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(ContractError, match="basename"):
+ validate_machine_path(path)
+
+ def test_artifact_byte_verification(self, tmp_path: Path) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ content = b"test artifact content\n"
+ doc["artifacts"]["test-artifact"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "test.txt",
+ "media_type": "text/plain",
+ "bytes": len(content),
+ "byte_sha256": hashlib.sha256(content).hexdigest(),
+ }
+ (tmp_path / "test.txt").write_bytes(content)
+ machine = tmp_path / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ validate_machine_path(machine)
+
+ (tmp_path / "test.txt").write_bytes(b"corrupted\n")
+ with pytest.raises(ContractError, match="byte count|sha256"):
+ validate_machine_path(machine)
+
+ def test_artifact_missing_file(self, tmp_path: Path) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ doc["artifacts"]["missing-art"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "does-not-exist.txt",
+ "media_type": "text/plain",
+ "bytes": 10,
+ "byte_sha256": "a" * 64,
+ }
+ machine = tmp_path / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(ContractError, match="missing"):
+ validate_machine_path(machine)
+
+ def test_artifact_path_traversal_schema_rejected(self, tmp_path: Path) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ doc["artifacts"]["escape"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "../../../etc/passwd",
+ "media_type": "text/plain",
+ "bytes": 100,
+ "byte_sha256": "a" * 64,
+ }
+ machine = tmp_path / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(ContractError, match="not valid"):
+ validate_machine_path(machine)
+
+ @pytest.mark.skipif(
+ sys.platform == "win32",
+ reason="os.symlink requires SeCreateSymbolicLinkPrivilege on Windows",
+ )
+ def test_artifact_symlink_escapes_generation(self, tmp_path: Path) -> None:
+ generation = tmp_path / "gen"
+ generation.mkdir()
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ secret = outside / "secret.txt"
+ secret.write_bytes(b"secret content\n")
+ link = generation / "escape.txt"
+ link.symlink_to(secret)
+ content = secret.read_bytes()
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ doc["artifacts"]["escape"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "escape.txt",
+ "media_type": "text/plain",
+ "bytes": len(content),
+ "byte_sha256": hashlib.sha256(content).hexdigest(),
+ }
+ machine = generation / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(
+ ContractError, match="escapes the generation|another volume"
+ ):
+ validate_machine_path(machine)
+
+ def test_artifact_hash_mismatch(self, tmp_path: Path) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ content = b"correct content\n"
+ doc["artifacts"]["hash-test"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "hashfile.txt",
+ "media_type": "text/plain",
+ "bytes": len(content),
+ "byte_sha256": "b" * 64,
+ }
+ (tmp_path / "hashfile.txt").write_bytes(content)
+ machine = tmp_path / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(ContractError, match="sha256"):
+ validate_machine_path(machine)
+
+ def test_artifact_size_mismatch(self, tmp_path: Path) -> None:
+ doc = copy.deepcopy(_fixture("chemvas-ready.json"))
+ content = b"short\n"
+ doc["artifacts"]["size-test"] = {
+ "status": "available",
+ "required": True,
+ "role": "document",
+ "path": "sizefile.txt",
+ "media_type": "text/plain",
+ "bytes": 9999,
+ "byte_sha256": hashlib.sha256(content).hexdigest(),
+ }
+ (tmp_path / "sizefile.txt").write_bytes(content)
+ machine = tmp_path / "machine.json"
+ machine.write_text(json.dumps(doc), encoding="utf-8")
+ with pytest.raises(ContractError, match="byte count"):
+ validate_machine_path(machine)
+
+
+# --- Provenance ---
+
+
+class TestProvenance:
+ PIN_HASHES: dict[str, str] = {
+ "scripts/validate.py": "ec9eda23eb908939afddad0f58ffe18e8e6c54b10a8b3d2f3f2e65de269759e1",
+ "schemas/machine-observation-v1.schema.json": "deda0ca05d46a68382b20a43601a1ddc2826d6f15a62b8b6273c672d39f8de1b",
+ "schemas/payloads/chemistry-elementary-step-v1.schema.json": "a0390f2cbe19cdca6b3909e21fd36ac3148e8604ba10acf55148cc9f56155923",
+ "schemas/payloads/chemistry-elementary-step-v2.schema.json": "9a696aa280e83922604da2ec3fdca8d2af1140a43ba4598b83e338bf3c03cc84",
+ "LICENSE": "25a57a182f456e94b38019bc404e069e86e28b3c4a0196e6498e4f5c7ae03382",
+ "fixtures/valid/chemvas-ready.json": "96c950f71d476f607a182b742598e15a46501155f36ab812c49cd660cee2fb48",
+ "fixtures/valid/chemvas-blocked.json": "326ad7e99257685fc3ac473c16080d7254c5441023385b4280796812deb9b0d7",
+ "fixtures/valid/chemvas-v2-ready.json": "2922c6586257e9c010bd6060353e45867f2c9d243897e46af91c655508672d72",
+ "fixtures/valid/chemvas-v2-blocked.json": "f843224418d2ad9a0730c874f4f7532c095f472859a4459dd7b57e9bd8375caa",
+ }
+ REGISTRY_HASH = "720ecf05c8400a9d7809e2c65297a4d513e5766e6014f608143fb875f8157769"
+
+ @staticmethod
+ def _file_sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+ def test_copied_files_match_pin_hashes(self) -> None:
+ for relative, expected in self.PIN_HASHES.items():
+ local = CONTRACT_ROOT / relative
+ assert local.is_file(), f"missing: {relative}"
+ actual = self._file_sha256(local)
+ assert actual == expected, f"{relative}: expected {expected}, got {actual}"
+
+ def test_derived_registry_hash(self) -> None:
+ actual = self._file_sha256(CONTRACT_ROOT / "registry.json")
+ assert actual == self.REGISTRY_HASH
+
+ def test_provenance_json_records_all_hashes(self) -> None:
+ prov = json.loads(
+ (CONTRACT_ROOT / "PROVENANCE.json").read_text(encoding="utf-8")
+ )
+ assert prov["pin"] == "38581a7737cd0521bb3d50b59115eefe3c9254ca"
+ for relative, expected in self.PIN_HASHES.items():
+ assert prov["copied_files"][relative]["sha256"] == expected
+ assert prov["derived_files"]["registry.json"]["sha256"] == self.REGISTRY_HASH
+
+ def test_notice_records_pin_sha(self) -> None:
+ notice = (CONTRACT_ROOT / "NOTICE.md").read_text(encoding="utf-8")
+ assert "38581a7737cd0521bb3d50b59115eefe3c9254ca" in notice
+
+ def test_gitattributes_preserves_contract_bytes(self) -> None:
+ attrs = (ROOT / ".gitattributes").read_text(encoding="utf-8")
+ assert "contracts/machine-observation/** -text" in attrs
+
+ def test_license_is_mit(self) -> None:
+ text = (CONTRACT_ROOT / "LICENSE").read_text(encoding="utf-8")
+ assert "MIT License" in text
+ assert "daehyupsohn" in text
+
+ def test_all_registered_schemas_exist(self) -> None:
+ registry = json.loads(
+ (CONTRACT_ROOT / "registry.json").read_text(encoding="utf-8")
+ )
+ assert (
+ CONTRACT_ROOT / "schemas" / "machine-observation-v1.schema.json"
+ ).is_file()
+ for payload in registry["payload_contracts"]:
+ assert (CONTRACT_ROOT / payload["schema"]).is_file()
+
+
+# --- Standalone: no external paths required ---
+
+
+class TestStandalone:
+ def test_validator_cli_accepts_valid_fixture(self, tmp_path: Path) -> None:
+ doc = _fixture("chemvas-ready.json")
+ path = tmp_path / "valid.json"
+ path.write_text(json.dumps(doc), encoding="utf-8")
+ env = {k: v for k, v in os.environ.items() if "FACTORY" not in k.upper()}
+ result = subprocess.run(
+ [sys.executable, str(CONTRACT_ROOT / "scripts" / "validate.py"), str(path)],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ assert result.returncode == 0, result.stderr
+
+ def test_validator_cli_rejects_invalid(self, tmp_path: Path) -> None:
+ doc = _fixture("chemvas-ready.json")
+ doc["producer"]["name"] = "unknown_producer"
+ path = tmp_path / "invalid.json"
+ path.write_text(json.dumps(doc), encoding="utf-8")
+ env = {k: v for k, v in os.environ.items() if "FACTORY" not in k.upper()}
+ result = subprocess.run(
+ [sys.executable, str(CONTRACT_ROOT / "scripts" / "validate.py"), str(path)],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ assert result.returncode == 1
+
+ def test_gate_uses_local_validator_path(self) -> None:
+ script = (ROOT / "scripts" / "check.sh").read_text(encoding="utf-8")
+ assert "contracts/machine-observation/scripts/validate.py" in script
+ assert "FACTORY_MACHINE_CONTRACT_REPO" not in script
+
+ def test_no_external_clone_in_ci(self) -> None:
+ ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
+ assert "repository: dhsohn/machine-contracts" not in ci
+
+ def test_no_external_clone_in_platform_ci(self) -> None:
+ platform = (ROOT / ".github" / "workflows" / "platform.yml").read_text(
+ encoding="utf-8"
+ )
+ assert "repository: dhsohn/machine-contracts" not in platform
+
+ def test_gate_validator_path_is_project_local(self) -> None:
+ script = (ROOT / "scripts" / "check.sh").read_text(encoding="utf-8")
+ validator_line = [
+ line
+ for line in script.splitlines()
+ if "CONTRACT_VALIDATOR=" in line and "export" not in line
+ ]
+ assert validator_line
+ assert "HOME" not in validator_line[0]
+ assert "FACTORY" not in validator_line[0]
+
+ def test_hostile_env_variable_ignored(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from tests.calculation_workflow_support import _validate_common_machine
+
+ pass_all = tmp_path / "pass_all.py"
+ pass_all.write_text("import sys; sys.exit(0)\n", encoding="utf-8")
+ monkeypatch.setenv("FACTORY_MACHINE_CONTRACT_VALIDATOR", str(pass_all))
+
+ valid_doc = _fixture("chemvas-ready.json")
+ generation = tmp_path / "valid-gen"
+ generation.mkdir()
+ (generation / "machine.json").write_text(
+ json.dumps(valid_doc), encoding="utf-8"
+ )
+ _validate_common_machine(generation / "machine.json")
+
+ invalid_doc = _fixture("chemvas-ready.json")
+ invalid_doc["producer"]["name"] = "unknown_producer"
+ bad_gen = tmp_path / "bad-gen"
+ bad_gen.mkdir()
+ (bad_gen / "machine.json").write_text(json.dumps(invalid_doc), encoding="utf-8")
+ with pytest.raises(subprocess.CalledProcessError):
+ _validate_common_machine(bad_gen / "machine.json")
diff --git a/tests/test_docs_sync.py b/tests/test_docs_sync.py
index 96efe81a..bb662698 100644
--- a/tests/test_docs_sync.py
+++ b/tests/test_docs_sync.py
@@ -24,11 +24,13 @@
from PyQt6.QtCore import QEvent
from PyQt6.QtGui import QKeyEvent, QKeySequence
from PyQt6.QtTest import QTest
+from PyQt6.QtWidgets import QToolButton
from chemvas.domain.atom_aliases import ATOM_ALIAS_DEFINITIONS
from chemvas.domain.document import VALID_BOND_STYLES, Atom, Bond, MoleculeModel
from chemvas.ui.canvas.canvas_lifecycle import schedule_canvas_deletion_for
from chemvas.ui.window.main_window_config import TOOL_ACTION_SPECS
+from chemvas.ui.window.main_window_context_bar_widgets import smiles_entry
from tests.canvas_factory import build_canvas_view
from tests.runtime_services import shortcut_service_for
@@ -81,15 +83,15 @@ def _canvas_file_version() -> int:
def _smiles_button_label() -> str:
- src = _read(
- APP / "chemvas" / "ui" / "window" / "main_window_context_bar_widgets.py"
- )
- # The SMILES insert button is built as action_button("
\n'
+ '
'
+ },
+ (
+ '
H'
+ '2O
'
+ ),
+ ),
+ (
+ # Qt scales a script run from its integer point size: 20pt -> 13pt.
+ {
+ "html": '
x B'
+ '2
'
+ },
+ (
+ '
x B2
'
+ ),
+ ),
+ (
+ # Bare inline HTML gets Qt's implicit block without margins.
+ {"html": "bold & plain"},
+ (
+ '