From 07e4c0bdaa0e96fd6e2be5dcb628681188914ba9 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Fri, 26 Jun 2026 18:16:04 -0600 Subject: [PATCH 1/3] wip --- src/sketch.h | 1 + tools/__pycache__/pimpl_gen.cpython-312.pyc | Bin 0 -> 15897 bytes {scripts => tools}/pbf-to-png.ps1 | 0 {scripts => tools}/pbf-to-png.py | 0 tools/pimpl_gen.py | 415 ++++++++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 tools/__pycache__/pimpl_gen.cpython-312.pyc rename {scripts => tools}/pbf-to-png.ps1 (100%) rename {scripts => tools}/pbf-to-png.py (100%) create mode 100644 tools/pimpl_gen.py diff --git a/src/sketch.h b/src/sketch.h index a7c6466..d6b7bc3 100644 --- a/src/sketch.h +++ b/src/sketch.h @@ -248,6 +248,7 @@ class Sketch /// Add a single node (no new edge); splits any linear edge the node lies on in its interior. void add_node_pt_(const ScreenCoords& screen_coords); void move_add_node_pt_(const ScreenCoords& screen_coords); + void split_linear_edges_at_node_if_interior_(size_t node_idx); void split_linear_edges_at_node_if_interior_(size_t node_idx, Sketch_op_recorder& rec); void split_linear_edges_at_node_if_interior_(size_t node_idx, Sketch_op_recorder* rec); diff --git a/tools/__pycache__/pimpl_gen.cpython-312.pyc b/tools/__pycache__/pimpl_gen.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0d0e1cedd2d3903798116745130fdc0cf92b713 GIT binary patch literal 15897 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 RmbJ8Ggcb-#p@~hx{|6Ov@method(...). + +Example: + python tools/pimpl_gen.py src/sketch_delta.h --class Sketch_op_recorder + -> sketch_delta_impl_000.inl + +The output is a starting point: review, rename, and merge into your .cpp. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +def definition_suffix(suffix: str) -> str: + """Suffix tokens valid on a declaration but not on an out-of-line definition.""" + suffix = re.sub(r"\boverride\b", "", suffix) + suffix = re.sub(r"\bfinal\b", "", suffix) + suffix = re.sub(r"=\s*0\b", "", suffix) + return normalize_ws(suffix) + + +@dataclass +class MethodDecl: + return_type: str + name: str + params: str + suffix: str # const, override, noexcept, etc. after the closing paren + + @property + def definition_suffix(self) -> str: + return definition_suffix(self.suffix) + + @property + def qualified_signature_tail(self) -> str: + """Everything after ClassName:: in a definition.""" + parts = [f"({self.params})"] + if self.definition_suffix: + parts.append(self.definition_suffix) + return " ".join(parts) + + @property + def forward_args(self) -> str: + return ", ".join(extract_param_names(self.params)) + + +def strip_comments(text: str) -> str: + # Block comments + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + # Line comments + text = re.sub(r"//[^\n]*", "", text) + return text + + +def find_matching_brace(text: str, open_index: int) -> int: + depth = 0 + i = open_index + while i < len(text): + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return i + i += 1 + raise ValueError("unbalanced braces in class body") + + +def split_top_level(text: str, sep: str) -> list[str]: + parts: list[str] = [] + depth_angle = depth_paren = depth_bracket = 0 + start = 0 + for i, ch in enumerate(text): + if ch == "<": + depth_angle += 1 + elif ch == ">": + depth_angle = max(0, depth_angle - 1) + elif ch == "(": + depth_paren += 1 + elif ch == ")": + depth_paren = max(0, depth_paren - 1) + elif ch == "[": + depth_bracket += 1 + elif ch == "]": + depth_bracket = max(0, depth_bracket - 1) + elif ch == sep and depth_angle == depth_paren == depth_bracket == 0: + parts.append(text[start:i].strip()) + start = i + 1 + parts.append(text[start:].strip()) + return [p for p in parts if p] + + +def extract_param_names(params: str) -> list[str]: + if not params.strip(): + return [] + names: list[str] = [] + for part in split_top_level(params, ","): + part = part.strip() + if not part or part == "...": + continue + # Drop default value + if "=" in part: + part = part[: part.index("=")].strip() + # Name is the last token (handles Type& name, Type* name, Type name) + tokens = part.split() + if not tokens: + continue + name = tokens[-1] + # Strip *, &, && from the name side + name = name.lstrip("*&") + if name: + names.append(name) + return names + + +def normalize_ws(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def is_skipped_member(name: str, class_name: str, decl: str) -> bool: + if name == class_name: + return True # constructor + if name.startswith("~"): + return True # destructor + if "= delete" in decl or "= default" in decl: + return True + if decl.strip().endswith("{") or "{" in decl: + return True # inline body in header + if name.startswith("operator"): + return True + return False + + +def parse_method_decl(decl: str, class_name: str) -> MethodDecl | None: + decl = normalize_ws(decl) + if not decl.endswith(";"): + return None + decl = decl[:-1].strip() + + # friend, using, enum, nested type, static data member + skip_prefixes = ( + "friend ", + "using ", + "enum ", + "struct ", + "class ", + "typedef ", + "static_assert", + ) + if decl.startswith(skip_prefixes): + return None + + paren = decl.find("(") + if paren < 0: + return None + + before = decl[:paren].strip() + after = decl[paren + 1 :] + close = find_matching_paren(after, 0, already_open=True) + if close < 0: + return None + params = after[:close].strip() + suffix = after[close + 1 :].strip() + + # static member function + if before.startswith("static "): + before = before[7:].strip() + + name_match = re.search(r"([~]?[\w:]+)\s*$", before) + if not name_match: + return None + name = name_match.group(1) + return_type = before[: name_match.start()].strip() + + if is_skipped_member(name, class_name, decl + ";"): + return None + if not return_type and name != class_name: + # Might be a ctor we missed, or malformed + if name == class_name: + return None + + return MethodDecl(return_type=return_type, name=name, params=params, suffix=suffix) + + +def find_matching_paren(text: str, start: int, *, already_open: bool = False) -> int: + depth = 1 if already_open else 0 + depth_angle = 0 + i = start + while i < len(text): + ch = text[i] + if ch == "<": + depth_angle += 1 + elif ch == ">": + depth_angle = max(0, depth_angle - 1) + elif ch == "(" and depth_angle == 0: + depth += 1 + elif ch == ")" and depth_angle == 0: + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def class_body_span(text: str, match: re.Match[str]) -> tuple[int, int, str] | None: + """Return (open_brace, close_brace, kind) or None for forward declarations.""" + kind = match.group(1) + tail = text[match.end() :] + semi = tail.find(";") + brace = tail.find("{") + if brace < 0 or (semi >= 0 and semi < brace): + return None + open_brace = match.end() + brace + close_brace = find_matching_brace(text, open_brace) + return open_brace, close_brace, kind + + +def extract_class_body(text: str, class_name: str) -> tuple[str, str]: + pattern = re.compile(rf"\b(class|struct)\s+{re.escape(class_name)}\b") + match = pattern.search(text) + if not match: + raise ValueError(f"class/struct '{class_name}' not found") + + span = class_body_span(text, match) + if not span: + raise ValueError(f"no body for '{class_name}' (forward declaration?)") + open_brace, close_brace, kind = span + return text[open_brace + 1 : close_brace], kind + + +def strip_leading_access(chunk: str) -> tuple[str, str | None]: + m = re.match(r"^(public|protected|private)\s*:\s*", chunk) + if not m: + return chunk, None + return chunk[m.end() :].strip(), m.group(1) + + +def collect_public_methods(class_body: str, class_name: str, kind: str) -> list[MethodDecl]: + default_public = kind == "struct" + access = "public" if default_public else "private" + methods: list[MethodDecl] = [] + + chunks = re.split(r";\s*", class_body) + for chunk in chunks: + chunk = normalize_ws(chunk) + if not chunk: + continue + + chunk, maybe_access = strip_leading_access(chunk) + if maybe_access: + access = maybe_access + if not chunk: + continue + + access_only = re.fullmatch(r"(public|protected|private)", chunk) + if access_only: + access = access_only.group(1) + continue + + if access != "public": + continue + + # Nested type forward declarations / definitions + if re.match(r"(class|struct)\s+\w+\s*$", chunk): + continue + if "{" in chunk: + continue + + method = parse_method_decl(chunk + ";", class_name) + if method: + methods.append(method) + + return methods + + +def render_method(class_name: str, method: MethodDecl, impl_var: str) -> str: + sig = f"{method.return_type} {class_name}::{method.name}{method.qualified_signature_tail}".strip() + args = method.forward_args + call = f"{impl_var}->{method.name}({args})" + + if not method.return_type or method.return_type == "void": + return f"{sig}\n{{\n {call};\n}}" + + return f"{sig}\n{{\n return {call};\n}}" + + +def default_out_path(header: Path, index: int = 0) -> Path: + stem = header.stem + return header.parent / f"{stem}_impl_{index:03d}.inl" + + +def generate_inl( + header_path: Path, + class_name: str, + impl_var: str = "m_impl", + out_path: Path | None = None, +) -> str: + raw = header_path.read_text(encoding="utf-8") + text = strip_comments(raw) + body, kind = extract_class_body(text, class_name) + methods = collect_public_methods(body, class_name, kind) + + if not methods: + raise ValueError(f"no public methods found for '{class_name}' in {header_path}") + + out_path = out_path or default_out_path(header_path) + lines = [ + f"// Generated by tools/pimpl_gen.py from {header_path.name}", + f"// Class: {class_name} impl: {impl_var}", + "// Review and merge into your .cpp; not included automatically.", + "", + ] + + for method in methods: + lines.append(render_method(class_name, method, impl_var)) + lines.append("") + + content = "\n".join(lines).rstrip() + "\n" + out_path.write_text(content, encoding="utf-8", newline="\n") + return str(out_path) + + +def list_pimpl_classes(text: str) -> list[str]: + text = strip_comments(text) + names: list[str] = [] + for match in re.finditer(r"\b(class|struct)\s+(\w+)", text): + name = match.group(2) + span = class_body_span(text, match) + if not span: + continue + open_brace, close_brace, _ = span + body = text[open_brace + 1 : close_brace] + if re.search(rf"\bstd::unique_ptr\s*<\s*Impl\s*>\s+m_impl\b", body): + names.append(name) + return names + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate PIMPL forwarding .inl stubs from a C++ header." + ) + parser.add_argument("header", type=Path, help="Interface .h file") + parser.add_argument( + "--class", + dest="class_name", + help="Class/struct name (default: first type with unique_ptr m_impl)", + ) + parser.add_argument( + "--impl-var", + default="m_impl", + help="Implementation pointer member name (default: m_impl)", + ) + parser.add_argument( + "--out", + type=Path, + help="Output .inl path (default: _impl_000.inl next to header)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List classes in the header that declare unique_ptr m_impl", + ) + args = parser.parse_args() + + if not args.header.is_file(): + print(f"error: not a file: {args.header}", file=sys.stderr) + return 1 + + raw = args.header.read_text(encoding="utf-8") + text = strip_comments(raw) + + if args.list: + for name in list_pimpl_classes(text): + print(name) + return 0 + + class_name = args.class_name + if not class_name: + candidates = list_pimpl_classes(text) + if not candidates: + print( + "error: no class with 'unique_ptr m_impl' found; pass --class", + file=sys.stderr, + ) + return 1 + if len(candidates) > 1: + print( + "error: multiple PIMPL classes found; pass --class:\n " + + "\n ".join(candidates), + file=sys.stderr, + ) + return 1 + class_name = candidates[0] + + try: + out = generate_inl(args.header, class_name, args.impl_var, args.out) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 654d2f291a51262fba6cade6b0ce24fb4101b834 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 27 Jun 2026 18:29:30 -0600 Subject: [PATCH 2/3] WIP --- CMakeLists.txt | 20 ++++- agents/issues/017-sketch-undo-stable-ids.md | 88 +++++++++++++++++++++ agents/prs/012-sketch-undo-stable-ids.md | 38 +++++++++ src/gui_settings.cpp | 9 ++- src/occt_view.cpp | 32 ++++++-- src/utl_geom.h | 41 +--------- src/utl_geom_boost.inl | 36 +++++++++ 7 files changed, 217 insertions(+), 47 deletions(-) create mode 100644 agents/issues/017-sketch-undo-stable-ids.md create mode 100644 agents/prs/012-sketch-undo-stable-ids.md create mode 100644 src/utl_geom_boost.inl diff --git a/CMakeLists.txt b/CMakeLists.txt index 07a982e..75b8475 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -652,7 +652,7 @@ source_group("web" FILES ${WEB_FILES_REL}) # Root documentation (README.md, CHANGELOG.md) and docs/ folder contents are made # visible in the IDE via the modern glob + source_group block near the end of this -# file (consistent with agents/, github-workflows, res/, scripts/). See below. +# file (consistent with agents/, github-workflows, res/, scripts/, tools/). See below. # Add Google Test only for non-Emscripten builds if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") @@ -735,6 +735,22 @@ if(EZYCAD_SCRIPT_FILES) set_source_files_properties(${EZYCAD_SCRIPT_FILES_REL} PROPERTIES HEADER_FILE_ONLY TRUE) endif() +# Dev tools: show tools/ in the IDE (codegen, asset helpers; not compiled) +file(GLOB EZYCAD_TOOL_FILES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/tools/*.py" + "${CMAKE_SOURCE_DIR}/tools/*.ps1") +if(EZYCAD_TOOL_FILES) + source_group(TREE "${CMAKE_SOURCE_DIR}/tools" PREFIX "tools" FILES ${EZYCAD_TOOL_FILES}) + set(EZYCAD_TOOL_FILES_REL) + foreach(f ${EZYCAD_TOOL_FILES}) + file(RELATIVE_PATH f_rel "${CMAKE_SOURCE_DIR}" "${f}") + list(APPEND EZYCAD_TOOL_FILES_REL "${f_rel}") + endforeach() + target_sources(${PROJECT_NAME}_lib PRIVATE ${EZYCAD_TOOL_FILES_REL}) + target_sources(${PROJECT_NAME} PRIVATE ${EZYCAD_TOOL_FILES_REL}) + set_source_files_properties(${EZYCAD_TOOL_FILES_REL} PROPERTIES HEADER_FILE_ONLY 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 @@ -772,7 +788,7 @@ endif() # Documentation folder: show docs/ (and the moved usage guides, style guides, building-occt.md etc.) # in the IDE under a 'docs' folder for both the library and executable targets. -# (non-compiled; uses the same pattern as agents/, github-workflows, res/, scripts/) +# (non-compiled; uses the same pattern as agents/, github-workflows, res/, scripts/, tools/) file(GLOB_RECURSE EZYCAD_DOCS_FILES CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/docs/*") diff --git a/agents/issues/017-sketch-undo-stable-ids.md b/agents/issues/017-sketch-undo-stable-ids.md new file mode 100644 index 0000000..c924b18 --- /dev/null +++ b/agents/issues/017-sketch-undo-stable-ids.md @@ -0,0 +1,88 @@ +# Sketch undo: stable ids, operation-axis undo, stack budget + +**Suggested labels:** `bug`, `sketch`, `enhancement` + +**Branch:** `Trailcode/sketch-undo-stable-ids` (base: `main`) + +--- + +## Title (GitHub) + +Sketch undo: stable sketch ids for delta resolve, operation-axis undo, and undo stack byte budget + +## Body (GitHub) + +### Summary + +Harden sketch delta undo/redo on branch **`Trailcode/sketch-undo-stable-ids`**: assign each sketch a stable persisted **id** for `Sketch_delta` lookup (display names may duplicate), fix operation-axis undo/clear and OCCT handle teardown, add undo-stack hygiene (entry kind + byte budget), and regression tests including a GUI-driven undo/redo path. + +### Problem + +- **Release crash on redo:** `Sketch_delta` cached a raw `Sketch*` and returned it without validation; after JSON reload or sketch recreation the pointer was dangling (`add_edge_impl_` read access violation). +- **Name-based resolve was fragile:** duplicate sketch names (rename UI, `"Sketch from face"`, load) could target the wrong sketch silently. +- **Operation axis undo incomplete:** Clear axis was not undoable; redo-after-clear could leave a stale axis; OCCT `Standard_ProgramError` when clearing axis if AIS handle was not nulled after `Remove`. +- **Undo stack memory:** 50 full JSON snapshots could dominate memory; no byte budget or debug footprint summary. + +### Implemented scope + +**Stable sketch ids** + +- `Sketch::get_id()` / `uint64_t m_id`; allocated via `Occt_view::allocate_sketch_id()`. +- Persisted in sketch JSON as `"id"`; backward compatible when field missing. +- `Sketch_delta` resolves target sketch by **id**, not name. + +**Operation axis undo** + +- `clear_operation_axis_undoable()` for Options **Clear axis**; records prev axis in delta. +- `finalize_operation_axis_` records axis endpoint nodes for undo tombstone. +- `apply_forward_` clears axis when redoing a clear step. +- `clear_operation_axis()`: `Nullify()` AIS handle after `Remove`. + +**Undo stack hygiene** + +- `Undo_entry_kind`: `Sketch_delta` vs `Document_snapshot`. +- `k_max_undo_bytes` (32 MB): trim oldest document snapshots first when over budget. +- Debug panel shows undo/redo step count and total MB (Debug builds only). + +**Tests** + +- `Gui_UndoRedo_edge_then_operation_axis_four_undo_two_redo` (GUI path). +- `Undo_delta_resolves_sketch_by_id_after_reload`. +- Existing undo tests use `curr_sketch()` (document-owned sketches). + +### Acceptance criteria + +- [ ] Add edge + operation axis, undo/redo repeatedly — no crash (Release). +- [ ] Clear axis is undoable; redo clear works. +- [ ] JSON save/load preserves sketch `id`; redo after reload applies to correct sketch. +- [ ] Duplicate sketch display names do not break delta undo. +- [ ] `EzyCad_tests`: `Sketch_test.Undo*`, `Sketch_test.Gui_UndoRedo*`. + +### Files touched + +- `src/delta.h`, `src/sketch.h`, `src/sketch.cpp`, `src/sketch_delta.h`, `src/sketch_delta.cpp`, `src/sketch_json.cpp` +- `src/occt_view.h`, `src/occt_view.cpp`, `src/gui.cpp`, `src/gui_mode.cpp` +- `tests/sketch_tests.cpp`, `docs/bugs.md` +- `agents/issues/017-sketch-undo-stable-ids.md`, `agents/prs/012-sketch-undo-stable-ids.md` + +### Related + +- Builds on sketch delta undo (#144 / #145). +- Compare: https://github.com/trailcode/EzyCad/compare/main...Trailcode/sketch-undo-stable-ids +- GitHub issue: https://github.com/trailcode/EzyCad/issues/150 +- GitHub PR: https://github.com/trailcode/EzyCad/pull/151 + +### Test plan + +- [ ] Build Release: `EzyCad`, `EzyCad_tests`. +- [ ] Run: `EzyCad_tests.exe --gtest_filter=Sketch_test.Undo*:Sketch_test.Gui_UndoRedo*` +- [ ] Manual: edge + operation axis; undo 4x, redo 2x (former Release crash). +- [ ] Manual: Clear axis → undo → redo. +- [ ] Manual: save `.ezy`, reload, redo sketch delta step. + +--- + +**Post-creation notes:** + +- GitHub issue: https://github.com/trailcode/EzyCad/issues/150 +- GitHub PR: https://github.com/trailcode/EzyCad/pull/151 diff --git a/agents/prs/012-sketch-undo-stable-ids.md b/agents/prs/012-sketch-undo-stable-ids.md new file mode 100644 index 0000000..8935668 --- /dev/null +++ b/agents/prs/012-sketch-undo-stable-ids.md @@ -0,0 +1,38 @@ +# PR - `Trailcode/sketch-undo-stable-ids` + +## Title + +Sketch undo: stable sketch ids for delta resolve, operation-axis undo, and undo stack byte budget + +## Summary + +- **Stable sketch ids:** Each sketch gets a monotonic `uint64_t` id (persisted in JSON). `Sketch_delta` resolves the target sketch by id, not display name — fixes Release redo crash from dangling `Sketch*` after reload/recreation. +- **Operation axis undo:** `clear_operation_axis_undoable()` for Options **Clear axis**; axis node recording on finalize; redo-clear via `apply_forward_`. OCCT handle `Nullify()` after `Remove` in `clear_operation_axis()`. +- **Undo stack hygiene:** `Undo_entry_kind`, per-entry `footprint_bytes`, 32 MB cap trimming oldest document snapshots first; debug panel shows stack MB (Debug builds). +- **Tests:** GUI undo/redo regression; reload-by-id redo test; undo tests on `curr_sketch()`. + +Closes sketch undo stability work tracked in `agents/issues/017-sketch-undo-stable-ids.md`. + +## Files Changed + +12 files (+325 / -64 vs `main`): `src/sketch*.cpp/h`, `src/sketch_delta.*`, `src/sketch_json.cpp`, `src/occt_view.*`, `src/delta.h`, `src/gui.cpp`, `src/gui_mode.cpp`, `tests/sketch_tests.cpp`, `docs/bugs.md`. + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/150 +- PR: https://github.com/trailcode/EzyCad/pull/151 +- Compare: https://github.com/trailcode/EzyCad/compare/main...Trailcode/sketch-undo-stable-ids +- Prior: #145 sketch delta undo + +## Test Plan + +- [x] Build Release: `EzyCad_tests`. +- [x] Run: `EzyCad_tests.exe --gtest_filter=Sketch_test.Undo*:Sketch_test.Gui_UndoRedo*` +- [ ] Manual: edge + operation axis; undo 4x, redo 2x (Release — former crash). +- [ ] Manual: Clear axis → undo → redo. +- [ ] Manual: save document, reload, redo sketch delta. + +## Notes + +- Old `.ezy` files without `"id"` allocate new ids on load (backward compatible). +- `approximate_undo_bytes()` is internal; user sees summed MB only in Debug **dbg** window. diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index d52455c..d645504 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -848,7 +848,9 @@ void GUI::settings_() ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Grid step"); ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##gstep", ImGuiDataType_Double, &step_ui, spd_s, nullptr, nullptr, "%.8g")) + const double step_min_ui = + (std::max(gr.graphic_x_size, gr.graphic_y_size) * 2.0 / 512.0) / dim_scale; + if (ImGui::DragScalar("##gstep", ImGuiDataType_Double, &step_ui, spd_s, &step_min_ui, nullptr, "%.8g")) geom_changed = true; ImGui::TableNextRow(); @@ -894,6 +896,11 @@ void GUI::settings_() gr.graphic_y_size = graphic_y_ui * dim_scale * 0.5; gr.graphic_z_offset = graphic_z_off_ui * dim_scale; m_view->set_occt_grid_rect_params(gr); + m_view->get_occt_grid_rect_params(gr); + step_ui = gr.step / dim_scale; + graphic_x_ui = (gr.graphic_x_size * 2.0) / dim_scale; + graphic_y_ui = (gr.graphic_y_size * 2.0) / dim_scale; + graphic_z_off_ui = gr.graphic_z_offset / dim_scale; } if (grid_changed || geom_changed) save_occt_view_settings(); diff --git a/src/occt_view.cpp b/src/occt_view.cpp index 3455b36..a18511a 100644 --- a/src/occt_view.cpp +++ b/src/occt_view.cpp @@ -1235,11 +1235,31 @@ void Occt_view::set_grid_colors(float r1, float g1, float b1, float r2, float g2 Occt_grid_rect_params Occt_view::clamp_occt_grid_rect_params_(Occt_grid_rect_params g) { - constexpr double min_step = 1e-9; - constexpr double min_graphic = 1e-6; - g.step = std::max(g.step, min_step); - g.graphic_x_size = std::max(g.graphic_x_size, min_graphic); - g.graphic_y_size = std::max(g.graphic_y_size, min_graphic); + // OCCT CPU grid rebuilds O(extent/step) line segments; tiny step crashes UpdateDisplay(). + constexpr int max_lines_per_axis = 512; + constexpr double min_graphic = 1e-6; + constexpr double default_step = 10.0; + constexpr double default_half_ext = 1000.0; + + if (!std::isfinite(g.graphic_x_size) || g.graphic_x_size <= 0.0) + g.graphic_x_size = default_half_ext; + if (!std::isfinite(g.graphic_y_size) || g.graphic_y_size <= 0.0) + g.graphic_y_size = default_half_ext; + if (!std::isfinite(g.graphic_z_offset)) + g.graphic_z_offset = 0.0; + + g.graphic_x_size = std::max(g.graphic_x_size, min_graphic); + g.graphic_y_size = std::max(g.graphic_y_size, min_graphic); + + if (!std::isfinite(g.step) || g.step <= 0.0) + g.step = default_step; + + const double full_x = g.graphic_x_size * 2.0; + const double full_y = g.graphic_y_size * 2.0; + const double min_step_x = full_x / static_cast(max_lines_per_axis); + const double min_step_y = full_y / static_cast(max_lines_per_axis); + g.step = std::max(g.step, std::max(min_step_x, min_step_y)); + return g; } @@ -1356,7 +1376,7 @@ void Occt_view::apply_occt_grid_rect_to_viewer_() m_occt_grid_rect = g; Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull() || viewer->Grid().IsNull()) + if (viewer.IsNull() || !viewer->IsGridActive() || viewer->Grid().IsNull()) return; Handle(Aspect_Grid) ag = viewer->Grid(); diff --git a/src/utl_geom.h b/src/utl_geom.h index a7c8e0e..764f7c5 100644 --- a/src/utl_geom.h +++ b/src/utl_geom.h @@ -1,9 +1,9 @@ #pragma once -#include +//#include #include #include -#include +//#include #include #include #include @@ -24,42 +24,7 @@ class TopoDS_Face; class TopoDS_Shape; class Geom_TrimmedCurve; -// Pure C++ implementation of the polygon/ring functionality (for OCCT face to -// 2D polygon conversion, shoelace-based clockwise/area checks, and basic WKT -// output for tests). No external geometry library dependency. -namespace ezy_geom -{ -struct point_2d -{ - double x_ = 0.0, y_ = 0.0; - double x() const { return x_; } - double y() const { return y_; } - point_2d() = default; - point_2d(double xx, double yy) - : x_(xx) - , y_(yy) - { - } -}; - -using ring_2d = std::vector; - -struct polygon_2d -{ - ring_2d outer_; - std::vector inners_; - ring_2d& outer() { return outer_; } - const ring_2d& outer() const { return outer_; } - std::vector& inners() { return inners_; } - const std::vector& inners() const { return inners_; } -}; - -// Basic validity (the OCCT construction code ensures well-formed polygons). -bool is_valid(const polygon_2d& poly); - -// Shoelace area (outer minus holes). -double area(const polygon_2d& poly); -} // namespace ezy_geom +#include "utl_geom_boost.inl" gp_Pnt2d to_pnt2d(const ezy_geom::point_2d& pt); ezy_geom::point_2d to_boost(const gp_Pln& plane, const gp_Pnt& point_3d); diff --git a/src/utl_geom_boost.inl b/src/utl_geom_boost.inl new file mode 100644 index 0000000..b8d87d5 --- /dev/null +++ b/src/utl_geom_boost.inl @@ -0,0 +1,36 @@ +// Pure C++ implementation of the polygon/ring functionality (for OCCT face to +// 2D polygon conversion, shoelace-based clockwise/area checks, and basic WKT +// output for tests). No external geometry library dependency. +namespace ezy_geom +{ +struct point_2d +{ + double x_ = 0.0, y_ = 0.0; + double x() const { return x_; } + double y() const { return y_; } + point_2d() = default; + point_2d(double xx, double yy) + : x_(xx) + , y_(yy) + { + } +}; + +using ring_2d = std::vector; + +struct polygon_2d +{ + ring_2d outer_; + std::vector inners_; + ring_2d& outer() { return outer_; } + const ring_2d& outer() const { return outer_; } + std::vector& inners() { return inners_; } + const std::vector& inners() const { return inners_; } +}; + +// Basic validity (the OCCT construction code ensures well-formed polygons). +bool is_valid(const polygon_2d& poly); + +// Shoelace area (outer minus holes). +double area(const polygon_2d& poly); +} // namespace ezy_geom From bf6aea96c0a5a4411a4ef80b70e6731a6b70baee Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 27 Jun 2026 19:02:41 -0600 Subject: [PATCH 3/3] wip --- docs/usage-settings.md | 7 +- res/ezycad_settings.json | 3 +- src/gui_settings.cpp | 51 +++----- src/occt_view.cpp | 259 ++++++++++++++++++++++++++------------- src/occt_view.h | 19 ++- src/sketch.cpp | 3 + 6 files changed, 213 insertions(+), 129 deletions(-) diff --git a/docs/usage-settings.md b/docs/usage-settings.md index d349280..e312a22 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -47,7 +47,7 @@ Between those, the pane has **six** collapsible sections. Expand a section to se 3. **View presentation** — **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**. **Element hover color** — highlight for rows hovered in the **Shape List** or **Sketch List** (**Dimensions** table); stored as **`gui.elm_list_hover_color`**. -4. **3D view grid** — **Show grid** (checkbox; grid is drawn on the **active sketch** plane). **Fine grid lines** and **Major grid lines** (passed to Open CASCADE `Aspect_Grid::SetColors`: dense lines vs every-tenth emphasis lines). **Grid step**, **Grid extent X / Y** (full span edge-to-edge), and **Grid display Z offset** in the Settings pane use the **same length scale as sketch length dimensions** (display value = model value / internal `dimension_scale`, default **100**). Saved JSON (`occt_view`) stores **half-extent** in model units for OCCT (`grid_graphic_*`); Settings shows **full** extent (twice the stored half-extent). +4. **3D view grid** — **Show grid** (checkbox; grid is drawn on the **active sketch** plane, behind coplanar geometry). **Fine grid lines** and **Major grid lines** (dense lines vs every-tenth emphasis). **Grid step**, **Grid padding** (margin around sketch content when sizing the grid), and **Grid display Z offset** in the Settings pane use the **same length scale as sketch length dimensions** (display value = model value / internal `dimension_scale`, default **100**). Saved JSON (`occt_view`) stores padding in model units (`grid_padding`). 5. **Sketch** — Expand **Dimensions** (nested, open by default) for length-dimension appearance and behavior (most rows have **?** help). Other sketch rows stay in the parent **Sketch** section: - **Dimension line width** — slider **0.5** to **8.0** @@ -148,9 +148,8 @@ String: ImGui `.ini` text for window positions and docking saved with **SaveIniS | `grid_color1` | array of 3 numbers | Fine (dense) grid lines (`Aspect_Grid` main color). | | `grid_color2` | array of 3 numbers | Major (sparse / every-tenth) grid lines (`Aspect_Grid` tenth-line color). | | `grid_visible` | boolean | When **true**, the OCCT reference grid is drawn in the 3D view (default **true**). | -| `grid_step` | number | Grid line spacing in **model** units (`Aspect_RectangularGrid`; default **10**). Legacy `grid_x_step` / `grid_y_step` load as this value (X preferred). | -| `grid_graphic_x_size` | number | Grid half-extent on X in **model** units (OCCT `SetGraphicValues`; default **1000**). Settings **Grid extent X** shows full span (**20** with those defaults). | -| `grid_graphic_y_size` | number | Grid half-extent on Y in **model** units. Settings **Grid extent Y** shows full span. | +| `grid_step` | number | Grid line spacing in **model** units (default **10**). Legacy `grid_x_step` / `grid_y_step` load as this value (X preferred). | +| `grid_padding` | number | Margin around active sketch content when sizing the grid, in **model** units (default **1000**; Settings shows display units). Legacy `grid_graphic_x_size` loads as padding if `grid_padding` is absent. | | `grid_graphic_z_offset` | number | Grid plane offset along Z in **model** units. | ### `gui` diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index bf24d0b..ea7933c 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -74,8 +74,7 @@ 0.11791712790727615, 0.13513511419296265 ], - "grid_graphic_x_size": 1000, - "grid_graphic_y_size": 1000, + "grid_padding": 1000, "grid_graphic_z_offset": 0, "grid_step": 10, "grid_visible": true diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index d645504..5e81d80 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -38,8 +38,7 @@ nlohmann::json build_occt_view_settings_object(const Occt_view& view) {"grid_color1", {g1[0], g1[1], g1[2]}}, {"grid_color2", {g2[0], g2[1], g2[2]}}, {"grid_step", grid_rect.step}, - {"grid_graphic_x_size", grid_rect.graphic_x_size}, - {"grid_graphic_y_size", grid_rect.graphic_y_size}, + {"grid_padding", grid_rect.grid_padding}, {"grid_graphic_z_offset", grid_rect.graphic_z_offset}, {"grid_visible", view.get_grid_visible()}, }; @@ -228,16 +227,20 @@ void GUI::parse_occt_view_settings_(const std::string& content) else if (ov.contains("grid_y_step")) apply_num("grid_y_step", grid_rect.step); } - apply_num("grid_graphic_x_size", grid_rect.graphic_x_size); - apply_num("grid_graphic_y_size", grid_rect.graphic_y_size); + apply_num("grid_padding", grid_rect.grid_padding); + if (!ov.contains("grid_padding") && ov.contains("grid_graphic_x_size")) + { + // Legacy: treat old half-extent as padding margin around sketch content. + apply_num("grid_graphic_x_size", grid_rect.grid_padding); + } apply_num("grid_graphic_z_offset", grid_rect.graphic_z_offset); m_view->set_occt_grid_rect_params(grid_rect); bool grid_visible = m_view->get_grid_visible(); if (ov.contains("grid_visible") && ov["grid_visible"].is_boolean()) grid_visible = ov["grid_visible"].get(); - // Always apply: default m_grid_visible may already match JSON, but the OCCT grid - // is not activated until apply_grid_visibility_() runs. + // Always apply: default m_grid_visible may already match JSON, but the shader grid + // is not displayed until apply_grid_visibility_() runs. m_view->set_grid_visible(grid_visible); } catch (...) @@ -796,10 +799,8 @@ void GUI::settings_() m_view->get_occt_grid_rect_params(gr); const double dim_scale = m_view->get_dimension_scale(); // Settings show the same length units as sketch dimensions (model / dimension_scale). - // Grid extent UI is full span; OCCT SetGraphicValues uses half-extent (x0.5 on apply). double step_ui = gr.step / dim_scale; - double graphic_x_ui = (gr.graphic_x_size * 2.0) / dim_scale; - double graphic_y_ui = (gr.graphic_y_size * 2.0) / dim_scale; + double padding_ui = gr.grid_padding / dim_scale; double graphic_z_off_ui = gr.graphic_z_offset / dim_scale; bool grid_changed = false; bool geom_changed = false; @@ -848,33 +849,23 @@ void GUI::settings_() ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Grid step"); ImGui::TableSetColumnIndex(1); - const double step_min_ui = - (std::max(gr.graphic_x_size, gr.graphic_y_size) * 2.0 / 512.0) / dim_scale; + // Step must stay positive; grid extent is derived from sketch bounds + padding. + const double step_min_ui = 1e-6; if (ImGui::DragScalar("##gstep", ImGuiDataType_Double, &step_ui, spd_s, &step_min_ui, nullptr, "%.8g")) geom_changed = true; ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Grid extent X"); - ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##ggx", ImGuiDataType_Double, &graphic_x_ui, spd_extent, nullptr, nullptr, "%.8g")) - geom_changed = true; - ImGui::SameLine(0.f, ImGui::GetStyle().ItemInnerSpacing.x); - GUI_DOC_HELP_("Full width of the drawn grid on X (edge to edge through the center). Same length scale as sketch " - "dimensions. OCCT uses half this value internally. Click ? to open the user guide.", - doc_urls::k_occt_view); - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Grid extent Y"); + ImGui::TextUnformatted("Grid padding"); ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##ggy", ImGuiDataType_Double, &graphic_y_ui, spd_extent, nullptr, nullptr, "%.8g")) + const double padding_min_ui = 0.0; + if (ImGui::DragScalar("##gpad", ImGuiDataType_Double, &padding_ui, spd_extent, &padding_min_ui, nullptr, "%.8g")) geom_changed = true; ImGui::SameLine(0.f, ImGui::GetStyle().ItemInnerSpacing.x); - GUI_DOC_HELP_("Full height of the drawn grid on Y (edge to edge through the center). OCCT uses half this value " - "internally. Click ? to open the user guide.", + GUI_DOC_HELP_("Margin added around the active sketch when sizing the grid (same length scale as sketch " + "dimensions). The grid extent follows sketch geometry plus this padding. Click ? to open the user " + "guide.", doc_urls::k_occt_view); ImGui::TableNextRow(); @@ -892,14 +883,12 @@ void GUI::settings_() if (geom_changed) { gr.step = step_ui * dim_scale; - gr.graphic_x_size = graphic_x_ui * dim_scale * 0.5; - gr.graphic_y_size = graphic_y_ui * dim_scale * 0.5; + gr.grid_padding = padding_ui * dim_scale; gr.graphic_z_offset = graphic_z_off_ui * dim_scale; m_view->set_occt_grid_rect_params(gr); m_view->get_occt_grid_rect_params(gr); step_ui = gr.step / dim_scale; - graphic_x_ui = (gr.graphic_x_size * 2.0) / dim_scale; - graphic_y_ui = (gr.graphic_y_size * 2.0) / dim_scale; + padding_ui = gr.grid_padding / dim_scale; graphic_z_off_ui = gr.graphic_z_offset / dim_scale; } if (grid_changed || geom_changed) diff --git a/src/occt_view.cpp b/src/occt_view.cpp index a18511a..0aa340e 100644 --- a/src/occt_view.cpp +++ b/src/occt_view.cpp @@ -3,8 +3,8 @@ #include #include #include +#include #include -#include #include #include #include @@ -221,7 +221,7 @@ void Occt_view::init_viewer() // m_view->SetScale(39.3701); capture_occt_grid_rect_from_viewer_(aViewer); - // Grid visibility/colors set in apply_grid_visibility_() / update_view_background_() + // Grid visibility/colors: shader background grid via apply_grid_visibility_() / apply_shader_grid_display_() // aViewer->SetGridEcho(true); // aViewer->Grid()->SetDrawMode(Aspect_GridDrawMode::Aspect_GDM_Points); @@ -1163,6 +1163,130 @@ void Occt_view::delete_(std::vector& to_delete) remove(to_delete); } +namespace +{ + +Aspect_GridParams build_shader_grid_params_(const Occt_grid_rect_params& g, const glm::vec3& color1, const glm::vec3& color2, + const double size_x, const double size_y) +{ + Aspect_GridParams params; + const double step = g.step > 0.0 ? g.step : 10.0; + params.SetColor(Quantity_Color(color1.x, color1.y, color1.z, Quantity_TOC_RGB)); + params.SetAccentColor(Quantity_Color(color2.x, color2.y, color2.z, Quantity_TOC_RGB)); + + // Fixed metric spacing (cells per model unit). Every 10th line uses the accent color. + params.SetScale(1.0 / step); + params.SetScaleY(0.0); // isotropic (mirror Scale) + params.SetAccentScaleX(1.0 / (step * 10.0)); + params.SetAccentScaleY(1.0 / (step * 10.0)); + + // Bounded grid on the sketch plane (full extent along each axis; OCCT uses half in the shader). + params.SetSizeX(size_x); + params.SetSizeY(size_y); + + // Overlay on the sketch plane (not view-space background). Slight negative offset along the + // plane normal so coplanar sketch geometry wins the depth test (OCCT default is step / 50). + params.SetZOffset(g.graphic_z_offset - step / 50.0); + params.SetDrawMode(Aspect_GDM_Lines); + params.SetIsBackground(false); + params.SetIsDrawAxis(false); + params.SetIsViewAdaptive(false); + return params; +} + +} // namespace + +Occt_view::Shader_grid_layout Occt_view::compute_shader_grid_layout_() const +{ + Shader_grid_layout layout{}; + const gp_Pln ref_pln = m_cur_sketch ? m_cur_sketch->get_plane() : gp_Pln(grid_display_plane_()); + const gp_Ax3 base_ax = ref_pln.Position(); + + const double step = m_occt_grid_rect.step > 0.0 ? m_occt_grid_rect.step : 10.0; + const double pad = m_occt_grid_rect.grid_padding >= 0.0 ? m_occt_grid_rect.grid_padding : 0.0; + + auto expand_pt = [](double& min_u, double& min_v, double& max_u, double& max_v, bool& any, const gp_Pnt2d& pt) + { + const double u = pt.X(); + const double v = pt.Y(); + if (!any) + { + min_u = max_u = u; + min_v = max_v = v; + any = true; + } + else + { + min_u = std::min(min_u, u); + max_u = std::max(max_u, u); + min_v = std::min(min_v, v); + max_v = std::max(max_v, v); + } + }; + + bool has_bounds = false; + double min_u = 0., min_v = 0., max_u = 0., max_v = 0.; + + if (m_cur_sketch) + { + for (const Sketch_nodes::Node& node : m_cur_sketch->get_nodes()) + { + if (node.deleted) + continue; + expand_pt(min_u, min_v, max_u, max_v, has_bounds, node); + } + } + + if (!has_bounds && !is_headless()) + { + const ImGuiIO& io = ImGui::GetIO(); + if (sketch_plane_view_aabb_2d(ref_pln, static_cast(io.DisplaySize.x), static_cast(io.DisplaySize.y), + min_u, min_v, max_u, max_v)) + has_bounds = true; + } + + if (!has_bounds) + { + const double half = std::max(step * 5.0, 1.0); + min_u = -half; + max_u = half; + min_v = -half; + max_v = half; + } + + min_u -= pad; + max_u += pad; + min_v -= pad; + max_v += pad; + + // Snap outer bounds to grid lines so edges align with major/minor spacing. + min_u = std::floor(min_u / step) * step; + max_u = std::ceil(max_u / step) * step; + min_v = std::floor(min_v / step) * step; + max_v = std::ceil(max_v / step) * step; + + constexpr double k_min_span = 1e-6; + if (max_u - min_u < k_min_span) + { + min_u -= step; + max_u += step; + } + if (max_v - min_v < k_min_span) + { + min_v -= step; + max_v += step; + } + + const double center_u = (min_u + max_u) * 0.5; + const double center_v = (min_v + max_v) * 0.5; + layout.size_x = max_u - min_u; + layout.size_y = max_v - min_v; + + const gp_Pnt center_3d = to_3d(ref_pln, gp_Pnt2d(center_u, center_v)); + layout.plane = gp_Ax3(center_3d, base_ax.Direction(), base_ax.XDirection()); + return layout; +} + static Aspect_GradientFillMethod gradient_method_from_int(int i) { static const Aspect_GradientFillMethod methods[] = {Aspect_GFM_HOR, Aspect_GFM_VER, Aspect_GFM_DIAG1, @@ -1182,14 +1306,6 @@ void Occt_view::update_view_background_() m_view->SetBgGradientColors(Quantity_Color(m_bg_color1.x, m_bg_color1.y, m_bg_color1.z, Quantity_TOC_RGB), Quantity_Color(m_bg_color2.x, m_bg_color2.y, m_bg_color2.z, Quantity_TOC_RGB), gradient_method_from_int(m_bg_gradient_method), true); - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (!viewer.IsNull() && !viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } } void Occt_view::get_bg_gradient_colors(float color1[3], float color2[3]) const @@ -1235,30 +1351,22 @@ void Occt_view::set_grid_colors(float r1, float g1, float b1, float r2, float g2 Occt_grid_rect_params Occt_view::clamp_occt_grid_rect_params_(Occt_grid_rect_params g) { - // OCCT CPU grid rebuilds O(extent/step) line segments; tiny step crashes UpdateDisplay(). - constexpr int max_lines_per_axis = 512; - constexpr double min_graphic = 1e-6; - constexpr double default_step = 10.0; - constexpr double default_half_ext = 1000.0; + constexpr double min_padding = 0.0; + constexpr double min_step = 1e-6; + constexpr double default_step = 10.0; + constexpr double default_padding = 1000.0; - if (!std::isfinite(g.graphic_x_size) || g.graphic_x_size <= 0.0) - g.graphic_x_size = default_half_ext; - if (!std::isfinite(g.graphic_y_size) || g.graphic_y_size <= 0.0) - g.graphic_y_size = default_half_ext; + 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; - g.graphic_x_size = std::max(g.graphic_x_size, min_graphic); - g.graphic_y_size = std::max(g.graphic_y_size, min_graphic); + g.grid_padding = std::max(g.grid_padding, min_padding); if (!std::isfinite(g.step) || g.step <= 0.0) g.step = default_step; - const double full_x = g.graphic_x_size * 2.0; - const double full_y = g.graphic_y_size * 2.0; - const double min_step_x = full_x / static_cast(max_lines_per_axis); - const double min_step_y = full_y / static_cast(max_lines_per_axis); - g.step = std::max(g.step, std::max(min_step_x, min_step_y)); + g.step = std::max(g.step, min_step); return g; } @@ -1270,22 +1378,42 @@ void Occt_view::capture_occt_grid_rect_from_viewer_(const V3d_Viewer_ptr& viewer Handle(Aspect_Grid) ag = viewer->Grid(); Handle(Aspect_RectangularGrid) rg = Handle(Aspect_RectangularGrid)::DownCast(ag); - Handle(V3d_RectangularGrid) vrg = Handle(V3d_RectangularGrid)::DownCast(ag); if (rg.IsNull()) return; m_occt_grid_rect.step = rg->XStep(); + m_occt_grid_rect.graphic_z_offset = rg->ZOffset(); +} - if (!vrg.IsNull()) +gp_Ax3 Occt_view::grid_display_plane_() const +{ + if (m_cur_sketch) + return m_cur_sketch->get_plane().Position(); + + if (!m_view.IsNull()) { - double gx{}, gy{}, gz{}; - vrg->GraphicValues(gx, gy, gz); - m_occt_grid_rect.graphic_x_size = gx; - m_occt_grid_rect.graphic_y_size = gy; - m_occt_grid_rect.graphic_z_offset = gz; + Handle(V3d_Viewer) viewer = m_view->Viewer(); + if (!viewer.IsNull()) + return viewer->PrivilegedPlane(); } + + return gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)); } +void Occt_view::apply_shader_grid_display_() +{ + if (is_headless() || m_view.IsNull() || !m_grid_visible) + return; + + const Shader_grid_layout layout = compute_shader_grid_layout_(); + const Aspect_GridParams params = + build_shader_grid_params_(m_occt_grid_rect, m_grid_color1, m_grid_color2, layout.size_x, layout.size_y); + m_view->GridDisplay(params, layout.plane); + m_view->Invalidate(); +} + +void Occt_view::refresh_active_sketch_grid() { refresh_viewer_grid_(); } + bool Occt_view::get_grid_visible() const { return m_grid_visible; } void Occt_view::set_grid_visible(const bool visible) @@ -1312,22 +1440,7 @@ void Occt_view::refresh_viewer_grid_() return; sync_grid_plane_to_active_sketch_(); - if (!m_grid_visible) - return; - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull() || !viewer->IsGridActive()) - return; - apply_occt_grid_rect_to_viewer_(); - if (!viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } - - m_view->Invalidate(); m_view->Redraw(); } @@ -1336,30 +1449,15 @@ void Occt_view::apply_grid_visibility_() if (is_headless() || m_view.IsNull()) return; - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull()) - return; - if (m_grid_visible) { sync_grid_plane_to_active_sketch_(); - if (!viewer->IsGridActive()) - { - viewer->ActivateGrid(Aspect_GT_Rectangular, Aspect_GDM_Lines); - viewer->SetGridEcho(false); - } - - apply_occt_grid_rect_to_viewer_(); - - if (!viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } + apply_shader_grid_display_(); + } + else + { + m_view->GridErase(); } - else if (viewer->IsGridActive()) - viewer->DeactivateGrid(); m_view->Invalidate(); m_view->Redraw(); @@ -1372,27 +1470,11 @@ void Occt_view::apply_occt_grid_rect_to_viewer_() sync_grid_plane_to_active_sketch_(); - Occt_grid_rect_params g = clamp_occt_grid_rect_params_(m_occt_grid_rect); - m_occt_grid_rect = g; - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull() || !viewer->IsGridActive() || viewer->Grid().IsNull()) - return; - - Handle(Aspect_Grid) ag = viewer->Grid(); - Handle(Aspect_RectangularGrid) rg = Handle(Aspect_RectangularGrid)::DownCast(ag); - Handle(V3d_RectangularGrid) vrg = Handle(V3d_RectangularGrid)::DownCast(ag); - if (rg.IsNull()) - return; - - rg->SetGridValues(0., 0., static_cast(m_occt_grid_rect.step), static_cast(m_occt_grid_rect.step), 0.); + m_occt_grid_rect = clamp_occt_grid_rect_params_(m_occt_grid_rect); - if (!vrg.IsNull()) - vrg->SetGraphicValues(static_cast(m_occt_grid_rect.graphic_x_size), - static_cast(m_occt_grid_rect.graphic_y_size), - static_cast(m_occt_grid_rect.graphic_z_offset)); - - m_view->Invalidate(); + // EzyCad does its own sketch snapping, so the legacy CPU grid (V3d_Viewer::ActivateGrid) is not + // needed. Drive the GL shader grid directly from our params - this is the OCCT 8 GPU grid path. + apply_shader_grid_display_(); } void Occt_view::get_occt_grid_rect_params(Occt_grid_rect_params& out) const { out = m_occt_grid_rect; } @@ -2385,5 +2467,6 @@ void Occt_view::new_file() clear_all(m_shps, m_sketches, m_cur_sketch); create_default_sketch_(); + refresh_viewer_grid_(); m_gui.set_mode(Mode::Normal); } \ No newline at end of file diff --git a/src/occt_view.h b/src/occt_view.h index c2537b0..606d5b0 100644 --- a/src/occt_view.h +++ b/src/occt_view.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -57,13 +58,12 @@ enum class Set_parent_mode No }; -/// Persisted rectangular OCCT viewer grid geometry (same units as the model scene; uniform line step and V3d graphic -/// half-extent on X/Y for OCCT). Settings UI edits full extent (2x these values). Origin and rotation stay at 0. +/// Persisted OCCT viewer grid geometry (model units). The drawn grid is sized to the active sketch bounds plus +/// `grid_padding`; extent is not stored manually. struct Occt_grid_rect_params { double step{10.}; - double graphic_x_size{1000.}; - double graphic_y_size{1000.}; + double grid_padding{1000.}; double graphic_z_offset{}; }; @@ -259,6 +259,8 @@ class Occt_view : protected AIS_ViewController void set_occt_grid_rect_params(const Occt_grid_rect_params& p); bool get_grid_visible() const; void set_grid_visible(bool visible); + /// Recompute grid bounds from the active sketch (call after sketch geometry changes). + void refresh_active_sketch_grid(); bool is_headless() const; @@ -298,7 +300,16 @@ class Occt_view : protected AIS_ViewController void sync_grid_plane_to_active_sketch_(); void refresh_viewer_grid_(); void apply_occt_grid_rect_to_viewer_(); + void apply_shader_grid_display_(); void apply_grid_visibility_(); + struct Shader_grid_layout + { + gp_Ax3 plane; + double size_x{0.}; + double size_y{0.}; + }; + [[nodiscard]] Shader_grid_layout compute_shader_grid_layout_() const; + [[nodiscard]] gp_Ax3 grid_display_plane_() const; //! GLFW callback redirecting messages into Message::DefaultMessenger(). // static void errorCallback(int theError, const char* theDescription); diff --git a/src/sketch.cpp b/src/sketch.cpp index 667e86a..1181f1b 100644 --- a/src/sketch.cpp +++ b/src/sketch.cpp @@ -2406,6 +2406,9 @@ void Sketch::update_faces_() purge_stale_length_dimensions_(); sync_permanent_node_annos_(); refresh_all_length_dimensions_(); + + if (is_current()) + m_view.refresh_active_sketch_grid(); } void Sketch::rebuild_dim_classifier_face_cache_()