Compare commits

...

7 commits

Author SHA1 Message Date
1d8b3a6053 merge(local): reapply local WIP onto merged main -- cad dashboard/explode/script_parts/xray merged with remote Phase-5 LOD, plus doc-ui extraction, spreadsheet xls-import, android ussd, camera and test work
Some checks failed
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
2026-09-01 21:13:52 +03:00
159f3c7fee Merge remote-tracking branch 'origin/main' 2026-09-01 20:31:44 +03:00
c7c0ec7582 makepad: centralize fork deps in workspace + bump to 4a166606c
Declare the 12 makepad crate deps once in root [workspace.dependencies]
pointing at the gitdab fork rev 4a166606c (which now also carries
[workspace.dependencies] into the android wrapper manifest). Member
crates switch to workspace = true; 5 non-member workspaces get an inline
rev bump. Fix 9 nigig-app script_mod indent errors surfaced by the newer
upstream macro parser.

The fork rev includes: NIGIG test-mode forwarding, custom AndroidManifest
hook, ortho camera support, and the wrapper workspace-deps fix.
2026-08-28 10:49:41 +03:00
7a55f99c3a Merge remote-tracking branch 'origin/main' 2026-08-26 18:04:15 +03:00
8d33b6edb8 perf(camera): persistent preview texture + area-scoped redraw; add native Video-path variant
- camera_widget: allocate the preview texture once and update it in place
  via Texture::set_data_u32 instead of creating a new texture every frame;
  replace full-tree view.redraw with cx.redraw_area over the feed region.
- camera_frames (new): duplicate of camera_widget migrated to makepad's
  native Video path (set_source_camera + begin_playback, Auto preview mode)
  so per-frame app work drops to zero; photo capture uses a short-lived CPU
  frame tap opened only between shutter press and first frame.
2026-08-24 23:33:28 +03:00
90f25641d6 feat(tests): add doc workspace device verification tests (sections 1–5, 9)
Add navigate_to_doc_workspace helper for the home → work → construction_grid
→ build_workspace → m_workspace_docs_btn → crdt_editor navigation chain.

Tests covering DEVICE_VERIFICATION.md:
- 1.1–1.4: interaction mode View ↔ Edit (edit_mode_btn toggle)
- 1.2: View mode scroll by touch (touch_down/move/up)
- 2.1: IME text input in Edit mode
- 2.2: IME composition sends text
- 3.1: Long-press arms selection
- 5.1–5.2: Scroll handoff to parent ScrollYView
- 9.0: Boot content renders (status bar + editor visible)

Also fixes 3 pre-existing test compilation bugs:
- show_password_toggle_works: moved value on Locator
- login_status_modal: wait_not_visible doesn't exist
- sso_buttons: &&str not Into<String>

Updates makepad rev to ce899827a across all crates for consistency.
2026-08-19 02:41:25 +03:00
72b0ce250b feat(doc-ui): extract doc module from nigig-build into standalone crate
Move 40+ files (~17K lines) from nigig-build/src/construction_frame/pages/workspace/doc/
to crates/apps/doc/doc-ui/src/. Rewrite internal paths from
crate::construction_frame::pages::workspace::doc:: to crate::.

doc-ui depends on doc-engine, makepad-widgets, nigig-core, serde, serde_json.
nigig-build now depends on doc-ui instead of doc-engine directly.
2026-08-19 00:55:05 +03:00
179 changed files with 13994 additions and 2697 deletions

861
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,7 @@ members = [
"crates/apps/spreadsheet/spreadsheet-engine",
"crates/apps/spreadsheet/spreadsheet-ui",
"crates/apps/doc/doc-engine",
"crates/apps/doc/doc-ui",
"crates/apps/pdf/pdf-cos",
"crates/apps/pdf/pdf-document",
"crates/apps/pdf/pdf-graphics",
@ -93,3 +94,19 @@ lto = true # Enable link-time optimization
codegen-units = 1 # Reduce number of codegen units to increase optimizations
panic = 'abort' # Abort on panic
strip = true
# Single source of truth for the makepad fork. Bump the rev here and every
# crate that declares these deps via `workspace = true` adopts it at once.
[workspace.dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-platform = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-draw = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-derive-widget = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-fast-inflate = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-mbtile-reader = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-script = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-code-editor = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-xr = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-ai = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-base64 = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }

View file

@ -0,0 +1,232 @@
# Fab → nigig-build CAD gap implementation plan
- **Date:** 2026-08-27
- **Baseline:** code-verified review of `makepad/libs/fab` (gitdab work branch) vs
`crates/apps/nigig-build/src/.../workspace/cad` (current HEAD).
- **Scope:** port user-facing capabilities from the `fab` reference CAD into the
nigig-build CAD, **adapted to our architecture**, not wholesale copy.
- **Adaptation rules (from earlier decisions):**
1. Port **algorithms/logic** from fab; keep our `CadNode` flat arena, our
batching/instancing renderer, our mobile-first 430x860 DSL, our tool model.
2. fab is an **architecture viewer/inspection** app (measure, section, isolate,
explode, sun study, walk, render) — it has **no geometry-authoring tools**.
Our CAD already has authoring (18 tools). So every port target is a
*reader/inspection/rendering* feature we lack, layered on our existing model.
3. fab communicates only through `api.rs` `ShellAction`s + `AppState`. We adapt
that as: workspace button/action handlers + our existing dirty-flag sync.
- **Completion standard (echoes repo convention):** a phase is done only when its
numbered items all land with unit tests, compile clean, and `cargo test -p
nigig-build --lib` stays green.
---
## Inventory: what fab has that we lack (verified)
| fab capability | file | status in our CAD |
|---|---|---|
| Section planes (drag handle, caps, GPU discard) | `tools/section.rs`, `viewport/dsl.rs` | **missing** — big gap |
| Explode view (by-storey / by-element) | `tools/explode.rs` | **missing** |
| Sun study (NOAA solar, day/hour scrub, compass) | `tools/sun_study.rs`, `tools/overlay.rs` | **missing** |
| Full object snap incl. midpoints/face + glyph preview | `tools/snap.rs` (we have most already) | **partial** — we lack glyph/ghost preview + normal |
| Element info card (I) + reveal in outliner | `tools/info.rs` | **missing** |
| Command palette (F3 fuzzy) + keymap help (F1) | `ui/command_palette.rs`, `ui/keymap.rs` | **missing** — high value, low risk |
| Isolate/solo/hide/unhide (H/Shift+H/Alt+H, `/`) | `tools/isolate.rs` | **partial** — we have isolate (I) + per-part hide via outliner; no solo, no unhide-all hotkey |
| F12 high-res render + Save PNG / track-to-mp4 | `render/mod.rs` | **missing** (we have ray mode, no export render) |
| Progressive path-traced preview | `viewport/mod.rs` | **missing** (we have Realistic/Ray view modes) |
| Drag-number / value field; colour picker | `ui/dragnum.rs`, `ui/colorpick.rs` | **partial** — we have numeric TextInputs; no drag+fine control |
| X-ray toggle; 6 shading modes | `api.rs`, `viewport/dsl.rs` | **partial** — we have 6 modes via display_mode; no x-ray |
| `●`/`○` outliner (done), gets search + type filter | `ui/outliner.rs` | **partial** — done base; no search/filter/funnel |
| Predefined camera views (front/right/top/iso) | `nav/`, `api.rs`, `keymap.rs` | **partial** — we have Alt+1-8 presets already |
| Frustum culling + BVH (element-level) | `model/bvh.rs` | **done** — we already ported BVH + frustum culling |
| Instancing/batching by shared shape | — | **done** — we have ShapeHash instancing |
| Measure distance/angle/area | `tools/measure.rs` | **done** — ported |
| Per-part visibility honored in render/pick/snap | `viewport/elements.rs` | **done** (new in this session) |
| Properties panel readout | `ui/properties.rs` | **partial** — we have X/Y/Z inputs + kind; no IFC-ish grouped props |
---
## Phase A — Command palette + keyboard map (highest value, lowest risk)
**Why first:** delivers broad discoverability and requires no new GPU/scene work;
reuses our existing workspace action handlers and already-mapped hotkeys.
1. **Pure command table** `command_palette.rs`: `Vec<PaletteItem{ id, label, shorcut, run }>`.
Commands = existing actions we already support: frame all (fit), frame selected,
preset views (F5/Alt+1-8), ortho toggle, shading modes, isolate, hide/show all,
toggle outliner, undo/redo, open/save, exit. Each `run` dispatches to the same
`CadWorkspace` handlers our toolbar buttons already call.
2. **Fuzzy subsequence matcher** (pure fn, unit-tested) — port fab's scoring
(subsequence + prefix/word-start bonus) exactly.
3. **Palette overlay** in the mobile DSL (a `View` list + filter `TextInput`,
arrow-keys + Enter), toggled by the existing keymap or a toolbar button.
4. **Keymap table** `keymap.rs` — single source of truth for our hotkeys; render an
**F1 help** panel from it (like fab). Unit test that every key maps to a real action.
**Acceptance:** palette filters and runs ≥6 commands with tests; F1 help renders from
the table; `parameter.palette` tests green; full lib suite green.
---
## Phase B — Isolate/solo/hide/unhide parity (small, our mechanism)
**Adapt:** fab uses an *isolation set* / solo mode; we use `__hidden__` name prefix
(made real this session). Extend, do not rewrite.
1. `CadViewport::solo_selected` — isolate to the selection; toggle off on repeat
(`isolate_selected` already does exactly this — expose as hotkey + outliner button).
2. `CadViewport::unhide_all` — alias for existing `show_all`; bind **Alt+H**.
3. Bind **H** = isolated-selected (currently `I`), keep `I` too. Unit test
`isolate_selected` round-trips (hide then restore) — add a test now that the
visibility mechanism is honored.
**Acceptance:** hotkeys + 2 unit tests (isolate round-trip, solo toggle); lib green.
---
## Phase C — Element info card + reveal in outliner
**Adapt:** fab's `I` tool card shows type/storey/layer/GUID/size/tri-count/quantities.
We have no storey/layer UI per part but have `CadNode` fields (name, kind, pos, size,
color, layer) + mesh tri-count via `scene_cache`.
1. `properties.rs` or new `info_card.rs`: pure `info_card_text(&CadNode, tri_count)`
returning the multi-line card (kind, id, name, pos, size, layer, tris). Unit-tested.
2. Draw the card as a small label overlay near the hovered part in `viewport_render.rs`
(2D + 3D), or reuse the status bar when parked. Follow fab's "click focuses and
reveals in outliner" by opening the outliner and selecting the part.
**Acceptance:** `info_card_text` tests; overlay/status wiring compiles; lib green.
---
## Phase D — Section planes (largest rendering gap)
**Scope honestly:** fab's section = GPU half-space discard + caps in `dsl.rs`. We use
a different renderer (`DrawCadMesh` shader, display_mode uniform). A faithful port is
large: add half-space uniforms to the shader + caps pass + drag handle + panel.
**Adapted approach (bounded):**
1. **CPU clip** in `viewport_render.rs`: when a section plane is active, keep only
parts whose AABB is entirely inside the kept half-spaces; draw a plane outline +
normal arrow overlay (reuse our existing overlay drawing). This gives the *editor
UX* (see the cut live, drag to move) without touching the shader.
2. `section.rs` (pure): `SectionPlane{ normal, offset }`, `kept(aabb) -> bool`,
`plane_through(p0, normal)`, offset/with_offset helpers — port from fab, unit-test.
3. Panel: `SetSection` buttons (axis, flip, clear) in the outliner/properties panel.
4. **Shader caps (stretch, gate):** add a CLIP uniform + cap fill only if CPU clip is
judged insufficient after a measurement of real scenes. Keep out of the first cut.
**Acceptance:** `section.rs` unit tests; CPU-clip + overlay compiles and draws; no
regression in lib suite. **Phase marked done even without GPU caps**, which are an
explicitly-gated stretch (named as external-effort, consistent with the completion
standard).
---
## Phase E — Explode view
**Adapt:** our parts have no "storey" grouping by default; support **by-element**
radial explode first, include **by-storey** only if a grouping exists (outliner could
group by `layer`).
1. `explode.rs` (pure): `ExplodeMode{ ByElement }`, `ExplodeState{ amount }`,
`element_offset(id_idx, centre, amount)` — port fab's radial rule, unit-test.
2. Apply offsets in `part_model_matrix_cadnode`/the draw when explode active
(transform-time, so pick/snap reuse the same offset — no LUT needed).
3. `ExplodeState` stored on `CadViewport`; slider in the outliner panel actions.
**Acceptance:** `explode.rs` tests (element 0 offset = 0; radial sign/direction);
transform application compiles; lib green.
---
## Phase F — Sun study
**Adapt:** pure NOAA solar model (azimuth/elevation from lat/lon/date/time) + a day
scrub. Our CAD has a real `u_light_dir` uniform (per `DrawCadMesh`), so the sun can
drive the existing key light + a cast-shadow plane fill.
1. `sun.rs` (pure): `SunSettings{ latitude, longitude, date, hour }`, NOAAlike
`solar_position() -> (azimuth_deg, elevation_deg)`, `compass_point()`,
`direction() -> Vec3f` — port from `api::SkyState` and `sun_study.rs`, unit-test
against known noon values.
2. Toolbar button opens a small sun panel (date/hour/latitude, play scrub) reusing
the drag-number/TextInput style; set `u_light_dir` from `direction()` in
`viewport_render.rs`.
3. Overlay sun-compass (arc + disc + readout) drawn in the viewport — port the
math, keep our draw style.
**Acceptance:** `sun.rs` tests (elevation sign at noon, compass names); light-dir
wiring compiles; overlay compiles; lib green.
---
## Phase G — F12 high-res render + Save PNG
**Adapt:** fab uses a progressive path-traced preview + `FabRenderView`. Our CAD has
a **Ray** shading mode via `display_mode` but no standalone capture. Minimal:
1. `RenderSettings{ width, height, samples }` state on `CadWorkspace`.
2. "Render" action captures the current scene at render resolution using our
existing DrawCadMesh into an offscreen target, accumulates, and **writes a PNG**
(we already export PNG from the arch_pdf path, so the encoder exists — reuse it).
3. Command-palette entry `render-image` (F12).
**Acceptance:** a `render settings` pure struct + tests; the PNG write path is wired
through an existing tested encoder; no new dependency; lib green.
---
## Phase H — X-ray + shading parity + value-field polish (fill-in gaps)
1. **X-ray:** add an `xray` overlay uniform to `DrawCadMesh` (or reuse display_mode
degree), toggled by `Alt+Z` + a toolbar button; only affects the shader, tested by
`parameter` snapshot if present.
2. **Drag-number:** port fab's pure `header_drag_math` (anchor/step/fine/ctrl) as a
Rust fn with tests, and wrap our existing numeric `TextInput`s where ergonomic
(properties panel X/Y/Z/W/H/D). Keep current inputs working.
3. **Outliner search + type filter:** add a `TextInput` filter in the outliner panel;
pure filter fn `filter_rows(rows, query) -> Vec<..>` unit-tested; funnel dropdown
filters by `PartKind`.
**Acceptance:** per-item tests; no regression; lib green.
---
## Explicitly NOT porting (with reason)
- **fab's `api.rs` shell/`ShellAction` dictionary** — our app has a different action
model and mobile-first layout; adopting it would be a rewrite.
- **`ui/shell.rs` dock / `area.rs` swappable editors / `menubar.rs`** — desktop-chrome
that our 430x860 mobile UI does not host; our toolbar + bottom sheet already cover it.
- **`render/mod.rs` camera-track to mp4** — needs movie encoding we don't ship.
- **`file_browser.rs` / in-app open dialog** — platform has no file picker; gated on
a platform capability, not effort (matches the completion-standard exception).
- **`ui/colorpick.rs` full hue-ring picker** — nice-to-have; we have a 9-swatch palette;
deferred unless requested.
- **`nav/gizmo.rs` axis-ball gizmo** — we have a nav pad + preset views; low ROI.
- **`ui/dragnum.rs` drag-number value field** — parity with fab: we have numeric
`TextInput`s in the properties panel; full drag+fine-control (anchor/step/ctrl)
is a UX polish, not an inspection capability. ([cross-ref Phase H.2](deferred).)
- **`render/mod.rs` progressive path-traced preview (live noise-accumulating view)**
— we ship Quality/Realistic/Ray shading modes already; porting fab's live
progressive preview to our GPU path is large and gated. See Phase G for the
bounded capture/export path we *do* ship.
---
## Recommended order & effort
| Phase | Effort | Risk | Do first? |
|---|---|---|---|
| A Command palette + keymap | S | low | ✅ yes |
| B Isolate/solo/outliner parity | XS | low | ✅ yes |
| C Info card + reveal | S | low | ✅ yes |
| D Section planes (CPU clip) | M | med | next |
| E Explode | S | low | next |
| F Sun study | M | med | later |
| G F12 render + PNG | M | med | later |
| H X-ray/dragnum/outliner search | M | med | last |
S = small, M = medium. Each phase ends with unit tests + green `--lib` suite, and the
GPU-heavy items (D caps, F shadows) are gated as explicit named work rather than
silently dropped.

View file

@ -0,0 +1,19 @@
[package]
name = "doc-ui"
version = "0.1.0"
edition = "2021"
description = "Makepad widget wrappers for the CRDT document engine."
publish = false
[dependencies]
makepad-widgets = { workspace = true, features = ["test"] }
doc-engine = { path = "../doc-engine" }
nigig-core = { path = "../../../nigig-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
robius-file-picker = { git = "https://github.com/project-robius/robius", rev = "b766e62b0600f5d2ee21cc6995648346fc277bd8" }
zip = "8"
quick-xml = "0.41"
[dev-dependencies]
makepad-test = { workspace = true }

View file

@ -232,20 +232,73 @@ init:` lines in logcat naming the branch that fired.
## 10. Sign-off
Device / OS / build:
Device / OS / build: Galaxy A60 (SM-A6060, `R28M52LJP2Y`) · Android ·
`pageflipnav` release APK, driven by the `doc_*` tests in
`crates/pageflipnav/tests/ui.rs`. All 14 tests PASS on device (8
nav/state + 6 content-operation: `doc_type_text_inserts_document`,
`doc_bold_italic_underline_ops`, `doc_insert_table_and_cell_text`,
`doc_merge_split_cell_ops`, `doc_long_press_empty_space_does_not_arm`,
`doc_diagonal_cell_range_merges`, plus `doc_diag_*` used to pin geometry).
| Section | CRDT workspace | Legacy DocWorkspace | Notes |
|---------|----------------|---------------------|-------|
| 1 Interaction mode | | | |
| 2 IME | | | |
| 3 Long-press + menu | | | |
| 4 Table gestures | | | |
| 5 Scroll handoff | | | the open roadmap box — closing needs BOTH columns |
| 6 Clipboard round-trips | | n/a | payload semantics are engine-agnostic |
| 7 Multi-line rendering | | | |
| 8 Visual sweep | | | |
| 9 Persistence | | | |
Verified devices: Galaxy A60 (SM-A6060, `R28M52LJP2Y`, 411 dp) and Galaxy
A16 (SM-A165F, `RF8Y103NERA`, 384 dp). One A60 full-suite run had 3
`adb: device not found` USB drops (test-infra flakes, not logic); all 3
reran clean on the A16.
When every row is PASS: check the roadmap's ScrollYView handoff box
with a link to this file, and archive the completed form under the
milestone notes in the README.
Coverage legend: **A** = automated (ran green on device),
**A⚠** = automated but only proves a subset, **M** = manual-only (cannot
be driven by `makepad_test` — reason in the Note column).
| # | Row | Result | Test / Note |
|---|-----|--------|-------------|
| 1.1 | Cold-launch tap → no keyboard | A | `doc_view_mode_scroll_by_touch` (View cold-release path); first tap never opens IME |
| 1.2 | Vertical drag → page scrolls | A | `doc_view_mode_scroll_by_touch` |
| 1.3 | Edit button relabels Done; tap opens IME | A | `doc_interaction_mode_view_and_edit`, `doc_ime_text_input_in_edit_mode` |
| 1.4 | Done closes keyboard | A⚠ | `doc_interaction_mode_view_and_edit` relabels back to Edit; keyboard visibility itself is not snapshot-observable, so "closes" is inferred from the Edit state + subsequent passive taps |
| 2.1 | Edit: tap mid-word, type | A | `doc_type_text_inserts_document` (stats `2 words | 11 chars`) |
| 2.2 | Tap in a cell, type | A | `doc_insert_table_and_cell_text` (cell "alpha beta" joins stats → `4 words | 21 chars`) |
| 2.3 | Accept autocorrect suggestion | M | `makepad_test` cannot press the OS keyboard's suggestion bar; needs a human tap |
| 2.4 | Type in empty cell, undo once | A⚠ | Undo not asserted on device; covered by `doc-ui` unit `runtime_cell_backspace_edits_cell_and_ctrl_z_restores_it` |
| 2.5 | Long word overflows cell | M | Visual single-line overflow check — note only, eyeball |
| 3.1 | Long-press word → word selects | A⚠ | `doc_long_press_arms_selection` proves the arm runs and the doc stays alive; handle/menu pixels are visual |
| 3.2 | Long-press with keyboard open | M | Cannot open/clamp the OS keyboard from the harness |
| 3.3 | Drag start/end handle | M | Handle hit-testing + native menu placement need visual confirmation |
| 3.4 | Long-press empty space → nothing arms | A | `doc_long_press_empty_space_does_not_arm` |
| 3.5 | Long-press cell → cell range arms | A | arming used by `doc_merge_split_cell_ops` (drag spans col1) |
| 3.6 | Menu re-floats after handle drag | M | Native menu is OS-owned, not a snapshot-able widget |
| 4.1 | Diagonal 2x2 range + highlight | A | `doc_diagonal_cell_range_merges` |
| 4.2 | Merge; undo in one step | A | `doc_merge_split_cell_ops` (merge); undo is unit-covered |
| 4.3 | Split merged cell | A | `doc_merge_split_cell_ops` (`Split merged cell`) |
| 4.4 | Drag past table edge clamps | A⚠ | Merge path proves the range stays in-col; out-of-table clamp is unit-covered |
| 4.5 | Paste range into second table | M | Requires system clipboard content + external app (section 6) |
| 5.1 | View drag → pans | A | `doc_view_mode_scroll_by_touch` |
| 5.2 | Edit quick drag → pans, no selection | A | `doc_scroll_handoff_view_and_edit` |
| 5.3 | Selection armed, lift, then drag scrolls | M | Requires a held selection + OS interactions not snapshot-able; unit/gesture-covered in `doc-ui` |
| 5.4 | Hold after long-press → selection tracks | M | Gesture requires frame-true finger sequencing only partially reproducible; covered by `doc-ui` `runtime_*` gesture tests |
| 5.5 | Legacy DocWorkspace 5.15.4 | M | Legacy workspace untested; blocked by in-progress user work (`nigig-build`, `xls_import`) |
| 5.6 | End-of-content rubber-band | M | Platform convention, visual |
| 6.16.4 | Clipboard round-trips | M | Needs `adb` clipboard + an external notes app to paste INTO — no harness API for system clipboard reads/veto |
| 7.17.4 | Multi-line cell rendering | M | Visual inspection of row growth / caret bands / overlap |
| 8.18.6 | Visual paint/clip sweep | M | GPU painting — eyeball, photograph failures |
| 9.0 | Fresh install demo doc | M | Needs app data wipe (reinstall) between launches; harness can't reset app-data |
| 9.1 | Force-close + relaunch restores | M | Harness cannot kill/relaunch the process to exercise the load path |
| 9.2 | Open saved file with table | M | Same process-restart limitation |
| 9.3 | Classic-format save fallback | M | Needs a pre-written classic save on the device + relaunch; unit-covered elsewhere |
Two real mobile-only bugs were found and fixed by these device runs
(unit-testable parts covered in `doc-ui/src/tests.rs`):
1. The CRDT/legacy toolbars overflowed the ~411 dp phone screen,
clipping Italic and pushing Underline/Table/Merge/Split off-screen —
the toolbars now wrap (`flow: Right {wrap: true}` in `doc-ui/src/lib.rs`).
2. `insert_table` produced a zero-size, un-typeable table — it now seeds a
2x2 grid and parks the caret in the top-left cell (`crdt_widget.rs`,
covered by `runtime_insert_table_seeds_default_grid_and_parks_caret_in_first_cell`).
Closing notes:
- The roadmap's ScrollYView handoff box (section 5) is NOT closed: most
of section 5 and the entire legacy `DocWorkspace` column remain manual
(5.3, 5.4, 5.5, 5.6).
- Rows still open: 2.3, 2.5, 3.1 (pixels), 3.2, 3.3, 3.5/3.6 (menu),
4.4 (pixels), 4.5, 5.35.6 (legacy + gestures), 6 (clipboard), 7
(visual), 8 (visual), 9 (process-restart). Most are genuinely not
automatable through `makepad_test`; the reasons are in the table.

View file

@ -1,6 +1,6 @@
//! Versioned JSON persistence for advanced nodes. Unsupported node kinds are
//! rejected rather than silently dropped.
use crate::construction_frame::pages::workspace::doc::model::{
use crate::model::{
BlockKind, CanvasNode, CanvasObject, CellStyle, DocumentNode, EmbeddedWidgetNode, ImageNode,
Inline, ListItem, TableBorders, TableCellModel, TableColumn, TableModel, TableRow,
};
@ -684,7 +684,7 @@ impl TryFrom<&Inline> for PersistedInline {
impl TryFrom<PersistedInline> for Inline {
type Error = String;
fn try_from(inline: PersistedInline) -> Result<Self, Self::Error> {
use crate::construction_frame::pages::workspace::doc::model::StyleSpan;
use crate::model::StyleSpan;
Ok(match inline {
PersistedInline::Text {
text,

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::editing::Transaction;
use crate::editing::Transaction;
pub type ActorId = String;

View file

@ -1,5 +1,5 @@
use super::ActorId;
use crate::construction_frame::pages::workspace::doc::model::{
use crate::model::{
CrdtSelection, CrdtTextPosition, DocCursor, Selection,
};

View file

@ -37,7 +37,7 @@ impl CollaborationSession {
pub fn make_local(
&mut self,
base_revision: u64,
transaction: crate::construction_frame::pages::workspace::doc::editing::Transaction,
transaction: crate::editing::Transaction,
) -> DocumentOperation {
self.next_sequence = self.next_sequence.wrapping_add(1);
let id = OperationId {

View file

@ -1,6 +1,6 @@
//! Temporary bridge from the standalone CRDT engine into the existing Makepad
//! document UI. Remove this once DocEditor consumes projections directly.
use crate::construction_frame::pages::workspace::doc::model::{
use crate::model::{
DocAlign, DocBlock, Document, StyleSpan,
};
use doc_engine::projection::DocumentProjection;
@ -16,7 +16,7 @@ impl CrdtProjectionBridge {
let cols = table.columns.len().max(1);
let cells = (0..rows).map(|row| (0..cols).map(|col| {
let key = (table.rows.get(row).cloned().unwrap_or_default(), table.columns.get(col).cloned().unwrap_or_default());
crate::construction_frame::pages::workspace::doc::model::CellContent { text: table.cells.get(&key).cloned().unwrap_or_default() }
crate::model::CellContent { text: table.cells.get(&key).cloned().unwrap_or_default() }
}).collect()).collect();
return DocBlock::Table { rows, cols, col_widths: vec![160.0; cols], cells };
}
@ -35,15 +35,15 @@ impl CrdtProjectionBridge {
}).collect();
document.nodes = projection.nodes.iter().enumerate().map(|(index, node)| {
let kind = match node.kind.as_str() {
"canvas" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Canvas(crate::construction_frame::pages::workspace::doc::model::CanvasNode { size: makepad_widgets::dvec2(400.0, 240.0), objects: Vec::new() }),
"divider" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Divider,
"audio" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Audio { resource: node.state_json.clone() },
"video" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Video { resource: node.state_json.clone() },
"diagram" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Diagram { resource: node.state_json.clone() },
"image" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Image(crate::construction_frame::pages::workspace::doc::model::ImageNode { resource: node.state_json.clone(), size: makepad_widgets::dvec2(480.0, 220.0), caption: Vec::new() }),
_ => crate::construction_frame::pages::workspace::doc::model::BlockKind::EmbeddedWidget(crate::construction_frame::pages::workspace::doc::model::EmbeddedWidgetNode { widget_type: node.kind.clone(), state_json: node.state_json.clone(), preferred_size: None }),
"canvas" => crate::model::BlockKind::Canvas(crate::model::CanvasNode { size: makepad_widgets::dvec2(400.0, 240.0), objects: Vec::new() }),
"divider" => crate::model::BlockKind::Divider,
"audio" => crate::model::BlockKind::Audio { resource: node.state_json.clone() },
"video" => crate::model::BlockKind::Video { resource: node.state_json.clone() },
"diagram" => crate::model::BlockKind::Diagram { resource: node.state_json.clone() },
"image" => crate::model::BlockKind::Image(crate::model::ImageNode { resource: node.state_json.clone(), size: makepad_widgets::dvec2(480.0, 220.0), caption: Vec::new() }),
_ => crate::model::BlockKind::EmbeddedWidget(crate::model::EmbeddedWidgetNode { widget_type: node.kind.clone(), state_json: node.state_json.clone(), preferred_size: None }),
};
crate::construction_frame::pages::workspace::doc::model::DocumentNode { id: (index + 1) as u64, style: Default::default(), kind }
crate::model::DocumentNode { id: (index + 1) as u64, style: Default::default(), kind }
}).collect();
let projected_blocks = document.blocks.clone();
let node_ref = |id: &str| {
@ -77,7 +77,7 @@ impl CrdtProjectionBridge {
) {
if let Some(block_idx) = projection.order.iter().position(|id| id == &table.id)
{
document.table_merges.push(crate::construction_frame::pages::workspace::doc::model::TableMerge { selection: crate::construction_frame::pages::workspace::doc::model::TableSelection { block_idx, start_row, start_col, end_row, end_col }.normalized() });
document.table_merges.push(crate::model::TableMerge { selection: crate::model::TableSelection { block_idx, start_row, start_col, end_row, end_col }.normalized() });
}
}
}

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state;
use crate::persistence::load_saved_doc_state;
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*;
use std::cell::RefCell;
@ -6,7 +6,7 @@ use std::rc::Rc;
// NOTE: imported rather than referenced as a fully-qualified path below.
// The `Script`/`Widget` derive macros cannot parse a path type (`a::b::C`)
// in a `#[rust]` field and reject it with "Unexpected field form".
use crate::construction_frame::pages::workspace::doc::projection_layout::{
use crate::projection_layout::{
block_glyph_offset, cell_char_offset_at, cell_range_mergeable, cell_selection_rects,
cell_text_backspace, cell_text_delete, cell_text_insert, cell_text_line_col,
cell_text_line_count, cell_text_offset_at, cell_text_replace_range, cell_text_span_rects,
@ -15,11 +15,11 @@ use crate::construction_frame::pages::workspace::doc::projection_layout::{
table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_range, table_cell_text,
word_atom_range, ProjectionLayoutTree, SelectionHandles,
};
use crate::construction_frame::pages::workspace::doc::projection_renderer::ProjectionRenderer;
use crate::construction_frame::pages::workspace::doc::projection_session::{
use crate::projection_renderer::ProjectionRenderer;
use crate::projection_session::{
crdt_engine_from_saved, crdt_save_wire, ProjectionSession, TableCellCursor, TableCellSelection,
};
use crate::construction_frame::pages::workspace::doc::{
use crate::{
InteractionMode, MobileGestureAction, MobileGestureRouter, MobileGestureState,
};
use doc_engine::controller::DocumentController;
@ -123,6 +123,23 @@ pub struct CrdtDocEditor {
layout_cache: RefCell<Option<(u64, Rc<ProjectionLayoutTree>)>>,
}
/// Total content height of a projection layout, from the top margin down to
/// the bottom-most glyph/table/node. Used to resolve the editor's Fit walk
/// into a concrete Fixed height for `walk_turtle`.
fn content_height(layout: &ProjectionLayoutTree) -> f64 {
let mut bottom = 0.0_f64;
for glyph in &layout.glyphs {
bottom = bottom.max(glyph.rect.pos.y + glyph.rect.size.y);
}
for table in &layout.tables {
bottom = bottom.max(table.rect.pos.y + table.rect.size.y);
}
for node in &layout.nodes {
bottom = bottom.max(node.rect.pos.y + node.rect.size.y);
}
(bottom + crate::projection_layout::LAYOUT_MARGIN).max(1.0)
}
impl CrdtDocEditor {
pub fn set_engine(&mut self, cx: &mut Cx, engine: doc_engine::controller::DocumentController) {
self.engine = engine;
@ -249,9 +266,49 @@ impl CrdtDocEditor {
/// legacy toolbar's CRDT InsertBlock-table routing.
pub fn insert_table(&mut self, cx: &mut Cx) -> bool {
let after = self.session.cursor_block.clone();
if self.engine.insert_table("local", after).is_none() {
let Some(table) = self.engine.insert_table("local", after) else {
return false;
};
// Seed a default 2x2 grid so the table is visible and usable:
// a bare table block carries no rows/columns/cells and the layout
// (driven purely by their counts) renders it at zero size. Park
// the caret in the first cell so typing lands in the table, matching
// the legacy editor's insert behavior.
let mut rows = Vec::new();
for _ in 0..2 {
if let Some(row) = self.engine.insert_table_row("local", table.clone(), None) {
rows.push(row);
}
}
let mut cols = Vec::new();
for _ in 0..2 {
let after = cols.last().cloned();
if let Some(col) = self.engine.insert_table_column("local", table.clone(), after) {
cols.push(col);
}
}
// Rows/columns anchored on the same `after` serialize counter-
// descending, so the visual first cell is the LAST-inserted row and
// the FIRST column of the seed. Park the caret there (top-left).
let row_id = rows.pop().or_else(|| rows.first().cloned());
let col_id = cols.first().cloned();
if let (Some(row_id), Some(col_id)) = (row_id, col_id) {
if self
.engine
.set_table_cell("local", table.clone(), row_id.clone(), col_id.clone(), "")
{
self.session.cell_cursor = Some(TableCellCursor {
table,
row: row_id,
column: col_id,
offset: 0,
});
}
}
self.session.cell_selection = None;
self.session.cell_text_anchor = None;
self.session.cursor_block = None;
self.session.cursor_atom = None;
self.redraw(cx);
true
}
@ -2717,7 +2774,28 @@ impl Widget for CrdtDocEditor {
if self.engine.projection.blocks.is_empty() {
self.engine.insert_block("local", None, "paragraph");
}
let rect = cx.walk_turtle(walk);
// Adopt the mobile interaction policy at boot (mirroring the legacy
// DocEditor): a phone-width window starts in View — IME closed, the
// Edit/Done toolbar button re-enters Edit; wide/desktop stays Edit.
if !self.mobile_mode_initialized && cx.cx.display_context.is_screen_size_known() {
self.interaction_mode = if cx.cx.display_context.screen_size.x < 700.0 {
InteractionMode::View
} else {
InteractionMode::Edit
};
self.mobile_mode_initialized = true;
}
let layout = self.layout_tree();
// Fit-height custom widgets have no intrinsic height makepad can
// resolve, so walk_turtle would yield a NaN height and the editor
// collapses to an invisible 0x0. Resolve the content height from
// the projection layout (mirrors the legacy DocEditor) and ask for
// that concrete size before walking.
let mut fixed_walk = walk;
if let Size::Fit { .. } = walk.height {
fixed_walk.height = Size::Fixed(content_height(&layout));
}
let rect = cx.walk_turtle(fixed_walk);
self.draw_bg.draw_abs(cx, rect);
let layout = self.layout_tree();
ProjectionRenderer::draw_text_projection(

View file

@ -0,0 +1,290 @@
//! `DocDashboard` widget: the file-list view shown on first launch or
//! before a document is opened.
//!
//! Shows saved documents from the `generated/` directory in a grid of
//! preview cards. A "+ New" button creates a blank document. Clicking a
//! card opens that document. "Import Doc" opens a platform-native file
//! dialog for `.docx`/`.odt`/`.rtf`/`.txt`/`.md`/`.doc.json`.
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*;
use crate::persistence::{list_saved_docs, DocEntry};
/// Emitted to the workspace when the dashboard wants to switch views.
#[derive(Clone, Debug)]
pub enum DocDashboardAction {
/// Create a new blank document (replaces the current model).
NewDocument,
/// Open an existing saved document by filename (from `generated/`).
OpenFile(String),
/// User wants to go back to the dashboard.
BackToDashboard,
/// User wants to import an external document from a platform-native
/// file dialog.
ImportDocument,
}
#[derive(Script, ScriptHook, Widget)]
pub struct DocDashboard {
#[deref]
view: View,
#[rust]
files: Vec<DocEntry>,
#[rust]
pub action: Option<DocDashboardAction>,
#[rust]
initialized: bool,
// --- Draw resources for the file card grid (manual rendering) ---
#[live]
draw_card_bg: DrawColor,
#[live]
draw_card_hover_bg: DrawColor,
#[live]
draw_card_text: DrawText,
#[live]
draw_card_preview: DrawText,
#[live]
card_normal_color: Vec4f,
#[live]
card_hover_color: Vec4f,
#[live]
card_text_color: Vec4f,
#[live]
card_preview_color: Vec4f,
/// Hit-test areas for each file card.
#[rust]
card_areas: Vec<(usize, Rect)>,
#[rust]
rect: Rect,
/// Index of the card currently under the cursor (for hover highlight).
#[rust]
hover_card: Option<usize>,
}
/// Toggle the dashboard's own visibility for the workspace overlay.
/// The runtime widget is this component, so the workspace cannot
/// downcast it to a plain `View`; this forwards to the component's
/// root view instead.
impl DocDashboard {
pub fn set_dash_visible(&mut self, cx: &mut Cx, visible: bool) {
self.view.set_visible(cx, visible);
}
}
impl Widget for DocDashboard {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
if let Event::Actions(actions) = event {
self.handle_actions(cx, actions, scope);
}
self.handle_card_clicks(cx, event);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.initialized {
self.refresh_files();
self.initialized = true;
}
let draw_step = self.view.draw_walk(cx, scope, walk);
self.rect = self.view.area().rect(cx);
if !self.files.is_empty() {
self.draw_cards(cx);
}
draw_step
}
}
impl DocDashboard {
/// Refresh the file listing from disk.
pub fn refresh_files(&mut self) {
self.files = list_saved_docs();
self.card_areas.clear();
}
/// Called by the workspace when it becomes visible.
pub fn refresh_and_redraw(&mut self, cx: &mut Cx) {
self.files = list_saved_docs();
self.card_areas.clear();
self.view.redraw(cx);
}
/// Draw document preview cards onto the canvas.
fn draw_cards(&mut self, cx: &mut Cx2d) {
self.card_areas.clear();
let area = self.view.area().rect(cx);
let card_w = 240.0_f64;
let card_h = 100.0_f64;
let margin_x = 16.0_f64;
let margin_y = 80.0_f64;
let spacing_x = 20.0_f64;
let spacing_y = 16.0_f64;
let cols = ((area.size.x - margin_x * 2.0 + spacing_x) / (card_w + spacing_x)) as usize;
let cols = cols.max(1);
let mut col = 0usize;
let mut row = 0usize;
for (i, entry) in self.files.iter().enumerate() {
let x = area.pos.x + margin_x + col as f64 * (card_w + spacing_x);
let y = area.pos.y + margin_y + row as f64 * (card_h + spacing_y);
let card_rect = Rect {
pos: DVec2 { x, y },
size: DVec2 {
x: card_w,
y: card_h,
},
};
let is_hovered = self.hover_card == Some(i);
self.draw_card_bg.color = if is_hovered {
self.card_hover_color
} else {
self.card_normal_color
};
self.draw_card_bg.draw_abs(cx, card_rect);
// Title (first paragraph).
self.draw_card_text.color = self.card_text_color;
let title = if entry.title.is_empty() { "(empty)" } else { &entry.title };
self.draw_card_text.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 12.0,
},
title,
);
// Preview snippet.
let preview = if entry.preview.is_empty() {
"(empty)"
} else {
&entry.preview
};
self.draw_card_preview.color = self.card_preview_color;
self.draw_card_preview.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 34.0,
},
preview,
);
// Filename.
self.draw_card_preview.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 52.0,
},
&entry.filename,
);
// File size.
let size_str = if entry.size < 1024 {
format!("{} B", entry.size)
} else {
format!("{:.1} KB", entry.size as f64 / 1024.0)
};
self.draw_card_preview.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 68.0,
},
&size_str,
);
self.card_areas.push((i, card_rect));
col += 1;
if col >= cols {
col = 0;
row += 1;
}
}
}
/// Handle clicks on document cards.
fn handle_card_clicks(&mut self, cx: &mut Cx, event: &Event) {
if let Hit::FingerMove(fme) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) {
let pos = fme.abs;
let new_hover = self
.card_areas
.iter()
.find(|(_, rect)| rect.contains(pos))
.map(|(i, _)| *i);
if new_hover != self.hover_card {
self.hover_card = new_hover;
self.view.redraw(cx);
}
}
if let Event::TouchUpdate(tu) = event {
for touch in &tu.touches {
if touch.state == TouchState::Stop {
for &(idx, rect) in &self.card_areas {
if rect.contains(touch.abs) {
let filename = self.files[idx].filename.clone();
self.action = Some(DocDashboardAction::OpenFile(filename));
return;
}
}
}
}
}
if let Hit::FingerUp(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) {
if fe.is_primary_hit() {
for &(idx, rect) in &self.card_areas {
if rect.contains(fe.abs) {
let filename = self.files[idx].filename.clone();
self.action = Some(DocDashboardAction::OpenFile(filename));
return;
}
}
}
}
}
fn create_new_document(&mut self, cx: &mut Cx) {
self.action = Some(DocDashboardAction::NewDocument);
self.view.redraw(cx);
}
}
impl WidgetMatchEvent for DocDashboard {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
if self.button(cx, ids!(new_document_btn)).clicked(actions) {
self.create_new_document(cx);
}
if self.button(cx, ids!(refresh_btn)).clicked(actions) {
self.refresh_and_redraw(cx);
}
if self.button(cx, ids!(back_btn)).clicked(actions) {
self.action = Some(DocDashboardAction::BackToDashboard);
self.view.redraw(cx);
}
if self.button(cx, ids!(import_doc_btn)).clicked(actions) {
self.action = Some(DocDashboardAction::ImportDocument);
self.view.redraw(cx);
}
}
}

View file

@ -0,0 +1,598 @@
//! External document import for the doc dashboard.
//!
//! Reads `.doc.json` (the app's own save format), `.docx`, `.odt`,
//! `.rtf`, `.txt` and `.md` files and converts them into the `#MP_CRDT_V1`
//! wire format both editors consume. Text extraction is deliberately
//! lightweight: `zip` + `quick-xml` walk the package XML, `.rtf` is
//! control-word stripped, and plain text is split into paragraphs. The
//! goal is readable prose, not a lossless round-trip of the source format.
use std::io::Read;
use std::path::Path;
use doc_engine::controller::DocumentController;
use doc_engine::crdt::OpId;
use crate::projection_session::crdt_save_wire;
/// A document produced from an import: the `#MP_CRDT_V1` wire for either
/// editor plus dashboard metadata.
pub struct ImportedDoc {
/// Serialization accepted by `DocEditor::deserialize` and
/// `CrdtDocEditor::deserialize`.
pub wire: String,
/// First non-empty paragraph, or a fallback derived from the file.
pub title: String,
/// Preview snippet of the body text.
pub preview: String,
}
/// Outcome of the async document-import file picker.
///
/// The `robius-file-picker` completion callback runs off the UI thread with
/// no `Cx`, so it parks the outcome here and raises a UI signal; the
/// workspace's `drain_doc_import` applies it on the next `Event::Signal`.
/// This is the same shape the spreadsheet/invoicer apps use.
pub enum DocImportOutcome {
Picked(std::path::PathBuf),
Failed(String),
}
static PENDING_DOC_IMPORT: std::sync::Mutex<Option<DocImportOutcome>> =
std::sync::Mutex::new(None);
/// Lock (recovering a poisoned guard), park an outcome, and poke the UI
/// thread.
fn park_import(outcome: DocImportOutcome) {
let mut guard = PENDING_DOC_IMPORT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*guard = Some(outcome);
makepad_widgets::SignalToUI::set_ui_signal();
}
/// Take any parked file-picker outcome, if one is pending. Called on
/// `Event::Signal`.
pub fn take_pending_import() -> Option<DocImportOutcome> {
let mut guard = PENDING_DOC_IMPORT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.take()
}
/// Open the platform file dialog for a document file.
///
/// `robius-file-picker` rather than makepad's own dialog, which is
/// implemented on macOS only — the Linux and Android backends never
/// handle `CxOsOp::SelectFileDialog`, so the button would do nothing on
/// the platforms this repo targets.
pub fn open_document_dialog() -> Result<(), String> {
use robius_file_picker::FileDialog;
FileDialog::new()
.set_title("Import Document")
.add_filter(
"Documents",
&["docx", "odt", "rtf", "txt", "md", "json", "doc"],
)
.pick_file(|outcome| match outcome {
Ok(Some(picked)) => match picked.path() {
Some(path) => park_import(DocImportOutcome::Picked(path.to_path_buf())),
None => park_import(DocImportOutcome::Failed(
"That file has no local path this app can read".to_string(),
)),
},
// Cancelled: say nothing and change nothing.
Ok(None) => {}
Err(e) => park_import(DocImportOutcome::Failed(format!(
"File picker failed: {e}"
))),
})
.map_err(|err| err.to_string())
}
/// Import a document from an external file, chosen by extension.
pub fn import_document_from_path(path: &Path) -> Result<ImportedDoc, String> {
let text = std::fs::read(path)
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
let lower = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if lower.ends_with(".doc.json") || ext == "json" {
return import_native(&text);
}
let paragraphs = match ext.as_str() {
"docx" => parse_docx(&text)?,
"odt" => parse_odt(&text)?,
"rtf" => paragraphize(&strip_rtf(&String::from_utf8_lossy(&text))),
"txt" | "md" => paragraphize(&String::from_utf8_lossy(&text)),
other => {
return Err(format!(
"unsupported document format: {}",
if other.is_empty() { "(none)" } else { other }
))
}
};
finish_text_import(&paragraphs)
}
/// Native `.doc.json`: the content is either `#MP_CRDT_V1` wire or the
/// legacy `M|...` delimiter format. The wire passes through untouched;
/// legacy is converted to wire so it opens in both editors.
fn import_native(bytes: &[u8]) -> Result<ImportedDoc, String> {
let content = String::from_utf8_lossy(bytes).into_owned();
let paragraphs = plain_text_paragraphs(&content);
let (title, preview) = title_and_snippet(&paragraphs);
let wire = if content.starts_with(crate::projection_session::CRDT_SAVE_HEADER) {
content
} else {
legacy_to_wire(&paragraphs)?
};
Ok(ImportedDoc {
wire,
title,
preview,
})
}
/// Wrap extracted paragraphs into a CRDT wire document.
fn finish_text_import(paragraphs: &[String]) -> Result<ImportedDoc, String> {
let (title, preview) = title_and_snippet(paragraphs);
let wire = build_wire_from_paragraphs(paragraphs)?;
Ok(ImportedDoc {
wire,
title,
preview,
})
}
/// `plain_text_paragraphs` as a `DocPreview`-style pair: first non-empty
/// paragraph is the title, up to 160 chars of the body is the preview.
fn title_and_snippet(paragraphs: &[String]) -> (String, String) {
let title = paragraphs
.iter()
.find(|p| !p.trim().is_empty())
.cloned()
.unwrap_or_default();
let mut snippet = String::new();
for p in paragraphs {
let trimmed = p.trim();
if trimmed.is_empty() {
continue;
}
if !snippet.is_empty() {
snippet.push(' ');
}
snippet.push_str(trimmed);
if snippet.chars().count() >= 160 {
snippet = snippet.chars().take(160).collect();
snippet.push_str("");
break;
}
}
(title, snippet)
}
/// Split a raw text blob into non-empty trimmed paragraphs on blank lines.
fn paragraphize(text: &str) -> Vec<String> {
text.split('\n')
.map(|line| line.trim().to_string())
.collect()
}
/// Convert the legacy `P|`/`H` delimiter save to `#MP_CRDT_V1` wire, so a
/// classic-format import opens in the CRDT editor too.
fn legacy_to_wire(paragraphs: &[String]) -> Result<String, String> {
let paragraphs: Vec<String> = paragraphs
.iter()
.filter(|p| !p.trim().is_empty())
.cloned()
.collect();
build_wire_from_paragraphs(&paragraphs)
}
/// Build a `#MP_CRDT_V1` wire document, one paragraph block per string.
/// Empty paragraphs are skipped; an all-empty input yields an empty
/// paragraph-block document rather than failing (imports always succeed,
/// even for a blank file).
pub fn build_wire_from_paragraphs(paragraphs: &[String]) -> Result<String, String> {
build_wire(paragraphs, true)
}
/// Wire for a fresh blank document: a single empty paragraph block. The CRDT
/// editor's `init_document` gate only fires on an empty projection, so a new
/// page must have at least one block or it gets replaced by the saved
/// document on the first event.
pub fn blank_document_wire() -> Result<String, String> {
build_wire(&[String::new()], false)
}
fn build_wire(paragraphs: &[String], skip_blank: bool) -> Result<String, String> {
let mut controller = DocumentController::default();
let mut last: Option<OpId> = None;
for paragraph in paragraphs {
let text = paragraph.trim();
if skip_blank && text.is_empty() {
continue;
}
let block = controller
.insert_block("import", last.clone(), "paragraph")
.ok_or("could not create a paragraph block")?;
if !text.is_empty() {
controller.insert_text("import", block.clone(), None, text);
}
last = Some(block);
}
crdt_save_wire(&controller.document).ok_or_else(|| "could not serialize the imported document".to_string())
}
/// Extract the paragraph texts of a document save: `#MP_CRDT_V1` JSON or
/// the legacy delimiter format. Used by the dashboard previews.
pub fn plain_text_paragraphs(content: &str) -> Vec<String> {
if let Some(json) = content.strip_prefix(crate::projection_session::CRDT_SAVE_HEADER) {
if let Ok(document) = doc_engine::crdt::CrdtDocument::from_json(json) {
let projection = document.materialize();
return projection.blocks.iter().map(|block| block.text.clone()).collect();
}
}
// Legacy delimiter format: `P|align|text§…§~` and `H{level}|align|…`.
let mut out = Vec::new();
for line in content.lines() {
let mut parts = line.splitn(3, '|');
let kind = parts.next().unwrap_or("");
if kind == "P" || kind.starts_with('H') {
if let Some(spans) = parts.nth(1) {
let mut text = String::new();
for (index, chunk) in spans.split('§').enumerate() {
if index % 6 == 0 {
text.push_str(chunk);
}
}
out.push(text);
}
}
}
out
}
/// Extract paragraphs from a `.docx` (`word/document.xml`) by reading the
/// ZIP entry and walking `w:p` elements.
fn parse_docx(bytes: &[u8]) -> Result<Vec<String>, String> {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
.map_err(|err| format!("not a valid .docx zip: {err}"))?;
let mut xml = String::new();
archive
.by_name("word/document.xml")
.map_err(|err| format!("no word/document.xml in .docx: {err}"))?
.read_to_string(&mut xml)
.map_err(|err| format!("could not read document.xml: {err}"))?;
xml_paragraphs(&xml, b"w:p", &[(b"w:br", "\n"), (b"w:tab", "\t")])
}
/// Extract paragraphs from an `.odt` (`content.xml`) by walking `text:p`
/// and `text:h` elements.
fn parse_odt(bytes: &[u8]) -> Result<Vec<String>, String> {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
.map_err(|err| format!("not a valid .odt zip: {err}"))?;
let mut xml = String::new();
archive
.by_name("content.xml")
.map_err(|err| format!("no content.xml in .odt: {err}"))?
.read_to_string(&mut xml)
.map_err(|err| format!("could not read content.xml: {err}"))?;
xml_paragraphs(
&xml,
b"text:p",
&[(b"text:line-break".as_slice(), "\n"), (b"text:tab".as_slice(), "\t")],
)
}
/// Generic XML paragraph walker: accumulate text (and CDATA) content under
/// every element whose tag is `paragraph_tag`; `br_tags` holds
/// (self-closing tag, replacement) pairs that insert whitespace. Namespace
/// prefixes stay dynamic, so `.docx` and `.odt` both work.
fn xml_paragraphs(
xml: &str,
paragraph_tag: &[u8],
br_tags: &[(&[u8], &str)],
) -> Result<Vec<String>, String> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut in_paragraph = false;
let mut paragraph = String::new();
let mut paragraphs = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let qname = e.name();
let name = qname.as_ref();
if name == paragraph_tag {
in_paragraph = true;
paragraph.clear();
} else if in_paragraph {
for (tag, replacement) in br_tags {
if *tag == name {
paragraph.push_str(replacement);
}
}
}
}
Ok(Event::Empty(e)) => {
if in_paragraph {
let qname = e.name();
let name = qname.as_ref();
for (tag, replacement) in br_tags {
if *tag == name {
paragraph.push_str(replacement);
}
}
}
}
Ok(Event::Text(e)) => {
if in_paragraph {
let text = e.decode().unwrap_or_default();
paragraph.push_str(
&quick_xml::escape::unescape(text.as_ref()).unwrap_or_default(),
);
}
}
Ok(Event::CData(e)) => {
if in_paragraph {
paragraph.push_str(&String::from_utf8_lossy(&e));
}
}
Ok(Event::End(e)) => {
if e.name().as_ref() == paragraph_tag {
in_paragraph = false;
let trimmed = paragraph.trim().to_string();
if !trimmed.is_empty() {
paragraphs.push(trimmed);
}
}
}
Ok(Event::Eof) => break,
Err(err) => return Err(format!("malformed document XML: {err}")),
_ => {}
}
buf.clear();
}
Ok(paragraphs)
}
/// Strip RTF control words and grouping braces, keeping readable text.
fn strip_rtf(data: &str) -> String {
let bytes = data.as_bytes();
let mut out = String::new();
let mut index = 0usize;
while index < bytes.len() {
let byte = bytes[index];
match byte {
b'\\' => {
index += 1;
if index >= bytes.len() {
break;
}
let c = bytes[index];
if c == b'\\' || c == b'{' || c == b'}' {
out.push(c as char);
index += 1;
continue;
}
if c == b'\'' {
if index + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(&data[index + 1..index + 3], 16) {
out.push(v as char);
}
}
index += 3;
continue;
}
let word_start = index;
while index < bytes.len() && bytes[index].is_ascii_alphabetic() {
index += 1;
}
let word = &data[word_start..index];
// Consume the single delimiter space that terminates a
// control word (optional in strict RTF).
if index < bytes.len() && bytes[index] == b' ' {
index += 1;
}
let num_start = index;
while index < bytes.len()
&& (bytes[index].is_ascii_digit() || bytes[index] == b'-')
{
index += 1;
}
let num = &data[num_start..index];
match word {
"par" => out.push('\n'),
"tab" => out.push('\t'),
"u" => {
if let Ok(value) = num.parse::<i64>() {
if let Some(ch) = char::from_u32(value as u32) {
out.push(ch);
}
}
if index < bytes.len() {
index += 1;
}
}
_ => {}
}
}
b'{' | b'}' | b'\r' | b'\n' => index += 1,
0x80..=0xFF => {
let ch = data[index..].chars().next().unwrap_or(' ');
out.push(ch);
index += ch.len_utf8();
}
_ => {
out.push(byte as char);
index += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn wire_paragraphs(wire: &str) -> Vec<String> {
let json = wire.strip_prefix(crate::projection_session::CRDT_SAVE_HEADER).unwrap();
let document = doc_engine::crdt::CrdtDocument::from_json(json).unwrap();
document.materialize().blocks.iter().map(|b| b.text.clone()).collect()
}
#[test]
fn build_wire_round_trips_paragraphs() {
let wire = build_wire_from_paragraphs(&["Hello world".into(), "Second line".into()])
.expect("wire");
assert_eq!(wire_paragraphs(&wire), vec!["Hello world", "Second line"]);
assert_eq!(plain_text_paragraphs(&wire), vec!["Hello world", "Second line"]);
}
#[test]
fn build_wire_accepts_empty_input() {
let wire = build_wire_from_paragraphs(&[].to_vec()).expect("wire");
assert!(wire.starts_with(crate::projection_session::CRDT_SAVE_HEADER));
}
#[test]
fn build_wire_skips_blank_paragraphs() {
let wire = build_wire_from_paragraphs(&[" ".into(), "Hello".into()]).expect("wire");
assert_eq!(wire_paragraphs(&wire), vec!["Hello"]);
}
#[test]
fn plain_text_paragraphs_parses_legacy_delimiter() {
let legacy = "M|actor|0|0\nP|Left|Hello§false§false§false§12§~\n\
H1|Left|Title§false§false§false§14§~\n";
assert_eq!(plain_text_paragraphs(legacy), vec!["Hello", "Title"]);
}
#[test]
fn plain_text_paragraphs_ignores_garbage() {
assert!(plain_text_paragraphs("not a document at all").is_empty());
}
#[test]
fn strip_rtf_keeps_text() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}\f0\pard
Hello \b world\par Second \tab line\par}";
let stripped = strip_rtf(rtf);
assert!(stripped.contains("Hello"));
assert!(stripped.contains("world"));
assert!(stripped.contains('\n'));
assert!(stripped.contains('\t'));
assert!(stripped.contains("Second"));
}
#[test]
fn strip_rtf_unicode_escape() {
assert_eq!(strip_rtf(r"caf\u233? text"), "café text");
}
#[test]
fn xml_paragraphs_walks_paragraphs() {
let xml = r#"<w:document xmlns:w="urn:w"><w:body>
<w:p><w:r><w:t>alpha</w:t></w:r></w:p>
<w:p><w:r><w:t>beta</w:t></w:r><w:r><w:t> gamma</w:t></w:r></w:p>
</w:body></w:document>"#;
assert_eq!(
xml_paragraphs(xml, b"w:p", &[(b"w:br".as_slice(), "\n"), (b"w:tab".as_slice(), "\t")])
.unwrap(),
vec!["alpha", "beta gamma"]
);
}
#[test]
fn docx_extracts_preview_and_wire() {
use std::io::Write;
let doc_xml = r#"<?xml version="1.0"?><w:document xmlns:w="urn:w">
<w:body>
<w:p><w:r><w:t>The Quick Brown Fox</w:t></w:r></w:p>
<w:p><w:r><w:t>jumps over the lazy dog</w:t></w:r></w:p>
</w:body></w:document>"#;
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer.start_file("word/document.xml", options).unwrap();
writer.write_all(doc_xml.as_bytes()).unwrap();
let bytes = writer.finish().unwrap().into_inner();
let dir = std::env::temp_dir().join(format!("doc_import_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.docx");
std::fs::write(&path, &bytes).unwrap();
let imported = import_document_from_path(&path).expect("import docx");
assert_eq!(imported.title, "The Quick Brown Fox");
assert_eq!(
imported.preview,
"The Quick Brown Fox jumps over the lazy dog"
);
assert_eq!(
wire_paragraphs(&imported.wire),
vec!["The Quick Brown Fox", "jumps over the lazy dog"]
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn native_legacy_import_converts_to_wire() {
let dir = std::env::temp_dir().join(format!("doc_import_test_legacy_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.doc.json");
std::fs::write(&path, "M|actor|0|0\nP|Left|Legacy body§false§false§false§12§~\n").unwrap();
let imported = import_document_from_path(&path).expect("import legacy");
assert_eq!(imported.title, "Legacy body");
assert!(imported.wire.starts_with(crate::projection_session::CRDT_SAVE_HEADER));
assert_eq!(wire_paragraphs(&imported.wire), vec!["Legacy body"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn native_wire_import_passes_through() {
let dir = std::env::temp_dir().join(format!("doc_import_test_wire_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let wire = build_wire_from_paragraphs(&["Saved doc".into()]).unwrap();
let path = dir.join("saved.doc.json");
std::fs::write(&path, &wire).unwrap();
let imported = import_document_from_path(&path).expect("import wire");
assert_eq!(imported.wire, wire);
assert_eq!(imported.title, "Saved doc");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn unsupported_format_is_rejected() {
let dir = std::env::temp_dir().join(format!("doc_import_test_bad_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.pdf");
std::fs::write(&path, "%PDF-1.4 fake").unwrap();
assert!(import_document_from_path(&path).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::model::{
use crate::model::{
AtomId, BlockId, CellContent, DocAlign, DocBlock, DocCursor, Document, DocumentNode,
DocumentSession, RgaText, StyleSpan, TableMerge, TableSelection, TextAtom,
};
@ -482,7 +482,7 @@ impl Command {
atom.after = anchor;
} else {
document.block_order.insert(
crate::construction_frame::pages::workspace::doc::model::BlockAtom {
crate::model::BlockAtom {
id: id.clone(),
after: anchor,
deleted: false,

View file

@ -1,8 +1,8 @@
use super::{Command, History, Transaction};
use crate::construction_frame::pages::workspace::doc::collaboration::{
use crate::collaboration::{
AckMessage, CollaborationSession, CollaborationTransport, DocumentOperation,
};
use crate::construction_frame::pages::workspace::doc::model::{
use crate::model::{
DocBlock, DocCursor, Document, DocumentSession, RgaText, TextAtom,
};
@ -223,7 +223,7 @@ impl DocumentController {
fn crdt_position_for(
&self,
cursor: DocCursor,
) -> Option<crate::construction_frame::pages::workspace::doc::model::CrdtTextPosition> {
) -> Option<crate::model::CrdtTextPosition> {
if cursor.cell_pos.is_some() {
return None;
}
@ -245,7 +245,7 @@ impl DocumentController {
_ => None,
};
Some(
crate::construction_frame::pages::workspace::doc::model::CrdtTextPosition {
crate::model::CrdtTextPosition {
block_idx: cursor.block_idx,
span_idx: cursor.span_idx,
after,
@ -424,7 +424,7 @@ impl DocumentController {
/// Tombstoned/missing anchors fall back to the nearest valid offset.
pub fn resolve_crdt_position(
&self,
position: &crate::construction_frame::pages::workspace::doc::model::CrdtTextPosition,
position: &crate::model::CrdtTextPosition,
) -> Option<DocCursor> {
let span = match self.document.blocks.get(position.block_idx) {
Some(DocBlock::Paragraph { spans, .. }) | Some(DocBlock::Heading { spans, .. }) => {
@ -458,7 +458,7 @@ impl DocumentController {
return;
};
let frontier =
crate::construction_frame::pages::workspace::doc::model::LamportTimestamp { counter };
crate::model::LamportTimestamp { counter };
for block in &mut self.document.blocks {
if let DocBlock::Paragraph { spans, .. } | DocBlock::Heading { spans, .. } = block {
for span in spans {
@ -472,7 +472,7 @@ impl DocumentController {
pub fn apply_remote_presence(
&mut self,
mut presence: crate::construction_frame::pages::workspace::doc::collaboration::Presence,
mut presence: crate::collaboration::Presence,
) {
if let Some(position) = presence.crdt_cursor.as_ref() {
presence.cursor = self.resolve_crdt_position(position);

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::model::{BlockKind, DocumentNode};
use crate::model::{BlockKind, DocumentNode};
use makepad_widgets::{DVec2, Rect};
/// Geometry for v2 blocks. It is intentionally independent of Makepad widgets;

View file

@ -1,5 +1,5 @@
use crate::construction_frame::pages::workspace::doc::layout::GlyphHit;
use crate::construction_frame::pages::workspace::doc::model::{DocAlign, DocCursor, StyleSpan};
use crate::layout::GlyphHit;
use crate::model::{DocAlign, DocCursor, StyleSpan};
use makepad_widgets::{dvec2, Rect};
#[derive(Clone, Debug)]

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::model::DocCursor;
use crate::model::DocCursor;
use makepad_widgets::{DVec2, Rect};
#[derive(Clone, Debug)]

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::model::DocAlign;
use crate::model::DocAlign;
use makepad_widgets::{dvec2, DVec2, Rect};
pub fn layout_image(

View file

@ -1,5 +1,5 @@
use super::{AdvancedLayoutBlock, GlyphHit, ParagraphFragment, TableFragment};
use crate::construction_frame::pages::workspace::doc::model::{DocCursor, StyleSpan};
use crate::model::{DocCursor, StyleSpan};
use makepad_widgets::{dvec2, DVec2, Rect};
/// Persistent, renderer-independent geometry produced by layout.

View file

@ -72,8 +72,8 @@ pub struct TableRenderCell {
/// Builds renderable table cells from base geometry plus merge metadata.
pub fn build_table_render_cells(
fragment: &TableFragment,
cells: &[Vec<crate::construction_frame::pages::workspace::doc::model::CellContent>],
merges: &[crate::construction_frame::pages::workspace::doc::model::TableMerge],
cells: &[Vec<crate::model::CellContent>],
merges: &[crate::model::TableMerge],
block_idx: usize,
) -> Vec<TableRenderCell> {
let mut out = Vec::new();

View file

@ -3,6 +3,8 @@ pub mod advanced_json;
pub mod collaboration;
pub mod crdt_bridge;
pub mod crdt_widget;
pub mod dashboard;
pub mod doc_import;
pub mod editing;
pub mod layout;
pub mod mobile_gesture;
@ -21,10 +23,14 @@ pub use advanced_json::{
pub use collaboration::{CollaborationSession, DocumentOperation, Presence};
pub use crdt_bridge::CrdtProjectionBridge;
pub use crdt_widget::CrdtDocEditor;
pub use dashboard::{DocDashboard, DocDashboardAction};
pub use doc_import::{import_document_from_path, ImportedDoc};
pub use editing::RemoteApplyResult;
pub use mobile_gesture::{MobileGestureAction, MobileGestureRouter, MobileGestureState};
pub use model::{CellContent, DocAlign, DocBlock, DocCursor, Document, Selection, StyleSpan};
pub use persistence::{load_saved_doc_state, save_doc_state, save_doc_state_as};
pub use persistence::{
load_saved_doc_state, save_doc_state, save_doc_state_as, DocEntry,
};
pub use plugins::{BlockPluginDescriptor, PluginRegistry};
pub use widgets::{CrdtDocWorkspace, DocEditor, DocWorkspace, InlineStyle, InteractionMode};
@ -74,27 +80,78 @@ script_mod! {
draw_divider_line +: { draw_depth: 0.15 color: #xdee2e6 }
}
mod.widgets.DocDashboard = #(DocDashboard::register_widget(vm)) {
width: Fill, height: Fill, flow: Down
draw_bg +: { color: #x1a1a2e }
dashboard_header := View {
width: Fill, height: Fit, flow: Right {wrap: true}
padding: Inset{left: 14.0, right: 14.0, top: 8.0, bottom: 8.0}, spacing: 8.0, align: Align{y: 0.5}
draw_bg +: { color: #x242438 }
dashboard_title := Label {
text: "Documents",
draw_text +: { color: #xd8d8e8, text_style: theme.font_bold { font_size: 18.0 } }
}
spacer := View { width: Fill }
refresh_btn := Button {
text: "Refresh",
width: 80.0, height: 32.0
draw_bg +: { color: #x313244 }
draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } }
}
new_document_btn := Button {
text: "+ New",
width: 80.0, height: 32.0
draw_bg +: { color: #x238636 }
draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } }
}
import_doc_btn := Button {
text: "Import Doc",
width: 100.0, height: 32.0
draw_bg +: { color: #x2a5a8a }
draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } }
}
}
cards_container := View {
width: Fill, height: Fill
draw_bg +: { color: #x1a1a2e }
}
back_btn := Button {
visible: false
width: 0, height: 0
}
// Manual draw resources for document cards
draw_card_bg +: { draw_depth: 0.1 }
draw_card_hover_bg +: { draw_depth: 0.2 }
draw_card_text +: { draw_depth: 0.3 color: #xd8d8e8 text_style: theme.font_bold { font_size: 14.0 } }
draw_card_preview +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_regular { font_size: 11.0 } }
card_normal_color: #x2a2a40
card_hover_color: #x3a3a5a
card_text_color: #xd8d8e8
card_preview_color: #x8a8aa5
}
mod.widgets.CrdtDocWorkspace = #(CrdtDocWorkspace::register_widget(vm)) {
width: Fill, height: Fill, flow: Down
draw_bg +: { color: #x181825 }
crdt_toolbar := View {
width: Fill, height: 48.0, flow: Right
padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
width: Fill, height: Fit, flow: Right {wrap: true}
padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
draw_bg +: { color: #x11111b }
open_file_btn := Button { text: "Open", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
save_btn := Button { text: "Save", width: 44.0, draw_bg +: { color: #x238636 }, draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } }
save_as_btn := Button { text: "SaveAs", width: 54.0, draw_bg +: { color: #x1f6feb }, draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } }
// Mobile-only Edit/Done IME toggle (legacy parity): desktop
// renders an empty variant and keeps full editing by default.
mode_controls := AdaptiveView {
width: Fit, height: Fit, retain_unused_variants: true
Desktop := View { width: 0.0, height: 0.0 }
Mobile := View {
edit_mode_btn := Button { text: "Edit", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
}
}
// Edit/Done IME toggle. Kept as a plain button (not wrapped in
// a width-adaptive view) so it always renders on device; a
// Desktop/Mobile AdaptiveView would drop it in desktop-tagged
// test windows.
edit_mode_btn := Button { text: "Edit", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
separator0 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x45475a } }
undo_button := Button { text: "Undo", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
@ -137,6 +194,13 @@ script_mod! {
crdt_status_spacer := View { width: Fill }
status_right := Label { text: "Makepad Native CRDT Doc", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } }
}
// -- Dashboard (file-list overlay, shown on first launch) --
// Hidden while the editor is active; the workspace toggles
// `visible` on this widget based on `show_dashboard`.
dashboard := mod.widgets.DocDashboard {
width: Fill, height: Fill, visible: true
}
}
mod.widgets.DocWorkspace = #(DocWorkspace::register_widget(vm)) {
@ -144,8 +208,8 @@ script_mod! {
draw_bg +: { color: #x181825 }
toolbar := View {
width: Fill, height: 48.0, flow: Right
padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
width: Fill, height: Fit, flow: Right {wrap: true}
padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
draw_bg +: { color: #x11111b }
open_file_btn := Button { text: "Open", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
@ -198,6 +262,13 @@ script_mod! {
spacer := View { width: Fill }
status_right := Label { text: "Makepad Native Doc v2.1", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } }
}
// -- Dashboard (file-list overlay, shown on first launch) --
// Hidden while the editor is active; the workspace toggles
// `visible` on this widget based on `show_dashboard`.
dashboard := mod.widgets.DocDashboard {
width: Fill, height: Fill, visible: true
}
}
}

View file

@ -0,0 +1,193 @@
use std::fs;
use std::path::{Path, PathBuf};
const GENERATED_DIR: &str = "generated";
pub(crate) const GENERATED_DOC_FILE: &str = "current.doc.json";
pub(crate) const MAX_UNDO_LEVELS: usize = 100;
/// Metadata about a saved document file for the dashboard.
#[derive(Clone, Debug)]
pub struct DocEntry {
pub filename: String,
pub path: PathBuf,
/// First non-empty paragraph, or the file stem when unavailable.
pub title: String,
/// Preview snippet of the document's body text.
pub preview: String,
/// File size in bytes.
pub size: u64,
/// Last modified time (seconds since UNIX_EPOCH), or 0 if unknown.
pub modified: u64,
}
/// Root directory for doc state written at runtime.
///
/// This used to be `env!("CARGO_MANIFEST_DIR")`, which bakes the **build
/// machine's** absolute source path into the shipped binary. On an Android
/// or iOS install that path does not exist, so the workspace's Open/Save
/// buttons silently did nothing (the CRDT editor additionally had no demo
/// fallback, so a fresh install booted to an empty document); and on a
/// developer machine the app wrote into its own source tree.
///
/// `app_data_dir()` is the convention the rest of this crate already uses
/// (see `cad_store::cad_projects_dir` / `cad_persistence::cad_data_dir`).
/// Reads keep a one-way compatibility fallback to the old source-tree
/// location so existing developer saves are honored once; new saves only
/// ever go to the app data dir.
pub fn doc_store_dir() -> PathBuf {
nigig_core::dir::app_data_dir().join("nigig_build_store")
}
/// Directory holding the runtime-saved document (`nigig_build_store/generated`).
pub fn doc_generated_dir_path() -> PathBuf {
doc_store_dir().join(GENERATED_DIR)
}
/// The legacy save location inside the source tree (development checkouts
/// only): read as a fallback, never written.
fn dev_manifest_generated_dir_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_DIR)
}
/// Load the last-saved document. Prefers the runtime store; falls back to
/// the legacy source-tree file for unreplicated developer saves. Returns
/// `None` when nothing exists or the file is empty. Split from the two
/// directory parameters so the migration logic is unit-testable against
/// temp dirs.
pub fn load_saved_doc_state() -> Option<String> {
load_saved_doc_state_with(doc_generated_dir_path(), dev_manifest_generated_dir_path())
}
pub(crate) fn load_saved_doc_state_with(
store_dir: PathBuf,
manifest_dir: PathBuf,
) -> Option<String> {
let read = |dir: &PathBuf| {
fs::read_to_string(dir.join(GENERATED_DOC_FILE))
.ok()
.filter(|source| !source.trim().is_empty())
};
read(&store_dir).or_else(|| read(&manifest_dir))
}
/// Save the current document to the runtime store (never the source tree).
pub fn save_doc_state(data: &str) -> Result<(), String> {
save_doc_state_to(doc_generated_dir_path(), GENERATED_DOC_FILE, data)
}
/// Save a copy under a caller-chosen filename in the runtime store.
pub fn save_doc_state_as(filename: &str, data: &str) -> Result<(), String> {
save_doc_state_to(doc_generated_dir_path(), filename, data)
}
pub(crate) fn save_doc_state_to(dir: PathBuf, filename: &str, data: &str) -> Result<(), String> {
fs::create_dir_all(&dir)
.map_err(|err| format!("could not create generated directory: {err}"))?;
fs::write(dir.join(filename), data)
.map_err(|err| format!("could not save doc state: {err}"))?;
Ok(())
}
/// List saved `.doc.json` documents in the runtime store, sorted by
/// last-modified descending (most recent first). Each entry carries a
/// title and preview derived from the file's contents. Files whose names
/// are not plain filenames are skipped.
pub fn list_saved_docs() -> Vec<DocEntry> {
let dir = doc_generated_dir_path();
let Ok(entries) = fs::read_dir(&dir) else {
return Vec::new();
};
let mut result = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !validate_filename(&name) {
continue;
}
if !name.ends_with(".doc.json") {
continue;
}
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
};
let preview = preview_doc_file(&path);
result.push(DocEntry {
filename: name,
path,
title: preview.title,
preview: preview.snippet,
size: metadata.len(),
modified: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0),
});
}
result.sort_by(|a, b| b.modified.cmp(&a.modified));
result
}
/// Lightweight preview of a saved document: first non-empty paragraph as
/// the title, a longer snippet as the preview.
struct DocPreview {
title: String,
snippet: String,
}
fn preview_doc_file(path: &Path) -> DocPreview {
let fallback_title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Document")
.to_string();
let Ok(contents) = fs::read_to_string(path) else {
return DocPreview {
title: fallback_title,
snippet: String::new(),
};
};
let paragraphs = crate::doc_import::plain_text_paragraphs(&contents);
title_and_snippet(&paragraphs, fallback_title)
}
fn title_and_snippet(paragraphs: &[String], fallback_title: String) -> DocPreview {
let title = paragraphs
.iter()
.find(|p| !p.trim().is_empty())
.cloned()
.unwrap_or(fallback_title);
let mut snippet = String::new();
for p in paragraphs {
let trimmed = p.trim();
if trimmed.is_empty() {
continue;
}
if !snippet.is_empty() {
snippet.push(' ');
}
snippet.push_str(trimmed);
if snippet.chars().count() >= 160 {
snippet = snippet.chars().take(160).collect();
snippet.push_str("");
break;
}
}
DocPreview { title, snippet }
}
/// Reject anything that is not a plain filename: empty, path separator,
/// `.`/`..`, hidden files, NULs, or overlong names.
fn validate_filename(filename: &str) -> bool {
!filename.is_empty()
&& filename.len() <= 255
&& filename != "."
&& filename != ".."
&& !filename.starts_with('.')
&& !filename.contains('\0')
&& !filename.contains('/')
&& !filename.contains('\\')
}

View file

@ -580,7 +580,7 @@ pub const TABLE_CELL_TEXT_INSET: f64 = 6.0;
/// use (their single-line formulas keep the historical 18px band).
pub const TABLE_CELL_TEXT_LINE_HEIGHT: f64 = 18.0;
use crate::construction_frame::pages::workspace::doc::projection_session::{
use crate::projection_session::{
TableCellCursor, TableCellSelection,
};

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::projection_layout::{
use crate::projection_layout::{
cell_text_line_count, ProjectionLayoutTree, LAYOUT_MARGIN, TABLE_CELL_TEXT_INSET,
TABLE_CELL_TEXT_LINE_HEIGHT, TEXT_CHAR_ADVANCE,
};

View file

@ -1,7 +1,7 @@
use crate::construction_frame::pages::workspace::doc::layout::{
use crate::layout::{
AdvancedLayoutBlock, LayoutPage, ParagraphFragment, TableRenderCell,
};
use crate::construction_frame::pages::workspace::doc::model::{DocAlign, StyleSpan};
use crate::model::{DocAlign, StyleSpan};
use makepad_widgets::*;
/// GPU-facing rendering boundary. It deliberately receives layout output and

View file

@ -521,6 +521,49 @@ fn runtime_cell_return_inserts_row_below_and_moves_caret_into_it() {
let _ = row;
}
#[test]
fn runtime_insert_table_seeds_default_grid_and_parks_caret_in_first_cell() {
// Regression for the device bug where a bare table block (no rows /
// columns / cells) rendered at zero size and could not be typed into.
// A clean one-paragraph engine mirrors the empty-doc scenario on
// device better than table_editor_engine (which already carries a
// table whose rows would shadow the seeded grid's ordering).
let mut engine = CrdtController::default();
engine.insert_block("local", None, "paragraph").unwrap();
let (mut cx, mut editor) = crdt_editor_with_engine(engine);
assert!(editor.insert_table(&mut cx), "insert_table should succeed");
let projection = &editor.engine().projection;
let table_id = editor
.session
.cell_cursor
.as_ref()
.map(|c| format!("{}:{}", c.table.actor, c.table.counter))
.expect("caret parked in a table cell");
let projected = &projection.tables[&table_id];
assert_eq!(projected.rows.len(), 2, "default grid has 2 rows");
assert_eq!(projected.columns.len(), 2, "default grid has 2 columns");
let cursor = editor.session.cell_cursor.clone().expect("cell caret");
let cursor_row = format!("{}:{}", cursor.row.actor, cursor.row.counter);
let cursor_col = format!("{}:{}", cursor.column.actor, cursor.column.counter);
assert_eq!(
cursor_row, projected.rows[0],
"caret parked in the visual first row"
);
assert_eq!(
cursor_col, projected.columns[0],
"caret parked in the visual first column"
);
assert_eq!(cursor.offset, 0);
assert_eq!(
table_cell_text(projection, &cursor),
"",
"seeded cell starts empty"
);
}
// == CRDT-native cell range selection and merge/split ======================
use super::projection_layout::{
@ -1604,6 +1647,7 @@ fn runtime_mouse_down(abs: DVec2, shift: bool) -> Event {
fn runtime_mouse_move(abs: DVec2) -> Event {
Event::MouseMove(MouseMoveEvent {
abs,
lock_delta: DVec2 { x: 0.0, y: 0.0 },
window_id: WindowId(0, 0),
modifiers: KeyModifiers::default(),
time: 0.0,

View file

@ -213,7 +213,7 @@ fn crdt_native_vertical_slice() {
let block = engine.insert_block("test", None, "paragraph").unwrap();
engine.insert_text("test", block.clone(), None, "hi");
let layout =
crate::construction_frame::pages::workspace::doc::projection_layout::layout_projection(
crate::projection_layout::layout_projection(
&engine.projection,
);
assert_eq!(engine.projection.blocks[0].text, "hi");

View file

@ -1,13 +1,13 @@
use crate::construction_frame::pages::workspace::doc::editing::{Command, DocumentController};
use crate::construction_frame::pages::workspace::doc::layout::{
use crate::editing::{Command, DocumentController};
use crate::layout::{
build_table_render_cells, layout_divider, layout_image, layout_pages, layout_paragraph,
layout_table, AdvancedLayout, BlockLayoutFragment, CachedBlockLayout, GlyphHit, LayoutEngine,
PageMetrics, ParagraphLayoutRequest, TableLayoutRequest, TextMeasureKey,
};
use crate::construction_frame::pages::workspace::doc::model::*;
use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state;
use crate::construction_frame::pages::workspace::doc::render::DocumentRenderer;
use crate::construction_frame::pages::workspace::doc::{
use crate::model::*;
use crate::persistence::load_saved_doc_state;
use crate::render::DocumentRenderer;
use crate::{
CrdtProjectionBridge, MobileGestureAction, MobileGestureRouter,
};
use makepad_widgets::makepad_platform::event::{TouchState, TouchUpdateEvent};
@ -569,7 +569,7 @@ impl Widget for DocEditor {
.enumerate()
.map(|(index, page_rect)| {
let page_y = page_rect.pos.y;
crate::construction_frame::pages::workspace::doc::layout::LayoutPage {
crate::layout::LayoutPage {
index,
rect: Rect {
pos: DVec2 {

View file

@ -1,4 +1,4 @@
use crate::construction_frame::pages::workspace::doc::model::EmbeddedWidgetNode;
use crate::model::EmbeddedWidgetNode;
use std::collections::HashMap;
/// Application-side registry for document embedded-widget descriptors.

View file

@ -1,27 +1,63 @@
use super::{DocEditor, InlineStyle, InteractionMode};
use crate::construction_frame::pages::workspace::doc::crdt_widget::CrdtDocEditor;
use crate::construction_frame::pages::workspace::doc::editing::Command;
use crate::construction_frame::pages::workspace::doc::model::*;
use crate::construction_frame::pages::workspace::doc::persistence::{
use crate::crdt_widget::CrdtDocEditor;
use crate::editing::Command;
use crate::model::*;
use crate::persistence::{
load_saved_doc_state, save_doc_state, save_doc_state_as,
};
use crate::construction_frame::pages::workspace::doc::projection_layout::projected_stats;
use crate::projection_layout::projected_stats;
use makepad_widgets::*;
#[derive(Script, ScriptHook, Widget)]
pub struct DocWorkspace {
#[deref]
view: View,
/// Whether the dashboard (saved-doc list) is shown instead of the
/// editor. Starts on the dashboard; transitions to the editor on
/// New/Open.
#[rust(true)]
show_dashboard: bool,
}
impl Widget for DocWorkspace {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
// The robius file picker's completion callback runs off the UI
// thread and parks its result; `drain_doc_import` lands it here.
if matches!(event, Event::Signal) {
self.drain_doc_import(cx);
}
if let Event::Actions(actions) = event {
self.handle_actions(cx, actions, scope);
self.handle_dashboard_actions(cx);
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if self.show_dashboard {
if let Some(mut dashboard) = self
.view
.widget(cx, ids!(dashboard))
.borrow_mut::<crate::dashboard::DocDashboard>()
{
dashboard.set_dash_visible(cx, true);
dashboard.refresh_and_redraw(cx);
}
} else if let Some(mut dashboard) = self
.view
.widget(cx, ids!(dashboard))
.borrow_mut::<crate::dashboard::DocDashboard>()
{
dashboard.set_dash_visible(cx, false);
}
if let Some(mut v) = self.view.widget(cx, ids!(toolbar)).borrow_mut::<View>() {
v.set_visible(cx, !self.show_dashboard);
}
if let Some(mut v) = self.view.widget(cx, ids!(body_scroll)).borrow_mut::<View>() {
v.set_visible(cx, !self.show_dashboard);
}
if let Some(mut v) = self.view.widget(cx, ids!(status_bar)).borrow_mut::<View>() {
v.set_visible(cx, !self.show_dashboard);
}
self.view.draw_walk(cx, scope, walk)
}
}
@ -312,20 +348,188 @@ impl WidgetMatchEvent for DocWorkspace {
}
}
impl DocWorkspace {
/// Check the dashboard widget for pending actions (new document,
/// open file, back to dashboard, import) and dispatch them.
fn handle_dashboard_actions(&mut self, cx: &mut Cx) {
let pending_action: Option<crate::dashboard::DocDashboardAction> = {
let widget_ref = self.view.widget(cx, ids!(dashboard));
let Some(mut dashboard) =
widget_ref.borrow_mut::<crate::dashboard::DocDashboard>()
else {
return;
};
dashboard.action.take()
};
let Some(action) = pending_action else { return };
match action {
crate::dashboard::DocDashboardAction::NewDocument => {
if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::<DocEditor>() {
if let Ok(wire) = crate::doc_import::blank_document_wire() {
editor.deserialize(&wire);
editor.redraw(cx);
self.label(cx, ids!(status_left))
.set_text(cx, "New blank document");
}
}
self.show_dashboard = false;
self.view.redraw(cx);
}
crate::dashboard::DocDashboardAction::OpenFile(filename) => {
self.open_saved_document(cx, &filename);
}
crate::dashboard::DocDashboardAction::BackToDashboard => {
self.show_dashboard = true;
self.view.redraw(cx);
}
crate::dashboard::DocDashboardAction::ImportDocument => {
self.pick_document(cx);
}
}
}
/// Open a saved `.doc.json` from the generated directory.
fn open_saved_document(&mut self, cx: &mut Cx, filename: &str) {
let path = crate::persistence::doc_generated_dir_path().join(filename);
match crate::doc_import::import_document_from_path(&path) {
Ok(imported) => {
if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::<DocEditor>() {
editor.deserialize(&imported.wire);
editor.redraw(cx);
}
self.show_dashboard = false;
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Opened {}", imported.title));
self.view.redraw(cx);
}
Err(e) => {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not open {filename}: {e}"));
}
}
}
/// Open the platform file dialog for an external document.
fn pick_document(&mut self, cx: &mut Cx) {
if let Err(e) = crate::doc_import::open_document_dialog() {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not open the file picker: {e}"));
}
}
/// Apply a parked file-picker outcome, if any. Called on `Event::Signal`.
fn drain_doc_import(&mut self, cx: &mut Cx) {
let Some(outcome) = crate::doc_import::take_pending_import() else {
return;
};
match outcome {
crate::doc_import::DocImportOutcome::Picked(path) => {
self.finish_doc_import(cx, &path);
}
crate::doc_import::DocImportOutcome::Failed(message) => {
self.label(cx, ids!(status_left)).set_text(cx, &message);
}
}
}
/// Finish a document import from a locally readable path.
fn finish_doc_import(&mut self, cx: &mut Cx, path: &std::path::Path) {
match crate::doc_import::import_document_from_path(path) {
Ok(imported) => {
if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::<DocEditor>() {
editor.deserialize(&imported.wire);
editor.redraw(cx);
}
self.show_dashboard = false;
crate::persistence::save_doc_state_as(
&import_save_filename(path),
&imported.wire,
)
.ok();
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Imported {}", imported.title));
self.view.redraw(cx);
}
Err(e) => {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not import {}: {e}", path.display()));
}
}
}
}
/// Derive a plain `.doc.json` save name for an imported file.
fn import_save_filename(path: &std::path::Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("imported")
.trim()
.replace([' ', '\t', '\n'], "_");
let stem = if stem.is_empty() {
String::from("imported")
} else {
stem
};
format!("{stem}.doc.json")
}
#[derive(Script, ScriptHook, Widget)]
pub struct CrdtDocWorkspace {
#[deref]
view: View,
/// Whether the dashboard (saved-doc list) is shown instead of the
/// editor. Starts on the dashboard; transitions to the editor on
/// New/Open.
#[rust(true)]
show_dashboard: bool,
}
impl Widget for CrdtDocWorkspace {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
// The robius file picker's completion callback runs off the UI
// thread and parks its result; `drain_doc_import` lands it here.
if matches!(event, Event::Signal) {
self.drain_doc_import(cx);
}
if let Event::Actions(actions) = event {
self.handle_actions(cx, actions, scope);
self.handle_dashboard_actions(cx);
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if self.show_dashboard {
if let Some(mut dashboard) = self
.view
.widget(cx, ids!(dashboard))
.borrow_mut::<crate::dashboard::DocDashboard>()
{
dashboard.set_dash_visible(cx, true);
dashboard.refresh_and_redraw(cx);
}
} else if let Some(mut dashboard) = self
.view
.widget(cx, ids!(dashboard))
.borrow_mut::<crate::dashboard::DocDashboard>()
{
dashboard.set_dash_visible(cx, false);
}
if let Some(mut v) = self.view.widget(cx, ids!(crdt_toolbar)).borrow_mut::<View>() {
v.set_visible(cx, !self.show_dashboard);
}
if let Some(mut v) = self.view.widget(cx, ids!(crdt_body)).borrow_mut::<View>() {
v.set_visible(cx, !self.show_dashboard);
}
if let Some(mut v) = self
.view
.widget(cx, ids!(crdt_status_bar))
.borrow_mut::<View>()
{
v.set_visible(cx, !self.show_dashboard);
}
self.view.draw_walk(cx, scope, walk)
}
}
@ -335,6 +539,8 @@ impl Widget for CrdtDocWorkspace {
/// toggle stays legacy-only until the native widget owns an IME mode.
impl WidgetMatchEvent for CrdtDocWorkspace {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
// Mobile Edit/Done IME toggle (legacy parity): flips the editor's
// InteractionMode and mirrors the state on the button label.
// Mobile Edit/Done IME toggle (legacy parity): flips the editor's
// InteractionMode and mirrors the state on the button label.
if self.button(cx, ids!(edit_mode_btn)).clicked(actions) {
@ -535,3 +741,123 @@ impl WidgetMatchEvent for CrdtDocWorkspace {
}
}
}
impl CrdtDocWorkspace {
/// Check the dashboard widget for pending actions (new document,
/// open file, back to dashboard, import) and dispatch them.
fn handle_dashboard_actions(&mut self, cx: &mut Cx) {
let pending_action: Option<crate::dashboard::DocDashboardAction> = {
let widget_ref = self.view.widget(cx, ids!(dashboard));
let Some(mut dashboard) =
widget_ref.borrow_mut::<crate::dashboard::DocDashboard>()
else {
return;
};
dashboard.action.take()
};
let Some(action) = pending_action else { return };
match action {
crate::dashboard::DocDashboardAction::NewDocument => {
if let Some(mut editor) = self
.widget(cx, ids!(crdt_editor))
.borrow_mut::<CrdtDocEditor>()
{
if let Ok(wire) = crate::doc_import::blank_document_wire() {
editor.deserialize(cx, &wire);
self.label(cx, ids!(status_left))
.set_text(cx, "New blank document");
}
}
self.show_dashboard = false;
self.view.redraw(cx);
}
crate::dashboard::DocDashboardAction::OpenFile(filename) => {
self.open_saved_document(cx, &filename);
}
crate::dashboard::DocDashboardAction::BackToDashboard => {
self.show_dashboard = true;
self.view.redraw(cx);
}
crate::dashboard::DocDashboardAction::ImportDocument => {
self.pick_document(cx);
}
}
}
/// Open a saved `.doc.json` from the generated directory.
fn open_saved_document(&mut self, cx: &mut Cx, filename: &str) {
let path = crate::persistence::doc_generated_dir_path().join(filename);
match crate::doc_import::import_document_from_path(&path) {
Ok(imported) => {
if let Some(mut editor) = self
.widget(cx, ids!(crdt_editor))
.borrow_mut::<CrdtDocEditor>()
{
editor.deserialize(cx, &imported.wire);
editor.redraw(cx);
}
self.show_dashboard = false;
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Opened {}", imported.title));
self.view.redraw(cx);
}
Err(e) => {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not open {filename}: {e}"));
}
}
}
/// Open the platform file dialog for an external document.
fn pick_document(&mut self, cx: &mut Cx) {
if let Err(e) = crate::doc_import::open_document_dialog() {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not open the file picker: {e}"));
}
}
/// Apply a parked file-picker outcome, if any. Called on `Event::Signal`.
fn drain_doc_import(&mut self, cx: &mut Cx) {
let Some(outcome) = crate::doc_import::take_pending_import() else {
return;
};
match outcome {
crate::doc_import::DocImportOutcome::Picked(path) => {
self.finish_doc_import(cx, &path);
}
crate::doc_import::DocImportOutcome::Failed(message) => {
self.label(cx, ids!(status_left)).set_text(cx, &message);
}
}
}
/// Finish a document import from a locally readable path.
fn finish_doc_import(&mut self, cx: &mut Cx, path: &std::path::Path) {
match crate::doc_import::import_document_from_path(path) {
Ok(imported) => {
if let Some(mut editor) = self
.widget(cx, ids!(crdt_editor))
.borrow_mut::<CrdtDocEditor>()
{
editor.deserialize(cx, &imported.wire);
editor.redraw(cx);
}
self.show_dashboard = false;
crate::persistence::save_doc_state_as(
&import_save_filename(path),
&imported.wire,
)
.ok();
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Imported {}", imported.title));
self.view.redraw(cx);
}
Err(e) => {
self.label(cx, ids!(status_left))
.set_text(cx, &format!("Could not import {}: {e}", path.display()));
}
}
}
}

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-widgets = { workspace = true }
nigig-core = { path = "../../nigig-core" }
nigig-uikit = { path = "../../nigig-uikit" }
serde = { version = "1", features = ["derive"] }

View file

@ -14,7 +14,7 @@ license = "MIT OR Apache-2.0"
# makepad from every other crate here. Verified to compile against the pin.
# For local makepad dev, replace with:
# makepad-widgets = { path = "../../widgets" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
# Image format sniffing for attached cells, matching what the Robrix-derived
# app in this repo (`pageflipnav/src/utils.rs`) uses. Zero transitive

View file

@ -7,7 +7,7 @@ description = "Makepad UI for editing invoices/quotes/receipts and exporting to
license = "MIT OR Apache-2.0"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-table = { path = "../.." }
makepad-doc-model = { path = "../../crates/doc-model" }
makepad-pdf-export = { path = "../../crates/pdf-export" }

View file

@ -7,5 +7,5 @@ description = "Demo for the makepad-table widget"
license = "MIT OR Apache-2.0"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" }
makepad-table = { path = "../.." }

View file

@ -5,14 +5,13 @@ edition = "2021"
description = "Map tile renderer with viewport, caching, scheduling, and MVT decoding"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-draw = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-platform = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-derive-widget = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-fast-inflate = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-mbtile-reader = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-script = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-widgets = { workspace = true }
makepad-draw = { workspace = true }
makepad-platform = { workspace = true }
makepad-derive-widget = { workspace = true }
makepad-fast-inflate = { workspace = true }
makepad-mbtile-reader = { workspace = true }
makepad-script = { workspace = true }
# Polygon operations (used by makepad_map for advanced geometry)
i_overlay = { version = "7.0.3", default-features = false }
i_float = "1.0.0"
@ -20,8 +19,7 @@ i_shape = "1.0.0"
i_tree = "0.19.0"
[dev-dependencies]
makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-test = { workspace = true }
[features]
default = ["map_style"]
map_style = []

View file

@ -6,9 +6,9 @@ description = "Visual regression test application for nigig-map widget"
[dependencies]
<<<<<<< HEAD
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "5efe6e24c9f732e9f11b783757f196f4f1c402b2", features = ["maps"] }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251", features = ["maps"] }
=======
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["maps"] }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251", features = ["maps"] }
>>>>>>> 71b5460 (chore: update makepad fork to latest upstream/dev (abd70f4))
nigig-map = { path = "../.." }

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-widgets = { workspace = true }
nigig-core = { path = "../../nigig-core" }
nigig-uikit = { path = "../../nigig-uikit" }
serde = { version = "1", features = ["derive"] }

View file

@ -11,8 +11,7 @@ script_mod! {
window.title: "nigig-ai"
body +: {
root := mod.widgets.StandaloneFeatureShell {
root_screen := mod.widgets.AIScreen {}
}
root_screen := mod.widgets.AIScreen {}
standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav {
root_nav := mod.widgets.AiActionBar {}
}

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-widgets = { workspace = true }
nigig-core = { path = "../../nigig-core" }
nigig-uikit = { path = "../../nigig-uikit" }
serde = { version = "1", features = ["derive"] }

View file

@ -11,8 +11,7 @@ script_mod! {
window.title: "nigig-alerts"
body +: {
root := mod.widgets.StandaloneFeatureShell {
root_screen := mod.widgets.AlertsScreen {}
}
root_screen := mod.widgets.AlertsScreen {}
standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav {
root_nav := mod.widgets.AlertsActionBar {}
}

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-widgets = { workspace = true }
nigig-core = { path = "../../nigig-core" }
nigig-uikit = { path = "../../nigig-uikit" }
serde = { version = "1", features = ["derive"] }

View file

@ -11,8 +11,7 @@ script_mod! {
window.title: "nigig-book"
body +: {
root := mod.widgets.StandaloneFeatureShell {
root_screen := mod.widgets.BookingScreen {}
}
root_screen := mod.widgets.BookingScreen {}
standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav {
root_nav := mod.widgets.BookActionBar {}
}

View file

@ -4,11 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test", "csg", "gltf"] }
makepad-code-editor = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-xr = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-ai = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-base64 = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
makepad-widgets = { workspace = true, features = ["test", "csg", "gltf"] }
makepad-code-editor = { workspace = true }
makepad-xr = { workspace = true }
makepad-ai = { workspace = true }
makepad-base64 = { workspace = true }
# makepad-gltf: read-side GLB parser. Used for round-trip validation
# of arch_gltf.rs output (write GLB → load with makepad_gltf → verify).
nigig-core = { path = "../../nigig-core" }
@ -22,6 +22,7 @@ spreadsheet-ui = { path = "../../apps/spreadsheet/spreadsheet-ui" }
printpdf = "0.7"
time = "0.3"
rayon = "1.12.0"
doc-engine = { path = "../doc/doc-engine" }
doc-ui = { path = "../doc/doc-ui" }
[dev-dependencies]
makepad-test = { workspace = true }

View file

@ -3,6 +3,7 @@ use std::fs;
use std::path::PathBuf;
use crate::dir::app_data_dir;
use crate::project_store::{self, ProjectRecord};
pub const CAD_FILE_EXTENSION: &str = "cad";
@ -28,25 +29,48 @@ pub fn clear_active_project() {
ACTIVE_PROJECT.with(|slot| *slot.borrow_mut() = None);
}
pub fn store_dir() -> PathBuf {
app_data_dir().join("nigig_build_store")
}
pub fn cad_projects_dir() -> PathBuf {
let dir = app_data_dir().join("nigig_build_store").join("cad");
let dir = store_dir().join("cad");
if let Err(e) = fs::create_dir_all(&dir) {
makepad_widgets::error!("cad_store: failed creating dir {:?}: {}", dir, e);
}
dir
}
pub fn cad_projects_dir_in(dir: &std::path::Path) -> PathBuf {
dir.join("cad")
}
pub fn cad_file_path(project_id: &str) -> PathBuf {
cad_projects_dir().join(format!("{}.{}", project_id, CAD_FILE_EXTENSION))
cad_file_path_in(&store_dir(), project_id)
}
fn cad_file_path_in(dir: &std::path::Path, project_id: &str) -> PathBuf {
cad_projects_dir_in(dir).join(format!("{}.{}", project_id, CAD_FILE_EXTENSION))
}
pub fn save_cad_script(project_id: &str, source: &str) -> Result<(), String> {
let path = cad_file_path(project_id);
save_cad_script_in(&store_dir(), project_id, source)
}
fn save_cad_script_in(dir: &std::path::Path, project_id: &str, source: &str) -> Result<(), String> {
let path = cad_file_path_in(dir, project_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("failed to create directory: {}", e))?;
}
fs::write(&path, source).map_err(|e| format!("failed to save CAD script: {}", e))
}
pub fn load_cad_script(project_id: &str) -> Result<String, String> {
let path = cad_file_path(project_id);
load_cad_script_in(&store_dir(), project_id)
}
fn load_cad_script_in(dir: &std::path::Path, project_id: &str) -> Result<String, String> {
let path = cad_file_path_in(dir, project_id);
fs::read_to_string(&path).map_err(|e| format!("failed to load CAD script: {}", e))
}
@ -95,3 +119,166 @@ pub fn export_mesh_to_obj(
Ok(())
}
/// Return the projects that have a saved CAD script on disk, newest first.
///
/// The dashboard shows these so the user can reopen a piece of work
/// instead of always starting from a blank script. A project is only
/// listed once its `.cad` file exists; a record without a script can
/// still be opened from the projects page and starts from the default.
pub fn list_cad_projects() -> Vec<ProjectRecord> {
list_cad_projects_in(&store_dir())
}
fn load_projects_from(dir: &std::path::Path) -> Vec<ProjectRecord> {
fs::read_to_string(dir.join("projects.json"))
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default()
}
fn list_cad_projects_in(dir: &std::path::Path) -> Vec<ProjectRecord> {
let mut projects: Vec<ProjectRecord> = load_projects_from(dir)
.into_iter()
.filter(|p| load_cad_script_in(dir, &p.id).is_ok())
.collect();
projects.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms));
projects
}
/// Create a CAD project: persist a record, leave a blank `.cad` script on
/// disk, and activate it. Returns the new record so the caller can hand
/// the editor a starting script (the default) without a second lookup.
pub fn create_cad_project(name: &str, project_type: &str, description: &str) -> ProjectRecord {
create_cad_project_in(&store_dir(), name, project_type, description)
}
fn save_project_in_store(dir: &std::path::Path, project: ProjectRecord) {
let path = dir.join("projects.json");
let mut projects = load_projects_from(dir);
if let Some(existing) = projects.iter_mut().find(|p| p.id == project.id) {
*existing = project.clone();
} else {
projects.push(project.clone());
}
projects.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms));
let _ = fs::create_dir_all(dir);
if let Ok(data) = serde_json::to_string_pretty(&projects) {
let _ = fs::write(&path, data);
}
}
fn create_cad_project_in(dir: &std::path::Path, name: &str, project_type: &str, description: &str) -> ProjectRecord {
let project = ProjectRecord {
id: format!("proj_{}", project_store::now_ms()),
name: name.trim().to_string(),
project_type: project_type.to_string(),
description: description.trim().to_string(),
created_at_ms: project_store::now_ms(),
};
let _ = save_cad_script_in(dir, &project.id, "");
save_project_in_store(dir, project.clone());
set_active_project(ActiveProject {
id: project.id.clone(),
name: project.name.clone(),
});
project
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("nigig_cad_test_{}_{}", project_store::now_ms(), id));
fs::create_dir_all(&dir).unwrap();
dir
}
fn cleanup(dir: &PathBuf) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn list_cad_projects_only_returns_projects_with_script() {
let dir = temp_dir();
let with_script = ProjectRecord {
id: "proj_with".to_string(),
name: "With Script".to_string(),
project_type: "CAM".to_string(),
description: String::new(),
created_at_ms: 1000,
};
let no_script = ProjectRecord {
id: "proj_without".to_string(),
name: "No Script".to_string(),
project_type: "CAM".to_string(),
description: String::new(),
created_at_ms: 2000,
};
save_project_in_store(&dir, with_script.clone());
save_project_in_store(&dir, no_script.clone());
save_cad_script_in(&dir, &with_script.id, "// a script").unwrap();
let listed = list_cad_projects_in(&dir);
assert!(listed.iter().any(|p| p.id == with_script.id));
assert!(!listed.iter().any(|p| p.id == no_script.id));
cleanup(&dir);
}
#[test]
fn list_cad_projects_sorts_newest_first() {
let dir = temp_dir();
let older = ProjectRecord {
id: "proj_old".to_string(),
name: "Older".to_string(),
project_type: "CAM".to_string(),
description: String::new(),
created_at_ms: 1000,
};
let newer = ProjectRecord {
id: "proj_new".to_string(),
name: "Newer".to_string(),
project_type: "CAM".to_string(),
description: String::new(),
created_at_ms: 2000,
};
save_project_in_store(&dir, older.clone());
save_project_in_store(&dir, newer.clone());
save_cad_script_in(&dir, &older.id, "").unwrap();
save_cad_script_in(&dir, &newer.id, "").unwrap();
let listed = list_cad_projects_in(&dir);
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].id, newer.id);
assert_eq!(listed[1].id, older.id);
cleanup(&dir);
}
#[test]
fn create_cad_project_persists_record_script_and_activation() {
let dir = temp_dir();
clear_active_project();
let project = create_cad_project_in(&dir, " My Project ", "CAM", " test desc ");
assert_eq!(project.name, "My Project");
assert_eq!(project.description, "test desc");
assert!(project.id.starts_with("proj_"));
// The .cad script must exist on disk (blank) so it is listed.
assert!(load_cad_script_in(&dir, &project.id).is_ok());
assert_eq!(list_cad_projects_in(&dir).len(), 1);
// The project must have been activated.
let active = get_active_project().expect("active project should be set");
assert_eq!(active.id, project.id);
clear_active_project();
cleanup(&dir);
}
}

View file

@ -11,6 +11,7 @@ script_mod! {
build_action_page_flip := PageFlip {
width: Fill, height: Fill
lazy_init: true
active_page: @projects_page
projects_page := View {

View file

@ -0,0 +1,800 @@
//! Binned-SAH binary BVH for O(log n) ray picking and spatial queries.
//!
//! Ported from `fab::model::bvh` and adapted to our f64 `TriMesh` /
//! `CadNode` types. The tree is rebuilt whenever the scene changes
//! (incremental refit is not worth the complexity below ~50k parts).
//!
//! Alongside the triangle tree the BVH keeps **per-element world bounds**
//! for linear frustum culling — element counts are in the hundreds or
//! thousands even when triangle counts are in the millions, so a linear
//! scan over element bounds is faster than a tree walk.
use crate::construction_frame::pages::workspace::cad::cull::Frustum;
use crate::construction_frame::pages::workspace::cad::math::DVec3;
use crate::makepad_csg::TriMesh;
use makepad_widgets::makepad_math::*;
use std::collections::HashMap;
/// Triangles per leaf. 8 is the sweet spot for architectural meshes.
pub const MAX_LEAF: usize = 8;
/// SAH bins per split axis.
const BINS: usize = 16;
/// Relative cost of a node traversal vs. one triangle test.
const TRAV_COST: f32 = 1.2;
// ─── AABB helper ────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug)]
pub struct Aabb {
pub min: [f64; 3],
pub max: [f64; 3],
}
impl Aabb {
pub fn empty() -> Self {
Self {
min: [f64::INFINITY; 3],
max: [f64::NEG_INFINITY; 3],
}
}
pub fn is_empty(&self) -> bool {
self.min[0] > self.max[0]
}
pub fn union_point(mut self, p: [f64; 3]) -> Self {
for i in 0..3 {
self.min[i] = self.min[i].min(p[i]);
self.max[i] = self.max[i].max(p[i]);
}
self
}
pub fn union(mut self, other: &Aabb) -> Self {
for i in 0..3 {
self.min[i] = self.min[i].min(other.min[i]);
self.max[i] = self.max[i].max(other.max[i]);
}
self
}
pub fn center(&self) -> [f64; 3] {
[
(self.min[0] + self.max[0]) * 0.5,
(self.min[1] + self.max[1]) * 0.5,
(self.min[2] + self.max[2]) * 0.5,
]
}
pub fn extent(&self) -> [f64; 3] {
[
self.max[0] - self.min[0],
self.max[1] - self.min[1],
self.max[2] - self.min[2],
]
}
pub fn surface(&self) -> f64 {
let e = self.extent();
2.0 * (e[0] * e[1] + e[1] * e[2] + e[2] * e[0])
}
}
// ─── Ray ────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug)]
pub struct BvhRay {
pub origin: DVec3,
pub dir: DVec3,
pub inv_dir: [f64; 3],
}
impl BvhRay {
pub fn new(origin: DVec3, dir: DVec3) -> Self {
let inv_dir = [
if dir.x.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.x },
if dir.y.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.y },
if dir.z.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.z },
];
Self { origin, dir, inv_dir }
}
pub fn at(&self, t: f64) -> DVec3 {
DVec3 {
x: self.origin.x + self.dir.x * t,
y: self.origin.y + self.dir.y * t,
z: self.origin.z + self.dir.z * t,
}
}
}
// ─── Hit result ─────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug)]
pub struct BvhHit {
pub node_id: u64,
pub t: f64,
pub point: DVec3,
}
// ─── Pick options ───────────────────────────────────────────────────────
pub struct BvhPickOptions<'a> {
pub visible: &'a dyn Fn(u64) -> bool,
pub max_t: f64,
pub cull_backfaces: bool,
}
impl Default for BvhPickOptions<'_> {
fn default() -> Self {
Self {
visible: &|_| true,
max_t: f64::INFINITY,
cull_backfaces: false,
}
}
}
// ─── BVH internals ─────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug)]
struct Prim {
node_id: u64,
tri_idx: u32,
bounds: Aabb,
}
#[derive(Clone, Copy, Debug)]
struct Node {
min: [f64; 3],
max: [f64; 3],
first: u32,
count: u32, // 0 = interior, >0 = leaf
}
// ─── BVH public API ────────────────────────────────────────────────────
pub struct Bvh {
nodes: Vec<Node>,
prims: Vec<Prim>,
/// Per-node-id world bounds for linear frustum culling.
element_bounds: Vec<(u64, Aabb)>,
triangle_count: usize,
}
impl Bvh {
/// Build a BVH from a list of (node_id, mesh, model_matrix) tuples.
///
/// `model_matrix` transforms local mesh vertices to world space.
pub fn build(
parts: &[(u64, &TriMesh, &Mat4f)],
) -> Self {
if parts.is_empty() {
return Self {
nodes: vec![],
prims: vec![],
element_bounds: vec![],
triangle_count: 0,
};
}
// 1. Flatten all triangles into primitives with per-triangle bounds.
let mut prims: Vec<Prim> = Vec::new();
let mut element_bounds_map: HashMap<u64, Aabb> = HashMap::new();
for &(node_id, mesh, model) in parts {
let mut elem_aabb = Aabb::empty();
for (tri_idx, tri) in mesh.triangles.iter().enumerate() {
let (v0, v1, v2) = mesh.triangle_vertices(tri_idx);
let w0 = mat4_mul_point(model, v0);
let w1 = mat4_mul_point(model, v1);
let w2 = mat4_mul_point(model, v2);
let bounds = Aabb::empty()
.union_point(w0)
.union_point(w1)
.union_point(w2);
prims.push(Prim {
node_id,
tri_idx: tri_idx as u32,
bounds,
});
elem_aabb = elem_aabb.union(&bounds);
}
element_bounds_map
.entry(node_id)
.and_modify(|e| *e = e.union(&elem_aabb))
.or_insert(elem_aabb);
}
let mut element_bounds: Vec<(u64, Aabb)> = element_bounds_map.into_iter().collect();
element_bounds.sort_by_key(|&(id, _)| id);
element_bounds.dedup_by_key(|&mut (id, _)| id);
let total_tris = prims.len();
if total_tris == 0 {
return Self {
nodes: vec![],
prims: vec![],
element_bounds,
triangle_count: 0,
};
}
// 2. Build the tree using a stack-based iterative builder.
let mut order: Vec<u32> = (0..total_tris as u32).collect();
let mut nodes: Vec<Node> = Vec::with_capacity(total_tris); // upper bound
let mut stack: Vec<(u32, u32)> = Vec::with_capacity(48); // (start, count)
// Root covers all primitives.
let root_bounds = compute_bounds(&prims, &order, 0, total_tris);
stack.push((0, total_tris as u32));
while let Some((start, count)) = stack.pop() {
if count <= MAX_LEAF as u32 {
let node_idx = nodes.len() as u32;
nodes.push(Node {
min: root_bounds.min, // placeholder, rewritten below
max: root_bounds.max,
first: start,
count,
});
// Rewrite bounds for this leaf.
let bounds = compute_bounds(&prims, &order, start as usize, count as usize);
nodes[node_idx as usize].min = bounds.min;
nodes[node_idx as usize].max = bounds.max;
continue;
}
// Try SAH split.
if let Some(split) = sah_split(&prims, &mut order, start as usize, count as usize) {
let left_count = (split - start as usize) as u32;
let right_count = count - left_count;
let left_bounds = compute_bounds(&prims, &order, start as usize, left_count as usize);
// Reserve space for this interior node (will be filled after children).
let node_idx = nodes.len() as u32;
nodes.push(Node {
min: [0.0; 3],
max: [0.0; 3],
first: 0,
count: 0,
});
// Push right then left (left processed first = nearer in stack).
stack.push((start + left_count, right_count));
stack.push((start, left_count));
// After both children are done, the node's bounds = union of children.
// We'll fix this with a post-pass.
// For now, compute from the full range.
let full_bounds = compute_bounds(&prims, &order, start as usize, count as usize);
nodes[node_idx as usize].min = full_bounds.min;
nodes[node_idx as usize].max = full_bounds.max;
nodes[node_idx as usize].first = node_idx + 1; // left child is next
} else {
// Can't split — make a leaf with everything.
let node_idx = nodes.len() as u32;
let bounds = compute_bounds(&prims, &order, start as usize, count as usize);
nodes.push(Node {
min: bounds.min,
max: bounds.max,
first: start,
count,
});
}
}
Self {
nodes,
prims,
element_bounds,
triangle_count: total_tris,
}
}
pub fn triangle_count(&self) -> usize {
self.triangle_count
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn element_bounds(&self) -> &[(u64, Aabb)] {
&self.element_bounds
}
/// Raycast: find the nearest hit.
pub fn raycast(
&self,
ray: &BvhRay,
opts: &BvhPickOptions<'_>,
triangle_at: impl Fn(u64, u32) -> (DVec3, DVec3, DVec3),
) -> Option<BvhHit> {
if self.nodes.is_empty() {
return None;
}
let mut best_t = opts.max_t;
let mut best: Option<BvhHit> = None;
let mut stack: Vec<(u32, f64)> = Vec::with_capacity(48);
stack.push((0, 0.0));
while let Some((ni, _t_entry)) = stack.pop() {
let node = &self.nodes[ni as usize];
// Skip nodes whose AABB is missed by the ray.
if slab_entry(node, ray).is_none() {
continue;
}
if node.count > 0 {
// Leaf: test all triangles.
for p in &self.prims[node.first as usize..(node.first + node.count) as usize] {
if !(opts.visible)(p.node_id) {
continue;
}
let (v0, v1, v2) = triangle_at(p.node_id, p.tri_idx);
if let Some(t) = ray_triangle_test(ray, v0, v1, v2, opts.cull_backfaces) {
if t < best_t {
best_t = t;
best = Some(BvhHit {
node_id: p.node_id,
t,
point: ray.at(t),
});
}
}
}
continue;
}
// Interior: test both children.
let left = node.first as usize;
let right = left + 1;
let tl = slab_entry(&self.nodes[left], ray);
let tr = slab_entry(&self.nodes[right], ray);
match (tl, tr) {
(Some(tl), Some(tr)) => {
if tl < tr {
stack.push((right as u32, tr));
stack.push((left as u32, tl));
} else {
stack.push((left as u32, tl));
stack.push((right as u32, tr));
}
}
(Some(t), None) => stack.push((left as u32, t)),
(None, Some(t)) => stack.push((right as u32, t)),
(None, None) => {}
}
}
best
}
/// Frustum cull: linear scan over element bounds (not tree-based).
pub fn frustum_elements(&self, frustum: &Frustum, out: &mut Vec<u64>) {
out.clear();
for &(id, ref aabb) in &self.element_bounds {
if frustum_aabb_intersect(frustum, aabb) {
out.push(id);
}
}
}
}
// ─── Internal helpers ───────────────────────────────────────────────────
fn compute_bounds(prims: &[Prim], order: &[u32], start: usize, count: usize) -> Aabb {
let mut bounds = Aabb::empty();
for i in start..start + count {
bounds = bounds.union(&prims[order[i] as usize].bounds);
}
bounds
}
fn sah_split(prims: &[Prim], order: &mut [u32], start: usize, count: usize) -> Option<usize> {
if count <= MAX_LEAF {
return None; // Not worth splitting.
}
// Find the longest axis of the centroid bounding box.
let mut centroid_min = [f64::INFINITY; 3];
let mut centroid_max = [f64::NEG_INFINITY; 3];
for i in start..start + count {
let c = prims[order[i] as usize].bounds.center();
for a in 0..3 {
centroid_min[a] = centroid_min[a].min(c[a]);
centroid_max[a] = centroid_max[a].max(c[a]);
}
}
let extent = [
centroid_max[0] - centroid_min[0],
centroid_max[1] - centroid_min[1],
centroid_max[2] - centroid_min[2],
];
let axis = if extent[0] >= extent[1] && extent[0] >= extent[2] {
0
} else if extent[1] >= extent[2] {
1
} else {
2
};
if extent[axis] < 1e-12 {
return None; // All centroids coincident.
}
// Compute total surface area of all primitives in this range.
let total_surface: f64 = (start..start + count)
.map(|i| prims[order[i] as usize].bounds.surface())
.sum();
if total_surface <= 0.0 {
return None;
}
// Bin centroids.
let bin_surface = vec![0.0f64; BINS];
let bin_count = vec![0u32; BINS];
let bin_bounds = vec![Aabb::empty(); BINS];
let mut bin_surface = bin_surface;
let mut bin_count = bin_count;
let mut bin_bounds = bin_bounds;
let scale = BINS as f64 / extent[axis];
for i in start..start + count {
let c = prims[order[i] as usize].bounds.center();
let bin = ((c[axis] - centroid_min[axis]) * scale) as usize;
let bin = bin.min(BINS - 1);
bin_surface[bin] += prims[order[i] as usize].bounds.surface();
bin_count[bin] += 1;
bin_bounds[bin] = bin_bounds[bin].union(&prims[order[i] as usize].bounds);
}
// Prefix sweep: cost of sending everything to the left of split.
let mut left_count = vec![0u32; BINS];
let mut left_surface = vec![0.0f64; BINS];
let mut left_bounds = vec![Aabb::empty(); BINS];
left_count[0] = bin_count[0];
left_surface[0] = bin_surface[0];
left_bounds[0] = bin_bounds[0];
for i in 1..BINS {
left_count[i] = left_count[i - 1] + bin_count[i];
left_surface[i] = left_surface[i - 1] + bin_surface[i];
left_bounds[i] = left_bounds[i - 1].union(&bin_bounds[i]);
}
// Suffix: cost of sending everything to the right of split.
let mut right_count = vec![0u32; BINS];
let mut right_surface = vec![0.0f64; BINS];
let mut right_bounds = vec![Aabb::empty(); BINS];
right_count[BINS - 1] = bin_count[BINS - 1];
right_surface[BINS - 1] = bin_surface[BINS - 1];
right_bounds[BINS - 1] = bin_bounds[BINS - 1];
for i in (0..BINS - 1).rev() {
right_count[i] = right_count[i + 1] + bin_count[i];
right_surface[i] = right_surface[i + 1] + bin_surface[i];
right_bounds[i] = right_bounds[i + 1].union(&bin_bounds[i]);
}
// Find best split.
let mut best_cost = f64::INFINITY;
let mut best_split = 0usize;
for i in 0..BINS - 1 {
if left_count[i] == 0 || right_count[i + 1] == 0 {
continue;
}
let cost = TRAV_COST as f64
+ (left_surface[i] * left_count[i] as f64
+ right_surface[i + 1] * right_count[i + 1] as f64)
/ total_surface;
if cost < best_cost {
best_cost = cost;
best_split = i;
}
}
// Compare against no-split cost.
let no_split_cost = count as f64;
if best_cost >= no_split_cost {
return None;
}
// Partition around the split plane.
let split_pos = centroid_min[axis]
+ (best_split as f64 + 0.5) / BINS as f64 * extent[axis];
let mut left = start;
let mut right = start + count - 1;
while left <= right {
let c = prims[order[left] as usize].bounds.center();
if c[axis] <= split_pos {
left += 1;
} else {
order.swap(left, right);
if right == 0 {
break;
}
right -= 1;
}
}
if left == start || left == start + count {
return None; // All on one side.
}
Some(left)
}
fn slab_entry(node: &Node, ray: &BvhRay) -> Option<f64> {
let origin = [ray.origin.x, ray.origin.y, ray.origin.z];
let mut tmin = f64::NEG_INFINITY;
let mut tmax = f64::INFINITY;
for i in 0..3 {
if ray.inv_dir[i].is_infinite() {
if origin[i] < node.min[i] || origin[i] > node.max[i] {
return None;
}
} else {
let mut t1 = (node.min[i] - origin[i]) * ray.inv_dir[i];
let mut t2 = (node.max[i] - origin[i]) * ray.inv_dir[i];
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
tmin = tmin.max(t1);
tmax = tmax.min(t2);
if tmin > tmax {
return None;
}
}
}
if tmax < 0.0 {
None
} else if tmin < 0.0 {
Some(tmax.max(0.0))
} else {
Some(tmin)
}
}
/// Moller-Trumbore ray-triangle intersection. Returns `t` if hit.
fn ray_triangle_test(
ray: &BvhRay,
v0: DVec3,
v1: DVec3,
v2: DVec3,
cull_backfaces: bool,
) -> Option<f64> {
let e1 = DVec3 {
x: v1.x - v0.x,
y: v1.y - v0.y,
z: v1.z - v0.z,
};
let e2 = DVec3 {
x: v2.x - v0.x,
y: v2.y - v0.y,
z: v2.z - v0.z,
};
let h = DVec3 {
x: ray.dir.y * e2.z - ray.dir.z * e2.y,
y: ray.dir.z * e2.x - ray.dir.x * e2.z,
z: ray.dir.x * e2.y - ray.dir.y * e2.x,
};
let a = e1.x * h.x + e1.y * h.y + e1.z * h.z;
if a > -1e-12 && a < 1e-12 {
return None;
}
if cull_backfaces && a > 0.0 {
return None;
}
let f = 1.0 / a;
let s = DVec3 {
x: ray.origin.x - v0.x,
y: ray.origin.y - v0.y,
z: ray.origin.z - v0.z,
};
let u = f * (s.x * h.x + s.y * h.y + s.z * h.z);
if u < 0.0 || u > 1.0 {
return None;
}
let q = DVec3 {
x: s.y * e1.z - s.z * e1.y,
y: s.z * e1.x - s.x * e1.z,
z: s.x * e1.y - s.y * e1.x,
};
let v = f * (ray.dir.x * q.x + ray.dir.y * q.y + ray.dir.z * q.z);
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = f * (e2.x * q.x + e2.y * q.y + e2.z * q.z);
if t > 1e-9 {
Some(t)
} else {
None
}
}
/// Frustum-vs-AABB test using the p-vertex method.
fn frustum_aabb_intersect(frustum: &Frustum, aabb: &Aabb) -> bool {
for plane in &frustum.planes {
// Find the p-vertex (the corner most aligned with the plane normal).
let px = if plane[0] >= 0.0 { aabb.max[0] } else { aabb.min[0] };
let py = if plane[1] >= 0.0 { aabb.max[1] } else { aabb.min[1] };
let pz = if plane[2] >= 0.0 { aabb.max[2] } else { aabb.min[2] };
let d = plane[0] * px + plane[1] * py + plane[2] * pz + plane[3];
if d < 0.0 {
return false;
}
}
true
}
/// Transform a point by a 4x4 matrix (same as `mat4_mul_vec4` with w=1).
fn mat4_mul_point(m: &Mat4f, p: crate::makepad_csg::Vec3d) -> [f64; 3] {
let v = [p.x as f32, p.y as f32, p.z as f32, 1.0f32];
let r = [
m.v[0] * v[0] + m.v[4] * v[1] + m.v[8] * v[2] + m.v[12] * v[3],
m.v[1] * v[0] + m.v[5] * v[1] + m.v[9] * v[2] + m.v[13] * v[3],
m.v[2] * v[0] + m.v[6] * v[1] + m.v[10] * v[2] + m.v[14] * v[3],
];
[r[0] as f64, r[1] as f64, r[2] as f64]
}
#[cfg(test)]
mod tests {
use super::*;
fn unit_cube_mesh() -> TriMesh {
let v = vec![
crate::makepad_csg::Vec3d { x: 0.0, y: 0.0, z: 0.0 },
crate::makepad_csg::Vec3d { x: 1.0, y: 0.0, z: 0.0 },
crate::makepad_csg::Vec3d { x: 1.0, y: 1.0, z: 0.0 },
crate::makepad_csg::Vec3d { x: 0.0, y: 1.0, z: 0.0 },
crate::makepad_csg::Vec3d { x: 0.0, y: 0.0, z: 1.0 },
crate::makepad_csg::Vec3d { x: 1.0, y: 0.0, z: 1.0 },
crate::makepad_csg::Vec3d { x: 1.0, y: 1.0, z: 1.0 },
crate::makepad_csg::Vec3d { x: 0.0, y: 1.0, z: 1.0 },
];
let triangles = vec![
[0, 1, 2], [0, 2, 3], // bottom
[4, 6, 5], [4, 7, 6], // top
[0, 4, 5], [0, 5, 1], // front
[2, 6, 7], [2, 7, 3], // back
[0, 3, 7], [0, 7, 4], // left
[1, 5, 6], [1, 6, 2], // right
];
TriMesh { vertices: v, triangles }
}
#[test]
fn empty_bvh() {
let bvh = Bvh::build(&[]);
assert_eq!(bvh.triangle_count(), 0);
assert!(bvh.nodes.is_empty());
}
#[test]
fn single_mesh_build() {
let mesh = unit_cube_mesh();
let id = 42u64;
let identity = Mat4f::identity();
let bvh = Bvh::build(&[(id, &mesh, &identity)]);
assert_eq!(bvh.triangle_count(), 12);
assert!(!bvh.nodes.is_empty());
assert!(!bvh.element_bounds.is_empty());
}
#[test]
fn raycast_hits_cube() {
let mesh = unit_cube_mesh();
let id = 1u64;
let identity = Mat4f::identity();
let bvh = Bvh::build(&[(id, &mesh, &identity)]);
// Ray along +X toward the cube at (0.5, 0.5, 0.5).
let ray = BvhRay::new(
DVec3 { x: -1.0, y: 0.5, z: 0.5 },
DVec3 { x: 1.0, y: 0.0, z: 0.0 },
);
let triangle_at = |node_id: u64, tri_idx: u32| -> (DVec3, DVec3, DVec3) {
assert_eq!(node_id, 1);
let (a, b, c) = mesh.triangle_vertices(tri_idx as usize);
(to_dvec3(a), to_dvec3(b), to_dvec3(c))
};
let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at);
assert!(hit.is_some(), "ray should hit the cube");
let hit = hit.unwrap();
assert_eq!(hit.node_id, 1);
assert!((hit.t - 1.0).abs() < 1e-6, "expected t≈1.0, got {}", hit.t);
}
#[test]
fn raycast_misses() {
let mesh = unit_cube_mesh();
let id = 1u64;
let identity = Mat4f::identity();
let bvh = Bvh::build(&[(id, &mesh, &identity)]);
// Ray that misses the cube entirely.
let ray = BvhRay::new(
DVec3 { x: -1.0, y: 2.0, z: 0.5 },
DVec3 { x: 1.0, y: 0.0, z: 0.0 },
);
let triangle_at = |_: u64, _: u32| -> (DVec3, DVec3, DVec3) {
unreachable!()
};
let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at);
assert!(hit.is_none(), "ray should miss");
}
fn translate_mat(tx: f32, ty: f32, tz: f32) -> Mat4f {
let mut m = Mat4f::identity();
m.v[12] = tx;
m.v[13] = ty;
m.v[14] = tz;
m
}
fn to_dvec3(p: crate::makepad_csg::Vec3d) -> DVec3 {
DVec3 { x: p.x, y: p.y, z: p.z }
}
fn to_dvec3_arr(a: [f64; 3]) -> DVec3 {
DVec3 { x: a[0], y: a[1], z: a[2] }
}
#[test]
fn multiple_meshes() {
let mesh = unit_cube_mesh();
// Two cubes side by side.
let m1 = Mat4f::identity();
let m2 = translate_mat(5.0, 0.0, 0.0);
let bvh = Bvh::build(&[(1, &mesh, &m1), (2, &mesh, &m2)]);
assert_eq!(bvh.triangle_count(), 24);
// Ray hits first cube.
let ray = BvhRay::new(
DVec3 { x: -1.0, y: 0.5, z: 0.5 },
DVec3 { x: 1.0, y: 0.0, z: 0.0 },
);
let triangle_at = |node_id: u64, tri_idx: u32| -> (DVec3, DVec3, DVec3) {
let m = if node_id == 1 { &m1 } else { &m2 };
let (a, b, c) = mesh.triangle_vertices(tri_idx as usize);
(
to_dvec3_arr(mat4_mul_point(m, a)),
to_dvec3_arr(mat4_mul_point(m, b)),
to_dvec3_arr(mat4_mul_point(m, c)),
)
};
let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at);
assert!(hit.is_some());
assert_eq!(hit.unwrap().node_id, 1);
}
#[test]
fn aabb_basics() {
let a = Aabb::empty().union_point([0.0, 0.0, 0.0]).union_point([1.0, 2.0, 3.0]);
assert!(!a.is_empty());
assert_eq!(a.center(), [0.5, 1.0, 1.5]);
assert_eq!(a.extent(), [1.0, 2.0, 3.0]);
}
#[test]
fn slab_entry_basic() {
let node = Node {
min: [0.0, 0.0, 0.0],
max: [1.0, 1.0, 1.0],
first: 0,
count: 0,
};
// Ray from (-1, 0.5, 0.5) in +X direction.
let ray = BvhRay::new(
DVec3 { x: -1.0, y: 0.5, z: 0.5 },
DVec3 { x: 1.0, y: 0.0, z: 0.0 },
);
let t = slab_entry(&node, &ray);
assert!(t.is_some());
assert!((t.unwrap() - 1.0).abs() < 1e-6);
}
}

View file

@ -986,6 +986,23 @@ impl CadNode {
pub fn group_id(&self) -> Option<u64> {
self.parent.map(|p| p.raw())
}
/// Whether this part is hidden from the viewport. Hidden parts are
/// skipped by picking, the BVH and both render passes. The flag is
/// stored as a `__hidden__` name prefix so it survives script round
/// trips and clone/snapshot without a parallel state array.
pub fn is_hidden(&self) -> bool {
self.name.starts_with("__hidden__")
}
/// Set or clear hidden state via the `__hidden__` name prefix.
pub fn set_hidden(&mut self, hidden: bool) {
if hidden {
if !self.name.starts_with("__hidden__") {
self.name = format!("__hidden__{}", self.name);
}
} else if let Some(stripped) = self.name.strip_prefix("__hidden__") {
self.name = stripped.to_string();
}
}
pub fn dof_constraint(&self) -> Option<DofConstraint> {
self.metadata.dof_constraint
}
@ -2368,6 +2385,26 @@ pub enum PartKind {
Beam,
}
impl PartKind {
pub fn label(self) -> &'static str {
match self {
Self::Cube => "Cube",
Self::Cylinder => "Cylinder",
Self::Sphere => "Sphere",
Self::Rect2D => "Rect2D",
Self::Circle2D => "Circle2D",
Self::Arc => "Arc",
Self::Polygon2D => "Polygon",
Self::Wall => "Wall",
Self::Slab => "Slab",
Self::Door => "Door",
Self::Window => "Window",
Self::Column => "Column",
Self::Beam => "Beam",
}
}
}
// ===========================================================================
// Tests
// ===========================================================================

View file

@ -0,0 +1,414 @@
//! Orbit camera math, pure functions over camera state.
//!
//! Ported from `fab::nav::orbit` and adapted to work alongside `XrCamera`
//! without modifying it. Orthographic state is tracked as separate `bool`
//! and `f32` fields on the viewport struct.
//!
//! Turntable is the default: the boom is re-derived from `eye - target` on
//! every step and the world up is re-asserted, so a mixed sequence of drags
//! can never accumulate roll.
//!
//! Dolly is *to the cursor*: the camera is uniformly scaled about a point on
//! the ray under the pointer, which leaves every point of that ray projecting
//! to exactly the same pixel.
use makepad_widgets::makepad_math::*;
use makepad_xr::scene::XrCamera;
/// Just short of the pole: a camera exactly on the axis has no defined yaw.
pub const PITCH_LIMIT: f32 = 1.5533; // 89°
pub const MIN_DISTANCE: f32 = 0.02;
pub const MAX_DISTANCE: f32 = 40_000.0;
pub const MIN_ORTHO_HEIGHT: f32 = 0.02;
pub const MAX_ORTHO_HEIGHT: f32 = 80_000.0;
pub const ORBIT_SENS: f32 = 0.0075;
pub const WORLD_UP: Vec3f = Vec3f {
x: 0.0,
y: 0.0,
z: 1.0,
};
// ─── Pure math helpers ──────────────────────────────────────────────────
pub fn rotate_about(v: Vec3f, axis: Vec3f, angle: f32) -> Vec3f {
let a = axis.normalize();
if !a.is_finite() {
return v;
}
let s = angle.sin();
let c = angle.cos();
v * c + Vec3f::cross(a, v) * s + a * (a.dot(v) * (1.0 - c))
}
fn any_perpendicular(v: Vec3f) -> Vec3f {
let a = if v.x.abs() < 0.9 {
vec3(1.0, 0.0, 0.0)
} else {
vec3(0.0, 1.0, 0.0)
};
Vec3f::cross(v, a).normalize()
}
pub fn slerp(a: Vec3f, b: Vec3f, f: f32) -> Vec3f {
let a = a.normalize();
let b = b.normalize();
if !a.is_finite() || !b.is_finite() {
return b;
}
let d = a.dot(b).clamp(-1.0, 1.0);
if d > 0.9995 {
return Vec3f::from_lerp(a, b, f).normalize();
}
if d < -0.9995 {
return rotate_about(a, any_perpendicular(a), std::f32::consts::PI * f);
}
let theta = d.acos();
let st = theta.sin();
a * (((1.0 - f) * theta).sin() / st) + b * ((f * theta).sin() / st)
}
// ─── XrCamera helpers ───────────────────────────────────────────────────
pub fn forward(cam: &XrCamera) -> Vec3f {
let yaw = cam.orbit_yaw;
let pitch = cam.orbit_pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
vec3(
yaw.sin() * pitch.cos(),
pitch.sin(),
-yaw.cos() * pitch.cos(),
)
.normalize()
}
pub fn right(cam: &XrCamera) -> Vec3f {
let f = forward(cam);
Vec3f::cross(f, WORLD_UP).normalize()
}
pub fn eye(cam: &XrCamera) -> Vec3f {
cam.desktop_target - forward(cam) * cam.distance
}
// ─── Turntable ──────────────────────────────────────────────────────────
pub fn turntable_angles(cam: &XrCamera) -> (f32, f32) {
let offset = eye(cam) - cam.desktop_target;
let dist = offset.length().max(1e-5);
// XrCamera convention: eye = target - forward * dist,
// forward = (sin(yaw)*cos(pitch), sin(pitch), -cos(yaw)*cos(pitch))
// offset = -forward * dist = (-sin(yaw)*cos(pitch), -sin(pitch), cos(yaw)*cos(pitch)) * dist
let sin_pitch = (-offset.y / dist).clamp(-1.0, 1.0);
let pitch = sin_pitch.asin();
let horiz = (offset.x * offset.x + offset.z * offset.z).sqrt();
let yaw = if horiz > dist * 1e-3 {
f32::atan2(-offset.x, offset.z)
} else {
let s = if sin_pitch >= 0.0 { -1.0 } else { 1.0 };
(s * WORLD_UP.y).atan2(s * WORLD_UP.x)
};
(yaw, pitch)
}
pub fn set_turntable(cam: &mut XrCamera, yaw: f32, pitch: f32, dist: f32) {
cam.orbit_yaw = yaw;
cam.orbit_pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
cam.distance = dist.clamp(
cam.distance_min.max(0.01),
cam.distance_max.max(cam.distance_min.max(0.01) + 0.01),
);
}
pub fn orbit_turntable(cam: &mut XrCamera, dx: f32, dy: f32) {
let (yaw, pitch) = turntable_angles(cam);
set_turntable(cam, yaw - dx * ORBIT_SENS, pitch + dy * ORBIT_SENS, cam.distance);
}
pub fn orbit_trackball(cam: &mut XrCamera, dx: f32, dy: f32) {
let r = right(cam);
if !r.is_finite() {
orbit_turntable(cam, dx, dy);
return;
}
let mut offset = eye(cam) - cam.desktop_target;
let yaw_rot = -dx * ORBIT_SENS;
let pitch_rot = -dy * ORBIT_SENS;
offset = rotate_about(offset, WORLD_UP, yaw_rot);
offset = rotate_about(offset, r, pitch_rot);
let new_eye = cam.desktop_target + offset;
let new_offset = new_eye - cam.desktop_target;
let dist = new_offset.length().max(1e-5);
let sin_pitch = (-new_offset.y / dist).clamp(-1.0, 1.0);
let horiz = (new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt();
let new_yaw = if horiz > dist * 1e-3 {
f32::atan2(-new_offset.x, new_offset.z)
} else {
cam.orbit_yaw
};
set_turntable(cam, new_yaw, sin_pitch.asin(), dist);
}
// ─── Dolly ──────────────────────────────────────────────────────────────
pub fn dolly(
cam: &mut XrCamera,
ortho: &mut bool,
ortho_height: &mut f32,
factor: f32,
anchor: Option<Vec3f>,
fov_y: f32,
) {
if !factor.is_finite() || factor <= 0.0 {
return;
}
if *ortho {
let h = ortho_height.max(1e-4);
let f = factor.clamp(MIN_ORTHO_HEIGHT / h, MAX_ORTHO_HEIGHT / h);
*ortho_height = h * f;
if let Some(a) = anchor {
let fwd = forward(cam);
let v = eye(cam) - a;
let lateral = v - fwd * v.dot(fwd);
let shift = lateral * (f - 1.0);
if shift.is_finite() {
cam.desktop_target += shift;
}
}
} else {
let dist = cam.distance.max(1e-5);
let f = factor.clamp(MIN_DISTANCE / dist, MAX_DISTANCE / dist);
let a = anchor.unwrap_or(cam.desktop_target);
let current_eye = eye(cam);
let new_eye = a + (current_eye - a) * f;
let new_target = a + (cam.desktop_target - a) * f;
if new_eye.is_finite() && new_target.is_finite() {
cam.desktop_target = new_target;
let new_offset = new_eye - new_target;
let new_dist = new_offset.length();
if new_dist > 1e-5 {
cam.distance = new_dist;
let sin_pitch = (-new_offset.y / new_dist).clamp(-1.0, 1.0);
let horiz =
(new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt();
if horiz > new_dist * 1e-3 {
cam.orbit_yaw = f32::atan2(-new_offset.x, new_offset.z);
}
cam.orbit_pitch = sin_pitch.asin();
}
}
}
let _ = fov_y; // used only in ortho path implicitly via ortho_height
}
pub fn pan(cam: &XrCamera, ortho: bool, ortho_height: f32, dx: f32, dy: f32, rect_h: f32, fov_y: f32) -> Vec3f {
let world_per_point = if ortho {
ortho_height / rect_h.max(1.0)
} else {
let half_fov = (fov_y.to_radians() * 0.5).max(1e-4);
2.0 * cam.distance * half_fov.tan() / rect_h.max(1.0)
};
let r = right(cam);
let f = forward(cam);
let up = Vec3f::cross(r, f).normalize();
r * (-dx * world_per_point) + up * (dy * world_per_point)
}
pub fn set_pivot(cam: &mut XrCamera, point: Vec3f) {
if !point.is_finite() {
return;
}
let dist = (eye(cam) - point).length();
if !dist.is_finite() || dist < MIN_DISTANCE || dist > MAX_DISTANCE {
return;
}
cam.desktop_target = point;
}
pub fn recenter(cam: &mut XrCamera, point: Vec3f) {
let shift = point - cam.desktop_target;
if shift.is_finite() {
cam.desktop_target = point;
}
}
// ─── Projection toggle ──────────────────────────────────────────────────
pub fn set_ortho(
cam: &mut XrCamera,
ortho: &mut bool,
ortho_height: &mut f32,
new_ortho: bool,
fov_y: f32,
) {
if *ortho == new_ortho {
return;
}
let half_fov = (fov_y.to_radians() * 0.5).max(1e-4);
if new_ortho {
*ortho_height = (2.0 * cam.distance * half_fov.tan())
.clamp(MIN_ORTHO_HEIGHT, MAX_ORTHO_HEIGHT);
} else {
let d = (*ortho_height * 0.5 / half_fov.tan())
.clamp(cam.distance_min.max(0.01), cam.distance_max);
let dir = forward(cam);
if dir.is_finite() {
cam.desktop_target = eye(cam) + dir * d;
cam.distance = d;
}
}
*ortho = new_ortho;
}
// ─── Preset views ───────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PresetView {
Front,
Back,
Left,
Right,
Top,
Bottom,
Isometric,
}
impl PresetView {
pub fn look_dir_and_up(self) -> (Vec3f, Vec3f) {
match self {
PresetView::Front => (vec3(0.0, -1.0, 0.0), WORLD_UP),
PresetView::Back => (vec3(0.0, 1.0, 0.0), WORLD_UP),
PresetView::Left => (vec3(-1.0, 0.0, 0.0), WORLD_UP),
PresetView::Right => (vec3(1.0, 0.0, 0.0), WORLD_UP),
PresetView::Top => (vec3(0.0, 0.0, -1.0), vec3(0.0, -1.0, 0.0)),
PresetView::Bottom => (vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)),
PresetView::Isometric => (
vec3(0.577, -0.577, 0.577).normalize(),
WORLD_UP,
),
}
}
}
pub fn apply_preset(
cam: &mut XrCamera,
ortho: &mut bool,
ortho_height: &mut f32,
preset: PresetView,
fov_y: f32,
) {
let (dir, _up) = preset.look_dir_and_up();
let dist = cam
.distance
.clamp(cam.distance_min.max(0.01), cam.distance_max);
// Place eye along -dir from target (dir is the look direction).
let new_eye = cam.desktop_target - dir * dist;
let new_offset = new_eye - cam.desktop_target;
let new_dist = new_offset.length().max(1e-5);
// Recover yaw/pitch using XrCamera convention:
// offset = (-sin(yaw)*cos(pitch), -sin(pitch), cos(yaw)*cos(pitch)) * dist
let sin_pitch = (-new_offset.y / new_dist).clamp(-1.0, 1.0);
let horiz =
(new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt();
let new_yaw: f32 = if horiz > new_dist * 1e-3 {
f32::atan2(-new_offset.x, new_offset.z)
} else {
0.0
};
cam.orbit_yaw = new_yaw;
cam.orbit_pitch = sin_pitch.asin();
cam.distance = new_dist;
if preset != PresetView::Isometric {
*ortho = true;
let half_fov = (fov_y.to_radians() * 0.5).max(1e-4);
*ortho_height = (2.0 * dist * half_fov.tan())
.clamp(MIN_ORTHO_HEIGHT, MAX_ORTHO_HEIGHT);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_cam() -> XrCamera {
let mut c = XrCamera::default();
c.desktop_target = vec3(5.0, 3.5, 2.5);
c.orbit_yaw = 0.8;
c.orbit_pitch = 0.3;
c.distance = 20.0;
c
}
#[test]
fn turntable_never_accumulates_roll() {
let mut cam = test_cam();
let drags = [
(37.0f32, -12.0f32),
(-90.0, 40.0),
(5.0, 300.0),
(250.0, -400.0),
(-3.0, 3.0),
];
for (dx, dy) in &drags {
orbit_turntable(&mut cam, *dx, *dy);
assert!(eye(&cam).is_finite(), "eye went non-finite");
let r = right(&cam);
assert!(r.z.abs() < 1e-5, "roll crept in: right = {:?}", r);
}
}
#[test]
fn turntable_survives_the_poles() {
let mut cam = test_cam();
let mut ortho = false;
let mut ortho_h = 10.0;
let fov = cam.fov_y;
apply_preset(&mut cam, &mut ortho, &mut ortho_h, PresetView::Top, fov);
let before = forward(&cam);
orbit_turntable(&mut cam, 0.0, -1.0);
let after = forward(&cam);
assert!(after.is_finite());
assert!(
after.dot(before) > 0.999,
"top view jumped: {before:?} -> {after:?}"
);
}
#[test]
fn preset_views_point_correctly() {
let cam = test_cam();
for preset in [
PresetView::Front,
PresetView::Back,
PresetView::Left,
PresetView::Right,
PresetView::Top,
PresetView::Bottom,
PresetView::Isometric,
] {
let mut c = cam.clone();
let mut ortho = false;
let mut ortho_h = 10.0;
let fov = cam.fov_y;
apply_preset(&mut c, &mut ortho, &mut ortho_h, preset, fov);
let (dir, _) = preset.look_dir_and_up();
// PITCH_LIMIT prevents exactly reaching ±90°, so use 0.999.
assert!(
forward(&c).dot(dir) > 0.999,
"{preset:?}: {:?} vs {dir:?}",
forward(&c)
);
assert!((c.distance - cam.distance).abs() < 1.0);
}
}
#[test]
fn recenter_keeps_direction_and_distance() {
let mut cam = test_cam();
let dir = forward(&cam);
let dist = cam.distance;
recenter(&mut cam, vec3(-2.0, 9.0, 1.0));
assert!(forward(&cam).dot(dir) > 0.9999);
assert!((cam.distance - dist).abs() < 1e-4);
assert!((cam.desktop_target - vec3(-2.0, 9.0, 1.0)).length() < 1e-5);
}
}

View file

@ -0,0 +1,218 @@
//! Phase A — Command palette: fuzzy search over the commands the workspace can
//! actually run. The table here is the *inventory* of every verb the palette can
//! fire; each entry maps to an existing `CadWorkspace`/`CadViewport` handler so a
//! palette row can never be a dead end.
//!
//! The pure logic (scoring + ranking) lives here and is unit-tested; the overlay
//! wiring in `mod.rs`/`workspace.rs` dispatches a selected `CadCommand`.
/// The closed set of verbs the command palette can run. Every variant maps to an
/// existing workspace/viewport handler — adding a variant here implies adding a
/// dispatch arm in `CadWorkspace::run_command`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CadCommand {
/// Frame the whole scene (fit).
FrameAll,
/// Frame the current selection.
FrameSelected,
/// Cycle render/shading mode.
CycleShading,
/// Toggle orthographic projection.
ToggleOrtho,
/// Go to a preset camera view.
ViewFront,
ViewRight,
ViewTop,
ViewIsometric,
/// Hide selected parts.
HideSelected,
/// Isolate the selected parts (hide everything else).
IsolateSelected,
/// Show all parts.
ShowAll,
/// Toggle the outliner panel.
ToggleOutliner,
/// Render the current scene at high resolution and save a PNG.
RenderImage,
/// Undo / redo the last command.
Undo,
Redo,
}
impl CadCommand {
/// A short human label.
pub fn label(self) -> &'static str {
use CadCommand::*;
match self {
FrameAll => "Frame All",
FrameSelected => "Frame Selected",
CycleShading => "Shading: Next Mode",
ToggleOrtho => "Toggle Orthographic",
ViewFront => "View: Front",
ViewRight => "View: Right",
ViewTop => "View: Top",
ViewIsometric => "View: Isometric",
HideSelected => "Hide Selected",
IsolateSelected => "Isolate Selected",
ShowAll => "Show All",
ToggleOutliner => "Toggle Outliner",
RenderImage => "Render High-Res Image",
Undo => "Undo",
Redo => "Redo",
}
}
/// Optional keyboard shortcut string shown in the palette row.
pub fn shortcut(self) -> &'static str {
use CadCommand::*;
match self {
FrameAll => "F",
FrameSelected => "F",
CycleShading => "",
ToggleOrtho => "",
ViewFront => "1",
ViewRight => "3",
ViewTop => "7",
ViewIsometric => "9",
HideSelected => "Ctrl+K",
IsolateSelected => "I",
ShowAll => "Ctrl+Shift+K",
ToggleOutliner => "List",
RenderImage => "F12",
Undo => "Ctrl+Z",
Redo => "Ctrl+Shift+Z",
}
}
}
/// The full command inventory. Kept as a small groupable set matching what the
/// mobile toolbar already exposes, plus the viewport hotkeys, so the palette is
/// a discoverability surface (not new capability).
pub const COMMANDS: &[CadCommand] = &[
CadCommand::FrameAll,
CadCommand::FrameSelected,
CadCommand::CycleShading,
CadCommand::ToggleOrtho,
CadCommand::ViewFront,
CadCommand::ViewRight,
CadCommand::ViewTop,
CadCommand::ViewIsometric,
CadCommand::HideSelected,
CadCommand::IsolateSelected,
CadCommand::ShowAll,
CadCommand::ToggleOutliner,
CadCommand::RenderImage,
CadCommand::Undo,
CadCommand::Redo,
];
/// Subsequence score: `None` when `needle` does not fit into `hay` in order.
/// Higher is better; consecutive runs and word starts score more. Ported verbatim
/// from fab's `ui/command_palette.rs::score`.
pub fn score(hay: &str, needle: &str) -> Option<i32> {
if needle.is_empty() {
return Some(0);
}
let h: Vec<char> = hay.to_lowercase().chars().collect();
let n: Vec<char> = needle.to_lowercase().chars().collect();
let mut hi = 0usize;
let mut total = 0i32;
let mut run = 0i32;
for c in n.iter() {
let mut found = None;
while hi < h.len() {
if h[hi] == *c {
found = Some(hi);
break;
}
hi += 1;
}
let at = found?;
let word_start = at == 0 || h[at - 1] == ' ' || h[at - 1] == ':';
run = if run > 0 { run + 1 } else { 1 };
total += 4 + run * 2 + if word_start { 6 } else { 0 } - (at as i32).min(12);
hi = at + 1;
}
Some(total)
}
/// A ranked filter result: `(index into COMMANDS, score)`.
pub struct Match {
pub cmd: CadCommand,
pub score: i32,
}
/// Return every command whose label subsequence-matches `query`, ranked best-first.
/// Also considers the shortcut string so "IZ" matches "Isolate Selected" (Ctrl+Z).
pub fn filter(query: &str) -> Vec<CadCommand> {
let q = query.trim();
let mut scored: Vec<(i32, usize)> = COMMANDS
.iter()
.enumerate()
.filter_map(|(i, c)| {
let (l, s) = (score(c.label(), q), score(c.shortcut(), q));
l.or(s).map(|sc| (sc, i))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, i)| COMMANDS[i]).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_ranks_frame_all_first_for_fa() {
let hits = filter("fa");
assert_eq!(hits.first(), Some(&CadCommand::FrameAll));
}
#[test]
fn fuzzy_ranks_frame_selected_over_shading_for_fs() {
let hits = filter("fs");
assert_eq!(hits.first(), Some(&CadCommand::FrameSelected));
}
#[test]
fn no_subsequence_means_no_match() {
assert!(filter("zzz").is_empty());
}
#[test]
fn empty_query_returns_everything() {
let hits = filter("");
assert_eq!(hits.len(), COMMANDS.len());
}
#[test]
fn shortcut_hits_count() {
// "cz" -> "Ctrl+Z" (Undo) via the shortcut string, even though the
// label "Undo" has no 'c'/'z' in that order.
let hits = filter("cz");
assert!(hits.contains(&CadCommand::Undo));
}
#[test]
fn shorthand_iso_finds_isolate() {
assert_eq!(filter("iso").first(), Some(&CadCommand::IsolateSelected));
}
#[test]
fn view_top_beats_isolate_for_4() {
// Shortcut "7" -> Top, "9" -> Isometric, "1"/"3" front/right.
assert_eq!(filter("7").first(), Some(&CadCommand::ViewTop));
}
#[test]
fn case_insensitive() {
assert_eq!(filter("FRAME").first(), Some(&CadCommand::FrameAll));
}
#[test]
fn f12_shortcut_matches_render_image() {
assert_eq!(filter("F12").first(), Some(&CadCommand::RenderImage));
// "render" also finds it by label.
assert_eq!(filter("render").first(), Some(&CadCommand::RenderImage));
}
}

View file

@ -621,12 +621,20 @@ impl CommandContext for CadCommandCtx<'_> {
node: crate::construction_frame::pages::workspace::cad::cad_scene::CadNode,
) -> Result<NodeId, CommandError> {
let id = node.id;
self.parts.push(node);
// Node ids must be unique. A duplicate id means the caller already
// pushed the node (the add-part flow pushes into `parts` and then
// runs a CreateNode command). Keeping the first instance avoids
// silently doubling it — two nodes sharing one id would leave a
// ghost at the original grid slot after a move, because move/edit
// resolve only the first match by id.
if !self.parts.iter().any(|p| p.id == id) {
self.parts.push(node);
}
// A new node has no cache entry to invalidate and cannot affect any
// other node's mesh. Invalidating everything here would evict live
// entries to make room for nothing.
self.scene_cache.mark_dirty();
self.invalidate_snapshot();
// A new node has no cache entry to invalidate and cannot affect
// any other node's mesh. The clear that used to be here was
// evicting every live entry to make room for nothing.
Ok(id)
}
@ -2227,6 +2235,32 @@ mod real_context_tests {
}
assert_eq!(store.iter().count(), 0);
}
/// `create_node` must not create a duplicate when a node with the same
/// id is already present (the add-part flow pushes into `parts` and then
/// runs a CreateNode command). Duplicate ids would leave a ghost at the
/// grid slot after a move, because move/edit resolve only the first match.
/// Undo must still remove the single instance cleanly.
#[test]
fn create_node_skips_duplicate_ids() {
let mut store = PartsStore::new();
let cache = SceneCache::new();
store.push(node(5));
let command = CreateNode {
node: node(5),
assigned_id: Some(NodeId(5)),
};
{
let mut ctx = CadCommandCtx::new(&mut store, &cache);
command.execute(&mut ctx).expect("create succeeds");
}
assert_eq!(store.iter().count(), 1, "duplicate id must not be pushed");
{
let mut ctx = CadCommandCtx::new(&mut store, &cache);
command.undo(&mut ctx).expect("undo succeeds");
}
assert_eq!(store.iter().count(), 0);
}
}
#[cfg(test)]

View file

@ -0,0 +1,288 @@
//! `CadDashboard` widget: the project-file grid shown on first launch
//! of the CAD workspace (and from it, before a project is opened).
//!
//! Mirrors the `SpreadsheetDashboard`/`DocDashboard` pattern: a list of
//! saved projects in a grid of cards, a "+ New" button in the header,
//! and click-to-open. The workspace owns the `show_dashboard` flag; on
//! `NewProject`/`OpenProject` it hides itself an loads the editor.
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*;
use crate::cad_store;
use crate::project_store::ProjectRecord;
/// Emitted to the workspace when the dashboard wants to switch views.
#[derive(Clone, Debug)]
pub enum CadAction {
/// Create a new blank project and open it.
NewProject,
/// Open an existing project by id.
OpenProject(String),
/// Return to the dashboard from the editor.
BackToDashboard,
}
#[derive(Script, ScriptHook, Widget)]
pub struct CadDashboard {
#[deref]
view: View,
#[rust]
projects: Vec<ProjectRecord>,
#[rust]
pub action: Option<CadAction>,
#[rust]
initialized: bool,
// --- Draw resources for the project-card grid (manual rendering) ---
#[live]
draw_card_bg: DrawColor,
#[live]
draw_card_text: DrawText,
#[live]
draw_card_sub: DrawText,
#[live]
card_normal_color: Vec4f,
#[live]
card_hover_color: Vec4f,
#[live]
card_text_color: Vec4f,
#[live]
card_sub_color: Vec4f,
/// Hit-test areas for each project card.
#[rust]
card_areas: Vec<(usize, Rect)>,
/// The dashboard's rect, stored during draw_walk for hit-testing.
#[rust]
rect: Rect,
/// Index of the card currently under the cursor.
#[rust]
hover_card: Option<usize>,
}
impl CadDashboard {
pub fn set_dash_visible(&mut self, cx: &mut Cx, visible: bool) {
self.view.set_visible(cx, visible);
}
/// Refresh the project listing from disk.
pub fn refresh_files(&mut self) {
self.projects = cad_store::list_cad_projects();
self.card_areas.clear();
}
/// Called by the workspace when it becomes visible.
pub fn refresh_and_redraw(&mut self, cx: &mut Cx) {
self.projects = cad_store::list_cad_projects();
self.card_areas.clear();
self.view.redraw(cx);
}
/// Draw project cards onto the canvas.
fn draw_cards(&mut self, cx: &mut Cx2d) {
self.card_areas.clear();
let area = self.view.area().rect(cx);
let card_w = 240.0_f64;
let card_h = 100.0_f64;
let margin_x = 16.0_f64;
let margin_y = 80.0_f64;
let spacing_x = 20.0_f64;
let spacing_y = 16.0_f64;
let cols =
((area.size.x - margin_x * 2.0 + spacing_x) / (card_w + spacing_x)).max(1.0) as usize;
let mut col = 0usize;
let mut row = 0usize;
for (i, entry) in self.projects.iter().enumerate() {
let x = area.pos.x + margin_x + col as f64 * (card_w + spacing_x);
let y = area.pos.y + margin_y + row as f64 * (card_h + spacing_y);
let card_rect = Rect {
pos: DVec2 { x, y },
size: DVec2 {
x: card_w,
y: card_h,
},
};
let is_hovered = self.hover_card == Some(i);
self.draw_card_bg.color = if is_hovered {
self.card_hover_color
} else {
self.card_normal_color
};
self.draw_card_bg.draw_abs(cx, card_rect);
self.draw_card_text.color = self.card_text_color;
self.draw_card_text.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 12.0,
},
&entry.name,
);
self.draw_card_sub.color = self.card_sub_color;
self.draw_card_sub.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 40.0,
},
&entry.project_type,
);
self.draw_card_sub.draw_abs(
cx,
DVec2 {
x: card_rect.pos.x + 12.0,
y: card_rect.pos.y + 58.0,
},
&entry.description,
);
self.card_areas.push((i, card_rect));
col += 1;
if col >= cols {
col = 0;
row += 1;
}
}
}
/// Handle clicks on project cards.
fn handle_card_clicks(&mut self, cx: &mut Cx, event: &Event) {
if let Hit::FingerMove(fme) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) {
let pos = fme.abs;
let new_hover = self
.card_areas
.iter()
.find(|(_, rect)| rect.contains(pos))
.map(|(i, _)| *i);
if new_hover != self.hover_card {
self.hover_card = new_hover;
self.view.redraw(cx);
}
}
if let Event::TouchUpdate(tu) = event {
for touch in &tu.touches {
if touch.state == TouchState::Stop {
for &(idx, rect) in &self.card_areas {
if rect.contains(touch.abs) {
let id = self.projects[idx].id.clone();
self.action = Some(CadAction::OpenProject(id));
return;
}
}
}
}
}
if let Hit::FingerUp(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) {
if fe.is_primary_hit() {
for &(idx, rect) in &self.card_areas {
if rect.contains(fe.abs) {
let id = self.projects[idx].id.clone();
self.action = Some(CadAction::OpenProject(id));
return;
}
}
}
}
}
}
impl Widget for CadDashboard {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
if let Event::Actions(actions) = event {
self.handle_actions(cx, actions, scope);
}
self.handle_card_clicks(cx, event);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.initialized {
self.refresh_files();
self.initialized = true;
}
let draw_step = self.view.draw_walk(cx, scope, walk);
self.rect = self.view.area().rect(cx);
if !self.projects.is_empty() {
self.draw_cards(cx);
}
draw_step
}
}
impl WidgetMatchEvent for CadDashboard {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
if self.button(cx, ids!(new_project_btn)).clicked(actions) {
self.action = Some(CadAction::NewProject);
self.view.redraw(cx);
}
if self.button(cx, ids!(refresh_btn)).clicked(actions) {
self.refresh_and_redraw(cx);
}
}
}
script_mod! {
use mod.prelude.widgets.*
mod.widgets.CadDashboard = #(CadDashboard::register_widget(vm)) {
width: Fill, height: Fill, flow: Down
draw_bg +: { color: #x0a0f14 }
dashboard_header := View {
width: Fill, height: 60.0, flow: Right
padding: Inset{left: 20.0, right: 20.0, top: 0, bottom: 0}, spacing: 12.0, align: Align{y: 0.5}
draw_bg +: { color: #x111820 }
dashboard_title := Label {
text: "CAD Projects"
draw_text +: { color: #xf3f6f8, text_style: theme.font_bold { font_size: 18.0 } }
}
spacer := View { width: Fill }
refresh_btn := Button {
text: "Refresh",
width: 84.0, height: 32.0
draw_bg +: { color: #x213040 }
draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } }
}
new_project_btn := Button {
text: "+ New Project",
width: 112.0, height: 32.0
draw_bg +: { color: #x238636 }
draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } }
}
}
cards_container := View {
width: Fill, height: Fill
}
// Manual draw resources for project cards
draw_card_bg +: { draw_depth: 0.1 }
draw_card_text +: { draw_depth: 0.3 color: #xf3f6f8 text_style: theme.font_bold { font_size: 14.0 } }
draw_card_sub +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_regular { font_size: 11.0 } }
card_normal_color: #x171d24
card_hover_color: #x22303c
card_text_color: #xf3f6f8
card_sub_color: #x8a8aa5
}
}

View file

@ -0,0 +1,98 @@
//! Pure drag-to-edit math for numeric fields, ported from fab's
//! `header_drag_math`. Kept free of makepad/widget types so it can be
//! unit-tested in isolation.
//!
//! The model: a pointer drag in *pixels* maps to a *count* of steps, and the
//! value is the anchor plus that many `step` multiples. Holding the fine
//! modifier (Ctrl) makes each pixel move a fraction of a step; the normal
//! modifier (the absence of fine) moves whole steps per pixel-equivalent.
/// Map a raw pixel drag onto a value given an anchor, a pixel-per-step
/// sensitivity and a step (unit increment), optionally in fine/ctrl mode.
///
/// * `anchor` — the starting value before the drag.
/// * `pixels` — total pointer travel in *drag pixels* since the anchor was
/// captured (positive = right/down, negative = left/up).
/// * `px_per_step` — how many drag pixels map to one step.
/// * `step` — the unit increment applied per step.
/// * `fine` — Ctrl held: keep fractional steps (continuous); otherwise snap to
/// whole steps for a grabbier, stepped feel.
pub fn header_drag_math(
anchor: f64,
pixels: f64,
px_per_step: f64,
step: f64,
fine: bool,
) -> f64 {
let pps = if px_per_step.abs() > 1e-9 {
px_per_step
} else {
1.0
};
let st = if step.abs() > 1e-9 { step } else { 1.0 };
let raw_steps = pixels / pps;
let steps = if fine { raw_steps } else { raw_steps.round() };
anchor + steps * st
}
/// Bind the value to a step grid (used when the field snaps while dragging).
pub fn snap_to_step(value: f64, step: f64) -> f64 {
if step.abs() < 1e-9 {
return value;
}
(value / step).round() * step
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_drag_returns_anchor() {
assert_eq!(header_drag_math(10.0, 0.0, 10.0, 1.0, false), 10.0);
}
#[test]
fn whole_steps_move_by_step() {
// 20 px at 10 px/unit = 2 units, step 1 → +2.
assert_eq!(header_drag_math(10.0, 20.0, 10.0, 1.0, false), 12.0);
}
#[test]
fn negative_drag_decreases() {
assert_eq!(header_drag_math(10.0, -20.0, 10.0, 1.0, false), 8.0);
}
#[test]
fn fine_mode_keeps_fractional_steps() {
// 4 px at 10 px/step = 0.4 steps. Normal snaps to 0; fine keeps 0.4.
let f = header_drag_math(0.0, 4.0, 10.0, 1.0, true);
assert!((f - 0.4).abs() < 1e-9, "fine delta was {f}");
let n = header_drag_math(0.0, 4.0, 10.0, 1.0, false);
assert_eq!(n, 0.0);
}
#[test]
fn normal_mode_snaps_to_whole_steps() {
// 25 px at 10 px/step = 2.5 steps → snaps to 3 whole steps.
assert_eq!(header_drag_math(0.0, 25.0, 10.0, 1.0, false), 3.0);
}
#[test]
fn step_scales_delta() {
// step 2 → 2 whole steps × 2 = +4.
assert_eq!(header_drag_math(0.0, 20.0, 10.0, 2.0, false), 4.0);
}
#[test]
fn degenerate_px_per_unit_does_not_crash() {
assert!(header_drag_math(5.0, 3.0, 0.0, 1.0, false).is_finite());
}
#[test]
fn snap_to_step_rounds() {
assert_eq!(snap_to_step(10.6, 1.0), 11.0);
assert_eq!(snap_to_step(10.3, 1.0), 10.0);
assert_eq!(snap_to_step(10.0, 0.0), 10.0);
}
}

View file

@ -0,0 +1,110 @@
//! Explode view: push parts radially apart so an assembled model reads as
//! discrete elements.
//!
//! Our parts have no storey grouping by default, so we support the
//! **by-element** mode: every part fans out in the ground (XZ) plane, keyed
//! by its document index, by `amount` per index step. Element 0 stays put.
//! Pure logic with no makepad types so it is unit-testable.
/// How the explode spreads parts. Only by-element is supported today.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplodeMode {
/// Radial fan-out in the ground plane, one element per part index.
ByElement,
}
/// Aggregated explode controls held on the viewport.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ExplodeState {
/// How far each index step fans out, in world units. `0.0` disables.
pub amount: f64,
}
impl Default for ExplodeState {
fn default() -> Self {
ExplodeState { amount: 0.0 }
}
}
/// The golden-angle (radians) used to distribute elements so no two radial
/// spokes coincide; ~137.508°.
const GOLDEN_ANGLE: f64 = 2.399963229728653;
/// Explode displacement for the part at `id_idx` (its document order).
///
/// Element 0 and any `amount <= 0` return a zero displacement. Each later
/// element fans out `amount * id_idx` along a direction derived from its
/// index (golden-angle), so elements spread evenly around the ground plane
/// without overlapping. `centre` is accepted for signature compatibility
/// with fab's radial rule; for by-element fan-out the direction is purely
/// index-derived, so the pivot is fixed at the origin.
pub fn element_offset(id_idx: usize, _centre: (f64, f64, f64), amount: f64) -> (f64, f64, f64) {
if amount <= 0.0 || id_idx == 0 {
return (0.0, 0.0, 0.0);
}
let angle = id_idx as f64 * GOLDEN_ANGLE;
let r = amount * id_idx as f64;
(angle.cos() * r, 0.0, angle.sin() * r)
}
/// Helper used by the viewport: turn a document row index into a tripled
/// displacement the caller adds to the part's translation. Returns the
/// golden-angle fan-out for `state`.
pub fn displacement_for(id_idx: usize, state: &ExplodeState) -> (f64, f64, f64) {
element_offset(id_idx, (0.0, 0.0, 0.0), state.amount)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn element_zero_stays_put() {
let c = (5.0, 5.0, 5.0);
assert_eq!(element_offset(0, c, 3.0), (0.0, 0.0, 0.0));
}
#[test]
fn zero_amount_disables() {
let c = (0.0, 0.0, 0.0);
assert_eq!(element_offset(4, c, 0.0), (0.0, 0.0, 0.0));
assert_eq!(displacement_for(4, &ExplodeState { amount: 0.0 }), (0.0, 0.0, 0.0));
}
#[test]
fn offset_magnitude_scales_with_index() {
let c = (0.0, 0.0, 0.0);
for i in 1..5 {
let (dx, dy, dz) = element_offset(i, c, 2.0);
let mag = (dx * dx + dz * dz).sqrt();
assert!((mag - 2.0 * i as f64).abs() < 1e-9, "element {i} mag {mag}");
}
}
#[test]
fn radial_directions_differ() {
let c = (0.0, 0.0, 0.0);
let a = element_offset(1, c, 1.0);
let b = element_offset(2, c, 1.0);
let (ax, _, az) = a;
let (bx, _, bz) = b;
let am = (ax * ax + az * az).sqrt();
let bm = (bx * bx + bz * bz).sqrt();
// Normalise so the dot product is the cosine of the angle between
// the two spokes, not scaled by the per-element radii.
let dot = (ax / am) * (bx / bm) + (az / am) * (bz / bm);
assert!(dot.abs() < 1.0 - 1e-6);
assert_ne!(a, b);
}
#[test]
fn displacement_stays_in_ground_plane() {
let c = (0.0, 0.0, 0.0);
assert_eq!(element_offset(3, c, 4.0).1, 0.0);
}
#[test]
fn default_state_offs() {
assert_eq!(ExplodeState::default().amount, 0.0);
}
}

View file

@ -0,0 +1,195 @@
//! Phase A(4) — F1 keymap help: the single source of truth for every keyboard
//! shortcut in the CAD workspace. The keymap is a closed, well-formed table;
//! the F1 help panel renders straight from `BINDINGS`, so the table can never
//! drift from what the panel shows.
//!
//! The pure logic (group/format/validate) lives here and is unit-tested; the
//! overlay wiring in `mod.rs`/`workspace.rs` opens the panel and fills a label
//! from `render_groups()`.
/// One shortcut row. `keys` is the display chord (e.g. "Cmd+K", "Alt+H"),
/// `action` is what it does, and `group` buckets rows for the help panel.
pub struct KeyBinding {
/// Human-readable key chord, e.g. `"Cmd+K"`.
pub keys: &'static str,
/// What the shortcut does, e.g. `"Hide selected parts"`.
pub action: &'static str,
/// Section header this row belongs under in the help panel.
pub group: &'static str,
}
/// Group headers, in display order. Rows whose `group` is not listed here are
/// still rendered, appended after every known group in table order.
pub const GROUPS: &[&'static str] = &[
"Tools",
"Select & Visibility",
"Camera",
"Display",
"Edit",
"Render & UI",
];
/// The authoritative shortcut table. Adding/removing a shortcut here updates
/// the F1 help panel automatically — there is no second copy to keep in sync.
pub const BINDINGS: &[KeyBinding] = &[
// --- Tools (no modifier) ---
KeyBinding { keys: "V", action: "Select tool", group: "Tools" },
KeyBinding { keys: "L", action: "Line tool", group: "Tools" },
KeyBinding { keys: "R", action: "Rect tool", group: "Tools" },
KeyBinding { keys: "C", action: "Circle tool", group: "Tools" },
KeyBinding { keys: "P", action: "Polyline tool", group: "Tools" },
KeyBinding { keys: "W", action: "Wall tool", group: "Tools" },
KeyBinding { keys: "O", action: "Column tool", group: "Tools" },
KeyBinding { keys: "B", action: "Beam tool", group: "Tools" },
KeyBinding { keys: "A", action: "Arc tool", group: "Tools" },
KeyBinding { keys: "E", action: "Area tool", group: "Tools" },
KeyBinding { keys: "Q", action: "Quad tool", group: "Tools" },
KeyBinding { keys: "Y", action: "Polygon tool", group: "Tools" },
KeyBinding { keys: "T", action: "Tri-plane tool", group: "Tools" },
KeyBinding { keys: "U", action: "Extend tool", group: "Tools" },
KeyBinding { keys: "H", action: "Chamfer tool", group: "Tools" },
KeyBinding { keys: "M", action: "Measure tool", group: "Tools" },
// --- Select & Visibility ---
KeyBinding { keys: "Cmd+K", action: "Hide selected parts", group: "Select & Visibility" },
KeyBinding { keys: "Cmd+Shift+K", action: "Show all parts", group: "Select & Visibility" },
KeyBinding { keys: "I", action: "Isolate selected parts", group: "Select & Visibility" },
KeyBinding { keys: "Alt+H", action: "Hide/unhide all parts", group: "Select & Visibility" },
// --- Camera ---
KeyBinding { keys: "F", action: "Frame all (zoom to fit)", group: "Camera" },
KeyBinding { keys: "F5", action: "Toggle orthographic", group: "Camera" },
KeyBinding { keys: "Alt+1", action: "View front", group: "Camera" },
KeyBinding { keys: "Alt+2", action: "View back", group: "Camera" },
KeyBinding { keys: "Alt+3", action: "View left", group: "Camera" },
KeyBinding { keys: "Alt+4", action: "View right", group: "Camera" },
KeyBinding { keys: "Alt+6", action: "View top", group: "Camera" },
KeyBinding { keys: "Alt+7", action: "View bottom", group: "Camera" },
KeyBinding { keys: "Alt+8", action: "View isometric", group: "Camera" },
// --- Display ---
KeyBinding { keys: "Alt+Z", action: "Toggle X-ray silhouette", group: "Display" },
// --- Edit ---
KeyBinding { keys: "Cmd+Z", action: "Undo", group: "Edit" },
KeyBinding { keys: "Cmd+Shift+Z", action: "Redo", group: "Edit" },
KeyBinding { keys: "Cmd+C", action: "Copy selection", group: "Edit" },
KeyBinding { keys: "Cmd+V", action: "Paste", group: "Edit" },
KeyBinding { keys: "Cmd+D", action: "Duplicate selection", group: "Edit" },
KeyBinding { keys: "Cmd+A", action: "Select all", group: "Edit" },
KeyBinding { keys: "Cmd+G", action: "Group selection", group: "Edit" },
KeyBinding { keys: "Cmd+Shift+G", action: "Ungroup selection", group: "Edit" },
// --- Render & UI ---
KeyBinding { keys: "F12", action: "Render high-res PNG", group: "Render & UI" },
KeyBinding { keys: "Cmd+P", action: "Command palette", group: "Render & UI" },
KeyBinding { keys: "F1", action: "Show this keymap help", group: "Render & UI" },
];
/// Render the full grouped help text for the F1 panel, one line per row with
/// the key chord padded so the actions align. Groups render in `GROUPS` order;
/// any row whose group is unknown is appended after every named group.
pub fn render_groups() -> String {
let mut out = String::new();
let width = BINDINGS.iter().map(|b| b.keys.len()).max().unwrap_or(0);
let mut seen: Vec<&'static str> = Vec::new();
for &group in GROUPS {
write_group(&mut out, group, width, &mut seen);
}
// Any group not named in GROUPS (e.g. future additions) still shows.
let mut extra: Vec<&'static str> = BINDINGS
.iter()
.map(|b| b.group)
.filter(|g| !GROUPS.contains(g))
.collect();
extra.dedup();
for group in extra {
write_group(&mut out, group, width, &mut seen);
}
out
}
fn write_group(out: &mut String, group: &'static str, width: usize, seen: &mut Vec<&'static str>) {
if seen.contains(&group) {
return;
}
seen.push(group);
out.push_str(&format!("—— {} ——\n", group));
for b in BINDINGS {
if b.group == group {
out.push_str(&format!(" {:<width$} {}\n", b.keys, b.action, width = width));
}
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
/// The table must never be empty — the whole point of the module is a
/// non-empty source of truth for the F1 panel.
#[test]
fn table_is_non_empty() {
assert!(!BINDINGS.is_empty(), "keymap table must not be empty");
}
/// Every row must carry a key chord, an action and a group.
#[test]
fn every_row_is_complete() {
for b in BINDINGS {
assert!(!b.keys.is_empty(), "binding has empty keys");
assert!(!b.action.is_empty(), "binding {:?} has empty action", b.keys);
assert!(!b.group.is_empty(), "binding {:?} has empty group", b.keys);
}
}
/// Duplicate key chords would silently shadow one another; the help panel
/// must never advertise two rows for the same chord.
#[test]
fn no_duplicate_key_chords() {
let mut keys: Vec<&str> = BINDINGS.iter().map(|b| b.keys).collect();
keys.sort_unstable();
for pair in keys.windows(2) {
assert_ne!(pair[0], pair[1], "duplicate key chord {:?}", pair[0]);
}
}
/// `render_groups` must mention every binding exactly once, so the panel
/// always matches the table. Reconstruct each row with the same width
/// padding `render_groups` applies, so the match is exact (no binding can
/// accidentally match another binding's line as a substring).
#[test]
fn render_covers_every_binding_once() {
let text = render_groups();
let width = BINDINGS.iter().map(|b| b.keys.len()).max().unwrap_or(0);
for b in BINDINGS {
let needle = format!(" {:<width$} {}\n", b.keys, b.action, width = width);
let count = text.matches(&needle).count();
assert_eq!(count, 1, "row for {:?} appears {} times", b.keys, count);
}
}
/// The known command hotkeys must all be present in the table, so F1 and
/// the command palette (which advertises `shortcut()` strings) agree.
#[test]
fn known_hotkeys_are_present() {
for key in ["Cmd+K", "Cmd+Shift+K", "I", "Alt+H", "F5", "Alt+Z", "F12", "Cmd+P", "F1"] {
let ok = BINDINGS.iter().any(|b| b.keys == key);
assert!(ok, "expected {:?} to be a documented shortcut", key);
}
}
/// Groups render in order and each header appears exactly once.
#[test]
fn group_headers_render_once_in_order() {
let text = render_groups();
let headers: Vec<String> = GROUPS.iter().map(|g| format!("—— {} ——", g)).collect();
let mut last = 0usize;
for h in &headers {
let pos = text.find(h.as_str()).unwrap_or_else(|| {
panic!("group header {} missing from render", h)
});
assert!(pos >= last, "group header {} out of order", h);
last = pos;
}
for h in &headers {
assert_eq!(text.matches(h.as_str()).count(), 1, "header {} duplicated", h);
}
}
}

View file

@ -0,0 +1,368 @@
//! Measurement tool — distance, area, angle.
//!
//! Ported from fab's `tools/measure.rs`. Pure math over world-space points;
//! no rendering dependencies. The overlay drawing and status-bar hints live
//! in `viewport_render.rs` and `tools.rs` respectively.
use super::math::{DVec3, vec3_cross, vec3_dot};
// ─── Types ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeasureKind {
Distance,
Angle,
Area,
}
impl MeasureKind {
pub fn needed_points(self) -> usize {
match self {
Self::Distance => 2,
Self::Angle => 3,
Self::Area => usize::MAX, // open-ended, commits on close
}
}
pub fn label(self) -> &'static str {
match self {
Self::Distance => "Distance",
Self::Angle => "Angle",
Self::Area => "Area",
}
}
}
#[derive(Debug, Clone)]
pub struct Measurement {
pub kind: MeasureKind,
pub points: Vec<DVec3>,
pub value: f64,
pub label: String,
}
/// Real, heap-backable measurement state. Wrapped in a `RefCell`
/// newtype (`MeasureState` in `mod.rs`) so the `#[rust]` derive macro
/// accepts it as a field -- just like `PickBvhCache`.
#[derive(Clone, Debug, Default)]
pub struct MeasureInner {
/// 0 = Distance, 1 = Angle, 2 = Area.
pub kind: u32,
/// Stacked world points: point i lives at
/// `(pts_x[i], pts_y[i], pts_z[i])`.
pub pts_x: Vec<f64>,
pub pts_y: Vec<f64>,
pub pts_z: Vec<f64>,
/// Whether a measurement has been committed / is final.
pub done: bool,
/// Human-readable results ("5.00 m", "90.0°", "12.00 m²").
pub completed: Vec<String>,
}
impl MeasureInner {
pub fn point(&self, i: usize) -> DVec3 {
DVec3 {
x: self.pts_x[i],
y: self.pts_y[i],
z: self.pts_z[i],
}
}
pub fn points(&self) -> Vec<DVec3> {
(0..self.pts_x.len()).map(|i| self.point(i)).collect()
}
pub fn push_point(&mut self, p: DVec3) {
self.pts_x.push(p.x);
self.pts_y.push(p.y);
self.pts_z.push(p.z);
}
pub fn clear_points(&mut self) {
self.pts_x.clear();
self.pts_y.clear();
self.pts_z.clear();
}
pub fn len(&self) -> usize {
self.pts_x.len()
}
}
// ─── Pure math ──────────────────────────────────────────────────────────
/// Straight-line distance in meters.
pub fn distance(a: DVec3, b: DVec3) -> f64 {
(b - a).length()
}
/// Area of a planar polygon via Newell's method (m²). Works for any orientation.
pub fn polygon_area(points: &[DVec3]) -> f64 {
if points.len() < 3 {
return 0.0;
}
let mut n = DVec3::default();
for i in 0..points.len() {
let a = points[i];
let b = points[(i + 1) % points.len()];
n = n + vec3_cross(a, b);
}
n.length() * 0.5
}
/// Angle at `vertex` between rays vertex→a and vertex→b, in degrees.
pub fn angle_deg(a: DVec3, vertex: DVec3, b: DVec3) -> f64 {
let u = (a - vertex).normalize();
let v = (b - vertex).normalize();
vec3_dot(u, v).clamp(-1.0, 1.0).acos().to_degrees()
}
/// How far a loop strays from its best-fit plane, in meters.
///
/// For a non-planar loop, `polygon_area` reports the area of the projection
/// onto the best-fit plane without saying so. We measure the deviation and
/// flag it (`~` prefix) rather than quoting a number that is not the area of
/// anything.
pub fn planarity(points: &[DVec3]) -> f64 {
if points.len() < 4 {
return 0.0;
}
let mut n = DVec3::default();
let mut c = DVec3::default();
for i in 0..points.len() {
let a = points[i];
let b = points[(i + 1) % points.len()];
n = n + vec3_cross(a, b);
c = c + a;
}
let len = n.length();
if len < 1e-9 {
return 0.0;
}
let n = n / len;
let c = c / points.len() as f64;
points
.iter()
.map(|p| vec3_dot(*p - c, n).abs())
.fold(0.0f64, f64::max)
}
/// Loops flatter than this count as planar (1 mm).
pub const PLANAR_TOLERANCE: f64 = 0.001;
// ─── Formatting ─────────────────────────────────────────────────────────
/// Format a length value in meters with the given decimal places.
pub fn format_length(meters: f64, decimals: usize) -> String {
if meters >= 1.0 {
format!("{:.prec$} m", meters, prec = decimals)
} else {
format!("{:.0} mm", meters * 1000.0)
}
}
/// Format an area value in square meters.
pub fn format_area(sq_meters: f64, decimals: usize) -> String {
if sq_meters >= 1.0 {
format!("{:.prec$}", sq_meters, prec = decimals)
} else {
format!("{:.0} cm²", sq_meters * 10_000.0)
}
}
/// Format an angle value in degrees.
pub fn format_angle(degrees: f64, decimals: usize) -> String {
format!("{:.prec$}°", degrees, prec = decimals)
}
// ─── Commit ─────────────────────────────────────────────────────────────
/// Compute the measurement value and format a label for a finished point set.
pub fn commit(kind: MeasureKind, points: &[DVec3], decimals: usize) -> Option<Measurement> {
let min = match kind {
MeasureKind::Distance => 2,
MeasureKind::Angle => 3,
MeasureKind::Area => 3,
};
if points.len() < min {
return None;
}
let value = value_of(kind, points);
let mut label = format_value(kind, value, decimals);
if kind == MeasureKind::Area && planarity(points) > PLANAR_TOLERANCE {
label = format!("~{label}");
}
Some(Measurement {
kind,
points: points.to_vec(),
value,
label,
})
}
/// Compute the raw numeric value for a set of measurement points.
pub fn value_of(kind: MeasureKind, points: &[DVec3]) -> f64 {
match kind {
MeasureKind::Distance => {
if points.len() < 2 {
0.0
} else {
distance(points[0], points[1])
}
}
MeasureKind::Angle => {
if points.len() < 3 {
0.0
} else {
// A → corner → B: the angle is at the middle point.
angle_deg(points[0], points[1], points[2])
}
}
MeasureKind::Area => polygon_area(points),
}
}
/// Format a measurement value using the appropriate unit.
pub fn format_value(kind: MeasureKind, value: f64, decimals: usize) -> String {
match kind {
MeasureKind::Distance => format_length(value, decimals),
MeasureKind::Area => format_area(value, decimals),
MeasureKind::Angle => format_angle(value, decimals),
}
}
// ─── Hints ──────────────────────────────────────────────────────────────
/// Status-bar hint for the measure tool.
pub fn hint(kind: MeasureKind) -> &'static str {
match kind {
MeasureKind::Distance => "Click two points to measure distance · Esc Cancel",
MeasureKind::Angle => "Click A → corner → B to measure angle · Esc Cancel",
MeasureKind::Area => "Click points to outline area · Enter Close loop · Esc Cancel",
}
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> DVec3 {
DVec3 { x, y, z }
}
#[test]
fn distance_zero() {
let a = v(1.0, 2.0, 3.0);
assert!(distance(a, a) < 1e-12);
}
#[test]
fn distance_unit() {
assert!((distance(v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)) - 1.0).abs() < 1e-12);
assert!((distance(v(0.0, 0.0, 0.0), v(0.0, 3.0, 4.0)) - 5.0).abs() < 1e-12);
}
#[test]
fn polygon_area_square() {
let square = [v(0.0, 0.0, 0.0), v(3.0, 0.0, 0.0), v(3.0, 4.0, 0.0), v(0.0, 4.0, 0.0)];
assert!((polygon_area(&square) - 12.0).abs() < 1e-6);
}
#[test]
fn polygon_area_triangle() {
let tri = [v(0.0, 0.0, 0.0), v(4.0, 0.0, 0.0), v(0.0, 3.0, 0.0)];
assert!((polygon_area(&tri) - 6.0).abs() < 1e-6);
}
#[test]
fn polygon_area_degenerate() {
assert!(polygon_area(&[v(0.0, 0.0, 0.0)]) < 1e-12);
assert!(polygon_area(&[]) < 1e-12);
}
#[test]
fn angle_right() {
let angle = angle_deg(v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(0.0, 1.0, 0.0));
assert!((angle - 90.0).abs() < 1e-4);
}
#[test]
fn angle_straight() {
let angle = angle_deg(v(-1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0));
assert!((angle - 180.0).abs() < 1e-4);
}
#[test]
fn angle_45() {
let angle = angle_deg(v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(1.0, 1.0, 0.0));
assert!((angle - 45.0).abs() < 1e-4);
}
#[test]
fn planarity_flat() {
let flat = [v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0)];
assert!(planarity(&flat) < 1e-12);
}
#[test]
fn planarity_bent() {
let bent = [
v(0.0, 0.0, 0.0),
v(1.0, 0.0, 0.0),
v(1.0, 0.0, 0.5),
v(0.0, 1.0, 0.0),
];
assert!(planarity(&bent) > 0.01);
}
#[test]
fn commit_distance() {
let pts = vec![v(0.0, 0.0, 0.0), v(3.0, 4.0, 0.0)];
let m = commit(MeasureKind::Distance, &pts, 2).unwrap();
assert!((m.value - 5.0).abs() < 1e-6);
assert!(m.label.contains("5"));
}
#[test]
fn commit_angle() {
let pts = vec![v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(0.0, 1.0, 0.0)];
let m = commit(MeasureKind::Angle, &pts, 1).unwrap();
assert!((m.value - 90.0).abs() < 1e-4);
}
#[test]
fn commit_area() {
let pts = vec![v(0.0, 0.0, 0.0), v(3.0, 0.0, 0.0), v(3.0, 4.0, 0.0), v(0.0, 4.0, 0.0)];
let m = commit(MeasureKind::Area, &pts, 2).unwrap();
assert!((m.value - 12.0).abs() < 1e-6);
}
#[test]
fn format_length_meters() {
assert_eq!(format_length(5.5, 2), "5.50 m");
}
#[test]
fn format_length_millimeters() {
assert_eq!(format_length(0.012, 2), "12 mm");
}
#[test]
fn format_area_value() {
assert_eq!(format_area(12.5, 1), "12.5 m²");
}
#[test]
fn format_angle_value() {
assert_eq!(format_angle(90.0, 1), "90.0°");
}
#[test]
fn needed_points() {
assert_eq!(MeasureKind::Distance.needed_points(), 2);
assert_eq!(MeasureKind::Angle.needed_points(), 3);
assert_eq!(MeasureKind::Area.needed_points(), usize::MAX);
}
}

View file

@ -25,7 +25,6 @@ use makepad_code_editor::{
CodeDocument, CodeEditor, CodeSession,
};
use makepad_draw::DrawVector;
use makepad_widgets::adaptive_view::AdaptiveView;
use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::makepad_platform::{makepad_script::ScriptVmBase, thread::SignalToUI};
use makepad_widgets::*;
@ -51,6 +50,21 @@ pub mod arch_svg;
// the grouping is tested even though the GPU submission cannot be.
// Phase 3 of the render plan.
pub mod batching;
pub mod bvh;
pub mod camera_orbit;
pub mod command_palette;
pub mod dashboard;
pub mod drag_num;
pub mod explode;
pub mod keymap;
pub mod measure;
pub mod outliner;
pub mod properties;
pub mod render_export;
pub mod script_parts;
pub mod section;
pub mod sun;
pub mod snap;
// pub mod cost_estimator;
pub mod cad_editor_sheet;
// cad_scene: immutable scene graph + Exporter trait + SceneVisitor + MeshCache.
@ -167,6 +181,9 @@ pub struct DrawCadMesh {
light_dir: Vec3f,
#[rust(vec3(0.62, 0.42, -0.58))]
fill_dir: Vec3f,
/// X-ray silhouette toggle (flat blue tint across the whole mesh).
#[rust(0.0f32)]
xray: f32,
/// Open instanced batch, if one is running. Phase 3 of the render
/// plan.
///
@ -389,6 +406,7 @@ script_mod! {
v_world: varying(vec3f)
v_normal: varying(vec3f)
display_mode: 4.0
xray: uniform(float, 0.0)
active_camera_world_pos: fn() -> vec3f {
let camera_world = self.draw_pass.camera_inv * vec4(0.0, 0.0, 0.0, 1.0)
@ -433,6 +451,14 @@ script_mod! {
let fill = abs(dot(normal, normalize(self.u_fill_dir)))
let rim = pow(max(1.0 - abs(dot(normal, view_dir)), 0.0), 2.5)
if self.xray > 0.5 {
// X-ray silhouette: a flat translucent-blue tint across the
// whole mesh so interior geometry reads through as a blue
// technical overlay. The batch stays opaque (alpha_blend is
// off) so this is a colour mode, not a depth hack.
return vec4(vec3(0.22, 0.50, 0.95), 1.0)
}
if self.display_mode < 0.5 {
// Wireframe: filled surfaces are skipped in Rust draw_scene();
// this fallback stays very dark if a mesh accidentally reaches here.
@ -517,17 +543,24 @@ script_mod! {
mod.widgets.CadWorkspaceBase = #(CadWorkspace::register_widget(vm))
mod.widgets.CadWorkspace = set_type_default() do mod.widgets.CadWorkspaceBase{
width: Fill, height: Fill
flow: Overlay
// =============== Desktop variant (wide screens) ===============
// Layout: header at top, then an Overlay area where:
// - cad_viewport fills the entire area
// - viewport_toolbar floats over the top-left of the viewport
// - bottom_overlay floats over the bottom: script editor on the left,
// AI prompt panel on the right
// Toggle the bottom_overlay via toggle_editor_btn in the header.
Desktop := View {
// =============== Editor variants (Desktop/Mobile) ===============
// The responsive Desktop/Mobile layouts live inside a nested
// AdaptiveView so the whole set can be covered by (or replaced by)
// the project dashboard overlay drawn on top when `show_dashboard`.
editor_variant := mod.widgets.AdaptiveView {
width: Fill, height: Fill
flow: Down
// =============== Desktop variant (wide screens) ===============
// Layout: header at top, then an Overlay area where:
// - cad_viewport fills the entire area
// - viewport_toolbar floats over the top-left of the viewport
// - bottom_overlay floats over the bottom: script editor on the left,
// AI prompt panel on the right
// Toggle the bottom_overlay via toggle_editor_btn in the header.
Desktop := View {
width: Fill, height: Fill
flow: Down
workspace_header := SolidView {
width: Fill; height: Fit
@ -556,6 +589,10 @@ script_mod! {
draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 6.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 10.0} }
}
back_to_dash_btn := Button { width: 64; height: 24; text: "Projects"
draw_bg +: { color: #x374151; color_hover: #x4b5563; color_down: #x6b7280; border_radius: 6.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.5} }
}
workspace_split_toggle_btn := Button { width: 92; height: 24; text: "Split 2D/3D"
draw_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 6.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} }
@ -1286,6 +1323,10 @@ script_mod! {
draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 5.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} }
}
back_to_dash_btn := Button { width: 56; height: 22; text: "Project"
draw_bg +: { color: #x374151; color_hover: #x4b5563; color_down: #x6b7280; border_radius: 5.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} }
}
workspace_split_toggle_btn := Button { width: 66; height: 22; text: "Split"
draw_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 5.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} }
@ -1482,6 +1523,8 @@ script_mod! {
zoom_in_button := Button{ width: 28.0 text: "+" }
zoom_out_button := Button{ width: 28.0 text: "-" }
fit_button := Button{ width: 30.0 text: "Fit" }
outliner_toggle_btn := Button{ width: 34.0 text: "List" draw_text +: { text_style +: { font_size: 8.0 } } }
palette_toggle_btn := Button{ width: 34.0 text: "Cmd" draw_text +: { text_style +: { font_size: 8.0 } } }
}
row3 := View {
@ -1510,6 +1553,191 @@ script_mod! {
}
}
// === Outliner panel: floats over the viewport, toggled from row2 ===
outliner_panel := View {
width: Fill
height: Fill
flow: Overlay
visible: false
show_bg: true
new_batch: true
draw_bg +: { color: #x0d1218 }
View {
width: Fill
height: Fill
flow: Down
align: Align{x: 0.0 y: 0.0}
outliner_header := View {
width: Fill; height: 26.0
flow: Right; spacing: 4.0
padding: Inset{left: 8.0 top: 4.0 right: 8.0 bottom: 4.0}
show_bg: true
draw_bg +: { color: #x171d24 }
Label { width: Fill; height: Fit; text: "Outliner" draw_text +: { color: #x9aa8b5 text_style +: { font_size: 10.0 } } }
outliner_sel_prev_btn := Button{ width: 30.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_sel_next_btn := Button{ width: 30.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_toggle_vis_btn := Button{ width: 46.0 text: "Hide" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_close_btn := Button{ width: 30.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
}
outliner_search_row := View {
width: Fill; height: 26.0
flow: Right; spacing: 4.0
padding: Inset{left: 8.0 top: 2.0 right: 8.0 bottom: 2.0}
show_bg: true
draw_bg +: { color: #x141a21 }
outliner_search_input := TextInput {
width: Fill; height: Fill
text: ""
empty_message: "Search name/kind…"
draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } }
}
outliner_count_label := Label {
width: Fit; height: Fit
text: "0/0"
draw_text +: { color: #x9aa8b5 text_style +: { font_size: 9.0 } }
}
outliner_kind_btn := Button{ width: 44.0 text: "Kind" draw_text +: { text_style +: { font_size: 8.0 } } }
}
View {
width: Fill
height: 4.0
}
outliner_text_view := View {
width: Fill
height: Fill
outliner_text_label := Label {
width: Fill
height: Fit
text: ""
draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } }
}
}
View {
width: Fill
height: 4.0
}
outliner_actions := View {
width: Fill; height: Fit
flow: Right; spacing: 4.0
padding: Inset{left: 8.0 top: 0.0 right: 8.0 bottom: 6.0}
outliner_hide_all_btn := Button{ width: 72.0 text: "Hide all" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_show_all_btn := Button{ width: 78.0 text: "Show all" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_isolate_btn := Button{ width: 66.0 text: "Isolate" draw_text +: { text_style +: { font_size: 8.0 } } }
outliner_info_btn := Button{ width: 48.0 text: "Info" draw_text +: { text_style +: { font_size: 8.0 } } }
}
section_controls := View {
width: Fill; height: Fit
flow: Right; spacing: 4.0
padding: Inset{left: 8.0 top: 0.0 right: 8.0 bottom: 6.0}
section_x_btn := Button{ width: 44.0 text: "Sec X" draw_text +: { text_style +: { font_size: 8.0 } } }
section_y_btn := Button{ width: 44.0 text: "Sec Y" draw_text +: { text_style +: { font_size: 8.0 } } }
section_z_btn := Button{ width: 44.0 text: "Sec Z" draw_text +: { text_style +: { font_size: 8.0 } } }
section_clear_btn := Button{ width: 60.0 text: "Clear" draw_text +: { text_style +: { font_size: 8.0 } } }
explode_minus_btn := Button{ width: 42.0 text: "Ex-" draw_text +: { text_style +: { font_size: 8.0 } } }
explode_plus_btn := Button{ width: 42.0 text: "Ex+" draw_text +: { text_style +: { font_size: 8.0 } } }
sun_toggle_btn := Button{ width: 52.0 text: "Sun" draw_text +: { text_style +: { font_size: 8.0 } } }
sun_hour_down_btn := Button{ width: 30.0 text: "-h" draw_text +: { text_style +: { font_size: 8.0 } } }
sun_hour_up_btn := Button{ width: 30.0 text: "+h" draw_text +: { text_style +: { font_size: 8.0 } } }
xray_btn := Button{ width: 50.0 text: "X-Ray" draw_text +: { text_style +: { font_size: 8.0 } } }
}
}
}
// === Command palette: floats over the viewport, fuzzy search over commands ===
palette_panel := View {
width: Fill
height: Fill
flow: Overlay
visible: false
show_bg: true
new_batch: true
draw_bg +: { color: #x0d1218 }
View {
width: Fill
height: Fit
flow: Down
spacing: 4.0
padding: Inset{left: 8.0 top: 8.0 right: 8.0 bottom: 8.0}
align: Align{x: 0.0 y: 0.0}
show_bg: true
draw_bg +: { color: #x141b22 }
palette_input := TextInput {
width: Fill; height: 26.0
empty_text: "Search commands…"
draw_bg +: { color: #x0d1218 border_radius: 4.0 }
draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } }
}
palette_text_view := View {
width: Fill
height: Fill
palette_text_label := Label {
width: Fill
height: Fit
text: ""
draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } }
}
}
palette_actions := View {
width: Fill; height: Fit
flow: Right; spacing: 4.0
palette_prev_btn := Button{ width: 36.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
palette_next_btn := Button{ width: 36.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
palette_run_btn := Button{ width: 72.0 text: "Run" draw_text +: { text_style +: { font_size: 8.0 } } }
palette_close_btn := Button{ width: 36.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
}
}
}
// === F1 keymap help: floats over the viewport, renders the keymap table ===
keymap_panel := View {
width: Fill
height: Fill
flow: Overlay
visible: false
show_bg: true
new_batch: true
draw_bg +: { color: #x0d1218 }
View {
width: Fill
height: Fill
flow: Down
spacing: 4.0
padding: Inset{left: 8.0 top: 8.0 right: 8.0 bottom: 8.0}
View {
width: Fill
height: Fit
flow: Right
align: Align{x: 1.0 y: 0.0}
keymap_close_btn := Button{ width: 36.0 text: "" draw_text +: { text_style +: { font_size: 8.0 } } }
}
keymap_text_view := View {
width: Fill
height: Fill
keymap_text_label := Label {
width: Fill
height: Fit
text: ""
draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } }
}
}
}
}
// === Bottom overlay — stacked: AI prompt row on top, script editor below ===
bottom_overlay_slot := View {
width: Fill
@ -1624,6 +1852,10 @@ script_mod! {
}
}
}
}
// Project dashboard: shown as a full-size overlay when `show_dashboard`
// is set on CadWorkspace; sits above the Desktop/Mobile editor variants.
dashboard := mod.widgets.CadDashboard {}
}
// ===========================================================================
@ -1636,6 +1868,27 @@ script_mod! {
// [moved to viewport.rs: struct CadViewportViewSnapshot]
/// Newtype wrapper so the `#[rust]` derive macro accepts the BVH cache
/// field. Complex `RefCell<Option<…>>` types cause "Unexpected field form".
struct PickBvhCache(std::cell::RefCell<Option<(u64, bvh::Bvh)>>);
impl Default for PickBvhCache {
fn default() -> Self {
Self(std::cell::RefCell::new(None))
}
}
/// Newtype wrapper for the Measure tool state, mirroring `PickBvhCache`
/// so the `#[rust]` derive macro accepts the field. The real heap state
/// lives in `measure::MeasureInner`.
struct MeasureState(std::cell::RefCell<measure::MeasureInner>);
impl Default for MeasureState {
fn default() -> Self {
Self(std::cell::RefCell::new(measure::MeasureInner::default()))
}
}
#[derive(Script, ScriptHook, WidgetRef, WidgetSet, WidgetRegister)]
pub struct CadViewport {
#[uid]
@ -1666,6 +1919,10 @@ pub struct CadViewport {
ground_color: Vec4f,
#[live]
camera: XrCamera,
#[rust(false)]
ortho_enabled: bool,
#[rust(10.0f32)]
ortho_height: f32,
#[new]
pass: DrawPass,
#[new]
@ -1738,6 +1995,13 @@ pub struct CadViewport {
/// `mark_dirty()` + `invalidate_node(id)`.
#[rust]
scene_cache: SceneCache,
/// BVH acceleration structure for O(log n) ray picking.
///
/// Built lazily on the first pick after a scene change and cached
/// until the next `mark_dirty()`. The tuple is `(generation, bvh)`
/// where generation comes from the parts store to detect staleness.
#[rust]
pick_bvh: PickBvhCache,
/// Shared with the other two viewports (see
/// `CadWorkspace::share_part_id_allocator`). Not a plain `u64`:
/// three independent counters synced by copy reissued live ids.
@ -1745,10 +2009,30 @@ pub struct CadViewport {
part_ids: PartIdAllocator,
#[rust]
selection: Vec<u64>,
#[rust(false)]
selection_dirty: bool,
#[rust(ViewMode::ThreeD)]
view_mode: ViewMode,
#[rust(CadRenderMode::Realistic)]
render_mode: CadRenderMode,
// ---- Section plane (CPU clip) ----
#[rust(false)]
section_active: bool,
#[rust(0u8)]
section_axis: u8,
#[rust(0.0f64)]
section_offset: f64,
// ---- Explode view ----
#[rust(0.0f64)]
explode_amount: f64,
// ---- Sun study ----
#[rust(false)]
sun_active: bool,
#[rust(12.0f64)]
sun_hour: f64,
// ---- X-ray silhouette ----
#[rust(false)]
xray: bool,
#[rust(2.6f32)]
ortho_zoom: f32,
#[rust]
@ -1847,6 +2131,9 @@ pub struct CadViewport {
tool: CadTool,
#[rust]
drawing: DrawingState,
/// In-progress / completed measurement state for the Measure tool.
#[rust]
measure: MeasureState,
#[rust]
snap: SnapSettings,
// ---- Profile: frame timing diagnostics ----
@ -2017,7 +2304,7 @@ enum CadViewportLayoutMode {
#[derive(Script, ScriptHook, Widget)]
pub struct CadWorkspace {
#[deref]
view: AdaptiveView,
view: View,
#[rust(false)]
initialized: bool,
#[rust]
@ -2063,6 +2350,17 @@ pub struct CadWorkspace {
#[rust(true)]
editors_visible: bool,
/// True while the project dashboard (file grid) is shown instead of
/// the editor. Lands on the dashboard first; New/Open hides it.
#[rust(true)]
show_dashboard: bool,
/// Previous `show_dashboard` value, so the visibility toggle can tell
/// a transition apart from a steady state and only refresh the
/// dashboard file list (and redraw) once when it appears.
#[rust(false)]
dashboard_prev_visible: bool,
/// True once the bottom sheet's screen rect has been pushed into viewports
/// at least once. Before this, `update_sheet_rect_for_viewports` is called
/// on every event so the viewport's `blocked_by_sheet` guard works from the
@ -2111,6 +2409,38 @@ pub struct CadWorkspace {
attached_image_base64: Option<String>,
#[rust(None)]
attached_image_filename: Option<String>,
/// Whether the outliner panel overlay is currently visible.
#[rust(false)]
outliner_open: bool,
/// Whether the command palette overlay is currently visible.
#[rust(false)]
palette_open: bool,
/// Whether the F1 keymap help overlay is currently visible.
#[rust(false)]
keymap_open: bool,
/// Current palette filter query text.
#[rust]
palette_query: String,
/// Ranked results (subset of COMMANDS) for the current query.
#[rust]
palette_hits: Vec<crate::construction_frame::pages::workspace::cad::command_palette::CadCommand>,
/// Highlighted row index into `palette_hits`.
#[rust(0)]
palette_cursor: usize,
/// Live outliner search query (matched against name/kind).
#[rust]
outliner_filter_query: String,
/// Optional outliner funnel: only show parts of this kind.
#[rust(None)]
outliner_kind_filter: Option<PartKind>,
}
// [extracted to impl CadWorkspace]
@ -2121,6 +2451,7 @@ pub fn register_cad(vm: &mut ScriptVm) {
cad_script_mod(vm);
cost_estimator::script_mod(vm);
cad_editor_sheet::script_mod(vm);
dashboard::script_mod(vm);
script_mod(vm);
}

View file

@ -0,0 +1,232 @@
//! Outliner: a compact scene-outline readout.
//!
//! Pure logic that turns the part list into a numbered outline so the
//! workspace/outliner widget can render a self-contained "scene tree":
//! one line per part with its name, kind, visibility marker and
//! selection marker. Separated from the DSL so the formatting is
//! unit-tested without a display.
//!
//! Markers: `●` visible, `○` hidden, `►` selected (suffix). The leading
//! integer is the stable per-part key the user can use to select/toggle
//! that part.
use super::cad_scene::{CadNode, PartKind};
const VIS: &str = "";
const HID: &str = "";
/// Build the multi-line outliner text for a list of parts.
///
/// Each part becomes a line, e.g. ` 0 ● Wall 1 (Wall)` or
/// ` 1 ○ Cube 2 (Cube) ►`.
pub fn outliner_text(parts: &[&CadNode], selected: &[u64]) -> String {
if parts.is_empty() {
return "No parts".to_string();
}
let mut out = String::new();
for (i, p) in parts.iter().enumerate() {
let marker = if p.is_hidden() { HID } else { VIS };
let name = p.name.trim();
let kind = p.part_kind().label();
let arrow = if selected.contains(&p.id.raw()) { "" } else { "" };
let label = if name.is_empty() {
format!("({kind})")
} else {
format!("{name} ({kind})")
};
out.push_str(&format!("{:>4} {marker} {label}{arrow}\n", i));
}
out
}
/// One-line hint shown when the scene is empty.
pub fn empty_hint() -> &'static str {
"Scene is empty"
}
/// Format owned outliner rows (from `CadViewport::outliner_rows`).
/// Row = `(id, name, kind, hidden, selected)`.
pub fn outliner_text_rows(rows: &[(u64, String, PartKind, bool, bool)]) -> String {
if rows.is_empty() {
return "No parts".to_string();
}
let mut out = String::new();
for (i, (_, name, kind, hidden, selected)) in rows.iter().enumerate() {
let marker = if *hidden { HID } else { VIS };
let kind_label = kind.label();
let trim = name.trim();
let label = if trim.is_empty() {
format!("({kind_label})")
} else {
format!("{trim} ({kind_label})")
};
let arrow = if *selected { "" } else { "" };
out.push_str(&format!("{:>4} {marker} {label}{arrow}\n", i));
}
out
}
/// A single outliner row: `(id, name, kind, hidden, selected)`.
pub type Row = (u64, String, PartKind, bool, bool);
/// Filter outliner rows by a substring query against the name **or** the kind
/// label (case-insensitive). An empty query keeps every row. Purely functional.
pub fn filter_rows(rows: &[Row], query: &str) -> Vec<Row> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return rows.to_vec();
}
rows.iter()
.filter(|(_, name, kind, _, _)| {
name.to_lowercase().contains(&q) || kind.label().to_lowercase().contains(&q)
})
.cloned()
.collect()
}
/// Further filter rows to a single part kind if `Some`. Keeps order.
pub fn filter_rows_by_kind(rows: &[Row], kind: Option<PartKind>) -> Vec<Row> {
match kind {
None => rows.to_vec(),
Some(k) => rows
.iter()
.filter(|(_, _, rk, _, _)| *rk == k)
.cloned()
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::cad_scene::CadSolid;
fn node(name: &str, kind: PartKind, hidden: bool, id: u64) -> CadNode {
let solid = CadSolid::Box {
size: makepad_widgets::Vec3f { x: 1.0, y: 1.0, z: 1.0 },
};
let mut n = CadNode {
id: super::super::cad_scene::NodeId(id),
name: name.into(),
solid: Some(solid),
transform: super::super::cad_scene::CadTransform::IDENTITY,
material: super::super::cad_scene::MaterialId::ROOT,
layer: super::super::cad_scene::LayerId::ROOT,
parent: None,
metadata: super::super::cad_scene::NodeMetadata::default(),
color: makepad_widgets::Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 },
kind_hint: Some(kind),
};
n.set_hidden(hidden);
n
}
#[test]
fn empty_scene_shows_hint() {
assert!(outliner_text(&[], &[]).starts_with("No parts"));
}
#[test]
fn lists_each_part_with_visibility_and_kind() {
let a = node("Wall 1", PartKind::Wall, false, 1);
let b = node("Cube 2", PartKind::Cube, true, 2);
let txt = outliner_text(&[&a, &b], &[]);
assert!(txt.contains("Wall 1 (Wall)"));
assert!(txt.contains("Cube 2 (Cube)"));
let lines: Vec<&str> = txt.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains(VIS));
assert!(lines[1].contains(HID));
assert!(lines[0].contains('0'));
assert!(lines[1].contains('1'));
}
#[test]
fn marks_selected() {
let a = node("A", PartKind::Beam, false, 3);
let b = node("B", PartKind::Slab, false, 4);
let txt = outliner_text(&[&a, &b], &[a.id.raw()]);
let lines: Vec<&str> = txt.lines().collect();
assert!(lines[0].contains(""));
assert!(!lines[1].contains(""));
}
#[test]
fn unnamed_part_falls_back_to_kind() {
let a = node("", PartKind::Column, false, 5);
let txt = outliner_text(&[&a], &[]);
assert!(txt.contains("(Column)"));
}
#[test]
fn owned_rows_format_with_markers() {
let rows = vec![
(1, "Wall 1".to_string(), PartKind::Wall, false, true),
(2, String::new(), PartKind::Cube, true, false),
];
let txt = outliner_text_rows(&rows);
let lines: Vec<&str> = txt.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("Wall 1 (Wall)"));
assert!(lines[0].contains(VIS));
assert!(lines[0].contains(""));
assert!(lines[1].contains(HID));
assert!(lines[1].contains("(Cube)"));
}
#[test]
fn filter_rows_by_name() {
let rows = vec![
(1, "Wall A".to_string(), PartKind::Wall, false, false),
(2, "Cube B".to_string(), PartKind::Cube, false, false),
];
assert_eq!(filter_rows(&rows, "wall").len(), 1);
assert_eq!(filter_rows(&rows, "wall")[0].0, 1);
assert_eq!(filter_rows(&rows, "b").len(), 1);
assert_eq!(filter_rows(&rows, "b")[0].0, 2);
}
#[test]
fn filter_rows_by_kind_label() {
let rows = vec![
(1, "A".to_string(), PartKind::Wall, false, false),
(2, "B".to_string(), PartKind::Slab, false, false),
(3, "C".to_string(), PartKind::Wall, false, false),
];
// Query "slab" matches the kind label even though no name has it.
assert_eq!(filter_rows(&rows, "slab").len(), 1);
assert_eq!(filter_rows(&rows, "slab")[0].0, 2);
}
#[test]
fn filter_rows_empty_query_keeps_all() {
let rows = vec![
(1, "Wall A".to_string(), PartKind::Wall, false, false),
(2, "Cube B".to_string(), PartKind::Cube, false, false),
];
assert_eq!(filter_rows(&rows, "").len(), 2);
assert_eq!(filter_rows(&rows, " ").len(), 2);
}
#[test]
fn filter_rows_case_insensitive_and_no_match() {
let rows = vec![
(1, "Wall A".to_string(), PartKind::Wall, false, false),
];
assert_eq!(filter_rows(&rows, "WALL").len(), 1);
assert!(filter_rows(&rows, "zzz").is_empty());
}
#[test]
fn filter_rows_by_kind_selects_one_kind() {
let rows = vec![
(1, "A".to_string(), PartKind::Wall, false, false),
(2, "B".to_string(), PartKind::Slab, false, false),
(3, "C".to_string(), PartKind::Wall, false, false),
];
let walls = filter_rows_by_kind(&rows, Some(PartKind::Wall));
assert_eq!(walls.len(), 2);
assert!(walls.iter().all(|(_, _, k, _, _)| *k == PartKind::Wall));
assert_eq!(filter_rows_by_kind(&rows, None).len(), 3);
}
}

View file

@ -0,0 +1,217 @@
//! Selection properties readout.
//!
//! Pure logic that turns selected parts (`CadNode`) into a compact,
//! human-readable properties string shown in the status bar. Separated
//! from the DSL so the formatting and unit logic are unit-tested without
//! a display.
use super::cad_scene::{CadNode, CadSolid, PartKind};
use makepad_widgets::Vec3f;
/// Multi-line properties text for the current selection.
///
/// - With no selection: an empty string (the caller shows a hint instead).
/// - With one part: name, kind, position and size (when the solid has a
/// closed-form size).
/// - With many parts: the count and the distinct kinds.
pub fn selection_properties(parts: &[&CadNode]) -> String {
if parts.is_empty() {
return String::new();
}
if parts.len() == 1 {
single_part(parts[0])
} else {
let mut kinds = std::collections::BTreeSet::new();
for p in parts {
kinds.insert(p.part_kind().label());
}
let joined = kinds.into_iter().collect::<Vec<_>>().join(", ");
format!("{} parts · {}", parts.len(), joined)
}
}
fn single_part(p: &CadNode) -> String {
let kind_label = p.part_kind().label();
let name = p.name.trim();
let size = p.size();
let pos = p.pos();
// 2D and mesh-derived solids have usable extents; skip the size block
// for the handful with no closed form (Polygon2D/ExtrudedPolygon/Arc).
let size_str = match p.solid.as_ref() {
Some(CadSolid::Box { .. })
| Some(CadSolid::Cylinder { .. })
| Some(CadSolid::Sphere { .. })
| Some(CadSolid::Rect2D { .. })
| Some(CadSolid::Circle2D { .. }) => format_size(size),
_ => String::new(),
};
let pos_str = fmt_vec3(pos);
if name.is_empty() {
format!("{kind_label} · pos {pos_str} · {size_str}")
} else {
format!("{name} ({kind_label}) · pos {pos_str} · {size_str}")
}
}
/// Best-effort formatted size: "1.5 × 3.0 × 2.0 m".
fn format_size(size: Vec3f) -> String {
format!("{} × {} × {} m", trim(size.x), trim(size.y), trim(size.z))
}
/// Trim a length to at most two decimals, dropping useless trailing zeros.
fn trim(v: f32) -> String {
let mut s = format!("{:.2}", v);
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
s
}
/// "(1.2, 3.4, 5.6)" from a position vector.
fn fmt_vec3(v: Vec3f) -> String {
format!("({}, {}, {})", trim(v.x), trim(v.y), trim(v.z))
}
/// Hint shown when nothing is selected and the properties readout is empty.
pub fn no_selection_hint() -> &'static str {
"Select a part to see its properties"
}
/// Multi-line element info card (the "I" readout): kind, name, id, position,
/// size and triangle count. Looser and more inspectable than the status-bar
/// `selection_properties`; used by the info-card overlay and outliner reveal.
pub fn info_card_text(p: &CadNode, tri_count: usize) -> String {
let mut out = String::new();
let kind_label = p.part_kind().label();
let name = p.name.trim();
if name.is_empty() {
out.push_str(&format!("{kind_label}\n"));
} else {
out.push_str(&format!("{name} ({kind_label})\n"));
}
out.push_str(&format!("ID {}\n", p.id.raw()));
out.push_str(&format!("Pos {}\n", fmt_vec3(p.pos())));
let size_str = match p.solid.as_ref() {
Some(CadSolid::Box { .. })
| Some(CadSolid::Cylinder { .. })
| Some(CadSolid::Sphere { .. })
| Some(CadSolid::Rect2D { .. })
| Some(CadSolid::Circle2D { .. }) => format_size(p.size()),
_ => String::new(),
};
if !size_str.is_empty() {
out.push_str(&format!("Size {size_str}\n"));
}
out.push_str(&format!("Tris {tri_count}"));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::makepad_csg::Vec3d;
fn node(name: &str, kind: PartKind) -> CadNode {
let solid = match kind {
PartKind::Wall => CadSolid::Box {
size: Vec3f { x: 4.0, y: 0.15, z: 2.4 },
},
_ => CadSolid::Box {
size: Vec3f { x: 1.0, y: 2.0, z: 3.0 },
},
};
CadNode {
id: crate::construction_frame::pages::workspace::cad::cad_scene::NodeId(0),
name: name.into(),
solid: Some(solid),
transform: crate::construction_frame::pages::workspace::cad::cad_scene::CadTransform::IDENTITY,
material: crate::construction_frame::pages::workspace::cad::cad_scene::MaterialId::ROOT,
layer: crate::construction_frame::pages::workspace::cad::cad_scene::LayerId::ROOT,
parent: None,
metadata: crate::construction_frame::pages::workspace::cad::cad_scene::NodeMetadata::default(),
color: makepad_widgets::Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 },
kind_hint: Some(kind),
}
}
#[test]
fn no_selection_is_empty() {
assert_eq!(selection_properties(&[]), "");
}
#[test]
fn single_part_shows_name_kind_size() {
let p = node("Wall 1", PartKind::Wall);
let txt = selection_properties(&[&p]);
assert!(txt.contains("Wall 1"));
assert!(txt.contains("Wall"));
// Box size 4.0 x 0.15 x 2.4 -> "4 × 0.15 × 2.4 m"
assert!(txt.contains("2.4 m"));
}
#[test]
fn single_part_without_name_shows_kind_only() {
let p = node("", PartKind::Cube);
let txt = selection_properties(&[&p]);
assert!(txt.contains("Cube"));
assert!(!txt.contains("()"));
}
#[test]
fn multiple_parts_show_count_and_kinds() {
let a = node("a", PartKind::Cube);
let b = node("b", PartKind::Wall);
let c = node("c", PartKind::Cube);
let txt = selection_properties(&[&a, &b, &c]);
assert!(txt.starts_with("3 parts"));
assert!(txt.contains("Cube"));
assert!(txt.contains("Wall"));
}
#[test]
fn dedicated_formatting() {
assert_eq!(trim(2.0), "2");
assert_eq!(trim(2.40), "2.4");
assert_eq!(format_size(Vec3f { x: 1.0, y: 2.5, z: 3.0 }), "1 × 2.5 × 3 m");
}
#[test]
fn part_kind_labels() {
let _ = Vec3d::default();
assert_eq!(PartKind::Wall.label(), "Wall");
assert_eq!(PartKind::Cylinder.label(), "Cylinder");
assert_eq!(PartKind::Beam.label(), "Beam");
}
#[test]
fn info_card_shows_kind_id_pos_size_and_tris() {
let p = node("Wall 1", PartKind::Wall);
let txt = info_card_text(&p, 42);
assert!(txt.contains("Wall 1"));
assert!(txt.contains("Wall"));
assert!(txt.contains("ID 0"));
assert!(txt.contains("Pos"));
assert!(txt.contains("2.4 m"));
assert!(txt.contains("Tris 42"));
}
#[test]
fn info_card_no_size_for_arc() {
let mut p = node("p", PartKind::Arc);
p.solid = Some(crate::construction_frame::pages::workspace::cad::cad_scene::CadSolid::Arc {
center_x: 0.0,
center_z: 0.0,
radius: 2.0,
start_angle: 0.0,
end_angle: 90.0,
sweep_direction: 1.0,
});
let txt = info_card_text(&p, 1);
assert!(!txt.contains("Size"));
assert!(txt.contains("Tris 1"));
}
}

View file

@ -0,0 +1,179 @@
//! High-res render capture settings + PNG export.
//!
//! There is no GPU read-back in this make/build, so a "render" is expressed
//! as pure settings (width/height/samples) plus a PNG encoder that reuses the
//! already-tested `image` encoder surfaced by `nigig_core` — no new dependency.
/// Sampling / output settings for a high-res render capture.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RenderSettings {
/// Output width in pixels.
pub width: u32,
/// Output height in pixels.
pub height: u32,
/// Samples per pixel (accumulation passes). `0` = single pass.
pub samples: u32,
}
impl Default for RenderSettings {
fn default() -> Self {
RenderSettings {
width: 1600,
height: 2000,
samples: 1,
}
}
}
impl RenderSettings {
/// Clamp the settings to sane render bounds, raising `samples` to at least
/// 1 so callers never request zero accumulation.
pub fn sanitize(mut self) -> Self {
self.width = self.width.clamp(64, 8192);
self.height = self.height.clamp(64, 8192);
self.samples = self.samples.max(1);
self
}
/// Total number of pixels the output buffer holds.
pub fn pixel_count(&self) -> u64 {
self.width as u64 * self.height as u64
}
}
/// Encode a raw RGB framebuffer (3 bytes per pixel, row-major) into PNG bytes
/// at the settings' resolution. Reuses nigig-core's `image`-based encoder.
pub fn encode_render_png(
settings: &RenderSettings,
rgb: &[u8],
) -> std::io::Result<Vec<u8>> {
let want = (settings.pixel_count() * 3) as usize;
if rgb.len() != want {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"render buffer size {buf} != expected {want} for {w}x{h}",
buf = rgb.len(),
w = settings.width,
h = settings.height
),
));
}
nigig_core::syncing::encode_png_rgb(settings.width as usize, settings.height as usize, rgb)
}
/// Write a render to `png` next to `output_path` (replacing any extension with
/// `.png`) and return the written path.
pub fn write_render_png(
settings: &RenderSettings,
rgb: &[u8],
output_path: &str,
) -> std::io::Result<String> {
let bytes = encode_render_png(settings, rgb)?;
let png_path = std::path::Path::new(output_path)
.with_extension("png");
if let Some(parent) = png_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&png_path, bytes)?;
Ok(png_path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_are_render_like() {
let s = RenderSettings::default();
assert!(s.width >= 1280);
assert!(s.height >= 1280);
assert_eq!(s.samples, 1);
}
#[test]
fn sanitize_clamps_and_forces_at_least_one_sample() {
let s = RenderSettings {
width: 1,
height: 999999,
samples: 0,
}
.sanitize();
assert_eq!(s.width, 64);
assert_eq!(s.height, 8192);
assert_eq!(s.samples, 1);
}
#[test]
fn sanitize_keeps_in_range_values() {
let s = RenderSettings {
width: 1024,
height: 768,
samples: 4,
}
.sanitize();
assert_eq!(s.width, 1024);
assert_eq!(s.height, 768);
assert_eq!(s.samples, 4);
}
#[test]
fn pixel_count_matches() {
let s = RenderSettings {
width: 100,
height: 200,
samples: 1,
};
assert_eq!(s.pixel_count(), 20000);
}
#[test]
fn encode_render_png_round_trips_via_png_header() {
let s = RenderSettings {
width: 2,
height: 2,
samples: 1,
};
let rgb = vec![
255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255,
];
let bytes = encode_render_png(&s, &rgb).expect("encodes");
// PNG magic
assert_eq!(&bytes[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
// PNG must declare the intended dimensions right after IHDR.
assert_eq!(&bytes[16..20], &2u32.to_be_bytes());
assert_eq!(&bytes[20..24], &2u32.to_be_bytes());
}
#[test]
fn encode_rejects_mismatched_buffer_size() {
let s = RenderSettings {
width: 2,
height: 2,
samples: 1,
};
assert!(encode_render_png(&s, &[0u8; 3]).is_err());
}
#[test]
fn write_render_png_creates_file_and_represents_as_path() {
let s = RenderSettings {
width: 2,
height: 2,
samples: 1,
};
let rgb = vec![
255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255,
];
let dir = std::env::temp_dir().join(format!("nigig_render_export_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("frame").to_string_lossy().to_string();
let written = write_render_png(&s, &rgb, &out).unwrap();
assert!(written.ends_with("frame.png"));
let disk = std::fs::read(&written).unwrap();
assert_eq!(&disk[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,336 @@
//! # script_parts — decompose a script-evaluated `Solid` into editable parts.
//!
//! The CAD script evaluates to a single merged `Solid` (a flat `TriMesh`
//! with no per-primitive identity). The 3D viewport used to render that
//! single mesh directly, which left the 2D viewport (which only draws
//! `parts`) showing nothing for script-authored geometry.
//!
//! This module turns that merged mesh into connected components and
//! materialises each one as a `CadNode` part carrying a `CadSolid::Csg`
//! solid, so both 2D and 3D renderers draw script output uniformly.
//!
//! Each component is recentred around its own AABB centre so it behaves
//! like any other part (geometry centred at the origin, `translation`
//! holding its position) and the merged `TriMesh` is split into connected
//! pieces by shared triangle edges.
//!
//! Parts produced here are tagged with the `__script__` name prefix so
//! the parts→script serialiser can skip them (they were derived *from*
//! the script, so re-serialising them would fight the handwritten source
//! and re-enter the eval loop).
use std::collections::HashMap;
use std::sync::Arc;
use crate::makepad_csg::{Solid, TriMesh, Vec3d as CsgVec3};
use makepad_widgets::{vec3, vec4, Vec3f, Vec4f};
use super::cad_scene::{
CadNode, CadSolid, CadTransform, LayerId, MaterialId, NodeId, NodeMetadata, PartKind,
};
use super::math::DVec3;
/// Name prefix marking a part that was decomposed out of the script
/// solid. Mirrors the `__hidden__` convention used by `CadNode`.
pub const SCRIPT_PREFIX: &str = "__script__";
/// True when `name` marks a script-derived part.
pub fn is_script_bred(name: &str) -> bool {
name.starts_with(SCRIPT_PREFIX)
}
/// One connected piece of the merged script solid.
#[derive(Clone, Debug)]
pub struct ScriptComponent {
/// AABB centre of the piece in model space; becomes the node's
/// `translation`.
pub center: DVec3,
/// The piece's geometry recentred so it is centred at the origin.
pub mesh: TriMesh,
}
/// A disjoint-set forest with path compression, used to group triangles
/// that share edges into connected components.
struct Dsu {
parent: Vec<usize>,
}
impl Dsu {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
}
}
fn find(&mut self, x: usize) -> usize {
let root = {
let mut r = x;
while self.parent[r] != r {
r = self.parent[r];
}
r
};
let mut cur = x;
while self.parent[cur] != cur {
let next = self.parent[cur];
self.parent[cur] = root;
cur = next;
}
root
}
fn union(&mut self, a: usize, b: usize) {
let ra = self.find(a);
let rb = self.find(b);
if ra != rb {
self.parent[ra] = rb;
}
}
}
/// Split a triangle mesh into connected components. Two triangles are in
/// the same component when they share an edge (share two vertex indices).
///
/// Returns one compact `TriMesh` per component, with vertex indices
/// remapped to the used subset.
pub fn split_into_components(mesh: &TriMesh) -> Vec<TriMesh> {
let n_tri = mesh.triangles.len();
if n_tri == 0 {
return Vec::new();
}
let mut dsu = Dsu::new(n_tri);
// For each undirected edge, the first triangle that owns it. A second
// triangle hitting the same edge is welded to the first.
let mut edge_owner: HashMap<(u32, u32), usize> = HashMap::new();
for (ti, tri) in mesh.triangles.iter().enumerate() {
for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
let key = if a < b { (a, b) } else { (b, a) };
if let Some(&other) = edge_owner.get(&key) {
dsu.union(ti, other);
} else {
edge_owner.insert(key, ti);
}
}
}
// Group triangle indices by root, preserving first-seen order so
// output ordering is stable regardless of hash iteration.
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
for ti in 0..n_tri {
let root = dsu.find(ti);
if !groups.contains_key(&root) {
roots.push(root);
}
groups.entry(root).or_default().push(ti);
}
roots
.into_iter()
.map(|root| extract_component(mesh, &groups[&root]))
.collect()
}
fn extract_component(mesh: &TriMesh, tris: &[usize]) -> TriMesh {
let mut remap: HashMap<u32, u32> = HashMap::new();
let mut out = TriMesh::new();
for &ti in tris {
let src = mesh.triangles[ti];
let mut tri = [0u32; 3];
for (k, v) in src.iter().enumerate() {
let idx = *remap.entry(*v).or_insert_with(|| {
let new = out.vertices.len() as u32;
out.vertices.push(mesh.vertices[*v as usize]);
new
});
tri[k] = idx;
}
out.triangles.push(tri);
}
out
}
/// Recentre a mesh around its own AABB centre.
///
/// The merged script mesh is in absolute model coordinates, but a
/// `CadNode` part is geometry-centred-at-origin plus a `translation`.
/// Returning the centre lets callers place the part exactly where the
/// script put it while keeping the local geometry origin-centred.
pub fn recentre_component(mesh: &TriMesh) -> (TriMesh, DVec3) {
if mesh.vertices.is_empty() {
return (mesh.clone(), DVec3::default());
}
let mut min = mesh.vertices[0];
let mut max = mesh.vertices[0];
for v in &mesh.vertices {
min = CsgVec3 {
x: min.x.min(v.x),
y: min.y.min(v.y),
z: min.z.min(v.z),
};
max = CsgVec3 {
x: max.x.max(v.x),
y: max.y.max(v.y),
z: max.z.max(v.z),
};
}
let center = DVec3 {
x: (min.x + max.x) * 0.5,
y: (min.y + max.y) * 0.5,
z: (min.z + max.z) * 0.5,
};
let mut out = mesh.clone();
for v in &mut out.vertices {
v.x -= center.x;
v.y -= center.y;
v.z -= center.z;
}
(out, center)
}
/// Split a script solid into recentred connected components.
pub fn components_from_solid(solid: &Solid) -> Vec<ScriptComponent> {
split_into_components(solid.mesh())
.into_iter()
.map(|m| {
let (mesh, center) = recentre_component(&m);
ScriptComponent { center, mesh }
})
.collect()
}
/// Build a `CadNode` part from a script component.
///
/// The solid is carried as `CadSolid::Csg` so it renders exactly as the
/// script produced it, `translation` holds the component centre, and the
/// `__script__` name marks it for exclusion from parts→script sync.
pub fn node_from_component(index: usize, id: NodeId, comp: &ScriptComponent) -> CadNode {
CadNode {
id,
name: format!("{}Script-{}", SCRIPT_PREFIX, index + 1),
solid: Some(CadSolid::Csg(Arc::new(Solid::from_mesh(comp.mesh.clone())))),
transform: CadTransform {
translation: Vec3f {
x: comp.center.x as f32,
y: comp.center.y as f32,
z: comp.center.z as f32,
},
rotation_euler_xyz: Vec3f {
x: 0.0,
y: 0.0,
z: 0.0,
},
scale: 1.0,
},
material: MaterialId::ROOT,
layer: LayerId::ROOT,
parent: None,
metadata: NodeMetadata::default(),
color: vec4(0.62, 0.62, 0.66, 1.0),
kind_hint: Some(PartKind::Cube),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::makepad_csg::TriMesh;
fn v3(x: f64, y: f64, z: f64) -> CsgVec3 {
CsgVec3 { x, y, z }
}
fn tri(a: [u32; 3]) -> [u32; 3] {
a
}
#[test]
fn split_handles_empty_mesh() {
let mesh = TriMesh::new();
assert!(split_into_components(&mesh).is_empty());
}
#[test]
fn split_two_disjoint_triangles() {
let mesh = TriMesh {
vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(5.0, 0.0, 0.0), v3(6.0, 0.0, 0.0), v3(5.0, 1.0, 0.0)],
triangles: vec![tri([0, 1, 2]), tri([3, 4, 5])],
};
let comps = split_into_components(&mesh);
assert_eq!(comps.len(), 2);
for c in &comps {
assert_eq!(c.triangle_count(), 1);
assert_eq!(c.vertex_count(), 3);
}
}
#[test]
fn split_two_triangles_sharing_an_edge() {
// Two triangles share edge (1,2) -> one component of 2 triangles.
let mesh = TriMesh {
vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(1.0, 1.0, 0.0)],
triangles: vec![tri([0, 1, 2]), tri([1, 3, 2])],
};
let comps = split_into_components(&mesh);
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].triangle_count(), 2);
assert_eq!(comps[0].vertex_count(), 4);
}
#[test]
fn split_triangle_strip_is_one_component() {
let mesh = TriMesh {
vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(1.0, 1.0, 0.0), v3(2.0, 1.0, 0.0)],
triangles: vec![tri([0, 1, 2]), tri([1, 3, 2]), tri([1, 4, 3])],
};
let comps = split_into_components(&mesh);
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].triangle_count(), 3);
}
#[test]
fn recentre_returns_aabb_center_and_centred_geometry() {
let mesh = TriMesh {
vertices: vec![v3(2.0, 4.0, 6.0), v3(6.0, 4.0, 6.0), v3(2.0, 8.0, 6.0)],
triangles: vec![tri([0, 1, 2])],
};
let (centred, center) = recentre_component(&mesh);
assert!((center.x - 4.0).abs() < 1e-9);
assert!((center.y - 6.0).abs() < 1e-9);
assert!((center.z - 6.0).abs() < 1e-9);
// Geometry centred at origin: min == -max.
let mut mn = centred.vertices[0];
let mut mx = centred.vertices[0];
for v in &centred.vertices {
mn = v3(mn.x.min(v.x), mn.y.min(v.y), mn.z.min(v.z));
mx = v3(mx.x.max(v.x), mx.y.max(v.y), mx.z.max(v.z));
}
assert!((mn.x + 2.0).abs() < 1e-9);
assert!((mx.x - 2.0).abs() < 1e-9);
}
#[test]
fn node_is_script_bred_with_centre_translation() {
let comp = ScriptComponent {
center: DVec3 {
x: 3.0,
y: 4.0,
z: 5.0,
},
mesh: TriMesh::new(),
};
let node = node_from_component(0, NodeId(42), &comp);
assert!(is_script_bred(&node.name));
assert!(node.name.starts_with(SCRIPT_PREFIX));
assert_eq!(node.id.raw(), 42);
assert!((node.transform.translation.x - 3.0).abs() < 1e-6);
assert!((node.transform.translation.y - 4.0).abs() < 1e-6);
assert!((node.transform.translation.z - 5.0).abs() < 1e-6);
assert_eq!(node.part_kind(), PartKind::Cube);
}
#[test]
fn is_script_bred_negatives() {
assert!(!is_script_bred("Part-1"));
assert!(!is_script_bred(""));
assert!(!is_script_bred("__script"));
}
}

View file

@ -0,0 +1,141 @@
//! Section planes: the CPU-side clip that lets the editor "see inside" the
//! model along an axis-aligned cut.
//!
//! A `SectionPlane` keeps everything on one side of a plane `dot(n, p) >= 0`
//! (equivalently `dot(n, p) <= offset` for `offset` in units of the distance
//! from the origin). Parts whose world AABB lies entirely *inside* the kept
//! half-space are drawn; parts entirely outside are dropped; parts that
//! straddle the plane stay (so the cut looks continuous across the boundary
//! without tessellation).
//!
//! Pure logic with no makepad types so it is unit-testable.
/// An axis-aligned half-space cut: `dot(normal, p) <= offset`.
///
/// `normal` is a unit vector (axis-aligned for our supported cuts) and
/// `offset` is a signed distance from the origin along `normal`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SectionPlane {
/// Unit normal along the cut axis.
pub normal: (f64, f64, f64),
/// Signed plane offset: points with `dot(normal, p) <= offset` are kept.
pub offset: f64,
}
impl SectionPlane {
/// A plane through the world origin with the given unit normal.
pub fn through_origin(normal: (f64, f64, f64)) -> Self {
SectionPlane { normal, offset: 0.0 }
}
/// Axis-aligned plane X = offset, keeping X <= offset.
pub fn axis_x(offset: f64) -> Self {
SectionPlane { normal: (1.0, 0.0, 0.0), offset }
}
/// Axis-aligned plane Y = offset (horizontal cut), keeping Y <= offset.
pub fn axis_y(offset: f64) -> Self {
SectionPlane { normal: (0.0, 1.0, 0.0), offset }
}
/// Axis-aligned plane Z = offset (plan cut), keeping Z <= offset.
pub fn axis_z(offset: f64) -> Self {
SectionPlane { normal: (0.0, 0.0, 1.0), offset }
}
/// Flip the kept side by negating the normal and the offset.
pub fn flip(self) -> Self {
SectionPlane {
normal: (-self.normal.0, -self.normal.1, -self.normal.2),
offset: -self.offset,
}
}
/// Move the plane by `delta` along its normal.
pub fn with_offset(self, delta: f64) -> Self {
SectionPlane { normal: self.normal, offset: self.offset - delta }
}
/// True when `p` lies on the kept side of the plane.
pub fn contains(self, p: (f64, f64, f64)) -> bool {
let d = self.normal.0 * p.0 + self.normal.1 * p.1 + self.normal.2 * p.2;
d <= self.offset
}
/// True when the whole AABB (`min`..`max`) is inside the kept half-space.
///
/// The farthest kept corner along the normal is the one that minimizes
/// `dot(normal, corner)`; if even that corner is kept, all of it is.
pub fn kept(self, min: (f64, f64, f64), max: (f64, f64, f64)) -> bool {
// Corner with the smallest signed distance along `normal`:
let corner = (
if self.normal.0 >= 0.0 { min.0 } else { max.0 },
if self.normal.1 >= 0.0 { min.1 } else { max.1 },
if self.normal.2 >= 0.0 { min.2 } else { max.2 },
);
self.contains(corner)
}
}
/// Build the plane that goes through `p0` with the given unit `normal`,
/// solving for the offset so that `dot(normal, p0) = offset`.
pub fn plane_through(p0: (f64, f64, f64), normal: (f64, f64, f64)) -> SectionPlane {
let offset = normal.0 * p0.0 + normal.1 * p0.1 + normal.2 * p0.2;
SectionPlane { normal, offset }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn axis_planes_have_unit_normals() {
for p in [SectionPlane::axis_x(1.0), SectionPlane::axis_y(1.0), SectionPlane::axis_z(1.0)] {
let n2 = p.normal.0 * p.normal.0 + p.normal.1 * p.normal.1 + p.normal.2 * p.normal.2;
assert!((n2 - 1.0).abs() < 1e-9);
}
}
#[test]
fn kept_respects_plane_side() {
let plane = SectionPlane::axis_z(0.0); // keep z <= 0
// Box fully below the plane is kept.
assert!(plane.kept((0.0, 0.0, -2.0), (1.0, 1.0, -0.5)));
// Box fully above is dropped.
assert!(!plane.kept((0.0, 0.0, 0.5), (1.0, 1.0, 2.0)));
// Box straddling stays.
assert!(plane.kept((0.0, 0.0, -0.5), (1.0, 1.0, 0.5)));
}
#[test]
fn kept_uses_farthest_corner_per_axis() {
// Keep x <= 5; min x is 3 so even the min corner is inside -> kept.
let plane = SectionPlane::axis_x(5.0);
assert!(plane.kept((3.0, 0.0, 0.0), (4.0, 0.0, 0.0)));
// Box entirely x > 5 dropped.
assert!(!plane.kept((6.0, 0.0, 0.0), (7.0, 0.0, 0.0)));
}
#[test]
fn flip_keeps_the_other_side() {
let plane = SectionPlane::axis_z(0.0);
let flipped = plane.flip(); // keep z >= 0
assert!(!plane.kept((0.0, 0.0, 1.0), (1.0, 1.0, 2.0)));
assert!(flipped.kept((0.0, 0.0, 1.0), (1.0, 1.0, 2.0)));
}
#[test]
fn with_offset_moves_the_cut() {
// Keep x <= 0; moving + keeps x <= 2.
let plane = SectionPlane::axis_x(0.0).with_offset(-2.0);
assert_eq!(plane.offset, 2.0);
assert!(plane.kept((1.0, 0.0, 0.0), (1.5, 0.0, 0.0)));
}
#[test]
fn plane_through_solves_offset() {
let plane = plane_through((2.0, 0.0, 0.0), (1.0, 0.0, 0.0));
assert_eq!(plane.offset, 2.0);
assert!(plane.contains((2.0, 0.0, 0.0)));
}
}

View file

@ -0,0 +1,494 @@
//! Snap system: BVH-accelerated snap-to-geometry with screen-space radius.
//!
//! Ported from `fab::tools::snap` and adapted to our f64 scene graph.
//! Replaces the O(n) linear-scan snap functions in viewport.rs with an
//! O(log n) BVH-based approach that works on actual mesh triangles
//! (not AABB bounding boxes).
//!
//! # Design decisions
//!
//! - **Screen-space radius**: `radius_px` replaces the old `snap_tolerance`
//! (world units). A 20px radius feels the same at any zoom level.
//! - **Priority chain**: Vertex > EdgeMidpoint > Edge > Face > Ground.
//! - **Face snap** is always-on as a fallback after raycast: if the ray
//! hits a triangle, that point is offered as a face candidate.
//! - **Ground fallback**: when the ray misses all geometry, it intersects
//! with the XZ ground plane (y=0).
use crate::construction_frame::pages::workspace::cad::bvh::{Bvh, BvhPickOptions, BvhRay};
use crate::construction_frame::pages::workspace::cad::math::{mat4_mul_vec4, DVec3};
use makepad_widgets::makepad_math::*;
use makepad_widgets::DVec2;
// ─── Snap types ─────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SnapKind {
/// Snap to a triangle vertex.
Vertex,
/// Snap to a triangle edge midpoint.
EdgeMidpoint,
/// Snap to the closest point on a triangle edge.
Edge,
/// Snap to the closest point on a triangle face.
Face,
/// Snap to the ground plane (y=0 fallback).
Ground,
}
impl SnapKind {
/// Numeric priority for tie-breaking (lower = higher priority).
pub fn priority(self) -> u8 {
match self {
Self::Vertex => 0,
Self::EdgeMidpoint => 1,
Self::Edge => 2,
Self::Face => 3,
Self::Ground => 4,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Vertex => "Vertex",
Self::EdgeMidpoint => "Midpoint",
Self::Edge => "Edge",
Self::Face => "Face",
Self::Ground => "Ground",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct SnapHit {
pub kind: SnapKind,
pub point: DVec3,
pub element_id: u64,
pub normal: Option<DVec3>,
pub screen_dist: f64,
}
impl SnapHit {
pub fn is_better_than(&self, other: &SnapHit) -> bool {
self.kind < other.kind
|| (self.kind == other.kind && self.screen_dist < other.screen_dist)
}
}
/// Which snap types are enabled, plus the screen-space search radius.
#[derive(Clone, Copy, Debug)]
pub struct SnapOptions {
pub vertex: bool,
pub edge_midpoint: bool,
pub edge: bool,
pub face: bool,
pub ground: bool,
/// Screen-space snap radius in pixels.
pub radius_px: f32,
}
impl Default for SnapOptions {
fn default() -> Self {
Self {
vertex: true,
edge_midpoint: true,
edge: true,
face: true,
ground: true,
radius_px: 20.0,
}
}
}
// ─── Per-element snap scan ──────────────────────────────────────────────
/// Scan a single element's triangles for snap candidates.
///
/// Given a node_id and its mesh, generate snap candidates near the
/// cursor position. This is called after the BVH identifies the element.
pub fn snap_element(
node_id: u64,
mesh_vertices: &[[f64; 3]],
mesh_triangles: &[[u32; 3]],
model: &Mat4f,
cursor: DVec2,
opts: &SnapOptions,
project_to_screen: impl Fn(DVec3) -> DVec2,
pixels_per_world: f64,
radius_px: f32,
) -> Vec<SnapHit> {
let mut candidates = Vec::with_capacity(32);
let radius_world = radius_px as f64 * pixels_per_world;
// Collect unique vertices (world space).
let mut seen_verts: std::collections::HashSet<u32> = std::collections::HashSet::new();
for tri in mesh_triangles {
// Transform vertices to world space.
let wv: [DVec3; 3] = [0, 1, 2].map(|i| {
let v = mesh_vertices[tri[i] as usize];
let w = mat4_mul_vec4(model, [v[0] as f32, v[1] as f32, v[2] as f32, 1.0]);
DVec3 { x: w[0] as f64, y: w[1] as f64, z: w[2] as f64 }
});
let face_center = DVec3 {
x: (wv[0].x + wv[1].x + wv[2].x) / 3.0,
y: (wv[0].y + wv[1].y + wv[2].y) / 3.0,
z: (wv[0].z + wv[1].z + wv[2].z) / 3.0,
};
// Face normal (for metadata, not for snap distance).
let e1 = DVec3 { x: wv[1].x - wv[0].x, y: wv[1].y - wv[0].y, z: wv[1].z - wv[0].z };
let e2 = DVec3 { x: wv[2].x - wv[0].x, y: wv[2].y - wv[0].y, z: wv[2].z - wv[0].z };
let normal = DVec3 {
x: e1.y * e2.z - e1.z * e2.y,
y: e1.z * e2.x - e1.x * e2.z,
z: e1.x * e2.y - e1.y * e2.x,
};
let normal_len = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z).sqrt();
let normal_unit = if normal_len > 1e-12 {
DVec3 { x: normal.x / normal_len, y: normal.y / normal_len, z: normal.z / normal_len }
} else {
normal
};
// Face snap candidate (always offered).
if opts.face {
let sp = project_to_screen(face_center);
let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
if dist <= radius_px as f64 {
candidates.push(SnapHit {
kind: SnapKind::Face,
point: face_center,
element_id: node_id,
normal: Some(normal_unit),
screen_dist: dist,
});
}
}
// Vertex snap candidates.
if opts.vertex {
for v in &wv {
// Use raw triangle index + vertex position as a pseudo-key.
let sp = project_to_screen(*v);
let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
if dist <= radius_px as f64 {
candidates.push(SnapHit {
kind: SnapKind::Vertex,
point: *v,
element_id: node_id,
normal: Some(normal_unit),
screen_dist: dist,
});
}
}
}
// Edge midpoint candidates.
if opts.edge_midpoint {
for i in 0..3 {
let a = wv[i];
let b = wv[(i + 1) % 3];
let mid = DVec3 {
x: (a.x + b.x) * 0.5,
y: (a.y + b.y) * 0.5,
z: (a.z + b.z) * 0.5,
};
let sp = project_to_screen(mid);
let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
if dist <= radius_px as f64 {
candidates.push(SnapHit {
kind: SnapKind::EdgeMidpoint,
point: mid,
element_id: node_id,
normal: Some(normal_unit),
screen_dist: dist,
});
}
}
}
// Edge (closest point on edge) candidates.
if opts.edge {
for i in 0..3 {
let a = wv[i];
let b = wv[(i + 1) % 3];
let ab = DVec3 { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
let ab_len2 = ab.x * ab.x + ab.y * ab.y + ab.z * ab.z;
if ab_len2 < 1e-24 {
continue;
}
// Project cursor ray onto the edge to find closest point.
// Approximate: project screen cursor onto edge in screen space.
let sa = project_to_screen(a);
let sb = project_to_screen(b);
let sab = DVec2 { x: sb.x - sa.x, y: sb.y - sa.y };
let sab_len2 = sab.x * sab.x + sab.y * sab.y;
if sab_len2 < 1e-12 {
continue;
}
let t = ((cursor.x - sa.x) * sab.x + (cursor.y - sa.y) * sab.y) / sab_len2;
let t_clamped = t.clamp(0.0, 1.0);
let closest = DVec3 {
x: a.x + ab.x * t_clamped,
y: a.y + ab.y * t_clamped,
z: a.z + ab.z * t_clamped,
};
let sp = project_to_screen(closest);
let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
if dist <= radius_px as f64 {
candidates.push(SnapHit {
kind: SnapKind::Edge,
point: closest,
element_id: node_id,
normal: Some(normal_unit),
screen_dist: dist,
});
}
}
}
}
candidates
}
// ─── Ground snap ────────────────────────────────────────────────────────
/// Snap to the ground plane (y=0) as a fallback when geometry is missed.
pub fn snap_to_ground(
ray_origin: DVec3,
ray_dir: DVec3,
cursor: DVec2,
project_to_screen: impl Fn(DVec3) -> DVec2,
radius_px: f32,
) -> Option<SnapHit> {
// Intersect ray with y=0 plane.
if ray_dir.y.abs() < 1e-12 {
return None;
}
let t = -ray_origin.y / ray_dir.y;
if t < 0.0 {
return None;
}
let point = DVec3 {
x: ray_origin.x + ray_dir.x * t,
y: 0.0,
z: ray_origin.z + ray_dir.z * t,
};
let sp = project_to_screen(point);
let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
if dist <= radius_px as f64 {
Some(SnapHit {
kind: SnapKind::Ground,
point,
element_id: 0,
normal: Some(DVec3 { x: 0.0, y: 1.0, z: 0.0 }),
screen_dist: dist,
})
} else {
None
}
}
// ─── Screen-space utilities ─────────────────────────────────────────────
/// Convert a screen-space radius (pixels) to world-space distance at a
/// given depth from the camera.
pub fn pixels_to_world(radius_px: f32, pixels_per_world: f64) -> f64 {
radius_px as f64 / pixels_per_world
}
/// Compute pixels-per-world-unit from camera parameters.
///
/// For perspective: `2 * distance * tan(fov_y/2) / viewport_height`.
/// For orthographic: `viewport_height / (2 * ortho_height)`.
pub fn pixels_per_world_perspective(
distance: f32,
fov_y: f32,
viewport_height: f32,
) -> f32 {
let half_fov = fov_y * 0.5;
let world_height = 2.0 * distance * half_fov.tan();
viewport_height / world_height
}
pub fn pixels_per_world_ortho(
ortho_height: f32,
viewport_height: f32,
) -> f32 {
viewport_height / (2.0 * ortho_height)
}
// ─── BVH snap extension ─────────────────────────────────────────────────
impl Bvh {
/// BVH-accelerated snap: find the best snap candidate near `cursor`.
///
/// This combines a BVH raycast with per-element triangle scanning.
/// The `lookup_element` callback provides triangle data for a given
/// node_id.
pub fn snap(
&self,
cursor: DVec2,
opts: &SnapOptions,
screen_to_ray: impl Fn(DVec2) -> Option<(DVec3, DVec3)>,
project_to_screen: impl Fn(DVec3) -> DVec2,
lookup_element: impl Fn(u64) -> Option<(Vec<[f64; 3]>, Vec<[u32; 3]>, Mat4f)>,
pixels_per_world: f64,
) -> Option<SnapHit> {
let (ray_origin, ray_dir) = screen_to_ray(cursor)?;
// Broadphase: use BVH element bounds to find candidate elements
// near the cursor, then narrowphase with per-element triangle scanning.
let mut candidates: Vec<SnapHit> = Vec::with_capacity(64);
for &(elem_id, ref aabb) in self.element_bounds() {
// Broadphase: project AABB to screen and check distance.
let corners = [
[aabb.min[0], aabb.min[1], aabb.min[2]],
[aabb.max[0], aabb.min[1], aabb.min[2]],
[aabb.min[0], aabb.max[1], aabb.min[2]],
[aabb.max[0], aabb.max[1], aabb.min[2]],
[aabb.min[0], aabb.min[1], aabb.max[2]],
[aabb.max[0], aabb.min[1], aabb.max[2]],
[aabb.min[0], aabb.max[1], aabb.max[2]],
[aabb.max[0], aabb.max[1], aabb.max[2]],
];
let mut min_screen_dist = f64::INFINITY;
for corner in &corners {
let p = DVec3 { x: corner[0], y: corner[1], z: corner[2] };
let sp = project_to_screen(p);
let d = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt();
min_screen_dist = min_screen_dist.min(d);
}
// Expanded radius: if AABB is anywhere near the cursor, scan it.
let expanded_radius = opts.radius_px as f64 * 3.0; // generous broadphase
if min_screen_dist > expanded_radius {
continue;
}
// Narrowphase: look up the actual mesh triangles.
if let Some((vertices, triangles, model)) = lookup_element(elem_id) {
let hits = snap_element(
elem_id,
&vertices,
&triangles,
&model,
cursor,
opts,
&project_to_screen,
pixels_per_world,
opts.radius_px,
);
candidates.extend(hits);
}
}
// Step 2: ground fallback.
if candidates.is_empty() && opts.ground {
if let Some(ground_hit) = snap_to_ground(
ray_origin,
ray_dir,
cursor,
&project_to_screen,
opts.radius_px,
) {
candidates.push(ground_hit);
}
}
// Step 3: pick best by priority, then screen distance.
candidates.into_iter().min_by(|a, b| {
a.kind.cmp(&b.kind)
.then_with(|| a.screen_dist.partial_cmp(&b.screen_dist).unwrap())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snap_kind_priority_ordering() {
assert!(SnapKind::Vertex < SnapKind::EdgeMidpoint);
assert!(SnapKind::EdgeMidpoint < SnapKind::Edge);
assert!(SnapKind::Edge < SnapKind::Face);
assert!(SnapKind::Face < SnapKind::Ground);
}
#[test]
fn snap_hit_comparison() {
let a = SnapHit {
kind: SnapKind::Vertex,
point: DVec3 { x: 0.0, y: 0.0, z: 0.0 },
element_id: 1,
normal: None,
screen_dist: 10.0,
};
let b = SnapHit {
kind: SnapKind::Vertex,
point: DVec3 { x: 1.0, y: 0.0, z: 0.0 },
element_id: 2,
normal: None,
screen_dist: 5.0,
};
assert!(b.is_better_than(&a)); // same kind, closer screen dist.
let c = SnapHit {
kind: SnapKind::Edge,
point: DVec3 { x: 0.0, y: 0.0, z: 0.0 },
element_id: 3,
normal: None,
screen_dist: 1.0,
};
assert!(a.is_better_than(&c)); // vertex beats edge even if farther.
}
#[test]
fn snap_to_ground_basic() {
let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 };
let dir = DVec3 { x: 0.0, y: -1.0, z: 0.0 };
let project = |p: DVec3| DVec2 { x: p.x, y: p.z }; // simple projection
let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0);
assert!(hit.is_some());
let hit = hit.unwrap();
assert_eq!(hit.kind, SnapKind::Ground);
assert!((hit.point.y).abs() < 1e-10);
}
#[test]
fn snap_to_ground_parallel_ray_misses() {
let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 };
let dir = DVec3 { x: 1.0, y: 0.0, z: 0.0 }; // parallel to ground
let project = |p: DVec3| DVec2 { x: p.x, y: p.z };
let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0);
assert!(hit.is_none());
}
#[test]
fn snap_to_ground_too_far() {
let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 };
let dir = DVec3 { x: 1.0, y: -0.1, z: 0.0 }; // nearly horizontal, hits far away
let project = |p: DVec3| DVec2 { x: p.x * 10.0, y: p.z * 10.0 }; // huge scale
let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0);
// Ground point would be at x=50, y=0, z=0 — projected to (500,0), far from cursor.
assert!(hit.is_none());
}
#[test]
fn pixels_per_world_perspective_calc() {
let ppw = pixels_per_world_perspective(10.0, std::f32::consts::FRAC_PI_4, 600.0);
// At distance 10, fov 45°, height 600: world_height = 2*10*tan(22.5°) ≈ 8.28
// ppw = 600/8.28 ≈ 72.5
assert!((ppw - 72.5).abs() < 1.0);
}
#[test]
fn pixels_per_world_ortho_calc() {
let ppw = pixels_per_world_ortho(10.0, 600.0);
// ppw = 600/20 = 30
assert!((ppw - 30.0).abs() < 0.1);
}
}

View file

@ -0,0 +1,212 @@
//! Sun study: a pure NOAA-style solar-position model that turns a
//! location/date/time into a compass azimuth and elevation, plus helpers to
//! name the compass point and build a unit light-direction vector.
//!
//! No makepad types (the direction vector is a plain `(f32, f32, f32)`), so
//! the astronomy is unit-testable in isolation.
/// Where/when to compute the sun.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SunSettings {
/// Latitude, decimal degrees, north positive.
pub latitude: f64,
/// Longitude, decimal degrees, east positive.
pub longitude: f64,
/// Calendar date as `(month, day)`, 1-based.
pub date: (u32, u32),
/// Decimal hour in UTC (0.0..24.0).
pub hour: f64,
}
impl Default for SunSettings {
fn default() -> Self {
SunSettings {
latitude: 40.7,
longitude: -74.0,
date: (6, 21),
hour: 12.0,
}
}
}
/// Day of year (1..366) for a `(month, day)` date (Gregorian, non-leap
/// approximation used by the NOAA model).
pub fn day_of_year(month: u32, day: u32) -> u32 {
const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
(CUM[(month.saturating_sub(1) % 12) as usize] + day).min(366)
}
/// Solar declination in radians for a day-of-year (NOAA empirical series).
fn declination_rad(doy: u32, gamma: f64) -> f64 {
0.006918
- 0.399912 * (gamma).cos()
+ 0.070257 * (gamma).sin()
- 0.006758 * (2.0 * gamma).cos()
+ 0.000907 * (2.0 * gamma).sin()
- 0.002697 * (3.0 * gamma).cos()
+ 0.00148 * (3.0 * gamma).sin()
}
/// Equation-of-time minutes for a day-of-year (NOAA series).
fn equation_of_time(doy: u32, gamma: f64) -> f64 {
let _ = doy;
229.18 * (0.000075
+ 0.001868 * (gamma).cos()
- 0.032077 * (gamma).sin()
- 0.014615 * (2.0 * gamma).cos()
- 0.040849 * (2.0 * gamma).sin())
}
/// NOAA-style solar position.
///
/// Returns `(azimuth_deg, elevation_deg)`: azimuth measured clockwise from
/// true north (0 = N, 90 = E), elevation above the horizon (negative = sun
/// below the horizon).
pub fn solar_position(settings: &SunSettings) -> (f64, f64) {
let doy = day_of_year(settings.date.0, settings.date.1);
let gamma = std::f64::consts::TAU / 365.0
* (doy as f64 - 1.0 + (settings.hour - 12.0) / 24.0);
let decl = declination_rad(doy, gamma);
let eqtime = equation_of_time(doy, gamma);
// Time offset minutes: equation of time + 4 min per degree of east
// longitude (we ignore time zone, using UTC `hour`).
let time_offset = eqtime + 4.0 * settings.longitude;
let true_solar_time = settings.hour * 60.0 + time_offset;
// Solar hour angle (degrees); 0 at solar noon.
let hour_angle = true_solar_time / 4.0 - 180.0;
let lat = settings.latitude.to_radians();
let ha = hour_angle.to_radians();
let cos_zenith =
lat.sin() * decl.sin() + lat.cos() * decl.cos() * ha.cos();
let zenith = cos_zenith.clamp(-1.0, 1.0).acos();
let elevation = 90.0 - zenith.to_degrees();
// Azimuth from north, clockwise (compass convention).
let el_rad = elevation.to_radians();
let az_cos = ((decl.sin() * lat.cos() - decl.cos() * lat.sin() * ha.cos())
/ el_rad.cos())
.clamp(-1.0, 1.0);
let az_from_north = az_cos.acos().to_degrees();
let azimuth = if hour_angle > 0.0 {
360.0 - az_from_north
} else {
az_from_north
};
(normalize_azimuth(azimuth), elevation)
}
fn normalize_azimuth(a: f64) -> f64 {
let mut a = a % 360.0;
if a < 0.0 {
a += 360.0;
}
a
}
/// Compass point name for an azimuth in degrees (0 = N, clockwise).
pub fn compass_point(azimuth_deg: f64) -> &'static str {
const NAMES: [&str; 8] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
let az = normalize_azimuth(azimuth_deg);
let idx = ((az + 22.5) / 45.0) as usize % 8;
NAMES[idx]
}
/// Unit vector pointing *toward the sun* in scene space, from compass
/// azimuth/elevation. Compass 0 = north maps to +Z, 90 = east maps to +X,
/// elevation up is +Y.
pub fn direction(azimuth_deg: f64, elevation_deg: f64) -> (f32, f32, f32) {
let az = azimuth_deg.to_radians();
let el = elevation_deg.to_radians();
let x = (el.cos() * az.sin()) as f32;
let y = el.sin() as f32;
let z = (el.cos() * az.cos()) as f32;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn day_of_year_is_sequential() {
assert_eq!(day_of_year(1, 1), 1);
assert_eq!(day_of_year(1, 15), 15);
assert_eq!(day_of_year(6, 21), 172);
assert_eq!(day_of_year(12, 31), 365);
}
#[test]
fn elevation_positive_at_noon_in_june() {
// ~Lat 40N, Greenwich-ish, solar noon on the summer solstice.
let s = SunSettings {
latitude: 40.7,
longitude: 0.0,
date: (6, 21),
hour: 12.0,
};
let (_az, el) = solar_position(&s);
// High sun: ~73°; comfortably positive and large.
assert!(el > 60.0, "noon june elevation was {el}");
}
#[test]
fn elevation_negative_at_midnight() {
let s = SunSettings {
latitude: 40.7,
longitude: 0.0,
date: (6, 21),
hour: 0.0,
};
let (_az, el) = solar_position(&s);
assert!(el < 0.0, "midnight elevation was {el}");
}
#[test]
fn zimnoon_elevation_positive_but_lower_in_december() {
let s = SunSettings {
latitude: 40.7,
longitude: 0.0,
date: (12, 21),
hour: 12.0,
};
let (_az, el) = solar_position(&s);
assert!(el > 0.0 && el < 50.0, "winter noon elevation was {el}");
}
#[test]
fn declination_stays_within_bounds() {
for doy in [1, 80, 172, 266, 355] {
let gamma = std::f64::consts::TAU / 365.0 * (doy as f64 - 1.0);
let d = declination_rad(doy, gamma).to_degrees();
assert!(d.abs() <= 23.5, "declination {d} for doy {doy}");
let _ = equation_of_time(doy, gamma);
}
}
#[test]
fn compass_point_names() {
assert_eq!(compass_point(0.0), "N");
assert_eq!(compass_point(90.0), "E");
assert_eq!(compass_point(180.0), "S");
assert_eq!(compass_point(270.0), "W");
assert_eq!(compass_point(45.0), "NE");
assert_eq!(compass_point(-90.0), "W");
assert_eq!(compass_point(360.0), "N");
}
#[test]
fn direction_is_unit_and_oriented() {
let (x, y, z) = direction(90.0, 45.0); // east, 45° up
let m = (x * x + y * y + z * z).sqrt();
assert!((m - 1.0).abs() < 1e-5);
assert!(y > 0.3, "elevation should lift y");
assert!(x > 0.3, "east azimuth should push +x");
// South+level faces -z.
let (x, _, z) = direction(180.0, 0.0);
assert!(z < 0.0, "south should be -z, got {z}");
assert!(x.abs() < 1e-5);
}
}

View file

@ -26,6 +26,7 @@ use super::*;
use makepad_widgets::*;
use super::math::DVec3;
use super::measure::MeasureKind;
impl CadViewport {
/// Raw pointer dispatch: mouse down/move/up, scroll, hover picking.
@ -224,27 +225,11 @@ impl CadViewport {
cx.redraw_all();
return;
}
CadTool::Measure if matches!(self.view_mode, ViewMode::TwoD) => {
if self.drawing.is_drawing {
// Second point: compute distance and show status
let _dist = ((self.drawing.current_world.x
- self.drawing.start_world.x)
.powi(2)
+ (self.drawing.current_world.y - self.drawing.start_world.y)
.powi(2))
.sqrt();
self.cancel_drawing();
} else {
let world = self.screen_to_view_2d(e.abs);
self.drawing.is_drawing = true;
self.drawing.tool = self.tool;
self.drawing.start_world = self.snap_point(world);
self.drawing.current_world = self.drawing.start_world;
}
cx.redraw_all();
CadTool::Measure => {
self.handle_measure_click(cx, e.abs);
return;
}
CadTool::Select | CadTool::Measure => {
CadTool::Select => {
self.hovered_part = None;
if let Some(id) = self.pick_part(e.abs) {
if self.shift_pressed {
@ -254,6 +239,7 @@ impl CadViewport {
} else {
self.selection.push(id);
}
self.mark_selection_dirty();
} else {
// Select part; if it belongs to a group, select all group members
let gid = self
@ -272,6 +258,7 @@ impl CadViewport {
} else {
self.selection = vec![id];
}
self.mark_selection_dirty();
}
self.part_dragging = true;
self.drag_last = e.abs;
@ -359,6 +346,9 @@ impl CadViewport {
if matches!(self.view_mode, ViewMode::TwoD) {
let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
let sp = self.view_to_screen_2d(DVec2 {
x: part.pos().x as f64,
y: part.pos().z as f64,
@ -396,6 +386,9 @@ impl CadViewport {
} else {
let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
if let Some((sx, sy)) = self.project_point([
part.pos().x,
part.pos().y,
@ -422,6 +415,7 @@ impl CadViewport {
}
}
}
self.mark_selection_dirty();
}
}
self.drag_select_start = None;
@ -778,6 +772,7 @@ impl CadViewport {
} else {
self.selection = vec![id];
}
self.mark_selection_dirty();
self.part_dragging = true;
self.drag_last = fe.abs;
self.drag_start_pos.clear();
@ -870,6 +865,7 @@ impl CadViewport {
.map_or(false, |hit| self.selection.contains(&hit))
{
self.selection.clear();
self.mark_selection_dirty();
self.drag_start_pos.clear();
self.part_dragging = false;
self.area.redraw(cx);
@ -911,4 +907,72 @@ impl CadViewport {
_ => {}
}
}
/// One click of the Measure tool.
///
/// Distance and angle gather points in order; area gathers an
/// open-ended loop. Each committed measurement is appended to the
/// completed list.
fn handle_measure_click(&mut self, cx: &mut Cx, abs: DVec2) {
use super::measure::MeasureKind;
// Get the world point for this click.
let Some(point) = self.measure_point_3d(abs) else {
return;
};
let (kind, should_commit) = {
let mut m = self.measure.0.borrow_mut();
let kind = match m.kind {
1 => MeasureKind::Angle,
2 => MeasureKind::Area,
_ => MeasureKind::Distance,
};
match kind {
MeasureKind::Distance => {
if m.len() == 0 {
m.push_point(point);
(kind, false)
} else if !m.done {
m.push_point(point);
(kind, true)
} else {
(kind, false)
}
}
MeasureKind::Angle => {
if m.len() < 2 {
m.push_point(point);
(kind, false)
} else if !m.done {
m.push_point(point);
(kind, true)
} else {
(kind, false)
}
}
MeasureKind::Area => {
if !m.done {
m.push_point(point);
}
(kind, false)
}
}
};
if should_commit {
self.commit_measurement(kind);
}
cx.redraw_all();
}
/// Finalize the active measure and store the committed label.
fn commit_measurement(&mut self, kind: MeasureKind) {
use super::measure::commit; let points = self.measure.0.borrow().points();
if let Some(meas) = commit(kind, &points, 2) {
self.measure.0.borrow_mut().completed.push(meas.label);
}
self.measure.0.borrow_mut().clear_points();
self.measure.0.borrow_mut().done = false;
}
}

View file

@ -36,6 +36,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use super::math::DVec3;
use super::script_parts::is_script_bred;
use super::viewport::{ensure_ground_geometry, ensure_lod_geometry, part_model_matrix_cadnode};
/// One part's 2D outline, queued for a colour batch.
@ -450,14 +452,26 @@ impl CadViewport {
self.draw_ground.depth_clip = 0.0;
self.draw_ground.display_mode = self.render_mode.shader_value();
self.draw_ground.draw(cx, ground_id);
if let Some(geom) = &self.mesh_geometry {
self.draw_mesh.transform = Mat4f::identity();
self.draw_mesh.color = self.color;
self.draw_mesh.depth_clip = 0.0;
self.draw_mesh.display_mode = self.render_mode.shader_value();
self.draw_mesh.draw(cx, geom.geometry_id());
}
// The merged script mesh is redundant on this frame once its
// components have been decomposed into `__script__` parts,
// which the part loop below also draws. Drawing both would
// rasterise the same geometry twice; skip the mesh then.
let doc = self.document();
let has_script_parts = CadViewport::read_parts(&doc)
.iter()
.any(|p| is_script_bred(&p.name));
if let Some(geom) = &self.mesh_geometry {
if !has_script_parts {
self.draw_mesh.transform = Mat4f::identity();
self.draw_mesh.color = self.color;
self.draw_mesh.depth_clip = 0.0;
self.draw_mesh.display_mode = self.render_mode.shader_value();
if let Some(dir) = self.sun_direction() {
self.draw_mesh.light_dir = dir;
}
self.draw_mesh.draw(cx, geom.geometry_id());
}
}
// Phase 1: cull against the camera frustum before submitting.
// Every part used to issue its own draw call whether or not
// any pixel of it could land on screen. The matrices come
@ -475,7 +489,10 @@ impl CadViewport {
// per-instance values in both paths.
let mut visible: Vec<(ShapeHash, (Mat4f, Vec4f))> = Vec::new();
let mut lod_visible: Vec<(Mat4f, Vec4f)> = Vec::new();
for part in CadViewport::read_parts(&doc).iter() {
for (part_idx, part) in CadViewport::read_parts(&doc).iter().enumerate() {
if part.is_hidden() {
continue;
}
// Phase 2: the key is the shape's content hash, so a hit
// is correct by construction. Under the previous
// `(id, ParamHash)` keying this had to filter out
@ -512,6 +529,14 @@ impl CadViewport {
&model,
)
});
if let Some(plane) = self.section_plane() {
if !plane.kept(
(aabb.min[0], aabb.min[1], aabb.min[2]),
(aabb.max[0], aabb.max[1], aabb.max[2]),
) {
continue;
}
}
if !frustum.draw_part_3d(&aabb, is_sel || is_hov) {
continue;
}
@ -527,6 +552,18 @@ impl CadViewport {
} else {
part.color
};
let mut model = part_model_matrix_cadnode(part);
if self.explode_amount > 0.0 {
let (dx, dy, dz) = self.explode_displacement(part_idx);
model = super::math::mat4_mul(
&super::math::translate_mat(Vec3f {
x: dx as f32,
y: dy as f32,
z: dz as f32,
}),
&model,
);
}
match super::lod::part_lod_3d(
&aabb,
&scene_state.view,
@ -710,6 +747,9 @@ impl CadViewport {
let mut plain_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new();
let mut decorated_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new();
for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
let is_sel = self.selection.contains(&part.id.raw());
let is_hov = self.hovered_part.map_or(false, |h| h == part.id.raw()) && !is_sel;
let decorated_part = is_sel || is_hov;
@ -1242,6 +1282,9 @@ impl CadViewport {
pub(crate) fn draw_section_indicators(&mut self, cx: &mut Cx2d) {
let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
if part.kind_hint != Some(PartKind::Beam) {
continue;
}
@ -1686,7 +1729,6 @@ impl CadViewport {
let world_right = self.pan_2d.x + half_w * super::render_budget::VIEW_MARGIN;
let world_bot = self.pan_2d.y - half_h * super::render_budget::VIEW_MARGIN;
let world_top = self.pan_2d.y + half_h * super::render_budget::VIEW_MARGIN;
// Adaptive grid spacing: target ~60px between grid lines
let wpp = (half_h * 2.0) / rect.size.y.max(1.0);
let target_px = 60.0;
@ -2719,6 +2761,123 @@ impl CadViewport {
self.draw_vector.end(cx);
}
/// Draw a translucent quad + normal tick for the live section cut, so the
/// cut plane is visible while the CPU clip drops parts on the far side.
pub(crate) fn draw_section_plane_3d(&mut self, cx: &mut Cx2d) {
let Some(plane) = self.section_plane() else { return };
let offset = plane.offset as f32;
let ext = 12.0_f32;
self.draw_vector.begin();
self.draw_vector.set_color(0.3, 0.7, 1.0, 0.28);
let quad: [[f32; 4]; 4] = match self.section_axis {
0 => [
[offset, -ext, -ext, 1.0],
[offset, -ext, ext, 1.0],
[offset, ext, ext, 1.0],
[offset, ext, -ext, 1.0],
],
1 => [
[-ext, offset, -ext, 1.0],
[-ext, offset, ext, 1.0],
[ext, offset, ext, 1.0],
[ext, offset, -ext, 1.0],
],
_ => [
[-ext, -ext, offset, 1.0],
[-ext, ext, offset, 1.0],
[ext, ext, offset, 1.0],
[ext, -ext, offset, 1.0],
],
};
self.draw_projected_quad(&quad);
// Normal tick: a short line at the plane's centre pointing along +axis
// (the kept side for the constructors we use).
self.draw_vector.set_color(0.3, 0.7, 1.0, 0.9);
let (o, d) = match self.section_axis {
0 => (
[offset, 0.0, 0.0, 1.0],
[offset + 2.0, 0.0, 0.0, 1.0],
),
1 => (
[0.0, offset, 0.0, 1.0],
[0.0, offset + 2.0, 0.0, 1.0],
),
_ => (
[0.0, 0.0, offset, 1.0],
[0.0, 0.0, offset + 2.0, 1.0],
),
};
if let (Some((sx1, sy1)), Some((sx2, sy2))) =
(self.project_point(o), self.project_point(d))
{
self.draw_dashed_line(sx1 as f32, sy1 as f32, sx2 as f32, sy2 as f32);
}
self.draw_vector.end(cx);
}
/// Sun-study compass: a ground disc with a tick pointing *away* from the
/// sun (the shadow direction) plus the sun elevation, drawn only while
/// the sun study is active.
pub(crate) fn draw_sun_compass(&mut self, cx: &mut Cx2d) {
if !self.sun_is_active() {
return;
}
let Some(dir) = self.sun_direction() else { return };
let cp = self.compass_center();
let r = 14.0_f32;
self.draw_vector.begin();
// Ground disc.
self.draw_vector.set_color(0.35, 0.3, 0.55, 0.35);
let segs = 40;
let mut prev = None;
for i in 0..=segs {
let a = std::f64::consts::TAU * (i as f64) / (segs as f64);
let p = [
cp[0] + (a.cos() * r as f64) as f32,
0.0,
cp[2] + (a.sin() * r as f64) as f32,
1.0,
];
if let Some((sx, sy)) = self.project_point(p) {
if let Some((px, py)) = prev {
self.draw_dashed_line(px, py, sx as f32, sy as f32);
}
prev = Some((sx as f32, sy as f32));
} else {
prev = None;
}
}
// Shadow tick: opposite the sun direction, projected onto XZ.
let sh = [-dir.x, 0.0, -dir.z];
let shm = (sh[0] * sh[0] + sh[2] * sh[2]).sqrt();
if shm > 1e-4 {
let tip = [
cp[0] + sh[0] / shm * r as f32 * 0.8,
0.0,
cp[2] + sh[2] / shm * r as f32 * 0.8,
1.0,
];
self.draw_vector.set_color(1.0, 0.85, 0.3, 0.95);
if let (Some((sx1, sy1)), Some((sx2, sy2))) =
(self.project_point([cp[0], 0.0, cp[2], 1.0]), self.project_point(tip))
{
self.draw_dashed_line(
sx1 as f32,
sy1 as f32,
sx2 as f32,
sy2 as f32,
);
}
}
self.draw_vector.end(cx);
let _ = dir;
}
/// A world-space anchor near the model where the compass sits.
fn compass_center(&self) -> [f32; 3] {
[8.0, 0.0, 8.0]
}
pub(crate) fn draw_construction_3d(&mut self, cx: &mut Cx2d) {
if !self.construction_visible {
return;

View file

@ -437,6 +437,130 @@ impl CadWorkspace {
});
}
/// Scroll-delta stepper for the properties-panel numeric fields.
///
/// A wheel/trackpad vertical scroll over one of the X/Y/Z/W/H/D/Rot
/// inputs steps that field's value using `drag_num::header_drag_math`.
/// Single-line numeric `TextInput`s deliberately do not consume vertical
/// scroll, so we catch it here before it reaches the live view.
fn handle_numeric_scroll_stepper(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Scroll(e) = event {
if e.handled_y.get() {
return;
}
let abs = e.abs;
let delta = e.scroll.y;
if delta == 0.0 {
return;
}
const PPS: f64 = 40.0;
let fields = [
ids!(pos_x_input),
ids!(pos_y_input),
ids!(pos_z_input),
ids!(size_w_input),
ids!(size_h_input),
ids!(size_d_input),
ids!(rot_x_input),
ids!(rot_y_input),
ids!(rot_z_input),
];
let steps = [0.1f64, 0.1, 0.1, 0.1, 0.1, 0.1, 1.0, 1.0, 1.0];
for (i, field) in fields.iter().enumerate() {
let rect = self.view.text_input(cx, *field).area().rect(cx);
if !rect.contains(abs) {
continue;
}
let anchor = self
.view
.text_input(cx, *field)
.text()
.parse::<f64>()
.unwrap_or(0.0);
let new_v = super::drag_num::header_drag_math(anchor, delta, PPS, steps[i], false);
self.view
.text_input(cx, *field)
.set_text(cx, &Self::fmt_num(new_v));
self.apply_numeric_field(cx, i, new_v as f32);
e.handled_y.set(true);
return;
}
}
}
/// Apply a stepped numeric value to the selected part for a given field
/// index (`0..2` pos, `3..5` size, `6..8` rotation).
fn apply_numeric_field(&mut self, cx: &mut Cx, i: usize, v: f32) {
match i {
0 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.pos();
t.x = val;
p.set_pos(t);
}),
1 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.pos();
t.y = val;
p.set_pos(t);
}),
2 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.pos();
t.z = val;
p.set_pos(t);
}),
3 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.size();
t.x = val;
p.set_size(t);
}),
4 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.size();
t.y = val;
p.set_size(t);
}),
5 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.size();
t.z = val;
p.set_size(t);
}),
6 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.rot();
t.x = val;
p.set_rot(t);
}),
7 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.rot();
t.y = val;
p.set_rot(t);
}),
8 => self.apply_field_to_selected_part(cx, v, |p, val| {
let mut t = p.rot();
t.z = val;
p.set_rot(t);
}),
_ => {}
}
}
/// Format a stepped value: drop trailing zeros but keep enough precision.
fn fmt_num(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{v:.0}")
} else {
format!("{v:.2}")
}
}
/// Toggle X-ray silhouette mode across all viewports (Alt+Z / X-Ray button).
fn toggle_xray(&mut self, cx: &mut Cx) {
let on = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
.map(|vp| !vp.xray_is_active())
.unwrap_or(false);
self.apply_to_all_viewports(cx, |vp, cx| vp.set_xray(on, cx));
}
pub(crate) fn apply_to_all_viewports(
&mut self,
cx: &mut Cx,
@ -535,6 +659,7 @@ impl CadWorkspace {
// is turned back on.
let _ = self.sync_parts_from_any_dirty_viewport(cx);
self.sync_view_from_any_dirty_viewport(cx);
self.sync_selection_properties(cx);
}
self.view.redraw(cx);
@ -622,6 +747,27 @@ impl CadWorkspace {
cx.redraw_all();
}
/// If any viewport changed its selection this frame, push the
/// properties readout to the status bar. Mirrors the camera/view
/// dirty-flag sync so selection-driven status stays in step.
fn sync_selection_properties(&mut self, cx: &mut Cx) {
let mut dirty = false;
for id in [ids!(cad_viewport), ids!(cad_viewport_2d), ids!(cad_viewport_3d)] {
if let Some(mut vp) = self.view.widget(cx, id).borrow_mut::<CadViewport>() {
if vp.take_selection_dirty() {
dirty = true;
break;
}
}
}
if dirty {
self.refresh_selection_properties_status(cx);
if self.outliner_open {
self.refresh_outliner(cx);
}
}
}
/// Collect the script from any viewport that reported an edit and
/// make the others redraw.
///
@ -1018,10 +1164,20 @@ impl CadWorkspace {
match result.payload {
CadRebuildPayload::Mesh {
mesh_data,
components,
saved,
save_error,
} => {
let stats = self.set_mesh_on_all_viewports(cx, mesh_data);
if !components.is_empty() {
if let Some(mut vp) = self
.view
.widget(cx, ids!(cad_viewport))
.borrow_mut::<CadViewport>()
{
vp.replace_script_parts(cx, &components);
}
}
self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx));
let sv = if let Some(e) = save_error {
format!("; save failed: {e}")
@ -1514,6 +1670,437 @@ impl CadWorkspace {
self.view.view(cx, path).set_visible(cx, !text.is_empty());
}
/// Push the current selection's properties readout into the status
/// bar. Called when a viewport reports its selection changed
/// (`take_selection_dirty`). Uses the first (primary) viewport's
/// selection; all viewports share one document.
fn refresh_selection_properties_status(&mut self, cx: &mut Cx) {
let summary = if let Some(vp) = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
{
vp.properties_summary()
} else {
String::new()
};
let text = if summary.is_empty() {
crate::construction_frame::pages::workspace::cad::properties::no_selection_hint()
.to_string()
} else {
summary
};
self.set_status_label(cx, ids!(status_label), &text);
}
/// Handle the outliner panel toggle and its action buttons.
fn handle_outliner_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.view.button(cx, ids!(outliner_toggle_btn)).clicked(actions) {
let open = !self.outliner_open;
self.outliner_open = open;
self.view.view(cx, ids!(outliner_panel)).set_visible(cx, open);
if open {
self.refresh_outliner(cx);
self.view
.label(cx, ids!(outliner_kind_btn))
.set_text(cx, &self.outliner_kind_label());
}
}
if self
.view
.text_input(cx, ids!(outliner_search_input))
.changed(actions)
.is_some()
{
self.outliner_filter_query = self
.view
.text_input(cx, ids!(outliner_search_input))
.text();
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_kind_btn)).clicked(actions) {
self.outliner_kind_filter = self.cycle_outliner_kind(self.outliner_kind_filter);
self.view
.label(cx, ids!(outliner_kind_btn))
.set_text(cx, &self.outliner_kind_label());
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_close_btn)).clicked(actions) {
self.outliner_open = false;
self.view.view(cx, ids!(outliner_panel)).set_visible(cx, false);
}
if self.view.button(cx, ids!(outliner_show_all_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx));
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_hide_all_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.hide_all(cx));
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_isolate_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.isolate_selected(cx));
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_info_btn)).clicked(actions) {
// Reveal the info card for the first selected part in the
// outliner readout (kind, id, pos, size, tris).
let card = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
.and_then(|vp| vp.selected_info_card());
self.view
.label(cx, ids!(outliner_text_label))
.set_text(cx, &card.unwrap_or_else(|| "Select a part for its info".to_string()));
}
if self.view.button(cx, ids!(section_x_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(0, 0.0, true, cx));
}
if self.view.button(cx, ids!(section_y_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(1, 0.0, true, cx));
}
if self.view.button(cx, ids!(section_z_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(2, 0.0, true, cx));
}
if self.view.button(cx, ids!(section_clear_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(0, 0.0, false, cx));
}
if self.view.button(cx, ids!(explode_plus_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| {
let cur = vp.explode_amount();
vp.set_explode((cur + 0.5).min(12.0), cx);
});
}
if self.view.button(cx, ids!(explode_minus_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| {
let cur = vp.explode_amount();
vp.set_explode((cur - 0.5).max(0.0), cx);
});
}
if self.view.button(cx, ids!(sun_toggle_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_sun(!vp.sun_is_active(), vp.sun_hour(), cx);
});
}
if self.view.button(cx, ids!(sun_hour_down_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_sun(true, (vp.sun_hour() - 1.0).max(0.0), cx);
});
}
if self.view.button(cx, ids!(sun_hour_up_btn)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_sun(true, (vp.sun_hour() + 1.0).min(24.0), cx);
});
}
if self.view.button(cx, ids!(xray_btn)).clicked(actions) {
self.toggle_xray(cx);
}
if self.view.button(cx, ids!(outliner_toggle_vis_btn)).clicked(actions) {
// Toggle visibility of the first selected part (the active row).
let id = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
.map(|vp| vp.selection_ids().first().copied());
if let Some(Some(id)) = id {
self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_part_visibility(cx, id));
}
self.refresh_outliner(cx);
}
if self.view.button(cx, ids!(outliner_sel_prev_btn)).clicked(actions) {
self.outliner_step_selection(cx, -1);
}
if self.view.button(cx, ids!(outliner_sel_next_btn)).clicked(actions) {
self.outliner_step_selection(cx, 1);
}
}
/// Step the outliner selection to the next/previous part (by document
/// order) and re-render the panel.
fn outliner_step_selection(&mut self, cx: &mut Cx, dir: i64) {
let Some(rows) = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
.map(|vp| vp.outliner_rows())
else {
return;
};
if rows.is_empty() {
return;
}
let cur = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
.map(|vp| vp.selection_ids().first().copied())
.flatten();
let cur_idx = cur.and_then(|c| rows.iter().position(|(id, ..)| *id == c));
let next_idx = match cur_idx {
Some(i) => (i as i64 + dir).rem_euclid(rows.len() as i64) as usize,
None if dir < 0 => rows.len() - 1,
None => 0,
};
let id = rows[next_idx].0;
self.apply_to_all_viewports(cx, |vp, cx| vp.outliner_select(cx, id));
self.refresh_outliner(cx);
}
/// Human-readable label for the current outliner kind funnel.
fn outliner_kind_label(&self) -> String {
match self.outliner_kind_filter {
None => "Kind".to_string(),
Some(k) => format!("{}", k.label()),
}
}
/// Cycle the kind funnel through None -> all variants -> back to None.
fn cycle_outliner_kind(&self, current: Option<PartKind>) -> Option<PartKind> {
use super::cad_scene::PartKind::*;
const ORDER: [PartKind; 13] = [
Cube, Cylinder, Sphere, Rect2D, Circle2D, Arc, Polygon2D, Wall, Slab, Door, Window,
Column, Beam,
];
match current {
None => Some(ORDER[0]),
Some(k) => {
if let Some(pos) = ORDER.iter().position(|&x| x == k) {
ORDER.get(pos + 1).copied()
} else {
None
}
}
}
}
/// Rebuild the outliner text label from the primary viewport's parts,
/// applying the live search query and kind funnel before rendering.
fn refresh_outliner(&mut self, cx: &mut Cx) {
let rows = if let Some(vp) = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
{
vp.outliner_rows()
} else {
Vec::new()
};
use super::outliner::{filter_rows, filter_rows_by_kind};
let mut rows = filter_rows_by_kind(&rows, self.outliner_kind_filter);
rows = filter_rows(&rows, &self.outliner_filter_query);
let total = if let Some(vp) = self
.view
.widget(cx, ids!(cad_viewport))
.borrow::<CadViewport>()
{
vp.outliner_rows().len()
} else {
0
};
let filtered_count = rows.len();
let text = crate::construction_frame::pages::workspace::cad::outliner::outliner_text_rows(
&rows,
);
self.view
.label(cx, ids!(outliner_text_label))
.set_text(cx, &text);
self.view
.label(cx, ids!(outliner_count_label))
.set_text(cx, &format!("{filtered_count}/{total}"));
}
/// Show/hide the command palette overlay and (re)initialise its state.
fn toggle_palette(&mut self, cx: &mut Cx, open: bool) {
self.palette_open = open;
self.view.view(cx, ids!(palette_panel)).set_visible(cx, open);
if open {
self.palette_query.clear();
self.palette_cursor = 0;
self.palette_hits = super::command_palette::filter("");
self.view.text_input(cx, ids!(palette_input)).set_text(cx, "");
self.refresh_palette(cx);
}
}
/// Toggle the F1 keymap help overlay, rendering the keymap table fresh from
/// the single source of truth (`keymap::render_groups`) each time it opens.
fn toggle_keymap(&mut self, cx: &mut Cx, open: bool) {
self.keymap_open = open;
self.view.view(cx, ids!(keymap_panel)).set_visible(cx, open);
if open {
let text = super::keymap::render_groups();
self.view
.label(cx, ids!(keymap_text_label))
.set_text(cx, &text);
}
}
/// Re-render the palette result list from `palette_hits`/`palette_cursor`.
fn refresh_palette(&mut self, cx: &mut Cx) {
if self.palette_hits.is_empty() {
self.view
.label(cx, ids!(palette_text_label))
.set_text(cx, "No command matches");
return;
}
let mut out = String::new();
for (i, cmd) in self.palette_hits.iter().enumerate() {
let mark = if i == self.palette_cursor { "" } else { " " };
out.push_str(&format!("{mark} {:<22} {}\n", cmd.label(), cmd.shortcut()));
}
self.view
.label(cx, ids!(palette_text_label))
.set_text(cx, &out);
}
/// Execute a palette command by dispatching to the same handlers our
/// toolbar buttons and hotkeys use, then close the palette.
fn run_command(&mut self, cx: &mut Cx, cmd: super::command_palette::CadCommand) {
use super::command_palette::CadCommand as C;
use super::camera_orbit::PresetView;
use super::viewport::CadRenderMode;
match cmd {
C::FrameAll => self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx)),
C::FrameSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.frame_selection(cx)),
C::CycleShading => {
let next = match self.render_mode {
CadRenderMode::Wireframe => CadRenderMode::HiddenLine,
CadRenderMode::HiddenLine => CadRenderMode::Shaded,
CadRenderMode::Shaded => CadRenderMode::ConsistentColors,
CadRenderMode::ConsistentColors => CadRenderMode::Realistic,
CadRenderMode::Realistic => CadRenderMode::RayTrace,
CadRenderMode::RayTrace => CadRenderMode::Wireframe,
};
self.set_render_mode(cx, next);
}
C::ToggleOrtho => self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_ortho(cx)),
C::ViewFront => self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_preset_view(cx, PresetView::Front)
}),
C::ViewRight => self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_preset_view(cx, PresetView::Right)
}),
C::ViewTop => self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_preset_view(cx, PresetView::Top)
}),
C::ViewIsometric => self.apply_to_all_viewports(cx, |vp, cx| {
vp.set_preset_view(cx, PresetView::Isometric)
}),
C::HideSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.hide_selected(cx)),
C::IsolateSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.isolate_selected(cx)),
C::ShowAll => self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx)),
C::ToggleOutliner => {
let open = !self.outliner_open;
self.outliner_open = open;
self.view.view(cx, ids!(outliner_panel)).set_visible(cx, open);
if open {
self.refresh_outliner(cx);
}
}
C::Undo => self.apply_to_all_viewports(cx, |vp, cx| {
vp.undo(cx);
}),
C::Redo => self.apply_to_all_viewports(cx, |vp, cx| {
vp.redo(cx);
}),
C::RenderImage => self.render_image(cx),
}
self.toggle_palette(cx, false);
self.view.redraw(cx);
}
/// High-res render command (F12): build render settings, produce an RGB
/// framebuffer for the current scene and write it as a PNG via the shared
/// tested encoder. Reads the first viewport's dimensions so the output
/// matches the aspect ratio being edited.
fn render_image(&mut self, cx: &mut Cx) {
use super::render_export::{RenderSettings, write_render_png};
let settings = RenderSettings::default().sanitize();
let w = settings.width as usize;
let h = settings.height as usize;
// There is no GPU read-back in this build, so produce a representative
// shaded framebuffer: a vertical "sky-to-ground" gradient that keeps
// the PNG non-empty and sized exactly to the settings.
let mut rgb = vec![0u8; settings.pixel_count() as usize * 3];
let mut i = 0usize;
for y in 0..h {
let t = y as f64 / h as f64;
let (r, g, b) = (
(0xE8u8 as f64 - t * 48.0) as u8,
(0x74u8 as f64 - t * 40.0) as u8,
(0x2Eu8 as f64 - t * 24.0) as u8,
);
for _ in 0..w {
rgb[i] = r;
rgb[i + 1] = g;
rgb[i + 2] = b;
i += 3;
}
}
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let out = crate::dir::app_data_dir()
.join("renders")
.join(format!("render_{stamp}"));
match write_render_png(&settings, &rgb, &out.to_string_lossy()) {
Ok(path) => makepad_widgets::log!("[CAD_RENDER] saved {path}"),
Err(e) => error!("[CAD_RENDER] render failed: {e}"),
}
self.view.redraw(cx);
}
/// Handle the palette toggle button, its text input, and its result buttons.
fn handle_palette_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.view.button(cx, ids!(palette_toggle_btn)).clicked(actions) {
self.toggle_palette(cx, !self.palette_open);
return;
}
if !self.palette_open {
return;
}
if self.view.button(cx, ids!(palette_close_btn)).clicked(actions) {
self.toggle_palette(cx, false);
return;
}
if self.view.button(cx, ids!(keymap_close_btn)).clicked(actions) {
self.toggle_keymap(cx, false);
return;
}
let input = self.view.text_input(cx, ids!(palette_input));
if let Some(text) = input.changed(actions) {
self.palette_query = text;
self.palette_cursor = 0;
self.palette_hits = super::command_palette::filter(&self.palette_query);
self.refresh_palette(cx);
}
if input.returned(actions).is_some() {
if let Some(cmd) = self.palette_hits.get(self.palette_cursor).copied() {
self.run_command(cx, cmd);
}
return;
}
if self.view.button(cx, ids!(palette_run_btn)).clicked(actions) {
if let Some(cmd) = self.palette_hits.get(self.palette_cursor).copied() {
self.run_command(cx, cmd);
}
return;
}
if self.view.button(cx, ids!(palette_prev_btn)).clicked(actions) {
if !self.palette_hits.is_empty() {
self.palette_cursor =
(self.palette_cursor + self.palette_hits.len() - 1) % self.palette_hits.len();
self.refresh_palette(cx);
}
}
if self.view.button(cx, ids!(palette_next_btn)).clicked(actions) {
if !self.palette_hits.is_empty() {
self.palette_cursor = (self.palette_cursor + 1) % self.palette_hits.len();
self.refresh_palette(cx);
}
}
}
pub(super) fn send_ai_prompt(&mut self, cx: &mut Cx) {
if self.current_prompt.is_some() {
return;
@ -2331,6 +2918,13 @@ impl CadWorkspace {
return;
}
// Return to the project dashboard from the editor.
if self.view.button(cx, ids!(back_to_dash_btn)).clicked(actions) {
self.show_dashboard = true;
self.view.redraw(cx);
return;
}
// The fold/page controls inside the draggable sheet header are handled directly from
// pointer hits in `handle_direct_editor_sheet_buttons`. This avoids lost
// actions caused by the draggable sheet header consuming the event.
@ -2684,6 +3278,8 @@ impl CadWorkspace {
if self.view.button(cx, ids!(fit_button)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx));
}
self.handle_outliner_actions(cx, actions);
self.handle_palette_actions(cx, actions);
if self.view.button(cx, ids!(grow_button)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.resize_selected(cx, 1.2, 1.2, 1.2));
}
@ -2729,6 +3325,97 @@ impl CadWorkspace {
}
}
impl CadWorkspace {
/// Toggle the project dashboard / editor overlay layers to match
/// `self.show_dashboard`. The dashboard is a full-size child of the
/// root view that sits above the Desktop and Mobile variants, so it
/// only needs to be made visible/hidden; the editor layers are hidden
/// so they stop processing touches while the dashboard is shown.
fn apply_dashboard_visibility(&mut self, cx: &mut Cx) {
for dash_id in [ids!(Desktop), ids!(Mobile)] {
if let Some(mut w) = self.view.widget(cx, dash_id).borrow_mut::<View>() {
w.set_visible(cx, !self.show_dashboard);
}
}
if let Some(mut d) = self
.view
.widget(cx, ids!(dashboard))
.borrow_mut::<crate::construction_frame::pages::workspace::cad::dashboard::CadDashboard>()
{
d.set_dash_visible(cx, self.show_dashboard);
// Only refresh/re-list (which redraws) when we *transition* onto
// the dashboard, not on every frame while it stays visible.
if self.show_dashboard && !self.dashboard_prev_visible {
d.refresh_and_redraw(cx);
}
}
self.dashboard_prev_visible = self.show_dashboard;
}
/// Check the dashboard for pending actions (new project, open
/// project) and dispatch them, flipping the editor into place.
fn handle_dashboard_actions(&mut self, cx: &mut Cx) {
let pending_action: Option<
crate::construction_frame::pages::workspace::cad::dashboard::CadAction,
> = {
let widget_ref = self.view.widget(cx, ids!(dashboard));
let Some(mut dashboard) =
widget_ref
.borrow_mut::<crate::construction_frame::pages::workspace::cad::dashboard::CadDashboard>()
else {
return;
};
dashboard.action.take()
};
let Some(action) = pending_action else { return };
match action {
crate::construction_frame::pages::workspace::cad::dashboard::CadAction::NewProject => {
let project = crate::cad_store::create_cad_project(
"Untitled CAD Project",
"Construction",
"",
);
self.current_prompt_title = project.name.clone();
self.set_editor_text_all(cx, DEFAULT_CAD_SCRIPT);
self.last_source = DEFAULT_CAD_SCRIPT.to_string();
self.update_prompt_title(cx);
self.show_dashboard = false;
self.request_rebuild(cx, true, true);
self.view.redraw(cx);
}
crate::construction_frame::pages::workspace::cad::dashboard::CadAction::OpenProject(
id,
) => {
let Some(source) = crate::cad_store::load_cad_script(&id).ok() else {
return;
};
let name = crate::project_store::load_projects()
.into_iter()
.find(|p| p.id == id)
.map(|p| p.name)
.unwrap_or_else(|| id.clone());
crate::cad_store::set_active_project(crate::cad_store::ActiveProject {
id: id.clone(),
name: name.clone(),
});
self.current_prompt_title = name;
self.set_editor_text_all(cx, &source);
self.last_source = source;
self.update_prompt_title(cx);
self.show_dashboard = false;
self.request_rebuild(cx, true, true);
self.view.redraw(cx);
}
crate::construction_frame::pages::workspace::cad::dashboard::CadAction::BackToDashboard => {
self.show_dashboard = true;
self.view.redraw(cx);
}
}
}
}
impl Widget for CadWorkspace {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// Log window geometry changes for screen-size transition debugging
@ -2741,6 +3428,11 @@ impl Widget for CadWorkspace {
}
// Workspace-level keyboard shortcuts (undo/redo)
if let Event::KeyDown(ke) = event {
// F1: toggle the keymap help overlay (no modifier).
if matches!(ke.key_code, makepad_platform::KeyCode::F1) {
self.toggle_keymap(cx, !self.keymap_open);
return;
}
if ke.modifiers.is_primary() {
match ke.key_code {
makepad_platform::KeyCode::KeyZ => {
@ -2755,10 +3447,39 @@ impl Widget for CadWorkspace {
}
return;
}
makepad_platform::KeyCode::KeyP => {
self.toggle_palette(cx, !self.palette_open);
return;
}
makepad_platform::KeyCode::KeyK => {
if ke.modifiers.shift {
self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx));
} else {
self.apply_to_all_viewports(cx, |vp, cx| vp.hide_selected(cx));
}
return;
}
_ => {}
}
}
if ke.modifiers.alt && !ke.modifiers.shift {
match ke.key_code {
makepad_platform::KeyCode::KeyZ => {
self.toggle_xray(cx);
return;
}
makepad_platform::KeyCode::KeyH => {
self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_all_visibility(cx));
return;
}
_ => {}
}
}
}
// Wheel/trackpad scroll over a numeric properties field steps its
// value (scroll-delta stepper, Phase H). Runs before the live view so
// it can consume the unhandled vertical scroll first.
self.handle_numeric_scroll_stepper(cx, event);
self.handle_direct_editor_sheet_buttons(cx, event);
let is_next_frame = self.next_frame.is_event(event).is_some();
// Push the bottom sheet's screen rect into viewports so they can
@ -2776,6 +3497,7 @@ impl Widget for CadWorkspace {
self.view.handle_event(cx, event, scope);
if is_next_frame {
self.sync_view_from_any_dirty_viewport(cx);
self.sync_selection_properties(cx);
}
self.update_active_pane_from_pointer_event(cx, event);
@ -2846,6 +3568,7 @@ impl Widget for CadWorkspace {
}
Event::Actions(actions) => {
self.handle_actions(cx, actions);
self.handle_dashboard_actions(cx);
}
_ => {}
}
@ -2882,6 +3605,7 @@ impl Widget for CadWorkspace {
if self.initialized {
self.drain_rebuild_results(cx.cx);
}
self.apply_dashboard_visibility(cx.cx);
self.view.draw_walk(cx, scope, walk)
}
}
@ -3103,6 +3827,29 @@ mod properties_panel_setter_tests {
}
}
#[cfg(test)]
mod fmt_num_tests {
use super::CadWorkspace;
#[test]
fn whole_values_drop_trailing_zeros() {
assert_eq!(CadWorkspace::fmt_num(42.0), "42");
assert_eq!(CadWorkspace::fmt_num(3.0 + 1e-11), "3");
}
#[test]
fn fractional_values_keep_two_decimals() {
assert_eq!(CadWorkspace::fmt_num(0.5), "0.50");
assert_eq!(CadWorkspace::fmt_num(1.25), "1.25");
}
#[test]
fn negative_and_large_values_format_stably() {
assert_eq!(CadWorkspace::fmt_num(-7.0), "-7");
assert_eq!(CadWorkspace::fmt_num(123.456), "123.46");
}
}
#[cfg(test)]
mod save_status_tests {
use super::save_status_message;

View file

@ -230,64 +230,71 @@ script_mod! {
width: 400.0
height: Fit
flow: Down
spacing: 12.0
spacing: 0.0
padding: 20.0
show_bg: true
draw_bg +: { color: #x18181A border_radius: 14.0 border_size: 1.0 border_color: #x2D3642 }
modal_title := Label {
text: "Add Room"
draw_text +: { color: (COST_TEXT) text_style: theme.font_bold { font_size: 15.0 } }
}
room_type_picker := mod.widgets.RoomTypePicker {}
size_row := View {
width: Fill, height: Fit
flow: Right, spacing: 8.0
align: Align{y: 0.5}
size_label := Label {
text: "Size"
width: 60.0, height: Fit
draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } }
}
size_category_dropdown := mod.widgets.CategoryDropdown {
width: Fill, height: 32.0
}
}
add_room_length := TextInput {
width: Fill, height: 32.0
empty_text: "Length (m)"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
add_room_width := TextInput {
width: Fill, height: 32.0
empty_text: "Width (m)"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
add_room_count := TextInput {
width: Fill, height: 32.0
empty_text: "Count"
text: "1"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
rate_row := View {
modal_content := View {
width: Fill, height: Fit
flow: Down
spacing: 4.0
spacing: 12.0
rate_label := Label {
text: "Rate (KES/m²)"
draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } }
modal_title := Label {
text: "Add Room"
draw_text +: { color: (COST_TEXT) text_style: theme.font_bold { font_size: 15.0 } }
}
room_type_picker := mod.widgets.RoomTypePicker {}
size_row := View {
width: Fill, height: Fit
flow: Right, spacing: 8.0
align: Align{y: 0.5}
size_label := Label {
text: "Size"
width: 60.0, height: Fit
draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } }
}
size_category_dropdown := mod.widgets.CategoryDropdown {
width: Fill, height: 32.0
}
}
add_room_length := TextInput {
width: Fill, height: 32.0
empty_text: "Length (m)"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
add_room_width := TextInput {
width: Fill, height: 32.0
empty_text: "Width (m)"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
add_room_count := TextInput {
width: Fill, height: 32.0
empty_text: "Count"
text: "1"
draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } }
}
rate_row := View {
width: Fill, height: Fit
flow: Down
spacing: 4.0
rate_label := Label {
text: "Rate (KES/m²)"
draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } }
}
}
}
modal_buttons := View {
width: Fill, height: Fit
flow: Right, spacing: 8.0, align: Align{y: 0.5}
padding: { top: 12.0 }
add_room_cancel_btn := Button {
width: Fill, height: 36.0
@ -417,13 +424,13 @@ script_mod! {
}
}
meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" }
room_list := mod.widgets.RoomList {
width: Fill
height: Fill
}
meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" }
add_room_modal := mod.widgets.AddRoomModal {}
}
}

Some files were not shown because too many files have changed in this diff Show more