diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd30c9..59d6e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Project format (`.ezy`) + +- **Format v3:** Saved projects are a ZIP archive (`manifest.json` plus deduplicated `assets/*.rgba` underlay blobs). Sketch underlays reference assets by content hash instead of inline base64. Legacy plain-JSON `.ezy` files (v1/v2, including `rgba_b64` underlays) still load. Document snapshots for undo/redo reference shared assets rather than re-embedding pixel data. + ### Shape List - **Shape info...** on the right-click menu for a shape name or **M** button. Opens a dialog with OCCT topology and property details: root type (Solid, Shell, Face, etc.), validity, sub-shape counts, bounding box, volume, center of mass, surface area, and length where applicable. Includes document fields (name, material, display mode, visibility) and a **Refresh** button. Documented in the user guide under [Shape info](docs/usage.md#shape-info). diff --git a/agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md b/agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md index cc24363..3c6f999 100644 --- a/agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md +++ b/agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md @@ -39,7 +39,7 @@ Improve discoverability and accuracy of the user guide by ensuring every sketch **Options pane UI + help links (src/):** - Ensured *every* mode that renders an Options pane (including Normal/Inspection and Sketch_inspection_mode) now starts with: - - `current_mode_description()` (pulled from m_toolbar_buttons tooltip data, or c_mode_strs as fallback). + - `current_mode_description_()` (pulled from m_toolbar_buttons tooltip data, or c_mode_strs as fallback). - `options_doc_help_button_()` — a small "?" that calls `open_url_(get_doc_url_for_mode(get_mode()))`. - Added `get_doc_url_for_mode(Mode)` static map with one entry per Mode (asserted to match Mode::_count at runtime). - Specific anchors: e.g. Sketch_add_node → usage-sketch.html#add-node-tool, Operation Axis → #operation-axis-tool, Slot (with note on its semantics), etc. @@ -69,7 +69,7 @@ Improve discoverability and accuracy of the user guide by ensuring every sketch - `docs/usage-sketch.md` - `src/gui_mode.cpp` - `src/gui.h` -- `src/gui.cpp` (minor, for current_mode_description) +- `src/gui.cpp` (minor, for current_mode_description_) - `agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md` (this draft) - `agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md` - `agents/README.md` diff --git a/agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md b/agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md index 53c5eb2..271f3fa 100644 --- a/agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md +++ b/agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md @@ -10,7 +10,7 @@ Docs + UI: document Tab + Shift+Tab for Operation Axis / Rectangles / Slot / Cir - Bring the user guide (usage-sketch.md) up to date for every remaining sketch creation tool so it documents both precise distance (Tab) and angle (Shift+Tab) during placement, with full "Angle Constraint" subsections, updated Features/How-to/Shortcuts/Tips, and notes on use cases (future snapping, etc.). - Special handling for Slot (first edge = length/orientation of the slot, radius point = the "second edge dim" / arc radius) and for tools that auto-align (e.g. Rectangle Two Points). -- Add (or ensure) a top-level tool/mode header using `current_mode_description()` followed by a small "?" button in the Options pane for *all* modes that render options content, including pure Normal (Inspection) and Sketch_inspection_mode. +- Add (or ensure) a top-level tool/mode header using `current_mode_description_()` followed by a small "?" button in the Options pane for *all* modes that render options content, including pure Normal (Inspection) and Sketch_inspection_mode. - The "?" buttons use a central `get_doc_url_for_mode(Mode)` map (with runtime size assert vs Mode::_count) so future tools automatically get correct contextual links (e.g. #operation-axis-tool, #slot-creation-tool, #rectangle-tool-center-point, etc.). - Added the generic `agents/drafts/issues/_template.md` and `agents/drafts/prs/_template.md` templates and referenced them from agents/README.md (for future drafts). - Minor supporting changes: doc style guide note about the new ? buttons, unit test for link existence, etc. @@ -22,7 +22,7 @@ This makes the in-app Options experience (headers + help) and the written user g - `docs/usage-sketch.md` (main user-guide updates for the listed tools) - `src/gui_mode.cpp` (header+button calls for Normal + new Sketch_inspection_mode handler, map + assert, options_doc_help_button_ helper) - `src/gui.h` (declarations) -- `src/gui.cpp` (current_mode_description lookup from toolbar data) +- `src/gui.cpp` (current_mode_description_ lookup from toolbar data) - `agents/drafts/issues/archive/gh-131-sketch-tool-precise-input-docs-and-options-help.md` (this issue draft) - `agents/drafts/prs/archive/gh-132-sketch-tool-input-docs-options-pane-help.md` (this PR draft) - `agents/README.md` (added references to the new generic templates) diff --git a/agents/issues/018-ezy-format-v3-zip-underlay-assets.md b/agents/issues/018-ezy-format-v3-zip-underlay-assets.md new file mode 100644 index 0000000..49e6d53 --- /dev/null +++ b/agents/issues/018-ezy-format-v3-zip-underlay-assets.md @@ -0,0 +1,26 @@ +# .ezy format v3: zip container + underlay asset refs + +**Status:** implemented on `main` + +--- + +## Summary + +Upgrade `.ezy` from monolithic JSON to a self-contained ZIP (`ezyFormat: 3`): + +- `manifest.json` — sketches, shapes, view (BRep still inline) +- `assets/.rgba` — deduplicated underlay RGBA8 blobs +- Sketch underlay JSON uses `"asset"` ref instead of `"rgba_b64"` + +Legacy plain-JSON `.ezy` files continue to load (`rgba_b64` migrated into the session asset store on read). + +## Key files + +- `src/ezy_asset_store.h/.cpp` — content-hash registry, shared pixel blobs +- `src/ezy_io.h/.cpp` — zip pack/unpack, format sniff, base64 for Wasm startup storage +- `src/sketch_underlay.*`, `src/sketch_json.cpp`, `src/occt_view.*`, `src/gui.cpp`, `src/utl_settings.cpp` + +## Out of scope (phase 2) + +- External BRep blobs (`shapes[].geom`, `originating_face`) +- Asset GC across long sessions diff --git a/docs/usage.md b/docs/usage.md index f27a913..9f2bf29 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -146,7 +146,7 @@ For shortcuts, sample scripts, binding tables, and limitations, see the **[Scrip ## File Operations ### Supported Formats -- Native format: `.ezy` files +- Native format: `.ezy` files (v3: ZIP archive with `manifest.json` and externalized underlay images; older plain-JSON `.ezy` files still open) - [Import formats: STEP (`.step`, `.stp`), PLY (`.ply`)](#importing-3d-geometries) - [Export formats: STEP, IGES, STL (binary), PLY (binary)](#exporting-3d-geometries) diff --git a/src/ezy_asset_store.cpp b/src/ezy_asset_store.cpp new file mode 100644 index 0000000..a92c325 --- /dev/null +++ b/src/ezy_asset_store.cpp @@ -0,0 +1,55 @@ +#include "ezy_asset_store.h" + +#include +#include + +namespace +{ + +uint64_t fnv1a64_feed(uint64_t hash, const uint8_t* data, std::size_t len) +{ + for (std::size_t i = 0; i < len; ++i) + { + hash ^= static_cast(data[i]); + hash *= 1099511628211ULL; + } + return hash; +} + +} // namespace + +std::string Ezy_asset_store::make_asset_id(const uint8_t* rgba, std::size_t len, int w, int h) +{ + uint64_t hash = 14695981039346656037ULL; + hash = fnv1a64_feed(hash, reinterpret_cast(&w), sizeof(w)); + hash = fnv1a64_feed(hash, reinterpret_cast(&h), sizeof(h)); + if (rgba && len > 0) + hash = fnv1a64_feed(hash, rgba, len); + + std::ostringstream oss; + oss << std::hex << std::setfill('0') << std::setw(16) << hash; + return oss.str(); +} + +std::string Ezy_asset_store::register_rgba(const std::vector& rgba, int w, int h) +{ + const std::string id = make_asset_id(rgba.data(), rgba.size(), w, h); + if (m_by_id.find(id) == m_by_id.end()) + m_by_id[id] = std::make_shared>(rgba); + return id; +} + +std::shared_ptr> Ezy_asset_store::get(const std::string& asset_id) const +{ + const auto it = m_by_id.find(asset_id); + if (it == m_by_id.end()) + return {}; + return it->second; +} + +void Ezy_asset_store::import_asset(const std::string& asset_id, std::vector&& rgba) +{ + m_by_id[asset_id] = std::make_shared>(std::move(rgba)); +} + +void Ezy_asset_store::clear() { m_by_id.clear(); } diff --git a/src/ezy_asset_store.h b/src/ezy_asset_store.h new file mode 100644 index 0000000..91a64f6 --- /dev/null +++ b/src/ezy_asset_store.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include +#include + +/// Session-lifetime deduplicated RGBA blobs referenced from sketch underlay JSON. +class Ezy_asset_store +{ +public: + /// Content-addressed id (FNV-1a 64-bit hex) from w, h, and pixel bytes. + [[nodiscard]] std::string register_rgba(const std::vector& rgba, int w, int h); + + [[nodiscard]] std::shared_ptr> get(const std::string& asset_id) const; + + /// Insert or replace bytes for \a asset_id (e.g. when loading a zip archive). + void import_asset(const std::string& asset_id, std::vector&& rgba); + + void clear(); + + [[nodiscard]] static std::string make_asset_id(const uint8_t* rgba, std::size_t len, int w, int h); + +private: + std::unordered_map>> m_by_id; +}; diff --git a/src/ezy_io.cpp b/src/ezy_io.cpp new file mode 100644 index 0000000..89f4e33 --- /dev/null +++ b/src/ezy_io.cpp @@ -0,0 +1,409 @@ +#include "ezy_io.h" + +#include "ezy_asset_store.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + +constexpr uint32_t k_zip_local_sig = 0x04034b50u; +constexpr uint32_t k_zip_central_sig = 0x02014b50u; +constexpr uint32_t k_zip_eocd_sig = 0x06054b50u; + +struct Zip_entry +{ + std::string name; + std::string data; +}; + +uint32_t crc32_bytes(const uint8_t* data, std::size_t len) +{ + static uint32_t table[256]; + static bool init = false; + if (!init) + { + for (uint32_t i = 0; i < 256; ++i) + { + uint32_t c = i; + for (int k = 0; k < 8; ++k) + c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + init = true; + } + + uint32_t crc = 0xFFFFFFFFu; + for (std::size_t i = 0; i < len; ++i) + crc = table[(crc ^ data[i]) & 0xFFu] ^ (crc >> 8); + return crc ^ 0xFFFFFFFFu; +} + +void write_u16(std::vector& out, uint16_t v) +{ + out.push_back(static_cast(v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); +} + +void write_u32(std::vector& out, uint32_t v) +{ + out.push_back(static_cast(v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + out.push_back(static_cast((v >> 16) & 0xFFu)); + out.push_back(static_cast((v >> 24) & 0xFFu)); +} + +bool read_u16(const uint8_t* p, std::size_t avail, std::size_t& off, uint16_t& v) +{ + if (off + 2 > avail) + return false; + v = static_cast(p[off]) | (static_cast(p[off + 1]) << 8); + off += 2; + return true; +} + +bool read_u32(const uint8_t* p, std::size_t avail, std::size_t& off, uint32_t& v) +{ + if (off + 4 > avail) + return false; + v = static_cast(p[off]) | (static_cast(p[off + 1]) << 8) | + (static_cast(p[off + 2]) << 16) | (static_cast(p[off + 3]) << 24); + off += 4; + return true; +} + +std::vector zip_write_stored(const std::vector& entries) +{ + std::vector out; + struct Local_rec + { + uint32_t offset; + uint16_t name_len; + uint32_t crc; + uint32_t size; + std::string name; + }; + std::vector locals; + + for (const Zip_entry& e : entries) + { + Local_rec rec; + rec.offset = static_cast(out.size()); + rec.name = e.name; + rec.name_len = static_cast(e.name.size()); + rec.size = static_cast(e.data.size()); + rec.crc = crc32_bytes(reinterpret_cast(e.data.data()), e.data.size()); + + write_u32(out, k_zip_local_sig); + write_u16(out, 20); // version needed to extract + write_u16(out, 0); // flags + write_u16(out, 0); // compression: store + write_u16(out, 0); // mod time + write_u16(out, 0); // mod date + write_u32(out, rec.crc); + write_u32(out, rec.size); + write_u32(out, rec.size); + write_u16(out, rec.name_len); + write_u16(out, 0); // extra len + out.insert(out.end(), e.name.begin(), e.name.end()); + out.insert(out.end(), e.data.begin(), e.data.end()); + locals.push_back(std::move(rec)); + } + + const uint32_t central_start = static_cast(out.size()); + for (const Local_rec& rec : locals) + { + write_u32(out, k_zip_central_sig); + write_u16(out, 20); // version made by + write_u16(out, 20); // version needed + write_u16(out, 0); + write_u16(out, 0); + write_u16(out, 0); + write_u16(out, 0); + write_u32(out, rec.crc); + write_u32(out, rec.size); + write_u32(out, rec.size); + write_u16(out, rec.name_len); + write_u16(out, 0); // extra + write_u16(out, 0); // comment + write_u16(out, 0); // disk start + write_u16(out, 0); // int attrs + write_u32(out, 0); // ext attrs + write_u32(out, rec.offset); + out.insert(out.end(), rec.name.begin(), rec.name.end()); + } + + const uint32_t central_size = static_cast(out.size()) - central_start; + write_u32(out, k_zip_eocd_sig); + write_u16(out, 0); // disk + write_u16(out, 0); // disk with central + write_u16(out, static_cast(locals.size())); + write_u16(out, static_cast(locals.size())); + write_u32(out, central_size); + write_u32(out, central_start); + write_u16(out, 0); // comment len + + return out; +} + +bool zip_read_stored(const std::string& bytes, std::vector& out_entries) +{ + out_entries.clear(); + if (bytes.size() < 22) + return false; + + const uint8_t* data = reinterpret_cast(bytes.data()); + const std::size_t n = bytes.size(); + std::size_t eocd = std::string::npos; + + for (std::size_t i = n; i >= 4; --i) + { + const std::size_t at = i - 4; + if (data[at] == 0x50 && data[at + 1] == 0x4b && data[at + 2] == 0x05 && data[at + 3] == 0x06) + { + eocd = at; + break; + } + if (i == 4) + break; + } + + if (eocd == std::string::npos) + return false; + + std::size_t off = eocd + 4; + uint16_t disk_num, disk_with_cd, num_entries_cd, num_entries_total; + uint32_t cd_size, cd_offset; + if (!read_u16(data, n, off, disk_num) || !read_u16(data, n, off, disk_with_cd) || !read_u16(data, n, off, num_entries_cd) || + !read_u16(data, n, off, num_entries_total) || !read_u32(data, n, off, cd_size) || !read_u32(data, n, off, cd_offset)) + return false; + + if (num_entries_cd != num_entries_total || cd_offset + cd_size > n) + return false; + + off = cd_offset; + for (uint16_t i = 0; i < num_entries_cd; ++i) + { + uint32_t sig; + if (!read_u32(data, n, off, sig) || sig != k_zip_central_sig) + return false; + + uint16_t ver_made, ver_need, flags, method, mod_t, mod_d, name_len, extra_len, comment_len, disk_start, int_attr; + uint32_t crc, comp_size, uncomp_size, ext_attr, local_offset; + if (!read_u16(data, n, off, ver_made) || !read_u16(data, n, off, ver_need) || !read_u16(data, n, off, flags) || + !read_u16(data, n, off, method) || !read_u16(data, n, off, mod_t) || !read_u16(data, n, off, mod_d) || + !read_u32(data, n, off, crc) || !read_u32(data, n, off, comp_size) || !read_u32(data, n, off, uncomp_size) || + !read_u16(data, n, off, name_len) || !read_u16(data, n, off, extra_len) || !read_u16(data, n, off, comment_len) || + !read_u16(data, n, off, disk_start) || !read_u16(data, n, off, int_attr) || !read_u32(data, n, off, ext_attr) || + !read_u32(data, n, off, local_offset)) + return false; + + if (off + name_len + extra_len + comment_len > n) + return false; + + const std::string name(reinterpret_cast(data + off), name_len); + off += name_len + extra_len + comment_len; + + if (method != 0 || comp_size != uncomp_size) + return false; + + if (local_offset + 30u > n) + return false; + std::size_t loc = local_offset + 4u; + uint16_t loc_ver, loc_flags, loc_method, loc_mod_t, loc_mod_d, loc_name_len, loc_extra_len; + uint32_t loc_crc, loc_comp, loc_uncomp; + if (!read_u16(data, n, loc, loc_ver) || !read_u16(data, n, loc, loc_flags) || !read_u16(data, n, loc, loc_method) || + !read_u16(data, n, loc, loc_mod_t) || !read_u16(data, n, loc, loc_mod_d) || !read_u32(data, n, loc, loc_crc) || + !read_u32(data, n, loc, loc_comp) || !read_u32(data, n, loc, loc_uncomp) || !read_u16(data, n, loc, loc_name_len) || + !read_u16(data, n, loc, loc_extra_len)) + return false; + + const std::size_t data_start = local_offset + 30u + loc_name_len + loc_extra_len; + if (data_start + comp_size > n) + return false; + + Zip_entry entry; + entry.name = name; + entry.data.assign(reinterpret_cast(data + data_start), comp_size); + out_entries.push_back(std::move(entry)); + } + + return true; +} + +void collect_underlay_asset_ids(const nlohmann::json& j, std::vector& out) +{ + if (!j.contains("sketches") || !j["sketches"].is_array()) + return; + + for (const nlohmann::json& sk : j["sketches"]) + { + if (!sk.contains("underlay") || !sk["underlay"].is_object()) + continue; + const nlohmann::json& ul = sk["underlay"]; + if (ul.contains("asset") && ul["asset"].is_string()) + out.push_back(ul["asset"].get()); + } +} + +std::string asset_path(const std::string& asset_id) { return std::string(k_ezy_assets_dir) + asset_id + ".rgba"; } + +bool parse_asset_path(std::string_view path, std::string& out_id) +{ + const std::string_view prefix = k_ezy_assets_dir; + const std::string_view suffix = ".rgba"; + if (path.size() <= prefix.size() + suffix.size()) + return false; + if (path.substr(0, prefix.size()) != prefix) + return false; + if (path.substr(path.size() - suffix.size()) != suffix) + return false; + out_id.assign(path.substr(prefix.size(), path.size() - prefix.size() - suffix.size())); + return !out_id.empty(); +} + +int from_b64(char c) +{ + if (c >= 'A' && c <= 'Z') + return c - 'A'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 26; + if (c >= '0' && c <= '9') + return c - '0' + 52; + if (c == '+') + return 62; + if (c == '/') + return 63; + return -1; +} + +} // namespace + +bool is_ezy_zip(const std::string& bytes) +{ + return bytes.size() >= 4 && bytes[0] == 'P' && bytes[1] == 'K' && bytes[2] == 0x03 && bytes[3] == 0x04; +} + +bool is_ezy_json(const std::string& bytes) +{ + std::size_t i = 0; + while (i < bytes.size() && (bytes[i] == ' ' || bytes[i] == '\t' || bytes[i] == '\r' || bytes[i] == '\n')) + ++i; + return i < bytes.size() && bytes[i] == '{'; +} + +std::optional unpack_ezy(const std::string& bytes) +{ + std::vector entries; + if (!zip_read_stored(bytes, entries)) + return std::nullopt; + + Ezy_unpack_result result; + for (Zip_entry& e : entries) + { + if (e.name == k_ezy_manifest_path) + { + result.manifest_json = std::move(e.data); + continue; + } + + std::string asset_id; + if (parse_asset_path(e.name, asset_id)) + result.assets.emplace(std::move(asset_id), std::vector(e.data.begin(), e.data.end())); + } + + if (result.manifest_json.empty()) + return std::nullopt; + + return result; +} + +std::vector pack_ezy(const std::string& manifest_json, const Ezy_asset_store& store) +{ + std::vector asset_ids; + try + { + const nlohmann::json j = nlohmann::json::parse(manifest_json); + collect_underlay_asset_ids(j, asset_ids); + } + catch (...) + { + return {}; + } + + std::sort(asset_ids.begin(), asset_ids.end()); + asset_ids.erase(std::unique(asset_ids.begin(), asset_ids.end()), asset_ids.end()); + + std::vector entries; + entries.push_back({k_ezy_manifest_path, manifest_json}); + + for (const std::string& id : asset_ids) + { + const auto pixels = store.get(id); + if (!pixels) + continue; + entries.push_back({asset_path(id), std::string(reinterpret_cast(pixels->data()), pixels->size())}); + } + + return zip_write_stored(entries); +} + +std::string ezy_base64_encode(const std::vector& bytes) +{ + static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + const std::size_t len = bytes.size(); + out.reserve(((len + 2) / 3) * 4); + for (std::size_t i = 0; i < len; i += 3) + { + const std::size_t n = len - i; + const unsigned b0 = bytes[i]; + const unsigned b1 = n > 1 ? bytes[i + 1] : 0u; + const unsigned b2 = n > 2 ? bytes[i + 2] : 0u; + const unsigned triple = (b0 << 16) | (b1 << 8) | b2; + out.push_back(tbl[(triple >> 18) & 63]); + out.push_back(tbl[(triple >> 12) & 63]); + out.push_back(n > 1 ? tbl[(triple >> 6) & 63] : '='); + out.push_back(n > 2 ? tbl[triple & 63] : '='); + } + return out; +} + +std::vector ezy_base64_decode(const std::string& b64) +{ + std::vector out; + if (b64.empty()) + return out; + + std::size_t len = b64.size(); + while (len > 0 && (b64[len - 1] == '=' || b64[len - 1] == '\n' || b64[len - 1] == '\r' || b64[len - 1] == ' ')) + --len; + + out.reserve((len * 3) / 4); + unsigned buf = 0; + int bits = 0; + for (std::size_t i = 0; i < len; ++i) + { + const char c = b64[i]; + if (c == '\n' || c == '\r' || c == ' ') + continue; + const int v = from_b64(c); + if (v < 0) + return {}; + buf = (buf << 6) | static_cast(v); + bits += 6; + if (bits >= 8) + { + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + } + } + return out; +} diff --git a/src/ezy_io.h b/src/ezy_io.h new file mode 100644 index 0000000..4c8f3a0 --- /dev/null +++ b/src/ezy_io.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include +#include + +class Ezy_asset_store; + +inline constexpr const char* k_ezy_manifest_path = "manifest.json"; +inline constexpr const char* k_ezy_assets_dir = "assets/"; + +[[nodiscard]] bool is_ezy_zip(const std::string& bytes); + +[[nodiscard]] bool is_ezy_json(const std::string& bytes); + +struct Ezy_unpack_result +{ + std::string manifest_json; + std::unordered_map> assets; // asset_id -> raw RGBA bytes +}; + +/// Read a v3 zip `.ezy` archive. Returns nullopt on invalid zip or missing manifest. +[[nodiscard]] std::optional unpack_ezy(const std::string& bytes); + +/// Build a v3 zip `.ezy` from manifest JSON and assets referenced by underlay `"asset"` fields. +[[nodiscard]] std::vector pack_ezy(const std::string& manifest_json, const Ezy_asset_store& store); + +/// Base64 encode/decode for binary startup project storage (Emscripten localStorage). +[[nodiscard]] std::string ezy_base64_encode(const std::vector& bytes); +[[nodiscard]] std::vector ezy_base64_decode(const std::string& b64); diff --git a/src/gui.cpp b/src/gui.cpp index 97a1571..0c8113c 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -26,6 +26,7 @@ #include "utl_log.h" #include "scr_lua_console.h" #include "occt_view.h" +#include "ezy_io.h" #include "scr_python_console.h" #include "shp_info.h" #include "sketch.h" @@ -33,26 +34,12 @@ #include "version.h" #include - -// Must be here to prevent compiler warning #include using namespace glm; GUI* gui_instance = nullptr; -// Portable safe copy for fixed-size char buffers used with ImGui::InputText. -// Uses strncpy_s (with _TRUNCATE) on MSVC to avoid C4996; falls back elsewhere. -static void safe_cstr_copy(char* dest, size_t dest_size, const char* src) -{ -#ifdef _MSC_VER - strncpy_s(dest, dest_size, src, _TRUNCATE); -#else - std::strncpy(dest, src, dest_size - 1); - dest[dest_size - 1] = '\0'; -#endif -} - GUI::GUI() { EZY_ASSERT(!gui_instance); @@ -81,6 +68,7 @@ void GUI::set_show_sketch_dimensions(const bool show) { if (m_show_sketch_dimensions == show) return; + m_show_sketch_dimensions = show; apply_sketch_dimensions_visibility(); } @@ -169,55 +157,41 @@ void GUI::render_gui() } void GUI::render_occt() { m_view->do_frame(); } + // Initialize toolbar buttons void GUI::initialize_toolbar_() { m_toolbar_buttons = { - {load_texture("res/icons/User.png"), true, "Inspection mode", Mode::Normal}, - {load_texture("res/icons/Workbench_Sketcher_none.png"), false, "Sketch inspection mode", Mode::Sketch_inspection_mode}, - {load_texture("res/icons/Assembly_AxialMove.png"), false, "Shape move (g)", Mode::Move}, - {load_texture("res/icons/Draft_Rotate.png"), false, "Shape rotate (r)", Mode::Rotate}, - {load_texture("res/icons/Part_Scale.png"), false, "Shape Scale (s)", Mode::Scale}, - {load_texture("res/icons/Macro_FaceToSketch_48.png"), false, "Create a sketch from planar face", - Mode::Sketch_from_planar_face}, - {load_texture("res/icons/Sketcher_MirrorSketch.png"), false, "Operational axis", Mode::Sketch_operation_axis}, - {load_texture("res/icons/Sketcher_CreatePoint.png"), false, "Add node", Mode::Sketch_add_node}, - {load_texture("res/icons/Sketcher_Element_Line_Edge.png"), false, "Add line edge", Mode::Sketch_add_edge}, - {load_texture("res/icons/ls.png"), false, "Add multi-line edge", Mode::Sketch_add_multi_edges}, - {load_texture("res/icons/Sketcher_Element_Arc_Edge.png"), false, "Add arc circle", Mode::Sketch_add_seg_circle_arc}, - {load_texture("res/icons/Sketcher_CreateSquare.png"), false, "Add square", Mode::Sketch_add_square}, - {load_texture("res/icons/Sketcher_CreateRectangle.png"), false, "Add rectangle from two points", - Mode::Sketch_add_rectangle}, - {load_texture("res/icons/Sketcher_CreateRectangle_Center.png"), false, "Add rectangle with center point", - Mode::Sketch_add_rectangle_center_pt}, - {load_texture("res/icons/Sketcher_CreateCircle.png"), false, "Add circle", Mode::Sketch_add_circle}, - {load_texture("res/icons/Sketcher_Create3PointCircle.png"), false, "Add circle from three points", - Mode::Sketch_add_circle_3_pts}, - {load_texture("res/icons/Sketcher_CreateSlot.png"), false, "Add slot", Mode::Sketch_add_slot}, - {load_texture("res/icons/TechDraw_LengthDimension.png"), false, "Length dimension (d)", Mode::Sketch_dim_anno}, - {load_texture("res/icons/Design456_Extrude.png"), false, "Extrude sketch face (e)", Mode::Sketch_face_extrude}, - {load_texture("res/icons/PartDesign_Chamfer.png"), false, "Chamfer (c)", Mode::Shape_chamfer}, - {load_texture("res/icons/PartDesign_Fillet.png"), false, "Fillet (f)", Mode::Shape_fillet}, - {load_texture("res/icons/Draft_PolarArray.png"), false, "Shape polar duplicate", Mode::Shape_polar_duplicate}, - {load_texture("res/icons/Part_Cut.png"), false, "Shape cut", Command::Shape_cut}, - {load_texture("res/icons/Part_Fuse.png"), false, "Shape fuse", Command::Shape_fuse}, - {load_texture("res/icons/Part_Common.png"), false, "Shape common", Command::Shape_common}, + // clang-format off + {load_texture("res/icons/User.png"), true, "Inspection mode", Mode::Normal}, + {load_texture("res/icons/Workbench_Sketcher_none.png"), false, "Sketch inspection mode", Mode::Sketch_inspection_mode}, + {load_texture("res/icons/Assembly_AxialMove.png"), false, "Shape move (g)", Mode::Move}, + {load_texture("res/icons/Draft_Rotate.png"), false, "Shape rotate (r)", Mode::Rotate}, + {load_texture("res/icons/Part_Scale.png"), false, "Shape Scale (s)", Mode::Scale}, + {load_texture("res/icons/Macro_FaceToSketch_48.png"), false, "Create a sketch from planar face", Mode::Sketch_from_planar_face}, + {load_texture("res/icons/Sketcher_MirrorSketch.png"), false, "Operational axis", Mode::Sketch_operation_axis}, + {load_texture("res/icons/Sketcher_CreatePoint.png"), false, "Add node", Mode::Sketch_add_node}, + {load_texture("res/icons/Sketcher_Element_Line_Edge.png"), false, "Add line edge", Mode::Sketch_add_edge}, + {load_texture("res/icons/ls.png"), false, "Add multi-line edge", Mode::Sketch_add_multi_edges}, + {load_texture("res/icons/Sketcher_Element_Arc_Edge.png"), false, "Add arc circle", Mode::Sketch_add_seg_circle_arc}, + {load_texture("res/icons/Sketcher_CreateSquare.png"), false, "Add square", Mode::Sketch_add_square}, + {load_texture("res/icons/Sketcher_CreateRectangle.png"), false, "Add rectangle from two points", Mode::Sketch_add_rectangle}, + {load_texture("res/icons/Sketcher_CreateRectangle_Center.png"), false, "Add rectangle with center point", Mode::Sketch_add_rectangle_center_pt}, + {load_texture("res/icons/Sketcher_CreateCircle.png"), false, "Add circle", Mode::Sketch_add_circle}, + {load_texture("res/icons/Sketcher_Create3PointCircle.png"), false, "Add circle from three points", Mode::Sketch_add_circle_3_pts}, + {load_texture("res/icons/Sketcher_CreateSlot.png"), false, "Add slot", Mode::Sketch_add_slot}, + {load_texture("res/icons/TechDraw_LengthDimension.png"), false, "Length dimension (d)", Mode::Sketch_dim_anno}, + {load_texture("res/icons/Design456_Extrude.png"), false, "Extrude sketch face (e)", Mode::Sketch_face_extrude}, + {load_texture("res/icons/PartDesign_Chamfer.png"), false, "Chamfer (c)", Mode::Shape_chamfer}, + {load_texture("res/icons/PartDesign_Fillet.png"), false, "Fillet (f)", Mode::Shape_fillet}, + {load_texture("res/icons/Draft_PolarArray.png"), false, "Shape polar duplicate", Mode::Shape_polar_duplicate}, + {load_texture("res/icons/Part_Cut.png"), false, "Shape cut", Command::Shape_cut}, + {load_texture("res/icons/Part_Fuse.png"), false, "Shape fuse", Command::Shape_fuse}, + {load_texture("res/icons/Part_Common.png"), false, "Shape common", Command::Shape_common}, + // clang-format on }; } -const char* GUI::current_mode_description() const -{ - for (const auto& b : m_toolbar_buttons) - { - if (b.data.index() == 0) // holds a Mode - { - if (std::get(b.data) == m_mode) - return b.tooltip; - } - } - return ""; -} - void GUI::load_examples_list_() { m_example_files.clear(); @@ -242,6 +216,7 @@ void GUI::load_examples_list_() std::string label = p.filename().string(); m_example_files.push_back(Example_file{std::move(label), std::move(path)}); } + std::sort(m_example_files.begin(), m_example_files.end(), [](const Example_file& a, const Example_file& b) { return a.label < b.label; }); } @@ -304,10 +279,10 @@ void GUI::menu_bar_() for (const Example_file& ex : m_example_files) if (ImGui::MenuItem(ex.label.c_str())) { - std::ifstream file(ex.path); - std::string json_str{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (file.good() && !json_str.empty()) - on_file(ex.path, json_str); + std::ifstream file(ex.path, std::ios::binary); + const std::string file_bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (file.good() && !file_bytes.empty()) + on_file(ex.path, file_bytes); else show_message("Error opening example: " + ex.label); } @@ -336,95 +311,7 @@ void GUI::menu_bar_() if (ImGui::MenuItem("Redo", "Ctrl+Y", false, m_view->can_redo())) m_view->redo(); - ImGui::Separator(); - if (ImGui::MenuItem("New sketch...")) - { - m_new_sketch_plane = 0; - m_new_sketch_offset = 0.0; - m_open_add_sketch_popup = true; - } - - ImGui::Separator(); - if (ImGui::MenuItem("Add box")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_box(0, 0, 0, scale, scale, scale); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add box_prms")) - { - m_add_box_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_box_size = glm::dvec3(1.0, 1.0, 1.0); - m_open_add_box_popup = true; - } - - if (ImGui::MenuItem("Add pyramid")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_pyramid(0, 0, 0, scale); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add pyramid_prms")) - { - m_add_pyramid_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_pyramid_side = 1.0; - m_open_add_pyramid_popup = true; - } - - if (ImGui::MenuItem("Add sphere")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_sphere(0, 0, 0, scale); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add sphere_prms")) - { - m_add_sphere_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_sphere_radius = 1.0; - m_open_add_sphere_popup = true; - } - - if (ImGui::MenuItem("Add cylinder")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_cylinder(0, 0, 0, scale, scale); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add cylinder_prms")) - { - m_add_cylinder_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_cylinder_radius = m_add_cylinder_height = 1.0; - m_open_add_cylinder_popup = true; - } - - if (ImGui::MenuItem("Add cone")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_cone(0, 0, 0, scale, 0.0, scale); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add cone_prms")) - { - m_add_cone_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_cone_R1 = 1.0; - m_add_cone_R2 = 0.0; - m_add_cone_height = 1.0; - m_open_add_cone_popup = true; - } - - if (ImGui::MenuItem("Add torus")) - { - const double scale = m_view->get_dimension_scale(); - m_view->add_torus(0, 0, 0, scale, scale / 2.0); - } - - if (ui_show_feature(3) && ImGui::MenuItem("Add torus_prms")) - { - m_add_torus_origin = glm::dvec3(0.0, 0.0, 0.0); - m_add_torus_R1 = 1.0; - m_add_torus_R2 = 0.5; - m_open_add_torus_popup = true; - } + add_menu_items_(); ImGui::EndMenu(); } @@ -526,6 +413,7 @@ void GUI::about_dialog_() ImFont* font = ImGui::GetFont(); ImGui::MarkdownConfig md; + md.linkCallback = about_markdown_link_cb_; md.imageCallback = about_markdown_image_cb_; md.tooltipCallback = ImGui::defaultMarkdownTooltipCallback; @@ -533,15 +421,12 @@ void GUI::about_dialog_() md.headingFormats[0] = {font, true}; md.headingFormats[1] = {font, true}; md.headingFormats[2] = {font, false}; -#ifdef IMGUI_HAS_TEXTURES - { - float const fs = ImGui::GetFontSize(); - md.headingFormats[0].fontSize = fs * 1.15f; - md.headingFormats[1].fontSize = fs * 1.05f; - md.headingFormats[2].fontSize = fs; - } -#endif - md.userData = this; + md.userData = this; + + float const fs = ImGui::GetFontSize(); + md.headingFormats[0].fontSize = fs * 1.15f; + md.headingFormats[1].fontSize = fs * 1.05f; + md.headingFormats[2].fontSize = fs; ImGui::BeginChild("AboutMd", ImVec2(0.0f, 0.0f), true, ImGuiWindowFlags_HorizontalScrollbar); ImGui::Markdown(m_about_markdown.c_str(), m_about_markdown.size(), md); @@ -560,6 +445,7 @@ std::string GUI::project_title_segment_() const const std::string fn = std::filesystem::path(m_last_saved_path).filename().string(); if (fn.empty()) return "untitled"; + return fn; } @@ -620,10 +506,9 @@ void GUI::doc_help_button_(const char* scope, int line, const char* tooltip, con ImGui::PushID(scope); ImGui::PushID(line); if (ImGui::SmallButton("?")) - { if (doc_url && doc_url[0] != '\0') open_url_(doc_url); - } + ImGui::PopID(); ImGui::PopID(); @@ -816,7 +701,6 @@ void GUI::toolbar_() void GUI::set_dist_edit(float dist, std::function&& callback, const std::optional screen_coords) { - DBG_MSG("dist " << dist); // Sketch calls this every mousemove while TAB length mode is on; do not reset value/position each frame // or typed distance is replaced by the rubber-band length at the cursor. const bool already_editing = m_dist_callback != nullptr; @@ -901,7 +785,6 @@ void GUI::dist_edit_() void GUI::set_angle_edit(float angle, std::function&& callback, const std::optional screen_coords) { - DBG_MSG("angle " << angle); const bool already_editing = m_angle_callback != nullptr; if (!already_editing) { @@ -925,6 +808,7 @@ void GUI::hide_angle_edit() float parsed{}; if (parse_dist_text_to_float_(m_angle_text_buf.data(), parsed)) m_angle_val = parsed; + std::function callback; std::swap(callback, m_angle_callback); callback(m_angle_val, true); @@ -1002,7 +886,7 @@ bool GUI::parse_dist_text_to_float_(const char* buf, float& out) return true; } -bool GUI::is_valid_project_json_(const std::string& s) +bool GUI::is_valid_project_manifest_(const std::string& s) { try { @@ -1015,6 +899,40 @@ bool GUI::is_valid_project_json_(const std::string& s) } } +bool GUI::is_valid_project_file_(const std::string& bytes) +{ + if (is_ezy_zip(bytes)) + { + const auto unpacked = unpack_ezy(bytes); + return unpacked && is_valid_project_manifest_(unpacked->manifest_json); + } + + return is_ezy_json(bytes) && is_valid_project_manifest_(bytes); +} + +std::optional GUI::manifest_from_project_file_(const std::string& file_bytes, Occt_view& view, bool replace_assets) +{ + if (is_ezy_zip(file_bytes)) + { + auto unpacked = unpack_ezy(file_bytes); + if (!unpacked) + return std::nullopt; + + if (replace_assets) + view.asset_store().clear(); + + for (auto& [id, data] : unpacked->assets) + view.asset_store().import_asset(id, std::move(data)); + + return unpacked->manifest_json; + } + + if (is_ezy_json(file_bytes)) + return file_bytes; + + return std::nullopt; +} + const std::vector& GUI::occt_material_combo_labels_() { static std::vector names; @@ -1028,8 +946,7 @@ const std::vector& GUI::occt_material_combo_labels_() return names; } -void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch::sptr& hover_sketch, - size_t& hover_dim_index) +void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch::sptr& hover_sketch, size_t& hover_dim_index) { ImGui::Indent(); ImGui::PushID(index); @@ -1045,6 +962,7 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch:: { for (const std::string& label : labels) ImGui::BulletText("%s", label.c_str()); + if (count > 0) ImGui::TreePop(); } @@ -1067,8 +985,8 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch:: for (size_t i = 0; i < count; ++i) { - bool visible = sketch->dimension_visible(i); - double offset = sketch->dimension_offset(i); + bool visible = sketch->dimension_visible(i); + double offset = sketch->dimension_offset(i); bool row_hovered = false; ImGui::PushID(static_cast(i)); @@ -1077,6 +995,7 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch:: ImGui::TableSetColumnIndex(0); if (ImGui::Checkbox("##dim_visible", &visible)) sketch->set_dimension_visible(i, visible); + row_hovered |= ImGui::IsItemHovered(); ImGui::TableSetColumnIndex(1); @@ -1086,12 +1005,14 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch:: ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::InputText("##dim_name", name_buf, sizeof(name_buf))) sketch->set_dimension_name(i, std::string(name_buf)); + row_hovered |= ImGui::IsItemHovered(); ImGui::TableSetColumnIndex(2); ImGui::SetNextItemWidth(86.f); if (ImGui::InputDouble("##dim_offset", &offset, 0.5, 2.0, "%.2f")) sketch->set_dimension_offset(i, offset); + row_hovered |= ImGui::IsItemHovered(); if (ui_show_contextual_help() && ImGui::IsItemHovered()) @@ -1109,6 +1030,7 @@ void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch:: ImGui::EndTable(); } + if (count > 0) ImGui::TreePop(); } @@ -1697,6 +1619,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) if (changed) apply_affine(); } + { const bool changed = ImGui::SliderScalar("Base Y", ImGuiDataType_Double, &m_underlay_base.y, &min_v, &max_v, "%.4f", ImGuiSliderFlags_ClampOnInput); @@ -1711,6 +1634,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) const bool changed = ImGui::InputDouble(label, p_data, 0.0, 0.0, "%.4f"); if (ImGui::IsItemActivated()) m_view->push_undo_snapshot(); + if (changed) apply_affine(); }; @@ -1727,6 +1651,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) ImGui::SetTooltip( "Maps the vertical (height / rows) of the source bitmap. From the Base, this vector reaches the bottom edge of the " "full image. 'Set Y from edge...' and y-axis calibration adjust +V (this is what you set for the Y-axis)."); + vector_component("V X", &m_underlay_v.x); vector_component("V Y", &m_underlay_v.y); @@ -1741,6 +1666,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) const double c = std::clamp(dot / (lu * lv), -1.0, 1.0); shear_deg = std::acos(c) * (180.0 / std::acos(-1.0)); } + ImGui::Text("U len: %.4f V len: %.4f Angle U-V: %.2f deg", lu, lv, shear_deg); } @@ -1749,6 +1675,7 @@ void GUI::sketch_underlay_panel_settings_(const Sketch::sptr& sk) m_view->push_undo_snapshot(); force_underlay_orthogonal_(sk); } + if (ui_show_contextual_help() && ImGui::IsItemHovered()) ImGui::SetTooltip("Project V to be perpendicular to U. Keeps the current lengths of both axes and the original " "orientation (sign of U cross V). After this the Center / Half / Rotation sliders return."); @@ -1803,13 +1730,10 @@ void GUI::force_underlay_orthogonal_(const Sketch::sptr& sk) const double det = au.X() * av.Y() - au.Y() * av.X(); gp_Vec2d av_perp; if (det >= 0.0) - { av_perp = gp_Vec2d(-au_n.Y() * lv, au_n.X() * lv); - } else - { av_perp = gp_Vec2d(au_n.Y() * lv, -au_n.X() * lv); - } + const gp_Vec2d au_final = au_n * lu; sk->underlay_set_affine_plane(b, au_final, av_perp); // Refresh locals for this frame and any immediate follow-up UI. @@ -1854,6 +1778,7 @@ void GUI::begin_underlay_calib_set_y_(const Sketch::sptr& sk) cancel_underlay_calib_(); sk->underlay_ui_params(m_underlay_center.x, m_underlay_center.y, m_underlay_half_extents.x, m_underlay_half_extents.y, m_underlay_rot); + m_underlay_calib_sketch_wk = sk; m_underlay_calib_phase = Underlay_calib_phase::PickY1; show_message("Underlay Y: uses the current transform. Click two points along +V; then enter the drawing distance."); @@ -1901,13 +1826,12 @@ void GUI::underlay_calib_prompt_x_distance_(const Sketch::sptr& sk) m_underlay_calib_axis_u = s->underlay_axis_u_vec(); s->underlay_ui_params(m_underlay_center.x, m_underlay_center.y, m_underlay_half_extents.x, m_underlay_half_extents.y, m_underlay_rot); + m_underlay_calib_x_done = true; m_underlay_panel_sketch = nullptr; if (m_underlay_calib_x_done && m_underlay_calib_y_done) - { force_underlay_orthogonal_(s); - } m_dist_callback = nullptr; m_underlay_calib_phase = Underlay_calib_phase::None; @@ -1960,13 +1884,12 @@ void GUI::underlay_calib_prompt_y_distance_(const Sketch::sptr& sk) s->underlay_ui_params(m_underlay_center.x, m_underlay_center.y, m_underlay_half_extents.x, m_underlay_half_extents.y, m_underlay_rot); + m_underlay_calib_y_done = true; m_underlay_panel_sketch = nullptr; if (m_underlay_calib_x_done && m_underlay_calib_y_done) - { force_underlay_orthogonal_(s); - } m_dist_callback = nullptr; cancel_underlay_calib_(); @@ -2184,6 +2107,7 @@ void GUI::shape_list_() { if (!visible && m_view->shape_list_hover() == shape) m_view->set_shape_list_hover(nullptr); + shape->set_visible(visible); } @@ -2305,13 +2229,11 @@ void GUI::shape_info_dialog_() bool shape_still_exists = false; for (const Shp_ptr& s : m_view->get_shapes()) - { if (s == m_shape_info_shp) { shape_still_exists = true; break; } - } if (!shape_still_exists) { @@ -2434,7 +2356,7 @@ void GUI::message_status_window_() // Check if 3 seconds have passed auto now = std::chrono::steady_clock::now(); auto elapsed = std::chrono::duration_cast(now - m_message_start_time).count(); - if (elapsed > 3000) // 3 seconds + if (elapsed > 6000) // 6 seconds { m_message_visible = false; m_message.clear(); @@ -2504,6 +2426,7 @@ void GUI::log_window_() ImGui::SetScrollHereY(1.0f); m_log_scroll_to_bottom = false; } + ImGui::EndChild(); ImGui::End(); } @@ -2588,11 +2511,11 @@ void GUI::load_default_project_() std::ifstream file(p, std::ios::binary); if (file.is_open()) { - const std::string json_str{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (is_valid_project_json_(json_str)) + const std::string file_bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (is_valid_project_file_(file_bytes)) { log_message("EzyCad: loading last opened project: " + p.string()); - on_file(p.string(), json_str, false); + on_file(p.string(), file_bytes, false); log_message("EzyCad: startup document loaded (last opened)."); return; } @@ -2611,7 +2534,7 @@ void GUI::load_default_project_() #endif const std::string user_startup = settings::load_user_startup_project(); - if (is_valid_project_json_(user_startup)) + if (is_valid_project_file_(user_startup)) { #ifdef __EMSCRIPTEN__ log_message("EzyCad: loading saved startup project (browser storage)."); @@ -2641,11 +2564,11 @@ void GUI::load_default_project_() return; } - const std::string json_str{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (is_valid_project_json_(json_str)) + const std::string file_bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (is_valid_project_file_(file_bytes)) { log_message("EzyCad: loading bundled default project (" + std::string(k_bundled_default) + ")."); - on_file(k_bundled_default, json_str, false); + on_file(k_bundled_default, file_bytes, false); m_last_saved_path.clear(); log_message("EzyCad: startup document loaded (bundled default)."); } @@ -2662,9 +2585,16 @@ std::string GUI::serialized_project_json_() const return j.dump(2); } +std::vector GUI::serialized_project_ezy_() const +{ + const std::string manifest = serialized_project_json_(); + return pack_ezy(manifest, m_view->asset_store()); +} + void GUI::save_startup_project_() { - if (!settings::save_user_startup_project(serialized_project_json_())) + const std::vector ezy_bytes = serialized_project_ezy_(); + if (!settings::save_user_startup_project(ezy_bytes)) { show_message("Could not save startup project."); return; @@ -2739,12 +2669,12 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords) { switch (m_mode) { - // clang-format off - case Mode::Move: m_view->shp_move().finalize(); break; - case Mode::Rotate: m_view->shp_rotate().finalize(); break; - case Mode::Scale: m_view->shp_scale().finalize(); break; - case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, false); break; - // clang-format on + // clang-format off + case Mode::Move: m_view->shp_move().finalize(); break; + case Mode::Rotate: m_view->shp_rotate().finalize(); break; + case Mode::Scale: m_view->shp_scale().finalize(); break; + case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, false); break; + // clang-format on case Mode::Sketch_add_node: case Mode::Sketch_add_edge: @@ -3019,10 +2949,10 @@ void GUI::open_file_dialog_() ); if (selected) { - std::ifstream file(selected); - const std::string json_str{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (file.good() && json_str != "") - on_file(selected, json_str); + std::ifstream file(selected, std::ios::binary); + const std::string file_bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (file.good() && !file_bytes.empty()) + on_file(selected, file_bytes); else show_message("Error opening: " + std::filesystem::path(selected).filename().string()); @@ -3035,7 +2965,7 @@ void GUI::open_file_dialog_() void GUI::save_file_dialog_() { - const std::string json_str = serialized_project_json_(); + const std::vector ezy_bytes = serialized_project_ezy_(); #ifndef __EMSCRIPTEN__ std::string file; @@ -3056,7 +2986,7 @@ void GUI::save_file_dialog_() std::ofstream out(file, std::ios::binary); if (out.is_open()) { - out.write(json_str.data(), json_str.size()); + out.write(reinterpret_cast(ezy_bytes.data()), static_cast(ezy_bytes.size())); out.close(); show_message("Saved: " + std::filesystem::path(file).filename().string()); } @@ -3068,16 +2998,35 @@ void GUI::save_file_dialog_() #else std::string default_file = m_last_saved_path.empty() ? "project.ezy" : std::filesystem::path(m_last_saved_path).filename().string(); - save_file_dialog_async("Save EzyCad project", default_file, json_str); + save_file_dialog_async("Save EzyCad project", default_file, ezy_bytes); #endif } -void GUI::on_file(const std::string& file_path, const std::string& json_str, bool announce_load) +void GUI::on_file(const std::string& file_path, const std::string& file_bytes, bool announce_load) { using namespace nlohmann; + + log_message("on_file: path=" + file_path + " bytes=" + std::to_string(file_bytes.size()) + + " zip=" + (is_ezy_zip(file_bytes) ? "yes" : "no") + " json=" + (is_ezy_json(file_bytes) ? "yes" : "no")); + m_view->push_undo_snapshot(); - const json j = json::parse(json_str); - m_view->load(json_str); + const std::optional manifest = manifest_from_project_file_(file_bytes, *m_view, true); + if (!manifest) + log_message("on_file: manifest_from_project_file_ FAILED"); + else + log_message("on_file: manifest ok, len=" + std::to_string(manifest->size()) + + " valid=" + (is_valid_project_manifest_(*manifest) ? "yes" : "no")); + + if (!manifest || !is_valid_project_manifest_(*manifest)) + { + m_view->pop_undo_snapshot(); + show_message("Invalid EzyCad project: " + std::filesystem::path(file_path).filename().string()); + return; + } + + const json j = json::parse(*manifest); + m_view->load(*manifest); + log_message("on_file: load complete"); m_last_saved_path = file_path; Mode opened_mode = Mode::Normal; if (j.contains("mode") && j["mode"].is_number_integer()) @@ -3213,8 +3162,9 @@ void GUI::sketch_underlay_file_dialog_async() input.click(); }); } -void GUI::save_file_dialog_async(const char* title, const std::string& default_file, const std::string& json_str) +void GUI::save_file_dialog_async(const char* title, const std::string& default_file, const std::vector& ezy_bytes) { + (void)title; EM_ASM_ARGS( { var data = HEAPU8.subarray($0, $0 + $1); @@ -3233,7 +3183,7 @@ void GUI::save_file_dialog_async(const char* title, const std::string& default_f URL.revokeObjectURL(url); Module.ccall('on_save_file_selected', null, ['string'], [UTF8ToString($2)]); }, - json_str.data(), json_str.size(), default_file.c_str()); + ezy_bytes.data(), ezy_bytes.size(), default_file.c_str()); } void GUI::note_saved_project_filename(const std::string& filename) { @@ -3263,8 +3213,8 @@ void GUI::download_blob_async(const std::string& default_filename, const std::st // C-style callback for Emscripten extern "C" void on_file_selected(const char* file_path, char* contents, int length) { - const std::string json_str(contents, length); - GUI::instance().on_file(file_path, json_str); + const std::string file_bytes(contents, static_cast(length)); + GUI::instance().on_file(file_path, file_bytes); } extern "C" void on_import_file_selected(const char* file_path, char* contents, int length) { @@ -3281,6 +3231,7 @@ extern "C" void on_save_file_selected(const char* file_name) GUI& g = GUI::instance(); if (file_name) g.note_saved_project_filename(file_name); + g.show_message(std::string("Saved: ") + (file_name ? file_name : "")); } #endif diff --git a/src/gui.h b/src/gui.h index a878a7c..282193f 100644 --- a/src/gui.h +++ b/src/gui.h @@ -8,6 +8,7 @@ #include #include #include +#include #include // Added for log messages #include #include @@ -19,7 +20,7 @@ #include "imgui.h" #include "imgui_markdown.h" #include "utl_log.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "shp_info.h" #include "utl_types.h" @@ -148,8 +149,7 @@ class GUI void on_mouse_scroll(double xoffset, double yoffset); void on_resize(int width, int height); Mode get_mode() const { return m_mode; } - const char* current_mode_description() const; - + static std::string get_doc_url_for_mode(Mode mode); Chamfer_mode get_chamfer_mode() const { return m_chamfer_mode; } Fillet_mode get_fillet_mode() const { return m_fillet_mode; } @@ -223,13 +223,13 @@ class GUI #ifdef __EMSCRIPTEN__ void open_file_dialog_async(); // Emscripten: hidden ; no custom title (browser UI) void import_file_dialog_async(); // STEP / PLY import (routes to on_import_file) - void save_file_dialog_async(const char* title, const std::string& default_file, const std::string& json_str); + void save_file_dialog_async(const char* title, const std::string& default_file, const std::vector& ezy_bytes); void download_blob_async(const std::string& default_filename, const std::string& data); /// After browser download save, remember basename for window title and Save-as default. void note_saved_project_filename(const std::string& filename); #endif - void on_file(const std::string& file_path, const std::string& json_str, bool announce_load = true); + void on_file(const std::string& file_path, const std::string& file_bytes, bool announce_load = true); void on_import_file(const std::string& file_path, const std::string& file_data); /// Emscripten `on_sketch_underlay_selected` routes here (must be public for C callback). void on_sketch_underlay_file(const std::string& file_path, const std::string& file_bytes); @@ -293,6 +293,8 @@ class GUI void options_sketch_add_circle_three_pts_mode_(); void options_sketch_add_slot_mode_(); + const char* current_mode_description_() const; + // Options related helpers void options_doc_help_button_(); void doc_help_button_(const char* scope, int line, const char* tooltip, const char* doc_url, bool trailing_same_line = false); @@ -315,6 +317,8 @@ class GUI void menu_bar_(); void toolbar_(); void message_status_window_(); + + // Add related void add_box_dialog_(); void add_pyramid_dialog_(); void add_sphere_dialog_(); @@ -322,6 +326,8 @@ class GUI void add_cone_dialog_(); void add_torus_dialog_(); void add_sketch_dialog_(); + void add_menu_items_(); + void about_dialog_(); void ensure_about_assets_(); static float list_name_field_width_(const ImGuiStyle& st, float max_name_text_w); @@ -365,13 +371,17 @@ class GUI /// Native only: store path in settings after a successful Open (for optional startup load). void persist_last_opened_project_path_(const std::string& path); [[nodiscard]] std::string serialized_project_json_() const; + [[nodiscard]] std::vector serialized_project_ezy_() const; void open_url_(const std::string& url); void update_window_title_(); [[nodiscard]] std::string project_title_segment_() const; /// Parses a float from manual dist/angle ImGui text fields (trimmed, full-string match). [[nodiscard]] static bool parse_dist_text_to_float_(const char* buf, float& out); - /// True if JSON parses and looks like an EzyCad project document (`sketches` array[). - [[nodiscard]] static bool is_valid_project_json_(const std::string& s); + /// True if bytes are a valid v3 zip or legacy JSON EzyCad project. + [[nodiscard]] static bool is_valid_project_file_(const std::string& bytes); + [[nodiscard]] static bool is_valid_project_manifest_(const std::string& manifest_json); + [[nodiscard]] static std::optional manifest_from_project_file_(const std::string& file_bytes, + Occt_view& view, bool replace_assets); /// OCCT standard material display names for ImGui combos (index matches \c Graphic3d_NameOfMaterial). [[nodiscard]] static const std::vector& occt_material_combo_labels_(); diff --git a/src/gui_add.cpp b/src/gui_add.cpp index d50a4da..9c47b46 100644 --- a/src/gui_add.cpp +++ b/src/gui_add.cpp @@ -71,6 +71,7 @@ void GUI::add_box_dialog_() const double scale = m_view->get_dimension_scale(); m_view->add_box(m_add_box_origin.x * scale, m_add_box_origin.y * scale, m_add_box_origin.z * scale, m_add_box_size.x * scale, m_add_box_size.y * scale, m_add_box_size.z * scale); + ImGui::CloseCurrentPopup(); } } @@ -127,6 +128,7 @@ void GUI::add_pyramid_dialog_() const double scale = m_view->get_dimension_scale(); m_view->add_pyramid(m_add_pyramid_origin.x * scale, m_add_pyramid_origin.y * scale, m_add_pyramid_origin.z * scale, m_add_pyramid_side * scale); + ImGui::CloseCurrentPopup(); } ImGui::SameLine(); @@ -143,6 +145,7 @@ void GUI::add_sphere_dialog_() ImGui::OpenPopup("Add sphere"); m_open_add_sphere_popup = false; } + if (!ImGui::BeginPopupModal("Add sphere", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; @@ -183,12 +186,14 @@ void GUI::add_sphere_dialog_() const double scale = m_view->get_dimension_scale(); m_view->add_sphere(m_add_sphere_origin.x * scale, m_add_sphere_origin.y * scale, m_add_sphere_origin.z * scale, m_add_sphere_radius * scale); + ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); } @@ -199,8 +204,10 @@ void GUI::add_cylinder_dialog_() ImGui::OpenPopup("Add cylinder"); m_open_add_cylinder_popup = false; } + if (!ImGui::BeginPopupModal("Add cylinder", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; + ImGui::TextUnformatted("Values in display units."); ImGui::Spacing(); if (ImGui::BeginTable("Add cylinder##table", 2, ImGuiTableFlags_SizingStretchProp)) @@ -237,17 +244,20 @@ void GUI::add_cylinder_dialog_() ImGui::InputDouble("##cyl_height", &m_add_cylinder_height, 0.0, 0.0, "%.3f"); ImGui::EndTable(); } + ImGui::Spacing(); if (ImGui::Button("Add") && m_add_cylinder_radius > 0 && m_add_cylinder_height > 0) { const double scale = m_view->get_dimension_scale(); m_view->add_cylinder(m_add_cylinder_origin.x * scale, m_add_cylinder_origin.y * scale, m_add_cylinder_origin.z * scale, m_add_cylinder_radius * scale, m_add_cylinder_height * scale); + ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); } @@ -258,8 +268,10 @@ void GUI::add_cone_dialog_() ImGui::OpenPopup("Add cone"); m_open_add_cone_popup = false; } + if (!ImGui::BeginPopupModal("Add cone", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; + ImGui::TextUnformatted("Values in display units."); ImGui::Spacing(); if (ImGui::BeginTable("Add cone##table", 2, ImGuiTableFlags_SizingStretchProp)) @@ -302,17 +314,20 @@ void GUI::add_cone_dialog_() ImGui::InputDouble("##cone_height", &m_add_cone_height, 0.0, 0.0, "%.3f"); ImGui::EndTable(); } + ImGui::Spacing(); if (ImGui::Button("Add") && m_add_cone_R1 >= 0 && m_add_cone_R2 >= 0 && m_add_cone_height > 0) { const double scale = m_view->get_dimension_scale(); m_view->add_cone(m_add_cone_origin.x * scale, m_add_cone_origin.y * scale, m_add_cone_origin.z * scale, m_add_cone_R1 * scale, m_add_cone_R2 * scale, m_add_cone_height * scale); + ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); } @@ -323,8 +338,10 @@ void GUI::add_torus_dialog_() ImGui::OpenPopup("Add torus"); m_open_add_torus_popup = false; } + if (!ImGui::BeginPopupModal("Add torus", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; + ImGui::TextUnformatted("Values in display units."); ImGui::Spacing(); if (ImGui::BeginTable("Add torus##table", 2, ImGuiTableFlags_SizingStretchProp)) @@ -361,17 +378,20 @@ void GUI::add_torus_dialog_() ImGui::InputDouble("##torus_R2", &m_add_torus_R2, 0.0, 0.0, "%.3f"); ImGui::EndTable(); } + ImGui::Spacing(); if (ImGui::Button("Add") && m_add_torus_R1 > 0 && m_add_torus_R2 > 0) { const double scale = m_view->get_dimension_scale(); m_view->add_torus(m_add_torus_origin.x * scale, m_add_torus_origin.y * scale, m_add_torus_origin.z * scale, m_add_torus_R1 * scale, m_add_torus_R2 * scale); + ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); } @@ -435,3 +455,96 @@ void GUI::add_sketch_dialog_() ImGui::EndPopup(); } + +void GUI::add_menu_items_() +{ + ImGui::Separator(); + if (ImGui::MenuItem("New sketch...")) + { + m_new_sketch_plane = 0; + m_new_sketch_offset = 0.0; + m_open_add_sketch_popup = true; + } + + ImGui::Separator(); + if (ImGui::MenuItem("Add box")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_box(0, 0, 0, scale, scale, scale); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add box_prms")) + { + m_add_box_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_box_size = glm::dvec3(1.0, 1.0, 1.0); + m_open_add_box_popup = true; + } + + if (ImGui::MenuItem("Add pyramid")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_pyramid(0, 0, 0, scale); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add pyramid_prms")) + { + m_add_pyramid_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_pyramid_side = 1.0; + m_open_add_pyramid_popup = true; + } + + if (ImGui::MenuItem("Add sphere")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_sphere(0, 0, 0, scale); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add sphere_prms")) + { + m_add_sphere_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_sphere_radius = 1.0; + m_open_add_sphere_popup = true; + } + + if (ImGui::MenuItem("Add cylinder")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_cylinder(0, 0, 0, scale, scale); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add cylinder_prms")) + { + m_add_cylinder_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_cylinder_radius = m_add_cylinder_height = 1.0; + m_open_add_cylinder_popup = true; + } + + if (ImGui::MenuItem("Add cone")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_cone(0, 0, 0, scale, 0.0, scale); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add cone_prms")) + { + m_add_cone_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_cone_R1 = 1.0; + m_add_cone_R2 = 0.0; + m_add_cone_height = 1.0; + m_open_add_cone_popup = true; + } + + if (ImGui::MenuItem("Add torus")) + { + const double scale = m_view->get_dimension_scale(); + m_view->add_torus(0, 0, 0, scale, scale / 2.0); + } + + if (ui_show_feature(3) && ImGui::MenuItem("Add torus_prms")) + { + m_add_torus_origin = glm::dvec3(0.0, 0.0, 0.0); + m_add_torus_R1 = 1.0; + m_add_torus_R2 = 0.5; + m_open_add_torus_popup = true; + } +} \ No newline at end of file diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 95f6383..446a2cd 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -15,7 +15,7 @@ #include "gui.h" #include "imgui.h" #include "imgui_internal.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "sketch.h" #include "utl_occt.h" @@ -26,45 +26,14 @@ using namespace glm; namespace { - constexpr ImGuiTableFlags k_options_table_flags = ImGuiTableFlags_SizingFixedFit; constexpr float k_options_control_col_w = 148.f; constexpr float k_options_sketch_control_col_w = 176.f; -void options_table_setup_columns_(float label_col_w, float control_col_w) -{ - ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, label_col_w); - ImGui::TableSetupColumn("control", ImGuiTableColumnFlags_WidthFixed, control_col_w); -} - -void options_right_aligned_label_(const char* text) -{ - ImGui::AlignTextToFramePadding(); - const float text_w = ImGui::CalcTextSize(text).x; - const float col_w = ImGui::GetColumnWidth(); - const float x0 = ImGui::GetCursorPosX(); - const float right_x = x0 + std::max(0.0f, col_w - text_w - ImGui::GetStyle().CellPadding.x * 2.0f); - ImGui::SetCursorPosX(right_x); - ImGui::TextUnformatted(text); -} - -// Up to `max_frac` digits after the decimal, strip trailing zeros (and a trailing '.'). -void format_double_trim_fraction(char* dst, std::size_t dst_sz, double v, int max_frac) -{ - char tmp[64]; - std::snprintf(tmp, sizeof tmp, "%.*f", max_frac, v); - std::size_t len = std::strlen(tmp); - while (len > 0 && tmp[len - 1] == '0') - --len; - if (len > 0 && tmp[len - 1] == '.') - --len; - tmp[len] = '\0'; - if (std::strcmp(tmp, "-0") == 0) - std::snprintf(dst, dst_sz, "0"); - else - std::snprintf(dst, dst_sz, "%s", tmp); -} - +void options_table_setup_columns_(float label_col_w, float control_col_w); +void options_right_aligned_label_(const char* text); +void format_double_trim_fraction_(char* dst, std::size_t dst_sz, double v, int max_frac); +void set_default_material_(const std::vector& material_names, int current_mat, Occt_view::uptr& view); } // namespace std::string GUI::get_doc_url_for_mode(Mode mode) @@ -101,13 +70,23 @@ std::string GUI::get_doc_url_for_mode(Mode mode) auto it = doc_urls.find(mode); if (it != doc_urls.end() && !it->second.empty()) - { return it->second; - } + // fallback to main guide return "https://ezycad.readthedocs.io/en/latest/usage.html"; } +const char* GUI::current_mode_description_() const +{ + for (const auto& b : m_toolbar_buttons) + if (b.data.index() == 0) // holds a Mode + if (std::get(b.data) == m_mode) + return b.tooltip; + + EZY_ASSERT_MSG(false, "Current mode not found in toolbar buttons"); + return ""; +} + void GUI::options_doc_help_button_() { ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); @@ -128,28 +107,30 @@ void GUI::set_mode(Mode mode) void GUI::set_parent_mode() { static std::map parent_modes = { - {Mode::Normal, Mode::Normal}, - {Mode::Move, Mode::Normal}, - {Mode::Scale, Mode::Normal}, - {Mode::Rotate, Mode::Normal}, - {Mode::Sketch_inspection_mode, Mode::Normal}, - {Mode::Sketch_from_planar_face, Mode::Normal}, - {Mode::Sketch_face_extrude, Mode::Normal}, - {Mode::Shape_chamfer, Mode::Normal}, - {Mode::Shape_fillet, Mode::Normal}, - {Mode::Shape_polar_duplicate, Mode::Normal}, - {Mode::Sketch_add_node, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_edge, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_multi_edges, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_seg_circle_arc, Mode::Sketch_inspection_mode}, - {Mode::Sketch_operation_axis, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_square, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_rectangle, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_rectangle_center_pt, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_circle, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_circle_3_pts, Mode::Sketch_inspection_mode}, - {Mode::Sketch_add_slot, Mode::Sketch_inspection_mode}, - {Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode}, + // clang-format off + {Mode::Normal, Mode::Normal}, + {Mode::Move, Mode::Normal}, + {Mode::Scale, Mode::Normal}, + {Mode::Rotate, Mode::Normal}, + {Mode::Sketch_inspection_mode, Mode::Normal}, + {Mode::Sketch_from_planar_face, Mode::Normal}, + {Mode::Sketch_face_extrude, Mode::Normal}, + {Mode::Shape_chamfer, Mode::Normal}, + {Mode::Shape_fillet, Mode::Normal}, + {Mode::Shape_polar_duplicate, Mode::Normal}, + {Mode::Sketch_add_node, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_edge, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_multi_edges, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_seg_circle_arc, Mode::Sketch_inspection_mode}, + {Mode::Sketch_operation_axis, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_square, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_rectangle, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_rectangle_center_pt, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_circle, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_circle_3_pts, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_slot, Mode::Sketch_inspection_mode}, + {Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode}, + // clang-format on }; static bool check = [&]() @@ -184,10 +165,12 @@ void GUI::on_key(int key, int scancode, int action, int mods) case GLFW_KEY_KP_ADD: zoom_in = true; break; + case GLFW_KEY_KP_SUBTRACT: case GLFW_KEY_MINUS: zoom_out = true; break; + case GLFW_KEY_EQUAL: if ((mods & GLFW_MOD_SHIFT) != 0) { @@ -195,6 +178,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) zoom_in_shift_is_structural = true; } break; + default: break; } @@ -333,6 +317,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) m_view->angle_input(screen_coords); else m_view->dimension_input(screen_coords); + break; } @@ -355,12 +340,12 @@ void GUI::on_key(int key, int scancode, int action, int mods) m_view->delete_selected(); break; - case GLFW_KEY_G: set_mode(Mode::Move); break; - case GLFW_KEY_R: set_mode(Mode::Rotate); break; + case GLFW_KEY_G: set_mode(Mode::Move); break; + case GLFW_KEY_R: set_mode(Mode::Rotate); break; case GLFW_KEY_E: set_mode(Mode::Sketch_face_extrude); break; - case GLFW_KEY_S: set_mode(Mode::Scale); break; - case GLFW_KEY_C: set_mode(Mode::Shape_chamfer); break; - case GLFW_KEY_F: set_mode(Mode::Shape_fillet); break; + case GLFW_KEY_S: set_mode(Mode::Scale); break; + case GLFW_KEY_C: set_mode(Mode::Shape_chamfer); break; + case GLFW_KEY_F: set_mode(Mode::Shape_fillet); break; // clang-format on default: break; @@ -442,7 +427,7 @@ void GUI::options_normal_mode_() { EZY_ASSERT(get_mode() == Mode::Normal); - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -468,6 +453,7 @@ void GUI::options_normal_mode_() ImGui::EndCombo(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Hotkeys: 1-9 (Normal mode) set filter when the 3D view has focus, not while typing in UI. Click ? to " "open the user guide.", @@ -482,17 +468,13 @@ void GUI::options_normal_mode_() int current_mat = int(m_view->get_default_material().Name()); if (current_mat < 0 || current_mat >= static_cast(material_names.size())) current_mat = 0; + const float material_row_w = label_col_w + k_options_control_col_w; ImGui::SetNextItemWidth(std::max(ImGui::GetContentRegionAvail().x, material_row_w)); if (ImGui::BeginCombo("##default_material_normal", material_names[static_cast(current_mat)].data(), ImGuiComboFlags_HeightSmall)) { - for (int i = 0; i < static_cast(material_names.size()); i++) - if (ImGui::Selectable(material_names[static_cast(i)].data(), current_mat == i)) - { - Graphic3d_MaterialAspect mat(static_cast(i)); - m_view->set_default_material(mat); - } + set_default_material_(material_names, current_mat, m_view); ImGui::EndCombo(); } @@ -503,7 +485,7 @@ void GUI::options_move_mode_() { EZY_ASSERT(get_mode() == Mode::Move); - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); ImGui::TextUnformatted("Constrain axis:"); @@ -523,7 +505,7 @@ void GUI::options_scale_mode_() { EZY_ASSERT(get_mode() == Mode::Scale); - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -534,7 +516,7 @@ void GUI::options_rotate_mode_() { EZY_ASSERT(get_mode() == Mode::Rotate); - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -561,7 +543,7 @@ void GUI::options_shape_chamfer_mode_() float label_col_w = std::max(ImGui::CalcTextSize("Chamfer Mode").x, ImGui::CalcTextSize("Chamfer dist").x); label_col_w += ImGui::GetStyle().CellPadding.x * 2.0f + 8.0f; - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -589,7 +571,7 @@ void GUI::options_shape_chamfer_mode_() const ImGuiID chamfer_input_id = ImGui::GetID("##micron"); ImGuiContext* ctx = ImGui::GetCurrentContext(); if (ctx && ctx->ActiveId != chamfer_input_id) - format_double_trim_fraction(chamfer_buf, sizeof chamfer_buf, chamfer_dist, 6); + format_double_trim_fraction_(chamfer_buf, sizeof chamfer_buf, chamfer_dist, 6); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); @@ -605,6 +587,7 @@ void GUI::options_shape_chamfer_mode_() { while (*end == ' ' || *end == '\t') ++end; + if (*end == '\0') m_view->shp_chamfer().set_chamfer_dist(p * scale); } @@ -623,7 +606,7 @@ void GUI::options_shape_fillet_mode_() float label_col_w = std::max(ImGui::CalcTextSize("Fillet Mode").x, ImGui::CalcTextSize("Fillet radius").x); label_col_w += ImGui::GetStyle().CellPadding.x * 2.0f + 8.0f; - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -651,7 +634,7 @@ void GUI::options_shape_fillet_mode_() const ImGuiID fillet_input_id = ImGui::GetID("##micron"); ImGuiContext* ctx = ImGui::GetCurrentContext(); if (ctx && ctx->ActiveId != fillet_input_id) - format_double_trim_fraction(fillet_buf, sizeof fillet_buf, fillet_radius, 6); + format_double_trim_fraction_(fillet_buf, sizeof fillet_buf, fillet_radius, 6); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); @@ -697,7 +680,7 @@ void GUI::options_shape_polar_duplicate_mode_() label_col_w = std::max(label_col_w, ImGui::CalcTextSize("Material").x); label_col_w += ImGui::GetStyle().CellPadding.x * 2.0f + 8.0f; - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -754,12 +737,7 @@ void GUI::options_shape_polar_duplicate_mode_() if (ImGui::BeginCombo("##default_material_polar_dup", material_names[static_cast(current_item)].data(), ImGuiComboFlags_HeightSmall)) { - for (int i = 0; i < static_cast(material_names.size()); i++) - if (ImGui::Selectable(material_names[static_cast(i)].data(), current_item == i)) - { - Graphic3d_MaterialAspect mat(static_cast(i)); - m_view->set_default_material(mat); - } + set_default_material_(material_names, current_item, m_view); ImGui::EndCombo(); } @@ -773,7 +751,7 @@ void GUI::options_sketch_from_planer_face_mode_() { EZY_ASSERT(get_mode() == Mode::Sketch_from_planar_face); - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -843,12 +821,7 @@ void GUI::options_sketch_face_extrude_mode_() if (ImGui::BeginCombo("##default_material_sketch_extrude", material_names[static_cast(current_item)].data(), ImGuiComboFlags_HeightSmall)) { - for (int i = 0; i < static_cast(material_names.size()); i++) - if (ImGui::Selectable(material_names[static_cast(i)].data(), current_item == i)) - { - Graphic3d_MaterialAspect mat(static_cast(i)); - m_view->set_default_material(mat); - } + set_default_material_(material_names, current_item, m_view); ImGui::EndCombo(); } @@ -986,6 +959,7 @@ void GUI::options_orthographic_projection_() save_occt_view_settings(); m_view->apply_camera_projection(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Use an orthographic camera in non-sketch modes (no perspective foreshortening). Click ? to open the user " "guide.", @@ -994,7 +968,7 @@ void GUI::options_orthographic_projection_() void GUI::options_sketch_common_() { - ImGui::TextUnformatted(current_mode_description()); + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -1030,6 +1004,7 @@ void GUI::options_sketch_common_() for (int i = 0; i < static_cast(k_snap_guide_mode_labels.size()); ++i) if (ImGui::Selectable(k_snap_guide_mode_labels[static_cast(i)], i == snap_mode)) Sketch_nodes::set_snap_guide_mode(static_cast(i)); + ImGui::EndCombo(); } @@ -1078,6 +1053,7 @@ void GUI::sync_sketch_add_mid_pt_edges_if_applicable_() case Mode::Sketch_add_slot: Sketch::set_add_mid_pt_edges(add_mid_pt_edges_for_mode_(m_mode)); break; + default: break; } @@ -1090,12 +1066,15 @@ bool GUI::add_mid_pt_edges_for_mode_(const Mode mode) const case Mode::Sketch_add_edge: case Mode::Sketch_add_multi_edges: return m_add_mid_pt_line_edges; + case Mode::Sketch_add_square: case Mode::Sketch_add_rectangle: case Mode::Sketch_add_rectangle_center_pt: return m_add_mid_pt_rect_edges; + case Mode::Sketch_add_slot: return m_add_mid_pt_slot_edges; + default: return false; } @@ -1110,6 +1089,7 @@ void GUI::options_sketch_add_midpoint_nodes_checkbox_(bool& setting) Sketch::set_add_mid_pt_edges(add_mids); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("When on, new straight edges from this tool get an automatic midpoint node (for center snapping). " "Saved per tool group in Settings. Click ? to open the user guide.", @@ -1134,6 +1114,7 @@ float GUI::options_sketch_label_col_w_() const sketch_label_col_w = std::max(sketch_label_col_w, ImGui::CalcTextSize("Both sides").x); sketch_label_col_w = std::max(sketch_label_col_w, ImGui::CalcTextSize("Material").x); } + sketch_label_col_w += ImGui::GetStyle().CellPadding.x * 2.0f + 8.0f; return sketch_label_col_w; } @@ -1163,6 +1144,7 @@ void GUI::on_key_rotate_mode_(int key) m_view->shp_rotate().set_rotation_axis(Rotation_axis::View_to_object); else m_view->shp_rotate().set_rotation_axis(Rotation_axis::X_axis); + break; case GLFW_KEY_Y: @@ -1170,6 +1152,7 @@ void GUI::on_key_rotate_mode_(int key) m_view->shp_rotate().set_rotation_axis(Rotation_axis::View_to_object); else m_view->shp_rotate().set_rotation_axis(Rotation_axis::Y_axis); + break; case GLFW_KEY_Z: @@ -1177,6 +1160,7 @@ void GUI::on_key_rotate_mode_(int key) m_view->shp_rotate().set_rotation_axis(Rotation_axis::View_to_object); else m_view->shp_rotate().set_rotation_axis(Rotation_axis::Z_axis); + break; } } @@ -1188,19 +1172,65 @@ void GUI::on_key_move_mode_(int key) switch (key) { - case GLFW_KEY_X: - opts.constr_axis_x ^= 1; - break; - case GLFW_KEY_Y: - opts.constr_axis_y ^= 1; - break; - case GLFW_KEY_Z: - opts.constr_axis_z ^= 1; - break; + // clang-format off + case GLFW_KEY_X: opts.constr_axis_x ^= 1; break; + case GLFW_KEY_Y: opts.constr_axis_y ^= 1; break; + case GLFW_KEY_Z: opts.constr_axis_z ^= 1; break; + // clang-format on case GLFW_KEY_TAB: m_view->shp_move().show_dist_edit(screen_coords); break; + default: break; } } + +namespace +{ +void options_table_setup_columns_(float label_col_w, float control_col_w) +{ + ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, label_col_w); + ImGui::TableSetupColumn("control", ImGuiTableColumnFlags_WidthFixed, control_col_w); +} + +void options_right_aligned_label_(const char* text) +{ + ImGui::AlignTextToFramePadding(); + const float text_w = ImGui::CalcTextSize(text).x; + const float col_w = ImGui::GetColumnWidth(); + const float x0 = ImGui::GetCursorPosX(); + const float right_x = x0 + std::max(0.0f, col_w - text_w - ImGui::GetStyle().CellPadding.x * 2.0f); + ImGui::SetCursorPosX(right_x); + ImGui::TextUnformatted(text); +} + +// Up to `max_frac` digits after the decimal, strip trailing zeros (and a trailing '.'). +void format_double_trim_fraction_(char* dst, std::size_t dst_sz, double v, int max_frac) +{ + char tmp[64]; + std::snprintf(tmp, sizeof tmp, "%.*f", max_frac, v); + std::size_t len = std::strlen(tmp); + while (len > 0 && tmp[len - 1] == '0') + --len; + + if (len > 0 && tmp[len - 1] == '.') + --len; + + tmp[len] = '\0'; + if (std::strcmp(tmp, "-0") == 0) + std::snprintf(dst, dst_sz, "0"); + else + std::snprintf(dst, dst_sz, "%s", tmp); +} + +void set_default_material_(const std::vector& material_names, int current_mat, Occt_view::uptr& view) +{ + for (int i = 0; i < static_cast(material_names.size()); i++) + if (ImGui::Selectable(material_names[static_cast(i)].data(), current_mat == i)) + { + Graphic3d_MaterialAspect mat(static_cast(i)); + view->set_default_material(mat); + } +} +} // namespace diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index 5e81d80..cbecbc8 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include "utl_dbg.h" @@ -14,35 +13,12 @@ #include "sketch.h" #include "sketch_nodes.h" -#include - namespace { const char* const k_settings_version = "1"; const char* const k_gui_key_permanent_node_anno_scale = "permanent_node_anno_scale"; -/// `occt_view` JSON object: view background gradient and grid (shared with `save_occt_view_settings` / -/// `occt_view_settings_json`). -nlohmann::json build_occt_view_settings_object(const Occt_view& view) -{ - float bg1[3], bg2[3], g1[3], g2[3]; - view.get_bg_gradient_colors(bg1, bg2); - view.get_grid_colors(g1, g2); - const int method = view.get_bg_gradient_method(); - Occt_grid_rect_params grid_rect{}; - view.get_occt_grid_rect_params(grid_rect); - return nlohmann::json{ - {"bg_color1", {bg1[0], bg1[1], bg1[2]}}, - {"bg_color2", {bg2[0], bg2[1], bg2[2]}}, - {"bg_gradient_method", method}, - {"grid_color1", {g1[0], g1[1], g1[2]}}, - {"grid_color2", {g2[0], g2[1], g2[2]}}, - {"grid_step", grid_rect.step}, - {"grid_padding", grid_rect.grid_padding}, - {"grid_graphic_z_offset", grid_rect.graphic_z_offset}, - {"grid_visible", view.get_grid_visible()}, - }; -} +nlohmann::json build_occt_view_settings_object_(const Occt_view& view); } // namespace void GUI::set_ui_verbosity(int v) { m_ui_verbosity = std::max(k_gui_ui_verbosity_min, v); } @@ -52,7 +28,7 @@ std::string GUI::occt_view_settings_json() const using nlohmann::json; EZY_ASSERT(m_view); json j; - j["occt_view"] = build_occt_view_settings_object(*m_view); + j["occt_view"] = build_occt_view_settings_object_(*m_view); j["gui"] = { {"edge_dim_label_h", m_edge_dim_label_h}, {"edge_dim_line_width", m_edge_dim_line_width}, @@ -106,8 +82,9 @@ void GUI::save_occt_view_settings() { } } + EZY_ASSERT(m_view); - j["occt_view"] = build_occt_view_settings_object(*m_view); + j["occt_view"] = build_occt_view_settings_object_(*m_view); j["gui"] = { {"show_options", m_show_options}, {"show_sketch_list", m_show_sketch_list}, @@ -197,14 +174,19 @@ void GUI::parse_occt_view_settings_(const std::string& content) }; if (ov.contains("bg_color1")) arr3(ov["bg_color1"], bg1); + if (ov.contains("bg_color2")) arr3(ov["bg_color2"], bg2); + if (ov.contains("bg_gradient_method") && ov["bg_gradient_method"].is_number_integer()) method = ov["bg_gradient_method"].get(); + if (ov.contains("grid_color1")) arr3(ov["grid_color1"], g1); + if (ov.contains("grid_color2")) arr3(ov["grid_color2"], g2); + m_view->set_bg_gradient_colors(bg1[0], bg1[1], bg1[2], bg2[0], bg2[1], bg2[2]); m_view->set_bg_gradient_method(method); m_view->set_grid_colors(g1[0], g1[1], g1[2], g2[0], g2[1], g2[2]); @@ -215,6 +197,7 @@ void GUI::parse_occt_view_settings_(const std::string& content) { if (!ov.contains(key)) return; + const json& v = ov[key]; if (v.is_number()) dst = v.get(); @@ -224,21 +207,22 @@ void GUI::parse_occt_view_settings_(const std::string& content) { if (ov.contains("grid_x_step")) apply_num("grid_x_step", grid_rect.step); + else if (ov.contains("grid_y_step")) apply_num("grid_y_step", grid_rect.step); } 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 shader grid // is not displayed until apply_grid_visibility_() runs. m_view->set_grid_visible(grid_visible); @@ -257,18 +241,23 @@ void GUI::parse_gui_panes_settings_(const std::string& content) const json j = json::parse(content); if (!j.contains("gui") || !j["gui"].is_object()) return; + const json& g = j["gui"]; auto b = [&g](const char* key, bool current) { if (!g.contains(key)) return current; + const json& v = g[key]; if (v.is_boolean()) return v.get(); + if (v.is_number_integer()) return v.get() != 0; + return current; }; + m_ui_verbosity = k_gui_ui_verbosity_default; if (g.contains("ui_verbosity") && g["ui_verbosity"].is_number_integer()) m_ui_verbosity = std::max(k_gui_ui_verbosity_min, g["ui_verbosity"].get()); @@ -287,6 +276,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (v >= 0 && v <= 3) m_edge_dim_label_h = v; } + auto parse_bounded_float = [&g](const char* key, const float min_v, const float max_v, const float default_v) -> float { float out = default_v; @@ -296,6 +286,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (v >= min_v && v <= max_v) out = v; } + 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); @@ -310,6 +301,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (v >= min_v && v <= max_v) return v; } + 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, @@ -321,29 +313,35 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (a[i].is_number()) m_edge_dim_color[i] = std::clamp(a[i].get(), 0.f, 1.f); } + if (g.contains("edge_dim_arrow_style") && g["edge_dim_arrow_style"].is_number_integer()) { const int v = g["edge_dim_arrow_style"].get(); if (v >= k_gui_edge_dim_arrow_style_min && v <= k_gui_edge_dim_arrow_style_max) m_edge_dim_arrow_style = v; } + if (g.contains("edge_dim_arrow_orientation") && g["edge_dim_arrow_orientation"].is_number_integer()) { const int v = g["edge_dim_arrow_orientation"].get(); if (v >= k_gui_edge_dim_arrow_orientation_min && v <= k_gui_edge_dim_arrow_orientation_max) m_edge_dim_arrow_orientation = v; } + m_show_sketch_dimensions = b("show_sketch_dimensions", true); m_permanent_node_anno_scale = parse_bounded_float(k_gui_key_permanent_node_anno_scale, k_gui_permanent_node_anno_scale_min, k_gui_permanent_node_anno_scale_max, k_gui_permanent_node_anno_scale_default); + m_add_mid_pt_line_edges = b("add_mid_pt_edges", false); if (g.contains("add_midpoints_to_linear_edges") && !g.contains("add_mid_pt_edges") && g["add_midpoints_to_linear_edges"].is_boolean()) m_add_mid_pt_line_edges = g["add_midpoints_to_linear_edges"].get(); - m_add_mid_pt_rect_edges = b("add_mid_pt_rect_edges", true); - m_add_mid_pt_slot_edges = b("add_mid_pt_slot_edges", false); + + m_add_mid_pt_rect_edges = b("add_mid_pt_rect_edges", true); + m_add_mid_pt_slot_edges = b("add_mid_pt_slot_edges", false); m_load_last_opened_on_startup = b("load_last_opened_on_startup", b("load_last_saved_on_startup", false)); + if (g.contains("last_opened_project_path") && g["last_opened_project_path"].is_string()) m_last_opened_project_path = g["last_opened_project_path"].get(); else if (g.contains("last_saved_project_path") && g["last_saved_project_path"].is_string()) @@ -361,6 +359,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) } return fallback; }; + m_imgui_rounding_general = round_from_json("imgui_rounding_general", fb_general); m_imgui_rounding_scroll = round_from_json("imgui_rounding_scroll", fb_scroll); m_imgui_rounding_tabs = round_from_json("imgui_rounding_tabs", fb_tabs); @@ -391,6 +390,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) std::to_string(k_gui_view_zoom_scroll_scale_min) + ", " + std::to_string(k_gui_view_zoom_scroll_scale_max) + "], got " + std::to_string(v) + "; using default."); } + if (m_view) m_view->set_zoom_scroll_scale(m_view_zoom_scroll_scale); @@ -412,6 +412,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) for (size_t i = 0; i < 3; ++i) if (a[static_cast(i)].is_number()) c[static_cast(i)] = std::clamp(a[static_cast(i)].get(), 0.f, 1.f); + Sketch_nodes::set_snap_guide_color_node(c[0], c[1], c[2]); Sketch_nodes::set_snap_guide_color_axis(c[0], c[1], c[2]); } @@ -423,6 +424,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) for (size_t i = 0; i < 3; ++i) if (a[static_cast(i)].is_number()) c[static_cast(i)] = std::clamp(a[static_cast(i)].get(), 0.f, 1.f); + Sketch_nodes::set_snap_guide_color_axis(c[0], c[1], c[2]); } else if (g.contains("snap_guide_color_node") && g["snap_guide_color_node"].is_array() && @@ -448,9 +450,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) Sketch_nodes::set_annotate_all_coaxial_nodes(true); if (g.contains("annotate_all_coaxial_nodes") && g["annotate_all_coaxial_nodes"].is_boolean()) - { Sketch_nodes::set_annotate_all_coaxial_nodes(g["annotate_all_coaxial_nodes"].get()); - } if (g.contains("underlay_highlight_color") && g["underlay_highlight_color"].is_array() && g["underlay_highlight_color"].size() >= 3) @@ -460,6 +460,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (a[static_cast(i)].is_number()) m_underlay_highlight_color[static_cast(i)] = std::clamp(a[static_cast(i)].get(), 0.f, 1.f); + m_underlay_highlight_color[3] = 1.f; if (a.size() >= 4 && a[3].is_number()) m_underlay_highlight_color[3] = std::clamp(a[3].get(), 0.f, 1.f); @@ -472,6 +473,7 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (a[static_cast(i)].is_number()) m_elm_list_hover_color[static_cast(i)] = std::clamp(a[static_cast(i)].get(), 0.f, 1.f); + m_elm_list_hover_color[3] = 1.f; if (a.size() >= 4 && a[3].is_number()) m_elm_list_hover_color[3] = std::clamp(a[3].get(), 0.f, 1.f); @@ -499,11 +501,14 @@ void GUI::load_occt_view_settings_() { if (!doc.contains("version")) return false; + const json& v = doc["version"]; if (v.is_string()) return v.get() == k_settings_version; + if (v.is_number_integer()) return std::to_string(v.get()) == k_settings_version; + return false; }; const bool version_ok = settings_version_matches(j); @@ -593,6 +598,7 @@ void GUI::settings_() --m_ui_verbosity; verb_changed = true; } + ImGui::EndDisabled(); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::SetNextItemWidth(56.0f); @@ -602,12 +608,14 @@ void GUI::settings_() m_ui_verbosity = std::max(k_gui_ui_verbosity_min, verb_edit); verb_changed = true; } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); if (ImGui::ArrowButton("##ui_verbosity_inc", ImGuiDir_Right)) { ++m_ui_verbosity; verb_changed = true; } + ImGui::EndTable(); } if (verb_changed) @@ -638,6 +646,7 @@ void GUI::settings_() &k_gui_view_roll_step_deg_max, "%.2f deg", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_ClampOnInput)) save_occt_view_settings(); + m_view_roll_step_deg = std::clamp(m_view_roll_step_deg, k_gui_view_roll_step_deg_min, k_gui_view_roll_step_deg_max); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); @@ -661,6 +670,7 @@ void GUI::settings_() save_occt_view_settings(); } + m_view_zoom_scroll_scale = std::clamp(m_view_zoom_scroll_scale, k_gui_view_zoom_scroll_scale_min, k_gui_view_zoom_scroll_scale_max); @@ -714,6 +724,7 @@ void GUI::settings_() ImGui::EndTable(); } + if (r_changed) save_occt_view_settings(); } @@ -760,6 +771,7 @@ void GUI::settings_() ImGui::EndTable(); } + if (bg_changed) { m_view->set_bg_gradient_colors(bg1[0], bg1[1], bg1[2], bg2[0], bg2[1], bg2[2]); @@ -776,14 +788,14 @@ void GUI::settings_() ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Element hover color"); ImGui::TableSetColumnIndex(1); - element_hover_changed |= - ImGui::ColorEdit4("##element_hover", &m_elm_list_hover_color[0], ImGuiColorEditFlags_Float); + element_hover_changed |= ImGui::ColorEdit4("##element_hover", &m_elm_list_hover_color[0], ImGuiColorEditFlags_Float); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Highlight color when hovering a row in the Shape List or a dimension row in the Sketch List. " "Updates immediately when a row is hovered.", doc_urls::k_occt_view); ImGui::EndTable(); } + if (element_hover_changed) { m_view->refresh_shape_list_hover_highlight(); @@ -862,6 +874,7 @@ void GUI::settings_() 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_("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 " @@ -880,6 +893,7 @@ void GUI::settings_() } if (grid_changed) m_view->set_grid_colors(g1[0], g1[1], g1[2], g2[0], g2[1], g2[2]); + if (geom_changed) { gr.step = step_ui * dim_scale; @@ -891,6 +905,7 @@ void GUI::settings_() padding_ui = gr.grid_padding / dim_scale; graphic_z_off_ui = gr.graphic_z_offset / dim_scale; } + if (grid_changed || geom_changed) save_occt_view_settings(); } @@ -903,7 +918,6 @@ void GUI::settings_() ImGui::Indent(ImGui::GetStyle().IndentSpacing); if (ImGui::CollapsingHeader("Dimensions", ImGuiTreeNodeFlags_DefaultOpen)) - { if (ImGui::BeginTable("settings_sketch_dims", 2, ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, k_label_col_w); @@ -921,6 +935,7 @@ void GUI::settings_() m_edge_dim_line_width = lw; dim_changed = true; } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Thickness of sketch edge length dimensions (Open CASCADE line width scale; 1.0 = default).", nullptr); } @@ -937,6 +952,7 @@ void GUI::settings_() m_edge_dim_arrow_size = arrow; dim_changed = true; } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Arrow head length for sketch and extrude length dimensions (Open CASCADE display units).", nullptr); } @@ -976,6 +992,7 @@ void GUI::settings_() 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; + ImGui::SetNextItemWidth(220.0f); if (ImGui::BeginCombo("##edge_dim_text_render", k_labels[static_cast(rm)], ImGuiComboFlags_HeightSmall)) { @@ -985,8 +1002,10 @@ void GUI::settings_() m_edge_dim_text_render_mode = i; dim_changed = true; } + ImGui::EndCombo(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("How dimension value labels are composited. Z-layer Top and Topmost avoid ghosting against the " "grid; Topmost is the default.", @@ -1037,6 +1056,7 @@ void GUI::settings_() m_edge_dim_arrow_style = i; dim_changed = true; } + ImGui::EndCombo(); } } @@ -1059,6 +1079,7 @@ void GUI::settings_() m_edge_dim_arrow_orientation = i; dim_changed = true; } + ImGui::EndCombo(); } } @@ -1075,6 +1096,7 @@ void GUI::settings_() set_show_sketch_dimensions(show); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("When off, hides all sketch length dimensions. Tool mode may still limit which sketch shows " "dimensions when this is on.", @@ -1083,7 +1105,7 @@ void GUI::settings_() ImGui::EndTable(); } - } + ImGui::Unindent(ImGui::GetStyle().IndentSpacing); if (ImGui::BeginTable("settings_sketch", 2, ImGuiTableFlags_SizingStretchProp)) @@ -1104,6 +1126,7 @@ void GUI::settings_() m_permanent_node_anno_scale = size_scale; node_anno_changed = true; } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Scale for permanent '+' node markers in sketch mode. Click ? to open the user guide.", doc_urls::k_add_node_tool); @@ -1122,6 +1145,7 @@ void GUI::settings_() sync_sketch_add_mid_pt_edges_if_applicable_(); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Line edge and multi-line edge tools (default off). Click ? to open the user guide.", doc_urls::k_line_edge_midpoint_nodes); @@ -1140,6 +1164,7 @@ void GUI::settings_() sync_sketch_add_mid_pt_edges_if_applicable_(); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Square and rectangle tools (default on). Click ? to open the user guide.", doc_urls::k_line_edge_midpoint_nodes); @@ -1158,6 +1183,7 @@ void GUI::settings_() sync_sketch_add_mid_pt_edges_if_applicable_(); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Slot tool straight edges only (default off). Click ? to open the user guide.", doc_urls::k_line_edge_midpoint_nodes); @@ -1187,6 +1213,7 @@ void GUI::settings_() Sketch_nodes::set_snap_guide_color_node(snap_col[0], snap_col[1], snap_col[2]); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Guides when both X and Y snap to the same node (vertex lock). Click ? to open the user guide.", doc_urls::k_sketch_snapping); @@ -1204,6 +1231,7 @@ void GUI::settings_() Sketch_nodes::set_snap_guide_color_axis(snap_col[0], snap_col[1], snap_col[2]); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Guides when the cursor aligns to a node on X or Y only (axis snap). Click ? to open the user " "guide.", @@ -1222,6 +1250,7 @@ void GUI::settings_() Sketch_nodes::set_snap_guide_line_width(line_width); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Line width for sketch snap guides (axis lines, markers, and co-axial overlay). Click ? to open " "the user guide.", @@ -1250,6 +1279,7 @@ void GUI::settings_() Sketch_nodes::set_snap_guide_mode(static_cast(i)); save_occt_view_settings(); } + ImGui::EndCombo(); } ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); @@ -1270,6 +1300,7 @@ void GUI::settings_() Sketch_nodes::set_annotate_all_coaxial_nodes(annotate_all); save_occt_view_settings(); } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("When on (global mode): axis guide lines + markers for *all* nodes in the current sketch and all " "other visible sketches. When off (default): only closest node per active axis is annotated. Click ? " @@ -1304,6 +1335,7 @@ void GUI::settings_() if (sk->has_underlay()) sk->underlay_set_line_tint_rgba(hr, hg, hb, ha); } + m_underlay_panel_sketch = nullptr; save_occt_view_settings(); } @@ -1323,6 +1355,7 @@ void GUI::settings_() ImGui::TableSetColumnIndex(1); if (ImGui::Checkbox("##load_last", &m_load_last_opened_on_startup)) save_occt_view_settings(); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("When enabled, EzyCad opens the last .ezy file you opened (path is stored in settings). Click ? to " "open the user guide.", @@ -1340,6 +1373,7 @@ void GUI::settings_() ImGui::SameLine(); if (ImGui::Button("Clear saved startup")) clear_saved_startup_project_(); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); GUI_DOC_HELP_("Save the current document (geometry, view, and tool mode) as what loads when EzyCad starts. If none is " "saved, the install default (res/default.ezy) is used. Click ? to open the user guide.", @@ -1369,6 +1403,7 @@ void GUI::settings_() if (!ini.empty()) ImGui::LoadIniSettingsFromMemory(ini.c_str(), ini.size()); } + show_message("Default settings applied."); } catch (...) @@ -1388,6 +1423,7 @@ void GUI::underlay_highlight_color_rgba(uint8_t& r, uint8_t& g, uint8_t& b, uint const float x = std::clamp(c, 0.f, 1.f) * 255.f; return static_cast(x + 0.5f); }; + r = to_u8(m_underlay_highlight_color[0]); g = to_u8(m_underlay_highlight_color[1]); b = to_u8(m_underlay_highlight_color[2]); @@ -1401,6 +1437,7 @@ void GUI::elm_list_hover_color_rgba(uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& const float x = std::clamp(c, 0.f, 1.f) * 255.f; return static_cast(x + 0.5f); }; + r = to_u8(m_elm_list_hover_color[0]); g = to_u8(m_elm_list_hover_color[1]); b = to_u8(m_elm_list_hover_color[2]); @@ -1414,6 +1451,7 @@ void GUI::imgui_rounding_fallbacks_from_theme_(float& general, float& scroll, fl ImGui::StyleColorsDark(&s); else ImGui::StyleColorsLight(&s); + s.ScaleAllSizes(ImGui::GetStyle().FontScaleDpi); general = s.WindowRounding; scroll = s.ScrollbarRounding; @@ -1431,3 +1469,29 @@ void GUI::apply_imgui_rounding_from_members_() st.GrabRounding = m_imgui_rounding_scroll; st.TabRounding = m_imgui_rounding_tabs; } + +namespace +{ +/// `occt_view` JSON object: view background gradient and grid (shared with `save_occt_view_settings` / +/// `occt_view_settings_json`). +nlohmann::json build_occt_view_settings_object_(const Occt_view& view) +{ + float bg1[3], bg2[3], g1[3], g2[3]; + view.get_bg_gradient_colors(bg1, bg2); + view.get_grid_colors(g1, g2); + const int method = view.get_bg_gradient_method(); + Occt_grid_rect_params grid_rect{}; + view.get_occt_grid_rect_params(grid_rect); + return nlohmann::json{ + {"bg_color1", {bg1[0], bg1[1], bg1[2]}}, + {"bg_color2", {bg2[0], bg2[1], bg2[2]}}, + {"bg_gradient_method", method}, + {"grid_color1", {g1[0], g1[1], g1[2]}}, + {"grid_color2", {g2[0], g2[1], g2[2]}}, + {"grid_step", grid_rect.step}, + {"grid_padding", grid_rect.grid_padding}, + {"grid_graphic_z_offset", grid_rect.graphic_z_offset}, + {"grid_visible", view.get_grid_visible()}, + }; +} +} // namespace \ No newline at end of file diff --git a/src/gui_modes.cpp b/src/mode.cpp similarity index 97% rename from src/gui_modes.cpp rename to src/mode.cpp index 991f3b6..50ad9de 100644 --- a/src/gui_modes.cpp +++ b/src/mode.cpp @@ -1,4 +1,4 @@ -#include "gui_modes.h" +#include "mode.h" Mode mode_from_string(std::string_view name) { diff --git a/src/gui_modes.h b/src/mode.h similarity index 100% rename from src/gui_modes.h rename to src/mode.h diff --git a/src/occt_view.cpp b/src/occt_view.cpp index a618b42..2a77001 100644 --- a/src/occt_view.cpp +++ b/src/occt_view.cpp @@ -23,9 +23,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -39,11 +37,8 @@ #include #include #include -#include -#include #include #include -#include #include #include @@ -71,8 +66,6 @@ #include -#include -#include #include #include @@ -155,10 +148,10 @@ void Occt_view::init_viewer() aViewer->SetDefaultShadingModel(Graphic3d_TypeOfShadingModel_Phong); m_ctx = new AIS_InteractiveContext(aViewer); -#else +#else // __EMSCRIPTEN__ Handle(Aspect_DisplayConnection) aDisp; - Handle(OpenGl_GraphicDriver) aDriver = new OpenGl_GraphicDriver(aDisp, false); - aDriver->ChangeOptions().buffersNoSwap = true; // swap has no effect in WebGL + Handle(OpenGl_GraphicDriver) aDriver = new OpenGl_GraphicDriver(aDisp, false); + aDriver->ChangeOptions().buffersNoSwap = true; // swap has no effect in WebGL #if defined(WASM_BISECT_DISABLE_OPAQUE_ALPHA) aDriver->ChangeOptions().buffersOpaqueAlpha = false; #else @@ -217,11 +210,11 @@ void Occt_view::init_viewer() #endif m_view->SetImmediateUpdate(false); - auto& params = m_view->ChangeRenderingParams(); - params.ToShowStats = true; - params.ShadowMapResolution = 1024; - params.OitDepthFactor = 0.0; - params.Resolution = (unsigned int)(96.0 * myDevicePixelRatio + 0.5); + auto& params = m_view->ChangeRenderingParams(); + params.ToShowStats = true; + params.ShadowMapResolution = 1024; + params.OitDepthFactor = 0.0; + params.Resolution = (unsigned int)(96.0 * myDevicePixelRatio + 0.5); #ifdef __EMSCRIPTEN__ #if defined(WASM_BISECT_DISABLE_MSAA) params.NbMsaaSamples = 0; @@ -250,16 +243,8 @@ void Occt_view::init_viewer() params.TransparencyMethod = Graphic3d_RTM_BLEND_UNORDERED; #endif - // m_view->SetScale(1000.0); // Treat coordinates as millimeters - // m_view->SetScale(39.3701); - capture_occt_grid_rect_from_viewer_(aViewer); - // Grid visibility/colors set in apply_grid_visibility_() / refresh_viewer_grid_() - // aViewer->SetGridEcho(true); - // aViewer->Grid()->SetDrawMode(Aspect_GridDrawMode::Aspect_GDM_Points); - - // aViewer->SetComputedMode(false); - // Enable anti-aliasing through the viewer + aViewer->SetDefaultComputedMode(true); aViewer->SetDefaultShadingModel(Graphic3d_TypeOfShadingModel_Phong); aViewer->SetDefaultLights(); @@ -268,7 +253,6 @@ void Occt_view::init_viewer() m_ctx->SetAutoActivateSelection(true); // Enable automatic selection auto highlight_style = m_ctx->HighlightStyle(); - // highlight_style->SetDisplayMode(1); Handle(Graphic3d_AspectFillArea3d) fill_aspect = new Graphic3d_AspectFillArea3d(); fill_aspect->SetAlphaMode(Graphic3d_AlphaMode::Graphic3d_AlphaMode_Blend); fill_aspect->SetColor(Quantity_Color(0.1, 0.1, 0.1, Quantity_TOC_RGB)); @@ -276,19 +260,7 @@ void Occt_view::init_viewer() highlight_style->SetBasicFillAreaAspect(fill_aspect); highlight_style->SetTransparency(0.8f); // Seems to not do anything - /* - Handle(Prs3d_LineAspect) line_aspect = new Prs3d_LineAspect( - Quantity_NOC_GREEN, // Color - Aspect_TOL_SOLID, // Line type - 5.0 // Width - ); - //highlight_style->SetLineAspect(line_aspect); - highlight_style->SetWireAspect(line_aspect); - */ - m_ctx->SetHighlightStyle(highlight_style); - // m_ctx->SetAutomaticHilight(false); - // m_ctx->SetSelectionStyle(highlight_style); m_ctx->SetPixelTolerance(10); // Picking? m_ctx->UpdateCurrentViewer(); @@ -304,17 +276,15 @@ void Occt_view::init_viewer() Handle(AIS_ViewCube) myViewCube = new AIS_ViewCube(); myViewCube->SetTransformPersistence( new Graphic3d_TransformPers(Graphic3d_TMF_TriedronPers, Aspect_TOTP_RIGHT_LOWER, NCollection_Vec2(100, 100))); + myViewCube->Attributes()->SetDatumAspect(new Prs3d_DatumAspect()); - // myViewCube->Attributes()->DatumAspect()->SetTextAspect(myTextStyle); - // animation parameters + + // Animation parameters myViewCube->SetViewAnimation(myViewAnimation); myViewCube->SetFixedAnimationLoop(false); myViewCube->SetAutoStartAnimation(true); m_ctx->Display(myViewCube, false); - // opencascade::handle env_map = new Graphic3d_TextureEnv("c:/src/pngwing.com.png"); - // m_view->SetTextureEnv(env_map); - m_default_material = Graphic3d_MaterialAspect(Graphic3d_NOM_CHROME); } @@ -447,6 +417,7 @@ void Occt_view::init_default() aGlInfo += TCollection_AsciiString(" ") + aKey + ": " + aValue; } } + Message::DefaultMessenger()->Send(TCollection_AsciiString("OpenGL info:\n") + aGlInfo, Message_Info); SetStickToRayOnRotation(true); @@ -455,15 +426,9 @@ void Occt_view::init_default() m_view->SetProj(0, 0, 1); // Look up the Z-axis (top view) m_view->SetUp(0, 1, 0); // Up direction along Y-axis - - // Optional: Adjust zoom or fit content m_view->SetZoom(2.0); - // apply_grid_visibility_(); - - // load(); - // m_view->FitAll(); } // Geometry related @@ -472,6 +437,7 @@ ScreenCoords Occt_view::get_screen_coords(const gp_Pnt& point) int screen_x; int screen_y; m_view->Convert(point.X(), point.Y(), point.Z(), screen_x, screen_y); + return ScreenCoords(dvec2(screen_x, screen_y)); } @@ -584,14 +550,17 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode) shp_move().cancel(); gui().set_mode(Mode::Normal); break; + case Mode::Rotate: shp_rotate().cancel(); gui().set_mode(Mode::Normal); break; + case Mode::Scale: shp_scale().cancel(); gui().set_mode(Mode::Normal); break; + default: operation_canceled |= cancel_sketch_extrude_(); operation_canceled |= curr_sketch().cancel_elm(); @@ -625,7 +594,6 @@ void Occt_view::revolve_selected(const double angle) void Occt_view::create_sketch_from_planar_face_(const ScreenCoords& screen_coords) { if (auto face = get_face_(screen_coords); face) - { if (auto pln = plane_from_face(*face); pln) { // Push only when we will create a sketch (avoids no-op undo on misclick). @@ -642,7 +610,6 @@ void Occt_view::create_sketch_from_planar_face_(const ScreenCoords& screen_coord } else gui().show_message("Error: Selected face is not planar. Please select a planar face."); - } } void Occt_view::finalize_sketch_extrude_() @@ -1243,9 +1210,9 @@ void set_grid_colors_on_viewer_(const V3d_Viewer_ptr& viewer, const glm::vec3& c Occt_view::Grid_layout Occt_view::compute_grid_layout_() const { - 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(); + 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; @@ -1285,8 +1252,8 @@ Occt_view::Grid_layout Occt_view::compute_grid_layout_() const 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)) + 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; } @@ -1396,10 +1363,10 @@ 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_padding = 0.0; - constexpr double min_step = 1e-6; - constexpr double default_step = 10.0; - constexpr double default_padding = 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.grid_padding) || g.grid_padding < 0.0) g.grid_padding = default_padding; @@ -1751,8 +1718,7 @@ void Occt_view::apply_sketch_list_measurement_hover_style_() uint8_t r{}, g{}, b{}, a{}; gui().elm_list_hover_color_rgba(r, g, b, a); (void)a; - const float hover_rgb[3] = {static_cast(r) / 255.f, static_cast(g) / 255.f, - static_cast(b) / 255.f}; + const float hover_rgb[3] = {static_cast(r) / 255.f, static_cast(g) / 255.f, static_cast(b) / 255.f}; const Length_dimension_style& style = gui().length_dimension_style(); const double base_w = static_cast(style.line_width); @@ -1762,10 +1728,7 @@ void Occt_view::apply_sketch_list_measurement_hover_style_() m_ctx->Redisplay(m_sketch_list_measurement_hover, false); } -void Occt_view::refresh_sketch_list_measurement_hover_highlight_() -{ - apply_sketch_list_measurement_hover_style_(); -} +void Occt_view::refresh_sketch_list_measurement_hover_highlight_() { apply_sketch_list_measurement_hover_style_(); } void Occt_view::refresh_shape_list_hover_highlight() { @@ -2220,7 +2183,7 @@ size_t Occt_view::redo_stack_size() const { return m_redo_stack.size(); } // Document format: 1 = legacy sketch edges could carry a 4th "dim" flag; 2 = length_dimensions array + 3-tuple edges. namespace { -constexpr int k_ezy_file_format_version = 2; +constexpr int k_ezy_file_format_version = 3; } // --------------------------------------------------------------------------- @@ -2240,7 +2203,7 @@ std::string Occt_view::to_json() const // --------------------------------------------------------------------------- // Sketches / shapes for (const Sketch_ptr& s : m_sketches) - sketches.push_back(Sketch_json::to_json(*s)); + sketches.push_back(Sketch_json::to_json(*s, m_assets)); for (const Shp_ptr& s : m_shps) { @@ -2549,6 +2512,7 @@ void Occt_view::new_file() push_undo_snapshot(); remove(m_shps); clear_all(m_shps, m_sketches, m_cur_sketch); + m_assets.clear(); create_default_sketch_(); refresh_viewer_grid_(); diff --git a/src/occt_view.h b/src/occt_view.h index 1430a8c..3cbe081 100644 --- a/src/occt_view.h +++ b/src/occt_view.h @@ -26,6 +26,7 @@ #include "shp_rotate.h" #include "shp_scale.h" #include "utl_types.h" +#include "ezy_asset_store.h" class Delta; class GUI; @@ -84,6 +85,8 @@ class Occt_view : protected AIS_ViewController std::string to_json() const; void load(const std::string& json_str, bool restore_view = true); + Ezy_asset_store& asset_store() { return m_assets; } + const Ezy_asset_store& asset_store() const { return m_assets; } [[nodiscard]] Status import_step(const std::string& step_data); bool import_ply(const std::string& ply_bytes); @@ -337,6 +340,7 @@ class Occt_view : protected AIS_ViewController std::vector m_undo_stack; std::vector m_redo_stack; bool m_restoring{false}; + Ezy_asset_store m_assets; // -------------------------------------------------------------------- // Dimension related diff --git a/src/scr_lua_console.cpp b/src/scr_lua_console.cpp index 427caa7..86d0e2e 100644 --- a/src/scr_lua_console.cpp +++ b/src/scr_lua_console.cpp @@ -2,7 +2,7 @@ #include "gui.h" #include "imgui.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "shp.h" #include "sketch.h" diff --git a/src/scr_python_console.cpp b/src/scr_python_console.cpp index 4c709f5..ee48703 100644 --- a/src/scr_python_console.cpp +++ b/src/scr_python_console.cpp @@ -9,7 +9,7 @@ #include "gui.h" #include "imgui.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "shp.h" #include "sketch.h" diff --git a/src/shp_chamfer.cpp b/src/shp_chamfer.cpp index 8fa64c9..d67a437 100644 --- a/src/shp_chamfer.cpp +++ b/src/shp_chamfer.cpp @@ -7,7 +7,7 @@ #include #include -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" Shp_chamfer::Shp_chamfer(Occt_view& view) diff --git a/src/shp_fillet.cpp b/src/shp_fillet.cpp index 92610b5..33fc48a 100644 --- a/src/shp_fillet.cpp +++ b/src/shp_fillet.cpp @@ -6,7 +6,7 @@ #include #include -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" Shp_fillet::Shp_fillet(Occt_view& view) diff --git a/src/shp_polar_dup.cpp b/src/shp_polar_dup.cpp index 32dad94..1ad671e 100644 --- a/src/shp_polar_dup.cpp +++ b/src/shp_polar_dup.cpp @@ -9,7 +9,7 @@ #include "utl_geom.h" #include "gui.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "sketch.h" #include "sketch_nodes.h" diff --git a/src/shp_scale.cpp b/src/shp_scale.cpp index 919ce34..33bb557 100644 --- a/src/shp_scale.cpp +++ b/src/shp_scale.cpp @@ -2,7 +2,7 @@ #include "utl_geom.h" #include "gui.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "utl.h" diff --git a/src/sketch.cpp b/src/sketch.cpp index 5e41311..c0755df 100644 --- a/src/sketch.cpp +++ b/src/sketch.cpp @@ -27,7 +27,7 @@ #include "utl_geom.h" #include "gui.h" #include "imgui.h" -#include "gui_modes.h" +#include "mode.h" #include "occt_view.h" #include "sketch_delta.h" #include "sketch_underlay.h" @@ -3101,7 +3101,7 @@ bool Sketch::load_underlay_image(const std::string& file_bytes) const int h = std::get<2>(*dec); if (!m_underlay) m_underlay = std::make_unique(); - if (!m_underlay->set_image_rgba(std::move(rgba), w, h)) + if (!m_underlay->set_image_rgba(std::move(rgba), w, h, m_view.asset_store())) return false; uint8_t hr, hg, hb, ha; m_view.gui().underlay_highlight_color_rgba(hr, hg, hb, ha); @@ -3460,7 +3460,9 @@ void Sketch::sync_permanent_node_annos_() const Mode mode = get_mode(); // Show "+" markers for permanent user nodes in sketch modes and polar duplicate (which snaps to sketch nodes). - const bool show_permanent_marks = is_sketch_mode(mode) || mode == Mode::Shape_polar_duplicate; + // Hide during face extrude so face selection is unobstructed. + const bool show_permanent_marks = + mode != Mode::Sketch_face_extrude && (is_sketch_mode(mode) || mode == Mode::Shape_polar_duplicate); const bool hide_for_operation_axis = operation_axis_suppresses_sketch_snap_(); for (size_t i = 0, n = m_nodes.size(); i < n; ++i) diff --git a/src/sketch_json.cpp b/src/sketch_json.cpp index 63222fa..818b881 100644 --- a/src/sketch_json.cpp +++ b/src/sketch_json.cpp @@ -16,6 +16,8 @@ #include "sketch.h" #include "sketch_nodes.h" #include "sketch_underlay.h" +#include "ezy_asset_store.h" +#include "occt_view.h" #include "utl_json.h" using json = nlohmann::json; @@ -135,7 +137,7 @@ void Sketch_json::from_json_legacy_coords_(Sketch& ret, const json& j, } } -nlohmann::json Sketch_json::to_json(const Sketch& sketch) +nlohmann::json Sketch_json::to_json(const Sketch& sketch, const Ezy_asset_store& assets) { json j; j["isCurrent"] = sketch.is_current(); @@ -223,7 +225,7 @@ nlohmann::json Sketch_json::to_json(const Sketch& sketch) } if (sketch.m_underlay && sketch.m_underlay->has_image()) - j["underlay"] = sketch.m_underlay->to_json(); + j["underlay"] = sketch.m_underlay->to_json(assets); if (sketch.m_operation_axis.has_value()) { @@ -303,7 +305,7 @@ Sketch::sptr Sketch_json::from_json(Occt_view& view, const nlohmann::json& j) if (j.contains("underlay") && j["underlay"].is_object()) { auto ul = std::make_unique(); - if (ul->from_json(j["underlay"])) + if (ul->from_json(j["underlay"], view.asset_store())) { ret->m_underlay = std::move(ul); if (ret->m_visible && ret->m_underlay->has_image()) diff --git a/src/sketch_json.h b/src/sketch_json.h index 1d4a956..4be3f87 100644 --- a/src/sketch_json.h +++ b/src/sketch_json.h @@ -9,11 +9,12 @@ class Sketch; class Occt_view; +class Ezy_asset_store; class Sketch_json { public: - static nlohmann::json to_json(const Sketch& sketch); + static nlohmann::json to_json(const Sketch& sketch, const Ezy_asset_store& assets); static std::shared_ptr from_json(Occt_view& view, const nlohmann::json& j); diff --git a/src/sketch_underlay.cpp b/src/sketch_underlay.cpp index 4749dc5..442031f 100644 --- a/src/sketch_underlay.cpp +++ b/src/sketch_underlay.cpp @@ -23,6 +23,7 @@ #include #include "utl_geom.h" +#include "ezy_asset_store.h" using namespace glm; @@ -270,7 +271,7 @@ void Sketch_underlay::remove_ais_(AIS_InteractiveContext& ctx) } } -bool Sketch_underlay::set_image_rgba(std::vector&& rgba, int w, int h) +bool Sketch_underlay::set_image_rgba(std::vector&& rgba, int w, int h, Ezy_asset_store& store) { if (w <= 0 || h <= 0 || rgba.size() < static_cast(w) * static_cast(h) * 4u) return false; @@ -281,9 +282,10 @@ bool Sketch_underlay::set_image_rgba(std::vector&& rgba, int w, int h) if (rgba.size() > k_max_rgba_bytes) return false; - m_rgba = std::move(rgba); - m_w = w; - m_h = h; + m_asset_id = store.register_rgba(rgba, w, h); + m_rgba = store.get(m_asset_id); + m_w = w; + m_h = h; // Default: centered at plane origin, 0.1 model units per image pixel (adjust in UI). const double s = 0.1; @@ -440,7 +442,7 @@ void Sketch_underlay::build_ais_(const gp_Pln& pln, AIS_InteractiveContext& ctx) return; face = faceMk.Face(); - pix = make_pixmap_bottom_up_linear(m_rgba.data(), m_w, m_h, m_key_white_transparent, m_line_tint_enabled, m_tint_r, + pix = make_pixmap_bottom_up_linear(m_rgba->data(), m_w, m_h, m_key_white_transparent, m_line_tint_enabled, m_tint_r, m_tint_g, m_tint_b, m_tint_a); } else @@ -480,7 +482,7 @@ void Sketch_underlay::build_ais_(const gp_Pln& pln, AIS_InteractiveContext& ctx) return; face = faceMk.Face(); - pix = make_pixmap_bottom_up_warped(m_rgba.data(), m_w, m_h, m_axis_u, m_axis_v, m_key_white_transparent, + pix = make_pixmap_bottom_up_warped(m_rgba->data(), m_w, m_h, m_axis_u, m_axis_v, m_key_white_transparent, m_line_tint_enabled, m_tint_r, m_tint_g, m_tint_b, m_tint_a); } } @@ -506,7 +508,7 @@ void Sketch_underlay::build_ais_(const gp_Pln& pln, AIS_InteractiveContext& ctx) return; face = faceMk.Face(); - pix = make_pixmap_bottom_up_linear(m_rgba.data(), m_w, m_h, m_key_white_transparent, m_line_tint_enabled, m_tint_r, + pix = make_pixmap_bottom_up_linear(m_rgba->data(), m_w, m_h, m_key_white_transparent, m_line_tint_enabled, m_tint_r, m_tint_g, m_tint_b, m_tint_a); if (!pix.IsNull()) @@ -608,13 +610,16 @@ void Sketch_underlay::redisplay(AIS_InteractiveContext& ctx) ctx.Redisplay(m_ais, true); } -nlohmann::json Sketch_underlay::to_json() const +nlohmann::json Sketch_underlay::to_json(const Ezy_asset_store& store) const { using nlohmann::json; json j; if (!has_image()) return j; - j["rgba_b64"] = base64_encode(m_rgba.data(), m_rgba.size()); + if (m_asset_id.empty()) + return j; + (void)store; + j["asset"] = m_asset_id; j["w"] = m_w; j["h"] = m_h; j["base"] = json::object({{"x", m_base.X()}, {"y", m_base.Y()}}); @@ -629,27 +634,40 @@ nlohmann::json Sketch_underlay::to_json() const return j; } -bool Sketch_underlay::from_json(const nlohmann::json& j) +bool Sketch_underlay::from_json(const nlohmann::json& j, Ezy_asset_store& store) { using nlohmann::json; - if (!j.is_object() || !j.contains("rgba_b64") || !j.contains("w") || !j.contains("h")) + if (!j.is_object() || !j.contains("w") || !j.contains("h")) return false; const int w = j.at("w").get(); const int h = j.at("h").get(); if (w <= 0 || h <= 0 || w > k_max_image_dim || h > k_max_image_dim) return false; - std::vector decoded; - if (!base64_decode(j.at("rgba_b64").get(), decoded)) - return false; - if (decoded.size() < static_cast(w) * static_cast(h) * 4u) - return false; - if (decoded.size() > k_max_rgba_bytes) + if (j.contains("asset") && j["asset"].is_string()) + { + m_asset_id = j["asset"].get(); + m_rgba = store.get(m_asset_id); + if (!m_rgba || m_rgba->size() < static_cast(w) * static_cast(h) * 4u) + return false; + } + else if (j.contains("rgba_b64")) + { + std::vector decoded; + if (!base64_decode(j.at("rgba_b64").get(), decoded)) + return false; + if (decoded.size() < static_cast(w) * static_cast(h) * 4u) + return false; + if (decoded.size() > k_max_rgba_bytes) + return false; + m_asset_id = store.register_rgba(decoded, w, h); + m_rgba = store.get(m_asset_id); + } + else return false; - m_rgba = std::move(decoded); - m_w = w; - m_h = h; + m_w = w; + m_h = h; if (j.contains("base")) m_base = gp_Pnt2d(j["base"].at("x").get(), j["base"].at("y").get()); diff --git a/src/sketch_underlay.h b/src/sketch_underlay.h index d4618a9..a42ab31 100644 --- a/src/sketch_underlay.h +++ b/src/sketch_underlay.h @@ -15,6 +15,7 @@ class AIS_TexturedShape; class AIS_Shape; class AIS_InteractiveContext; class gp_Pln; +class Ezy_asset_store; /// Raster image drawn in the sketch plane (below sketch edges) for tracing / digitizing. class Sketch_underlay @@ -27,10 +28,10 @@ class Sketch_underlay Sketch_underlay(Sketch_underlay&&) = delete; Sketch_underlay& operator=(Sketch_underlay&&) = delete; - [[nodiscard]] bool has_image() const { return !m_rgba.empty() && m_w > 0 && m_h > 0; } + [[nodiscard]] bool has_image() const { return m_rgba && !m_rgba->empty() && m_w > 0 && m_h > 0; } - /// Replace image (RGBA8). Sets a default axis-aligned rectangle in plane coords (0.1 unit per pixel). - [[nodiscard]] bool set_image_rgba(std::vector&& rgba, int w, int h); + /// Replace image (RGBA8). Registers pixels in \a store. Sets default axis-aligned rectangle (0.1 unit per pixel). + [[nodiscard]] bool set_image_rgba(std::vector&& rgba, int w, int h, Ezy_asset_store& store); void set_affine(const gp_Pnt2d& base, const gp_Vec2d& axis_u, const gp_Vec2d& axis_v); /// Center \a center, half width/height \a half_extents.x/.y, rotation in degrees (matches Sketch UI transform). @@ -75,17 +76,18 @@ class Sketch_underlay /// Force viewer update after live property changes (e.g. threshold) without a full rebuild. void redisplay(AIS_InteractiveContext& ctx); - nlohmann::json to_json() const; + nlohmann::json to_json(const Ezy_asset_store& store) const; /// Returns false if JSON is invalid or image decode fails. - [[nodiscard]] bool from_json(const nlohmann::json& j); + [[nodiscard]] bool from_json(const nlohmann::json& j, Ezy_asset_store& store); private: void remove_ais_(AIS_InteractiveContext& ctx); void build_ais_(const gp_Pln& pln, AIS_InteractiveContext& ctx); - std::vector m_rgba; - int m_w{0}; - int m_h{0}; + std::shared_ptr> m_rgba; + std::string m_asset_id; + int m_w{0}; + int m_h{0}; gp_Pnt2d m_base{0., 0.}; gp_Vec2d m_axis_u{100., 0.}; diff --git a/src/utl.cpp b/src/utl.cpp index fb37915..fe6452c 100644 --- a/src/utl.cpp +++ b/src/utl.cpp @@ -103,4 +103,16 @@ std::size_t Pair_hash::operator()(const std::pair& p) const seed ^= std::hash{}(p.first) + 0x9e3779b9 + (seed << 6) + (seed >> 2); seed ^= std::hash{}(p.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2); return seed; +} + +// Portable safe copy for fixed-size char buffers used with ImGui::InputText. +// Uses strncpy_s (with _TRUNCATE) on MSVC to avoid C4996; falls back elsewhere. +void safe_cstr_copy(char* dest, size_t dest_size, const char* src) +{ +#ifdef _MSC_VER + strncpy_s(dest, dest_size, src, _TRUNCATE); +#else + std::strncpy(dest, src, dest_size - 1); + dest[dest_size - 1] = '\0'; +#endif } \ No newline at end of file diff --git a/src/utl.h b/src/utl.h index bdfa68f..da773d9 100644 --- a/src/utl.h +++ b/src/utl.h @@ -137,4 +137,8 @@ std::optional, int, int>> decode_image_bytes(con void disable_shape_highlighting(const AIS_Shape_ptr& ais_shape, const AIS_InteractiveContext_ptr& context, bool disable_selection = false); +// Portable safe copy for fixed-size char buffers used with ImGui::InputText. +// Uses strncpy_s (with _TRUNCATE) on MSVC to avoid C4996; falls back elsewhere. +void safe_cstr_copy(char* dest, size_t dest_size, const char* src); + #include "utl.inl" diff --git a/src/utl_settings.cpp b/src/utl_settings.cpp index 5a54ce4..966bf87 100644 --- a/src/utl_settings.cpp +++ b/src/utl_settings.cpp @@ -1,5 +1,7 @@ #include "utl_settings.h" +#include "ezy_io.h" + #include #include #include @@ -200,9 +202,14 @@ std::string load_user_startup_project() }); if (!ptr) return {}; - std::string result((const char*)ptr); + std::string stored((const char*)ptr); free(ptr); - return result; + if (stored.rfind("B64:", 0) == 0) + { + const std::vector decoded = ezy_base64_decode(stored.substr(4)); + return std::string(reinterpret_cast(decoded.data()), decoded.size()); + } + return stored; #else const std::filesystem::path p = user_startup_project_path(); if (p.empty()) @@ -216,10 +223,11 @@ std::string load_user_startup_project() #endif } -bool save_user_startup_project(const std::string& json) +bool save_user_startup_project(const std::vector& ezy_bytes) { #ifdef __EMSCRIPTEN__ - EM_ASM_({ localStorage.setItem('ezycad_startup_ezy', UTF8ToString($0)); }, json.c_str()); + const std::string b64 = std::string("B64:") + ezy_base64_encode(ezy_bytes); + EM_ASM_({ localStorage.setItem('ezycad_startup_ezy', UTF8ToString($0)); }, b64.c_str()); return true; #else const std::filesystem::path p = user_startup_project_path(); @@ -232,7 +240,7 @@ bool save_user_startup_project(const std::string& json) std::ofstream f(p, std::ios::binary); if (!f) return false; - f.write(json.data(), static_cast(json.size())); + f.write(reinterpret_cast(ezy_bytes.data()), static_cast(ezy_bytes.size())); return true; #endif } diff --git a/src/utl_settings.h b/src/utl_settings.h index e319659..0b65769 100644 --- a/src/utl_settings.h +++ b/src/utl_settings.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace settings { @@ -32,6 +33,6 @@ std::filesystem::path user_startup_project_path(); // Optional startup project (Blender-style). Native: user_startup_project_path(); Wasm: localStorage. std::string load_user_startup_project(); -bool save_user_startup_project(const std::string& json); +bool save_user_startup_project(const std::vector& ezy_bytes); void clear_user_startup_project(); } // namespace settings diff --git a/tests/doc_link_tests.cpp b/tests/doc_link_tests.cpp index 4064e58..74c09ac 100644 --- a/tests/doc_link_tests.cpp +++ b/tests/doc_link_tests.cpp @@ -1,7 +1,7 @@ #include #include "gui.h" -#include "gui_modes.h" +#include "mode.h" #include #include diff --git a/tests/sketch_tests.cpp b/tests/sketch_tests.cpp index 162d05f..7984bcb 100644 --- a/tests/sketch_tests.cpp +++ b/tests/sketch_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "utl_geom.h" #include "gui.h" @@ -16,6 +17,8 @@ #include "sketch.h" #include "sketch_delta.h" #include "sketch_json.h" +#include "ezy_io.h" +#include "ezy_asset_store.h" #include "utl_occt.h" using namespace glm; @@ -352,17 +355,17 @@ TEST_F(Sketch_test, AddLinearEdge_MidpointOption) EXPECT_TRUE(sketch.get_nodes()[*it->node_idx_mid].midpoint); // Verify JSON shape: no mid => 2-element edge array; with mid => 3 elements - nlohmann::json j_no_mid = Sketch_json::to_json(sketch); // current state has one with mid + nlohmann::json j_no_mid = Sketch_json::to_json(sketch, view().asset_store()); // current state has one with mid // To test no-mid JSON, recreate a sketch with flag off Sketch sketch2("TestSketch2", view(), default_plane); Sketch::set_add_mid_pt_edges(false); Sketch_access::add_edge_(sketch2, p1, p2); - nlohmann::json j2 = Sketch_json::to_json(sketch2); + nlohmann::json j2 = Sketch_json::to_json(sketch2, view().asset_store()); EXPECT_EQ(j2["edges"][0].size(), 2u) << "Edge without mid should serialize as 2-element array [a,b]"; Sketch::set_add_mid_pt_edges(true); Sketch_access::add_edge_(sketch2, p3, p4); - nlohmann::json j3 = Sketch_json::to_json(sketch2); + nlohmann::json j3 = Sketch_json::to_json(sketch2, view().asset_store()); EXPECT_EQ(j3["edges"][1].size(), 3u) << "Edge with mid should serialize as 3-element array [a,b,mid]"; // Turn off again for subsequent tests (though fixture resets) @@ -1592,7 +1595,7 @@ TEST_F(Sketch_test, JsonSerializationDeserialization) } // Serialize to JSON - nlohmann::json json_data = Sketch_json::to_json(sketch); + nlohmann::json json_data = Sketch_json::to_json(sketch, view().asset_store()); // Verify JSON structure EXPECT_TRUE(json_data.contains("name")); @@ -1672,8 +1675,8 @@ TEST_F(Sketch_test, JsonSerializationDifferentEdgeCounts) } // Serialize both sketches - nlohmann::json json1 = Sketch_json::to_json(sketch1); - nlohmann::json json2 = Sketch_json::to_json(sketch2); + nlohmann::json json1 = Sketch_json::to_json(sketch1, view().asset_store()); + nlohmann::json json2 = Sketch_json::to_json(sketch2, view().asset_store()); // Verify different edge counts EXPECT_EQ(json1["edges"].size(), 3); @@ -1713,7 +1716,7 @@ TEST_F(Sketch_test, JsonSerializationWithDimensions) Sketch_access::add_edge_(sketch, pt1, pt2); - nlohmann::json json_data = Sketch_json::to_json(sketch); + nlohmann::json json_data = Sketch_json::to_json(sketch, view().asset_store()); // Endpoints remap to dense indices 0 and 1 (midpoint is 2) json_data["length_dimensions"] = nlohmann::json::array({nlohmann::json::array({0, 1})}); @@ -1734,7 +1737,7 @@ TEST_F(Sketch_test, JsonLegacyIndexedEdgeDimFlagMigratesToLengthDimensions) Sketch sketch("TestSketch", view(), default_plane); Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0)); - nlohmann::json j = Sketch_json::to_json(sketch); + nlohmann::json j = Sketch_json::to_json(sketch, view().asset_store()); j["edges"][0].push_back(true); j.erase("length_dimensions"); @@ -1748,7 +1751,7 @@ TEST_F(Sketch_test, EzyDocumentJsonIncludesFormatVersion) { const nlohmann::json doc = nlohmann::json::parse(view().to_json()); ASSERT_TRUE(doc.contains("ezyFormat")); - EXPECT_EQ(doc["ezyFormat"].get(), 2); + EXPECT_EQ(doc["ezyFormat"].get(), 3); } // Test bridge edge removal - rectangle with inner rectangle connected by bridge @@ -2163,7 +2166,7 @@ TEST_F(Sketch_test, OperationAxis_JSON_RoundTrip) sketch.add_sketch_pt(ScreenCoords(dvec2(axis_end.X(), axis_end.Y()))); ASSERT_TRUE(sketch.has_operation_axis()); - const nlohmann::json j = Sketch_json::to_json(sketch); + const nlohmann::json j = Sketch_json::to_json(sketch, view().asset_store()); ASSERT_TRUE(j.contains("operation_axis")); ASSERT_TRUE(j["operation_axis"].is_array()); EXPECT_EQ(j["operation_axis"].size(), 2u); @@ -2175,7 +2178,7 @@ TEST_F(Sketch_test, OperationAxis_JSON_RoundTrip) const std::shared_ptr loaded = Sketch_json::from_json(view(), j); ASSERT_TRUE(loaded->has_operation_axis()); - const nlohmann::json j2 = Sketch_json::to_json(*loaded); + const nlohmann::json j2 = Sketch_json::to_json(*loaded, view().asset_store()); ASSERT_TRUE(j2.contains("operation_axis")); EXPECT_NEAR(j2["operation_axis"][0]["x"].get(), axis_start.X(), 1e-8); EXPECT_NEAR(j2["operation_axis"][0]["y"].get(), axis_start.Y(), 1e-8); @@ -2906,4 +2909,134 @@ TEST_F(Sketch_test, AddNode_near_edge_snaps_onto_segment_and_splits) } } EXPECT_TRUE(found_at_seven); +} + +namespace +{ + +nlohmann::json minimal_sketch_json_with_underlay_b64(Ezy_asset_store& store) +{ + std::vector rgba(16); + for (int i = 0; i < 4; ++i) + { + rgba[static_cast(i) * 4u + 0] = 255; + rgba[static_cast(i) * 4u + 1] = 0; + rgba[static_cast(i) * 4u + 2] = 0; + rgba[static_cast(i) * 4u + 3] = 255; + } + + nlohmann::json j; + j["isCurrent"] = true; + j["name"] = "UnderlaySketch"; + j["plane"] = nlohmann::json::object({{"origin", {{"x", 0.0}, {"y", 0.0}, {"z", 0.0}}}, + {"normal", {{"x", 0.0}, {"y", 0.0}, {"z", 1.0}}}, + {"xAxis", {{"x", 1.0}, {"y", 0.0}, {"z", 0.0}}}}); + j["edges"] = nlohmann::json::array(); + j["nodes"] = nlohmann::json::array(); + j["arc_edges"] = nlohmann::json::array(); + j["underlay"] = nlohmann::json::object({{"rgba_b64", ezy_base64_encode(rgba)}, + {"w", 2}, + {"h", 2}, + {"opacity", 0.88}, + {"visible", true}}); + (void)store; + return j; +} + +} // namespace + +TEST_F(Sketch_test, EzyZipUnderlayRoundTrip) +{ + view().asset_store().clear(); + nlohmann::json doc = nlohmann::json::parse(view().to_json()); + doc["sketches"] = nlohmann::json::array({minimal_sketch_json_with_underlay_b64(view().asset_store())}); + const std::string load = doc.dump(); + view().load(load, false); + + const auto sk = view().curr_sketch_shared(); + ASSERT_TRUE(sk); + ASSERT_TRUE(sk->has_underlay()); + EXPECT_EQ(sk->underlay_image_w(), 2); + EXPECT_EQ(sk->underlay_image_h(), 2); + + const std::string manifest = view().to_json(); + const std::vector zip_bytes = pack_ezy(manifest, view().asset_store()); + ASSERT_FALSE(zip_bytes.empty()); + ASSERT_TRUE(is_ezy_zip(std::string(reinterpret_cast(zip_bytes.data()), zip_bytes.size()))); + + auto unpacked = unpack_ezy(std::string(reinterpret_cast(zip_bytes.data()), zip_bytes.size())); + ASSERT_TRUE(unpacked); + EXPECT_FALSE(unpacked->manifest_json.empty()); + EXPECT_EQ(unpacked->assets.size(), 1u); + + view().asset_store().clear(); + for (auto& [id, data] : unpacked->assets) + view().asset_store().import_asset(id, std::move(data)); + + view().load(unpacked->manifest_json, false); + const auto loaded = view().curr_sketch_shared(); + ASSERT_TRUE(loaded); + EXPECT_TRUE(loaded->has_underlay()); + EXPECT_EQ(loaded->underlay_image_w(), 2); + EXPECT_EQ(loaded->underlay_image_h(), 2); +} + +TEST_F(Sketch_test, EzyZipUnderlayDedup) +{ + view().asset_store().clear(); + nlohmann::json sk1 = minimal_sketch_json_with_underlay_b64(view().asset_store()); + nlohmann::json sk2 = minimal_sketch_json_with_underlay_b64(view().asset_store()); + sk1["name"] = "A"; + sk2["name"] = "B"; + sk2["isCurrent"] = false; + nlohmann::json doc; + doc["ezyFormat"] = 3; + doc["shapes"] = nlohmann::json::array(); + doc["sketches"] = nlohmann::json::array({sk1, sk2}); + view().load(doc.dump(), false); + + const std::string manifest = view().to_json(); + const std::vector zip_bytes = pack_ezy(manifest, view().asset_store()); + const auto unpacked = unpack_ezy(std::string(reinterpret_cast(zip_bytes.data()), zip_bytes.size())); + ASSERT_TRUE(unpacked); + EXPECT_EQ(unpacked->assets.size(), 1u); + + const nlohmann::json out = nlohmann::json::parse(unpacked->manifest_json); + std::set asset_ids; + for (const nlohmann::json& sk : out["sketches"]) + { + if (sk.contains("underlay") && sk["underlay"].is_object() && sk["underlay"].contains("asset")) + asset_ids.insert(sk["underlay"]["asset"].get()); + } + EXPECT_EQ(asset_ids.size(), 1u); + for (const nlohmann::json& sk : out["sketches"]) + { + if (sk.contains("underlay") && sk["underlay"].is_object()) + EXPECT_FALSE(sk["underlay"].contains("rgba_b64")); + } +} + +TEST_F(Sketch_test, LegacyUnderlayRgbaB64Load) +{ + view().asset_store().clear(); + const auto sk = Sketch_json::from_json(view(), minimal_sketch_json_with_underlay_b64(view().asset_store())); + ASSERT_TRUE(sk); + EXPECT_TRUE(sk->has_underlay()); + const nlohmann::json saved = Sketch_json::to_json(*sk, view().asset_store()); + EXPECT_TRUE(saved.contains("underlay")); + EXPECT_TRUE(saved["underlay"].contains("asset")); + EXPECT_FALSE(saved["underlay"].contains("rgba_b64")); +} + +TEST_F(Sketch_test, UnderlayUndoSnapshotUsesAssetRef) +{ + view().asset_store().clear(); + nlohmann::json doc = nlohmann::json::parse(view().to_json()); + doc["sketches"] = nlohmann::json::array({minimal_sketch_json_with_underlay_b64(view().asset_store())}); + view().load(doc.dump(), false); + + view().push_undo_snapshot(); + const std::string snap = view().to_json(); + EXPECT_EQ(snap.find("rgba_b64"), std::string::npos); + EXPECT_NE(snap.find("\"asset\""), std::string::npos); } \ No newline at end of file