Compare commits

..

No commits in common. "1d8b3a60536cc1d1d74a7c694ece62b8a2f3d70e" and "8bf62e26447cbd715567d63445c005ca10715f6b" have entirely different histories.

179 changed files with 2698 additions and 13995 deletions

859
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -71,7 +71,6 @@ members = [
"crates/apps/spreadsheet/spreadsheet-engine", "crates/apps/spreadsheet/spreadsheet-engine",
"crates/apps/spreadsheet/spreadsheet-ui", "crates/apps/spreadsheet/spreadsheet-ui",
"crates/apps/doc/doc-engine", "crates/apps/doc/doc-engine",
"crates/apps/doc/doc-ui",
"crates/apps/pdf/pdf-cos", "crates/apps/pdf/pdf-cos",
"crates/apps/pdf/pdf-document", "crates/apps/pdf/pdf-document",
"crates/apps/pdf/pdf-graphics", "crates/apps/pdf/pdf-graphics",
@ -94,19 +93,3 @@ lto = true # Enable link-time optimization
codegen-units = 1 # Reduce number of codegen units to increase optimizations codegen-units = 1 # Reduce number of codegen units to increase optimizations
panic = 'abort' # Abort on panic panic = 'abort' # Abort on panic
strip = true 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

@ -1,232 +0,0 @@
# 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

@ -1,19 +0,0 @@
[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

@ -1,290 +0,0 @@
//! `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

@ -1,598 +0,0 @@
//! 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,193 +0,0 @@
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

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
makepad-widgets = { workspace = true } makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"}
nigig-core = { path = "../../nigig-core" } nigig-core = { path = "../../nigig-core" }
nigig-uikit = { path = "../../nigig-uikit" } nigig-uikit = { path = "../../nigig-uikit" }
serde = { version = "1", features = ["derive"] } 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. # makepad from every other crate here. Verified to compile against the pin.
# For local makepad dev, replace with: # For local makepad dev, replace with:
# makepad-widgets = { path = "../../widgets" } # makepad-widgets = { path = "../../widgets" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
# Image format sniffing for attached cells, matching what the Robrix-derived # Image format sniffing for attached cells, matching what the Robrix-derived
# app in this repo (`pageflipnav/src/utils.rs`) uses. Zero transitive # 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" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-table = { path = "../.." } makepad-table = { path = "../.." }
makepad-doc-model = { path = "../../crates/doc-model" } makepad-doc-model = { path = "../../crates/doc-model" }
makepad-pdf-export = { path = "../../crates/pdf-export" } 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" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" }
makepad-table = { path = "../.." } makepad-table = { path = "../.." }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::dir::app_data_dir; use crate::dir::app_data_dir;
use crate::project_store::{self, ProjectRecord};
pub const CAD_FILE_EXTENSION: &str = "cad"; pub const CAD_FILE_EXTENSION: &str = "cad";
@ -29,48 +28,25 @@ pub fn clear_active_project() {
ACTIVE_PROJECT.with(|slot| *slot.borrow_mut() = None); 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 { pub fn cad_projects_dir() -> PathBuf {
let dir = store_dir().join("cad"); let dir = app_data_dir().join("nigig_build_store").join("cad");
if let Err(e) = fs::create_dir_all(&dir) { if let Err(e) = fs::create_dir_all(&dir) {
makepad_widgets::error!("cad_store: failed creating dir {:?}: {}", dir, e); makepad_widgets::error!("cad_store: failed creating dir {:?}: {}", dir, e);
} }
dir dir
} }
pub fn cad_projects_dir_in(dir: &std::path::Path) -> PathBuf {
dir.join("cad")
}
pub fn cad_file_path(project_id: &str) -> PathBuf { pub fn cad_file_path(project_id: &str) -> PathBuf {
cad_file_path_in(&store_dir(), project_id) cad_projects_dir().join(format!("{}.{}", project_id, CAD_FILE_EXTENSION))
}
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> { pub fn save_cad_script(project_id: &str, source: &str) -> Result<(), String> {
save_cad_script_in(&store_dir(), project_id, source) let path = cad_file_path(project_id);
}
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)) 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> { pub fn load_cad_script(project_id: &str) -> Result<String, String> {
load_cad_script_in(&store_dir(), project_id) let path = cad_file_path(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)) fs::read_to_string(&path).map_err(|e| format!("failed to load CAD script: {}", e))
} }
@ -119,166 +95,3 @@ pub fn export_mesh_to_obj(
Ok(()) 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,7 +11,6 @@ script_mod! {
build_action_page_flip := PageFlip { build_action_page_flip := PageFlip {
width: Fill, height: Fill width: Fill, height: Fill
lazy_init: true
active_page: @projects_page active_page: @projects_page
projects_page := View { projects_page := View {

View file

@ -1,800 +0,0 @@
//! 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,23 +986,6 @@ impl CadNode {
pub fn group_id(&self) -> Option<u64> { pub fn group_id(&self) -> Option<u64> {
self.parent.map(|p| p.raw()) 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> { pub fn dof_constraint(&self) -> Option<DofConstraint> {
self.metadata.dof_constraint self.metadata.dof_constraint
} }
@ -2385,26 +2368,6 @@ pub enum PartKind {
Beam, 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 // Tests
// =========================================================================== // ===========================================================================

View file

@ -1,414 +0,0 @@
//! 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

@ -1,218 +0,0 @@
//! 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,20 +621,12 @@ impl CommandContext for CadCommandCtx<'_> {
node: crate::construction_frame::pages::workspace::cad::cad_scene::CadNode, node: crate::construction_frame::pages::workspace::cad::cad_scene::CadNode,
) -> Result<NodeId, CommandError> { ) -> Result<NodeId, CommandError> {
let id = node.id; let id = node.id;
// Node ids must be unique. A duplicate id means the caller already self.parts.push(node);
// 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.scene_cache.mark_dirty();
self.invalidate_snapshot(); 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) Ok(id)
} }
@ -2235,32 +2227,6 @@ mod real_context_tests {
} }
assert_eq!(store.iter().count(), 0); 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)] #[cfg(test)]

View file

@ -1,288 +0,0 @@
//! `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

@ -1,98 +0,0 @@
//! 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

@ -1,110 +0,0 @@
//! 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

@ -1,195 +0,0 @@
//! 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

@ -1,368 +0,0 @@
//! 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,6 +25,7 @@ use makepad_code_editor::{
CodeDocument, CodeEditor, CodeSession, CodeDocument, CodeEditor, CodeSession,
}; };
use makepad_draw::DrawVector; use makepad_draw::DrawVector;
use makepad_widgets::adaptive_view::AdaptiveView;
use makepad_widgets::makepad_platform::event::TouchState; use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::makepad_platform::{makepad_script::ScriptVmBase, thread::SignalToUI}; use makepad_widgets::makepad_platform::{makepad_script::ScriptVmBase, thread::SignalToUI};
use makepad_widgets::*; use makepad_widgets::*;
@ -50,21 +51,6 @@ pub mod arch_svg;
// the grouping is tested even though the GPU submission cannot be. // the grouping is tested even though the GPU submission cannot be.
// Phase 3 of the render plan. // Phase 3 of the render plan.
pub mod batching; 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 cost_estimator;
pub mod cad_editor_sheet; pub mod cad_editor_sheet;
// cad_scene: immutable scene graph + Exporter trait + SceneVisitor + MeshCache. // cad_scene: immutable scene graph + Exporter trait + SceneVisitor + MeshCache.
@ -181,9 +167,6 @@ pub struct DrawCadMesh {
light_dir: Vec3f, light_dir: Vec3f,
#[rust(vec3(0.62, 0.42, -0.58))] #[rust(vec3(0.62, 0.42, -0.58))]
fill_dir: Vec3f, 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 /// Open instanced batch, if one is running. Phase 3 of the render
/// plan. /// plan.
/// ///
@ -406,7 +389,6 @@ script_mod! {
v_world: varying(vec3f) v_world: varying(vec3f)
v_normal: varying(vec3f) v_normal: varying(vec3f)
display_mode: 4.0 display_mode: 4.0
xray: uniform(float, 0.0)
active_camera_world_pos: fn() -> vec3f { active_camera_world_pos: fn() -> vec3f {
let camera_world = self.draw_pass.camera_inv * vec4(0.0, 0.0, 0.0, 1.0) let camera_world = self.draw_pass.camera_inv * vec4(0.0, 0.0, 0.0, 1.0)
@ -451,14 +433,6 @@ script_mod! {
let fill = abs(dot(normal, normalize(self.u_fill_dir))) 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) 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 { if self.display_mode < 0.5 {
// Wireframe: filled surfaces are skipped in Rust draw_scene(); // Wireframe: filled surfaces are skipped in Rust draw_scene();
// this fallback stays very dark if a mesh accidentally reaches here. // this fallback stays very dark if a mesh accidentally reaches here.
@ -543,24 +517,17 @@ script_mod! {
mod.widgets.CadWorkspaceBase = #(CadWorkspace::register_widget(vm)) mod.widgets.CadWorkspaceBase = #(CadWorkspace::register_widget(vm))
mod.widgets.CadWorkspace = set_type_default() do mod.widgets.CadWorkspaceBase{ mod.widgets.CadWorkspace = set_type_default() do mod.widgets.CadWorkspaceBase{
width: Fill, height: Fill width: Fill, height: Fill
flow: Overlay
// =============== Editor variants (Desktop/Mobile) =============== // =============== Desktop variant (wide screens) ===============
// The responsive Desktop/Mobile layouts live inside a nested // Layout: header at top, then an Overlay area where:
// AdaptiveView so the whole set can be covered by (or replaced by) // - cad_viewport fills the entire area
// the project dashboard overlay drawn on top when `show_dashboard`. // - viewport_toolbar floats over the top-left of the viewport
editor_variant := mod.widgets.AdaptiveView { // - 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 width: Fill, height: Fill
// =============== Desktop variant (wide screens) =============== flow: Down
// 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 { workspace_header := SolidView {
width: Fill; height: Fit width: Fill; height: Fit
@ -589,10 +556,6 @@ script_mod! {
draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 6.0 } draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 6.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 10.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" 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_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 6.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} } draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} }
@ -1323,10 +1286,6 @@ script_mod! {
draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 5.0 } draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 5.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.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" 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_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 5.0 }
draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} } draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} }
@ -1523,8 +1482,6 @@ script_mod! {
zoom_in_button := Button{ width: 28.0 text: "+" } zoom_in_button := Button{ width: 28.0 text: "+" }
zoom_out_button := Button{ width: 28.0 text: "-" } zoom_out_button := Button{ width: 28.0 text: "-" }
fit_button := Button{ width: 30.0 text: "Fit" } 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 { row3 := View {
@ -1553,191 +1510,6 @@ 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 — stacked: AI prompt row on top, script editor below ===
bottom_overlay_slot := View { bottom_overlay_slot := View {
width: Fill width: Fill
@ -1852,10 +1624,6 @@ 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 {}
} }
// =========================================================================== // ===========================================================================
@ -1868,27 +1636,6 @@ script_mod! {
// [moved to viewport.rs: struct CadViewportViewSnapshot] // [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)] #[derive(Script, ScriptHook, WidgetRef, WidgetSet, WidgetRegister)]
pub struct CadViewport { pub struct CadViewport {
#[uid] #[uid]
@ -1919,10 +1666,6 @@ pub struct CadViewport {
ground_color: Vec4f, ground_color: Vec4f,
#[live] #[live]
camera: XrCamera, camera: XrCamera,
#[rust(false)]
ortho_enabled: bool,
#[rust(10.0f32)]
ortho_height: f32,
#[new] #[new]
pass: DrawPass, pass: DrawPass,
#[new] #[new]
@ -1995,13 +1738,6 @@ pub struct CadViewport {
/// `mark_dirty()` + `invalidate_node(id)`. /// `mark_dirty()` + `invalidate_node(id)`.
#[rust] #[rust]
scene_cache: SceneCache, 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 /// Shared with the other two viewports (see
/// `CadWorkspace::share_part_id_allocator`). Not a plain `u64`: /// `CadWorkspace::share_part_id_allocator`). Not a plain `u64`:
/// three independent counters synced by copy reissued live ids. /// three independent counters synced by copy reissued live ids.
@ -2009,30 +1745,10 @@ pub struct CadViewport {
part_ids: PartIdAllocator, part_ids: PartIdAllocator,
#[rust] #[rust]
selection: Vec<u64>, selection: Vec<u64>,
#[rust(false)]
selection_dirty: bool,
#[rust(ViewMode::ThreeD)] #[rust(ViewMode::ThreeD)]
view_mode: ViewMode, view_mode: ViewMode,
#[rust(CadRenderMode::Realistic)] #[rust(CadRenderMode::Realistic)]
render_mode: CadRenderMode, 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)] #[rust(2.6f32)]
ortho_zoom: f32, ortho_zoom: f32,
#[rust] #[rust]
@ -2131,9 +1847,6 @@ pub struct CadViewport {
tool: CadTool, tool: CadTool,
#[rust] #[rust]
drawing: DrawingState, drawing: DrawingState,
/// In-progress / completed measurement state for the Measure tool.
#[rust]
measure: MeasureState,
#[rust] #[rust]
snap: SnapSettings, snap: SnapSettings,
// ---- Profile: frame timing diagnostics ---- // ---- Profile: frame timing diagnostics ----
@ -2304,7 +2017,7 @@ enum CadViewportLayoutMode {
#[derive(Script, ScriptHook, Widget)] #[derive(Script, ScriptHook, Widget)]
pub struct CadWorkspace { pub struct CadWorkspace {
#[deref] #[deref]
view: View, view: AdaptiveView,
#[rust(false)] #[rust(false)]
initialized: bool, initialized: bool,
#[rust] #[rust]
@ -2350,17 +2063,6 @@ pub struct CadWorkspace {
#[rust(true)] #[rust(true)]
editors_visible: bool, 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 /// 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 /// 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 /// on every event so the viewport's `blocked_by_sheet` guard works from the
@ -2409,38 +2111,6 @@ pub struct CadWorkspace {
attached_image_base64: Option<String>, attached_image_base64: Option<String>,
#[rust(None)] #[rust(None)]
attached_image_filename: Option<String>, 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] // [extracted to impl CadWorkspace]
@ -2451,7 +2121,6 @@ pub fn register_cad(vm: &mut ScriptVm) {
cad_script_mod(vm); cad_script_mod(vm);
cost_estimator::script_mod(vm); cost_estimator::script_mod(vm);
cad_editor_sheet::script_mod(vm); cad_editor_sheet::script_mod(vm);
dashboard::script_mod(vm);
script_mod(vm); script_mod(vm);
} }

View file

@ -1,232 +0,0 @@
//! 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

@ -1,217 +0,0 @@
//! 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

@ -1,179 +0,0 @@
//! 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

@ -1,336 +0,0 @@
//! # 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

@ -1,141 +0,0 @@
//! 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

@ -1,494 +0,0 @@
//! 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

@ -1,212 +0,0 @@
//! 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,7 +26,6 @@ use super::*;
use makepad_widgets::*; use makepad_widgets::*;
use super::math::DVec3; use super::math::DVec3;
use super::measure::MeasureKind;
impl CadViewport { impl CadViewport {
/// Raw pointer dispatch: mouse down/move/up, scroll, hover picking. /// Raw pointer dispatch: mouse down/move/up, scroll, hover picking.
@ -225,11 +224,27 @@ impl CadViewport {
cx.redraw_all(); cx.redraw_all();
return; return;
} }
CadTool::Measure => { CadTool::Measure if matches!(self.view_mode, ViewMode::TwoD) => {
self.handle_measure_click(cx, e.abs); 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();
return; return;
} }
CadTool::Select => { CadTool::Select | CadTool::Measure => {
self.hovered_part = None; self.hovered_part = None;
if let Some(id) = self.pick_part(e.abs) { if let Some(id) = self.pick_part(e.abs) {
if self.shift_pressed { if self.shift_pressed {
@ -239,7 +254,6 @@ impl CadViewport {
} else { } else {
self.selection.push(id); self.selection.push(id);
} }
self.mark_selection_dirty();
} else { } else {
// Select part; if it belongs to a group, select all group members // Select part; if it belongs to a group, select all group members
let gid = self let gid = self
@ -258,7 +272,6 @@ impl CadViewport {
} else { } else {
self.selection = vec![id]; self.selection = vec![id];
} }
self.mark_selection_dirty();
} }
self.part_dragging = true; self.part_dragging = true;
self.drag_last = e.abs; self.drag_last = e.abs;
@ -346,9 +359,6 @@ impl CadViewport {
if matches!(self.view_mode, ViewMode::TwoD) { if matches!(self.view_mode, ViewMode::TwoD) {
let doc = self.document(); let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() { for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
let sp = self.view_to_screen_2d(DVec2 { let sp = self.view_to_screen_2d(DVec2 {
x: part.pos().x as f64, x: part.pos().x as f64,
y: part.pos().z as f64, y: part.pos().z as f64,
@ -386,9 +396,6 @@ impl CadViewport {
} else { } else {
let doc = self.document(); let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() { for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
if let Some((sx, sy)) = self.project_point([ if let Some((sx, sy)) = self.project_point([
part.pos().x, part.pos().x,
part.pos().y, part.pos().y,
@ -415,7 +422,6 @@ impl CadViewport {
} }
} }
} }
self.mark_selection_dirty();
} }
} }
self.drag_select_start = None; self.drag_select_start = None;
@ -772,7 +778,6 @@ impl CadViewport {
} else { } else {
self.selection = vec![id]; self.selection = vec![id];
} }
self.mark_selection_dirty();
self.part_dragging = true; self.part_dragging = true;
self.drag_last = fe.abs; self.drag_last = fe.abs;
self.drag_start_pos.clear(); self.drag_start_pos.clear();
@ -865,7 +870,6 @@ impl CadViewport {
.map_or(false, |hit| self.selection.contains(&hit)) .map_or(false, |hit| self.selection.contains(&hit))
{ {
self.selection.clear(); self.selection.clear();
self.mark_selection_dirty();
self.drag_start_pos.clear(); self.drag_start_pos.clear();
self.part_dragging = false; self.part_dragging = false;
self.area.redraw(cx); self.area.redraw(cx);
@ -907,72 +911,4 @@ 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,8 +36,6 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use super::math::DVec3; use super::math::DVec3;
use super::script_parts::is_script_bred;
use super::viewport::{ensure_ground_geometry, ensure_lod_geometry, part_model_matrix_cadnode}; use super::viewport::{ensure_ground_geometry, ensure_lod_geometry, part_model_matrix_cadnode};
/// One part's 2D outline, queued for a colour batch. /// One part's 2D outline, queued for a colour batch.
@ -452,26 +450,14 @@ impl CadViewport {
self.draw_ground.depth_clip = 0.0; self.draw_ground.depth_clip = 0.0;
self.draw_ground.display_mode = self.render_mode.shader_value(); self.draw_ground.display_mode = self.render_mode.shader_value();
self.draw_ground.draw(cx, ground_id); self.draw_ground.draw(cx, ground_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 let Some(geom) = &self.mesh_geometry {
if !has_script_parts { self.draw_mesh.transform = Mat4f::identity();
self.draw_mesh.transform = Mat4f::identity(); self.draw_mesh.color = self.color;
self.draw_mesh.color = self.color; self.draw_mesh.depth_clip = 0.0;
self.draw_mesh.depth_clip = 0.0; self.draw_mesh.display_mode = self.render_mode.shader_value();
self.draw_mesh.display_mode = self.render_mode.shader_value(); self.draw_mesh.draw(cx, geom.geometry_id());
if let Some(dir) = self.sun_direction() {
self.draw_mesh.light_dir = dir;
}
self.draw_mesh.draw(cx, geom.geometry_id());
}
} }
let doc = self.document();
// Phase 1: cull against the camera frustum before submitting. // Phase 1: cull against the camera frustum before submitting.
// Every part used to issue its own draw call whether or not // Every part used to issue its own draw call whether or not
// any pixel of it could land on screen. The matrices come // any pixel of it could land on screen. The matrices come
@ -489,10 +475,7 @@ impl CadViewport {
// per-instance values in both paths. // per-instance values in both paths.
let mut visible: Vec<(ShapeHash, (Mat4f, Vec4f))> = Vec::new(); let mut visible: Vec<(ShapeHash, (Mat4f, Vec4f))> = Vec::new();
let mut lod_visible: Vec<(Mat4f, Vec4f)> = Vec::new(); let mut lod_visible: Vec<(Mat4f, Vec4f)> = Vec::new();
for (part_idx, part) in CadViewport::read_parts(&doc).iter().enumerate() { for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
// Phase 2: the key is the shape's content hash, so a hit // Phase 2: the key is the shape's content hash, so a hit
// is correct by construction. Under the previous // is correct by construction. Under the previous
// `(id, ParamHash)` keying this had to filter out // `(id, ParamHash)` keying this had to filter out
@ -529,14 +512,6 @@ impl CadViewport {
&model, &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) { if !frustum.draw_part_3d(&aabb, is_sel || is_hov) {
continue; continue;
} }
@ -552,18 +527,6 @@ if let Some(plane) = self.section_plane() {
} else { } else {
part.color 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( match super::lod::part_lod_3d(
&aabb, &aabb,
&scene_state.view, &scene_state.view,
@ -747,9 +710,6 @@ let mut model = part_model_matrix_cadnode(part);
let mut plain_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new(); let mut plain_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new();
let mut decorated_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() { for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
let is_sel = self.selection.contains(&part.id.raw()); 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 is_hov = self.hovered_part.map_or(false, |h| h == part.id.raw()) && !is_sel;
let decorated_part = is_sel || is_hov; let decorated_part = is_sel || is_hov;
@ -1282,9 +1242,6 @@ let mut model = part_model_matrix_cadnode(part);
pub(crate) fn draw_section_indicators(&mut self, cx: &mut Cx2d) { pub(crate) fn draw_section_indicators(&mut self, cx: &mut Cx2d) {
let doc = self.document(); let doc = self.document();
for part in CadViewport::read_parts(&doc).iter() { for part in CadViewport::read_parts(&doc).iter() {
if part.is_hidden() {
continue;
}
if part.kind_hint != Some(PartKind::Beam) { if part.kind_hint != Some(PartKind::Beam) {
continue; continue;
} }
@ -1729,6 +1686,7 @@ let mut model = part_model_matrix_cadnode(part);
let world_right = self.pan_2d.x + half_w * super::render_budget::VIEW_MARGIN; 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_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; let world_top = self.pan_2d.y + half_h * super::render_budget::VIEW_MARGIN;
// Adaptive grid spacing: target ~60px between grid lines // Adaptive grid spacing: target ~60px between grid lines
let wpp = (half_h * 2.0) / rect.size.y.max(1.0); let wpp = (half_h * 2.0) / rect.size.y.max(1.0);
let target_px = 60.0; let target_px = 60.0;
@ -2761,123 +2719,6 @@ let mut model = part_model_matrix_cadnode(part);
self.draw_vector.end(cx); 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) { pub(crate) fn draw_construction_3d(&mut self, cx: &mut Cx2d) {
if !self.construction_visible { if !self.construction_visible {
return; return;

View file

@ -437,130 +437,6 @@ 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( pub(crate) fn apply_to_all_viewports(
&mut self, &mut self,
cx: &mut Cx, cx: &mut Cx,
@ -659,7 +535,6 @@ impl CadWorkspace {
// is turned back on. // is turned back on.
let _ = self.sync_parts_from_any_dirty_viewport(cx); let _ = self.sync_parts_from_any_dirty_viewport(cx);
self.sync_view_from_any_dirty_viewport(cx); self.sync_view_from_any_dirty_viewport(cx);
self.sync_selection_properties(cx);
} }
self.view.redraw(cx); self.view.redraw(cx);
@ -747,27 +622,6 @@ impl CadWorkspace {
cx.redraw_all(); 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 /// Collect the script from any viewport that reported an edit and
/// make the others redraw. /// make the others redraw.
/// ///
@ -1164,20 +1018,10 @@ impl CadWorkspace {
match result.payload { match result.payload {
CadRebuildPayload::Mesh { CadRebuildPayload::Mesh {
mesh_data, mesh_data,
components,
saved, saved,
save_error, save_error,
} => { } => {
let stats = self.set_mesh_on_all_viewports(cx, mesh_data); 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)); self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx));
let sv = if let Some(e) = save_error { let sv = if let Some(e) = save_error {
format!("; save failed: {e}") format!("; save failed: {e}")
@ -1670,437 +1514,6 @@ impl CadWorkspace {
self.view.view(cx, path).set_visible(cx, !text.is_empty()); 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) { pub(super) fn send_ai_prompt(&mut self, cx: &mut Cx) {
if self.current_prompt.is_some() { if self.current_prompt.is_some() {
return; return;
@ -2918,13 +2331,6 @@ impl CadWorkspace {
return; 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 // The fold/page controls inside the draggable sheet header are handled directly from
// pointer hits in `handle_direct_editor_sheet_buttons`. This avoids lost // pointer hits in `handle_direct_editor_sheet_buttons`. This avoids lost
// actions caused by the draggable sheet header consuming the event. // actions caused by the draggable sheet header consuming the event.
@ -3278,8 +2684,6 @@ impl CadWorkspace {
if self.view.button(cx, ids!(fit_button)).clicked(actions) { if self.view.button(cx, ids!(fit_button)).clicked(actions) {
self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx)); 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) { 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)); self.apply_to_all_viewports(cx, |vp, cx| vp.resize_selected(cx, 1.2, 1.2, 1.2));
} }
@ -3325,97 +2729,6 @@ 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 { impl Widget for CadWorkspace {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// Log window geometry changes for screen-size transition debugging // Log window geometry changes for screen-size transition debugging
@ -3428,11 +2741,6 @@ impl Widget for CadWorkspace {
} }
// Workspace-level keyboard shortcuts (undo/redo) // Workspace-level keyboard shortcuts (undo/redo)
if let Event::KeyDown(ke) = event { 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() { if ke.modifiers.is_primary() {
match ke.key_code { match ke.key_code {
makepad_platform::KeyCode::KeyZ => { makepad_platform::KeyCode::KeyZ => {
@ -3447,39 +2755,10 @@ impl Widget for CadWorkspace {
} }
return; 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); self.handle_direct_editor_sheet_buttons(cx, event);
let is_next_frame = self.next_frame.is_event(event).is_some(); let is_next_frame = self.next_frame.is_event(event).is_some();
// Push the bottom sheet's screen rect into viewports so they can // Push the bottom sheet's screen rect into viewports so they can
@ -3497,7 +2776,6 @@ impl Widget for CadWorkspace {
self.view.handle_event(cx, event, scope); self.view.handle_event(cx, event, scope);
if is_next_frame { if is_next_frame {
self.sync_view_from_any_dirty_viewport(cx); self.sync_view_from_any_dirty_viewport(cx);
self.sync_selection_properties(cx);
} }
self.update_active_pane_from_pointer_event(cx, event); self.update_active_pane_from_pointer_event(cx, event);
@ -3568,7 +2846,6 @@ impl Widget for CadWorkspace {
} }
Event::Actions(actions) => { Event::Actions(actions) => {
self.handle_actions(cx, actions); self.handle_actions(cx, actions);
self.handle_dashboard_actions(cx);
} }
_ => {} _ => {}
} }
@ -3605,7 +2882,6 @@ impl Widget for CadWorkspace {
if self.initialized { if self.initialized {
self.drain_rebuild_results(cx.cx); self.drain_rebuild_results(cx.cx);
} }
self.apply_dashboard_visibility(cx.cx);
self.view.draw_walk(cx, scope, walk) self.view.draw_walk(cx, scope, walk)
} }
} }
@ -3827,29 +3103,6 @@ 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)] #[cfg(test)]
mod save_status_tests { mod save_status_tests {
use super::save_status_message; use super::save_status_message;

View file

@ -230,71 +230,64 @@ script_mod! {
width: 400.0 width: 400.0
height: Fit height: Fit
flow: Down flow: Down
spacing: 0.0 spacing: 12.0
padding: 20.0 padding: 20.0
show_bg: true show_bg: true
draw_bg +: { color: #x18181A border_radius: 14.0 border_size: 1.0 border_color: #x2D3642 } draw_bg +: { color: #x18181A border_radius: 14.0 border_size: 1.0 border_color: #x2D3642 }
modal_content := View { 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 width: Fill, height: Fit
flow: Down flow: Down
spacing: 12.0 spacing: 4.0
modal_title := Label { rate_label := Label {
text: "Add Room" text: "Rate (KES/m²)"
draw_text +: { color: (COST_TEXT) text_style: theme.font_bold { font_size: 15.0 } } draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.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 { modal_buttons := View {
width: Fill, height: Fit width: Fill, height: Fit
flow: Right, spacing: 8.0, align: Align{y: 0.5} flow: Right, spacing: 8.0, align: Align{y: 0.5}
padding: { top: 12.0 }
add_room_cancel_btn := Button { add_room_cancel_btn := Button {
width: Fill, height: 36.0 width: Fill, height: 36.0
@ -424,13 +417,13 @@ script_mod! {
} }
} }
meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" }
room_list := mod.widgets.RoomList { room_list := mod.widgets.RoomList {
width: Fill width: Fill
height: Fill height: Fill
} }
meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" }
add_room_modal := mod.widgets.AddRoomModal {} add_room_modal := mod.widgets.AddRoomModal {}
} }
} }

View file

@ -232,73 +232,20 @@ init:` lines in logcat naming the branch that fired.
## 10. Sign-off ## 10. Sign-off
Device / OS / build: Galaxy A60 (SM-A6060, `R28M52LJP2Y`) · Android · Device / OS / build:
`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).
Verified devices: Galaxy A60 (SM-A6060, `R28M52LJP2Y`, 411 dp) and Galaxy | Section | CRDT workspace | Legacy DocWorkspace | Notes |
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 | 1 Interaction mode | | | |
reran clean on the A16. | 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 | | | |
Coverage legend: **A** = automated (ran green on device), When every row is PASS: check the roadmap's ScrollYView handoff box
**A⚠** = automated but only proves a subset, **M** = manual-only (cannot with a link to this file, and archive the completed form under the
be driven by `makepad_test` — reason in the Note column). milestone notes in the README.
| # | 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

@ -0,0 +1,82 @@
Summary of the V2 Architecture
Strict Boundaries: Widget handles events -> Editor generates Commands -> Document is mutated -> LayoutEngine computes geometry -> Renderer draws.
Memory Safe History: Undo/Redo no longer stores whole JSON strings. It calculates inverse commands, resulting in O(1) tiny allocations per keystroke.
Decoupled Hit Testing: The LayoutEngine owns layout coordinates and hit-testing. The widget just asks layout_engine.hit_test(local_pos) and gets a clean Cursor back.
Extensibility: Because tables contain Vec<Block>, you can now put images, lists, and even nested tables inside a table cell. The prototype was hardcoded to flat strings.
To complete the production polish (the remaining 5%), you would:
Implement true font measurement by passing Cx2d into FontMeasurer (or caching a Font and using get_text_size).
Flesh out ArrowLeft and ArrowRight navigation in the Editor by asking the LayoutEngine for the previous/next visual run.
Add the Toolbar back, wiring its buttons to editor.toggle_bold() etc.
This architecture will scale beautifully to infinite canvas, collaborative editing, and embedded widgets
Current state
The document engine milestones are now an architectural base, but the following still need full end-to-end implementation rather than only foundation types:
granular command execution for every edit path;
command coalescing for typing;
full persistent layout reuse/invalidation;
moving table, image, divider, and advanced-node rendering fully into DocumentRenderer;
actual nested advanced-block editing;
embedding live Makepad widgets through the registry;
remote operation application and conflict resolution/CRDT behavior.
The next correct step after verifying mobile typing and drag selection is to wire granular commands into keyboard input and toolbar operations, rather than introducing more block types.
## Remaining task list
### Command engine
- [x] Text insert/delete
- [x] Same-span, cross-span, and cross-block replacement
- [x] Backspace/Delete
- [x] Paragraph split/merge in both directions
- [x] Style range operations
- [x] Alignment
- [x] Block insertion/removal
- [x] Table-cell typing/deletion
- [x] Table row insertion/removal command
- [x] Image property update
- [x] Typing coalescing
- [ ] Cut/copy/paste command integration
- [ ] Full table-column commands
- [ ] Table-cell merge/split commands
- [ ] Advanced-node editing UI commands
### Layout and rendering
- [x] Persistent layout-tree foundation
- [x] Renderer boundary foundation
- [ ] Real incremental reflow/invalidation by changed block
- [ ] Move table/image/divider rendering fully into `DocumentRenderer`
- [ ] Render CRDT remote cursor/selection presence
- [ ] Advanced-block hit testing and editing
- [ ] True multi-page pagination and page reflow
### Mobile interaction
- [x] View/Edit mode foundation
- [x] Long press selection
- [x] Selection handles
- [ ] Final mobile viewport gesture arbitration with `ScrollYView`
- [ ] AdaptiveView desktop/mobile toolbar layout
- [ ] Native mobile copy/share/select-all actions
### CRDT and collaboration
- [x] Stable AtomId / BlockId / Lamport primitives
- [x] Deterministic legacy text bootstrap
- [x] CRDT local insert/delete
- [x] CRDT selected-range delete/replace
- [x] CRDT cursor/selection identity foundation
- [x] CRDT block-order primitives and anchored block commands
- [x] Remote operation buffering
- [x] Concurrent CRDT-safe operation application
- [x] Remote presence mapping foundation
- [x] Acknowledgment frontier and tombstone-compaction foundation
- [ ] Record tombstone deletion timestamps
- [ ] Persist/restore CRDT metadata and tombstones
- [ ] Full peer synchronization protocol
- [ ] CRDT-aware table and advanced-block structures
- [ ] Visual remote selections/cursors

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
use crate::persistence::load_saved_doc_state; use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state;
use makepad_widgets::makepad_platform::event::TouchState; use makepad_widgets::makepad_platform::event::TouchState;
use makepad_widgets::*; use makepad_widgets::*;
use std::cell::RefCell; use std::cell::RefCell;
@ -6,7 +6,7 @@ use std::rc::Rc;
// NOTE: imported rather than referenced as a fully-qualified path below. // NOTE: imported rather than referenced as a fully-qualified path below.
// The `Script`/`Widget` derive macros cannot parse a path type (`a::b::C`) // The `Script`/`Widget` derive macros cannot parse a path type (`a::b::C`)
// in a `#[rust]` field and reject it with "Unexpected field form". // in a `#[rust]` field and reject it with "Unexpected field form".
use crate::projection_layout::{ use crate::construction_frame::pages::workspace::doc::projection_layout::{
block_glyph_offset, cell_char_offset_at, cell_range_mergeable, cell_selection_rects, 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_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, cell_text_line_count, cell_text_offset_at, cell_text_replace_range, cell_text_span_rects,
@ -15,11 +15,11 @@ use crate::projection_layout::{
table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_range, table_cell_text, table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_range, table_cell_text,
word_atom_range, ProjectionLayoutTree, SelectionHandles, word_atom_range, ProjectionLayoutTree, SelectionHandles,
}; };
use crate::projection_renderer::ProjectionRenderer; use crate::construction_frame::pages::workspace::doc::projection_renderer::ProjectionRenderer;
use crate::projection_session::{ use crate::construction_frame::pages::workspace::doc::projection_session::{
crdt_engine_from_saved, crdt_save_wire, ProjectionSession, TableCellCursor, TableCellSelection, crdt_engine_from_saved, crdt_save_wire, ProjectionSession, TableCellCursor, TableCellSelection,
}; };
use crate::{ use crate::construction_frame::pages::workspace::doc::{
InteractionMode, MobileGestureAction, MobileGestureRouter, MobileGestureState, InteractionMode, MobileGestureAction, MobileGestureRouter, MobileGestureState,
}; };
use doc_engine::controller::DocumentController; use doc_engine::controller::DocumentController;
@ -123,23 +123,6 @@ pub struct CrdtDocEditor {
layout_cache: RefCell<Option<(u64, Rc<ProjectionLayoutTree>)>>, 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 { impl CrdtDocEditor {
pub fn set_engine(&mut self, cx: &mut Cx, engine: doc_engine::controller::DocumentController) { pub fn set_engine(&mut self, cx: &mut Cx, engine: doc_engine::controller::DocumentController) {
self.engine = engine; self.engine = engine;
@ -266,49 +249,9 @@ impl CrdtDocEditor {
/// legacy toolbar's CRDT InsertBlock-table routing. /// legacy toolbar's CRDT InsertBlock-table routing.
pub fn insert_table(&mut self, cx: &mut Cx) -> bool { pub fn insert_table(&mut self, cx: &mut Cx) -> bool {
let after = self.session.cursor_block.clone(); let after = self.session.cursor_block.clone();
let Some(table) = self.engine.insert_table("local", after) else { if self.engine.insert_table("local", after).is_none() {
return false; 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); self.redraw(cx);
true true
} }
@ -2774,28 +2717,7 @@ impl Widget for CrdtDocEditor {
if self.engine.projection.blocks.is_empty() { if self.engine.projection.blocks.is_empty() {
self.engine.insert_block("local", None, "paragraph"); self.engine.insert_block("local", None, "paragraph");
} }
// Adopt the mobile interaction policy at boot (mirroring the legacy let rect = cx.walk_turtle(walk);
// 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); self.draw_bg.draw_abs(cx, rect);
let layout = self.layout_tree(); let layout = self.layout_tree();
ProjectionRenderer::draw_text_projection( ProjectionRenderer::draw_text_projection(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
use super::{AdvancedLayoutBlock, GlyphHit, ParagraphFragment, TableFragment}; use super::{AdvancedLayoutBlock, GlyphHit, ParagraphFragment, TableFragment};
use crate::model::{DocCursor, StyleSpan}; use crate::construction_frame::pages::workspace::doc::model::{DocCursor, StyleSpan};
use makepad_widgets::{dvec2, DVec2, Rect}; use makepad_widgets::{dvec2, DVec2, Rect};
/// Persistent, renderer-independent geometry produced by layout. /// 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. /// Builds renderable table cells from base geometry plus merge metadata.
pub fn build_table_render_cells( pub fn build_table_render_cells(
fragment: &TableFragment, fragment: &TableFragment,
cells: &[Vec<crate::model::CellContent>], cells: &[Vec<crate::construction_frame::pages::workspace::doc::model::CellContent>],
merges: &[crate::model::TableMerge], merges: &[crate::construction_frame::pages::workspace::doc::model::TableMerge],
block_idx: usize, block_idx: usize,
) -> Vec<TableRenderCell> { ) -> Vec<TableRenderCell> {
let mut out = Vec::new(); let mut out = Vec::new();

View file

@ -3,8 +3,6 @@ pub mod advanced_json;
pub mod collaboration; pub mod collaboration;
pub mod crdt_bridge; pub mod crdt_bridge;
pub mod crdt_widget; pub mod crdt_widget;
pub mod dashboard;
pub mod doc_import;
pub mod editing; pub mod editing;
pub mod layout; pub mod layout;
pub mod mobile_gesture; pub mod mobile_gesture;
@ -23,14 +21,10 @@ pub use advanced_json::{
pub use collaboration::{CollaborationSession, DocumentOperation, Presence}; pub use collaboration::{CollaborationSession, DocumentOperation, Presence};
pub use crdt_bridge::CrdtProjectionBridge; pub use crdt_bridge::CrdtProjectionBridge;
pub use crdt_widget::CrdtDocEditor; 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 editing::RemoteApplyResult;
pub use mobile_gesture::{MobileGestureAction, MobileGestureRouter, MobileGestureState}; pub use mobile_gesture::{MobileGestureAction, MobileGestureRouter, MobileGestureState};
pub use model::{CellContent, DocAlign, DocBlock, DocCursor, Document, Selection, StyleSpan}; pub use model::{CellContent, DocAlign, DocBlock, DocCursor, Document, Selection, StyleSpan};
pub use persistence::{ pub use persistence::{load_saved_doc_state, save_doc_state, save_doc_state_as};
load_saved_doc_state, save_doc_state, save_doc_state_as, DocEntry,
};
pub use plugins::{BlockPluginDescriptor, PluginRegistry}; pub use plugins::{BlockPluginDescriptor, PluginRegistry};
pub use widgets::{CrdtDocWorkspace, DocEditor, DocWorkspace, InlineStyle, InteractionMode}; pub use widgets::{CrdtDocWorkspace, DocEditor, DocWorkspace, InlineStyle, InteractionMode};
@ -80,78 +74,27 @@ script_mod! {
draw_divider_line +: { draw_depth: 0.15 color: #xdee2e6 } 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)) { mod.widgets.CrdtDocWorkspace = #(CrdtDocWorkspace::register_widget(vm)) {
width: Fill, height: Fill, flow: Down width: Fill, height: Fill, flow: Down
draw_bg +: { color: #x181825 } draw_bg +: { color: #x181825 }
crdt_toolbar := View { crdt_toolbar := View {
width: Fill, height: Fit, flow: Right {wrap: true} width: Fill, height: 48.0, flow: Right
padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
draw_bg +: { color: #x11111b } 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 } } } 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_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 } } } save_as_btn := Button { text: "SaveAs", width: 54.0, draw_bg +: { color: #x1f6feb }, draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } }
// Edit/Done IME toggle. Kept as a plain button (not wrapped in // Mobile-only Edit/Done IME toggle (legacy parity): desktop
// a width-adaptive view) so it always renders on device; a // renders an empty variant and keeps full editing by default.
// Desktop/Mobile AdaptiveView would drop it in desktop-tagged mode_controls := AdaptiveView {
// test windows. width: Fit, height: Fit, retain_unused_variants: true
edit_mode_btn := Button { text: "Edit", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } 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 } } }
}
}
separator0 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x45475a } } 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 } } } undo_button := Button { text: "Undo", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
@ -194,13 +137,6 @@ script_mod! {
crdt_status_spacer := View { width: Fill } crdt_status_spacer := View { width: Fill }
status_right := Label { text: "Makepad Native CRDT Doc", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } } 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)) { mod.widgets.DocWorkspace = #(DocWorkspace::register_widget(vm)) {
@ -208,8 +144,8 @@ script_mod! {
draw_bg +: { color: #x181825 } draw_bg +: { color: #x181825 }
toolbar := View { toolbar := View {
width: Fill, height: Fit, flow: Right {wrap: true} width: Fill, height: 48.0, flow: Right
padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5}
draw_bg +: { color: #x11111b } 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 } } } open_file_btn := Button { text: "Open", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } }
@ -262,13 +198,6 @@ script_mod! {
spacer := View { width: Fill } spacer := View { width: Fill }
status_right := Label { text: "Makepad Native Doc v2.1", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } } 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,74 @@
use std::fs;
use std::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;
/// 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 {
crate::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(())
}

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). /// use (their single-line formulas keep the historical 18px band).
pub const TABLE_CELL_TEXT_LINE_HEIGHT: f64 = 18.0; pub const TABLE_CELL_TEXT_LINE_HEIGHT: f64 = 18.0;
use crate::projection_session::{ use crate::construction_frame::pages::workspace::doc::projection_session::{
TableCellCursor, TableCellSelection, TableCellCursor, TableCellSelection,
}; };

View file

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

View file

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

View file

@ -521,49 +521,6 @@ fn runtime_cell_return_inserts_row_below_and_moves_caret_into_it() {
let _ = row; 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 ====================== // == CRDT-native cell range selection and merge/split ======================
use super::projection_layout::{ use super::projection_layout::{
@ -1647,7 +1604,6 @@ fn runtime_mouse_down(abs: DVec2, shift: bool) -> Event {
fn runtime_mouse_move(abs: DVec2) -> Event { fn runtime_mouse_move(abs: DVec2) -> Event {
Event::MouseMove(MouseMoveEvent { Event::MouseMove(MouseMoveEvent {
abs, abs,
lock_delta: DVec2 { x: 0.0, y: 0.0 },
window_id: WindowId(0, 0), window_id: WindowId(0, 0),
modifiers: KeyModifiers::default(), modifiers: KeyModifiers::default(),
time: 0.0, time: 0.0,

View file

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

View file

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

View file

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

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