diff --git a/.gitignore b/.gitignore index 560e4ba..da547ea 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /.vs # Cursor IDE (local only). Shared assistant-oriented notes live in agents/. /.cursor/ +/scripts/__pycache__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7153734..881b45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Help > About: in-app dialog with the app version, a short product description, and a link to the GitHub repository (replaces opening the README in the browser from that menu item). +- **Keyboard zoom:** **NumPad +** / **NumPad -**, main **-**, and **Shift+=** (US **+**) zoom the 3D view in steps equal to **one mouse wheel tick** at the cursor (same internal scaling as scroll). +- **NumPad 8 / 2 / 4 / 6:** orbit the 3D view in fixed steps (same axes as LMB orbit); step is **View rotation step** in Settings (shared with Shift+NumPad roll; default 45 degrees; `gui.view_roll_step_deg`). +- **NumPad 5:** snap the 3D view to the nearest orthographic world-axis orientation (top/bottom/front/back/left/right) with roll reset; keeps eye-target distance. +- **View roll:** **Shift+NumPad 4** and **Shift+NumPad 6** roll the 3D view (Blender-style). **Settings** has **View rotation step** (degrees per key press for orbit and roll; default 45, stored as `gui.view_roll_step_deg`). +- Help > About: reads bundled `res/about.md` via [imgui_markdown](https://github.com/enkisoftware/imgui_markdown), shows version text, splash image (`res/AI-gen-splashscreen_05_01_2026_512.png`), and clickable links; assets are copied next to the executable (and preloaded for Emscripten). - **Sketch length dimensions (Dimension tool):** dimensions are stored as an unordered pair of sketch nodes (not attached to a single edge). They can be selected in sketch mode and removed with **Delete**. - **Project JSON:** root-level `ezyFormat` (current value `2`) on save. Sketches serialize `length_dimensions` as dense node-index pairs alongside linear edges as `[a, b, mid]` only. - **Legacy load:** older sketch JSON that stored a per-edge dimension flag on indexed edges (`[a, b, mid, dim]`) or on legacy coordinate edges (`[pt_a, pt_b, dim]`) migrates those flags into `length_dimensions` when loading. +### Fixed + +- Ship `res/default.ezy` (empty sketch template) so native copy steps and Emscripten `--preload-file` resolve the path; startup loading could already fall back if the file was absent. + ### Changed +- **View roll** (**Shift**+**NumPad 4**/**6**, main **4**/**6**, or **Left**/**Right** arrow): same roll as **Shift**+**4**/**6**; helps when Num Lock makes the numpad send arrows. Handled on key **repeat** as well as press; **Shift**+main **4**/**6** no longer fall through to the selection filter. +- **Keyboard zoom** (**NumPad +/-**, **Shift+=**, **-**): each repeated OS key event zooms again so holding the key zooms continuously (uses GLFW key repeat). +- **Zoom:** **Zoom scroll scale** in Settings replaces the hard-coded wheel multiplier (**4**); stored as **`gui.view_zoom_scroll_scale`**. Hold **Shift** while scrolling or using +/- for Blender-style finer zoom (**x0.1** on the delta). +- **View roll** default step is **45** degrees (was 15). - Window title shows the current project file name (e.g. after Open or Save), or `untitled` when there is no path yet; **File > New** clears the name so the title matches an empty document. - **Dimension tool behavior:** click a straight edge to toggle a length dimension between its endpoints, or click two nodes to toggle a dimension between them; clicks away from nodes and edges do not create spurious dimensions. Node picks take precedence over edge picks when both could apply; moving the mouse updates node snap feedback in this mode. -- **Documentation:** `usage-sketch.md` and `usage.md` describe the tool as the **Dimension tool** and document the node-pair model. +- **Documentation:** `usage-sketch.md` and `usage.md` describe the tool as the **Dimension tool** and document the node-pair model. **Num Lock off** is documented as recommended for numpad view shortcuts (orbit, roll, zoom, snap); **Num Lock on** may remap the keypad on Windows and other OSes (`usage.md`, `usage-occt-view.md`, `usage-settings.md`). ## [0.1.0] - 2026-04-17 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cab864..a6d42ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,6 +291,8 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") --preload-file ${CMAKE_SOURCE_DIR}/res/default.ezy@/res/default.ezy \ --preload-file ${CMAKE_SOURCE_DIR}/res/examples@/res/examples \ --preload-file ${CMAKE_SOURCE_DIR}/res/scripts/lua@/res/scripts/lua \ + --preload-file ${CMAKE_SOURCE_DIR}/res/about.md@/res/about.md \ + --preload-file ${CMAKE_SOURCE_DIR}/res/AI-gen-splashscreen_05_01_2026_512.png@/res/AI-gen-splashscreen_05_01_2026_512.png \ --shell-file ${CMAKE_SOURCE_DIR}/web/EzyCad.html" ) else() @@ -324,6 +326,8 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") --preload-file ${CMAKE_SOURCE_DIR}/res/default.ezy@/res/default.ezy \ --preload-file ${CMAKE_SOURCE_DIR}/res/examples@/res/examples \ --preload-file ${CMAKE_SOURCE_DIR}/res/scripts/lua@/res/scripts/lua \ + --preload-file ${CMAKE_SOURCE_DIR}/res/about.md@/res/about.md \ + --preload-file ${CMAKE_SOURCE_DIR}/res/AI-gen-splashscreen_05_01_2026_512.png@/res/AI-gen-splashscreen_05_01_2026_512.png \ --shell-file ${CMAKE_SOURCE_DIR}/web/EzyCad.html" ) endif() @@ -360,6 +364,12 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_SOURCE_DIR}/res/default.ezy $/res/default.ezy + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/res/about.md + $/res/about.md + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/res/AI-gen-splashscreen_05_01_2026_512.png + $/res/AI-gen-splashscreen_05_01_2026_512.png COMMENT "Copying res/ezycad_settings.json and res/default.ezy for Emscripten" ) file(GLOB RES_EXAMPLE_FILES_EM "${CMAKE_SOURCE_DIR}/res/examples/*.ezy") @@ -411,6 +421,12 @@ else() COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_SOURCE_DIR}/res/default.ezy $/res/default.ezy + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/res/about.md + $/res/about.md + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/res/AI-gen-splashscreen_05_01_2026_512.png + $/res/AI-gen-splashscreen_05_01_2026_512.png COMMENT "Copying res/ezycad_settings.json and res/default.ezy for native" ) add_custom_command( @@ -558,6 +574,7 @@ endif(APPLE) include_directories(.) include_directories("third_party") include_directories("third_party/imgui") +include_directories("third_party/imgui_markdown") include_directories("third_party/json/include") # Define preprocessor macros for GLM diff --git a/agents/issues/001-imgui-openpopup-id-stack-and-tables.md b/agents/issues/001-imgui-openpopup-id-stack-and-tables.md new file mode 100644 index 0000000..f6e796b --- /dev/null +++ b/agents/issues/001-imgui-openpopup-id-stack-and-tables.md @@ -0,0 +1,40 @@ +# [Draft] ImGui: document pattern for OpenPopup vs BeginPopup outside tables + +**Paste into GitHub as a new issue.** Suggested labels: `documentation`, `imgui`, `low priority` + +--- + +## Title + +Document ImGui popup ID stack: avoid OpenPopup inside `BeginTable` unless BeginPopup matches scope + +## Body + +### Summary + +Dear ImGui resolves `OpenPopup("id")` / `BeginPopup("id")` using the current **ID stack** (see [imgui#331](https://github.com/ocornut/imgui/issues/331)). Calling `OpenPopup` from inside a **table cell** while calling `BeginPopup` **after** `EndTable()` produces **different hashed popup IDs**, so the popup never opens. + +### Context in EzyCad + +We hit this when adding UI next to **Settings → 3D view navigation → View roll step** (slider inside `BeginTable`). The fix was to **defer** `OpenPopup` until after `ImGui::EndTable()`, in the same ID scope as `BeginPopup`. + +**View roll (for users):** **Shift+NumPad 4** and **Shift+NumPad 6** roll the 3D view around the screen axis (Blender-style). The step in degrees is **Settings → 3D view navigation → View roll step** (`gui.view_roll_step_deg`, default 45). See [usage.md#view-roll](https://github.com/trailcode/EzyCad/blob/main/usage.md#view-roll) and `src/gui_mode.cpp` (`GUI::on_key`). + +Related code: `src/gui_settings.cpp` (3D view navigation section). The experimental **Type** button + popup was removed; **Ctrl+click** on `SliderScalar` remains the supported way to type exact values. + +### Suggestion + +- Add a short note to **`ezycad_code_style.md`** or **`agents/README.md`** (or a tiny **`agents/imgui-notes.md`**) so future UI in tables does not repeat the mistake. +- Optional: extract a tiny helper, e.g. `defer_open_popup_after_table(bool& pending)` — only if we add more table-adjacent popups. + +### Acceptance criteria + +- [ ] Project docs mention ImGui popup + table ID stack **or** link to imgui#331 / upstream FAQ. +- [ ] No functional change required if docs-only. + +--- + +## Metadata (do not paste) + +- Created for agent/local drafting under `agents/issues/`. +- Repo: `trailcode/EzyCad`. diff --git a/agents/issues/README.md b/agents/issues/README.md new file mode 100644 index 0000000..28698cd --- /dev/null +++ b/agents/issues/README.md @@ -0,0 +1,5 @@ +# GitHub issue drafts (repo-local) + +Markdown drafts for issues to open at **https://github.com/trailcode/EzyCad/issues**. Copy the body into a new issue on GitHub (title + description). + +These files are **not** a substitute for GitHub tracking; they keep wording and context in-repo for agents and contributors. diff --git a/res/AI-gen-splashscreen_05_01_2016_full.png b/res/AI-gen-splashscreen_05_01_2016_full.png new file mode 100644 index 0000000..53e71e5 Binary files /dev/null and b/res/AI-gen-splashscreen_05_01_2016_full.png differ diff --git a/res/AI-gen-splashscreen_05_01_2026_512.png b/res/AI-gen-splashscreen_05_01_2026_512.png new file mode 100644 index 0000000..5e28523 Binary files /dev/null and b/res/AI-gen-splashscreen_05_01_2026_512.png differ diff --git a/res/about.md b/res/about.md new file mode 100644 index 0000000..c5d62a2 --- /dev/null +++ b/res/about.md @@ -0,0 +1,9 @@ +![EzyCad splash](AI-gen-splashscreen_05_01_2026_512.png) + +**EzyCad** (Easy CAD) is a CAD application for hobbyist machinists to design and edit 2D and 3D models for machining projects. It supports sketching, extruding, and geometric operations using OpenGL, Dear ImGui, and Open CASCADE Technology (OCCT). Export models to STEP, STL, and other formats for CNC or 3D printing. + +Source repository: [https://github.com/trailcode/EzyCad](https://github.com/trailcode/EzyCad) + +--- + +Product version appears in the application window title. MIT License; see the `LICENSE` file in the distribution. diff --git a/res/default.ezy b/res/default.ezy index e3d7fb1..63246a6 100644 --- a/res/default.ezy +++ b/res/default.ezy @@ -1,13 +1,7 @@ { "ezyFormat": 2, "mode": 0, - "shapes": [ - { - "geom": "\nCASCADE Topology V3, (c) Open Cascade\nLocations 1\n1\n 1 0 0 0 \n 0 1 0 0 \n 0 0 1 0 \nCurve2ds 24\n1 0 0 1 0 \n1 0 0 1 0 \n1 10 0 0 -1 \n1 0 0 0 1 \n1 0 -10 1 0 \n1 0 0 1 0 \n1 0 0 0 -1 \n1 0 0 0 1 \n1 0 0 1 0 \n1 0 10 1 0 \n1 10 0 0 -1 \n1 10 0 0 1 \n1 0 -10 1 0 \n1 0 10 1 0 \n1 0 0 0 -1 \n1 10 0 0 1 \n1 0 0 0 1 \n1 0 0 1 0 \n1 10 0 0 1 \n1 0 0 1 0 \n1 0 0 0 1 \n1 0 10 1 0 \n1 10 0 0 1 \n1 0 10 1 0 \nCurves 12\n1 0 0 0 0 0 1 \n1 0 0 10 -0 1 0 \n1 0 10 0 0 0 1 \n1 0 0 0 -0 1 0 \n1 10 0 0 0 0 1 \n1 10 0 10 -0 1 0 \n1 10 10 0 0 0 1 \n1 10 0 0 -0 1 0 \n1 0 0 0 1 0 -0 \n1 0 0 10 1 0 -0 \n1 0 10 0 1 0 -0 \n1 0 10 10 1 0 -0 \nPolygon3D 0\nPolygonOnTriangulations 0\nSurfaces 6\n1 0 0 0 1 0 -0 0 0 1 0 -1 0 \n1 0 0 0 -0 1 0 0 0 1 1 0 -0 \n1 0 0 10 0 0 1 1 0 -0 -0 1 0 \n1 0 10 0 -0 1 0 0 0 1 1 0 -0 \n1 0 0 0 0 0 1 1 0 -0 -0 1 0 \n1 10 0 0 1 0 -0 0 0 1 0 -1 0 \nTriangulations 0\n\nTShapes 34\nVe\n1e-07\n0 0 10\n0 0\n\n0101101\n*\nVe\n1e-07\n0 0 0\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 1 0 0 10\n2 1 1 0 0 10\n2 2 2 0 0 10\n0\n\n0101000\n-34 0 +33 0 *\nVe\n1e-07\n0 10 10\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 2 0 0 10\n2 3 1 0 0 10\n2 4 3 0 0 10\n0\n\n0101000\n-31 0 +34 0 *\nVe\n1e-07\n0 10 0\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 3 0 0 10\n2 5 1 0 0 10\n2 6 4 0 0 10\n0\n\n0101000\n-31 0 +29 0 *\nEd\n 1e-07 1 1 0\n1 4 0 0 10\n2 7 1 0 0 10\n2 8 5 0 0 10\n0\n\n0101000\n-29 0 +33 0 *\nWi\n\n0101100\n-32 0 -30 0 +28 0 +27 0 *\nFa\n0 1e-07 1 0\n\n0101000\n+26 0 *\nVe\n1e-07\n10 0 10\n0 0\n\n0101101\n*\nVe\n1e-07\n10 0 0\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 5 0 0 10\n2 9 6 0 0 10\n2 10 2 0 0 10\n0\n\n0101000\n-24 0 +23 0 *\nVe\n1e-07\n10 10 10\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 6 0 0 10\n2 11 6 0 0 10\n2 12 3 0 0 10\n0\n\n0101000\n-21 0 +24 0 *\nVe\n1e-07\n10 10 0\n0 0\n\n0101101\n*\nEd\n 1e-07 1 1 0\n1 7 0 0 10\n2 13 6 0 0 10\n2 14 4 0 0 10\n0\n\n0101000\n-21 0 +19 0 *\nEd\n 1e-07 1 1 0\n1 8 0 0 10\n2 15 6 0 0 10\n2 16 5 0 0 10\n0\n\n0101000\n-19 0 +23 0 *\nWi\n\n0101100\n-22 0 -20 0 +18 0 +17 0 *\nFa\n0 1e-07 6 0\n\n0101000\n+16 0 *\nEd\n 1e-07 1 1 0\n1 9 0 0 10\n2 17 2 0 0 10\n2 18 5 0 0 10\n0\n\n0101000\n-23 0 +33 0 *\nEd\n 1e-07 1 1 0\n1 10 0 0 10\n2 19 2 0 0 10\n2 20 3 0 0 10\n0\n\n0101000\n-24 0 +34 0 *\nWi\n\n0101100\n-14 0 -22 0 +13 0 +32 0 *\nFa\n0 1e-07 2 0\n\n0101000\n+12 0 *\nEd\n 1e-07 1 1 0\n1 11 0 0 10\n2 21 4 0 0 10\n2 22 5 0 0 10\n0\n\n0101000\n-19 0 +29 0 *\nEd\n 1e-07 1 1 0\n1 12 0 0 10\n2 23 4 0 0 10\n2 24 3 0 0 10\n0\n\n0101000\n-21 0 +31 0 *\nWi\n\n0101100\n-10 0 -18 0 +9 0 +28 0 *\nFa\n0 1e-07 4 0\n\n0101000\n+8 0 *\nWi\n\n0101100\n-27 0 -10 0 +17 0 +14 0 *\nFa\n0 1e-07 5 0\n\n0101000\n+6 0 *\nWi\n\n0101100\n-30 0 -9 0 +20 0 +13 0 *\nFa\n0 1e-07 3 0\n\n0101000\n+4 0 *\nSh\n\n0101100\n-25 0 +15 0 -11 0 +7 0 -5 0 +3 0 *\nSo\n\n1100000\n+2 0 *\n\n+1 1 ", - "material": 14, - "name": "Box" - } - ], + "shapes": [], "sketches": [ { "arc_edges": [], @@ -28,32 +22,9 @@ "xAxis": { "x": 1.0, "y": 0.0, - "z": -0.0 + "z": 0.0 } } } - ], - "view": { - "at": { - "x": 0.8907552825678877, - "y": 5.662726832095677, - "z": 6.2384519054513206 - }, - "eye": { - "x": -61.538576671285604, - "y": -8.618591815144743, - "z": 53.52311764025625 - }, - "proj": { - "x": -0.7842226703185037, - "y": -0.17939858548361437, - "z": 0.5939789145121179 - }, - "scale": 6.280883086320588, - "up": { - "x": 0.36713761457808036, - "y": 0.6375645723074219, - "z": 0.6772897371881337 - } - } -} \ No newline at end of file + ] +} diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index 7063bc0..c8697c4 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -13,6 +13,8 @@ "show_lua_console": true, "show_options": true, "show_python_console": true, + "view_roll_step_deg": 45.0, + "view_zoom_scroll_scale": 4.0, "show_settings_dialog": true, "show_shape_list": true, "show_sketch_list": true, diff --git a/scripts/pbf-to-png.ps1 b/scripts/pbf-to-png.ps1 new file mode 100644 index 0000000..1f031d3 --- /dev/null +++ b/scripts/pbf-to-png.ps1 @@ -0,0 +1,41 @@ +# Convert PDF content (often mislabeled as .pbf) to PNG with configurable DPI. +# Requires Python 3 and: pip install pymupdf +# +# Examples: +# .\pbf-to-png.ps1 -Input drawing.pbf -Dpi 300 +# .\pbf-to-png.ps1 -Input doc.pdf -Output out.png -Dpi 150 +# .\pbf-to-png.ps1 -Input manual.pdf -AllPages -Dpi 200 + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string] $Input, + + [string] $Output, + + [double] $Dpi = 150, + + [switch] $AllPages, + + [int] $Page +) + +$here = $PSScriptRoot +$py = Join-Path $here "pbf-to-png.py" +if (-not (Test-Path $py)) { + Write-Error "Missing script: $py" + exit 1 +} + +$argsList = @($py, $Input, "--dpi", $Dpi) +if ($Output) { + $argsList += @("-o", $Output) +} +if ($AllPages) { + $argsList += "--all-pages" +} +if ($PSBoundParameters.ContainsKey("Page")) { + $argsList += @("--page", $Page) +} + +& python @argsList +exit $LASTEXITCODE diff --git a/scripts/pbf-to-png.py b/scripts/pbf-to-png.py new file mode 100644 index 0000000..f93b203 --- /dev/null +++ b/scripts/pbf-to-png.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Rasterize a PDF to PNG. Many tools mislabel PDFs as ".pbf"; this script opens the +file with PyMuPDF, which recognizes PDF by content. + +Requires: pip install pymupdf + +Usage: + python pbf-to-png.py input.pbf -o out.png --dpi 300 + python pbf-to-png.py doc.pdf --all-pages --dpi 150 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _is_pdf_file(path: Path) -> bool: + try: + with path.open("rb") as f: + head = f.read(5) + except OSError as e: + print(f"Error reading {path}: {e}", file=sys.stderr) + return False + return head.startswith(b"%PDF") + + +def main() -> int: + p = argparse.ArgumentParser( + description="Convert a PDF (often saved as .pbf) to PNG with configurable DPI." + ) + p.add_argument( + "input", + type=Path, + help="Input file (.pdf or PDF content with another extension such as .pbf)", + ) + p.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="Output PNG path (single-page). For multiple pages with --all-pages, " + "this is treated as a filename pattern: stem-001.png, stem-002.png, ...", + ) + p.add_argument( + "--dpi", + type=float, + default=150.0, + metavar="N", + help="Rasterization resolution in dots per inch (default: 150)", + ) + p.add_argument( + "--all-pages", + action="store_true", + help="Export every page; filenames use stem-001.png style unless -o is omitted " + "(then uses input basename next to the input file)", + ) + p.add_argument( + "--page", + type=int, + default=None, + metavar="N", + help="1-based page index to export (default with --all-pages: all pages; " + "otherwise: page 1 only)", + ) + args = p.parse_args() + + src = args.input + if not src.is_file(): + print(f"Not a file: {src}", file=sys.stderr) + return 1 + + if not _is_pdf_file(src): + print( + f"{src} does not look like a PDF (missing %PDF header). " + "If this is a Mapbox/OSM vector tile or other binary PBF, use a different tool.", + file=sys.stderr, + ) + return 1 + + try: + import fitz # PyMuPDF + except ImportError: + print( + "Missing dependency: install with pip install pymupdf", + file=sys.stderr, + ) + return 1 + + dpi = float(args.dpi) + if dpi <= 0: + print("--dpi must be positive", file=sys.stderr) + return 1 + + zoom = dpi / 72.0 + mat = fitz.Matrix(zoom, zoom) + + try: + doc = fitz.open(src) + except Exception as e: + print(f"Failed to open as PDF: {e}", file=sys.stderr) + return 1 + + try: + n = doc.page_count + if args.page is not None: + if args.page < 1 or args.page > n: + print(f"--page must be between 1 and {n}", file=sys.stderr) + return 1 + indices = [args.page - 1] + elif args.all_pages: + indices = list(range(n)) + else: + indices = [0] + + if len(indices) == 1: + out = args.output + if out is None: + out = src.with_suffix(".png") + else: + out = Path(out) + parent = out.parent + if parent and str(parent) != ".": + parent.mkdir(parents=True, exist_ok=True) + page = doc.load_page(indices[0]) + pix = page.get_pixmap(matrix=mat, alpha=False) + pix.save(str(out)) + print(out) + else: + base = args.output + if base is None: + stem = src.stem + out_dir = src.parent + else: + base = Path(base) + stem = base.stem + out_dir = base.parent + if not str(out_dir) or out_dir == Path("."): + out_dir = src.parent + out_dir.mkdir(parents=True, exist_ok=True) + width = max(3, len(str(len(indices)))) + for i, pi in enumerate(indices): + page = doc.load_page(pi) + pix = page.get_pixmap(matrix=mat, alpha=False) + name = f"{stem}-{i + 1:0{width}d}.png" + path = out_dir / name + pix.save(str(path)) + print(path) + finally: + doc.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/dbg.h b/src/dbg.h index cf5a6e0..a5609dc 100644 --- a/src/dbg.h +++ b/src/dbg.h @@ -37,6 +37,28 @@ } \ } while (false) +/// If \a condition is false: assert (programmer error), then \c return \a ret_value so execution does not continue past \ref EZY_ASSERT_MSG. +#define EZY_ASSERT_OR_RETURN(condition, ret_value) \ + do \ + { \ + if (!(condition)) \ + { \ + EZY_ASSERT_MSG(false, "EZY_ASSERT_OR_RETURN failed: " #condition); \ + return (ret_value); \ + } \ + } while (false) + +/// Same as \ref EZY_ASSERT_OR_RETURN for \c void functions. +#define EZY_ASSERT_OR_RETURN_VOID(condition) \ + do \ + { \ + if (!(condition)) \ + { \ + EZY_ASSERT_MSG(false, "EZY_ASSERT_OR_RETURN_VOID failed: " #condition); \ + return; \ + } \ + } while (false) + #define DBG_MSG(stream_expr) \ do \ { \ diff --git a/src/gui.cpp b/src/gui.cpp index ec36cbf..fafe54f 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1,13 +1,14 @@ #include "gui.h" #include +#include #include #include #include #include #include -#include #include +#include #include #include @@ -27,7 +28,7 @@ #include "occt_view.h" #include "python_console.h" #include "sketch.h" -#include "version.h" +#include "utl.h" // Must be here to prevent compiler warning #include @@ -439,41 +440,39 @@ void GUI::about_dialog_() { if (m_open_about_popup) { - ImGui::SetNextWindowSize(ImVec2(520.0f, 0.0f), ImGuiCond_Appearing); + m_about_popup_open = true; + ImGui::SetNextWindowSize(ImVec2(520.0f, 520.0f), ImGuiCond_Appearing); ImGui::OpenPopup("About"); m_open_about_popup = false; } - if (!ImGui::BeginPopupModal("About", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + if (!ImGui::BeginPopupModal("About", &m_about_popup_open, ImGuiWindowFlags_None)) return; - char title_line[96]; - std::snprintf(title_line, sizeof(title_line), "EazyCad %s", EZYCAD_VERSION_STRING); - ImGui::TextUnformatted(title_line); - ImGui::Spacing(); - - ImGui::TextWrapped( - "EzyCad (Easy CAD) is a CAD application for hobbyist machinists to design and edit 2D and 3D models for " - "machining projects. It supports creating precise parts with tools for sketching, extruding, and applying " - "geometric operations, using OpenGL, ImGui, and Open CASCADE Technology (OCCT). Export models to formats " - "like STEP or STL for CNC machines or 3D printers."); - ImGui::Spacing(); - - const char* k_repo = "https://github.com/trailcode/EzyCad"; - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.26f, 0.59f, 0.98f, 1.00f)); - ImGui::TextWrapped("%s", k_repo); - ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) + ensure_about_assets_(); + + ImFont* font = ImGui::GetFont(); + ImGui::MarkdownConfig md; + md.linkCallback = about_markdown_link_cb_; + md.imageCallback = about_markdown_image_cb_; + md.tooltipCallback = ImGui::defaultMarkdownTooltipCallback; + md.linkIcon = ""; + md.headingFormats[0] = {font, true}; + md.headingFormats[1] = {font, true}; + md.headingFormats[2] = {font, false}; +#ifdef IMGUI_HAS_TEXTURES { - ImGui::SetTooltip("Open in browser"); - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + float const fs = ImGui::GetFontSize(); + md.headingFormats[0].fontSize = fs * 1.15f; + md.headingFormats[1].fontSize = fs * 1.05f; + md.headingFormats[2].fontSize = fs; } - if (ImGui::IsItemClicked()) - open_url_(k_repo); +#endif + md.userData = this; - ImGui::Spacing(); - if (ImGui::Button("Close")) - ImGui::CloseCurrentPopup(); + ImGui::BeginChild("AboutMd", ImVec2(0.0f, 0.0f), true, ImGuiWindowFlags_HorizontalScrollbar); + ImGui::Markdown(m_about_markdown.c_str(), m_about_markdown.size(), md); + ImGui::EndChild(); ImGui::EndPopup(); } @@ -499,8 +498,7 @@ std::string GUI::project_title_segment_() const void GUI::update_window_title_() { - if (!m_glfw_window) - return; + EZY_ASSERT(m_glfw_window != nullptr); const std::string title = std::string("EzyCad - ") + project_title_segment_(); if (title == m_cached_window_title) @@ -510,12 +508,12 @@ void GUI::update_window_title_() glfwSetWindowTitle(m_glfw_window, m_cached_window_title.c_str()); } -void GUI::open_url_(const char* url) +void GUI::open_url_(const std::string& url) { #ifdef __EMSCRIPTEN__ // For Emscripten, use JavaScript's window.open() // Use EM_ASM for safer execution - EM_ASM_({ window.open(UTF8ToString($0), '_blank'); }, url); + EM_ASM_({ window.open(UTF8ToString($0), '_blank'); }, url.c_str()); #else // For native builds, use platform-specific system commands std::string cmd; @@ -541,6 +539,100 @@ void GUI::open_url_(const char* url) #endif } +void GUI::ensure_about_assets_() +{ + if (m_about_assets_loaded) + return; + + m_about_assets_loaded = true; + + const char* md_paths[] = { +#ifdef __EMSCRIPTEN__ + "/res/about.md", +#endif + "res/about.md", + }; + + for (const char* p : md_paths) + { + std::ifstream f(p); + if (!f) + continue; + + m_about_markdown.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); + break; + } + if (m_about_markdown.empty()) + m_about_markdown = "Could not load res/about.md.\n"; + + const char* png_paths[] = { +#ifdef __EMSCRIPTEN__ + "/res/AI-gen-splashscreen_05_01_2026_512.png", +#endif + "res/AI-gen-splashscreen_05_01_2026_512.png", + }; + for (const char* p : png_paths) + { + if (!std::filesystem::exists(p)) + continue; + + std::ifstream fi(p, std::ios::binary); + if (!fi) + continue; + + std::string bytes((std::istreambuf_iterator(fi)), std::istreambuf_iterator()); + if (auto dec = decode_image_bytes(bytes)) + { + m_about_splash_w = std::get<1>(*dec); + m_about_splash_h = std::get<2>(*dec); + } + m_about_splash_gl = load_texture(p); + break; + } +} + +ImGui::MarkdownImageData GUI::about_markdown_image_(ImGui::MarkdownLinkCallbackData data) +{ + ImGui::MarkdownImageData out{}; + if (!data.isImage) + return out; + + static constexpr char k_splash_id[] = "AI-gen-splashscreen_05_01_2026_512.png"; + std::string id(data.link, data.linkLength); + if (id != k_splash_id || m_about_splash_gl == 0) + return out; + + out.isValid = true; + out.useLinkCallback = false; + out.user_texture_id = (ImTextureID)(intptr_t)m_about_splash_gl; + out.size = ImVec2((float)m_about_splash_w, (float)m_about_splash_h); + ImVec2 const avail = ImGui::GetContentRegionAvail(); + if (out.size.x > avail.x && avail.x > 1.0f) + { + float const ratio = out.size.y / out.size.x; + out.size.x = avail.x; + out.size.y = avail.x * ratio; + } + return out; +} + +void GUI::about_markdown_link_cb_(ImGui::MarkdownLinkCallbackData data) +{ + if (data.isImage || !data.userData) + return; + + std::string url(data.link, data.linkLength); + static_cast(data.userData)->open_url_(url); +} + +ImGui::MarkdownImageData GUI::about_markdown_image_cb_(ImGui::MarkdownLinkCallbackData data) +{ + if (!data.userData) + return {}; + + return static_cast(data.userData)->about_markdown_image_(data); +} + // Render toolbar with ImGui void GUI::toolbar_() { @@ -2213,7 +2305,12 @@ void GUI::on_mouse_button(int button, int action, int mods) void GUI::on_mouse_scroll(double xoffset, double yoffset) { - m_view->on_mouse_scroll(xoffset, yoffset); + EZY_ASSERT(m_glfw_window != nullptr); + + const bool shift_finer = glfwGetKey(m_glfw_window, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS + || glfwGetKey(m_glfw_window, GLFW_KEY_RIGHT_SHIFT) == GLFW_PRESS; + + m_view->on_mouse_scroll(xoffset, yoffset, shift_finer); } void GUI::on_resize(int width, int height) diff --git a/src/gui.h b/src/gui.h index 7843d84..3413f76 100644 --- a/src/gui.h +++ b/src/gui.h @@ -13,6 +13,7 @@ // #include // Added for log storage #include "imgui.h" +#include "imgui_markdown.h" #include "log.h" #include "modes.h" #include "types.h" @@ -54,7 +55,15 @@ struct Example_file }; /// Default OCCT line-width scale for length dimensions when `edge_dim_line_width` is missing from settings JSON. -inline constexpr float k_gui_edge_dim_line_width_default = 1.0f; +inline constexpr float k_gui_edge_dim_line_width_default = 1.0f; +/// Allowed range and default for `gui.view_roll_step_deg` (view roll and numpad orbit steps; must match Settings slider). +inline constexpr double k_gui_view_roll_step_deg_min = 0.1; +inline constexpr double k_gui_view_roll_step_deg_max = 180.0; +inline constexpr double k_gui_view_roll_step_deg_default = 45.0; +/// Allowed range and default for `gui.view_zoom_scroll_scale` (wheel/keyboard zoom units; must match Settings slider). +inline constexpr double k_gui_view_zoom_scroll_scale_min = 0.25; +inline constexpr double k_gui_view_zoom_scroll_scale_max = 64.0; +inline constexpr double k_gui_view_zoom_scroll_scale_default = 4.0; class GUI { @@ -73,6 +82,7 @@ class GUI { m_console_font = font; } + ImFont* console_font() const; void render_gui(); @@ -175,26 +185,30 @@ class GUI void options_shape_polar_duplicate_mode_(); void options_rotate_mode_(); - void dbg_(); - void initialize_toolbar_(); - void load_examples_list_(); - void load_default_project_(); - void menu_bar_(); - void toolbar_(); - void message_status_window_(); - void add_box_dialog_(); - void add_pyramid_dialog_(); - void add_sphere_dialog_(); - void add_cylinder_dialog_(); - void add_cone_dialog_(); - void add_torus_dialog_(); - void about_dialog_(); - void log_window_(); - void lua_console_(); - void python_console_(); - void settings_(); - void setup_log_redirection_(); - void cleanup_log_redirection_(); + void dbg_(); + void initialize_toolbar_(); + void load_examples_list_(); + void load_default_project_(); + void menu_bar_(); + void toolbar_(); + void message_status_window_(); + void add_box_dialog_(); + void add_pyramid_dialog_(); + void add_sphere_dialog_(); + void add_cylinder_dialog_(); + void add_cone_dialog_(); + void add_torus_dialog_(); + void about_dialog_(); + void ensure_about_assets_(); + ImGui::MarkdownImageData about_markdown_image_(ImGui::MarkdownLinkCallbackData data); + static void about_markdown_link_cb_(ImGui::MarkdownLinkCallbackData data); + static ImGui::MarkdownImageData about_markdown_image_cb_(ImGui::MarkdownLinkCallbackData data); + void log_window_(); + void lua_console_(); + void python_console_(); + void settings_(); + void setup_log_redirection_(); + void cleanup_log_redirection_(); // Import/export related void import_file_dialog_(); @@ -218,18 +232,18 @@ class GUI void open_file_dialog_(); void save_file_dialog_(); - void save_startup_project_(); - void clear_saved_startup_project_(); + void save_startup_project_(); + void clear_saved_startup_project_(); /// Native only: store path in settings after a successful Open (for optional startup load). - void persist_last_opened_project_path_(const std::string& path); - std::string serialized_project_json_() const; - void open_url_(const char* url); - void update_window_title_(); - [[nodiscard]] std::string project_title_segment_() const; + void persist_last_opened_project_path_(const std::string& path); + std::string serialized_project_json_() const; + void open_url_(const std::string& url); + void update_window_title_(); + [[nodiscard]] std::string project_title_segment_() const; /// Parses a float from manual dist/angle ImGui text fields (trimmed, full-string match). - static bool parse_dist_text_to_float_(const char* buf, float& out); + static bool parse_dist_text_to_float_(const char* buf, float& out); /// True if JSON parses and looks like an EzyCad project document (`sketches` array). - static bool is_valid_project_json_(const std::string& s); + static bool is_valid_project_json_(const std::string& s); /// OCCT standard material display names for ImGui combos (index matches \c Graphic3d_NameOfMaterial). static const std::vector& occt_material_combo_labels_(); @@ -264,6 +278,10 @@ class GUI Fillet_mode m_fillet_mode = Fillet_mode::Shape; int m_edge_dim_label_h {3}; // Prs3d_DTHP_Fit float m_edge_dim_line_width {k_gui_edge_dim_line_width_default}; + /// Degrees per numpad orbit (8/2/4/6) and Blender-style roll (Shift+NumPad 4/6); persisted in `gui.view_roll_step_deg`. + double m_view_roll_step_deg {k_gui_view_roll_step_deg_default}; + /// Multiplier for `UpdateZoom(Aspect_ScrollDelta(..., int(y * scale)))`; persisted in `gui.view_zoom_scroll_scale`. + double m_view_zoom_scroll_scale {k_gui_view_zoom_scroll_scale_default}; std::vector m_toolbar_buttons; // Message status window @@ -289,36 +307,42 @@ class GUI using Example_file_list = std::vector; Example_file_list m_example_files; - bool m_show_sketch_list {true}; - bool m_show_shape_list {true}; - bool m_show_options {true}; - bool m_show_settings_dialog {false}; - bool m_open_about_popup {false}; - bool m_open_add_box_popup {false}; - double m_add_box_origin_x {0}; - double m_add_box_origin_y {0}; - double m_add_box_origin_z {0}; - double m_add_box_width {1}; - double m_add_box_length {1}; - double m_add_box_height {1}; - bool m_open_add_pyramid_popup {false}; - double m_add_pyramid_origin_x {0}, m_add_pyramid_origin_y {0}, m_add_pyramid_origin_z {0}; - double m_add_pyramid_side {1}; - bool m_open_add_sphere_popup {false}; - double m_add_sphere_origin_x {0}, m_add_sphere_origin_y {0}, m_add_sphere_origin_z {0}; - double m_add_sphere_radius {1}; - bool m_open_add_cylinder_popup {false}; - double m_add_cylinder_origin_x {0}, m_add_cylinder_origin_y {0}, m_add_cylinder_origin_z {0}; - double m_add_cylinder_radius {1}, m_add_cylinder_height {1}; - bool m_open_add_cone_popup {false}; - double m_add_cone_origin_x {0}, m_add_cone_origin_y {0}, m_add_cone_origin_z {0}; - double m_add_cone_R1 {1}, m_add_cone_R2 {0}, m_add_cone_height {1}; - bool m_open_add_torus_popup {false}; - double m_add_torus_origin_x {0}, m_add_torus_origin_y {0}, m_add_torus_origin_z {0}; - double m_add_torus_R1 {1}, m_add_torus_R2 {0.5}; - bool m_hide_all_shapes {false}; - bool m_show_tool_tips {true}; - bool m_dark_mode {false}; + bool m_show_sketch_list {true}; + bool m_show_shape_list {true}; + bool m_show_options {true}; + bool m_show_settings_dialog {false}; + bool m_open_about_popup {false}; + bool m_about_popup_open {false}; + std::string m_about_markdown; + uint32_t m_about_splash_gl {0}; + int m_about_splash_w {512}; + int m_about_splash_h {512}; + bool m_about_assets_loaded {false}; + bool m_open_add_box_popup {false}; + double m_add_box_origin_x {0}; + double m_add_box_origin_y {0}; + double m_add_box_origin_z {0}; + double m_add_box_width {1}; + double m_add_box_length {1}; + double m_add_box_height {1}; + bool m_open_add_pyramid_popup {false}; + double m_add_pyramid_origin_x {0}, m_add_pyramid_origin_y {0}, m_add_pyramid_origin_z {0}; + double m_add_pyramid_side {1}; + bool m_open_add_sphere_popup {false}; + double m_add_sphere_origin_x {0}, m_add_sphere_origin_y {0}, m_add_sphere_origin_z {0}; + double m_add_sphere_radius {1}; + bool m_open_add_cylinder_popup {false}; + double m_add_cylinder_origin_x {0}, m_add_cylinder_origin_y {0}, m_add_cylinder_origin_z {0}; + double m_add_cylinder_radius {1}, m_add_cylinder_height {1}; + bool m_open_add_cone_popup {false}; + double m_add_cone_origin_x {0}, m_add_cone_origin_y {0}, m_add_cone_origin_z {0}; + double m_add_cone_R1 {1}, m_add_cone_R2 {0}, m_add_cone_height {1}; + bool m_open_add_torus_popup {false}; + double m_add_torus_origin_x {0}, m_add_torus_origin_y {0}, m_add_torus_origin_z {0}; + double m_add_torus_R1 {1}, m_add_torus_R2 {0.5}; + bool m_hide_all_shapes {false}; + bool m_show_tool_tips {true}; + bool m_dark_mode {false}; #ifndef NDEBUG bool m_show_dbg {false}; #endif diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 5ef3121..40798e7 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -98,9 +98,92 @@ void GUI::set_parent_mode() void GUI::on_key(int key, int scancode, int action, int mods) { (void) scancode; + const bool press_or_repeat = (action == GLFW_PRESS || action == GLFW_REPEAT); + + // Zoom (+/-): scaled like mouse wheel; GLFW_REPEAT while held; Shift = Blender-style finer step. + if (press_or_repeat && (mods & (GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) + { + bool zoom_in = false; + bool zoom_out = false; + // Shift+= is the main-keyboard zoom-in path; Shift is structural (produces '+'), not "finer step" intent. + bool zoom_in_shift_is_structural = false; + switch (key) + { + case GLFW_KEY_KP_ADD: + zoom_in = true; + break; + case GLFW_KEY_KP_SUBTRACT: + case GLFW_KEY_MINUS: + zoom_out = true; + break; + case GLFW_KEY_EQUAL: + if ((mods & GLFW_MOD_SHIFT) != 0) + { + zoom_in = true; + zoom_in_shift_is_structural = true; + } + break; + default: + break; + } + + const bool shift_finer = + ((mods & GLFW_MOD_SHIFT) != 0) && !zoom_in_shift_is_structural; + + if (zoom_in) + { + m_view->zoom_view_wheel_notches(1.0, shift_finer); + return; + } + else if (zoom_out) + { + m_view->zoom_view_wheel_notches(-1.0, shift_finer); + return; + } + } + + // Blender-style view roll: Shift + NumPad 4/6, main 4/6, or Left/Right (NumLock-on numpad often maps here). + // Use PRESS and REPEAT (like zoom) so hold-to-repeat works; route before keypad digit -> selection filter. + if (press_or_repeat && (mods & GLFW_MOD_SHIFT) != 0 && (mods & (GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) + { + const bool roll_ccw = + (key == GLFW_KEY_KP_4 || key == GLFW_KEY_4 || key == GLFW_KEY_LEFT); + const bool roll_cw = + (key == GLFW_KEY_KP_6 || key == GLFW_KEY_6 || key == GLFW_KEY_RIGHT); + if (roll_ccw || roll_cw) + { + const double step = m_view_roll_step_deg; + m_view->roll_view_z_deg(roll_ccw ? -step : step); + return; + } + } + if (action != GLFW_PRESS) return; + // Nearest world-axis orthographic view (roll zero). Routes before Normal-mode keypad selection filters. + if (key == GLFW_KEY_KP_5 && (mods & (GLFW_MOD_SHIFT | GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) + { + m_view->snap_view_to_nearest_standard_axis(); + return; + } + + // Orbit like trihedron / LMB orbit (AIS_ViewController axes); step matches Settings view rotation step. + if ((mods & (GLFW_MOD_SHIFT | GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) + { + const double step = m_view_roll_step_deg; + // clang-format off + switch (key) + { + case GLFW_KEY_KP_8: m_view->orbit_view_screen_step_deg(0.0, step); return; + case GLFW_KEY_KP_2: m_view->orbit_view_screen_step_deg(0.0, -step); return; + case GLFW_KEY_KP_4: m_view->orbit_view_screen_step_deg(step, 0.0); return; + case GLFW_KEY_KP_6: m_view->orbit_view_screen_step_deg(-step, 0.0); return; + default: break; + } + // clang-format on + } + const ScreenCoords screen_coords(dvec2(ImGui::GetIO().MousePos.x, ImGui::GetIO().MousePos.y)); bool ctrl_pressed = (mods & GLFW_MOD_CONTROL) != 0; diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index 24daf63..6731613 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -3,7 +3,9 @@ #include #include #include +#include +#include "dbg.h" #include "gui.h" #include "imgui.h" #include "occt_view.h" @@ -38,8 +40,10 @@ std::string GUI::occt_view_settings_json() const json j; j["occt_view"] = build_occt_view_settings_object(*m_view); j["gui"] = { - { "edge_dim_label_h", m_edge_dim_label_h}, - {"edge_dim_line_width", m_edge_dim_line_width}, + { "edge_dim_label_h", m_edge_dim_label_h}, + { "edge_dim_line_width", m_edge_dim_line_width}, + { "view_roll_step_deg", m_view_roll_step_deg}, + {"view_zoom_scroll_scale", m_view_zoom_scroll_scale}, }; return j.dump(2); } @@ -77,6 +81,8 @@ void GUI::save_occt_view_settings() { "imgui_rounding_general", m_imgui_rounding_general}, { "imgui_rounding_scroll", m_imgui_rounding_scroll}, { "imgui_rounding_tabs", m_imgui_rounding_tabs}, + { "view_roll_step_deg", m_view_roll_step_deg}, + { "view_zoom_scroll_scale", m_view_zoom_scroll_scale}, #ifndef NDEBUG { "show_dbg", m_show_dbg}, #endif @@ -197,6 +203,34 @@ void GUI::parse_gui_panes_settings_(const std::string& content) m_imgui_rounding_scroll = round_from_json("imgui_rounding_scroll", fb_scroll); m_imgui_rounding_tabs = round_from_json("imgui_rounding_tabs", fb_tabs); + m_view_roll_step_deg = k_gui_view_roll_step_deg_default; + if (g.contains("view_roll_step_deg") && g["view_roll_step_deg"].is_number()) + { + const double v = g["view_roll_step_deg"].get(); + if (v >= k_gui_view_roll_step_deg_min && v <= k_gui_view_roll_step_deg_max) + m_view_roll_step_deg = v; + else + log_message("EzyCad: settings gui.view_roll_step_deg out of range [" + + std::to_string(k_gui_view_roll_step_deg_min) + ", " + + std::to_string(k_gui_view_roll_step_deg_max) + "], got " + std::to_string(v) + + "; using default."); + } + + m_view_zoom_scroll_scale = k_gui_view_zoom_scroll_scale_default; + if (g.contains("view_zoom_scroll_scale") && g["view_zoom_scroll_scale"].is_number()) + { + const double v = g["view_zoom_scroll_scale"].get(); + if (v >= k_gui_view_zoom_scroll_scale_min && v <= k_gui_view_zoom_scroll_scale_max) + m_view_zoom_scroll_scale = v; + else + log_message("EzyCad: settings gui.view_zoom_scroll_scale out of range [" + + std::to_string(k_gui_view_zoom_scroll_scale_min) + ", " + + std::to_string(k_gui_view_zoom_scroll_scale_max) + "], got " + std::to_string(v) + + "; using default."); + } + if (m_view) + m_view->set_zoom_scroll_scale(m_view_zoom_scroll_scale); + if (g.contains("underlay_highlight_color") && g["underlay_highlight_color"].is_array() && g["underlay_highlight_color"].size() >= 3) { const json& a = g["underlay_highlight_color"]; @@ -304,6 +338,71 @@ void GUI::settings_() if (ImGui::Checkbox("Dark mode", &m_dark_mode)) save_occt_view_settings(); + if (ImGui::CollapsingHeader("3D view navigation", ImGuiTreeNodeFlags_DefaultOpen)) + { + if (ImGui::BeginTable("settings_view_nav", 2, ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, k_label_col_w); + ImGui::TableSetupColumn("control", ImGuiTableColumnFlags_WidthStretch); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("View rotation step"); + ImGui::TableSetColumnIndex(1); + // SliderScalar(ImGuiDataType_Double): drag slider, or Ctrl+click for precise keyboard input (standard ImGui). + if (ImGui::SliderScalar("##view_roll_step", ImGuiDataType_Double, &m_view_roll_step_deg, + &k_gui_view_roll_step_deg_min, &k_gui_view_roll_step_deg_max, "%.2f deg", + ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_ClampOnInput)) + save_occt_view_settings(); + m_view_roll_step_deg = + std::clamp(m_view_roll_step_deg, k_gui_view_roll_step_deg_min, k_gui_view_roll_step_deg_max); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Degrees per key press: NumPad 8/2/4/6 orbit (like LMB drag), Shift+NumPad 4/6, Shift+4/6, or Shift+Left/Right roll. " + "Ctrl+click the slider to type a value."); + + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + if (ImGui::SmallButton("?##view_roll_help")) + open_url_("https://github.com/trailcode/EzyCad/blob/main/usage.md#view-roll"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Help: view roll (opens usage.md in your browser)."); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Zoom scroll scale"); + ImGui::TableSetColumnIndex(1); + if (ImGui::SliderScalar("##view_zoom_scroll_scale", ImGuiDataType_Double, &m_view_zoom_scroll_scale, + &k_gui_view_zoom_scroll_scale_min, &k_gui_view_zoom_scroll_scale_max, "%.2f", + ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_ClampOnInput)) + { + m_view_zoom_scroll_scale = std::clamp(m_view_zoom_scroll_scale, k_gui_view_zoom_scroll_scale_min, + k_gui_view_zoom_scroll_scale_max); + if (m_view) + m_view->set_zoom_scroll_scale(m_view_zoom_scroll_scale); + + save_occt_view_settings(); + } + m_view_zoom_scroll_scale = + std::clamp(m_view_zoom_scroll_scale, k_gui_view_zoom_scroll_scale_min, k_gui_view_zoom_scroll_scale_max); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Multiplier for mouse wheel and +/- zoom (same as UpdateZoom scroll delta). " + "Hold Shift while zooming for Blender-style finer steps (x0.1). Ctrl+click to type a value."); + + ImGui::EndTable(); + } + + ImGui::TextWrapped( + "NumPad 8 / 2 / 4 / 6 orbit the view (same axes as left-drag orbit). Hold Shift and press NumPad 4 or NumPad 6, " + "main 4 / 6, or Left / Right arrow for Blender-style roll around the screen Z axis (hold to repeat). " + "Num Lock off is recommended for numpad shortcuts (see usage.md View navigation). " + "Hold Shift while scrolling or pressing +/- for finer zoom."); + } + if (ImGui::CollapsingHeader("UI corner rounding")) { bool r_changed = false; diff --git a/src/lua_console.cpp b/src/lua_console.cpp index ffd1546..d31f1d4 100644 --- a/src/lua_console.cpp +++ b/src/lua_console.cpp @@ -14,17 +14,17 @@ extern "C" #include "lualib.h" } +#include #include #include -#include #include #include #include namespace { -const char* k_registry_gui = "ezycad_gui"; -const char* k_shp_metatable = "EzyCad_Shp"; +const char* k_registry_gui = "ezycad_gui"; +const char* k_shp_metatable = "EzyCad_Shp"; GUI* get_gui(lua_State* L) { @@ -184,7 +184,7 @@ int l_view_get_shape(lua_State* L) return 1; } std::list& shapes = view->get_shapes(); - auto it = shapes.begin(); + auto it = shapes.begin(); for (lua_Integer i = 1; i < idx && it != shapes.end(); ++i, ++it) ; if (it == shapes.end()) @@ -290,7 +290,7 @@ int l_shp_name(lua_State* L) // Shp:set_name(s) int l_shp_set_name(lua_State* L) { - Shp_ptr* p = static_cast(luaL_checkudata(L, 1, k_shp_metatable)); + Shp_ptr* p = static_cast(luaL_checkudata(L, 1, k_shp_metatable)); const char* s = luaL_checkstring(L, 2); (*p)->set_name(s); return 0; @@ -350,7 +350,7 @@ int l_ezy_help(lua_State* L) " ezy.get_mode() - return current mode name\n" " ezy.set_mode(name) - set mode by name\n" " ezy.save_occt_view_settings() - write settings JSON (incl. view colors)\n" - " ezy.occt_view_settings_json() - JSON: occt_view + gui edge_dim_label_h / edge_dim_line_width\n" + " ezy.occt_view_settings_json() - JSON: occt_view + gui edge_dim_*, view_roll_step_deg, view_zoom_scroll_scale\n" " ezy.help() - print this help\n" "view:\n" " view.sketch_count() - number of sketches\n" @@ -361,11 +361,11 @@ int l_ezy_help(lua_State* L) " view.get_shape(i) - get shape by 1-based index (returns Shp or nil)\n" " view.get_camera() - get camera eye/center/up vectors\n" " view.set_camera(ex,ey,ez,cx,cy,cz,ux,uy,uz) - set camera vectors\n" - "Shp (shape object):\n" - " s:name() - get shape name\n" - " s:set_name(s) - set shape name\n" - " s:visible() - get visibility\n" - " s:set_visible(b) - set visibility"; + "Shp (shape object):\n" + " s:name() - get shape name\n" + " s:set_name(s) - set shape name\n" + " s:visible() - get visibility\n" + " s:set_visible(b) - set visibility"; con->append_line_from_lua(help_text); return 0; } @@ -505,7 +505,7 @@ void Lua_console::load_scripts() std::string path_str = path.string(); std::string filename = path.filename().string(); - std::string content; + std::string content; std::ifstream f(path_str); if (f) { diff --git a/src/occt_view.cpp b/src/occt_view.cpp index 1848765..0ff6983 100644 --- a/src/occt_view.cpp +++ b/src/occt_view.cpp @@ -13,8 +13,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -24,11 +26,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include "dbg.h" #include "geom.h" @@ -285,6 +290,113 @@ void Occt_view::init_viewer() m_default_material = Graphic3d_MaterialAspect(Graphic3d_NOM_CHROME); } +void Occt_view::roll_view_z_deg(double degrees) +{ + if (m_view.IsNull()) + return; + + m_view->Turn(V3d_Z, to_radians(degrees), Standard_True); + m_view->Redraw(); +} + +void Occt_view::orbit_view_screen_step_deg(double yaw_deg, double pitch_deg) +{ + if (is_headless() || m_view.IsNull()) + return; + + const Graphic3d_Camera_ptr cam = m_view->Camera(); + if (cam.IsNull()) + return; + + const double yaw_rad = to_radians(yaw_deg); + const double pitch_rad = to_radians(pitch_deg); + if (std::abs(yaw_rad) <= Precision::Angular() && std::abs(pitch_rad) <= Precision::Angular()) + return; + + const gp_Pnt pivot(cam->Center()); + const gp_Dir aCamDir(cam->Direction().Reversed()); + const gp_Dir aCamUp(cam->Up()); + const gp_Dir aCamSide(aCamUp.Crossed(aCamDir)); + + gp_Trsf aTrsf; + if (std::abs(yaw_rad) > Precision::Angular()) + { + gp_Trsf yawTrsf; + yawTrsf.SetRotation(gp_Ax1(pivot, aCamUp), yaw_rad); + aTrsf.Multiply(yawTrsf); + } + + if (std::abs(pitch_rad) > Precision::Angular()) + { + gp_Trsf pitchTrsf; + pitchTrsf.SetRotation(gp_Ax1(pivot, aCamSide), pitch_rad); + aTrsf.Multiply(pitchTrsf); + } + + cam->Transform(aTrsf); + cam->OrthogonalizeUp(); + m_view->Invalidate(); + m_view->Redraw(); +} + +void Occt_view::snap_view_to_nearest_standard_axis() +{ + if (is_headless() || m_view.IsNull()) + return; + + gp_Pnt eye; + gp_Pnt center; + gp_Dir up_unused; + if (!get_camera(eye, center, up_unused)) + return; + + gp_Vec to_center(eye, center); + const double dist = to_center.Magnitude(); + if (dist <= Precision::Confusion()) + return; + + gp_Dir fwd(to_center); + + static const gp_Dir k_axes[6] = { + gp_Dir(1, 0, 0), + gp_Dir(-1, 0, 0), + gp_Dir(0, 1, 0), + gp_Dir(0, -1, 0), + gp_Dir(0, 0, 1), + gp_Dir(0, 0, -1), + }; + + int best_i = 0; + double best_dot = -2.0; + for (int i = 0; i < 6; ++i) + { + const double d = fwd.X() * k_axes[i].X() + fwd.Y() * k_axes[i].Y() + fwd.Z() * k_axes[i].Z(); + if (d > best_dot) + { + best_dot = d; + best_i = i; + } + } + + const gp_Dir f = k_axes[best_i]; + + gp_Dir new_up; + { + const double ax = std::abs(f.X()); + const double ay = std::abs(f.Y()); + const double az = std::abs(f.Z()); + if (az >= ax && az >= ay) + new_up = gp_Dir(0, 1, 0); + else + new_up = gp_Dir(0, 0, 1); + } + + gp_Vec offset(f); + offset.Multiply(-dist); + const gp_Pnt new_eye = center.Translated(offset); + set_camera(new_eye, center, new_up); +} + void Occt_view::init_default() { create_default_sketch_(); @@ -1175,10 +1287,44 @@ void Occt_view::on_resize(int theWidth, int theHeight) } } -void Occt_view::on_mouse_scroll(double theOffsetX, double theOffsetY) +namespace +{ +// Blender-style: Shift held while zooming uses a finer step (same idea as precision transforms). +constexpr double k_zoom_shift_finer_factor = 0.1; +} // namespace + +void Occt_view::set_zoom_scroll_scale(double scale) +{ + m_zoom_scroll_scale = + std::clamp(scale, k_gui_view_zoom_scroll_scale_min, k_gui_view_zoom_scroll_scale_max); +} + +int Occt_view::zoom_scroll_delta_int_(double wheel_y, bool shift_finer_zoom) const +{ + const double scaled = + wheel_y * m_zoom_scroll_scale * (shift_finer_zoom ? k_zoom_shift_finer_factor : 1.0); + long r = std::lround(scaled); + if (r == 0 && wheel_y != 0.0) + r = wheel_y > 0.0 ? 1L : -1L; + + return static_cast(r); +} + +void Occt_view::on_mouse_scroll(double theOffsetX, double theOffsetY, bool shift_finer_zoom) { + (void) theOffsetX; if (!m_view.IsNull()) - UpdateZoom(Aspect_ScrollDelta(m_occt_window->CursorPosition(), int(theOffsetY * 4.0))); + UpdateZoom(Aspect_ScrollDelta(m_occt_window->CursorPosition(), + zoom_scroll_delta_int_(theOffsetY, shift_finer_zoom))); +} + +void Occt_view::zoom_view_wheel_notches(double wheel_notches, bool shift_finer_zoom) +{ + if (m_view.IsNull()) + return; + + UpdateZoom(Aspect_ScrollDelta(m_occt_window->CursorPosition(), + zoom_scroll_delta_int_(wheel_notches, shift_finer_zoom))); } void Occt_view::on_mouse_button(int theButton, int theAction, int theMods) diff --git a/src/occt_view.h b/src/occt_view.h index 9f8b587..4b1fa51 100644 --- a/src/occt_view.h +++ b/src/occt_view.h @@ -12,7 +12,6 @@ #include #include "occt_glfw_win.h" -#include "types.h" #include "shp_chamfer.h" #include "shp_common.h" #include "shp_cut.h" @@ -23,6 +22,7 @@ #include "shp_polar_dup.h" #include "shp_rotate.h" #include "shp_scale.h" +#include "types.h" class Sketch; class GUI; @@ -62,10 +62,10 @@ class Occt_view : protected AIS_ViewController void init_viewer(); void init_default(); - std::string to_json() const; - void load(const std::string& json_str, bool restore_view = true); + std::string to_json() const; + void load(const std::string& json_str, bool restore_view = true); [[nodiscard]] Status import_step(const std::string& step_data); - bool import_ply(const std::string& ply_bytes); + bool import_ply(const std::string& ply_bytes); /// Writes STEP, IGES, binary STL, or PLY to \a file_path. Uses selected shapes if any, else all shapes. [[nodiscard]] Status export_document(Export_format fmt, const std::string& file_path); @@ -151,7 +151,8 @@ class Occt_view : protected AIS_ViewController // Input events. void on_resize(int theWidth, int theHeight); - void on_mouse_scroll(double theOffsetX, double theOffsetY); + /// \param shift_finer_zoom If true, Blender-style x0.1 zoom step (held Shift). + void on_mouse_scroll(double theOffsetX, double theOffsetY, bool shift_finer_zoom = false); void on_mouse_button(int theButton, int theAction, int theMods); void on_mouse_move(const ScreenCoords& screen_coords); @@ -173,6 +174,23 @@ class Occt_view : protected AIS_ViewController bool get_camera(gp_Pnt& out_eye, gp_Pnt& out_center, gp_Dir& out_up) const; void set_camera(const gp_Pnt& eye, const gp_Pnt& center, const gp_Dir& up); + /// Roll the view about screen Z (view depth axis) by \a degrees, via \c V3d_View::Turn(\c V3d_Z, ...). + void roll_view_z_deg(double degrees); + + /// Orbit the view like left-drag on the trihedron: \a yaw_deg about camera up (positive = orbit left), + /// \a pitch_deg about camera side (positive = orbit up). Matches \c AIS_ViewController orbit axes. + void orbit_view_screen_step_deg(double yaw_deg, double pitch_deg); + + /// Zoom like one mouse wheel notch at the cursor (\a wheel_notches > 0 zooms in; same units as \c on_mouse_scroll). + /// \param shift_finer_zoom Blender-style finer step when Shift is held (keyboard or scroll). + void zoom_view_wheel_notches(double wheel_notches, bool shift_finer_zoom = false); + + /// Clamp and store scroll-scale used by \c on_mouse_scroll / \c zoom_view_wheel_notches (from Settings JSON). + void set_zoom_scroll_scale(double scale); + + /// Snap orientation to the nearest world-axis orthographic view (+/-X/Y/Z), roll zero; keeps eye-center distance. + void snap_view_to_nearest_standard_axis(); + GUI& gui(); AIS_InteractiveContext& ctx(); @@ -225,7 +243,7 @@ class Occt_view : protected AIS_ViewController void add_shp_(Shp_ptr& shp); std::string unique_shape_name_(const char* base_name) const; - TopoDS_Shape shape_with_local_transform_(const AIS_Shape_ptr& ais) const; + TopoDS_Shape shape_with_local_transform_(const AIS_Shape_ptr& ais) const; [[nodiscard]] Status build_export_shape_(TopoDS_Shape& out_shape) const; void update_view_background_(); @@ -235,17 +253,22 @@ class Occt_view : protected AIS_ViewController static Aspect_VKeyMouse mouse_button_from_glfw_(int theButton); static Aspect_VKeyFlags key_flags_from_glfw_(int theFlags); + /// Maps wheel delta to OCCT zoom units using \ref m_zoom_scroll_scale and optional Shift (x0.1). + int zoom_scroll_delta_int_(double wheel_y, bool shift_finer_zoom) const; + GUI& m_gui; AIS_InteractiveContext_ptr m_ctx; V3d_View_ptr m_view; Occt_glfw_win_ptr m_occt_window; // Undo / redo static constexpr size_t k_max_undo {50}; + struct Undo_entry { std::string json; Mode mode; // Mode at time of operation; restored when navigating stacks }; + std::vector m_undo_stack; std::vector m_redo_stack; bool m_restoring {false}; @@ -269,6 +292,8 @@ class Occt_view : protected AIS_ViewController int m_bg_gradient_method {1}; // 0=HOR, 1=VER, 2=DIAG1, ... float m_grid_color1[3] {0.1f, 0.1f, 0.1f}; float m_grid_color2[3] {0.1f, 0.1f, 0.3f}; + /// User setting: same role as former literal in `UpdateZoom(Aspect_ScrollDelta(..., int(y * scale)))`. + double m_zoom_scroll_scale {4.0}; // -------------------------------------------------------------------- // Operations Shp_move m_shp_move; diff --git a/src/python_console.cpp b/src/python_console.cpp index f38895e..9896092 100644 --- a/src/python_console.cpp +++ b/src/python_console.cpp @@ -1,12 +1,5 @@ #include "python_console.h" -#include "gui.h" -#include "imgui.h" -#include "modes.h" -#include "occt_view.h" -#include "shp.h" -#include "sketch.h" - #include #include #include @@ -14,21 +7,28 @@ #include #include +#include "gui.h" +#include "imgui.h" +#include "modes.h" +#include "occt_view.h" +#include "shp.h" +#include "sketch.h" + #ifdef EZYCAD_HAVE_PYTHON -# ifdef _MSC_VER +#ifdef _MSC_VER // pybind11 pulls in Python.h; avoid python3xx_d.lib #pragma in Debug (we link release DLL). -# ifdef _DEBUG -# pragma push_macro("_DEBUG") -# undef _DEBUG -# define EZYCAD_POP_DEBUG_AFTER_PYBIND -# endif -# endif -# include -# include -# ifdef EZYCAD_POP_DEBUG_AFTER_PYBIND -# pragma pop_macro("_DEBUG") -# undef EZYCAD_POP_DEBUG_AFTER_PYBIND -# endif +#ifdef _DEBUG +#pragma push_macro("_DEBUG") +#undef _DEBUG +#define EZYCAD_POP_DEBUG_AFTER_PYBIND +#endif +#endif +#include +#include +#ifdef EZYCAD_POP_DEBUG_AFTER_PYBIND +#pragma pop_macro("_DEBUG") +#undef EZYCAD_POP_DEBUG_AFTER_PYBIND +#endif namespace py = pybind11; @@ -59,10 +59,10 @@ void append_python_exception_to_history(std::vector& history, bool* PyObject* fmt = PyObject_GetAttrString(tb_mod, "format_exception"); if (fmt && PyCallable_Check(fmt)) { - PyObject* t = type ? type : Py_None; - PyObject* v = value ? value : Py_None; - PyObject* tb = traceback ? traceback : Py_None; - PyObject* args = PyTuple_Pack(3, t, v, tb); + PyObject* t = type ? type : Py_None; + PyObject* v = value ? value : Py_None; + PyObject* tb = traceback ? traceback : Py_None; + PyObject* args = PyTuple_Pack(3, t, v, tb); if (args) { lines = PyObject_CallObject(fmt, args); @@ -78,8 +78,8 @@ void append_python_exception_to_history(std::vector& history, bool* Py_ssize_t n = PyList_GET_SIZE(lines); for (Py_ssize_t i = 0; i < n; ++i) { - PyObject* item = PyList_GET_ITEM(lines, i); - const char* u = PyUnicode_AsUTF8(item); + PyObject* item = PyList_GET_ITEM(lines, i); + const char* u = PyUnicode_AsUTF8(item); if (u) { std::string line(u); @@ -92,8 +92,8 @@ void append_python_exception_to_history(std::vector& history, bool* } else { - PyObject* s = value ? PyObject_Str(value) : nullptr; - const char* msg = s ? PyUnicode_AsUTF8(s) : "Python error"; + PyObject* s = value ? PyObject_Str(value) : nullptr; + const char* msg = s ? PyUnicode_AsUTF8(s) : "Python error"; history.push_back(std::string("[err] ") + (msg ? msg : "?")); *scroll = true; Py_XDECREF(s); @@ -158,10 +158,14 @@ del _ezycad_bootstrap PYBIND11_EMBEDDED_MODULE(ezycad_native, m) { py::class_(m, "Shp") - .def("name", [](const Ezy_shp& s) { return s.shp->get_name(); }) - .def("set_name", [](Ezy_shp& s, const std::string& n) { s.shp->set_name(n); }) - .def("visible", [](const Ezy_shp& s) { return s.shp->get_visible(); }) - .def("set_visible", [](Ezy_shp& s, bool v) { s.shp->set_visible(v); }) + .def("name", [](const Ezy_shp& s) + { return s.shp->get_name(); }) + .def("set_name", [](Ezy_shp& s, const std::string& n) + { s.shp->set_name(n); }) + .def("visible", [](const Ezy_shp& s) + { return s.shp->get_visible(); }) + .def("set_visible", [](Ezy_shp& s, bool v) + { s.shp->set_visible(v); }) .def( "__repr__", [](const Ezy_shp& s) @@ -222,7 +226,7 @@ PYBIND11_EMBEDDED_MODULE(ezycad_native, m) " ezy.get_mode() - return current mode name\n" " ezy.set_mode(name) - set mode by name\n" " ezy.save_occt_view_settings() - write settings JSON (incl. view colors)\n" - " ezy.occt_view_settings_json() - JSON: occt_view + gui edge_dim_label_h / edge_dim_line_width\n" + " ezy.occt_view_settings_json() - JSON: occt_view + gui edge_dim_*, view_roll_step_deg, view_zoom_scroll_scale\n" " ezy.help() - print this help\n" "view:\n" " view.sketch_count() - number of sketches\n" @@ -482,7 +486,7 @@ void Python_console::load_scripts() std::string path_str = path.string(); std::string filename = path.filename().string(); - std::string content; + std::string content; std::ifstream f(path_str); if (f) { @@ -716,7 +720,7 @@ void Python_console::render(bool* p_open) if (ImGui::Button("Save")) { const std::string to_save = script.editor.GetText(); - std::ofstream of(script.path); + std::ofstream of(script.path); if (of) { if (!to_save.empty()) @@ -737,4 +741,3 @@ void Python_console::render(bool* p_open) ImGui::EndTabBar(); ImGui::End(); } - diff --git a/src/sketch_underlay.h b/src/sketch_underlay.h index e14d03c..b3e2afe 100644 --- a/src/sketch_underlay.h +++ b/src/sketch_underlay.h @@ -42,55 +42,24 @@ class Sketch_underlay /// When true (default), bright pixels (white paper) become transparent in the texture; dark linework stays opaque. void set_key_white_transparent(bool on); - [[nodiscard]] bool key_white_transparent() const - { - return m_key_white_transparent; - } - void set_line_tint_enabled(bool on); void set_line_tint_rgb(uint8_t r, uint8_t g, uint8_t b); - [[nodiscard]] bool line_tint_enabled() const - { - return m_line_tint_enabled; - } - void line_tint_rgb(uint8_t& r, uint8_t& g, uint8_t& b) const; - [[nodiscard]] float opacity() const - { - return m_opacity; - } - - [[nodiscard]] bool visible() const - { - return m_visible; - } - - [[nodiscard]] gp_Pnt2d base() const - { - return m_base; - } - - [[nodiscard]] gp_Vec2d axis_u() const - { - return m_axis_u; - } - - [[nodiscard]] gp_Vec2d axis_v() const - { - return m_axis_v; - } + // clang-format off - [[nodiscard]] int image_w() const - { - return m_w; - } + [[nodiscard]] bool key_white_transparent() const { return m_key_white_transparent; } + [[nodiscard]] bool line_tint_enabled() const { return m_line_tint_enabled; } + [[nodiscard]] float opacity() const { return m_opacity; } + [[nodiscard]] bool visible() const { return m_visible; } + [[nodiscard]] gp_Pnt2d base() const { return m_base; } + [[nodiscard]] gp_Vec2d axis_u() const { return m_axis_u; } + [[nodiscard]] gp_Vec2d axis_v() const { return m_axis_v; } + [[nodiscard]] int image_w() const { return m_w; } + [[nodiscard]] int image_h() const { return m_h; } - [[nodiscard]] int image_h() const - { - return m_h; - } + // clang-format on void rebuild_and_display(const gp_Pln& pln, AIS_InteractiveContext& ctx); void erase(AIS_InteractiveContext& ctx); diff --git a/third_party/imgui_markdown/License.txt b/third_party/imgui_markdown/License.txt new file mode 100644 index 0000000..0378d14 --- /dev/null +++ b/third_party/imgui_markdown/License.txt @@ -0,0 +1,17 @@ +Copyright (c) 2019 Juliette Foucaut and Doug Binks + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgement in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. \ No newline at end of file diff --git a/third_party/imgui_markdown/imgui_markdown.h b/third_party/imgui_markdown/imgui_markdown.h new file mode 100644 index 0000000..a00fb09 --- /dev/null +++ b/third_party/imgui_markdown/imgui_markdown.h @@ -0,0 +1,1177 @@ +#pragma once + +// License: zlib +// Copyright (c) 2019 Juliette Foucaut & Doug Binks +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +/* +API BREAKING CHANGES +==================== +- 2020/04/22 - Added tooltipCallback parameter to ImGui::MarkdownConfig +- 2019/02/01 - Changed LinkCallback parameters, see https://github.com/juliettef/imgui_markdown/issues/2 +- 2019/02/05 - Added imageCallback parameter to ImGui::MarkdownConfig +- 2019/02/06 - Added useLinkCallback member variable to MarkdownImageData to configure using images as links +*/ + +/* +imgui_markdown https://github.com/juliettef/imgui_markdown +Markdown for Dear ImGui + +A permissively licensed markdown single-header library for https://github.com/ocornut/imgui + +Currently requires C++11 or above + +imgui_markdown currently supports the following markdown functionality: + - Wrapped text + - Headers H1, H2, H3 + - Emphasis + - Indented text, multi levels + - Unordered lists and sub-lists + - Link + - Image + - Horizontal rule + +Syntax + +Wrapping: +Text wraps automatically. To add a new line, use 'Return'. + +Headers: +# H1 +## H2 +### H3 + +Emphasis: +*emphasis* +_emphasis_ +**strong emphasis** +__strong emphasis__ + +Indents: +On a new line, at the start of the line, add two spaces per indent. + Indent level 1 + Indent level 2 + +Unordered lists: +On a new line, at the start of the line, add two spaces, an asterisks and a space. +For nested lists, add two additional spaces in front of the asterisk per list level increment. + * Unordered List level 1 + * Unordered List level 2 + +Link: +[link description](https://...) + +Image: +![image alt text](image identifier e.g. filename) + +Horizontal Rule: +*** +___ + +=============================================================================== + +// Example use on Windows with links opening in a browser + +#include "ImGui.h" // https://github.com/ocornut/imgui +#include "imgui_markdown.h" // https://github.com/juliettef/imgui_markdown +#include "IconsFontAwesome5.h" // https://github.com/juliettef/IconFontCppHeaders + +// Following includes for Windows LinkCallback +#define WIN32_LEAN_AND_MEAN +#include +#include "Shellapi.h" +#include + +void LinkCallback( ImGui::MarkdownLinkCallbackData data_ ); +inline ImGui::MarkdownImageData ImageCallback( ImGui::MarkdownLinkCallbackData data_ ); + +static ImFont* H1 = NULL; +static ImFont* H2 = NULL; +static ImFont* H3 = NULL; + +static ImGui::MarkdownConfig mdConfig; + + +void LinkCallback( ImGui::MarkdownLinkCallbackData data_ ) +{ + std::string url( data_.link, data_.linkLength ); + if( !data_.isImage ) + { + ShellExecuteA( NULL, "open", url.c_str(), NULL, NULL, SW_SHOWNORMAL ); + } +} + +inline ImGui::MarkdownImageData ImageCallback( ImGui::MarkdownLinkCallbackData data_ ) +{ + // In your application you would load an image based on data_ input. Here we just use the imgui font texture. + ImTextureID image = ImGui::GetIO().Fonts->TexID; + // > C++14 can use ImGui::MarkdownImageData imageData{ true, false, image, ImVec2( 40.0f, 20.0f ) }; + ImGui::MarkdownImageData imageData; + imageData.isValid = true; + imageData.useLinkCallback = false; + imageData.user_texture_id = image; + imageData.size = ImVec2( 40.0f, 20.0f ); + + // For image resize when available size.x > image width, add + ImVec2 const contentSize = ImGui::GetContentRegionAvail(); + if( imageData.size.x > contentSize.x ) + { + float const ratio = imageData.size.y/imageData.size.x; + imageData.size.x = contentSize.x; + imageData.size.y = contentSize.x*ratio; + } + + return imageData; +} + +void LoadFonts( float fontSize_ = 12.0f ) +{ + ImGuiIO& io = ImGui::GetIO(); + io.Fonts->Clear(); + // Base font + io.Fonts->AddFontFromFileTTF( "myfont.ttf", fontSize_ ); + // Bold headings H2 and H3 + H2 = io.Fonts->AddFontFromFileTTF( "myfont-bold.ttf", fontSize_ ); + H3 = mdConfig.headingFormats[ 1 ].font; + // bold heading H1 + float fontSizeH1 = fontSize_ * 1.1f; + H1 = io.Fonts->AddFontFromFileTTF( "myfont-bold.ttf", fontSizeH1 ); +} + +void ExampleMarkdownFormatCallback( const ImGui::MarkdownFormatInfo& markdownFormatInfo_, bool start_ ) +{ + // Call the default first so any settings can be overwritten by our implementation. + // Alternatively could be called or not called in a switch statement on a case by case basis. + // See defaultMarkdownFormatCallback definition for furhter examples of how to use it. + ImGui::defaultMarkdownFormatCallback( markdownFormatInfo_, start_ ); + + switch( markdownFormatInfo_.type ) + { + // example: change the colour of heading level 2 + case ImGui::MarkdownFormatType::HEADING: + { + if( markdownFormatInfo_.level == 2 ) + { + if( start_ ) + { + ImGui::PushStyleColor( ImGuiCol_Text, ImGui::GetStyle().Colors[ ImGuiCol_TextDisabled ] ); + } + else + { + ImGui::PopStyleColor(); + } + } + break; + } + default: + { + break; + } + } +} + +void Markdown( const std::string& markdown_ ) +{ + // You can make your own Markdown function with your prefered string container and markdown config. + // > C++14 can use ImGui::MarkdownConfig mdConfig{ LinkCallback, NULL, ImageCallback, ICON_FA_LINK, { { H1, true }, { H2, true }, { H3, false } }, NULL }; + mdConfig.linkCallback = LinkCallback; + mdConfig.tooltipCallback = NULL; + mdConfig.imageCallback = ImageCallback; + mdConfig.linkIcon = ICON_FA_LINK; + mdConfig.headingFormats[0] = { H1, true }; + mdConfig.headingFormats[1] = { H2, true }; + mdConfig.headingFormats[2] = { H3, false }; + mdConfig.userData = NULL; + mdConfig.formatCallback = ExampleMarkdownFormatCallback; + ImGui::Markdown( markdown_.c_str(), markdown_.length(), mdConfig ); +} + +void MarkdownExample() +{ + const std::string markdownText = u8R"( +# H1 Header: Text and Links +You can add [links like this one to enkisoftware](https://www.enkisoftware.com/) and lines will wrap well. +You can also insert images ![image alt text](image identifier e.g. filename) +Horizontal rules: +*** +___ +*Emphasis* and **strong emphasis** change the appearance of the text. +## H2 Header: indented text. + This text has an indent (two leading spaces). + This one has two. +### H3 Header: Lists + * Unordered lists + * Lists can be indented with two extra spaces. + * Lists can have [links like this one to Avoyd](https://www.avoyd.com/) and *emphasized text* +)"; + Markdown( markdownText ); +} + +=============================================================================== +*/ + +#include + +typedef int ImGuiMarkdownFormatFlags; + +enum ImGuiMarkdownFormatFlags_ +{ + ImGuiMarkdownFormatFlags_None = 0, + ImGuiMarkdownFormatFlags_DiscardExtraNewLines = 1 << 0, // (Accurate parsing) Provided markdown will discard all redundant newlines + ImGuiMarkdownFormatFlags_NoNewLineBeforeHeading = 1 << 1, // (Accurate parsing) Provided markdown will not format a newline after the first line if it is a heading + ImGuiMarkdownFormatFlags_SeparatorDoesNotAdvance = 1 << 2, // (Accurate parsing) Provided markdown will not advance to the next line after formatting a separator + ImGuiMarkdownFormatFlags_GithubStyle = ImGuiMarkdownFormatFlags_DiscardExtraNewLines | ImGuiMarkdownFormatFlags_NoNewLineBeforeHeading | ImGuiMarkdownFormatFlags_SeparatorDoesNotAdvance, + ImGuiMarkdownFormatFlags_CommonMarkAll = ImGuiMarkdownFormatFlags_DiscardExtraNewLines | ImGuiMarkdownFormatFlags_NoNewLineBeforeHeading | ImGuiMarkdownFormatFlags_SeparatorDoesNotAdvance, +}; + +namespace ImGui +{ + //----------------------------------------------------------------------------- + // Basic types + //----------------------------------------------------------------------------- + + struct Link; + struct MarkdownConfig; + + struct MarkdownLinkCallbackData // for both links and images + { + const char* text; // text between square brackets [] + int textLength; + const char* link; // text between brackets () + int linkLength; + void* userData; + bool isImage; // true if '!' is detected in front of the link syntax + }; + + struct MarkdownTooltipCallbackData // for tooltips + { + MarkdownLinkCallbackData linkData; + const char* linkIcon; + }; + + struct MarkdownImageData + { + bool isValid = false; // if true, will draw the image + bool useLinkCallback = false; // if true, linkCallback will be called when image is clicked + ImTextureID user_texture_id = {}; // see ImGui::Image + ImVec2 size = ImVec2( 100.0f, 100.0f ); // see ImGui::Image + ImVec2 uv0 = ImVec2( 0, 0 ); // see ImGui::Image + ImVec2 uv1 = ImVec2( 1, 1 ); // see ImGui::Image + ImVec4 tint_col = ImVec4( 1, 1, 1, 1 ); // see ImGui::Image + ImVec4 border_col = ImVec4( 0, 0, 0, 0 ); // see ImGui::Image + ImVec4 bg_col = ImVec4( 0, 0, 0, 0 ); // see ImGui::Image + }; + + enum class MarkdownFormatType + { + NORMAL_TEXT, + HEADING, + UNORDERED_LIST, + LINK, + EMPHASIS, + }; + + struct MarkdownFormatInfo + { + MarkdownFormatType type = MarkdownFormatType::NORMAL_TEXT; + int32_t level = 0; // Set for headings: 1 for H1, 2 for H2 etc. + bool itemHovered = false; // Currently only set for links when mouse hovered, only valid when start_ == false + const MarkdownConfig* config = NULL; + const char* text = NULL; + int32_t textLength = 0; + }; + + typedef void MarkdownLinkCallback( MarkdownLinkCallbackData data ); + typedef void MarkdownTooltipCallback( MarkdownTooltipCallbackData data ); + + inline void defaultMarkdownTooltipCallback( MarkdownTooltipCallbackData data_ ) + { + if( data_.linkData.isImage ) + { + ImGui::SetTooltip( "%.*s", data_.linkData.linkLength, data_.linkData.link ); + } + else + { + ImGui::SetTooltip( "%s Open in browser\n%.*s", data_.linkIcon, data_.linkData.linkLength, data_.linkData.link ); + } + } + + typedef MarkdownImageData MarkdownImageCallback( MarkdownLinkCallbackData data ); + typedef void MarkdownFormalCallback( const MarkdownFormatInfo& markdownFormatInfo_, bool start_ ); + + inline void defaultMarkdownFormatCallback( const MarkdownFormatInfo& markdownFormatInfo_, bool start_ ); + + struct MarkdownHeadingFormat + { + ImFont* font; // ImGui font + bool separator; // if true, an underlined separator is drawn after the header + #ifdef IMGUI_HAS_TEXTURES // used to detect dynamic font capability: https://github.com/ocornut/imgui/issues/8465#issuecomment-2701570771 + float fontSize = 0.0f; // Font size if using dynamic fonts + #endif + }; + + // Configuration struct for Markdown + // - linkCallback is called when a link is clicked on + // - linkIcon is a string which encode a "Link" icon, if available in the current font (e.g. linkIcon = ICON_FA_LINK with FontAwesome + IconFontCppHeaders https://github.com/juliettef/IconFontCppHeaders) + // - headingFormats controls the format of heading H1 to H3, those above H3 use H3 format + struct MarkdownConfig + { + static const int NUMHEADINGS = 3; + + MarkdownLinkCallback* linkCallback = NULL; + MarkdownTooltipCallback* tooltipCallback = NULL; + MarkdownImageCallback* imageCallback = NULL; + const char* linkIcon = ""; // icon displayd in link tooltip + MarkdownHeadingFormat headingFormats[ NUMHEADINGS ] = { { NULL, true }, { NULL, true }, { NULL, true } }; + void* userData = NULL; + MarkdownFormalCallback* formatCallback = defaultMarkdownFormatCallback; + ImGuiMarkdownFormatFlags formatFlags = ImGuiMarkdownFormatFlags_None; // Configure this to change how Markdown gets formatted. By default imgui_markdown uses psuedo-Markdown for backwards compatibility. + }; + + //----------------------------------------------------------------------------- + // External interface + //----------------------------------------------------------------------------- + + inline void Markdown( const char* markdown_, size_t markdownLength_, const MarkdownConfig& mdConfig_ ); + + //----------------------------------------------------------------------------- + // Internals + //----------------------------------------------------------------------------- + + struct TextRegion; + struct Line; + inline void UnderLine( ImColor col_ ); + inline void RenderLine( const char* markdown_, Line& line_, TextRegion& textRegion_, const MarkdownConfig& mdConfig_ ); + + struct TextRegion + { + TextRegion() : indentX( 0.0f ) + { + } + ~TextRegion() + { + ResetIndent(); + } + + void RenderTextWrapped( const char* text_, const char* text_end_, bool bIndentToHere_ = false ); + + void RenderListTextWrapped( const char* text_, const char* text_end_ ) + { + ImGui::Bullet(); + ImGui::SameLine(); + RenderTextWrapped( text_, text_end_, true ); + } + + bool RenderLinkText( const char* text_, const char* text_end_, const Link& link_, + const char* markdown_, const MarkdownConfig& mdConfig_, const char** linkHoverStart_ ); + + void RenderLinkTextWrapped( const char* text_, const char* text_end_, const Link& link_, + const char* markdown_, const MarkdownConfig& mdConfig_, const char** linkHoverStart_, bool bIndentToHere_ = false ); + + void ResetIndent() + { + if( indentX > 0.0f ) + { + ImGui::Unindent( indentX ); + } + indentX = 0.0f; + } + + private: + float indentX; + }; + + // Text that starts after a new line (or at beginning) and ends with a newline (or at end) + struct Line { + bool isHeading = false; + bool isEmphasis = false; + bool isUnorderedListStart = false; + bool isLeadingSpace = true; // spaces at start of line + int leadSpaceCount = 0; + int headingCount = 0; + int emphasisCount = 0; + int lineStart = 0; + int lineEnd = 0; + int lastRenderPosition = 0; // lines may get rendered in multiple pieces + }; + + struct TextBlock { // subset of line + int start = 0; + int stop = 0; + int size() const + { + return stop - start; + } + }; + + struct Link { + enum LinkState { + NO_LINK, + HAS_SQUARE_BRACKET_OPEN, + HAS_SQUARE_BRACKETS, + HAS_SQUARE_BRACKETS_ROUND_BRACKET_OPEN, + }; + LinkState state = NO_LINK; + TextBlock text; + TextBlock url; + bool isImage = false; + int num_brackets_open = 0; + }; + + struct Emphasis { + enum EmphasisState { + NONE, + LEFT, + MIDDLE, + RIGHT, + }; + EmphasisState state = NONE; + TextBlock text; + char sym; + }; + + inline void UnderLine( ImColor col_ ) + { + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + min.y = max.y; + ImGui::GetWindowDrawList()->AddLine( min, max, col_, 1.0f ); + } + + inline void RenderLine( const char* markdown_, Line& line_, TextRegion& textRegion_, const MarkdownConfig& mdConfig_ ) + { + // indent + int indentStart = 0; + if( line_.isUnorderedListStart ) // ImGui unordered list render always adds one indent + { + indentStart = 1; + } + for( int j = indentStart; j < line_.leadSpaceCount / 2; ++j ) // add indents + { + ImGui::Indent(); + } + + // render + MarkdownFormatInfo formatInfo; + formatInfo.config = &mdConfig_; + int textStart = line_.lastRenderPosition + 1; + int textSize = line_.lineEnd - textStart; + if( line_.isUnorderedListStart ) // render unordered list + { + formatInfo.type = MarkdownFormatType::UNORDERED_LIST; + mdConfig_.formatCallback( formatInfo, true ); + const char* text = markdown_ + textStart + 1; + textRegion_.RenderListTextWrapped( text, text + textSize - 1 ); + } + else if( line_.isHeading ) // render heading + { + formatInfo.level = line_.headingCount; + formatInfo.type = MarkdownFormatType::HEADING; + const char* text = markdown_ + textStart + 1; + formatInfo.text = text; + formatInfo.textLength = textSize - 1; + mdConfig_.formatCallback( formatInfo, true ); + textRegion_.RenderTextWrapped( text, text + textSize - 1 ); + } + else if( line_.isEmphasis ) // render emphasis + { + formatInfo.level = line_.emphasisCount; + formatInfo.type = MarkdownFormatType::EMPHASIS; + mdConfig_.formatCallback(formatInfo, true); + const char* text = markdown_ + textStart; + textRegion_.RenderTextWrapped(text, text + textSize); + } + else // render a normal paragraph chunk + { + formatInfo.type = MarkdownFormatType::NORMAL_TEXT; + mdConfig_.formatCallback( formatInfo, true ); + const char* text = markdown_ + textStart; + textRegion_.RenderTextWrapped( text, text + textSize ); + } + mdConfig_.formatCallback( formatInfo, false ); + + // unindent + for( int j = indentStart; j < line_.leadSpaceCount / 2; ++j ) + { + ImGui::Unindent(); + } + } + + // render markdown + inline void Markdown( const char* markdown_, size_t markdownLength_, const MarkdownConfig& mdConfig_ ) + { + static const char* s_linkHoverStart = NULL; // we need to preserve status of link hovering between frames + static ImGuiID s_linkHoverID = 0; + const char* linkHoverStart = NULL; + ImGuiID linkHoverID = ImGui::GetID("MDLHS"); + if( linkHoverID == s_linkHoverID ) + { + linkHoverStart = s_linkHoverStart; + } + + ImGuiStyle& style = ImGui::GetStyle(); + Line line; + Line prevLine; + Link link; + Emphasis em; + TextRegion textRegion; + int concurrentEmptyNewlines = 0; + bool appliedExtraNewline = false; + + char c = 0; + for( int i=0; i < (int)markdownLength_; ++i ) + { + c = markdown_[i]; // get the character at index + if( c == 0 ) { break; } // shouldn't happen but don't go beyond 0. + + // If we're at the beginning of the line, count any spaces + if( line.isLeadingSpace ) + { + if ( (mdConfig_.formatFlags & ImGuiMarkdownFormatFlags_DiscardExtraNewLines) ) // Discard LF and CRLF newlines by markdown spec + { + if ( c == '\n' ) + { + concurrentEmptyNewlines++; + line.lineStart += 1; + continue; + } + else if ( ( c == '\r' ) && ( (int)markdownLength_ > i + 1 ) && ( markdown_[i + 1] == '\n' ) ) + { + concurrentEmptyNewlines++; + line.lineStart += 2; + i += 1; + continue; + } + } + + if( c == ' ' ) + { + ++line.leadSpaceCount; + continue; + } + else + { + line.isLeadingSpace = false; + line.lastRenderPosition = i - 1; + if(( c == '*' ) && ( line.leadSpaceCount >= 2 )) + { + if( ( (int)markdownLength_ > i + 1 ) && ( markdown_[ i + 1 ] == ' ' ) ) // space after '*' + { + line.isUnorderedListStart = true; + ++i; + ++line.lastRenderPosition; + } + // carry on processing as could be emphasis + } + else if( c == '#' ) + { + line.headingCount++; + bool bContinueChecking = true; + int j = i; + while( ++j < (int)markdownLength_ && bContinueChecking ) + { + c = markdown_[j]; + switch( c ) + { + case '#': + line.headingCount++; + break; + case ' ': + line.lastRenderPosition = j - 1; + i = j; + line.isHeading = true; + bContinueChecking = false; + break; + default: + line.isHeading = false; + bContinueChecking = false; + break; + } + } + if( line.isHeading ) + { + // reset emphasis status, we do not support emphasis around headers for now + em = Emphasis(); + continue; + } + } + } + } + + if ( (mdConfig_.formatFlags & ImGuiMarkdownFormatFlags_DiscardExtraNewLines) ) + { + // In markdown spec, 2 or more consecutive newlines gets converted to a single blank + // line. The first newline is always digested by this parser so we check for 1 or more here. + if (!appliedExtraNewline && !prevLine.isHeading && concurrentEmptyNewlines >= 1) { + ImGui::NewLine(); + appliedExtraNewline = true; + } + } + + // Test to see if we have a link + switch( link.state ) + { + case Link::NO_LINK: + if( c == '[' && !line.isHeading ) // we do not support headings with links for now + { + link.state = Link::HAS_SQUARE_BRACKET_OPEN; + link.text.start = i + 1; + if( i > 0 && markdown_[i - 1] == '!' ) + { + link.isImage = true; + } + } + break; + case Link::HAS_SQUARE_BRACKET_OPEN: + if( c == ']' ) + { + link.state = Link::HAS_SQUARE_BRACKETS; + link.text.stop = i; + } + break; + case Link::HAS_SQUARE_BRACKETS: + if( c == '(' ) + { + link.state = Link::HAS_SQUARE_BRACKETS_ROUND_BRACKET_OPEN; + link.url.start = i + 1; + link.num_brackets_open = 1; + } + break; + case Link::HAS_SQUARE_BRACKETS_ROUND_BRACKET_OPEN: + if( c == '(' ) + { + ++link.num_brackets_open; + } + else if( c == ')' ) + { + --link.num_brackets_open; + } + if( link.num_brackets_open == 0 ) + { + // reset emphasis status, we do not support emphasis around links for now + em = Emphasis(); + // render previous line content + line.lineEnd = link.text.start - ( link.isImage ? 2 : 1 ); + RenderLine( markdown_, line, textRegion, mdConfig_ ); + line.leadSpaceCount = 0; + link.url.stop = i; + line.isUnorderedListStart = false; // the following text shouldn't have bullets + ImGui::SameLine( 0.0f, 0.0f ); + if( link.isImage ) // it's an image, render it. + { + bool drawnImage = false; + bool useLinkCallback = false; + if( mdConfig_.imageCallback ) + { + MarkdownImageData imageData = mdConfig_.imageCallback( { markdown_ + link.text.start, link.text.size(), markdown_ + link.url.start, link.url.size(), mdConfig_.userData, true } ); + useLinkCallback = imageData.useLinkCallback; + if( imageData.isValid ) + { +#if IMGUI_VERSION_NUM < 19185 + if( imageData.bg_col.w > 0.0f ) + { + ImVec2 p = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddRectFilled( p, ImVec2( p.x + imageData.size.x, p.y + imageData.size.y ), ImGui::GetColorU32( imageData.bg_col )); + } + ImGui::Image( imageData.user_texture_id, imageData.size, imageData.uv0, imageData.uv1, imageData.tint_col, imageData.border_col ); +#else + ImGui::PushStyleColor( ImGuiCol_Border, imageData.border_col ); + ImGui::ImageWithBg( imageData.user_texture_id, imageData.size, imageData.uv0, imageData.uv1, imageData.bg_col, imageData.tint_col ); + ImGui::PopStyleColor(); +#endif + drawnImage = true; + } + } + if( !drawnImage ) + { + ImGui::Text( "( Image %.*s not loaded )", link.url.size(), markdown_ + link.url.start ); + } + if( ImGui::IsItemHovered() ) + { + if( ImGui::IsMouseReleased( 0 ) && mdConfig_.linkCallback && useLinkCallback ) + { + mdConfig_.linkCallback( { markdown_ + link.text.start, link.text.size(), markdown_ + link.url.start, link.url.size(), mdConfig_.userData, true } ); + } + if( link.text.size() > 0 && mdConfig_.tooltipCallback ) + { + mdConfig_.tooltipCallback( { { markdown_ + link.text.start, link.text.size(), markdown_ + link.url.start, link.url.size(), mdConfig_.userData, true }, mdConfig_.linkIcon } ); + } + } + } + else // it's a link, render it. + { + textRegion.RenderLinkTextWrapped( markdown_ + link.text.start, markdown_ + link.text.start + link.text.size(), link, markdown_, mdConfig_, &linkHoverStart, false ); + } + ImGui::SameLine( 0.0f, 0.0f ); + // reset the link by reinitializing it + link = Link(); + line.lastRenderPosition = i; + break; + } + } + + // Test to see if we have emphasis styling + switch( em.state ) + { + case Emphasis::NONE: + if( link.state == Link::NO_LINK && !line.isHeading ) + { + int next = i + 1; + int prev = i - 1; + if( ( c == '*' || c == '_' ) + && ( i == line.lineStart + || markdown_[ prev ] == ' ' + || markdown_[ prev ] == '\t' ) // empasis must be preceded by whitespace or line start + && (int)markdownLength_ > next // emphasis must precede non-whitespace + && markdown_[ next ] != ' ' + && markdown_[ next ] != '\n' + && markdown_[ next ] != '\t' ) + { + em.state = Emphasis::LEFT; + em.sym = c; + em.text.start = i; + line.emphasisCount = 1; + continue; + } + } + break; + case Emphasis::LEFT: + if( em.sym == c ) + { + ++line.emphasisCount; + continue; + } + else + { + em.text.start = i; + em.state = Emphasis::MIDDLE; + } + break; + case Emphasis::MIDDLE: + if( em.sym == c ) + { + em.state = Emphasis::RIGHT; + em.text.stop = i; + // pass through to case Emphasis::RIGHT + } + else + { + break; + } + #if __cplusplus >= 201703L + [[fallthrough]]; + #endif + case Emphasis::RIGHT: + if( em.sym == c ) + { + if( line.emphasisCount < 3 && ( i - em.text.stop + 1 == line.emphasisCount ) ) + { + // render text up to emphasis + int lineEnd = em.text.start - line.emphasisCount; + if( lineEnd > line.lineStart ) + { + line.lineEnd = lineEnd; + RenderLine( markdown_, line, textRegion, mdConfig_ ); + ImGui::SameLine( 0.0f, 0.0f ); + line.isUnorderedListStart = false; + line.leadSpaceCount = 0; + } + line.isEmphasis = true; + line.lastRenderPosition = em.text.start - 1; + line.lineStart = em.text.start; + line.lineEnd = em.text.stop; + RenderLine( markdown_, line, textRegion, mdConfig_ ); + ImGui::SameLine( 0.0f, 0.0f ); + line.isEmphasis = false; + line.lastRenderPosition = i; + em = Emphasis(); + } + continue; + } + else + { + em.state = Emphasis::NONE; + // render text up to here + int start = em.text.start - line.emphasisCount; + if( start < line.lineStart ) + { + line.lineEnd = line.lineStart; + line.lineStart = start; + line.lastRenderPosition = start - 1; + RenderLine( markdown_, line, textRegion, mdConfig_ ); + line.lineStart = line.lineEnd; + line.lastRenderPosition = line.lineStart - 1; + } + } + break; + } + + // handle end of line (render) + if( c == '\n' ) + { + // first check if the line is a horizontal rule + line.lineEnd = i; + if( em.state == Emphasis::MIDDLE && line.emphasisCount >=3 && + ( line.lineStart + line.emphasisCount ) == i ) + { + ImGui::Separator(); + } + else + { + // render the line: multiline emphasis requires a complex implementation so not supporting + RenderLine( markdown_, line, textRegion, mdConfig_ ); + } + + // reset the line and emphasis state + prevLine = line; + line = Line(); + em = Emphasis(); + + line.lineStart = i + 1; + line.lastRenderPosition = i; + + textRegion.ResetIndent(); + + // reset the link + link = Link(); + + concurrentEmptyNewlines = 0; + appliedExtraNewline = false; + } + } + + if( em.state == Emphasis::LEFT && line.emphasisCount >= 3 ) + { + ImGui::Separator(); + } + else + { + // render any remaining text if last char wasn't 0 + if( markdownLength_ && line.lineStart < (int)markdownLength_ && markdown_[ line.lineStart ] != 0 ) + { + // handle both null terminated and non null terminated strings + line.lineEnd = (int)markdownLength_; + if( 0 == markdown_[ line.lineEnd - 1 ] ) + { + --line.lineEnd; + } + RenderLine( markdown_, line, textRegion, mdConfig_ ); + } + } + + if( NULL != linkHoverStart || linkHoverID == s_linkHoverID ) + { + s_linkHoverStart = linkHoverStart; + s_linkHoverID = linkHoverID; + } + } + + inline bool TextRegion::RenderLinkText( const char* text_, const char* text_end_, const Link& link_, + const char* markdown_, const MarkdownConfig& mdConfig_, const char** linkHoverStart_ ) + { + MarkdownFormatInfo formatInfo; + formatInfo.config = &mdConfig_; + formatInfo.type = MarkdownFormatType::LINK; + mdConfig_.formatCallback( formatInfo, true ); + ImGui::PushTextWrapPos( -1.0f ); + ImGui::TextUnformatted( text_, text_end_ ); + ImGui::PopTextWrapPos(); + + bool bThisItemHovered = ImGui::IsItemHovered(); + if(bThisItemHovered) + { + *linkHoverStart_ = markdown_ + link_.text.start; + } + bool bHovered = bThisItemHovered || ( *linkHoverStart_ == ( markdown_ + link_.text.start ) ); + + formatInfo.itemHovered = bHovered; + mdConfig_.formatCallback( formatInfo, false ); + + if(bHovered) + { + if( ImGui::IsMouseReleased( 0 ) && mdConfig_.linkCallback ) + { + mdConfig_.linkCallback( { markdown_ + link_.text.start, link_.text.size(), markdown_ + link_.url.start, link_.url.size(), mdConfig_.userData, false } ); + } + if( mdConfig_.tooltipCallback ) + { + mdConfig_.tooltipCallback( { { markdown_ + link_.text.start, link_.text.size(), markdown_ + link_.url.start, link_.url.size(), mdConfig_.userData, false }, mdConfig_.linkIcon } ); + } + } + return bThisItemHovered; + } + + // IsCharInsideWord based on ImGui's CalcWordWrapPositionA + inline bool IsCharInsideWord( char c_ ) + { + return c_ != ' ' && c_ != '.' && c_ != ',' && c_ != ';' && c_ != '!' && c_ != '?' && c_ != '\"'; + } + + // ImGui::TextWrapped will wrap at the starting position + // so to work around this we render using our own wrapping for the first line + inline void TextRegion::RenderTextWrapped( const char* text_, const char* text_end_, bool bIndentToHere_ ) + { + #if IMGUI_VERSION_NUM >= 19197 + float fontSize = ImGui::GetFontSize(); + #else + float scale = ImGui::GetIO().FontGlobalScale; + #endif + float widthLeft = GetContentRegionAvail().x; + const char* endLine = text_; + if( widthLeft > 0.0f ) + { + #if IMGUI_VERSION_NUM >= 19197 + endLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthLeft ); + #else + endLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthLeft ); + #endif + } + + if( endLine > text_ && endLine < text_end_ ) + { + if( IsCharInsideWord( *endLine ) ) + { + // see if we can do a better cut. + float widthNextLine = widthLeft + GetCursorScreenPos().x - GetWindowPos().x; // was GetContentRegionMax().x on IMGUI_VERSION_NUM < 19099 + #if IMGUI_VERSION_NUM >= 19197 + const char* endNextLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthNextLine ); + #else + const char* endNextLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthNextLine ); + #endif + if( endNextLine == text_end_ || ( endNextLine <= text_end_ && !IsCharInsideWord( *endNextLine ) ) ) + { + // can possibly do better if go to next line + endLine = text_; + } + } + } + ImGui::TextUnformatted( text_, endLine ); + if( bIndentToHere_ ) + { + float indentNeeded = GetContentRegionAvail().x - widthLeft; + if( indentNeeded ) + { + ImGui::Indent( indentNeeded ); + indentX += indentNeeded; + } + } + widthLeft = GetContentRegionAvail().x; + while( endLine < text_end_ ) + { + text_ = endLine; + if( *text_ == ' ' ) { ++text_; } // skip a space at start of line + #if IMGUI_VERSION_NUM >= 19197 + endLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthLeft ); + #else + endLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthLeft ); + #endif + if( text_ == endLine ) + { + endLine++; + } + ImGui::TextUnformatted( text_, endLine ); + } + } + + inline void TextRegion::RenderLinkTextWrapped( const char* text_, const char* text_end_, const Link& link_, + const char* markdown_, const MarkdownConfig& mdConfig_, const char** linkHoverStart_, bool bIndentToHere_ ) + { + #if IMGUI_VERSION_NUM >= 19197 + float fontSize = ImGui::GetFontSize(); + #else + float scale = ImGui::GetIO().FontGlobalScale; + #endif + float widthLeft = GetContentRegionAvail().x; + const char* endLine = text_; + if( widthLeft > 0.0f ) + { + #if IMGUI_VERSION_NUM >= 19197 + endLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthLeft ); + #else + endLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthLeft ); + #endif + } + + if( endLine > text_ && endLine < text_end_ ) + { + if( IsCharInsideWord( *endLine ) ) + { + // see if we can do a better cut. + float widthNextLine = widthLeft + GetCursorScreenPos().x - GetWindowPos().x; // was GetContentRegionMax().x on IMGUI_VERSION_NUM < 19099 + #if IMGUI_VERSION_NUM >= 19197 + const char* endNextLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthNextLine ); + #else + const char* endNextLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthNextLine ); + #endif + if( endNextLine == text_end_ || ( endNextLine <= text_end_ && !IsCharInsideWord( *endNextLine ) ) ) + { + // can possibly do better if go to next line + endLine = text_; + } + } + } + bool bHovered = RenderLinkText( text_, endLine, link_, markdown_, mdConfig_, linkHoverStart_ ); + if( bIndentToHere_ ) + { + float indentNeeded = GetContentRegionAvail().x - widthLeft; + if( indentNeeded ) + { + ImGui::Indent( indentNeeded ); + indentX += indentNeeded; + } + } + widthLeft = GetContentRegionAvail().x; + while( endLine < text_end_ ) + { + text_ = endLine; + if( *text_ == ' ' ) { ++text_; } // skip a space at start of line + #if IMGUI_VERSION_NUM >= 19197 + endLine = ImGui::GetFont()->CalcWordWrapPosition( fontSize, text_, text_end_, widthLeft ); + #else + endLine = ImGui::GetFont()->CalcWordWrapPositionA( scale, text_, text_end_, widthLeft ); + #endif + if( text_ == endLine ) + { + endLine++; + } + bool bThisLineHovered = RenderLinkText( text_, endLine, link_, markdown_, mdConfig_, linkHoverStart_ ); + bHovered = bHovered || bThisLineHovered; + } + if( !bHovered && *linkHoverStart_ == markdown_ + link_.text.start ) + { + *linkHoverStart_ = NULL; + } + } + + + inline void defaultMarkdownFormatCallback( const MarkdownFormatInfo& markdownFormatInfo_, bool start_ ) + { + switch( markdownFormatInfo_.type ) + { + case MarkdownFormatType::NORMAL_TEXT: + break; + case MarkdownFormatType::EMPHASIS: + { + MarkdownHeadingFormat fmt; + // default styling for emphasis uses last headingFormats - for your own styling + // implement EMPHASIS in your formatCallback + if( markdownFormatInfo_.level == 1 ) + { + // normal emphasis + if( start_ ) + { + ImGui::PushStyleColor( ImGuiCol_Text, ImGui::GetStyle().Colors[ ImGuiCol_TextDisabled ] ); + } + else + { + ImGui::PopStyleColor(); + } + } + else + { + // strong emphasis + fmt = markdownFormatInfo_.config->headingFormats[ MarkdownConfig::NUMHEADINGS - 1 ]; + if( start_ ) + { + if( fmt.font ) + { + #ifdef IMGUI_HAS_TEXTURES // used to detect dynamic font capability: + ImGui::PushFont( fmt.font, 0.0f ); // Change font and keep current size + #else + ImGui::PushFont( fmt.font ); + #endif + } + } + else + { + if( fmt.font ) + { + ImGui::PopFont(); + } + } + } + break; + } + case MarkdownFormatType::HEADING: + { + MarkdownHeadingFormat fmt; + if( markdownFormatInfo_.level > MarkdownConfig::NUMHEADINGS ) + { + fmt = markdownFormatInfo_.config->headingFormats[ MarkdownConfig::NUMHEADINGS - 1 ]; + } + else + { + fmt = markdownFormatInfo_.config->headingFormats[ markdownFormatInfo_.level - 1 ]; + } + if (start_) + { + if ( 0 == ( markdownFormatInfo_.config->formatFlags & ImGuiMarkdownFormatFlags_NoNewLineBeforeHeading ) ) + { + ImGui::NewLine(); + } + if (fmt.font) + { +#ifdef IMGUI_HAS_TEXTURES // used to detect dynamic font capability: https://github.com/ocornut/imgui/issues/8465#issuecomment-2701570771 + ImGui::PushFont(fmt.font, fmt.fontSize > 0.0f ? fmt.fontSize : fmt.font->LegacySize); +#else + ImGui::PushFont(fmt.font); +#endif + } + } + else + { + if (fmt.separator) + { + // In markdown the separator does not advance the cursor + ImVec2 cursor = ImGui::GetCursorPos(); + ImGui::Separator(); + if ( (markdownFormatInfo_.config->formatFlags & ImGuiMarkdownFormatFlags_SeparatorDoesNotAdvance) ) { + ImGui::SetCursorPos(cursor); + } + } + if (fmt.font) + { + ImGui::PopFont(); + } + ImGui::NewLine(); + } + break; + } + case MarkdownFormatType::UNORDERED_LIST: + break; + case MarkdownFormatType::LINK: + if( start_ ) + { + ImGui::PushStyleColor( ImGuiCol_Text, ImGui::GetStyle().Colors[ ImGuiCol_ButtonHovered ] ); + } + else + { + ImGui::PopStyleColor(); + if( markdownFormatInfo_.itemHovered ) + { + ImGui::UnderLine( ImGui::GetStyle().Colors[ ImGuiCol_ButtonHovered ] ); + } + else + { + ImGui::UnderLine( ImGui::GetStyle().Colors[ ImGuiCol_Button ] ); + } + } + break; + } + } +} diff --git a/usage-occt-view.md b/usage-occt-view.md new file mode 100644 index 0000000..305aef2 --- /dev/null +++ b/usage-occt-view.md @@ -0,0 +1,25 @@ +# 3D viewer (Open CASCADE) + +EzyCad's 3D viewport uses **Open CASCADE Technology (OCCT)** for displaying solids, sketches, the camera, lighting, and picking. For **mouse and keyboard** behavior, start with **[usage.md](usage.md)** ([View Controls](usage.md#view-controls)). + +## Navigation + +- **Orbit, pan, zoom** — See [Mouse Controls](usage.md#mouse-controls) in the usage guide. +- **Num Lock** — **Num Lock off** is recommended for all **NumPad** view shortcuts (orbit, roll, zoom, axis snap). With **Num Lock on**, the OS may remap the keypad so those keys no longer match the docs; use main-row alternatives listed in [View navigation](usage.md#view-navigation). +- **View orbit / roll** — **NumPad 8**/**2**/**4**/**6** orbit (same axes as LMB drag); **Shift**+**NumPad 4**/**6**, **Shift**+main **4**/**6**, or **Shift**+**Left**/**Right** roll around the screen axis (repeat while held). Step size is **Settings → 3D view navigation** (**View rotation step**). See [View navigation](usage.md#view-navigation). +- **Zoom** — mouse wheel, **right-drag**, **NumPad +/-**, **Shift+=**, and main **-** share one path. Strength is **Zoom scroll scale** in Settings (`gui.view_zoom_scroll_scale`, default **4**). Hold **Shift** while zooming for a Blender-style finer step (**x0.1**). See [View Controls](usage.md#view-controls). +- **Standard views** — **NumPad 5** snaps to the nearest main orthographic direction (top, bottom, front, back, left, right) and resets tilt relative to the model axes. See [View navigation](usage.md#view-navigation). + +## On-screen aids + +The **coordinate axes** (small triedron) and optional **view cube** are part of the OCCT-based viewer and help you stay oriented. + +## Settings file + +Background gradient, grid lines, and related **3D appearance** are stored under **`occt_view`** in your settings JSON. **View rotation step** and **zoom scroll scale** are stored under **`gui`** (`view_roll_step_deg`, `view_zoom_scroll_scale`) because they are edited in **Settings → 3D view navigation**. Full lists of keys: **[usage-settings.md — Settings file reference](usage-settings.md#settings-file-reference)**. + +If you use scripts, **`ezy.occt_view_settings_json()`** returns a small JSON snapshot of some of these values — see **[scripting.md](scripting.md)**. + +--- + +[Back to usage guide](usage.md) | [Settings](usage-settings.md) diff --git a/usage-settings.md b/usage-settings.md index b08ef72..00e16ee 100644 --- a/usage-settings.md +++ b/usage-settings.md @@ -38,17 +38,19 @@ Open **View -> Settings**. The window title is **Settings**. - **Dark mode** — checkbox at the top (not inside a collapsible section). - At the bottom: **Defaults** — reloads bundled defaults from the app `res/` tree (including ImGui layout from that file). -Between those, the pane has **five** collapsible sections. Expand a section to see its controls; when collapsed, only the section title bar is visible. +Between those, the pane has **six** collapsible sections. Expand a section to see its controls; when collapsed, only the section title bar is visible. -1. **UI corner rounding** — Sliders **0** to **16** for **Windows, frames, popups**; **Scrollbars and sliders** (has `(?)`); **Tabs**. +1. **3D view navigation** — **View rotation step** (degrees per key press for **NumPad 8**/**2**/**4**/**6** orbit and **Shift+NumPad 4**/**6** roll; default **45**). **Zoom scroll scale** (multiplier for wheel and **+**/**-** zoom; default **4**). Hold **Shift** while zooming for a Blender-style finer step (multiply by **0.1**). Numpad shortcuts are documented with **Num Lock off**; with **Num Lock on**, use main-row alternatives in [usage.md -> View navigation](usage.md#view-navigation). Stored as **`gui.view_roll_step_deg`** and **`gui.view_zoom_scroll_scale`**. See **[usage-occt-view.md](usage-occt-view.md)**. -2. **3D view background** — **Background color 1** and **Background color 2** (float RGB fields and swatches). **Gradient blend** — combo: **Horizontal**, **Vertical**, **Diagonal 1**, **Diagonal 2**, **Corner 1** … **Corner 4**. +2. **UI corner rounding** — Sliders **0** to **16** for **Windows, frames, popups**; **Scrollbars and sliders** (has `(?)`); **Tabs**. -3. **3D view grid** — **Fine grid lines** and **Major grid lines** (passed to Open CASCADE `Aspect_Grid::SetColors`: dense lines vs every-tenth emphasis lines). **Grid plane fill** — tint for a large XY plane slightly below z=0 so the ground area can differ from the sky gradient (see `(?)` in app). This third color is not part of OCCT grid line rendering. +3. **3D view background** — **Background color 1** and **Background color 2** (float RGB fields and swatches). **Gradient blend** — combo: **Horizontal**, **Vertical**, **Diagonal 1**, **Diagonal 2**, **Corner 1** … **Corner 4**. -4. **Sketch** — **Dimension line width** — slider **0.5** to **8.0** (has `(?)`). **Underlay highlight color** — RGB (has `(?)`). +4. **3D view grid** — **Fine grid lines** and **Major grid lines** (passed to Open CASCADE `Aspect_Grid::SetColors`: dense lines vs every-tenth emphasis lines). **Grid plane fill** — tint for a large XY plane slightly below z=0 so the ground area can differ from the sky gradient (see `(?)` in app). This third color is not part of OCCT grid line rendering. -5. **Startup project** — **Desktop only:** **Load last opened on startup** (checkbox, with `(?)`), then **Last opened path:** … or **(No path saved yet.)** Then **Save current as startup project**, **Clear saved startup** (with `(?)`). **WebAssembly:** no load-last row; only the two buttons and `(?)`. See [Startup project](#startup-project). +5. **Sketch** — **Dimension line width** — slider **0.5** to **8.0** (has `(?)`). **Underlay highlight color** — RGB (has `(?)`). + +6. **Startup project** — **Desktop only:** **Load last opened on startup** (checkbox, with `(?)`), then **Last opened path:** … or **(No path saved yet.)** Then **Save current as startup project**, **Clear saved startup** (with `(?)`). **WebAssembly:** no load-last row; only the two buttons and `(?)`. See [Startup project](#startup-project). **Not in this pane** @@ -130,11 +132,13 @@ String: ImGui `.ini` text for window positions and docking saved with **SaveIniS | `imgui_rounding_scroll` | number | Scrollbar and grab rounding (same clamp). | | `imgui_rounding_tabs` | number | Tab rounding (same clamp). | | `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel). | +| `view_roll_step_deg` | number | Degrees per **NumPad 8**/**2**/**4**/**6** orbit and **Shift+NumPad 4**/**6** roll (allowed range **0.1** to **180** in code; default **45**). | +| `view_zoom_scroll_scale` | number | Multiplier for `UpdateZoom` scroll delta from wheel and keyboard zoom (allowed range **0.25** to **64** in code; default **4**). With **Shift** held, the effective step is multiplied by **0.1** (Blender-style finer zoom). | | `load_last_opened_on_startup` | boolean | Desktop: open the last `.ezy` on launch. **Legacy:** `load_last_saved_on_startup` is read as a fallback if the newer key is absent. | | `last_opened_project_path` | string | Path of the last opened project for the option above. **Legacy:** `last_saved_project_path` is accepted if the newer key is missing. | -Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus **`gui.edge_dim_label_h`** and **`gui.edge_dim_line_width`** (same keys as in this section). See [scripting.md](scripting.md). +Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including **`gui.edge_dim_label_h`**, **`gui.edge_dim_line_width`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`** when saved). See [scripting.md](scripting.md). --- -For general workflows and tools, see [usage.md](usage.md). For 2D sketching, see [usage-sketch.md](usage-sketch.md). +For general workflows and tools, see [usage.md](usage.md). For the 3D viewer (Open CASCADE), see **[usage-occt-view.md](usage-occt-view.md)**. For 2D sketching, see [usage-sketch.md](usage-sketch.md). diff --git a/usage.md b/usage.md index 13cf510..f9753d7 100644 --- a/usage.md +++ b/usage.md @@ -9,11 +9,12 @@ 6. [Modeling Tools](#modeling-tools) 7. [Keyboard Shortcuts](#keyboard-shortcuts) 8. [View Controls](#view-controls) -9. [Tips and Tricks](#tips-and-tricks) -10. [Scripting](#scripting-lua-and-python) -11. [Support](#support) -12. [Tool Icons](#tool-icons) -13. [Settings](usage-settings.md) +9. [3D viewer (Open CASCADE)](usage-occt-view.md) +10. [Tips and Tricks](#tips-and-tricks) +11. [Scripting](#scripting-lua-and-python) +12. [Support](#support) +13. [Tool Icons](#tool-icons) +14. [Settings](usage-settings.md) ## Introduction @@ -515,9 +516,28 @@ The polar duplicate tool allows you to create multiple copies of selected shapes | E | Extrude mode | | D | Delete selected | +### View navigation + +| | | +| ---: | --- | +| NumPad 8 | Orbit [up](#view-orbit-numpad) (same sense as dragging the view up). Step: **Settings -> 3D view navigation -> View rotation step** (default **45** degrees). | +| NumPad 2 | Orbit [down](#view-orbit-numpad). | +| NumPad 4 | Orbit [left](#view-orbit-numpad). | +| NumPad 6 | Orbit [right](#view-orbit-numpad). | +| Shift+NumPad 4, Shift+4, or Shift+Left | [Roll the 3D view](#view-roll) one way (same step setting as orbit). | +| Shift+NumPad 6, Shift+6, or Shift+Right | [Roll the 3D view](#view-roll) the other way. | +| NumPad 5 | Snap to the nearest world-axis view (top, bottom, front, back, left, or right): keeps the current eye-target distance, aligns the view direction to +/- **X** / **Y** / **Z**, and resets roll to a standard **Up** (same convention as the initial top view: **Up** is **+Y** when looking along **Z**, else **+Z** when looking along **X** or **Y**). | +| NumPad + / NumPad - | Zoom in / out at the cursor; step size uses **Settings -> 3D view navigation -> Zoom scroll scale** (default **4**, same role as the former fixed wheel multiplier). **Hold** the key for continuous zoom (system key repeat). | +| Shift+= (often labeled **+**) | Zoom in (same as **NumPad +** on US layouts); hold for repeat. With **Shift**, Blender-style **finer** zoom (**x0.1** on the scroll delta). | +| - (main keyboard) | Zoom out (same as **NumPad -**); hold for repeat. **Shift** gives finer zoom. | + +**Num Lock (numeric keypad):** **Num Lock off** is what we test against and recommend. The shortcuts below assume the keypad produces **NumPad** key codes (orbit, axis snap, zoom, roll, and keypad selection digits). With **Num Lock on**, Windows and other systems often remap the keypad (digits vs arrow/Home/End behavior), so numpad shortcuts may not match this document. Use the alternatives in the table (main-row 4 / 6, Shift+Left / Right, main + / -, main 1-9 for selection) or turn **Num Lock off**. + +Same idea as Blender **View Roll** for Shift+NumPad 4 / NumPad 6, Shift+4 / 6, or Shift+Left / Right. Plain NumPad 8 / NumPad 2 / NumPad 4 / NumPad 6 (no modifiers) **orbit** instead of setting the [selection filter](#shape-selection-filter-normal-mode-only); use the main keyboard **4** / **6** / **2** / **8** for Shell, Wire, CompSolid, or Vertex in **Normal** mode. **NumPad 5** is reserved for axis snap (not the Face filter); use main keyboard **5** for Face in **Normal** mode. + ### Shape selection filter (Normal mode only) -In **Normal** mode, number keys set the **Selection Mode** filter for picking 3D shapes (same control as **Options -> Selection Mode**). Main keyboard **1-9** and keypad **1-9** are supported. Order matches Open CASCADE `TopAbs_ShapeEnum` (see `utl_occt.h` / combo labels): +In **Normal** mode, number keys set the **Selection Mode** filter for picking 3D shapes (same control as **Options -> Selection Mode**). Main keyboard **1-9** and keypad **1-9** are supported, except **keypad 5** and **keypad 2**, **4**, **6**, **8** (see [View navigation](#view-navigation)). The key order matches the list in the **Selection Mode** control (from compound down to whole shape): | Key | Filter | | ---: | --- | @@ -547,7 +567,20 @@ Open or close the **Lua** or **Python** consoles from **View -> Lua Console** or | **Left drag** | Orbit view | | **Middle drag** | Pan view | | **Right drag** | Zoom | -| **Scroll Wheel** | Zoom in/out | +| **Scroll Wheel** | Zoom in/out (**Zoom scroll scale** in Settings; hold **Shift** for finer steps) | +| NumPad + / NumPad -, Shift+=, - | Zoom in/out ([keyboard](#view-navigation); settings scale; Shift finer) | + +### View orbit (NumPad) + +Press NumPad 8, NumPad 2, NumPad 4, or NumPad 6 (without Shift) to orbit the camera in steps, using the same axes as **left-drag orbit** (Open CASCADE `AIS_ViewController` convention: yaw about camera up, pitch about camera side). NumPad 8 / NumPad 2 pitch up or down; NumPad 4 / NumPad 6 yaw left or right. The default step is **45** degrees per key press. **Num Lock off** is recommended so the keypad sends these **NumPad** codes (see [View navigation](#view-navigation) above). + +### View roll + +Hold Shift and press NumPad 4 or NumPad 6, main 4 / 6, or Left / Right to rotate the view around the viewing axis (the axis pointing out of the screen), in fixed degree steps. **Hold** to repeat (same as zoom key repeat). The default step is **45** degrees per key press. If Shift+NumPad 4 or Shift+NumPad 6 misbehaves, use Shift+4, Shift+6, Shift+Left, or Shift+Right, or turn **Num Lock off** (recommended for all numpad view shortcuts). + +To change the step for both orbit and roll, open **View -> Settings**, expand **3D view navigation**, and adjust **View rotation step**. The value is saved in your settings file as **`gui.view_roll_step_deg`** (see [Settings file reference](usage-settings.md#settings-file-reference)). + +More context on the 3D viewer stack: **[3D viewer (Open CASCADE)](usage-occt-view.md)**. ### View Options @@ -585,6 +618,7 @@ Open or close the **Lua** or **Python** consoles from **View -> Lua Console** or ### Documentation - [This usage guide](#ezycad-usage-guide) - [Settings](usage-settings.md) (Settings pane, View menu, JSON settings file, startup project) +- [3D viewer (Open CASCADE)](usage-occt-view.md) - [2D Sketching](usage-sketch.md) (including [add node](usage-sketch.md#add-node-tool)) - [Scripting (Lua / Python)](scripting.md) - Hosted docs and video tutorials are not published yet; this repository's markdown guides are the reference for now.