diff --git a/.github/workflows/source-hygiene.yml b/.github/workflows/source-hygiene.yml index 9766e24f..3e8b62ee 100644 --- a/.github/workflows/source-hygiene.yml +++ b/.github/workflows/source-hygiene.yml @@ -10,9 +10,14 @@ on: jobs: ascii-src: runs-on: windows-latest + env: + PYTHONUTF8: "1" steps: - uses: actions/checkout@v4 - - name: Check src/ for non-ASCII (comments and strings) - shell: cmd - run: scripts\check-nonascii-src.cmd + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: ASCII in src/ and tests/ + run: python scripts/agent_check.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e3493ad..c741f5f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -766,7 +766,8 @@ if(RES_FILES) set_source_files_properties(${RES_FILES_REL} PROPERTIES HEADER_FILE_ONLY TRUE) endif() -# Utility scripts: show scripts/ in the IDE (not compiled; same pattern as res/) +# Utility scripts: show scripts/ in the IDE (not compiled; same pattern as res/). +# Includes agent_check.py (ASCII) and optional code_style_check.py via this glob. file(GLOB_RECURSE EZYCAD_SCRIPT_FILES CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/scripts/*") @@ -782,6 +783,26 @@ if(EZYCAD_SCRIPT_FILES) set_source_files_properties(${EZYCAD_SCRIPT_FILES_REL} PROPERTIES HEADER_FILE_ONLY TRUE) endif() +# Runnable IDE target: ASCII on src/ and tests/ (python scripts/agent_check.py). +# Style (code_style_check.py) is optional/local, not this target or CI. +if(NOT Python3_EXECUTABLE) + find_package(Python3 3.8 QUIET COMPONENTS Interpreter) +endif() +if(Python3_EXECUTABLE) + add_custom_target(ezycad_agent_check + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/scripts/agent_check.py" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "EzyCad agent_check: ASCII (src/ and tests/)" + SOURCES + "${CMAKE_SOURCE_DIR}/scripts/agent_check.py" + VERBATIM + ) + set_target_properties(ezycad_agent_check PROPERTIES + FOLDER "scripts" + EXCLUDE_FROM_ALL TRUE + ) +endif() + # GitHub workflow files: show .github/workflows/*.yml in the IDE under 'github-workflows' folder # (so they are visible in Visual Studio Solution Explorer etc. without being compiled) file(GLOB_RECURSE GITHUB_WORKFLOW_FILES CONFIGURE_DEPENDS diff --git a/agents.md b/agents.md index 27988d51..3ea081a7 100644 --- a/agents.md +++ b/agents.md @@ -12,6 +12,7 @@ Pointer for AI coding assistants. Details live in [agents/README.md](agents/READ - [agents/conventions/ascii-source.md](agents/conventions/ascii-source.md) for `src/` and `tests/` - [docs/ezycad_code_style.md](docs/ezycad_code_style.md) for C++ style +- After creating or editing C++ (`src/`, `tests/`) or Markdown tables: `python scripts/agent_check.py ` (ASCII; table alignment for `.md`). Optional local style: `python scripts/code_style_check.py`. Do not run the individual ASCII scripts unless that one failed. ## When needed @@ -23,8 +24,6 @@ Pointer for AI coding assistants. Details live in [agents/README.md](agents/READ - GUI module: [src/doc/gui.md](src/doc/gui.md) (read; update when input routing, modes, or settings change) - Script consoles: [src/doc/script.md](src/doc/script.md) (read; update when bindings or console UI change) - Utilities: [src/doc/utility.md](src/doc/utility.md) (read; update when utl_* contracts or I/O change) -- Script consoles: [src/doc/script.md](src/doc/script.md) (read; update when bindings or console UI change) -- Utilities: [src/doc/utility.md](src/doc/utility.md) (read; update when utl_* contracts or I/O change) - Build/test: [agents/workflows/local-dev.md](agents/workflows/local-dev.md) or root README - OCCT APIs / WASM (desktop 8 vs wasm 7.9.3): [agents/conventions/occt-wasm-dual-version.md](agents/conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 - OCCT handles (`Handle` vs `*_ptr`): [agents/conventions/occt-handles.md](agents/conventions/occt-handles.md) diff --git a/agents/README.md b/agents/README.md index bc243165..cea81aea 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,24 +6,24 @@ Root markers: [AGENTS.md](../AGENTS.md) / [agents.md](../agents.md). ## Quick index -| Need | File | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) | -| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) | -| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | -| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | -| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | +| Need | File | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) — after edits: `python scripts/agent_check.py ` | +| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) — optional local: `python scripts/code_style_check.py` (not CI) | +| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | +| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | +| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | | GUI module (dev doc) | [src/doc/gui.md](../src/doc/gui.md) — read when editing `gui_*` / viewer shell; update if routing or settings change; **new Mode/Command → hotkeys checklist** | -| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | -| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | -| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | -| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | -| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | -| Release | [workflows/release.md](workflows/release.md) | -| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | -| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | -| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | -| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | -| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | +| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | +| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | +| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | +| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | +| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | +| Release | [workflows/release.md](workflows/release.md) | +| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | +| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | +| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | +| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | +| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | Full user-doc style: [docs/ezycad_doc_style.md](../docs/ezycad_doc_style.md). OCCT build: [docs/building-occt.md](../docs/building-occt.md). diff --git a/agents/conventions/ascii-source.md b/agents/conventions/ascii-source.md index 10d69da3..b601bf92 100644 --- a/agents/conventions/ascii-source.md +++ b/agents/conventions/ascii-source.md @@ -4,4 +4,4 @@ Use this as a **Cursor rule** or paste into your assistant context when editing In `src/` and `tests/`, keep **comments and string literals 7-bit ASCII** (no Unicode punctuation or symbols: smart quotes, en/em dashes, arrows, ellipsis, etc.). Use ASCII equivalents (`-`, `...`, `->`, `sqrt(2)`, plain `'`). -Project style: [docs/ezycad_code_style.md](../../docs/ezycad_code_style.md) (sections **Formatting** / line endings and **Source encoding**). Verify with `scripts/check-nonascii-src.ps1` or `scripts/check-nonascii-src.cmd`. +Project style: [docs/ezycad_code_style.md](../../docs/ezycad_code_style.md) (sections **Formatting** / line endings and **Source encoding**). After editing or creating `src/` or `tests/` C++, run `python scripts/agent_check.py ` (ASCII). CI: `.github/workflows/source-hygiene.yml`. Standalone ASCII: `scripts/check-nonascii-src.ps1` or `check-nonascii-src.cmd`. Optional style (not CI): `python scripts/code_style_check.py`. diff --git a/agents/conventions/markdown-tables.md b/agents/conventions/markdown-tables.md index 9a1a3b16..80854e2b 100644 --- a/agents/conventions/markdown-tables.md +++ b/agents/conventions/markdown-tables.md @@ -45,10 +45,13 @@ Padding/alignment can make rows slightly longer; that is intentional for source ## Re-align helper ```bash +python scripts/agent_check.py docs/usage.md python scripts/align_md_tables.py python scripts/align_md_tables.py --check ``` +After editing tables, agents should run `python scripts/agent_check.py ` (check only). Use `align_md_tables.py` without `--check` to write aligned files. + Skips `third_party/`, local `build*` trees, `_deps`, and similar vendor/output dirs. ## Related diff --git a/agents/conventions/token-lean.md b/agents/conventions/token-lean.md index b24ebd52..f157a2e7 100644 --- a/agents/conventions/token-lean.md +++ b/agents/conventions/token-lean.md @@ -7,6 +7,7 @@ Goal: give assistants **only what they need** for the task at hand. Full style g 1. Root [AGENTS.md](../../AGENTS.md) — pointers only (~20 lines). 2. [ascii-source.md](ascii-source.md) — when touching `src/` or `tests/`. 3. [docs/ezycad_code_style.md](../../docs/ezycad_code_style.md) — when writing C++ (do not duplicate in chat). +4. After creating or editing C++ or Markdown tables: `python scripts/agent_check.py ` (ASCII; MD tables if `.md`). Do not run `check-nonascii-src` / `align_md_tables --check` separately. `code_style_check.py` is optional, not CI. **Do not** auto-load: `workflows/release.md`, `outreach/`, `drafts/archive/`, `plans/` (except the one plan matching the prompt), or full `local-dev.md` unless building/releasing. @@ -24,7 +25,7 @@ Goal: give assistants **only what they need** for the task at hand. Full style g | Script consoles (`src/scr*`, bindings) | [src/doc/script.md](../../src/doc/script.md) — read before editing; update when `ezy`/`view` API or console UI changes | | Utilities (`src/utl*`, results, I/O, geometry) | [src/doc/utility.md](../../src/doc/utility.md) — read before editing; update when shared helper contracts change | | Docs build | [workflows/docs-build.md](../workflows/docs-build.md) | -| Editing Markdown tables | [markdown-tables.md](markdown-tables.md) — align GFM pipes; `python scripts/align_md_tables.py` | +| Editing Markdown tables | [markdown-tables.md](markdown-tables.md) — align GFM pipes; after edits: `python scripts/agent_check.py ` | | Release | [workflows/release.md](../workflows/release.md) | | Specific issue/PR | One file under `drafts/issues/active/` or `drafts/prs/active/` | | Feature plan under `plans/` | **Only** the matching file when the prompt is clearly about that feature (see [plans/README.md](../plans/README.md)); never bulk-load `plans/` | diff --git a/agents/workflows/local-dev.md b/agents/workflows/local-dev.md index af839817..2f78d651 100644 --- a/agents/workflows/local-dev.md +++ b/agents/workflows/local-dev.md @@ -88,27 +88,30 @@ See `scripts/build-occt-793-wasm.ps1`, `scripts/build-occt-v8-wasm.ps1`, and sha ## Code quality and pre-commit checks -- **Format C++** (run before committing changes under `src/`): +- **After editing or creating code** (agents: one command, pass the files you touched): ```powershell - .\scripts\format-src.ps1 + python scripts/agent_check.py src/gui.cpp src/gui.h ``` - Requires `clang-format` (either in PATH or at the default LLVM location). + Runs ASCII on C++. With `.md` paths, also checks table alignment. Default (no args): `src/` and `tests/`. IDE: `ezycad_agent_check` CMake target. Optional local [code style](../../docs/ezycad_code_style.md) (not CI): `python scripts/code_style_check.py`. -- **Check ASCII-only in src/ and tests/** (EzyCad_tests sources; must pass before commits; also enforced in CI): +- **Format C++** (run before committing changes under `src/`): ```powershell - .\scripts\check-nonascii-src.ps1 - # .cmd wrapper also available + .\scripts\format-src.ps1 ``` - See the ASCII rule in [docs/ezycad_code_style.md](../../docs/ezycad_code_style.md) and the summary in [agents/conventions/ascii-source.md](../conventions/ascii-source.md). + Requires `clang-format` (either in PATH or at the default LLVM location). + +- **ASCII-only** is also in `scripts/check-nonascii-src.ps1` / `.cmd`. CI (`.github/workflows/source-hygiene.yml`) runs `python scripts/agent_check.py`. Prefer that after edits so you do not launch a second checker. ## Other scripts - `scripts/ezycad/` — Importable remote client (put `scripts/` on `PYTHONPATH`, then `import ezycad`). Typed `ezy` / `view` / `sketch` API for IPython completion; see [docs/scripting.md](../../docs/scripting.md#remote-python---listen). - `scripts/ezycad_remote.py` — CLI wrapper (`python scripts/ezycad_remote.py`, or `python -m ezycad` with `scripts/` on the path). Smoke: `EzyCad --listen 127.0.0.1:8765`, then `python -c "import sys; sys.path.insert(0,'scripts'); import ezycad; print(ezycad.connect().view.sketch_count())"`. +- `scripts/agent_check.py` — Post-edit ASCII check (Markdown tables if `.md` paths). Agents run this instead of `check-nonascii-src`. Optional `--style` runs `code_style_check.py`. +- `scripts/code_style_check.py` — Optional local style from [docs/ezycad_code_style.md](../../docs/ezycad_code_style.md) (not CI). - `scripts/align_md_tables.py` — Align GFM pipe tables in `.md` files for source + preview readability (see [conventions/markdown-tables.md](../conventions/markdown-tables.md)). - `scripts/sync-github-pages-html.ps1` — Sync `web/` changes (EzyCad.html etc.) to the GitHub Pages wasm demo site. - `scripts/pbf-to-png.ps1` / `.py` — Icon / asset conversion helpers. diff --git a/agents/workflows/release.md b/agents/workflows/release.md index 969b10e2..9a2fa33a 100644 --- a/agents/workflows/release.md +++ b/agents/workflows/release.md @@ -20,7 +20,7 @@ See also the comment in `src/version.h`, the project declaration in `CHANGELOG.m - Help > About should start with a bold **EzyCad X.Y.Z** header (pulled from `EZYCAD_VERSION_STRING`). 5. Run pre-commit checks: - `.\scripts\format-src.ps1` - - `.\scripts\check-nonascii-src.ps1` + - `python scripts/agent_check.py` (ASCII; covers `check-nonascii-src.ps1`) 6. Commit the release prep changes (version bump + changelog + any doc tweaks). 7. **Create and push only the annotated tag** (this is the trigger): ```powershell diff --git a/docs/ezycad_code_style.md b/docs/ezycad_code_style.md index 7d9b7c4d..470abf0b 100644 --- a/docs/ezycad_code_style.md +++ b/docs/ezycad_code_style.md @@ -43,7 +43,7 @@ Run **`scripts/format-src.ps1`** (or `clang-format -i` on individual files) befo - **`AccessModifierOffset: -1`** — Access specifiers are outdented one space: `` ` public:` ``, `` ` private:` ``, `` ` protected:` `` (one leading space). - **`PointerAlignment: Left`** — Attach `*` / `&` to the type: `int* p`, `const Shp& shp`. - **`AlignConsecutiveDeclarations: true`** — Align types and names across consecutive declarations in the same block when it helps readability. -- **`AlignConsecutiveAssignments: true`** — Align `=` across consecutive assignments in the same block when it helps readability. +- **`AlignConsecutiveAssignments: true`** — Align `=` across consecutive assignments in the same block when it helps readability. Initialize with `=` (`bool ok = false;`) so names and values can line up; brace-init (`bool ok {false};`) does not participate in that alignment. - **`AllowShortIfStatementsOnASingleLine: false`** — Do not put an entire `if` (condition + body) on one line; condition and statement stay on separate lines. You may still omit braces for a single-statement body. - **`AllowShortLambdasOnASingleLine: Inline`** — Very short **inline** lambdas may stay on one line; longer lambdas break across lines. - **`IndentCaseLabels: false`** — `case` / `default` labels align with the surrounding `switch`, not extra-indented under it. @@ -105,6 +105,8 @@ Not enforced by clang-format. Treat each **logical beat** as its own short parag Do not sprinkle blank lines inside a tight expression or a one-line `if` body; the goal is readable beats, not sparse files. +Optional local check (not CI): `python scripts/code_style_check.py`. After C++ edits, run `python scripts/agent_check.py ` for ASCII only. + ### Control flow polarity When both branches are short and one is the normal success path, prefer **happy-path first**: @@ -122,7 +124,7 @@ rather than leading with the failure `if` and putting the main work in `else`, u ### Other conventions (not enforced by clang-format) -- Brace-initialize members (`bool ok {false};`). +- Initialize members and locals with `=` (`bool ok = false;`), not brace-init (`bool ok {false};`), so clang-format can align consecutive declarations and assignments. - Declare locals close to first use. - Omit braces on single-statement `if`/`for`/`while` bodies when clear. @@ -205,7 +207,7 @@ Prefer **`CHK_RET(expr)`** when a callee returns `Status` or `Result` and the - Ranges: `0-255`, `1-9` (hyphen), not en dash. - Punctuation in prose: `-` for dash; `...` for ellipsis; `->` for “maps to” / arrows in comments; plain `'` for apostrophes. - Math in comments: spell out (`sqrt(2)`, `theta`) or use ASCII operators (`x` for cross-product context, `*` for multiply). -- Run `scripts/check-nonascii-src.ps1` (or `check-nonascii-src.cmd`) before committing when touching `src/`. +- After editing `src/` or `tests/`, run `python scripts/agent_check.py ` (ASCII). CI: `.github/workflows/source-hygiene.yml`. Standalone ASCII: `scripts/check-nonascii-src.ps1` (or `check-nonascii-src.cmd`). - For AI tools: the same rule is summarized in `agents/conventions/ascii-source.md`. ## C++ usage diff --git a/scripts/agent_check.py b/scripts/agent_check.py new file mode 100644 index 00000000..6e750c5d --- /dev/null +++ b/scripts/agent_check.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""One post-edit quality check for agents and the IDE. + +Runs in a single Python process (no PowerShell): + - 7-bit ASCII in C/C++ under src/ and tests/ (or given paths) + - Markdown table alignment --check when .md paths are given + - Optional: code_style_check.py (vertical rhythm) with --style; not CI + +Usage (from repo root): + python scripts/agent_check.py + python scripts/agent_check.py src/gui.cpp src/gui.h + python scripts/agent_check.py docs/usage.md + python scripts/code_style_check.py src/gui.cpp + +Default paths: src/ and tests/. Pass the files you just edited so the report +stays small. Exit 1 if any check fails. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Same directory as this file (scripts/). +_SCRIPTS = Path(__file__).resolve().parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +import align_md_tables # noqa: E402 +import code_style_check # noqa: E402 + + +def repo_root() -> Path: + return _SCRIPTS.parent + + +def rel_path(path: Path, root: Path) -> str: + try: + return path.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def check_ascii(files: list[Path], root: Path) -> int: + found = 0 + for path in files: + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + text = path.read_text(encoding="utf-8", errors="replace") + for line_num, line in enumerate(text.splitlines(), start=1): + for col, ch in enumerate(line, start=1): + cp = ord(ch) + if cp > 0x7F: + print(f"{rel_path(path, root)}:{line_num}:{col}: non-ASCII U+{cp:04X} ({ch!r})") + found += 1 + if found: + print(f"Total non-ASCII character occurrences: {found}") + return 1 + print(f"ASCII: ok ({len(files)} file(s)).") + return 0 + + +def check_style(files: list[Path], root: Path) -> int: + findings = code_style_check.collect_findings(files) + for f in findings: + print(f.format(root)) + if findings: + print(f"{len(findings)} code-style finding(s) in {len({f.path for f in findings})} file(s).") + return 1 + print(f"Code style: ok ({len(files)} file(s)).") + return 0 + + +def check_md_tables(md_paths: list[Path], root: Path) -> int: + files = align_md_tables.iter_md_files(md_paths) + if not files: + return 0 + bad: list[Path] = [] + for path in files: + original = path.read_text(encoding="utf-8") + updated = align_md_tables.process_text(original) + if original.endswith("\n") and not updated.endswith("\n"): + updated += "\n" + if updated != original: + bad.append(path) + if bad: + print("Markdown tables need alignment (python scripts/align_md_tables.py):") + for path in bad: + print(f" {rel_path(path, root)}") + return 1 + print(f"Markdown tables: ok ({len(files)} file(s)).") + return 0 + + +def main() -> int: + root = repo_root() + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "paths", + nargs="*", + type=Path, + default=[root / "src", root / "tests"], + help="Files or directories you edited (default: src/ and tests/)", + ) + ap.add_argument( + "--style", + action="store_true", + help="Also run code_style_check.py (optional; not used by CI)", + ) + args = ap.parse_args() + paths = [p if p.is_absolute() else (Path.cwd() / p) for p in args.paths] + + cpp_files = code_style_check.iter_cpp_files(paths) + cpp_roots = {(root / "src").resolve(), (root / "tests").resolve()} + md_paths: list[Path] = [] + for p in paths: + if p.is_file() and p.suffix.lower() == ".md": + md_paths.append(p) + elif p.is_dir() and p.resolve() not in cpp_roots: + md_paths.append(p) + + if not cpp_files and not md_paths: + print("No C/C++ or Markdown files to check.", file=sys.stderr) + return 2 + + rc = 0 + if cpp_files: + rc |= check_ascii(cpp_files, root) + if args.style: + rc |= check_style(cpp_files, root) + if md_paths: + rc |= check_md_tables(md_paths, root) + return 1 if rc else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/code_style_check.py b/scripts/code_style_check.py new file mode 100644 index 00000000..490f9bb1 --- /dev/null +++ b/scripts/code_style_check.py @@ -0,0 +1,867 @@ +#!/usr/bin/env python3 +"""Optional local check of C++ against docs/ezycad_code_style.md. + +Not part of CI or the default agent_check.py run. Start with Vertical rhythm +(blank lines). Do not add groups here to CI unless a rule is as objective as ASCII. + +Usage: + python scripts/code_style_check.py [paths...] + python scripts/code_style_check.py --rule vertical-rhythm src/gui.cpp + python scripts/agent_check.py --style src/gui.cpp + +Default paths: /src +Exit 1 if any finding is reported. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + +CPP_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inl"} + +SKIP_DIR_NAMES = { + ".git", + "third_party", + "node_modules", + "_build", + "_deps", + "build", + "out", + ".venv", + "venv", +} +SKIP_DIR_PREFIXES = ("build-", "cmake-build") + +IDENT_START = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_") +IDENT_CONT = IDENT_START | set("0123456789") + +TYPE_DEF_STARTS = frozenset({"class", "struct", "enum", "union", "namespace"}) +CONTROL_STARTS = frozenset({"if", "for", "while", "switch", "do", "try"}) +ACCESS_LABELS = frozenset({"public", "private", "protected", "default"}) + +SAME_ROW_UI_PREFIXES = ("ImGui::SameLine", "GUI_DOC_HELP_") +IMGUI_TEXT_BEATS = ("ImGui::TextWrapped", "ImGui::TextDisabled") +IMGUI_SPACING = "ImGui::Spacing" +IMGUI_BUTTON = "ImGui::Button" + + +@dataclass(frozen=True) +class Finding: + path: Path + line: int + rule: str + message: str + + def format(self, repo: Path) -> str: + try: + rel = self.path.resolve().relative_to(repo.resolve()) + except ValueError: + rel = self.path + return f"{rel.as_posix()}:{self.line}: [{self.rule}] {self.message}" + + +@dataclass +class Stmt: + kind: str + start: int + end: int + has_else: bool = False + bodies: tuple[tuple[int, int], ...] = () + + +class Source: + def __init__(self, path: Path, text: str): + self.path = path + self.text = text + self.masked = mask_comments_and_strings(text) + self.lines = text.splitlines() + self._line_at = build_line_index(text) + self.off_ranges = clang_format_off_ranges(self.lines) + + def line_of(self, index: int) -> int: + if index < 0: + return 1 + if index >= len(self._line_at): + return self._line_at[-1] if self._line_at else 1 + return self._line_at[index] + + def in_clang_format_off(self, index: int) -> bool: + line = self.line_of(index) + return any(a <= line <= b for a, b in self.off_ranges) + + def slice(self, start: int, end: int) -> str: + return self.masked[start:end] + + def leading_code(self, start: int, end: int) -> str: + i = skip_ws(self.masked, start, end) + j = i + n = min(end, len(self.masked)) + while j < n and self.masked[j] not in " \t\n": + j += 1 + # Include qualified calls: ImGui::SameLine( + while j < n and self.masked[j] in ":": + j += 1 + while j < n and self.masked[j] not in " \t\n(": + j += 1 + return self.masked[i:j] + + def has_blank_line_between(self, a_end: int, b_start: int) -> bool: + first = self.line_of(max(0, a_end - 1)) + 1 + last = self.line_of(b_start) - 1 + for ln in range(first, last + 1): + if 1 <= ln <= len(self.lines) and not self.lines[ln - 1].strip(): + return True + return False + + def line_is_closer_only(self, stmt_end: int) -> bool: + """True when the statement ends on a line that is only `};` (multi-line close).""" + i = stmt_end + while i > 0 and self.masked[i - 1] in " \t\n": + i -= 1 + if i < 2 or self.masked[i - 2 : i] != "};": + return False + line = self.line_of(i - 1) + raw = self.lines[line - 1] if 1 <= line <= len(self.lines) else "" + return raw.strip() == "};" + + +def build_line_index(text: str) -> list[int]: + line = 1 + out = [1] * len(text) + for i, ch in enumerate(text): + out[i] = line + if ch == "\n": + line += 1 + return out + + +def clang_format_off_ranges(lines: list[str]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + off_at: int | None = None + for i, line in enumerate(lines, start=1): + stripped = line.strip() + if stripped == "// clang-format off": + off_at = i + elif stripped == "// clang-format on" and off_at is not None: + ranges.append((off_at, i)) + off_at = None + if off_at is not None: + ranges.append((off_at, len(lines))) + return ranges + + +def mask_comments_and_strings(text: str) -> str: + """Replace comments and string/char contents with spaces; keep newlines and code.""" + out: list[str] = [] + i = 0 + n = len(text) + while i < n: + ch = text[i] + nxt = text[i + 1] if i + 1 < n else "" + + if ch == "/" and nxt == "/": + while i < n and text[i] != "\n": + out.append(" ") + i += 1 + continue + if ch == "/" and nxt == "*": + out.append(" ") + i += 2 + while i < n - 1 and not (text[i] == "*" and text[i + 1] == "/"): + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if i < n - 1: + out.append(" ") + i += 2 + elif i < n: + out.append(" ") + i += 1 + continue + + if ch == "R" and nxt == '"': + i = _mask_raw_string(text, i, out) + continue + + if ch in "\"'": + quote = ch + out.append(" ") + i += 1 + while i < n and text[i] != quote: + if text[i] == "\\": + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if i < n: + out.append("\n" if text[i] == "\n" else " ") + i += 1 + continue + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if i < n: + out.append(" ") + i += 1 + continue + + out.append(ch) + i += 1 + return "".join(out) + + +def _mask_raw_string(text: str, i: int, out: list[str]) -> int: + # R"delim( ... )delim" + n = len(text) + out.append(" ") # R + i += 1 + out.append(" ") # " + i += 1 + delim: list[str] = [] + while i < n and text[i] != "(": + delim.append(text[i]) + out.append(" ") + i += 1 + if i < n: + out.append(" ") + i += 1 + close = ")" + "".join(delim) + '"' + while i < n: + if text.startswith(close, i): + for _ in close: + out.append(" ") + i += 1 + return i + out.append("\n" if text[i] == "\n" else " ") + i += 1 + return i + + +def skip_ws(masked: str, i: int, end: int) -> int: + n = min(end, len(masked)) + while i < n and masked[i] in " \t\n\r\f\v": + i += 1 + return i + + +def skip_ws_and_pp(masked: str, i: int, end: int) -> int: + n = min(end, len(masked)) + while i < n: + i = skip_ws(masked, i, end) + if i >= n: + break + if masked[i] == "#" and _at_line_start(masked, i): + while i < n and masked[i] != "\n": + i += 1 + continue + break + return i + + +def _at_line_start(masked: str, i: int) -> bool: + j = i - 1 + while j >= 0 and masked[j] in " \t": + j -= 1 + return j < 0 or masked[j] == "\n" + + +def peek_ident(masked: str, i: int, end: int) -> str: + n = min(end, len(masked)) + if i >= n or masked[i] not in IDENT_START: + return "" + j = i + 1 + while j < n and masked[j] in IDENT_CONT: + j += 1 + return masked[i:j] + + +def skip_ident(masked: str, i: int, end: int) -> int: + ident = peek_ident(masked, i, end) + return i + len(ident) + + +def skip_balanced_angles(masked: str, i: int, end: int) -> int: + n = min(end, len(masked)) + if i >= n or masked[i] != "<": + return i + depth = 0 + paren = 0 + while i < n: + ch = masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren = max(0, paren - 1) + elif ch == "<" and paren == 0: + depth += 1 + elif ch == ">" and paren == 0: + depth -= 1 + i += 1 + if depth == 0: + return i + continue + i += 1 + return i + + +def skip_decl_prefix(masked: str, i: int, end: int) -> int: + """Skip `template <...>` / `template <>` and `[[attribute]]` prefixes.""" + while True: + i = skip_ws_and_pp(masked, i, end) + ident = peek_ident(masked, i, end) + if ident == "template": + i = skip_ident(masked, i, end) + i = skip_ws(masked, i, end) + if i < end and masked[i] == "<": + i = skip_balanced_angles(masked, i, end) + continue + if i < end and masked[i] == "[" and i + 1 < end and masked[i + 1] == "[": + depth = 0 + while i < end: + if masked[i] == "[" and i + 1 < end and masked[i + 1] == "[": + depth += 1 + i += 2 + continue + if masked[i] == "]" and i + 1 < end and masked[i + 1] == "]": + depth -= 1 + i += 2 + if depth == 0: + break + continue + i += 1 + continue + return i + + +def skip_balanced(masked: str, i: int, end: int, open_ch: str, close_ch: str) -> int: + n = min(end, len(masked)) + if i >= n or masked[i] != open_ch: + return i + depth = 0 + while i < n: + ch = masked[i] + if ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + i += 1 + if depth == 0: + return i + continue + i += 1 + return i + + +def skip_labels(masked: str, i: int, end: int) -> int: + while True: + j = skip_ws_and_pp(masked, i, end) + ident = peek_ident(masked, j, end) + if not ident: + return j + k = skip_ident(masked, j, end) + k = skip_ws(masked, k, end) + if ident == "case": + paren = 0 + while k < end: + ch = masked[k] + if ch == "(": + paren += 1 + elif ch == ")": + paren -= 1 + elif ch == ":" and paren == 0 and (k + 1 >= end or masked[k + 1] != ":"): + i = k + 1 + break + k += 1 + else: + return j + continue + if ident in ACCESS_LABELS and k < end and masked[k] == ":" and (k + 1 >= end or masked[k + 1] != ":"): + i = k + 1 + continue + return j + + +def parse_if(src: Source, i: int, end: int) -> tuple[Stmt, int]: + start = i + i = skip_ident(src.masked, i, end) + i = skip_ws(src.masked, i, end) + if i < end and src.masked[i] == "(": + i = skip_balanced(src.masked, i, end, "(", ")") + i = skip_ws_and_pp(src.masked, i, end) + then_start = i + i = skip_statement(src, i, end) + then_end = i + bodies = [(then_start, then_end)] + has_else = False + + j = skip_ws_and_pp(src.masked, i, end) + if peek_ident(src.masked, j, end) == "else": + has_else = True + j = skip_ident(src.masked, j, end) + j = skip_ws_and_pp(src.masked, j, end) + else_start = j + if peek_ident(src.masked, j, end) == "if": + _, j = parse_if(src, j, end) + else: + j = skip_statement(src, j, end) + i = j + bodies.append((else_start, i)) + + return Stmt("if", start, i, has_else=has_else, bodies=tuple(bodies)), i + + +def skip_statement(src: Source, i: int, end: int) -> int: + i = skip_ws_and_pp(src.masked, i, end) + if i >= end: + return i + + ident = peek_ident(src.masked, i, end) + if ident == "if": + _, i = parse_if(src, i, end) + return i + if ident in ("for", "while", "switch"): + i = skip_ident(src.masked, i, end) + i = skip_ws(src.masked, i, end) + if i < end and src.masked[i] == "(": + i = skip_balanced(src.masked, i, end, "(", ")") + i = skip_ws_and_pp(src.masked, i, end) + return skip_statement(src, i, end) + if ident == "do": + i = skip_ident(src.masked, i, end) + i = skip_ws_and_pp(src.masked, i, end) + i = skip_statement(src, i, end) + i = skip_ws_and_pp(src.masked, i, end) + if peek_ident(src.masked, i, end) == "while": + i = skip_ident(src.masked, i, end) + i = skip_ws(src.masked, i, end) + if i < end and src.masked[i] == "(": + i = skip_balanced(src.masked, i, end, "(", ")") + i = skip_ws(src.masked, i, end) + if i < end and src.masked[i] == ";": + i += 1 + return i + if ident == "try": + i = skip_ident(src.masked, i, end) + i = skip_ws_and_pp(src.masked, i, end) + i = skip_statement(src, i, end) + while True: + j = skip_ws_and_pp(src.masked, i, end) + if peek_ident(src.masked, j, end) != "catch": + break + j = skip_ident(src.masked, j, end) + j = skip_ws(src.masked, j, end) + if j < end and src.masked[j] == "(": + j = skip_balanced(src.masked, j, end, "(", ")") + j = skip_ws_and_pp(src.masked, j, end) + i = skip_statement(src, j, end) + return i + if ident == "else": + # Orphan else (should be consumed by parse_if). Skip its body so we do not stall. + i = skip_ident(src.masked, i, end) + i = skip_ws_and_pp(src.masked, i, end) + return skip_statement(src, i, end) + + if src.masked[i] == "{": + return skip_balanced(src.masked, i, end, "{", "}") + + return skip_generic_statement(src, i, end) + + +def skip_generic_statement(src: Source, i: int, end: int) -> int: + head = skip_decl_prefix(src.masked, i, end) + ident = peek_ident(src.masked, head, end) + is_type_def = ident in TYPE_DEF_STARTS + paren = 0 + bracket = 0 + saw_eq = False + + n = min(end, len(src.masked)) + while i < n: + ch = src.masked[i] + if ch in " \t\r\f\v": + i += 1 + continue + if ch == "\n": + i += 1 + i = skip_ws_and_pp(src.masked, i, end) + continue + if ch == "#" and _at_line_start(src.masked, i): + while i < n and src.masked[i] != "\n": + i += 1 + continue + if ch == "=" and paren == 0 and bracket == 0: + i, is_assign = skip_equals_token(src.masked, i, n) + if is_assign: + saw_eq = True + continue + if ch == "(": + paren += 1 + i += 1 + continue + if ch == ")": + paren = max(0, paren - 1) + i += 1 + continue + if ch == "[": + bracket += 1 + i += 1 + continue + if ch == "]": + bracket = max(0, bracket - 1) + i += 1 + continue + if ch == "{": + if paren == 0 and bracket == 0: + close_as_fn = is_type_def or (not saw_eq and _opens_function_body(src.masked, i)) + i = skip_balanced(src.masked, i, end, "{", "}") + if close_as_fn: + i = skip_ws(src.masked, i, end) + if i < n and src.masked[i] == ";": + i += 1 + return i + continue + i = skip_balanced(src.masked, i, end, "{", "}") + continue + if ch == ";" and paren == 0 and bracket == 0: + return i + 1 + if ch == "}" and paren == 0 and bracket == 0: + return i + i += 1 + return i + + +def _prev_non_ws(masked: str, i: int, end: int) -> str: + j = i - 1 + while j >= 0 and j < end and masked[j] in " \t\n\r": + j -= 1 + if j < 0: + return "" + return masked[j] + + +def parse_statements(src: Source, start: int, end: int) -> list[Stmt]: + stmts: list[Stmt] = [] + i = start + guard = 0 + n = min(end, len(src.masked)) + while True: + guard += 1 + if guard > n + 8: + break + i = skip_labels(src.masked, i, end) + if i >= end: + break + if src.masked[i] == "}": + break + stmt_start = i + head = skip_decl_prefix(src.masked, i, end) + ident = peek_ident(src.masked, head, end) + if ident == "if": + stmt, i = parse_if(src, head, end) + stmts.append(Stmt(stmt.kind, stmt_start, stmt.end, has_else=stmt.has_else, bodies=stmt.bodies)) + if i <= stmt_start: + i += 1 + continue + if ident in CONTROL_STARTS: + body_hint = head + i = skip_statement(src, head, end) + stmts.append(Stmt(ident, stmt_start, i, bodies=_control_bodies(src, body_hint, i))) + if i <= stmt_start: + i += 1 + continue + if src.masked[i] == "{": + close = skip_balanced(src.masked, i, end, "{", "}") + stmts.append(Stmt("compound", i, close, bodies=((i + 1, close - 1 if close > i else close),))) + i = close + continue + i = skip_generic_statement(src, i, end) + if i <= stmt_start: + i += 1 + continue + kind = "typedef" if ident in TYPE_DEF_STARTS else "stmt" + inner: tuple[tuple[int, int], ...] = () + if kind == "typedef" or _looks_like_function_body(src, stmt_start, i): + inner = _outer_brace_span(src, stmt_start, i) + stmts.append(Stmt(kind, stmt_start, i, bodies=inner)) + return stmts + + +def _control_bodies(src: Source, start: int, end: int) -> tuple[tuple[int, int], ...]: + i = skip_ident(src.masked, start, end) + i = skip_ws(src.masked, i, end) + if i < end and src.masked[i] == "(": + i = skip_balanced(src.masked, i, end, "(", ")") + i = skip_ws_and_pp(src.masked, i, end) + if i < end and src.masked[i] == "{": + close = skip_balanced(src.masked, i, end, "{", "}") + return ((i + 1, close - 1),) + return ((i, end),) + + +def skip_equals_token(masked: str, i: int, n: int) -> tuple[int, bool]: + """Advance past `=` / `==` / `!=` / `<=` / `>=` / `<=>`. True if assignment `=`.""" + nxt = masked[i + 1] if i + 1 < n else "" + prev = masked[i - 1] if i > 0 else "" + if nxt == ">": + return i + 2, False + if nxt == "=": + return i + 2, False + if prev in "!<>=": + return i + 1, False + return i + 1, True + + +FUNC_TRAIL_IDENTS = frozenset({"const", "noexcept", "override", "final", "volatile", "mutable"}) + + +def _opens_function_body(masked: str, brace_at: int) -> bool: + """True if `{` starts a function/method body (not a brace-init).""" + prev = _prev_non_ws(masked, brace_at, len(masked)) + if prev == ")": + return True + ident = _prev_ident(masked, brace_at) + return ident in FUNC_TRAIL_IDENTS + + +def _prev_ident(masked: str, i: int) -> str: + j = i - 1 + while j >= 0 and masked[j] in " \t\n\r": + j -= 1 + if j < 0 or masked[j] not in IDENT_CONT: + return "" + end = j + 1 + while j >= 0 and masked[j] in IDENT_CONT: + j -= 1 + return masked[j + 1 : end] + + +def _prefix_has_assignment(masked: str, start: int, brace_at: int) -> bool: + i = start + paren = 0 + bracket = 0 + n = brace_at + while i < n: + ch = masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren = max(0, paren - 1) + elif ch == "[": + bracket += 1 + elif ch == "]": + bracket = max(0, bracket - 1) + elif ch == "=" and paren == 0 and bracket == 0: + i, is_assign = skip_equals_token(masked, i, n) + if is_assign: + return True + continue + i += 1 + return False + + +def _looks_like_function_body(src: Source, start: int, end: int) -> bool: + brace_at = src.masked.find("{", start, end) + if brace_at < 0: + return False + if _prefix_has_assignment(src.masked, start, brace_at): + return False + i = start + last_rparen = -1 + paren = 0 + while i < end: + ch = src.masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren -= 1 + if paren == 0: + last_rparen = i + elif ch == "{": + if last_rparen >= 0 and paren == 0: + return True + return False + i += 1 + return False + + +def _outer_brace_span(src: Source, start: int, end: int) -> tuple[tuple[int, int], ...]: + i = start + paren = 0 + while i < end: + ch = src.masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren -= 1 + elif ch == "{" and paren == 0: + close = skip_balanced(src.masked, i, end, "{", "}") + if close > i + 1: + return ((i + 1, close - 1),) + return () + i += 1 + return () + + +def starts_with_any(code: str, prefixes: tuple[str, ...]) -> bool: + return any(code.startswith(p) for p in prefixes) + + +def check_vertical_rhythm(src: Source) -> list[Finding]: + findings: list[Finding] = [] + _check_span(src, 0, len(src.masked), findings) + return findings + + +def _check_span(src: Source, start: int, end: int, findings: list[Finding]) -> None: + if start >= end: + return + stmts = parse_statements(src, start, end) + for stmt in stmts: + for b0, b1 in stmt.bodies: + _check_span(src, b0, b1, findings) + + for a, b in zip(stmts, stmts[1:]): + if src.in_clang_format_off(a.start) or src.in_clang_format_off(b.start): + continue + if src.has_blank_line_between(a.end, b.start): + continue + + b_lead = src.leading_code(b.start, b.end) + a_lead = src.leading_code(a.start, a.end) + line = src.line_of(b.start) + + if a.kind == "if" and not a.has_else and b.kind == "if": + findings.append( + Finding( + src.path, + line, + "vertical-rhythm", + "blank line required between consecutive if statements that have no else", + ) + ) + continue + + if a.kind == "if" and starts_with_any(b_lead, SAME_ROW_UI_PREFIXES): + findings.append( + Finding( + src.path, + line, + "vertical-rhythm", + "blank line required after if before same-row UI (ImGui::SameLine / GUI_DOC_HELP_)", + ) + ) + continue + + if src.line_is_closer_only(a.end) and a.kind not in TYPE_DEF_STARTS and a.kind != "typedef": + findings.append( + Finding( + src.path, + line, + "vertical-rhythm", + "blank line required after local lambda / helper / lookup table close (};)", + ) + ) + continue + + if starts_with_any(a_lead, IMGUI_TEXT_BEATS) and b_lead.startswith(IMGUI_SPACING): + findings.append( + Finding( + src.path, + line, + "vertical-rhythm", + "blank line required between ImGui text beat (TextWrapped / TextDisabled) and Spacing", + ) + ) + continue + + if a_lead.startswith(IMGUI_BUTTON) and ( + b_lead.startswith(IMGUI_BUTTON) or b_lead.startswith("ImGui::SameLine") + ): + findings.append( + Finding( + src.path, + line, + "vertical-rhythm", + "blank line required between sequential ImGui Button / SameLine actions", + ) + ) + + +def iter_cpp_files(paths: list[Path]) -> list[Path]: + files: list[Path] = [] + for path in paths: + path = path.resolve() + if path.is_file(): + if path.suffix.lower() in CPP_SUFFIXES: + files.append(path) + continue + if not path.is_dir(): + continue + for child in path.rglob("*"): + if not child.is_file() or child.suffix.lower() not in CPP_SUFFIXES: + continue + if any(p.name in SKIP_DIR_NAMES or p.name.startswith(SKIP_DIR_PREFIXES) for p in child.parents): + continue + files.append(child) + return sorted(set(files)) + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +RULES = { + "vertical-rhythm": check_vertical_rhythm, +} + + +def collect_findings(files: list[Path], rule_names: list[str] | None = None) -> list[Finding]: + names = rule_names or list(RULES) + findings: list[Finding] = [] + for path in files: + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + text = path.read_text(encoding="utf-8", errors="replace") + src = Source(path, text) + for name in names: + findings.extend(RULES[name](src)) + findings.sort(key=lambda f: (str(f.path).lower(), f.line, f.message)) + return findings + + +def main() -> int: + root = repo_root() + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "paths", + nargs="*", + type=Path, + default=[root / "src"], + help="Files or directories (default: src/)", + ) + ap.add_argument( + "--rule", + action="append", + choices=sorted(RULES), + dest="rules", + help="Rule group to run (repeatable). Default: all implemented groups", + ) + args = ap.parse_args() + files = iter_cpp_files(args.paths) + if not files: + print("No C/C++ files found.", file=sys.stderr) + return 2 + + findings = collect_findings(files, args.rules) + for f in findings: + print(f.format(root)) + + if findings: + print(f"{len(findings)} finding(s) in {len({f.path for f in findings})} file(s).") + return 1 + print("No code-style findings.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/gui.cpp b/src/gui.cpp index 4f101daf..77f8214b 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -208,6 +208,7 @@ void GUI::initialize_toolbar_() {load_texture("res/icons/Part_Common.png"), false, "Shape common", Command::Shape_common}, // clang-format on }; + sync_toolbar_hotkey_tooltips_(); } @@ -225,6 +226,7 @@ void GUI::sync_toolbar_hotkey_tooltips_() return; } }; + auto tip_cmd = [this](Command cmd, const char* base, Gui_action action) { for (Toolbar_button& b : m_toolbar_buttons) @@ -439,6 +441,7 @@ void GUI::menu_bar_() ImGui::EndMenu(); } #ifdef __EMSCRIPTEN__ + if (ImGui::MenuItem("Save settings")) { save_occt_view_settings(); @@ -518,12 +521,14 @@ void GUI::menu_bar_() } } #ifndef NDEBUG + if (ImGui::MenuItem("Debug", nullptr, m_show_dbg)) { m_show_dbg = !m_show_dbg; save_panes = true; } #endif + if (save_panes) save_occt_view_settings(); @@ -717,6 +722,7 @@ void GUI::ensure_about_assets_() #endif "res/AI-gen-splashscreen_05_01_2026_512.png", }; + for (const char* p : png_paths) { if (!std::filesystem::exists(p)) @@ -1336,6 +1342,7 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch_l sketch_list_extrude_face_(sketch, i); ImGui::EndPopup(); } + ImGui::SameLine(); if (ImGui::SmallButton("E")) sketch_list_extrude_face_(sketch, i); @@ -1512,6 +1519,7 @@ void GUI::sketch_list_() sketch->underlay().set_visible_sync(ul_vis, sketch->get_plane()); if (m_underlay_panel_sketch == sketch.get()) m_underlay_vis = ul_vis; + if (m_view->sketch_list_hover() == sketch) { m_view->set_sketch_list_hover(nullptr); @@ -1807,6 +1815,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) sketch_underlay_import_dialog_(); } #else + if (ImGui::Button("Import image...")) { m_underlay_import_sketch_target = sk; @@ -1860,6 +1869,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) const float x = std::clamp(c, 0.f, 1.f) * 255.f; return static_cast(x + 0.5f); }; + ul.set_line_tint_rgba(to_u8(m_underlay_tint_col[0]), to_u8(m_underlay_tint_col[1]), to_u8(m_underlay_tint_col[2]), to_u8(m_underlay_tint_col[3])); ul.rebuild_display(ul_pln, ul_sketch_shown); @@ -2064,6 +2074,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) ul.set_flip_image_u(m_underlay_flip_u); ul.rebuild_display(ul_pln, ul_sketch_shown); } + if (ImGui::Checkbox("Reverse image V (flip vertical in source)", &m_underlay_flip_v)) { ul.set_flip_image_v(m_underlay_flip_v); @@ -2099,8 +2110,10 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) ImGuiSliderFlags_ClampOnInput); if (ImGui::IsItemActivated()) begin_underlay_undo_(*sk); + if (ImGui::IsItemDeactivatedAfterEdit()) commit_underlay_undo_(*sk); + if (changed) apply_affine(); } @@ -2110,8 +2123,10 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) ImGuiSliderFlags_ClampOnInput); if (ImGui::IsItemActivated()) begin_underlay_undo_(*sk); + if (ImGui::IsItemDeactivatedAfterEdit()) commit_underlay_undo_(*sk); + if (changed) apply_affine(); } @@ -3282,10 +3297,12 @@ void GUI::file_inspector_dialog_() "Flat solids", "Union shapes", }; + int mode_i = static_cast(m_file_inspector_step_mode); ImGui::SetNextItemWidth(220.0f); if (ImGui::Combo("Import as", &mode_i, k_step_import_labels, IM_ARRAYSIZE(k_step_import_labels))) m_file_inspector_step_mode = static_cast(mode_i); + if (ui_show_contextual_help() && ImGui::IsItemHovered()) ImGui::SetTooltip("Preserve hierarchy: Shape List groups from the STEP assembly (default).\n" "Flat solids: leaf solids only at the document root.\n" @@ -3507,6 +3524,7 @@ void GUI::lua_console_() { if (!show_lua_console_effective()) return; + if (!m_lua_console) m_lua_console = std::make_unique(this); m_lua_console->render(&m_show_lua_console); @@ -3633,6 +3651,7 @@ void GUI::load_default_project_() log_message("EzyCad: startup document loaded (saved startup)."); return; } + if (!user_startup.empty()) { log_message("EzyCad: saved startup project is invalid or incomplete; falling back to install default."); @@ -3745,12 +3764,16 @@ nlohmann::json GUI::sketch_list_ui_to_json_() const json row; if (ui.expanded) row["expanded"] = true; + if (ui.dimensions) row["dimensions"] = true; + if (ui.nodes) row["nodes"] = true; + if (ui.edges) row["edges"] = true; + if (ui.faces) row["faces"] = true; rows[std::to_string(sketch->get_id())] = std::move(row); @@ -3797,12 +3820,16 @@ void GUI::apply_sketch_list_ui_from_json_(const nlohmann::json& j) Sketch_list_row_ui& ui_row = m_sketch_list_ui[id]; if (row.contains("expanded") && row["expanded"].is_boolean()) ui_row.expanded = row["expanded"].get(); + if (row.contains("dimensions") && row["dimensions"].is_boolean()) ui_row.dimensions = row["dimensions"].get(); + if (row.contains("nodes") && row["nodes"].is_boolean()) ui_row.nodes = row["nodes"].get(); + if (row.contains("edges") && row["edges"].is_boolean()) ui_row.edges = row["edges"].get(); + if (row.contains("faces") && row["faces"].is_boolean()) ui_row.faces = row["faces"].get(); } @@ -4124,6 +4151,7 @@ void GUI::export_units_dialog_() if (ImGui::RadioButton("Inches", m_export_unit == Export_unit::Inch)) m_export_unit = Export_unit::Inch; + if (ImGui::RadioButton("Millimeters", m_export_unit == Export_unit::Millimeter)) m_export_unit = Export_unit::Millimeter; @@ -4140,6 +4168,7 @@ void GUI::export_units_dialog_() m_export_units_modal_open = false; export_file_dialog_(fmt, unit); } + ImGui::SameLine(); if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) { @@ -4438,6 +4467,7 @@ void GUI::on_file(const std::string& file_path, const std::string& file_bytes, b if (file_path != "(startup)" && !file_path.empty() && file_path != "res/default.ezy") persist_last_opened_project_path_(file_path); #endif + if (announce_load) show_message("Opened: " + std::filesystem::path(file_path).filename().string()); } diff --git a/src/gui_add.cpp b/src/gui_add.cpp index 217e3399..82cd61c4 100644 --- a/src/gui_add.cpp +++ b/src/gui_add.cpp @@ -44,6 +44,7 @@ void GUI::add_box_dialog_() ImGui::CloseCurrentPopup(); } } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); @@ -58,6 +59,7 @@ void GUI::add_pyramid_dialog_() ImGui::OpenPopup("Add pyramid"); m_open_add_pyramid_popup = false; } + if (!ImGui::BeginPopupModal("Add pyramid", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; @@ -80,6 +82,7 @@ void GUI::add_pyramid_dialog_() ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); @@ -158,6 +161,7 @@ void GUI::add_cylinder_dialog_() ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); @@ -198,6 +202,7 @@ void GUI::add_cone_dialog_() ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); @@ -237,6 +242,7 @@ void GUI::add_torus_dialog_() ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); @@ -300,6 +306,7 @@ void GUI::add_sketch_dialog_() m_view->add_sketch(pln, base); ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 8e9b5edc..cbe581b6 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -465,12 +465,14 @@ bool GUI::try_capture_hotkey_press_(int key, int mods) show_message(m_hotkey_capture_error); return true; } + if (Gui_hotkeys::is_reserved_chord(chord)) { m_hotkey_capture_error = "Reserved: " + Gui_hotkeys::format_chord(chord) + " is a fixed shortcut and cannot be remapped."; show_message(m_hotkey_capture_error); return true; } + if (!m_hotkeys.set_chord(*m_hotkey_capture_action, chord)) { m_hotkey_capture_error = "Conflict: " + Gui_hotkeys::format_chord(chord) + " is already assigned."; @@ -738,6 +740,7 @@ void GUI::options_shape_chamfer_mode_() m_view->shp_chamfer().set_chamfer_dist(p * scale); } } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::TextUnformatted(m_view->project_unit_suffix()); ImGui::PopID(); @@ -803,6 +806,7 @@ void GUI::options_shape_fillet_mode_() m_view->shp_fillet().set_fillet_radius(p * scale); } } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::TextUnformatted(m_view->project_unit_suffix()); ImGui::PopID(); @@ -1146,6 +1150,7 @@ void GUI::options_sketch_add_edge_mode_() m_edge_from_center = from_center; Sketch::set_edge_from_center(from_center); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("First click sets the edge midpoint. The second click or Tab length input uses the full edge " "length. Click ? to open the user guide.", @@ -1261,6 +1266,7 @@ void GUI::options_sketch_common_() "Both", "None", }; + int snap_mode = static_cast(Sketch_nodes::get_snap_guide_mode()); ImGui::SetNextItemWidth(140.0f); if (ImGui::BeginCombo("##snap_guide_mode", k_snap_guide_mode_labels[static_cast(snap_mode)], diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 498c8055..0b82c6f3 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -856,6 +856,7 @@ bool Occt_view::sketch_plane_view_aabb_2d(const gp_Pln& pln, double display_w, d min_u -= 1.0; max_u += 1.0; } + if (max_v - min_v < k_eps) { min_v -= 1.0; @@ -2308,6 +2309,7 @@ Occt_view::Grid_layout Occt_view::compute_grid_layout_() const min_u -= step; max_u += step; } + if (max_v - min_v < k_min_span) { min_v -= step; @@ -2396,6 +2398,7 @@ Occt_grid_rect_params Occt_view::clamp_occt_grid_rect_params_(Occt_grid_rect_par if (!std::isfinite(g.grid_padding) || g.grid_padding < 0.0) g.grid_padding = default_padding; + if (!std::isfinite(g.graphic_z_offset)) g.graphic_z_offset = 0.0; @@ -3152,11 +3155,13 @@ void Occt_view::refresh_shape_list_hover_highlight() update_sketch_list_hover_face_drawer_(); apply_sketch_list_hover_ais_state_(m_sketch_list_hover_face, m_sketch_list_hover_face_drawer, AIS_Shaded); } + if (!m_sketch_list_hover_edge.ais.IsNull()) { update_sketch_list_hover_edge_drawer_(); apply_sketch_list_hover_ais_state_(m_sketch_list_hover_edge, m_sketch_list_hover_edge_drawer, AIS_WireFrame); } + if (!m_sketch_list_hover_node.ais.IsNull()) { update_sketch_list_hover_node_drawer_(); @@ -3197,10 +3202,13 @@ Sketch* Occt_view::sketch_owner_of_list_ais_(const AIS_Shape_ptr& ais) { if (ais.IsNull()) return nullptr; + if (auto* face = dynamic_cast(ais.get())) return &face->owner_sketch; + if (auto* edge = dynamic_cast(ais.get())) return &edge->owner_sketch; + if (auto* node = dynamic_cast(ais.get())) return &node->owner_sketch; return nullptr; @@ -3219,6 +3227,7 @@ void Occt_view::clear_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover) hover.ais->SetZLayer(hover.prev_zlayer); hover.zlayer_override = false; } + if (hover.temp_display) m_ctx->Erase(hover.ais, false); } @@ -4498,6 +4507,7 @@ bool Occt_view::import_ply(const std::string& ply_bytes) m_gui.log_message("PLY import failed: " + st.message()); return false; } + if (shape.IsNull()) return false; @@ -4628,6 +4638,7 @@ TopoDS_Shape scale_shape_about_origin_(const TopoDS_Shape& shape, double factor) { if (shape.IsNull()) return shape; + if (std::abs(factor - 1.0) <= Precision::Confusion()) return shape; diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index 0b898f2c..aabbb966 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -349,6 +349,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) return out; }; + m_edge_dim_line_width = parse_bounded_float("edge_dim_line_width", 0.5f, 8.0f, k_gui_edge_dim_line_width_default); m_edge_dim_arrow_size = parse_bounded_float("edge_dim_arrow_size", 1.0f, 24.0f, k_gui_edge_dim_arrow_size_default); m_edge_dim_text_scale = parse_bounded_float("edge_dim_text_scale", k_gui_edge_dim_text_scale_min, @@ -364,6 +365,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) return default_v; }; + m_edge_dim_text_render_mode = parse_dim_int("edge_dim_text_render_mode", 0, k_gui_edge_dim_text_render_mode_max, k_gui_edge_dim_text_render_mode_default); if (g.contains("edge_dim_color") && g["edge_dim_color"].is_array() && g["edge_dim_color"].size() >= 3) @@ -700,6 +702,7 @@ void GUI::load_occt_view_settings_() return false; }; + const bool version_ok = settings_version_matches(j); if (!version_ok) { @@ -841,6 +844,7 @@ void GUI::settings_() ImGui::EndTable(); } + if (verb_changed) save_occt_view_settings(); } @@ -1209,6 +1213,7 @@ void GUI::settings_() ImGui::EndTable(); } + if (grid_changed) m_view->set_grid_colors(g1[0], g1[1], g1[2], g2[0], g2[1], g2[2]); @@ -1525,6 +1530,7 @@ void GUI::settings_() constexpr std::array k_labels = { "Opaque 2D text", "SetCommonColor", "2D screen text", "3D text", "Z-layer Top", "Z-layer Topmost", }; + int rm = m_edge_dim_text_render_mode; if (rm < 0 || rm >= static_cast(k_labels.size())) rm = k_gui_edge_dim_text_render_mode_default; @@ -1821,6 +1827,7 @@ void GUI::settings_() "Both", "None", }; + int mode = static_cast(Sketch_nodes::get_snap_guide_mode()); ImGui::SetNextItemWidth(160.0f); if (ImGui::BeginCombo("##settings_snap_guide_mode", k_snap_guide_mode_labels[static_cast(mode)], @@ -1835,6 +1842,7 @@ void GUI::settings_() ImGui::EndCombo(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Traditional: compact local snap marker.\nFullscreen: full-view crosshair/axis guides.\nBoth: show " "compact marker and fullscreen guides together.\nNone: disable snap-to-node and snap guides. Click ? " @@ -2023,6 +2031,7 @@ void GUI::settings_() doc_urls::k_startup_project); ImGui::EndTable(); } + if (ui_show_contextual_help() && !m_last_opened_project_path.empty()) ImGui::TextWrapped("Last opened path: %s", m_last_opened_project_path.c_str()); else diff --git a/src/main.cpp b/src/main.cpp index 3d4ce1aa..a66ca54c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -113,6 +113,7 @@ int main(int argc, char** argv) std::fprintf(stderr, "EzyCad: %s\n", listen_cli_error.c_str()); return 1; } + #if !defined(EZYCAD_HAVE_PYTHON) if (want_listen) { @@ -227,6 +228,7 @@ int main(int argc, char** argv) auto try_cousine = (exe_dir / "Cousine-Regular.ttf").string(); if (std::filesystem::exists(try_droid)) droid_font_path = try_droid; + if (std::filesystem::exists(try_cousine)) cousine_font_path = try_cousine; } diff --git a/src/scr_lua_console.cpp b/src/scr_lua_console.cpp index 8576e84b..ffb992d8 100644 --- a/src/scr_lua_console.cpp +++ b/src/scr_lua_console.cpp @@ -954,6 +954,7 @@ void Lua_console::execute(const std::string& code) lua_pop(m_L, 1); return; } + if (lua_pcall(m_L, 0, LUA_MULTRET, 0) != LUA_OK) { append_line(lua_tostring(m_L, -1), true); diff --git a/src/scr_python_console.cpp b/src/scr_python_console.cpp index 484a046c..de022071 100644 --- a/src/scr_python_console.cpp +++ b/src/scr_python_console.cpp @@ -49,6 +49,7 @@ const TextEditor::LanguageDefinition& python_language_definition() "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", }; + for (const char* kw : keywords) lang_def.mKeywords.insert(kw); @@ -1119,6 +1120,7 @@ void Python_console::render(bool* p_open) m_log_display_buf.push_back('\0'); m_log_display_built_version = m_log_display_version; } + if (m_log_display_buf.empty()) m_log_display_buf.push_back('\0'); diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 6fd67a11..5c260702 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -292,6 +292,7 @@ std::optional Shp_cross_section::poll() m_running_active = false; } #else + if (m_chunked.has_value()) { Chunked_job& job = *m_chunked; diff --git a/src/shp_rotate.cpp b/src/shp_rotate.cpp index c6a377c7..e08bc43d 100644 --- a/src/shp_rotate.cpp +++ b/src/shp_rotate.cpp @@ -248,6 +248,7 @@ void Shp_rotate::clear_rotation_vis_() { if (!m_rotation_axis_vis.IsNull()) ctx().Remove(m_rotation_axis_vis, false); + if (!m_rotation_center_vis.IsNull()) ctx().Remove(m_rotation_center_vis, false); clear_all(m_rotation_axis_vis, m_rotation_center_vis); diff --git a/src/skt_nodes.cpp b/src/skt_nodes.cpp index efb9adab..d9905844 100644 --- a/src/skt_nodes.cpp +++ b/src/skt_nodes.cpp @@ -101,6 +101,7 @@ class Sketch_nodes::Impl { if (n.deleted) return false; + if (n.origin && !m_origin_snap_enabled) return false; return true; @@ -222,6 +223,7 @@ size_t Sketch_nodes::Impl::get_node_exact(const gp_Pnt2d& pt, bool permanent_for continue; } // If caller requests permanence (e.g. add-node tool), preserve/promote it. + if (permanent_for_new) n.permanent = true; diff --git a/src/skt_op_recorder.cpp b/src/skt_op_recorder.cpp index eb2836d3..94430599 100644 --- a/src/skt_op_recorder.cpp +++ b/src/skt_op_recorder.cpp @@ -667,6 +667,7 @@ void Sketch_op_data::capture_linear_edges_at_start_(Sketch& sketch, std::vector< Prev_edge_rec rec{sketch.m_nodes[e.node_idx_a], sketch.m_nodes[*e.node_idx_b], std::nullopt, e.name}; if (e.node_idx_mid.has_value()) rec.pt_mid = sketch.m_nodes[*e.node_idx_mid]; + if (std::find_if(out.begin(), out.end(), [&](const Prev_edge_rec& x) { return prev_linear_equal_(x, rec); }) == out.end()) out.push_back(std::move(rec)); } diff --git a/src/skt_tools.cpp b/src/skt_tools.cpp index 0b57f93f..73c1686f 100644 --- a/src/skt_tools.cpp +++ b/src/skt_tools.cpp @@ -932,6 +932,7 @@ void Sketch_tools::add_sketch_pt_(const ScreenCoords& screen_coords, size_t requ if (m_tmp_node_idxs.size() >= required_num_pts) callback(m_tmp_node_idxs.back()); }; + move_sketch_pt_(screen_coords, l); } diff --git a/src/skt_underlay.cpp b/src/skt_underlay.cpp index 1a6e67e6..52ac9102 100644 --- a/src/skt_underlay.cpp +++ b/src/skt_underlay.cpp @@ -715,6 +715,7 @@ void Sketch_underlay::Impl::ctx_erase() m_ctx.Remove(m_ais, false); m_ais.Nullify(); } + if (!m_border.IsNull()) { m_ctx.Remove(m_border, false); @@ -896,6 +897,7 @@ void Sketch_underlay::Impl::build_ais_(const gp_Pln& pln) std::swap(r1[c], r2[c]); } } + if (m_flip_image_u) { // horizontal flip (U / image columns) diff --git a/src/utl_settings.cpp b/src/utl_settings.cpp index 4af391cd..99c8b09c 100644 --- a/src/utl_settings.cpp +++ b/src/utl_settings.cpp @@ -87,12 +87,14 @@ std::string load_with_defaults() const std::filesystem::path user_p = user_settings_json_path(); if (!user_p.empty()) content = read_file(user_p); + if (content.empty()) { // Legacy: cwd ezycad_settings.json (same directory as exe when launched that way). content = read_file(std::filesystem::path("ezycad_settings.json")); } #endif + if (content.empty()) { content = load_defaults(); diff --git a/tests/skt_ops_tests.cpp b/tests/skt_ops_tests.cpp index 75fa51e5..621cfd60 100644 --- a/tests/skt_ops_tests.cpp +++ b/tests/skt_ops_tests.cpp @@ -460,7 +460,7 @@ TEST_F(Sketch_test, MirrorSelectedEdges_Arc) sketch.add_sketch_pt(ScreenCoords(dvec2(10.0, 0.0))); // Add a simple arc above the axis (using three points) - // Arc from (-1,1) through (0,2) to (1,1) — a bump above x axis + // Arc from (-1,1) through (0,2) to (1,1) - a bump above x axis gp_Pnt2d a(-1.0, 1.0); gp_Pnt2d b(0.0, 2.0); gp_Pnt2d c(1.0, 1.0); @@ -633,11 +633,11 @@ TEST_F(Sketch_test, RevolveSelected_SimpleEdgeProfile) // Revolve a closed edge profile (rectangle) by selecting its boundary edges. // This exercises the selected_edges path in revolve_selected (multiple edges in compound). -// Revolving a closed profile 360° around an external axis produces a solid of revolution. +// Revolving a closed profile 360 deg around an external axis produces a solid of revolution. // Revolve a closed edge profile (rectangle) by selecting its boundary edges. // This exercises the selected_edges path in revolve_selected (multiple edges in compound). -// Revolving a closed profile 360° around an external axis produces a solid of revolution. +// Revolving a closed profile 360 deg around an external axis produces a solid of revolution. TEST_F(Sketch_test, RevolveSelected_ClosedEdgeProfile) { gp_Pln default_plane(gp::Origin(), gp::DZ()); @@ -736,7 +736,7 @@ TEST_F(Sketch_test, RevolveSelected_ClosedEdgeProfile) EXPECT_NEAR(aymax, eymax, 1e-8); EXPECT_NEAR(azmax, ezmax, 1e-8); - // For a closed profile revolved 360°, we expect a solid of revolution. + // For a closed profile revolved 360 deg, we expect a solid of revolution. EXPECT_EQ(actual.ShapeType(), TopAbs_SOLID) << "Closed edge profile revolved 360 deg should be a solid"; } diff --git a/tests/skt_tests.cpp b/tests/skt_tests.cpp index 8b18e808..0fa4bef6 100644 --- a/tests/skt_tests.cpp +++ b/tests/skt_tests.cpp @@ -644,7 +644,7 @@ TEST_F(Sketch_test, OriginatingFaceSnapPointsCircle) // For a circle, the implementation extracts: // - Start vertex (at angle 0, where the circle starts) - // - Midpoint (at angle π, 180 degrees) + // - Midpoint (at angle pi, 180 degrees) // - End vertex (same as start for closed circle, so not added again) // So we expect exactly 2 points: start vertex and midpoint std::vector expected = { @@ -791,6 +791,7 @@ TEST_F(Sketch_test, AddNode_splits_linear_edge_interior) double x1 = std::max(pa.X(), pb.X()); if (std::abs(x0 - 0.0) < 1e-6 && std::abs(x1 - 7.0) < 1e-6) found_0_7 = true; + if (std::abs(x0 - 7.0) < 1e-6 && std::abs(x1 - 20.0) < 1e-6) found_7_20 = true; }