diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c99a981..b8a86bbc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -287,6 +287,8 @@ if(BLCAD_BUILD_GUI) src/gui/gui_selection_model.cpp src/gui/gui_sketch_interaction.cpp src/gui/gui_sketch_interaction_binder.cpp + src/gui/gui_sketch_drag.cpp + src/gui/gui_sketch_drag_binder.cpp src/gui/gui_sketch_workbench.cpp src/gui/gui_spatial_surface_workbench.cpp src/gui/gui_task_state.cpp @@ -554,6 +556,7 @@ if(BLCAD_BUILD_TESTS) tests/gui/gui_part_foundation_workbench_tests.cpp tests/gui/gui_part_operations_workbench_tests.cpp tests/gui/gui_sketch_interaction_tests.cpp + tests/gui/gui_sketch_drag_tests.cpp tests/gui/gui_sketch_workbench_tests.cpp tests/gui/gui_spatial_surface_workbench_tests.cpp tests/gui/gui_feature_coverage_acceptance_tests.cpp diff --git a/README.md b/README.md index 7a2265a3..a0feb689 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,16 @@ page. The assembly sequence is implemented through Block 47, Part Construction MVP-6 is complete through Block 94, and GUI Feature Validation MVP-7 is accepted through Block 105. Interactive Sketcher MVP-8 -is in progress: Blocks 106–109 implement the contextual Sketch workspace, device-independent plane -interaction, deterministic hit/selection/grid/snap/inference behavior, shared planar point/entity -topology with stable `SketchPointId`, dependency-safe topology editing and migration, and a -deterministic headless general planar constraint solver with normalized residuals, Jacobian-rank DOF, -stable redundancy/conflict attribution, and explicit convergence/reference diagnostics. The canonical -contracts are [`docs/gui-interactive-sketch-workspace-mvp8.md`](docs/gui-interactive-sketch-workspace-mvp8.md), +is in progress: Blocks 106–110 implement the contextual Sketch workspace, device-independent plane +interaction, stable shared `SketchPointId` topology, deterministic general planar solving with exact +local DOF/conflict diagnostics, and solver-backed semantic-handle mouse dragging with latest-pointer +coalescing, exact final release solve, live non-mutating preview, rollback, and one atomic undoable +release commit. Canonical contracts are [`docs/gui-interactive-sketch-workspace-mvp8.md`](docs/gui-interactive-sketch-workspace-mvp8.md), [`docs/gui-sketch-plane-interaction-mvp8.md`](docs/gui-sketch-plane-interaction-mvp8.md), -[`docs/sketch-shared-topology-mvp8.md`](docs/sketch-shared-topology-mvp8.md), and -[`docs/sketch-planar-constraint-solver-mvp8.md`](docs/sketch-planar-constraint-solver-mvp8.md). Block -110, solver-backed Sketch mouse dragging, is next in -[`docs/interactive-sketcher-sequence-mvp8.md`](docs/interactive-sketcher-sequence-mvp8.md). +[`docs/sketch-shared-topology-mvp8.md`](docs/sketch-shared-topology-mvp8.md), +[`docs/sketch-planar-constraint-solver-mvp8.md`](docs/sketch-planar-constraint-solver-mvp8.md), and +[`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md). Block 111, basic Sketch +creation tools, is next in [`docs/interactive-sketcher-sequence-mvp8.md`](docs/interactive-sketcher-sequence-mvp8.md). The optional Qt desktop covers document, Sketch, Part, Surface, Assembly, motion, analysis, and STEP-export validation workflows without moving authority out of Core/Geometry. Blocks 122–131 plan @@ -46,6 +45,11 @@ Start here: - [`docs/file-format.md`](docs/file-format.md) — save-format authority - [`docs/sketch-shared-topology-mvp8.md`](docs/sketch-shared-topology-mvp8.md) — shared planar topology/migration - [`docs/sketch-planar-constraint-solver-mvp8.md`](docs/sketch-planar-constraint-solver-mvp8.md) — planar solver/DOF/diagnostics +- [`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md) — semantic handles/live solver drag/atomic release +- [`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md) — semantic handles/live solver drag/atomic release +- [`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md) — semantic handles/live solver drag/atomic release +- [`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md) — semantic handles/live solver drag/atomic release +- [`docs/gui-sketch-solver-drag-mvp8.md`](docs/gui-sketch-solver-drag-mvp8.md) — semantic handles/live solver drag/atomic release ## License diff --git a/docs/architecture-summary.md b/docs/architecture-summary.md index 2664e7fc..fdbe26cd 100644 --- a/docs/architecture-summary.md +++ b/docs/architecture-summary.md @@ -347,7 +347,7 @@ that derived posed state. Flattened and structured assembly exchange derive stable product/occurrence identity; raw XDE/STEP entity identity is never model authority. -## Qt GUI architecture through Block 109 +## Qt GUI architecture through Block 110 `GuiDocumentSession` owns the current Project candidate/committed document view, recompute state, semantic selection bridge, and exact GUI undo/redo snapshots. Generic command/task state remains @@ -363,10 +363,19 @@ Block 108 adds Core shared point/entity identity and editable topology. The curr builder still projects historical Sketch compatibility intent; a sampled interaction point is not silently promoted to `SketchPointId`. -Block 109 adds a real Core producer for remaining DOF and solve state. The current Sketch status row -already has DOF/Solve presentation slots, but direct publication into continuous GUI drag belongs to -Block 110. Widgets must call the Core solver and render its derived result rather than duplicate -constraint mathematics. +Block 109 adds the Core producer for remaining DOF and solve state. + +Block 110 adds the first continuous GUI solver consumer. `GuiSketchDragController` derives stable +semantic handles from Block-108 point/entity identity and translates drag intent to transient Block-109 +Coincident, Midpoint, Concentric, or Radial equations. The temporary pointer/center ids are removed from +the solved topology before publication. Preview topology must losslessly materialize and re-migrate. + +`GuiSketchDragBinder` coalesces pointer moves to the latest pending sample and synchronously flushes the +exact release sample. Live preview rebuilds transient interaction presentation and publishes exact DOF/ +solve state without document mutation. Successful release rechecks topology and constraint-system +freshness and commits one `GuiDocumentSession` transaction. Cancellation, lost capture, solve refusal, +or stale commit restores the pre-drag document/presentation state. Widgets still do not own constraint +mathematics. ## Persistence and regeneration split @@ -391,7 +400,7 @@ Sketch legacy migration equivalence groups and reports solver variables / residual vectors / Jacobians / rank / DOF SketchSolveResult and solver diagnostics Block-107 interaction samples / screen mapping / hit stacks / grid / snap candidates -future Block-110 drag equations and live preview candidates +Block-110 semantic drag handles / pointer samples / augmented drag equations / live preview candidates Assembly solve/motion proposals and freshness snapshots posed occurrence shapes contact / interference / sweep analysis @@ -401,9 +410,9 @@ GUI hover / preview / rubber-band / HUD staging ## Current boundary -Blocks 106–109 are implemented. Block 110 is the current next technical step. +Blocks 106–110 are implemented. Block 111 is the current next technical step. -Block 110 connects semantic Sketch handles and Block-107 pointer mapping to disposable Block-108 -topology candidates and Block-109 solving. Drag preview remains transient; release commits one -validated document transaction, while `Esc`, lost capture, fixed geometry, or failed solve restores the -pre-drag snapshot. +Block 111 adds basic point/line/polyline/rectangle/parallelogram/polygon/centerline/construction +creation over the existing workspace, plane mapping, shared topology, solver, and document transaction +authorities. Creation commands must not turn Block-107 snap candidates or Block-110 handle positions +into implicit persistent identity. diff --git a/docs/development-setup.md b/docs/development-setup.md index 011ddf21..ab4c2c0a 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -115,7 +115,7 @@ QT_QPA_PLATFORM=offscreen ctest --test-dir build/dev-gui -R '^gui\.' --output-on ## Interactive Sketcher focused proof -Blocks 106–109 are implemented. +Blocks 106–110 are implemented. Block 106 workspace and command lifecycle: @@ -162,11 +162,23 @@ The Block-109 proof covers: - deterministic non-convergence classification; - adaptation of current persisted geometric constraints and parameter-backed dimensions. -The current implementation handoff is Block 110. Its focused tags are: +Block 110 solver-backed semantic-handle drag and live solve: + +```bash +QT_QPA_PLATFORM=offscreen ./build/dev-gui/blcad_gui_tests "[gui][sketch-drag]" +QT_QPA_PLATFORM=offscreen ./build/dev-gui/blcad_gui_tests "[integration][sketch-live-solve]" +``` + +The proof covers stable handle order and shared-junction deduplication, latest-pointer coalescing, exact +release flush, source-document immutability during preview, cancel/refusal rollback, Arc center/radius +solver targets, one `Drag sketch handle` session history entry, exact undo/redo, and an offscreen Qt +Press/Move/Release path through the installed binder. + +The current implementation handoff is Block 111. Its focused tags are: ```text -[gui][sketch-drag] -[integration][sketch-live-solve] +[gui][sketch-create-basic] +[integration][sketch-basic-profile] ``` ## Existing GUI validation tags @@ -318,6 +330,81 @@ Block-109 public Core boundary: include/blcad/core/sketch_constraint_solver.hpp ``` +Block-110 public GUI boundaries: + +```text +include/blcad/gui/gui_sketch_drag.hpp +include/blcad/gui/gui_sketch_drag_binder.hpp +``` + +Registered Block-110 implementation/proof: + +```text +src/gui/gui_sketch_drag.cpp +src/gui/gui_sketch_drag_binder.cpp +tests/gui/gui_sketch_drag_tests.cpp +``` + +Block-110 public GUI boundaries: + +```text +include/blcad/gui/gui_sketch_drag.hpp +include/blcad/gui/gui_sketch_drag_binder.hpp +``` + +Registered Block-110 implementation/proof: + +```text +src/gui/gui_sketch_drag.cpp +src/gui/gui_sketch_drag_binder.cpp +tests/gui/gui_sketch_drag_tests.cpp +``` + +Block-110 public GUI boundaries: + +```text +include/blcad/gui/gui_sketch_drag.hpp +include/blcad/gui/gui_sketch_drag_binder.hpp +``` + +Registered Block-110 implementation/proof: + +```text +src/gui/gui_sketch_drag.cpp +src/gui/gui_sketch_drag_binder.cpp +tests/gui/gui_sketch_drag_tests.cpp +``` + +Block-110 public GUI boundaries: + +```text +include/blcad/gui/gui_sketch_drag.hpp +include/blcad/gui/gui_sketch_drag_binder.hpp +``` + +Registered Block-110 implementation/proof: + +```text +src/gui/gui_sketch_drag.cpp +src/gui/gui_sketch_drag_binder.cpp +tests/gui/gui_sketch_drag_tests.cpp +``` + +Block-110 public GUI boundaries: + +```text +include/blcad/gui/gui_sketch_drag.hpp +include/blcad/gui/gui_sketch_drag_binder.hpp +``` + +Registered Block-110 implementation/proof: + +```text +src/gui/gui_sketch_drag.cpp +src/gui/gui_sketch_drag_binder.cpp +tests/gui/gui_sketch_drag_tests.cpp +``` + `SketchTopology`/`SketchPointId` are persistent Core topology identity. `SketchConstraintSystem` is a canonical solve request. `SketchSolveResult`, variable order, residual summary, Jacobian rank, remaining DOF, and solver diagnostics are derived. @@ -341,8 +428,13 @@ clang-format -i \ include/blcad/core/sketch_constraint_solver.hpp \ src/core/sketch_constraint_solver.cpp \ src/core/sketch_solver_legacy_adapter.cpp \ + include/blcad/gui/gui_sketch_drag.hpp \ + include/blcad/gui/gui_sketch_drag_binder.hpp \ + src/gui/gui_sketch_drag.cpp \ + src/gui/gui_sketch_drag_binder.cpp \ tests/core/sketch_tests.cpp \ - tests/core/sketch_constraint_solver_tests.cpp + tests/core/sketch_constraint_solver_tests.cpp \ + tests/gui/gui_sketch_drag_tests.cpp ``` When adding a block, register new translation units/tests in `CMakeLists.txt` and document exact scope @@ -363,11 +455,16 @@ rm -rf build/ - `docs/interactive-sketcher-sequence-mvp8.md`: Blocks 106–121 phase authority - `docs/sketch-shared-topology-mvp8.md`: Block-108 topology/migration/edit/persistence contract - `docs/sketch-planar-constraint-solver-mvp8.md`: Block-109 solver/DOF/diagnostics contract +- `docs/gui-sketch-solver-drag-mvp8.md`: Block-110 semantic handles/live solve/atomic drag contract +- `docs/gui-sketch-solver-drag-mvp8.md`: Block-110 semantic handles/live solve/atomic drag contract +- `docs/gui-sketch-solver-drag-mvp8.md`: Block-110 semantic handles/live solve/atomic drag contract +- `docs/gui-sketch-solver-drag-mvp8.md`: Block-110 semantic handles/live solve/atomic drag contract +- `docs/gui-sketch-solver-drag-mvp8.md`: Block-110 semantic handles/live solve/atomic drag contract ## Current development boundary -Blocks 106–109 are implemented. Block 110 is next. +Blocks 106–110 are implemented. Block 111 is next. -Block 110 exposes semantic Sketch handles and uses Block-107 mapping, Block-108 topology snapshots, and -Block-109 solving for live drag preview. Release commits one validated transaction; cancellation, lost -capture, fixed geometry, or failed solve restores the exact pre-drag snapshot. +Block 111 adds basic point/line/polyline/rectangle/parallelogram/polygon/centerline/construction +creation. It reuses current Sketch workspace staging, Block-107 snap/inference, Block-108 topology +identity/edit commands, and Block-109 solver authority. diff --git a/docs/file-format.md b/docs/file-format.md index 1c88a4b7..1a5b8f50 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -2,7 +2,7 @@ Status: implemented save-format seeds exist for single-part model intent including persistent Solid/Surface Body records, Feature Body-result operations, Body Booleans, BodyTransform stacks, and SketchOwnership records; assembly parameters, embedded Project JSON, part component occurrences, rigid child assembly occurrences, local Mate/Concentric/Distance/Insert/Angle intent, Project-level cross-hierarchy geometric intent, local and occurrence-qualified Revolute joint intent with typed `coordinates[]` plus historical scalar compatibility, semantic generated feature/axis/seat targets, `ref:` reference-geometry targets, canonical `topo:` generated-topology semantic targets, and authored transform/state records. -The save format stores parametric and semantic model intent. OCCT shapes, hierarchy traversal state, occurrence graphs, transform authorities, generated-topology producer classification/recovery results, resolved geometry, residuals, Jacobians, solve/motion results, freshness snapshots, proposals, diagnostics, and exchange products are derived. +The save format stores parametric and semantic model intent. OCCT shapes, hierarchy traversal state, occurrence graphs, transform authorities, generated-topology producer classification/recovery results, resolved geometry, residuals, Jacobians, solve/motion results, freshness snapshots, proposals, diagnostics, Block-110 semantic drag handles, pointer samples, temporary drag targets/augmented topology, live previews, and exchange products are derived. Block 110 adds no JSON schema field. ## Project structure diff --git a/docs/gui-interactive-sketch-workspace-mvp8.md b/docs/gui-interactive-sketch-workspace-mvp8.md index 525aa9d2..8e5f0566 100644 --- a/docs/gui-interactive-sketch-workspace-mvp8.md +++ b/docs/gui-interactive-sketch-workspace-mvp8.md @@ -1,8 +1,8 @@ # Interactive Sketch Workspace MVP-8 -Status: implemented in Block 106. Block 107 supplies plane-interaction producers, Block 108 supplies -persistent shared point/entity topology, and Block 109 supplies the deterministic headless solver/DOF -authority consumed by later GUI interaction. +Status: implemented in Block 106. Blocks 107–109 supply plane interaction, shared topology, and the +headless solver/DOF authority. Block 110 now implements the `SelectedHandle -> DragCandidate` live-solve +consumer and one-transaction release commit. This document is the canonical GUI contract for the contextual planar Sketch workspace introduced by Block 106. It extends the Block-95/96 command/transaction rules and Block-99 Sketch workbench. It does @@ -118,7 +118,7 @@ Preview / DragCandidate -> Preview There is no second GUI transaction or undo authority. Block-108 `SketchTopologyUndoStack` is a headless Core snapshot utility. Document-level GUI commits still use session transaction/history authority. -Block 110 fills the `SelectedHandle -> DragCandidate` path with Block-109 solving. +Block 110 fills `SelectedHandle -> DragCandidate` with semantic handle selection, live Block-109 solving, and exact rollback/commit behavior. ## Escape and focus rules @@ -131,8 +131,9 @@ CollectingPicks -> cancel command -> Idle Hover -> Idle ``` -A selected-handle/drag-candidate command cancels atomically to Idle. Block 110 owns exact pre-drag -snapshot restoration and solver-preview cleanup. +A selected-handle/drag-candidate command cancels atomically to Idle. Block 110 restores the pre-drag +interaction scene, clears its pending/processed pointer and solver preview, and leaves the persistent +document/history unchanged. Lost mouse capture and window deactivation use the same rollback policy. Numeric input owns keyboard focus in `NumericInput`; `Enter` accepts the current field for candidate validation. Canvas owns ordinary selection/placement focus. `MainWindow` routes unconsumed `Esc` to the @@ -209,9 +210,10 @@ Block 109 -> headless solve state, exact local remaining DOF, solver diagnostics Block 110 -> live drag solve invocation and status publication into GUI ``` -Block 109 means DOF/Solve now have a real Core producer. The existing GUI does not yet continuously -invoke that producer, so it may still display `DOF: —` / `Solve: Not evaluated` outside a later -solver-aware command. Block 110 owns the first live publication during drag. +Block 109 provides the Core producer and Block 110 is the first continuous GUI consumer. Entering an +editable Sketch builds a baseline solve request; baseline and live drag publication update the existing +remaining-DOF and solve-status labels. The UI renders `SketchSolveResult` and never estimates DOF from +endpoint or glyph counts. The UI must render `SketchSolveResult` status/remaining DOF; it must not infer them from endpoint counts, constraint glyph counts, or topology migration. @@ -243,8 +245,9 @@ Block-108 topology commands provide exact Core before/after snapshots. The histo compatibility bridge requires lossless re-migration before atomic `PartDocument::update_sketch(...)`. Block 109 returns a disposable solved topology; it does not choose a document commit boundary. -Block 110 must solve disposable candidates and commit exactly one validated document transaction on -successful release. +Block 110 solves disposable candidates, strips transient drag identities, requires lossless preview +materialization/re-migration, flushes the exact release pointer, and commits exactly one validated +`Drag sketch handle` document transaction on successful release. ## Failure policy @@ -296,7 +299,6 @@ Block 109: ## Next boundary -Block 110 uses Block-107 pointer mapping, Block-108 semantic topology handles, and Block-109 solving for -live direct manipulation. It freezes handle identity, transient drag target semantics, throttled live -preview without dropping the final pointer position, fixed-geometry refusal, cancellation/lost-capture -rollback, and one atomic document commit on release. +Block 111 adds basic point, line, continuous polyline, rectangle families, parallelogram, regular +polygon, centerline, and construction-geometry creation. It reuses Block-107 snap/inference, Block-108 +topology commands, Block-109 solving, and the existing command/task lifecycle. diff --git a/docs/gui-sketch-plane-interaction-mvp8.md b/docs/gui-sketch-plane-interaction-mvp8.md index e84342c2..9dff6c41 100644 --- a/docs/gui-sketch-plane-interaction-mvp8.md +++ b/docs/gui-sketch-plane-interaction-mvp8.md @@ -1,8 +1,7 @@ # Sketch Plane Interaction MVP-8 -Status: implemented in Block 107. Block 108 supplies persistent shared topology identity and Block 109 -supplies deterministic constraint solving. Block 110 is the first direct-manipulation consumer that -connects those Core authorities to this transient plane interaction layer. +Status: implemented in Block 107. Blocks 108–109 supply persistent topology and solving. Block 110 is +implemented as the first direct-manipulation consumer of fresh mapped/snapped pointer state. This document is the canonical GUI interaction contract for Block 107. It extends the contextual Sketch workspace with device-independent mapping and one deterministic transient authority for hover, @@ -125,9 +124,10 @@ Repeated selection at the same screen position cycles the deterministic hit stac when pointer movement exceeds tolerance or hit signature changes. Signature uses hit kind plus stable transient candidate id, never AIS owner address or OCCT traversal order. -Block 110 may add explicit semantic handle presentation ahead of normal Sketch hits, but handle identity -must resolve to Block-108 point/entity roles. It must not reuse arbitrary Block-107 candidate ids as -solver identity. +Block 110 renders semantic handles in a separate overlay collection and performs deterministic handle +hit testing within 9 DIP, ordered by screen distance then stable handle id. This does not modify the +frozen Block-107 Point/Curve/Dimension/Glyph hit stack or `GuiSelectionModel`; every handle still +resolves explicitly to Block-108 point/entity roles. ## Window and Crossing selection @@ -267,8 +267,10 @@ The separation is deliberate. A pixel-near endpoint hit may choose a visual cand Core topology establishes shared point identity and only stable topology point/entity ids can become solver targets. -Block 109 evaluates exact Core topology definitions. It does not consume interaction samples, -intersection approximations, or screen distances. +Block 109 evaluates exact Core topology definitions. Block 110 adds separate drag-move and Press/Release +callbacks to `OcctViewport`: pointer/snap/hit state is refreshed before Press and Release, moves may be +coalesced, and Release synchronously flushes the exact final snapped point. The solver still does not +consume interaction samples, approximated curves, or screen distances. ## Failure policy @@ -311,7 +313,6 @@ Block 109: ## Next boundary -Block 110 exposes semantic Sketch handles and connects this plane-space pointer authority to Block-108 -point/entity identity and Block-109 solving. Live drag solves disposable candidates and publishes -transient preview. Release commits one validated transaction; `Esc`, lost capture, fixed geometry, or -failed solve restores the exact pre-drag snapshot. +Block 111 consumes the same active-plane mapping and snap/inference authority for multi-click creation. +Accepted picks must create or reference explicit Block-108 topology identity; transient snap candidate +ids remain presentation/query state. diff --git a/docs/gui-sketch-solver-drag-mvp8.md b/docs/gui-sketch-solver-drag-mvp8.md new file mode 100644 index 00000000..259974d8 --- /dev/null +++ b/docs/gui-sketch-solver-drag-mvp8.md @@ -0,0 +1,420 @@ +# Solver-Backed Sketch Drag MVP-8 + +Status: implemented in Block 110. + +This document is the canonical GUI/Core integration contract for solver-backed planar Sketch mouse +dragging. Block 110 composes the Block-107 plane interaction layer, Block-108 shared topology identity, +Block-109 deterministic constraint solver, and the existing GUI document transaction/undo authority. + +The GUI does not implement constraint mathematics. A pointer position becomes a transient semantic drag +target; `SketchConstraintSolver` remains the sole solve authority. + +## Authority boundary + +```text +screen pointer + -> Block-107 Screen-DIP / active-plane mapping and snap + -> GuiSketchDragHandle + -> stable SketchPointId or SketchTopologyEntity role + -> transient solver target point/entity/value + -> Block-109 SketchConstraintSolver + -> source-only solved SketchTopology preview + -> lossless historical Sketch materialization + -> viewport preview + +release + -> exact final pointer flush + -> current-document/source freshness check + -> lossless materialization + exact re-migration check + -> GuiDocumentSession::commit_part_transaction(...) + -> one recomputed document history entry +``` + +`GuiSketchDragController` owns the headless GUI-layer drag candidate. `GuiSketchDragBinder` connects the +controller to Qt pointer phases and presentation. `OcctViewport` owns only event delivery and transient +handle overlay. + +## Semantic drag handles + +The implemented handle families are: + +```text +Endpoint +Midpoint +Center +Radius +Arc +Spline +Dimension +``` + +Stable handle ids are derived from the active `SketchId` and persistent topology identity: + +```text +sketch//handle/point/ +sketch//handle/entity//midpoint +sketch//handle/entity//center +sketch//handle/entity//radius +sketch//handle/entity//arc +sketch//handle/entity//spline/control1 +sketch//handle/entity//spline/control2 +sketch//handle/dimension/ +``` + +Handle records are sorted lexicographically by stable id. + +### Endpoint handles + +Line start/end, Arc start/end, and Spline start/end map to one persistent `SketchPointId`. + +A shared closed-profile junction creates one endpoint handle even when several topology entities +reference that point. The handle controls the shared point variable; Block 110 never searches for equal +coordinates or fans a move out to numerically equal endpoints. + +### Midpoint handles + +A Line midpoint handle addresses the persistent Line entity. The drag target uses the Block-109 +`Midpoint` family with a temporary reference point as the requested midpoint. + +The midpoint is not promoted to a persistent Sketch point merely because a handle is displayed. + +### Center handles + +Arc center is derived from the Arc's three persistent defining points. Dragging it uses a temporary +reference `CircleProfile` center and the Block-109 `Concentric` family. + +RectangleProfile, CircleProfile, and CircularHolePattern center handles map directly to their persistent +center `SketchPointId`. + +### Radius handles + +Block 110 exposes Arc radius dragging. The handle anchor is deterministic: it uses the Arc center and a +radial direction derived from the defining mid point rotated by 30 degrees. This keeps the radius handle +separate from the Arc defining-point handle. + +Pointer distance from the source Arc center becomes a positive millimeter target for the Block-109 +`Radial` family. + +Parameter-driven CircleProfile and CircularHolePattern sizes are not direct radius handles in Block +110. Their size authoring belongs to dimension/parameter workflows in Block 115. + +### Arc handles + +The Arc handle maps to the persistent three-point Arc `mid` point and therefore uses the ordinary +point-target drag path. The Arc start/end points are Endpoint handles. + +### Spline handles + +Cubic Bezier `control1` and `control2` points are explicit Spline handles and map to their persistent +`SketchPointId`. Spline endpoints use Endpoint handles. + +### Dimension handles + +Current historical driving dimensions address two explicit Line endpoint targets. Block 110 exposes a +Dimension handle at the second persistent target point and drags that point through the ordinary point +target path. + +The existing driving dimension remains in the base constraint system. Therefore an incompatible pointer +is refused as a conflicting solve rather than silently changing or deleting dimension intent. In-canvas +dimension value editing and reference/driven dimension behavior remain Block 115. + +## Read-only reference policy + +A handle is read-only when its underlying topology point/entity is reference state. `begin(...)` rejects +reference handles. + +Block-108 projected point/line records currently contain no invented resolved topology coordinates, so +they do not gain editable Block-110 handles from transient Block-107 projection samples. + +## Drag target translation + +`GuiSketchDragTargetKind` is: + +```text +Point +LineMidpoint +ArcCenter +ArcRadius +``` + +Block 110 translates each target into existing Block-109 mathematics. + +### Point + +An augmented disposable topology receives one reference point: + +```text +SketchPointId = __gui.drag.pointer +reference = true +position = snapped pointer plane coordinate +``` + +The transient constraint is: + +```text +Coincident(controlled SketchPointId, __gui.drag.pointer) +``` + +### LineMidpoint + +The transient constraint is: + +```text +Midpoint(__gui.drag.pointer, line entity) +``` + +### ArcCenter + +The augmented topology additionally receives a temporary reference CircleProfile entity: + +```text +entity = __gui.drag.center +center = __gui.drag.pointer +reference = true +``` + +The transient constraint is: + +```text +Concentric(arc entity, __gui.drag.center) +``` + +### ArcRadius + +No pointer identity is persisted. The positive source-center-to-pointer distance becomes: + +```text +Radial(arc entity, radius_mm) +``` + +### Stable transient constraint identity + +The drag equation id is: + +```text +zz.gui.drag.target +``` + +It sorts deterministically after ordinary persisted constraint ids. The id, temporary point, and +temporary center entity exist only in the disposable augmented topology/constraint system. + +## Removing transient identity from preview + +The raw Block-109 result may contain the temporary reference point/entity because the solve request was +augmented. + +Before preview publication, Block 110 rebuilds a source-only topology: + +1. iterate every point in the exact pre-drag source topology; +2. copy its solved position from the solver result by the same `SketchPointId`; +3. preserve source point flags; +4. copy the source entity and dependency collections exactly; +5. revalidate through `SketchTopology::create(...)`. + +No transient id reaches `GuiSketchDragPreview::topology()` or commit. + +The source-only topology is materialized with `SketchTopologyLegacyMaterializer`, re-migrated through +`SketchTopology::migrate_legacy(...)`, and required to compare exactly. A topology that cannot round-trip +without point-identity, flag, dependency, or orphan-state loss is refused before viewport preview. + +## Solve acceptance and refusal + +Accepted preview statuses are: + +```text +FullyConstrained +UnderConstrained +Redundant +``` + +The `Redundant` state is accepted because the transient drag equation may be redundant when the pointer +already lies at the current fully constrained solution. + +Refused statuses are: + +```text +Conflicting +NonConvergent +InvalidReference +``` + +Fixed or fully constrained geometry is not weakened. Block 110 appends the drag equation to the complete +persisted base system. An incompatible pointer therefore produces a failed solve/refusal; no constraint +or dimension is deleted. + +A refused live solve cancels the drag candidate, restores the pre-drag scene, clears the inference +anchor, and leaves the persistent document unchanged. + +## Pointer coalescing and final-sample rule + +`GuiSketchDragController::queue_pointer(...)` only overwrites one `pending_pointer`. + +The Qt binder schedules at most one zero-delay solve callback. When it runs: + +```text +consume latest pending pointer +solve once +publish latest valid preview +``` + +Several mouse moves before the callback therefore coalesce to one solve of the newest sample. + +Release has stronger semantics: + +```text +flush(final snapped pointer) +``` + +`flush(...)` overwrites any pending sample and synchronously solves the exact release position. Commit is +illegal while a pointer sample remains pending. A stale already-scheduled zero-delay callback observes +no active pending sample and becomes a no-op. + +The final pointer position is therefore never dropped by drag throttling/coalescing. + +## Preview publication + +`GuiSketchDragPreview` contains: + +```text +pointer +source-only solved SketchTopology +losslessly materialized temporary Sketch +published SketchSolveResult with source-only topology +``` + +The binder rebuilds the Block-107 interaction scene from the temporary Sketch and publishes handle +positions derived from the preview topology. + +`PartDocument`, `ShapeCache`, document history, and dependency/invalidation state are not mutated during +live preview. + +The existing Sketch status surface receives Block-109 `remaining_dof` and solve status during baseline +and live solve publication. + +## Mouse lifecycle + +Viewport pointer phases are explicit: + +```text +Press +Move samples +Release +``` + +`OcctViewport` refreshes raw/snap/hit state before publishing Press and Release. A separate Block-110 +drag pointer callback receives Move samples without replacing the Block-107 cursor/snap callback. + +Press performs deterministic handle hit testing in Screen DIP: + +```text +eligible distance <= 9 DIP +order by screen distance +then stable handle id +``` + +Handle hit testing is separate from Block-107 Point/Curve/Dimension/Glyph hit identity. Handle positions +are overlay state and never enter `GuiSelectionModel` or snap candidate identity. + +The workspace lifecycle is: + +```text +Idle/Hover + -> SelectedHandle + -> DragCandidate + -> successful release: commit -> Idle + or + -> cancel/refusal/lost capture: rollback -> Idle +``` + +A simple press/release without any move or pending sample cancels without creating a document history +entry. + +## Cancellation and lost capture + +`Esc` is observed by the drag binder before `MainWindow::keyPressEvent(...)` advances the existing +workspace state machine. The binder restores the source preview and clears the controller candidate; +the existing workspace `escape(...)` then cancels the generic task. + +`QEvent::UngrabMouse` and `QEvent::WindowDeactivate` are treated as lost pointer capture while a drag is +active. The binder restores source preview, cancels the controller and workspace task, clears the +inference anchor, and publishes no document mutation. + +## Atomic release commit + +A valid flushed preview commits through exactly one: + +```text +GuiDocumentSession::commit_part_transaction("Drag sketch handle", mutation) +``` + +The mutation re-reads the current Sketch from the cloned candidate PartDocument and checks: + +```text +current migrated topology == pre-drag source topology +current adapted constraint system == pre-drag source constraint system +``` + +This rejects stale release when Sketch geometry, constraints, dimensions, or bound parameter values +changed after preview began. + +The final source-only solved topology is materialized against the current Sketch, re-migrated, and +required to compare exactly before `PartDocument::update_sketch(...)`. + +The existing session transaction then recomputes the cloned Part and publishes it only after success. +One successful release therefore creates exactly one undo history entry. Undo restores the complete +pre-drag document snapshot and redo restores the complete committed result. + +A failed mutation or recompute leaves the current PartDocument and last valid ShapeCache unchanged and +creates no history entry. + +## Persistence + +Block 110 adds no JSON field or schema. + +Persisted state remains the existing Sketch/topology/document intent after a successful transaction. +The following remain transient/derived: + +```text +GuiSketchDragHandle +handle screen positions +active handle index +pending / processed pointer samples +__gui.drag.pointer +__gui.drag.center +zz.gui.drag.target +augmented topology / constraint system +GuiSketchDragPreview +live solver result / residuals / Jacobian / DOF +coalescing timer state +``` + +## Focused proof + +Focused tags: + +```text +[gui][sketch-drag] +[integration][sketch-live-solve] +``` + +The proof covers: + +- lexicographic stable handle order; +- one endpoint handle for one shared `SketchPointId` junction; +- endpoint, midpoint, center, radius, Arc, Spline-control, and Dimension handles; +- latest-pointer coalescing; +- source document unchanged during preview; +- exact final release sample flush; +- one atomic `Drag sketch handle` history entry; +- exact undo/redo document snapshots; +- explicit cancel rollback; +- incompatible fully constrained drag refusal; +- Arc radius and Arc center drag through Block-109 residual families; +- offscreen Qt mouse Press/Move/Release integration through the installed binder. + +## Next boundary + +Block 111 owns basic Sketch creation tools: point, two-point line, continuous polyline, rectangle +families, parallelogram, regular polygon, centerline, and construction geometry. Multi-click creation +reuses Block-107 snap/inference, Block-108 topology edit authority, Block-109 solving, and the Block-106 +command lifecycle. It does not add a second drag or solver authority. diff --git a/docs/gui-sketch-workbench-mvp7.md b/docs/gui-sketch-workbench-mvp7.md index e4e474d6..bc8b7caa 100644 --- a/docs/gui-sketch-workbench-mvp7.md +++ b/docs/gui-sketch-workbench-mvp7.md @@ -43,3 +43,14 @@ applied. Coverage includes atomic replacement/order preservation, construction and projected geometry, planar entities, constraints, coordinate mapping, semantic prompts, diagnostics/repair preview, undoable repair, and normal-to-plane camera activation. + +## Block-110 direct-manipulation integration + +The MVP-7 Sketch workbench remains a validation/transaction client over historical Sketch intent. Block +110 does not move live drag authority into `GuiSketchWorkbench`. `GuiSketchDragController` consumes +Block-108 topology and Block-109 solving; successful release enters the same +`GuiDocumentSession::commit_part_transaction(...)` authority used by validation workbenches. + +The final solved topology is materialized and re-migrated exactly before `PartDocument::update_sketch`. +Live handle positions, pointer samples, temporary drag constraints, and preview Sketches remain +transient. This preserves MVP-7 atomic recompute/undo semantics while adding direct manipulation. diff --git a/docs/interactive-sketcher-sequence-mvp8.md b/docs/interactive-sketcher-sequence-mvp8.md index 7fb3aa54..0fb4bdbc 100644 --- a/docs/interactive-sketcher-sequence-mvp8.md +++ b/docs/interactive-sketcher-sequence-mvp8.md @@ -1,6 +1,6 @@ # Interactive Sketcher Sequence MVP-8 -Status: in progress. Blocks 106–109 are implemented; Block 110 is the current next technical step. +Status: in progress. Blocks 106–110 are implemented; Block 111 is the current next technical step. Blocks 106–121 precede Interactive Modeling MVP-9 (Blocks 122–131) and STEP Import MVP-10 (Blocks 132–138). @@ -100,8 +100,8 @@ must be declared at the numbered boundary and proven headlessly before a GUI con 107 plane mapping, hit testing, box selection, grid, snapping, inference preview — implemented 108 shared planar point/entity topology, mutation commands, JSON migration, undo — implemented 109 deterministic planar constraint solver, DOF accounting, conflicts, diagnostics — implemented -110 solver-backed mouse dragging, handles, live preview, atomic commit — next -111 point, line, polyline, rectangle, polygon, construction-geometry creation +110 solver-backed mouse dragging, handles, live preview, atomic commit — implemented +111 point, line, polyline, rectangle, polygon, construction-geometry creation — next — next — next — next — next 112 circle, arc, ellipse, slot creation/editing 113 spline editing, continuity handles, Sketch text 114 manual and automatic geometric constraints with glyph interaction @@ -319,22 +319,33 @@ Canonical contract: `docs/sketch-planar-constraint-solver-mvp8.md`. Focused tags: `[core][sketch-solver]`, `[core][sketch-dof]`, `[core][sketch-conflict-diagnostics]`. -## Block 110 — Solver-backed mouse dragging — Current next technical step +## Block 110 — Solver-backed mouse dragging — Implemented -Expose endpoint, midpoint, center, radius, arc, spline, and dimension handles. Pointer movement maps to -a semantic drag target and asks Block 109 for a disposable candidate. Preview never mutates the -document. +`GuiSketchDragController` builds lexicographically ordered semantic Endpoint, Midpoint, Center, Radius, +Arc, Spline-control, and current Dimension-target handles from Block-108 topology. Shared junctions are +deduplicated by `SketchPointId`; handle screen positions are transient overlay state. -Release commits one validated topology/document transaction. `Esc`, lost capture, failed solve, or an -incompatible fixed/fully-constrained target restores the pre-drag snapshot. The final pointer position -is never dropped by throttling. +Point, line-midpoint, Arc-center, and Arc-radius drag targets translate to transient Block-109 +Coincident, Midpoint, Concentric, and Radial constraints. Temporary pointer/center ids and +`zz.gui.drag.target` are stripped from solve output before preview. The source-only solved topology must +materialize and re-migrate exactly before it can be shown or committed. -Existing authority: Blocks 106–109, `docs/gui-sketch-workbench-mvp7.md`, and the Block-96 GUI -transaction/undo contract. +Move samples coalesce into one latest pending pointer and one zero-delay solve callback. Release calls +`flush(...)` synchronously with the exact final snapped pointer before commit. Preview updates the +interaction scene, handles, remaining DOF, and solve status without `PartDocument` mutation. + +Conflicting/non-convergent/invalid-reference candidates, reference handles, or incompatible fully +constrained geometry are refused without weakening constraints. `Esc`, lost mouse capture, and window +deactivation restore the pre-drag scene and create no history entry. + +Successful release revalidates source topology and constraint-system freshness, then commits one +`Drag sketch handle` document transaction through the existing session recompute/undo authority. + +Canonical contract: `docs/gui-sketch-solver-drag-mvp8.md`. Focused tags: `[gui][sketch-drag]`, `[integration][sketch-live-solve]`. -## Block 111 — Basic creation tools +## Block 111 — Basic creation tools — Current next technical step Implement point, two-point line, continuous polyline, center/corner rectangle, three-point rectangle, parallelogram, regular polygon, centerline, and construction geometry. Multi-click commands reuse diff --git a/docs/mvp-plan.md b/docs/mvp-plan.md index 2aefac39..3bf54dc9 100644 --- a/docs/mvp-plan.md +++ b/docs/mvp-plan.md @@ -4,10 +4,10 @@ role: >- Implementation-sequence source of truth. Feature-specific documents remain canonical for exact contracts, formulas, persistence details, failure policies, ordering, and focused proofs. -implemented_through: Block 109 -current_block: 110 -current_boundary: Solver-backed Sketch mouse dragging, semantic handles, live preview, and atomic release commit -current_tag: "[gui][sketch-drag]" +implemented_through: Block 110 +current_block: 111 +current_boundary: Basic Sketch creation tools: point, line, polyline, rectangle families, polygon, centerline, and construction geometry +current_tag: "[gui][sketch-create-basic]" phase_status: mvp_1: "Single-part modeling — implemented" mvp_2: "Semantic references and richer sketch workflows — implemented" @@ -16,7 +16,7 @@ phase_status: mvp_5: "Assembly relationships, motion, hierarchy, analysis, exchange — Blocks 1–47 implemented" mvp_6: "Part Construction — Blocks 48–94 implemented; MVP complete" mvp_7: "GUI Feature Validation — Blocks 95–105 implemented; MVP complete" - mvp_8: "Interactive Sketcher — Blocks 106–109 implemented; Blocks 110–121 planned; Block 110 next" + mvp_8: "Interactive Sketcher — Blocks 106–110 implemented; Blocks 111–121 planned; Block 111 next" mvp_9: "Interactive Part & Assembly Modeling — Blocks 122–131 planned after Interactive Sketcher acceptance" mvp_10: "STEP Import — Blocks 132–138 planned after Interactive Modeling acceptance" --- @@ -30,13 +30,13 @@ mathematics, persistence spellings, migration rules, and failure policy. ## Current status ```text -implemented through Block 109 -current block Block 110 +implemented through Block 110 +current block Block 111 current phase Interactive Sketcher MVP-8 -current boundary solver-backed Sketch mouse dragging +current boundary basic Sketch creation tools ``` -Block 109 is implemented. Block 110 is the current next technical step. +Block 110 is implemented. Block 111 is the current next technical step. ## Phase map @@ -106,8 +106,8 @@ Frozen order: 107 plane mapping, hit testing, box selection, grid, snapping, inference preview — implemented 108 shared planar point/entity topology, mutation commands, JSON migration, undo — implemented 109 deterministic planar constraint solver, DOF accounting, conflicts, diagnostics — implemented -110 solver-backed mouse dragging, handles, live preview, atomic commit — next -111 point, line, polyline, rectangle, polygon, construction-geometry creation +110 solver-backed mouse dragging, handles, live preview, atomic commit — implemented +111 point, line, polyline, rectangle, polygon, construction-geometry creation — next — next — next — next — next 112 circle, arc, ellipse, slot creation/editing 113 spline editing, continuity handles, Sketch text 114 manual and automatic geometric constraints with glyph interaction @@ -237,51 +237,70 @@ Focused tags: [core][sketch-conflict-diagnostics] ``` -## Current next technical step — Block 110 +### Block 110 — Solver-backed Sketch mouse dragging — Implemented -Block 110 owns solver-backed direct manipulation over Blocks 106–109. +Block 110 adds stable semantic Endpoint, Midpoint, Center, Radius, Arc, Spline-control, and current +Dimension target handles. Handle identity resolves to existing `SketchPointId` or canonical topology +entity roles; shared profile junctions expose one endpoint handle for one shared point id. -Required boundary: +Pointer movement is translated to transient Block-109 Coincident, Midpoint, Concentric, or Radial +constraints. Temporary `__gui.drag.pointer`, `__gui.drag.center`, and `zz.gui.drag.target` identities +exist only in disposable solve requests. Before preview publication, transient topology is stripped and +the source-only solved topology must materialize and re-migrate exactly through the Block-108 legacy +compatibility bridge. + +`GuiSketchDragController` coalesces move samples by replacing one pending pointer. The Qt binder schedules +at most one zero-delay solve; `flush(final_pointer)` synchronously replaces any pending sample and solves +the exact release position. Commit is illegal while a sample remains pending, so throttling cannot drop +the final pointer. + +Live preview rebuilds the transient interaction scene and publishes Block-109 solve state/remaining DOF +without mutating `PartDocument`. Conflicting, non-convergent, invalid-reference, reference-geometry, or +incompatible fully constrained drags fail closed and restore the pre-drag snapshot. `Esc`, lost mouse +capture, and window deactivation also roll back without history. + +Successful release rechecks current topology and adapted constraint-system equality, requires lossless +materialization/re-migration, and commits exactly one +`GuiDocumentSession::commit_part_transaction("Drag sketch handle", ...)`. Undo/redo therefore restore +complete pre/post-drag document snapshots. + +Canonical contract: `docs/gui-sketch-solver-drag-mvp8.md`. + +Focused tags: ```text -semantic endpoint / midpoint / center / radius / arc / spline / dimension handles -screen pointer - -> Block-107 plane mapping and snap/inference - -> semantic SketchPointId/entity drag target - -> disposable Block-108 topology candidate - -> transient drag target equation - -> Block-109 solve - -> live viewport preview - -> release: one validated document transaction +[gui][sketch-drag] +[integration][sketch-live-solve] ``` -Freeze: +## Current next technical step — Block 111 -- handle identity and hit priority relative to normal Sketch hits; -- which topology points/entities each handle controls; -- temporary drag target semantics; -- solver throttling/coalescing without dropping the final pointer position; -- fixed/fully-constrained refusal behavior; -- `Esc` and lost-capture rollback; -- preview publication without PartDocument mutation; -- one undo entry on successful release; -- exact pre-drag snapshot restoration on failed solve or cancellation. +Block 111 owns basic creation tools over the implemented workspace, plane interaction, shared topology, +solver, and drag authorities. -Block 110 does not implement the broad creation surface from Block 111. +Required surface: -Existing authority: +```text +point +two-point line +continuous polyline +center/corner rectangle +three-point rectangle +parallelogram +regular polygon +centerline +construction geometry +``` -- `docs/gui-interactive-sketch-workspace-mvp8.md` -- `docs/gui-sketch-plane-interaction-mvp8.md` -- `docs/sketch-shared-topology-mvp8.md` -- `docs/sketch-planar-constraint-solver-mvp8.md` -- Block-96 GUI transaction/undo contract +Multi-click commands reuse Block-107 snap/inference and Block-106 command staging. Persistent additions +use Block-108 topology/edit authority and solved candidates use Block 109. Composite tools expand into +ordinary points, lines, and constraints rather than GUI-only primitives. Focused tags: ```text -[gui][sketch-drag] -[integration][sketch-live-solve] +[gui][sketch-create-basic] +[integration][sketch-basic-profile] ``` ## Remaining Interactive Sketcher sequence @@ -320,8 +339,8 @@ STEP Import MVP-10 is Blocks 132–138 and is canonical in `docs/step-import-seq ## Current handoff -Block 109 is implemented. Block 110 is next. +Block 110 is implemented. Block 111 is next. -Read the Block-106/107 GUI interaction contracts, `docs/sketch-shared-topology-mvp8.md`, and -`docs/sketch-planar-constraint-solver-mvp8.md`, then implement solver-backed semantic-handle dragging -before beginning creation tools in Block 111. +Read the Block-106/107 interaction contracts, `docs/sketch-shared-topology-mvp8.md`, +`docs/sketch-planar-constraint-solver-mvp8.md`, and `docs/gui-sketch-solver-drag-mvp8.md`, then implement +basic creation tools without introducing a second topology, solver, or transaction authority. diff --git a/docs/project-goal.md b/docs/project-goal.md index b95b548e..bf386967 100644 --- a/docs/project-goal.md +++ b/docs/project-goal.md @@ -48,17 +48,18 @@ The project grows through controlled headless vertical slices: 11. engineering modules. Phases 1–8 are implemented through GUI Feature Validation Block 105. Interactive Sketcher MVP-8 is in -progress with Blocks 106–109 implemented: +progress with Blocks 106–110 implemented: ```text 106 contextual planar Sketch workspace and command lifecycle 107 device-independent plane interaction / hit / box selection / grid / snap / inference 108 stable shared SketchPointId / SketchTopology / migration / edit commands / topology persistence 109 deterministic general planar constraint solver / exact local DOF / conflict and redundancy output +110 semantic Sketch handles / solver-backed live drag / rollback / exact final sample / atomic release ``` -Block 110 is the current next technical step and owns solver-backed mouse dragging, semantic handles, -live preview, rollback, and one atomic release commit. +Block 111 is the current next technical step and owns basic point, line, polyline, rectangle, polygon, +centerline, and construction-geometry creation. Development rule: @@ -97,7 +98,7 @@ Blocks 95–105 implement and accept the optional Qt application layer over thos owns session/command/task/selection and transient presentation state; Core and Geometry remain model, solver, geometry, recompute, analysis, and exchange authorities. -Blocks 106–109 establish the implemented Interactive Sketcher foundation: +Blocks 106–110 establish the implemented Interactive Sketcher foundation: ```text contextual Sketch workspace and command lifecycle @@ -117,6 +118,12 @@ contextual Sketch workspace and command lifecycle -> stable canonical redundancy attribution -> stable remove-one conflict attribution -> fully constrained / under constrained / redundant / conflicting / non-convergent / invalid reference + -> stable semantic drag handles over persistent point/entity roles + -> transient Coincident / Midpoint / Concentric / Radial drag equations + -> latest-pointer coalescing and synchronous exact release flush + -> live solved preview without PartDocument mutation + -> rollback on Esc / lost capture / solve refusal + -> one freshness-checked Drag sketch handle document transaction on release ``` The canonical Block-108 topology is solver/direct-manipulation identity authority. The historical @@ -127,9 +134,9 @@ Block 109 adds no opaque solved-coordinate cache. `SketchSolveResult`, solver va Jacobian, rank, remaining DOF, iteration state, and conflict/redundancy diagnostics are derived. The source topology is never mutated by `SketchConstraintSolver::solve(...)`. -The current next boundary is Block 110: semantic handle identity, transient drag targets, live -Block-109 solving on disposable Block-108 topology candidates, preview publication without document -mutation, cancellation/lost-capture rollback, and one validated release transaction. Interactive +The current next boundary is Block 111: basic creation commands over the implemented interaction, +topology, solver, and drag authorities. Creation must use explicit Core topology/edit commands and +ordinary points/lines/constraints rather than GUI-only composite primitives. Interactive Sketcher continues through Block 121. Interactive Part/Surface/Assembly Modeling follows in Blocks 122–131, and STEP Part plus structured Assembly import follows in Blocks 132–138. @@ -233,7 +240,7 @@ assistants. ## Non-goals for the current phase -The current phase does not yet claim production-grade GUI parity. Blocks 106–109 establish workspace, +The current phase does not yet claim production-grade GUI parity. Blocks 106–110 establish workspace, plane interaction, persistent shared Sketch topology, and deterministic solver foundations; Blocks 110–121 deliberately add direct manipulation, creation, constraint/dimension authoring, modify/project workflows, regions, Sketch3D interaction, and acceptance in sequence. diff --git a/docs/sketch-planar-constraint-solver-mvp8.md b/docs/sketch-planar-constraint-solver-mvp8.md index bef3a563..d91a695c 100644 --- a/docs/sketch-planar-constraint-solver-mvp8.md +++ b/docs/sketch-planar-constraint-solver-mvp8.md @@ -366,9 +366,117 @@ constrained solve, every initial residual family, canonical redundancy attributi attribution, invalid-reference classification, non-convergence classification, and adaptation of current persisted geometric constraints/driving dimensions into the solver. +## Block-110 live drag consumer + +Block 110 is the first continuous GUI consumer of this solver. It does not add solver mathematics. A +semantic handle maps to one of four transient target forms: + +```text +Point -> Coincident(controlled point, temporary reference point) +LineMidpoint -> Midpoint(temporary reference point, line) +ArcCenter -> Concentric(arc, temporary reference center entity) +ArcRadius -> Radial(arc, source-center-to-pointer distance) +``` + +The temporary constraint id is `zz.gui.drag.target`; temporary topology ids are +`__gui.drag.pointer` and `__gui.drag.center`. They exist only in the augmented solve request and are +removed before preview/commit. `FullyConstrained`, `UnderConstrained`, and `Redundant` are accepted +preview states; `Conflicting`, `NonConvergent`, and `InvalidReference` refuse the drag candidate. + +Move samples may be coalesced by the GUI, but the exact release pointer is synchronously solved before +commit. Qt renders the derived solve result/DOF and never evaluates substitute residuals. + +Canonical integration contract: `docs/gui-sketch-solver-drag-mvp8.md`. + +## Block-110 live drag consumer + +Block 110 is the first continuous GUI consumer of this solver. It does not add solver mathematics. A +semantic handle maps to one of four transient target forms: + +```text +Point -> Coincident(controlled point, temporary reference point) +LineMidpoint -> Midpoint(temporary reference point, line) +ArcCenter -> Concentric(arc, temporary reference center entity) +ArcRadius -> Radial(arc, source-center-to-pointer distance) +``` + +The temporary constraint id is `zz.gui.drag.target`; temporary topology ids are +`__gui.drag.pointer` and `__gui.drag.center`. They exist only in the augmented solve request and are +removed before preview/commit. `FullyConstrained`, `UnderConstrained`, and `Redundant` are accepted +preview states; `Conflicting`, `NonConvergent`, and `InvalidReference` refuse the drag candidate. + +Move samples may be coalesced by the GUI, but the exact release pointer is synchronously solved before +commit. Qt renders the derived solve result/DOF and never evaluates substitute residuals. + +Canonical integration contract: `docs/gui-sketch-solver-drag-mvp8.md`. + +## Block-110 live drag consumer + +Block 110 is the first continuous GUI consumer of this solver. It does not add solver mathematics. A +semantic handle maps to one of four transient target forms: + +```text +Point -> Coincident(controlled point, temporary reference point) +LineMidpoint -> Midpoint(temporary reference point, line) +ArcCenter -> Concentric(arc, temporary reference center entity) +ArcRadius -> Radial(arc, source-center-to-pointer distance) +``` + +The temporary constraint id is `zz.gui.drag.target`; temporary topology ids are +`__gui.drag.pointer` and `__gui.drag.center`. They exist only in the augmented solve request and are +removed before preview/commit. `FullyConstrained`, `UnderConstrained`, and `Redundant` are accepted +preview states; `Conflicting`, `NonConvergent`, and `InvalidReference` refuse the drag candidate. + +Move samples may be coalesced by the GUI, but the exact release pointer is synchronously solved before +commit. Qt renders the derived solve result/DOF and never evaluates substitute residuals. + +Canonical integration contract: `docs/gui-sketch-solver-drag-mvp8.md`. + +## Block-110 live drag consumer + +Block 110 is the first continuous GUI consumer of this solver. It does not add solver mathematics. A +semantic handle maps to one of four transient target forms: + +```text +Point -> Coincident(controlled point, temporary reference point) +LineMidpoint -> Midpoint(temporary reference point, line) +ArcCenter -> Concentric(arc, temporary reference center entity) +ArcRadius -> Radial(arc, source-center-to-pointer distance) +``` + +The temporary constraint id is `zz.gui.drag.target`; temporary topology ids are +`__gui.drag.pointer` and `__gui.drag.center`. They exist only in the augmented solve request and are +removed before preview/commit. `FullyConstrained`, `UnderConstrained`, and `Redundant` are accepted +preview states; `Conflicting`, `NonConvergent`, and `InvalidReference` refuse the drag candidate. + +Move samples may be coalesced by the GUI, but the exact release pointer is synchronously solved before +commit. Qt renders the derived solve result/DOF and never evaluates substitute residuals. + +Canonical integration contract: `docs/gui-sketch-solver-drag-mvp8.md`. + +## Block-110 live drag consumer + +Block 110 is the first continuous GUI consumer of this solver. It does not add solver mathematics. A +semantic handle maps to one of four transient target forms: + +```text +Point -> Coincident(controlled point, temporary reference point) +LineMidpoint -> Midpoint(temporary reference point, line) +ArcCenter -> Concentric(arc, temporary reference center entity) +ArcRadius -> Radial(arc, source-center-to-pointer distance) +``` + +The temporary constraint id is `zz.gui.drag.target`; temporary topology ids are +`__gui.drag.pointer` and `__gui.drag.center`. They exist only in the augmented solve request and are +removed before preview/commit. `FullyConstrained`, `UnderConstrained`, and `Redundant` are accepted +preview states; `Conflicting`, `NonConvergent`, and `InvalidReference` refuse the drag candidate. + +Move samples may be coalesced by the GUI, but the exact release pointer is synchronously solved before +commit. Qt renders the derived solve result/DOF and never evaluates substitute residuals. + +Canonical integration contract: `docs/gui-sketch-solver-drag-mvp8.md`. + ## Next boundary -Block 110 owns solver-backed mouse dragging. It exposes semantic Sketch handles, adds a transient drag -target to disposable solve candidates, publishes live preview without document mutation, and commits -one validated topology/document transaction only on release. `Esc`, lost capture, fixed geometry, or -failed solve restores the pre-drag snapshot. +Block 111 reuses the solver for disposable candidates produced by basic creation commands. Automatic +constraint authoring remains Block 114 and dimension editing remains Block 115. diff --git a/docs/sketch-shared-topology-mvp8.md b/docs/sketch-shared-topology-mvp8.md index f268eb80..f262c0d3 100644 --- a/docs/sketch-shared-topology-mvp8.md +++ b/docs/sketch-shared-topology-mvp8.md @@ -1,6 +1,6 @@ # Shared Planar Sketch Topology MVP-8 -Status: implemented in Block 108. Block 109 is the first general solver consumer. +Status: implemented in Block 108. Block 109 is the general solver consumer and Block 110 is the first direct-manipulation consumer. This document is the canonical Core contract for shared planar point/entity topology. It replaces floating-point-coordinate inference as the connectivity identity model used by Interactive Sketcher @@ -278,8 +278,11 @@ coordinates are valid and round-trip exactly. `serialize_sketch_topology_to_json(...)` and `deserialize_sketch_topology_from_json(...)` round-trip one canonical topology structurally exactly. -Block 109 adds no topology-schema fields for solver variables, residuals, Jacobians, rank, DOF, -convergence, or conflict diagnostics. Those values are derived on demand. +Blocks 109–110 add no topology-schema fields for solver variables, residuals, Jacobians, rank, DOF, +convergence, drag handles, pointer samples, temporary drag point/entity ids, or live previews. Those +values are derived/transient. Block 110 strips `__gui.drag.pointer` / `__gui.drag.center` from solver +output and rebuilds a topology containing exactly the source point/entity/dependency identities before +preview or commit. ## Existing PartDocument JSON migration @@ -312,9 +315,9 @@ Only an exactly representable candidate reaches `update_sketch(...)`. Identity, relationships, or orphan point records that historical Sketch JSON would lose cause fail-closed rejection. -Block 109 solving does not automatically call this bridge. Solve results are disposable derived -candidates. A later command/interaction owner must explicitly choose the validated persistent commit -boundary. +Block 109 solving does not automatically call this bridge. Block 110 is one explicit interaction owner: +it requires source-only solved topology to materialize and re-migrate exactly for preview, and repeats +the equality check inside one freshness-checked document transaction on release. ## Persistence and regeneration @@ -370,7 +373,6 @@ Block-109 consumer proof is documented in `docs/sketch-planar-constraint-solver- ## Next boundary -Block 110 consumes Block-108 topology snapshots and Block-109 solving for semantic-handle mouse drag. -It adds transient drag targets to disposable candidates, publishes live preview without document -mutation, restores the pre-drag snapshot on cancellation/failure, and commits exactly one validated -transaction on release. +Block 111 uses the same stable point/entity topology for basic Sketch creation. Snap positions may seed +new point coordinates, but only explicit topology/edit commands create persistent point identity or +shared connectivity. diff --git a/docs/sketch-solver-diagnostics-mvp.md b/docs/sketch-solver-diagnostics-mvp.md index 4c0563ab..67804b91 100644 --- a/docs/sketch-solver-diagnostics-mvp.md +++ b/docs/sketch-solver-diagnostics-mvp.md @@ -195,8 +195,9 @@ No failure/diagnostic state mutates persistent Sketch intent. The historical seed remains available to existing repair/presentation code until those consumers are migrated deliberately. -Block 110 may publish the Block-109 remaining DOF and solve status into the existing Sketch status -surface while implementing live drag. Blocks 114/115 use stable solver constraint ids for glyph/ +Block 110 publishes Block-109 baseline/live remaining DOF and solve status into the existing Sketch +status surface. Conflicting, non-convergent, or invalid-reference drag solves refuse/cancel the preview +without persistent mutation. Blocks 114/115 use stable solver constraint ids for glyph/ dimension conflict interaction. Block 119 may combine solver diagnostics with region/profile repair. Those consumers must not parse diagnostic prose to recover identity; stable constraint ids and enum diff --git a/docs/user-interface.md b/docs/user-interface.md index 5a0dd4df..3d28fb61 100644 --- a/docs/user-interface.md +++ b/docs/user-interface.md @@ -2,9 +2,9 @@ Status: MVP-7 accepted and Interactive Sketcher MVP-8 in progress. Blocks 95–105 provide the optional Qt shell, document transactions, OCCT viewport, deterministic browser/property surfaces, and semantic -selection synchronization. Blocks 106–109 establish the contextual Sketch workspace, transient plane -interaction, persistent shared planar topology, and deterministic general planar solver. Block 110 is -the current next technical step and connects mouse dragging to those authorities. Blocks 122–131 add +selection synchronization. Blocks 106–110 establish the contextual Sketch workspace, transient plane interaction, persistent +shared planar topology, deterministic general planar solver, and solver-backed semantic-handle mouse +dragging. Block 111 is the current next technical step and adds basic creation tools. Blocks 122–131 add interactive Part/Surface/Assembly modeling; STEP Import begins with Block 132. The UI is deliberately not built like FreeCAD. The goal is a modern, consistent, reduced interface @@ -51,12 +51,12 @@ Producer boundaries are explicit: Block 107 cursor / hover / hit / box selection / grid / snap / inference Block 108 persistent shared SketchPointId / SketchTopology identity Block 109 deterministic solve result / exact local remaining DOF / solver diagnostics -Block 110 semantic handles / live drag solve invocation / status publication / release commit +Block 110 semantic handles / live drag solve invocation / status publication / release commit — implemented — implemented — implemented — implemented — implemented ``` -Block 109 means DOF/Solve have a real headless Core producer. The current shell does not yet continuously -invoke it, so `DOF: —` / `Solve: Not evaluated` can still appear outside a solver-aware command. Block -110 owns the first live solve/status publication during drag. The UI must render +Block 109 provides the headless producer and Block 110 continuously publishes baseline/live drag +`SketchSolveResult` status and remaining DOF through the existing status row. The UI does not count +endpoints or glyphs to estimate DOF. The UI must render `SketchSolveResult`; it must not count endpoints or glyphs to estimate DOF. `Enter Sketch` captures workspace, semantic selection, full transient camera state, and viewport @@ -200,9 +200,9 @@ The UI must not fill this with sampled endpoints or OCCT subshapes. Canonical contract: `docs/sketch-planar-constraint-solver-mvp8.md`. -## Block-110 direct manipulation boundary +## Solver-backed Sketch direct manipulation through Block 110 -Block 110 is the first GUI consumer that composes Blocks 107–109: +Block 110 implements the first GUI consumer that composes Blocks 107–109: ```text screen pointer @@ -215,9 +215,11 @@ screen pointer -> release: one validated document transaction ``` -Preview never mutates PartDocument. `Esc`, lost capture, fixed/fully-constrained refusal, or failed -solve restores the exact pre-drag snapshot and clears preview. Solver throttling/coalescing must not -drop the final pointer position. +Preview never mutates `PartDocument`. Semantic handles are drawn as a separate cyan overlay and hit +tested within 9 DIP by screen distance then stable handle id, without changing Block-107 hit priority. +`Esc`, lost capture/window deactivation, reference geometry, incompatible fully constrained geometry, +or failed solve restores the source preview and creates no history entry. Pointer moves coalesce to the +latest pending sample; release synchronously flushes the exact final snapped position. The block must expose endpoint, midpoint, center, radius, arc, spline, and dimension handles only where semantic topology/constraint authority exists. Handle screen position is presentation; handle identity @@ -268,16 +270,16 @@ but release cannot commit a failed candidate. 5. Add contextual Sketch workspace and device-independent canvas interaction. Implemented in 106–107. 6. Replace equal-coordinate connectivity with stable shared planar Core topology. Implemented in 108. 7. Add deterministic general planar solving and exact local DOF over that topology. Implemented in 109. -8. Add solver-backed drag, creation, constraints, dimensions, modify/project tools, regions, and - Interactive Sketch3D through Block 121. Block 110 next. +8. Add solver-backed semantic-handle drag and atomic release commit. Implemented in 110. +9. Add creation, constraints, dimensions, modify/project tools, regions, and Interactive Sketch3D + through Block 121. Block 111 next. 9. Add selection-first Part/Surface/Assembly modeling through Blocks 122–131. 10. Add STEP Reference/EditableBody import through Blocks 132–138. ## Current boundary -Block 109 is implemented. Block 110 is next. +Block 110 is implemented. Block 111 is next. -No widget may implement substitute constraint mathematics. Direct manipulation must map transient -pointer interaction to stable Block-108 identities, call Block-109 solver authority on disposable -candidates, and commit through the existing validated document transaction/history boundary only on -successful release. +No widget may implement substitute constraint mathematics. Basic creation must map transient picks and +snap results to explicit Block-108 topology/edit commands, use Block-109 solve authority for disposable +candidates, and commit through the existing validated document transaction/history boundary. diff --git a/include/blcad/gui/gui_sketch_drag.hpp b/include/blcad/gui/gui_sketch_drag.hpp new file mode 100644 index 00000000..5a0efab2 --- /dev/null +++ b/include/blcad/gui/gui_sketch_drag.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include "blcad/core/sketch_constraint_solver.hpp" +#include "blcad/core/sketch_topology_part_document.hpp" +#include "blcad/gui/gui_document_session.hpp" + +#include +#include +#include +#include +#include + +namespace blcad::gui { + +enum class GuiSketchDragHandleKind { + Endpoint, + Midpoint, + Center, + Radius, + Arc, + Spline, + Dimension, +}; + +[[nodiscard]] std::string_view to_string(GuiSketchDragHandleKind kind) noexcept; + +enum class GuiSketchDragTargetKind { + Point, + LineMidpoint, + ArcCenter, + ArcRadius, +}; + +[[nodiscard]] std::string_view to_string(GuiSketchDragTargetKind kind) noexcept; + +struct GuiSketchDragHandle { + std::string id; + GuiSketchDragHandleKind kind{GuiSketchDragHandleKind::Endpoint}; + GuiSketchDragTargetKind target_kind{GuiSketchDragTargetKind::Point}; + Point2 position; + std::optional point_id; + std::string entity_id; + std::optional dimension_id; + bool reference{false}; + + friend bool operator==(const GuiSketchDragHandle&, const GuiSketchDragHandle&) = default; +}; + +class GuiSketchDragPreview { +public: + GuiSketchDragPreview(Point2 pointer, SketchTopology topology, Sketch preview_sketch, + SketchSolveResult solve); + + [[nodiscard]] Point2 pointer() const noexcept; + [[nodiscard]] const SketchTopology& topology() const noexcept; + [[nodiscard]] const Sketch& preview_sketch() const noexcept; + [[nodiscard]] const SketchSolveResult& solve() const noexcept; + +private: + Point2 pointer_; + SketchTopology topology_; + Sketch preview_sketch_; + SketchSolveResult solve_; +}; + +class GuiSketchDragController { +public: + [[nodiscard]] static Result + create(const PartDocument& document, SketchId sketch_id); + + [[nodiscard]] const SketchId& sketch_id() const noexcept; + [[nodiscard]] const Sketch& source_sketch() const noexcept; + [[nodiscard]] const SketchTopology& source_topology() const noexcept; + [[nodiscard]] const SketchConstraintSystem& source_system() const noexcept; + [[nodiscard]] const SketchSolveResult& baseline_solve() const noexcept; + [[nodiscard]] const std::vector& handles() const noexcept; + [[nodiscard]] std::vector + handles_for_topology(const SketchTopology& topology) const; + + [[nodiscard]] Result begin(std::string_view handle_id); + [[nodiscard]] Result queue_pointer(Point2 pointer); + [[nodiscard]] Result process_pending(); + [[nodiscard]] Result flush(Point2 final_pointer); + [[nodiscard]] Result commit(GuiDocumentSession& session); + void cancel() noexcept; + + [[nodiscard]] bool active() const noexcept; + [[nodiscard]] bool has_pending() const noexcept; + [[nodiscard]] const GuiSketchDragHandle* active_handle() const noexcept; + [[nodiscard]] const std::optional& latest_preview() const noexcept; + [[nodiscard]] const std::optional& pending_pointer() const noexcept; + [[nodiscard]] const std::optional& processed_pointer() const noexcept; + [[nodiscard]] std::size_t solve_count() const noexcept; + +private: + GuiSketchDragController(Sketch source_sketch, SketchTopology source_topology, + SketchConstraintSystem source_system, + SketchSolveResult baseline_solve, + std::vector handles); + + [[nodiscard]] Result solve_pointer(Point2 pointer); + + Sketch source_sketch_; + SketchTopology source_topology_; + SketchConstraintSystem source_system_; + SketchSolveResult baseline_solve_; + std::vector handles_; + std::optional active_handle_index_; + std::optional pending_pointer_; + std::optional processed_pointer_; + std::optional latest_preview_; + std::size_t solve_count_{0U}; +}; + +} // namespace blcad::gui diff --git a/include/blcad/gui/gui_sketch_drag_binder.hpp b/include/blcad/gui/gui_sketch_drag_binder.hpp new file mode 100644 index 00000000..077289b5 --- /dev/null +++ b/include/blcad/gui/gui_sketch_drag_binder.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace blcad::gui { + +class MainWindow; + +// Installs the transient Block-110 solver-backed drag bridge after the Block-107 interaction binder. +// It owns live drag preview/coalescing only; persistent mutation still uses GuiDocumentSession. +void install_sketch_drag_binder(MainWindow& window); + +} // namespace blcad::gui diff --git a/include/blcad/gui/main_window.hpp b/include/blcad/gui/main_window.hpp index deead58a..15c92056 100644 --- a/include/blcad/gui/main_window.hpp +++ b/include/blcad/gui/main_window.hpp @@ -99,7 +99,9 @@ class MainWindow final : public QMainWindow { std::uint32_t sketch_selection_filter_mask_{0xFFFFFFFFU}; QTabBar* workspace_tabs_{nullptr}; QWidget* sketch_command_groups_{nullptr}; + QAction* sketch_command_groups_action_{nullptr}; QLineEdit* sketch_numeric_hud_{nullptr}; + QAction* sketch_numeric_hud_action_{nullptr}; QLabel* sketch_cursor_status_{nullptr}; QLabel* sketch_snap_status_{nullptr}; QLabel* sketch_dof_status_{nullptr}; diff --git a/include/blcad/gui/occt_viewport.hpp b/include/blcad/gui/occt_viewport.hpp index e927b0d9..46e4e0c8 100644 --- a/include/blcad/gui/occt_viewport.hpp +++ b/include/blcad/gui/occt_viewport.hpp @@ -31,6 +31,7 @@ enum class GuiViewportDisplayMode { Shaded, ShadedWithEdges, Wireframe }; enum class GuiViewportProjection { Perspective, Orthographic }; enum class GuiStandardView { Isometric, Front, Back, Left, Right, Top, Bottom }; enum class GuiSketchSurroundingsMode { Dim, Isolate }; +enum class GuiSketchPointerPhase { Press, Release }; struct GuiPlaneCamera { Point3 target; @@ -52,6 +53,12 @@ class OcctViewport final : public QWidget { public: using SketchPointerCallback = std::function&)>; + using SketchDragPointerCallback = + std::function&)>; + using SketchPointerPhaseCallback = + std::function&)>; using SketchSelectionCallback = std::function&)>; explicit OcctViewport(QWidget* parent = nullptr); @@ -75,7 +82,10 @@ class OcctViewport final : public QWidget { void set_sketch_selection_enabled(bool enabled) noexcept; void set_sketch_inference_anchor(std::optional anchor) noexcept; void set_sketch_grid_config(GuiSketchGridConfig config); + void set_sketch_drag_handles(std::vector handles); void set_sketch_pointer_callback(SketchPointerCallback callback); + void set_sketch_drag_pointer_callback(SketchDragPointerCallback callback); + void set_sketch_pointer_phase_callback(SketchPointerPhaseCallback callback); void set_sketch_selection_callback(SketchSelectionCallback callback); void set_context_menu_callback(std::function callback); void fit_all(); @@ -101,6 +111,7 @@ class OcctViewport final : public QWidget { [[nodiscard]] const std::optional& hovered_sketch_hit() const noexcept; [[nodiscard]] const std::optional& sketch_box_selection() const noexcept; [[nodiscard]] std::size_t sketch_grid_line_count() const noexcept; + [[nodiscard]] std::size_t sketch_drag_handle_count() const noexcept; [[nodiscard]] Result sketch_plane_to_screen(Point2 point) const { if (!sketch_interaction_) return Result::failure( @@ -128,7 +139,10 @@ class OcctViewport final : public QWidget { void apply_selection_filters(); void apply_sketch_focus(); void rebuild_sketch_grid(); + void rebuild_sketch_drag_handles(); void update_sketch_pointer(GuiSketchScreenPoint screen_point); + void publish_sketch_pointer_phase(GuiSketchPointerPhase phase, + GuiSketchScreenPoint screen_point); void publish_sketch_selection(); void publish_selection(std::optional selection); [[nodiscard]] std::optional detected_selection() const; @@ -137,6 +151,8 @@ class OcctViewport final : public QWidget { std::unique_ptr sketch_interaction_; std::function)> selection_callback_; SketchPointerCallback sketch_pointer_callback_; + SketchDragPointerCallback sketch_drag_pointer_callback_; + SketchPointerPhaseCallback sketch_pointer_phase_callback_; SketchSelectionCallback sketch_selection_callback_; std::function context_menu_callback_; std::optional selected_semantic_; @@ -146,6 +162,7 @@ class OcctViewport final : public QWidget { std::optional sketch_snap_result_; std::optional hovered_sketch_hit_; std::optional sketch_box_selection_; + std::vector sketch_drag_handles_; GuiViewportDisplayMode display_mode_{GuiViewportDisplayMode::ShadedWithEdges}; GuiViewportProjection projection_{GuiViewportProjection::Perspective}; GuiSketchSurroundingsMode sketch_surroundings_mode_{GuiSketchSurroundingsMode::Dim}; diff --git a/src/gui/gui_sketch_drag.cpp b/src/gui/gui_sketch_drag.cpp new file mode 100644 index 00000000..d3e423b3 --- /dev/null +++ b/src/gui/gui_sketch_drag.cpp @@ -0,0 +1,588 @@ +#include "blcad/gui/gui_sketch_drag.hpp" + +#include +#include +#include +#include +#include +#include + +namespace blcad::gui { +namespace { + +constexpr double kPi = 3.141592653589793238462643383279502884; +constexpr double kEpsilon = 1.0e-12; +constexpr std::string_view kDragPointId = "__gui.drag.pointer"; +constexpr std::string_view kDragCenterEntityId = "__gui.drag.center"; +constexpr std::string_view kDragConstraintId = "zz.gui.drag.target"; + +[[nodiscard]] Error drag_error(std::string message) { + return Error::validation("gui.sketch_drag", std::move(message)); +} + +[[nodiscard]] bool finite(Point2 point) noexcept { + return std::isfinite(point.x) && std::isfinite(point.y); +} + +[[nodiscard]] double distance(Point2 first, Point2 second) noexcept { + return std::hypot(second.x - first.x, second.y - first.y); +} + +[[nodiscard]] Point2 midpoint(Point2 first, Point2 second) noexcept { + return {(first.x + second.x) * 0.5, (first.y + second.y) * 0.5}; +} + +struct ArcGeometry { + Point2 center; + double radius{0.0}; +}; + +[[nodiscard]] std::optional +arc_geometry(const SketchTopology& topology, const SketchTopologyEntity& entity) noexcept { + if (entity.kind() != SketchTopologyEntityKind::Arc || entity.points().size() != 3U) + return std::nullopt; + const auto* start_point = topology.find_point(entity.points()[0]); + const auto* mid_point = topology.find_point(entity.points()[1]); + const auto* end_point = topology.find_point(entity.points()[2]); + if (start_point == nullptr || mid_point == nullptr || end_point == nullptr) + return std::nullopt; + const Point2 start = start_point->position(); + const Point2 mid = mid_point->position(); + const Point2 end = end_point->position(); + const double denominator = + 2.0 * (start.x * (mid.y - end.y) + mid.x * (end.y - start.y) + + end.x * (start.y - mid.y)); + if (!std::isfinite(denominator) || std::abs(denominator) <= kEpsilon) + return std::nullopt; + const double start_norm = start.x * start.x + start.y * start.y; + const double mid_norm = mid.x * mid.x + mid.y * mid.y; + const double end_norm = end.x * end.x + end.y * end.y; + const Point2 center{ + (start_norm * (mid.y - end.y) + mid_norm * (end.y - start.y) + + end_norm * (start.y - mid.y)) / + denominator, + (start_norm * (end.x - mid.x) + mid_norm * (start.x - end.x) + + end_norm * (mid.x - start.x)) / + denominator}; + const double radius = distance(center, start); + if (!finite(center) || !std::isfinite(radius) || radius <= kEpsilon) + return std::nullopt; + return ArcGeometry{center, radius}; +} + +[[nodiscard]] std::string point_handle_id(const SketchId& sketch, const SketchPointId& point) { + return "sketch/" + sketch.value() + "/handle/point/" + point.value(); +} + +[[nodiscard]] std::string entity_handle_id(const SketchId& sketch, std::string_view entity, + std::string_view role) { + return "sketch/" + sketch.value() + "/handle/entity/" + std::string(entity) + "/" + + std::string(role); +} + +[[nodiscard]] std::string dimension_handle_id(const SketchId& sketch, + const SketchDimensionId& dimension) { + return "sketch/" + sketch.value() + "/handle/dimension/" + dimension.value(); +} + +[[nodiscard]] const SketchTopologyPoint* +point_for_target(const SketchTopology& topology, const SketchReferenceTarget& target) noexcept { + const auto* entity = topology.find_entity("entity/" + target.entity().value()); + if (entity == nullptr) + return nullptr; + switch (target.kind()) { + case SketchReferenceTargetKind::LineSegmentStart: + return entity->points().empty() ? nullptr : topology.find_point(entity->points().front()); + case SketchReferenceTargetKind::LineSegmentEnd: + return entity->points().size() < 2U ? nullptr : topology.find_point(entity->points().back()); + case SketchReferenceTargetKind::LineSegment: + case SketchReferenceTargetKind::ProjectedPoint: + case SketchReferenceTargetKind::ProjectedLine: + return nullptr; + } + return nullptr; +} + +[[nodiscard]] std::vector +build_handles(const Sketch& sketch, const SketchTopology& topology) { + std::vector handles; + std::map endpoint_handles; + + const auto append_endpoint = [&](const SketchPointId& point_id) { + const auto* point = topology.find_point(point_id); + if (point == nullptr) + return; + endpoint_handles.try_emplace( + point_id.value(), + GuiSketchDragHandle{point_handle_id(topology.sketch(), point_id), + GuiSketchDragHandleKind::Endpoint, + GuiSketchDragTargetKind::Point, + point->position(), point_id, {}, std::nullopt, point->reference()}); + }; + + for (const auto& entity : topology.entities()) { + if (entity.kind() == SketchTopologyEntityKind::Line) { + append_endpoint(entity.points()[0]); + append_endpoint(entity.points()[1]); + const auto* start = topology.find_point(entity.points()[0]); + const auto* end = topology.find_point(entity.points()[1]); + if (start != nullptr && end != nullptr) + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), "midpoint"), + GuiSketchDragHandleKind::Midpoint, + GuiSketchDragTargetKind::LineMidpoint, + midpoint(start->position(), end->position()), std::nullopt, entity.id(), + std::nullopt, entity.reference()}); + } else if (entity.kind() == SketchTopologyEntityKind::Arc) { + append_endpoint(entity.points()[0]); + append_endpoint(entity.points()[2]); + const auto* mid = topology.find_point(entity.points()[1]); + if (mid != nullptr) + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), "arc"), + GuiSketchDragHandleKind::Arc, GuiSketchDragTargetKind::Point, + mid->position(), mid->id(), entity.id(), std::nullopt, + entity.reference() || mid->reference()}); + const auto geometry = arc_geometry(topology, entity); + if (geometry) { + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), "center"), + GuiSketchDragHandleKind::Center, GuiSketchDragTargetKind::ArcCenter, + geometry->center, std::nullopt, entity.id(), std::nullopt, + entity.reference()}); + const Point2 radial_source = mid != nullptr ? mid->position() : + topology.find_point(entity.points()[0])->position(); + const double angle = std::atan2(radial_source.y - geometry->center.y, + radial_source.x - geometry->center.x) + + kPi / 6.0; + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), "radius"), + GuiSketchDragHandleKind::Radius, GuiSketchDragTargetKind::ArcRadius, + {geometry->center.x + geometry->radius * std::cos(angle), + geometry->center.y + geometry->radius * std::sin(angle)}, + std::nullopt, entity.id(), std::nullopt, entity.reference()}); + } + } else if (entity.kind() == SketchTopologyEntityKind::Spline) { + append_endpoint(entity.points()[0]); + append_endpoint(entity.points()[3]); + for (std::size_t index = 1U; index <= 2U; ++index) { + const auto* point = topology.find_point(entity.points()[index]); + if (point == nullptr) + continue; + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), + index == 1U ? "spline/control1" : "spline/control2"), + GuiSketchDragHandleKind::Spline, GuiSketchDragTargetKind::Point, + point->position(), point->id(), entity.id(), std::nullopt, + entity.reference() || point->reference()}); + } + } else if (entity.kind() == SketchTopologyEntityKind::RectangleProfile || + entity.kind() == SketchTopologyEntityKind::CircleProfile || + entity.kind() == SketchTopologyEntityKind::CircularHolePattern) { + const auto* center = topology.find_point(entity.points()[0]); + if (center != nullptr) + handles.push_back({entity_handle_id(topology.sketch(), entity.id(), "center"), + GuiSketchDragHandleKind::Center, GuiSketchDragTargetKind::Point, + center->position(), center->id(), entity.id(), std::nullopt, + entity.reference() || center->reference()}); + } + } + + for (auto& [id, handle] : endpoint_handles) { + (void)id; + handles.push_back(std::move(handle)); + } + + for (const auto& dimension : sketch.driving_dimensions()) { + const auto* target = point_for_target(topology, dimension.second_target()); + if (target == nullptr) + continue; + handles.push_back({dimension_handle_id(topology.sketch(), dimension.id()), + GuiSketchDragHandleKind::Dimension, GuiSketchDragTargetKind::Point, + target->position(), target->id(), {}, dimension.id(), target->reference()}); + } + + std::sort(handles.begin(), handles.end(), [](const auto& left, const auto& right) { + return left.id < right.id; + }); + return handles; +} + +[[nodiscard]] bool accepted_status(SketchSolveStatus status) noexcept { + return status == SketchSolveStatus::FullyConstrained || + status == SketchSolveStatus::UnderConstrained || + status == SketchSolveStatus::Redundant; +} + +[[nodiscard]] std::string solve_failure_message(const SketchSolveResult& solve) { + switch (solve.status) { + case SketchSolveStatus::Conflicting: + return "drag target conflicts with the current Sketch constraints"; + case SketchSolveStatus::NonConvergent: + return "drag target did not converge"; + case SketchSolveStatus::InvalidReference: + return "drag target cannot solve because a Sketch reference is invalid"; + case SketchSolveStatus::FullyConstrained: + case SketchSolveStatus::UnderConstrained: + case SketchSolveStatus::Redundant: + return {}; + } + return "drag target could not be solved"; +} + +[[nodiscard]] Result +strip_transient_topology(const SketchTopology& source, const SketchTopology& solved) { + std::vector points; + points.reserve(source.points().size()); + for (const auto& source_point : source.points()) { + const auto* solved_point = solved.find_point(source_point.id()); + if (solved_point == nullptr) + return Result::failure( + drag_error("solver result lost a source Sketch point identity")); + auto point = SketchTopologyPoint::create(source_point.id(), solved_point->position(), + source_point.flags()); + if (point.has_error()) + return Result::failure(point.error()); + points.push_back(std::move(point.value())); + } + return SketchTopology::create(source.sketch(), std::move(points), source.entities(), + source.dependencies()); +} + +[[nodiscard]] Result +augment_topology(const SketchTopology& source, Point2 pointer, bool add_center_entity) { + std::vector points = source.points(); + auto target = SketchTopologyPoint::create( + SketchPointId(std::string(kDragPointId)), pointer, + SketchTopologyFlags{.construction = false, .reference = true}); + if (target.has_error()) + return Result::failure(target.error()); + points.push_back(std::move(target.value())); + + std::vector entities = source.entities(); + if (add_center_entity) { + auto center = SketchTopologyEntity::create( + std::string(kDragCenterEntityId), SketchTopologyEntityKind::CircleProfile, + {SketchPointId(std::string(kDragPointId))}, {}, + SketchTopologyFlags{.construction = false, .reference = true}); + if (center.has_error()) + return Result::failure(center.error()); + entities.push_back(std::move(center.value())); + } + return SketchTopology::create(source.sketch(), std::move(points), std::move(entities), + source.dependencies()); +} + +} // namespace + +std::string_view to_string(GuiSketchDragHandleKind kind) noexcept { + switch (kind) { + case GuiSketchDragHandleKind::Endpoint: return "endpoint"; + case GuiSketchDragHandleKind::Midpoint: return "midpoint"; + case GuiSketchDragHandleKind::Center: return "center"; + case GuiSketchDragHandleKind::Radius: return "radius"; + case GuiSketchDragHandleKind::Arc: return "arc"; + case GuiSketchDragHandleKind::Spline: return "spline"; + case GuiSketchDragHandleKind::Dimension: return "dimension"; + } + return "endpoint"; +} + +std::string_view to_string(GuiSketchDragTargetKind kind) noexcept { + switch (kind) { + case GuiSketchDragTargetKind::Point: return "point"; + case GuiSketchDragTargetKind::LineMidpoint: return "line_midpoint"; + case GuiSketchDragTargetKind::ArcCenter: return "arc_center"; + case GuiSketchDragTargetKind::ArcRadius: return "arc_radius"; + } + return "point"; +} + +GuiSketchDragPreview::GuiSketchDragPreview(Point2 pointer, SketchTopology topology, + Sketch preview_sketch, SketchSolveResult solve) + : pointer_(pointer), topology_(std::move(topology)), + preview_sketch_(std::move(preview_sketch)), solve_(std::move(solve)) {} + +Point2 GuiSketchDragPreview::pointer() const noexcept { return pointer_; } +const SketchTopology& GuiSketchDragPreview::topology() const noexcept { return topology_; } +const Sketch& GuiSketchDragPreview::preview_sketch() const noexcept { return preview_sketch_; } +const SketchSolveResult& GuiSketchDragPreview::solve() const noexcept { return solve_; } + +Result +GuiSketchDragController::create(const PartDocument& document, SketchId sketch_id) { + const auto* sketch = document.find_sketch(sketch_id); + if (sketch == nullptr) + return Result::failure( + drag_error("solver-backed drag requires an existing planar Sketch")); + Sketch source_sketch = *sketch; + auto topology = SketchTopology::migrate_legacy(source_sketch); + if (topology.has_error()) + return Result::failure(topology.error()); + auto system = SketchConstraintSystemBuilder::from_legacy(topology.value(), source_sketch, document); + if (system.has_error()) + return Result::failure(system.error()); + auto baseline = SketchConstraintSolver{}.solve(topology.value(), system.value()); + if (baseline.has_error()) + return Result::failure(baseline.error()); + auto handles = build_handles(source_sketch, topology.value()); + return Result::success(GuiSketchDragController( + std::move(source_sketch), std::move(topology.value()), std::move(system.value()), + std::move(baseline.value()), std::move(handles))); +} + +GuiSketchDragController::GuiSketchDragController( + Sketch source_sketch, SketchTopology source_topology, SketchConstraintSystem source_system, + SketchSolveResult baseline_solve, std::vector handles) + : source_sketch_(std::move(source_sketch)), source_topology_(std::move(source_topology)), + source_system_(std::move(source_system)), baseline_solve_(std::move(baseline_solve)), + handles_(std::move(handles)) {} + +const SketchId& GuiSketchDragController::sketch_id() const noexcept { + return source_topology_.sketch(); +} + +const Sketch& GuiSketchDragController::source_sketch() const noexcept { return source_sketch_; } + +const SketchTopology& GuiSketchDragController::source_topology() const noexcept { + return source_topology_; +} + +const SketchConstraintSystem& GuiSketchDragController::source_system() const noexcept { + return source_system_; +} + +const SketchSolveResult& GuiSketchDragController::baseline_solve() const noexcept { + return baseline_solve_; +} + +const std::vector& GuiSketchDragController::handles() const noexcept { + return handles_; +} + +std::vector +GuiSketchDragController::handles_for_topology(const SketchTopology& topology) const { + return build_handles(source_sketch_, topology); +} + +Result GuiSketchDragController::begin(std::string_view handle_id) { + if (active()) + return Result::failure(drag_error("a Sketch handle drag is already active")); + if (!accepted_status(baseline_solve_.status)) + return Result::failure( + drag_error("Sketch must have a convergent valid baseline solve before dragging")); + const auto found = std::find_if(handles_.begin(), handles_.end(), + [handle_id](const auto& handle) { return handle.id == handle_id; }); + if (found == handles_.end()) + return Result::failure(drag_error("selected Sketch drag handle does not exist")); + if (found->reference) + return Result::failure(drag_error("reference Sketch geometry is read-only")); + active_handle_index_ = static_cast(std::distance(handles_.begin(), found)); + pending_pointer_.reset(); + processed_pointer_.reset(); + latest_preview_.reset(); + solve_count_ = 0U; + return Result::success(1U); +} + +Result GuiSketchDragController::queue_pointer(Point2 pointer) { + if (!active()) + return Result::failure(drag_error("queueing a pointer requires an active handle drag")); + if (!finite(pointer)) + return Result::failure(drag_error("drag pointer coordinates must be finite")); + pending_pointer_ = pointer; + return Result::success(1U); +} + +Result GuiSketchDragController::process_pending() { + if (!active() || !pending_pointer_) + return Result::failure( + drag_error("processing a drag preview requires one queued pointer sample")); + const Point2 pointer = *pending_pointer_; + pending_pointer_.reset(); + return solve_pointer(pointer); +} + +Result GuiSketchDragController::flush(Point2 final_pointer) { + auto queued = queue_pointer(final_pointer); + if (queued.has_error()) + return Result::failure(queued.error()); + return process_pending(); +} + +Result GuiSketchDragController::solve_pointer(Point2 pointer) { + const auto* handle = active_handle(); + if (handle == nullptr) + return Result::failure(drag_error("drag handle identity was lost")); + + const bool center_target = handle->target_kind == GuiSketchDragTargetKind::ArcCenter; + auto augmented = augment_topology(source_topology_, pointer, center_target); + if (augmented.has_error()) + return Result::failure(augmented.error()); + + std::vector constraints = source_system_.constraints(); + Result drag_constraint = [&]() -> Result { + switch (handle->target_kind) { + case GuiSketchDragTargetKind::Point: { + if (!handle->point_id) + return Result::failure(drag_error("point drag handle has no point id")); + auto controlled = SketchSolverTarget::point(*handle->point_id); + auto target = SketchSolverTarget::point(SketchPointId(std::string(kDragPointId))); + if (controlled.has_error()) return Result::failure(controlled.error()); + if (target.has_error()) return Result::failure(target.error()); + return SketchSolverConstraint::create( + std::string(kDragConstraintId), SketchSolverConstraintKind::Coincident, + {std::move(controlled.value()), std::move(target.value())}); + } + case GuiSketchDragTargetKind::LineMidpoint: { + auto target = SketchSolverTarget::point(SketchPointId(std::string(kDragPointId))); + auto line = SketchSolverTarget::entity(handle->entity_id); + if (target.has_error()) return Result::failure(target.error()); + if (line.has_error()) return Result::failure(line.error()); + return SketchSolverConstraint::create( + std::string(kDragConstraintId), SketchSolverConstraintKind::Midpoint, + {std::move(target.value()), std::move(line.value())}); + } + case GuiSketchDragTargetKind::ArcCenter: { + auto arc = SketchSolverTarget::entity(handle->entity_id); + auto center = SketchSolverTarget::entity(std::string(kDragCenterEntityId)); + if (arc.has_error()) return Result::failure(arc.error()); + if (center.has_error()) return Result::failure(center.error()); + return SketchSolverConstraint::create( + std::string(kDragConstraintId), SketchSolverConstraintKind::Concentric, + {std::move(arc.value()), std::move(center.value())}); + } + case GuiSketchDragTargetKind::ArcRadius: { + const auto* entity = source_topology_.find_entity(handle->entity_id); + const auto geometry = entity == nullptr ? std::nullopt : arc_geometry(source_topology_, *entity); + if (!geometry) + return Result::failure( + drag_error("arc radius handle references degenerate arc geometry")); + const double radius = distance(geometry->center, pointer); + if (!std::isfinite(radius) || radius <= kEpsilon) + return Result::failure( + drag_error("arc radius drag requires a positive finite radius")); + auto arc = SketchSolverTarget::entity(handle->entity_id); + if (arc.has_error()) return Result::failure(arc.error()); + return SketchSolverConstraint::create( + std::string(kDragConstraintId), SketchSolverConstraintKind::Radial, + {std::move(arc.value())}, radius); + } + } + return Result::failure(drag_error("unsupported Sketch drag target")); + }(); + if (drag_constraint.has_error()) + return Result::failure(drag_constraint.error()); + constraints.push_back(std::move(drag_constraint.value())); + auto system = SketchConstraintSystem::create(source_topology_.sketch(), std::move(constraints)); + if (system.has_error()) + return Result::failure(system.error()); + + auto solve = SketchConstraintSolver{}.solve(augmented.value(), system.value()); + ++solve_count_; + processed_pointer_ = pointer; + if (solve.has_error()) + return Result::failure(solve.error()); + if (!accepted_status(solve.value().status)) { + latest_preview_.reset(); + return Result::failure(drag_error(solve_failure_message(solve.value()))); + } + + auto stripped = strip_transient_topology(source_topology_, solve.value().topology); + if (stripped.has_error()) + return Result::failure(stripped.error()); + auto materialized = SketchTopologyLegacyMaterializer{}.materialize(source_sketch_, stripped.value()); + if (materialized.has_error()) + return Result::failure(materialized.error()); + auto represented = SketchTopology::migrate_legacy(materialized.value()); + if (represented.has_error()) + return Result::failure(represented.error()); + if (represented.value() != stripped.value()) + return Result::failure(drag_error( + "solver drag preview cannot be represented by legacy PartDocument Sketch JSON without identity loss")); + + SketchSolveResult published_solve = std::move(solve.value()); + published_solve.topology = stripped.value(); + latest_preview_.emplace(pointer, std::move(stripped.value()), std::move(materialized.value()), + std::move(published_solve)); + return Result::success(*latest_preview_); +} + +Result GuiSketchDragController::commit(GuiDocumentSession& session) { + if (!active() || !latest_preview_ || pending_pointer_) + return Result::failure( + drag_error("committing a Sketch drag requires a flushed valid final preview")); + if (session.document_kind() != GuiDocumentKind::Part || session.part_document() == nullptr) + return Result::failure(drag_error("Sketch drag commit requires an active Part document")); + + const SketchTopology expected_source = source_topology_; + const SketchConstraintSystem expected_system = source_system_; + const SketchTopology final_topology = latest_preview_->topology(); + const SketchId target_sketch = source_topology_.sketch(); + auto committed = session.commit_part_transaction( + "Drag sketch handle", + [expected_source, expected_system, final_topology, target_sketch](PartDocument& document) { + const auto* current = document.find_sketch(target_sketch); + if (current == nullptr) + return Result::failure( + Error::validation(target_sketch.value(), "dragged Sketch no longer exists")); + const Sketch current_snapshot = *current; + auto current_topology = SketchTopology::migrate_legacy(current_snapshot); + if (current_topology.has_error()) + return Result::failure(current_topology.error()); + if (current_topology.value() != expected_source) + return Result::failure(Error::validation( + target_sketch.value(), "Sketch changed after drag preview; commit refused")); + auto current_system = SketchConstraintSystemBuilder::from_legacy( + current_topology.value(), current_snapshot, document); + if (current_system.has_error()) + return Result::failure(current_system.error()); + if (current_system.value() != expected_system) + return Result::failure(Error::validation( + target_sketch.value(), "Sketch constraint system changed after drag preview; commit refused")); + auto materialized = SketchTopologyLegacyMaterializer{}.materialize( + current_snapshot, final_topology); + if (materialized.has_error()) + return Result::failure(materialized.error()); + auto represented = SketchTopology::migrate_legacy(materialized.value()); + if (represented.has_error()) + return Result::failure(represented.error()); + if (represented.value() != final_topology) + return Result::failure(Error::validation( + target_sketch.value(), + "dragged topology cannot be represented without shared-point identity loss")); + return document.update_sketch(std::move(materialized.value())); + }); + if (committed.has_error()) + return committed; + cancel(); + return committed; +} + +void GuiSketchDragController::cancel() noexcept { + active_handle_index_.reset(); + pending_pointer_.reset(); + processed_pointer_.reset(); + latest_preview_.reset(); + solve_count_ = 0U; +} + +bool GuiSketchDragController::active() const noexcept { return active_handle_index_.has_value(); } +bool GuiSketchDragController::has_pending() const noexcept { return pending_pointer_.has_value(); } + +const GuiSketchDragHandle* GuiSketchDragController::active_handle() const noexcept { + return active_handle_index_ && *active_handle_index_ < handles_.size() + ? &handles_[*active_handle_index_] + : nullptr; +} + +const std::optional& +GuiSketchDragController::latest_preview() const noexcept { + return latest_preview_; +} + +const std::optional& GuiSketchDragController::pending_pointer() const noexcept { + return pending_pointer_; +} + +const std::optional& GuiSketchDragController::processed_pointer() const noexcept { + return processed_pointer_; +} + +std::size_t GuiSketchDragController::solve_count() const noexcept { return solve_count_; } + +} // namespace blcad::gui diff --git a/src/gui/gui_sketch_drag_binder.cpp b/src/gui/gui_sketch_drag_binder.cpp new file mode 100644 index 00000000..7d5acf21 --- /dev/null +++ b/src/gui/gui_sketch_drag_binder.cpp @@ -0,0 +1,414 @@ +#include "blcad/gui/gui_sketch_drag_binder.hpp" + +#include "blcad/gui/gui_sketch_drag.hpp" +#include "blcad/gui/main_window.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace blcad::gui { +namespace { + +constexpr double kHandleToleranceDip = 9.0; + +[[nodiscard]] bool drag_stage(GuiSketchInteractionStage stage) noexcept { + return stage == GuiSketchInteractionStage::SelectedHandle || + stage == GuiSketchInteractionStage::DragCandidate; +} + +[[nodiscard]] std::string solve_status_text(SketchSolveStatus status) { + switch (status) { + case SketchSolveStatus::FullyConstrained: return "Fully constrained"; + case SketchSolveStatus::UnderConstrained: return "Under constrained"; + case SketchSolveStatus::Redundant: return "Redundant"; + case SketchSolveStatus::Conflicting: return "Conflicting"; + case SketchSolveStatus::NonConvergent: return "Non-convergent"; + case SketchSolveStatus::InvalidReference: return "Invalid reference"; + } + return "Not evaluated"; +} + +[[nodiscard]] double screen_distance(GuiSketchScreenPoint first, + GuiSketchScreenPoint second) noexcept { + return std::hypot(second.x - first.x, second.y - first.y); +} + +class SketchDragBinder final : public QObject { +public: + explicit SketchDragBinder(MainWindow& window) + : QObject(&window), window_(window), + viewport_(window.findChild(QStringLiteral("blcad.occt_viewport"))), + diagnostics_(window.findChild(QStringLiteral("blcad.diagnostics"))), + dof_status_(window.findChild(QStringLiteral("blcad.sketch.dof_status"))), + solve_status_(window.findChild(QStringLiteral("blcad.sketch.solve_status"))) { + setObjectName(QStringLiteral("blcad.sketch.drag_binder")); + if (viewport_ != nullptr) { + viewport_->installEventFilter(this); + bind_viewport(); + } + window_.installEventFilter(this); + bind_shell_actions(); + defer_sync(); + } + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == &window_ && event->type() == QEvent::KeyPress) { + const auto* key = static_cast(event); + if (key->key() == Qt::Key_Escape && controller_ && controller_->active() && + drag_stage(window_.sketch_workspace().stage())) { + restore_source_preview(); + controller_->cancel(); + if (viewport_ != nullptr) { + viewport_->set_sketch_inference_anchor(std::nullopt); + viewport_->set_sketch_selection_enabled(true); + } + publish_baseline_feedback(); + } + } else if (watched == viewport_ && + (event->type() == QEvent::UngrabMouse || + event->type() == QEvent::WindowDeactivate) && + controller_ && controller_->active() && + drag_stage(window_.sketch_workspace().stage())) { + cancel_drag(true, "Sketch drag cancelled because pointer capture was lost"); + } + return QObject::eventFilter(watched, event); + } + +private: + void bind_viewport() { + viewport_->set_sketch_drag_pointer_callback( + [this](GuiSketchScreenPoint screen, Point2 raw, const GuiSketchSnapResult& snap, + const std::optional& hit) { + (void)screen; + (void)raw; + (void)hit; + if (!controller_ || !controller_->active() || + !drag_stage(window_.sketch_workspace().stage())) + return; + if (window_.sketch_workspace().stage() == GuiSketchInteractionStage::SelectedHandle) { + if (!window_.sketch_workspace().show_drag_candidate(window_.session())) { + cancel_drag(true, "Sketch drag could not enter live preview"); + return; + } + window_.refresh_command_state(); + } + auto queued = controller_->queue_pointer(snap.snapped_point); + if (queued.has_error()) { + cancel_drag(true, queued.error().message()); + return; + } + schedule_solve(); + }); + viewport_->set_sketch_pointer_phase_callback( + [this](GuiSketchPointerPhase phase, GuiSketchScreenPoint screen, Point2 raw, + const GuiSketchSnapResult& snap, const std::optional& hit) { + (void)raw; + (void)hit; + if (phase == GuiSketchPointerPhase::Press) + begin_drag(screen); + else + release_drag(snap.snapped_point); + }); + } + + void bind_shell_actions() { + const auto defer_for = [this](QStringView object_name) { + if (QAction* action = window_.findChild(object_name.toString())) + connect(action, &QAction::triggered, this, [this] { defer_sync(); }); + }; + defer_for(u"blcad.action.edit_sketch"); + defer_for(u"blcad.action.finish_sketch"); + defer_for(u"blcad.action.repair_sketch"); + defer_for(u"blcad.action.recompute"); + } + + void defer_sync() { + QTimer::singleShot(0, this, [this] { sync_controller(); }); + } + + [[nodiscard]] GuiSketchInteractionConfig interaction_config() const { + GuiSketchInteractionConfig config; + if (const QAction* grid = window_.findChild(QStringLiteral("blcad.action.sketch_grid"))) + config.grid.visible = grid->isChecked(); + if (const QAction* snap = + window_.findChild(QStringLiteral("blcad.action.sketch_grid_snap"))) + config.grid.snap_enabled = snap->isChecked(); + return config; + } + + void sync_controller() { + if (!window_.sketch_workspace().active() || !window_.active_sketch() || + window_.session().part_document() == nullptr) { + controller_.reset(); + if (viewport_ != nullptr) + viewport_->set_sketch_drag_handles({}); + return; + } + if (controller_ && controller_->active()) + return; + auto controller = GuiSketchDragController::create(*window_.session().part_document(), + *window_.active_sketch()); + if (controller.has_error()) { + append_diagnostic(controller.error()); + controller_.reset(); + viewport_->set_sketch_drag_handles({}); + return; + } + controller_.emplace(std::move(controller.value())); + publish_baseline_feedback(); + publish_handles(controller_->handles()); + } + + void begin_drag(GuiSketchScreenPoint screen) { + if (!controller_ || controller_->active() || !window_.sketch_workspace().active() || + window_.session().task().active()) + return; + const auto handle = handle_at(screen); + if (!handle) + return; + auto begun = controller_->begin(handle->id); + if (begun.has_error()) { + append_diagnostic(begun.error()); + publish_baseline_feedback(); + return; + } + if (!window_.sketch_workspace().select_handle(window_.session(), handle->id)) { + controller_->cancel(); + append_message("selected Sketch handle could not enter the workspace drag lifecycle"); + return; + } + viewport_->set_sketch_selection_enabled(false); + viewport_->set_sketch_inference_anchor(handle->position); + window_.refresh_command_state(); + } + + void release_drag(Point2 final_pointer) { + if (!controller_ || !controller_->active() || !drag_stage(window_.sketch_workspace().stage())) + return; + if (window_.sketch_workspace().stage() == GuiSketchInteractionStage::SelectedHandle && + !controller_->has_pending() && !controller_->processed_pointer()) { + restore_source_preview(); + controller_->cancel(); + (void)window_.sketch_workspace().escape(window_.session()); + viewport_->set_sketch_inference_anchor(std::nullopt); + viewport_->set_sketch_selection_enabled(true); + publish_baseline_feedback(); + window_.refresh_command_state(); + return; + } + + if (window_.sketch_workspace().stage() == GuiSketchInteractionStage::SelectedHandle && + !window_.sketch_workspace().show_drag_candidate(window_.session())) { + cancel_drag(true, "Sketch drag could not enter release preview"); + return; + } + auto preview = controller_->flush(final_pointer); + solve_scheduled_ = false; + if (preview.has_error()) { + cancel_drag(true, preview.error().message()); + return; + } + publish_preview(preview.value()); + + auto committed = controller_->commit(window_.session()); + if (committed.has_error()) { + cancel_drag(true, committed.error().message()); + return; + } + if (!window_.sketch_workspace().commit_drag(window_.session())) { + append_message("Sketch drag document committed but workspace lifecycle could not close"); + return; + } + viewport_->set_sketch_inference_anchor(std::nullopt); + viewport_->set_sketch_selection_enabled(true); + window_.refresh_command_state(); + sync_controller(); + publish_current_document_scene(); + } + + [[nodiscard]] std::optional + handle_at(GuiSketchScreenPoint pointer) const { + if (!controller_ || viewport_ == nullptr) + return std::nullopt; + const auto handles = controller_->latest_preview() + ? controller_->handles_for_topology( + controller_->latest_preview()->topology()) + : controller_->handles(); + struct Candidate { + GuiSketchDragHandle handle; + double distance{0.0}; + }; + std::vector candidates; + for (const auto& handle : handles) { + auto screen = viewport_->sketch_plane_to_screen(handle.position); + if (screen.has_error()) + continue; + const double distance = screen_distance(pointer, screen.value()); + if (distance <= kHandleToleranceDip) + candidates.push_back({handle, distance}); + } + std::sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + if (std::abs(left.distance - right.distance) > 1.0e-9) + return left.distance < right.distance; + return left.handle.id < right.handle.id; + }); + return candidates.empty() ? std::nullopt + : std::optional{candidates.front().handle}; + } + + void schedule_solve() { + if (solve_scheduled_) + return; + solve_scheduled_ = true; + QTimer::singleShot(0, this, [this] { + solve_scheduled_ = false; + if (!controller_ || !controller_->active() || !controller_->has_pending()) + return; + auto preview = controller_->process_pending(); + if (preview.has_error()) { + cancel_drag(true, preview.error().message()); + return; + } + publish_preview(preview.value()); + }); + } + + void publish_preview(const GuiSketchDragPreview& preview) { + publish_scene(preview.preview_sketch()); + publish_handles(controller_->handles_for_topology(preview.topology())); + const auto& solve = preview.solve(); + window_.sketch_workspace().set_solve_feedback(solve.remaining_dof, + solve_status_text(solve.status)); + refresh_status_labels(); + } + + void publish_baseline_feedback() { + if (!controller_) + return; + const auto& solve = controller_->baseline_solve(); + window_.sketch_workspace().set_solve_feedback(solve.remaining_dof, + solve_status_text(solve.status)); + refresh_status_labels(); + } + + void publish_handles(const std::vector& handles) { + if (viewport_ == nullptr) + return; + std::vector positions; + positions.reserve(handles.size()); + for (const auto& handle : handles) + positions.push_back(handle.position); + viewport_->set_sketch_drag_handles(std::move(positions)); + } + + void publish_scene(const Sketch& sketch) { + const PartDocument* part = window_.session().part_document(); + if (viewport_ == nullptr || part == nullptr) + return; + auto scene = window_.session().part_shape_cache() != nullptr + ? GuiSketchInteractionSceneBuilder{}.build( + *part, sketch, *window_.session().part_shape_cache()) + : GuiSketchInteractionSceneBuilder{}.build(*part, sketch); + if (scene.has_error()) { + append_diagnostic(scene.error()); + return; + } + const auto plane = window_.sketch_workbench().plane_view(window_.session(), sketch.id()); + if (plane.has_error()) { + append_diagnostic(plane.error()); + return; + } + auto published = viewport_->set_sketch_interaction(plane.value(), std::move(scene.value()), + interaction_config()); + if (published.has_error()) + append_diagnostic(published.error()); + } + + void publish_current_document_scene() { + if (!window_.active_sketch() || window_.session().part_document() == nullptr) + return; + const Sketch* sketch = window_.session().part_document()->find_sketch(*window_.active_sketch()); + if (sketch != nullptr) + publish_scene(*sketch); + if (controller_) + publish_handles(controller_->handles()); + } + + void restore_source_preview() { + if (!controller_) + return; + publish_scene(controller_->source_sketch()); + publish_handles(controller_->handles()); + } + + void cancel_drag(bool cancel_workspace, std::string message) { + if (!message.empty()) + append_message(message); + restore_source_preview(); + if (controller_) + controller_->cancel(); + if (cancel_workspace && drag_stage(window_.sketch_workspace().stage())) + (void)window_.sketch_workspace().escape(window_.session()); + if (viewport_ != nullptr) { + viewport_->set_sketch_inference_anchor(std::nullopt); + viewport_->set_sketch_selection_enabled(true); + } + publish_baseline_feedback(); + window_.refresh_command_state(); + } + + void refresh_status_labels() const { + const auto& status = window_.sketch_workspace().status(); + if (dof_status_ != nullptr) + dof_status_->setText(status.remaining_dof + ? QStringLiteral("DOF: %1").arg(*status.remaining_dof) + : QStringLiteral("DOF: —")); + if (solve_status_ != nullptr) + solve_status_->setText( + QStringLiteral("Solve: %1").arg(QString::fromStdString(status.solve_status))); + } + + void append_diagnostic(const Error& error) const { + if (diagnostics_ != nullptr) + diagnostics_->append(QStringLiteral("[%1] %2: %3") + .arg(QString::fromStdString(std::string(to_string(error.category()))), + QString::fromStdString(error.object_id()), + QString::fromStdString(error.message()))); + } + + void append_message(const std::string& message) const { + if (diagnostics_ != nullptr) + diagnostics_->append(QStringLiteral("[Validation] gui.sketch_drag: %1") + .arg(QString::fromStdString(message))); + } + + MainWindow& window_; + OcctViewport* viewport_{nullptr}; + QTextEdit* diagnostics_{nullptr}; + QLabel* dof_status_{nullptr}; + QLabel* solve_status_{nullptr}; + std::optional controller_; + bool solve_scheduled_{false}; +}; + +} // namespace + +void install_sketch_drag_binder(MainWindow& window) { + if (window.findChild(QStringLiteral("blcad.sketch.drag_binder")) != nullptr) + return; + (void)new SketchDragBinder(window); +} + +} // namespace blcad::gui diff --git a/src/gui/gui_sketch_interaction_binder.cpp b/src/gui/gui_sketch_interaction_binder.cpp index ca947e82..d9f567c8 100644 --- a/src/gui/gui_sketch_interaction_binder.cpp +++ b/src/gui/gui_sketch_interaction_binder.cpp @@ -1,4 +1,5 @@ #include "blcad/gui/gui_sketch_interaction_binder.hpp" +#include "blcad/gui/gui_sketch_drag_binder.hpp" #include "blcad/gui/main_window.hpp" @@ -394,6 +395,7 @@ void install_sketch_interaction_binder(MainWindow& window) { if (window.findChild(QStringLiteral("blcad.sketch.interaction_binder")) != nullptr) return; (void)new SketchInteractionBinder(window); + install_sketch_drag_binder(window); } } // namespace blcad::gui diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index d40e7cf8..094ebaa9 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -431,13 +431,13 @@ void MainWindow::create_command_bar() { label->setObjectName(QStringLiteral("blcad.sketch.command_group")); groups_layout->addWidget(label); } - command_bar->addWidget(sketch_command_groups_); + sketch_command_groups_action_ = command_bar->addWidget(sketch_command_groups_); sketch_numeric_hud_ = new QLineEdit(command_bar); sketch_numeric_hud_->setObjectName(QStringLiteral("blcad.sketch.numeric_hud")); sketch_numeric_hud_->setPlaceholderText(QStringLiteral("id, x1, y1, x2, y2 (mm)")); sketch_numeric_hud_->setMaximumWidth(280); - command_bar->addWidget(sketch_numeric_hud_); + sketch_numeric_hud_action_ = command_bar->addWidget(sketch_numeric_hud_); connect(sketch_numeric_hud_, &QLineEdit::textChanged, this, [this](const QString& text) { if (sketch_workspace_.stage() == GuiSketchInteractionStage::NumericInput) (void)sketch_workspace_.set_numeric_input(text.toStdString()); @@ -938,11 +938,16 @@ void MainWindow::repair_selected_sketch() { void MainWindow::refresh_sketch_workspace_ui() { const bool active = sketch_workspace_.active(); + if (sketch_command_groups_action_) + sketch_command_groups_action_->setVisible(active); if (sketch_command_groups_) sketch_command_groups_->setVisible(active); + const bool numeric_hud_visible = + active && sketch_workspace_.stage() == GuiSketchInteractionStage::NumericInput; + if (sketch_numeric_hud_action_) + sketch_numeric_hud_action_->setVisible(numeric_hud_visible); if (sketch_numeric_hud_) - sketch_numeric_hud_->setVisible(active && - sketch_workspace_.stage() == GuiSketchInteractionStage::NumericInput); + sketch_numeric_hud_->setVisible(numeric_hud_visible); for (QLabel* label : {sketch_cursor_status_, sketch_snap_status_, sketch_dof_status_, sketch_solve_status_}) if (label) label->setVisible(active); diff --git a/src/gui/occt_viewport.cpp b/src/gui/occt_viewport.cpp index 8c20d3f6..ce3298c3 100644 --- a/src/gui/occt_viewport.cpp +++ b/src/gui/occt_viewport.cpp @@ -47,6 +47,11 @@ class SketchInteractionOverlay final : public QWidget { update(); } + void set_handles(std::vector handles) { + handles_ = std::move(handles); + update(); + } + void set_hover(std::vector polyline, std::optional point) { hover_polyline_ = std::move(polyline); @@ -73,6 +78,7 @@ class SketchInteractionOverlay final : public QWidget { } [[nodiscard]] std::size_t grid_line_count() const noexcept { return grid_.size(); } + [[nodiscard]] std::size_t handle_count() const noexcept { return handles_.size(); } protected: void paintEvent(QPaintEvent* event) override { @@ -87,6 +93,12 @@ class SketchInteractionOverlay final : public QWidget { painter.drawLine(QPointF(line.start.x, line.start.y), QPointF(line.end.x, line.end.y)); } + for (const auto& handle : handles_) { + painter.setPen(QPen(QColor(84, 190, 255), 1.8)); + painter.setBrush(QColor(48, 52, 59)); + painter.drawEllipse(QPointF(handle.x, handle.y), 4.2, 4.2); + } + if (hover_polyline_.size() >= 2U) { QPen pen(QColor(255, 196, 61)); pen.setWidthF(2.2); @@ -124,6 +136,7 @@ class SketchInteractionOverlay final : public QWidget { private: std::vector grid_; + std::vector handles_; std::vector hover_polyline_; std::optional hover_point_; std::optional snap_point_; @@ -493,6 +506,7 @@ OcctViewport::set_sketch_interaction(GuiSketchPlaneView plane, sketch_overlay_->show(); sketch_overlay_->raise(); rebuild_sketch_grid(); + rebuild_sketch_drag_handles(); return Result::success(primitive_count); } @@ -504,8 +518,10 @@ void OcctViewport::clear_sketch_interaction() { hovered_sketch_hit_.reset(); sketch_box_selection_.reset(); sketch_box_active_ = false; + sketch_drag_handles_.clear(); if (auto* overlay = static_cast(sketch_overlay_)) { overlay->set_grid({}); + overlay->set_handles({}); overlay->clear_transient(); overlay->hide(); } @@ -536,10 +552,23 @@ void OcctViewport::set_sketch_grid_config(GuiSketchGridConfig config) { static_cast(last_mouse_position_.y())}); } +void OcctViewport::set_sketch_drag_handles(std::vector handles) { + sketch_drag_handles_ = std::move(handles); + rebuild_sketch_drag_handles(); +} + void OcctViewport::set_sketch_pointer_callback(SketchPointerCallback callback) { sketch_pointer_callback_ = std::move(callback); } +void OcctViewport::set_sketch_drag_pointer_callback(SketchDragPointerCallback callback) { + sketch_drag_pointer_callback_ = std::move(callback); +} + +void OcctViewport::set_sketch_pointer_phase_callback(SketchPointerPhaseCallback callback) { + sketch_pointer_phase_callback_ = std::move(callback); +} + void OcctViewport::set_sketch_selection_callback(SketchSelectionCallback callback) { sketch_selection_callback_ = std::move(callback); } @@ -684,6 +713,11 @@ std::size_t OcctViewport::sketch_grid_line_count() const noexcept { return overlay == nullptr ? 0U : overlay->grid_line_count(); } +std::size_t OcctViewport::sketch_drag_handle_count() const noexcept { + const auto* overlay = static_cast(sketch_overlay_); + return overlay == nullptr ? 0U : overlay->handle_count(); +} + bool OcctViewport::native_viewer_available() const noexcept { return !impl_->view.IsNull(); } @@ -730,6 +764,7 @@ void OcctViewport::resizeEvent(QResizeEvent* event) { } else { rebuild_sketch_grid(); } + rebuild_sketch_drag_handles(); } void OcctViewport::mousePressEvent(QMouseEvent* event) { @@ -743,16 +778,18 @@ void OcctViewport::mousePressEvent(QMouseEvent* event) { impl_->view->StartRotation(last_mouse_position_.x(), last_mouse_position_.y()); } } - if (event->button() == Qt::LeftButton && sketch_interaction_ && sketch_selection_enabled_) { - sketch_press_position_ = last_mouse_position_; - auto hits = sketch_interaction_->hits_at( - {event->position().x(), event->position().y()}); - if (hits && hits.value().empty()) { - sketch_box_active_ = true; - sketch_box_selection_ = GuiSketchScreenRect{ - {event->position().x(), event->position().y()}, - {event->position().x(), event->position().y()}}; - static_cast(sketch_overlay_)->set_box(sketch_box_selection_); + if (event->button() == Qt::LeftButton && sketch_interaction_) { + const GuiSketchScreenPoint current{event->position().x(), event->position().y()}; + update_sketch_pointer(current); + publish_sketch_pointer_phase(GuiSketchPointerPhase::Press, current); + if (sketch_selection_enabled_) { + sketch_press_position_ = last_mouse_position_; + auto hits = sketch_interaction_->hits_at(current); + if (hits && hits.value().empty()) { + sketch_box_active_ = true; + sketch_box_selection_ = GuiSketchScreenRect{current, current}; + static_cast(sketch_overlay_)->set_box(sketch_box_selection_); + } } } QWidget::mousePressEvent(event); @@ -792,6 +829,8 @@ void OcctViewport::mouseReleaseEvent(QMouseEvent* event) { } if (event->button() == Qt::LeftButton && sketch_interaction_) { const GuiSketchScreenPoint current{event->position().x(), event->position().y()}; + update_sketch_pointer(current); + publish_sketch_pointer_phase(GuiSketchPointerPhase::Release, current); if (sketch_selection_enabled_) { if (sketch_box_active_ && (event->position().toPoint() - sketch_press_position_).manhattanLength() > 3) { @@ -970,6 +1009,20 @@ void OcctViewport::rebuild_sketch_grid() { overlay->set_grid({}); } +void OcctViewport::rebuild_sketch_drag_handles() { + auto* overlay = static_cast(sketch_overlay_); + if (!sketch_interaction_ || overlay == nullptr) + return; + std::vector handles; + handles.reserve(sketch_drag_handles_.size()); + for (const auto point : sketch_drag_handles_) { + auto screen = sketch_interaction_->mapping().plane_to_screen(point); + if (screen) + handles.push_back(screen.value()); + } + overlay->set_handles(std::move(handles)); +} + void OcctViewport::update_sketch_pointer(GuiSketchScreenPoint screen_point) { if (!sketch_interaction_) return; @@ -1012,6 +1065,16 @@ void OcctViewport::update_sketch_pointer(GuiSketchScreenPoint screen_point) { if (sketch_pointer_callback_) sketch_pointer_callback_(sketch_snap_result_->raw_point, *sketch_snap_result_, hovered_sketch_hit_); + if (sketch_drag_pointer_callback_) + sketch_drag_pointer_callback_(screen_point, sketch_snap_result_->raw_point, + *sketch_snap_result_, hovered_sketch_hit_); +} + +void OcctViewport::publish_sketch_pointer_phase(GuiSketchPointerPhase phase, + GuiSketchScreenPoint screen_point) { + if (sketch_pointer_phase_callback_ && sketch_snap_result_) + sketch_pointer_phase_callback_(phase, screen_point, sketch_snap_result_->raw_point, + *sketch_snap_result_, hovered_sketch_hit_); } void OcctViewport::publish_sketch_selection() { diff --git a/tests/gui/gui_sketch_drag_tests.cpp b/tests/gui/gui_sketch_drag_tests.cpp new file mode 100644 index 00000000..a95803ce --- /dev/null +++ b/tests/gui/gui_sketch_drag_tests.cpp @@ -0,0 +1,404 @@ +#include "blcad/gui/gui_sketch_drag.hpp" +#include "blcad/gui/gui_sketch_interaction_binder.hpp" +#include "blcad/gui/gui_sketch_workbench.hpp" +#include "blcad/gui/main_window.hpp" +#include "blcad/gui/occt_viewport.hpp" + +#include "blcad/core/parameter.hpp" + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace blcad; +using namespace blcad::gui; + +namespace { + +Parameter length_parameter(const char* id, double value) { + return Parameter::create_length(ParameterId(id), id, Quantity::length_mm(value, id).value()).value(); +} + +SketchReferenceTarget line_target(const char* id) { + return SketchReferenceTarget::create_line_segment(SketchEntityId(id)).value(); +} + +SketchReferenceTarget line_start(const char* id) { + return SketchReferenceTarget::create_line_segment_start(SketchEntityId(id)).value(); +} + +SketchReferenceTarget line_end(const char* id) { + return SketchReferenceTarget::create_line_segment_end(SketchEntityId(id)).value(); +} + +QTreeWidgetItem* find_item(QTreeWidgetItem* item, QStringView id) { + if (item->data(0, Qt::UserRole).toString() == id) + return item; + for (int index = 0; index < item->childCount(); ++index) + if (auto* found = find_item(item->child(index), id)) + return found; + return nullptr; +} + +const GuiSketchDragHandle* find_handle(const GuiSketchDragController& controller, + GuiSketchDragHandleKind kind, + std::string_view entity = {}) { + const auto found = std::find_if(controller.handles().begin(), controller.handles().end(), + [kind, entity](const auto& handle) { + return handle.kind == kind && + (entity.empty() || handle.entity_id == entity); + }); + return found == controller.handles().end() ? nullptr : &*found; +} + +struct ArcGeometry { + Point2 center; + double radius; +}; + +ArcGeometry arc_geometry(const SketchTopology& topology, std::string_view entity_id) { + const auto* entity = topology.find_entity(entity_id); + REQUIRE(entity != nullptr); + REQUIRE(entity->points().size() == 3U); + const Point2 a = topology.find_point(entity->points()[0])->position(); + const Point2 b = topology.find_point(entity->points()[1])->position(); + const Point2 c = topology.find_point(entity->points()[2])->position(); + const double denominator = + 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + REQUIRE(std::abs(denominator) > 1.0e-12); + const double aa = a.x * a.x + a.y * a.y; + const double bb = b.x * b.x + b.y * b.y; + const double cc = c.x * c.x + c.y * c.y; + const Point2 center{(aa * (b.y - c.y) + bb * (c.y - a.y) + cc * (a.y - b.y)) / + denominator, + (aa * (c.x - b.x) + bb * (a.x - c.x) + cc * (b.x - a.x)) / + denominator}; + return {center, std::hypot(a.x - center.x, a.y - center.y)}; +} + +void seed_drag_line(GuiDocumentSession& session, GuiSketchWorkbench& workbench, + const char* part_id, const char* sketch_id, bool fully_constrained) { + REQUIRE(session.create_part(DocumentId(part_id), "Drag Part")); + REQUIRE(workbench.create_xy_datum(session, DatumPlaneId("datum.xy"), "XY")); + if (fully_constrained) + REQUIRE(session.commit_part_transaction("Seed width", [](PartDocument& part) { + return part.add_parameter(length_parameter("drag.width", 10.0)); + })); + REQUIRE(workbench.create_sketch( + session, Sketch::create(SketchId(sketch_id), "Drag Sketch", DatumPlaneId("datum.xy")).value())); + REQUIRE(workbench.add_line( + session, SketchId(sketch_id), + LineSegment::create(SketchEntityId("line.a"), {0.0, 0.0}, {8.0, 2.0}).value())); + REQUIRE(workbench.add_constraint( + session, SketchId(sketch_id), + SketchGeometricConstraint::create_fixed(SketchConstraintId("fixed.start"), + line_start("line.a")).value())); + REQUIRE(workbench.add_constraint( + session, SketchId(sketch_id), + SketchGeometricConstraint::create_horizontal(SketchConstraintId("horizontal.line"), + line_target("line.a")).value())); + if (fully_constrained) + REQUIRE(workbench.add_dimension( + session, SketchId(sketch_id), + SketchDrivingDimension::create_horizontal_distance( + SketchDimensionId("width"), line_start("line.a"), line_end("line.a"), + ParameterId("drag.width")).value())); +} + +} // namespace + +TEST_CASE("Block 110 exposes stable semantic handles without duplicating shared endpoints", + "[gui][sketch-drag]") { + GuiDocumentSession session; + GuiSketchWorkbench workbench; + REQUIRE(session.create_part(DocumentId("part.handle_catalog"), "Handle Catalog")); + REQUIRE(workbench.create_xy_datum(session, DatumPlaneId("datum.xy"), "XY")); + REQUIRE(session.commit_part_transaction("Seed dimension", [](PartDocument& part) { + auto width = part.add_parameter(length_parameter("catalog.width", 10.0)); + if (width.has_error()) return width; + auto diameter = part.add_parameter(length_parameter("catalog.diameter", 8.0)); + if (diameter.has_error()) return diameter; + return part.add_parameter(length_parameter("catalog.height", 6.0)); + })); + + auto sketch = Sketch::create(SketchId("sketch.catalog"), "Catalog", DatumPlaneId("datum.xy")); + REQUIRE(sketch); + REQUIRE(sketch.value().add_entity(LineSegment::create( + SketchEntityId("line.a"), {0.0, 0.0}, {10.0, 0.0}).value())); + REQUIRE(sketch.value().add_entity(LineSegment::create( + SketchEntityId("line.b"), {10.0, 0.0}, {5.0, 8.0}).value())); + REQUIRE(sketch.value().add_entity(LineSegment::create( + SketchEntityId("line.c"), {5.0, 8.0}, {0.0, 0.0}).value())); + REQUIRE(sketch.value().add_entity(ArcSegment::create_three_point( + SketchEntityId("arc.a"), {20.0, 0.0}, {25.0, 5.0}, {30.0, 0.0}).value())); + REQUIRE(sketch.value().add_entity(SplineSegment::create_cubic_bezier( + SketchEntityId("spline.a"), {40.0, 0.0}, {42.0, 6.0}, {48.0, 6.0}, {50.0, 0.0}).value())); + REQUIRE(sketch.value().add_profile( + ClosedProfile::create(ProfileId("profile.triangle"), + {SketchEntityId("line.a"), SketchEntityId("line.b"), + SketchEntityId("line.c")}).value())); + REQUIRE(sketch.value().add_profile(RectangleProfile::create( + ProfileId("profile.rectangle"), ParameterId("catalog.width"), + ParameterId("catalog.height"), {60.0, 0.0}).value())); + REQUIRE(sketch.value().add_profile(CircleProfile::create( + ProfileId("profile.circle"), ParameterId("catalog.diameter"), {70.0, 0.0}).value())); + REQUIRE(sketch.value().add_dimension(SketchDrivingDimension::create_horizontal_distance( + SketchDimensionId("line.width"), line_start("line.a"), line_end("line.a"), + ParameterId("catalog.width")).value())); + REQUIRE(workbench.create_sketch(session, std::move(sketch.value()))); + + auto controller = GuiSketchDragController::create(*session.part_document(), SketchId("sketch.catalog")); + REQUIRE(controller); + CHECK(std::is_sorted(controller.value().handles().begin(), controller.value().handles().end(), + [](const auto& first, const auto& second) { return first.id < second.id; })); + + const auto count_kind = [&](GuiSketchDragHandleKind kind) { + return std::count_if(controller.value().handles().begin(), controller.value().handles().end(), + [kind](const auto& handle) { return handle.kind == kind; }); + }; + CHECK(count_kind(GuiSketchDragHandleKind::Endpoint) == 7); + CHECK(count_kind(GuiSketchDragHandleKind::Midpoint) == 3); + CHECK(count_kind(GuiSketchDragHandleKind::Center) == 3); + CHECK(count_kind(GuiSketchDragHandleKind::Radius) == 1); + CHECK(count_kind(GuiSketchDragHandleKind::Arc) == 1); + CHECK(count_kind(GuiSketchDragHandleKind::Spline) == 2); + CHECK(count_kind(GuiSketchDragHandleKind::Dimension) == 1); + + const auto* line_a = controller.value().source_topology().find_entity("entity/line.a"); + const auto* line_b = controller.value().source_topology().find_entity("entity/line.b"); + REQUIRE(line_a != nullptr); + REQUIRE(line_b != nullptr); + REQUIRE(line_a->points()[1] == line_b->points()[0]); + const auto shared_id = line_a->points()[1]; + CHECK(std::count_if(controller.value().handles().begin(), controller.value().handles().end(), + [&shared_id](const auto& handle) { + return handle.kind == GuiSketchDragHandleKind::Endpoint && + handle.point_id == shared_id; + }) == 1); +} + +TEST_CASE("Block 110 coalesces pointer samples and commits the exact final sample once", + "[gui][sketch-drag][integration][sketch-live-solve]") { + GuiDocumentSession session; + GuiSketchWorkbench workbench; + seed_drag_line(session, workbench, "part.coalesced_drag", "sketch.drag", false); + const Point2 original_end = + session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + + auto controller = GuiSketchDragController::create(*session.part_document(), SketchId("sketch.drag")); + REQUIRE(controller); + CHECK(controller.value().baseline_solve().status == SketchSolveStatus::UnderConstrained); + CHECK(controller.value().baseline_solve().remaining_dof == 1U); + const auto endpoint = std::find_if( + controller.value().handles().begin(), controller.value().handles().end(), [](const auto& handle) { + return handle.kind == GuiSketchDragHandleKind::Endpoint && handle.point_id && + handle.position == Point2{8.0, 2.0}; + }); + REQUIRE(endpoint != controller.value().handles().end()); + REQUIRE(controller.value().begin(endpoint->id)); + + REQUIRE(controller.value().queue_pointer({10.0, 0.0})); + REQUIRE(controller.value().queue_pointer({12.0, 0.0})); + REQUIRE(controller.value().queue_pointer({15.0, 0.0})); + CHECK(controller.value().solve_count() == 0U); + CHECK(session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end() == original_end); + + auto preview = controller.value().process_pending(); + REQUIRE(preview); + REQUIRE(controller.value().processed_pointer().has_value()); + CHECK(*controller.value().processed_pointer() == Point2{15.0, 0.0}); + CHECK(controller.value().solve_count() == 1U); + const auto* preview_line = preview.value().topology().find_entity("entity/line.a"); + REQUIRE(preview_line != nullptr); + const Point2 preview_end = preview.value().topology().find_point(preview_line->points()[1])->position(); + CHECK(preview_end.x == Catch::Approx(15.0).margin(1.0e-6)); + CHECK(preview_end.y == Catch::Approx(0.0).margin(1.0e-6)); + CHECK(session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end() == original_end); + + auto final_preview = controller.value().flush({20.0, 0.0}); + REQUIRE(final_preview); + CHECK(*controller.value().processed_pointer() == Point2{20.0, 0.0}); + CHECK(controller.value().solve_count() == 2U); + REQUIRE(controller.value().commit(session)); + const Point2 committed = session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + CHECK(committed.x == Catch::Approx(20.0).margin(1.0e-6)); + CHECK(committed.y == Catch::Approx(0.0).margin(1.0e-6)); + CHECK(session.undo_label() == "Drag sketch handle"); + + REQUIRE(session.undo()); + CHECK(session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end() == original_end); + REQUIRE(session.redo()); + const Point2 redone = session.part_document()->find_sketch(SketchId("sketch.drag")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + CHECK(redone.x == Catch::Approx(20.0).margin(1.0e-6)); + CHECK(redone.y == Catch::Approx(0.0).margin(1.0e-6)); +} + +TEST_CASE("Block 110 cancels previews and refuses incompatible fully constrained drags", + "[gui][sketch-drag][integration][sketch-live-solve]") { + SECTION("cancel restores the pre-drag document") { + GuiDocumentSession session; + GuiSketchWorkbench workbench; + seed_drag_line(session, workbench, "part.cancel_drag", "sketch.cancel", false); + const Point2 before = session.part_document()->find_sketch(SketchId("sketch.cancel")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + auto controller = + GuiSketchDragController::create(*session.part_document(), SketchId("sketch.cancel")); + REQUIRE(controller); + const auto endpoint = std::find_if( + controller.value().handles().begin(), controller.value().handles().end(), [](const auto& handle) { + return handle.kind == GuiSketchDragHandleKind::Endpoint && + handle.position == Point2{8.0, 2.0}; + }); + REQUIRE(endpoint != controller.value().handles().end()); + REQUIRE(controller.value().begin(endpoint->id)); + REQUIRE(controller.value().flush({16.0, 0.0})); + controller.value().cancel(); + CHECK_FALSE(controller.value().active()); + CHECK(session.part_document()->find_sketch(SketchId("sketch.cancel")) + ->find_line_segment(SketchEntityId("line.a"))->end() == before); + } + + SECTION("fixed and dimensioned geometry refuses an incompatible pointer") { + GuiDocumentSession session; + GuiSketchWorkbench workbench; + seed_drag_line(session, workbench, "part.fixed_drag", "sketch.fixed", true); + const Point2 before = session.part_document()->find_sketch(SketchId("sketch.fixed")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + auto controller = + GuiSketchDragController::create(*session.part_document(), SketchId("sketch.fixed")); + REQUIRE(controller); + CHECK(controller.value().baseline_solve().status == SketchSolveStatus::FullyConstrained); + const auto endpoint = std::find_if( + controller.value().handles().begin(), controller.value().handles().end(), [](const auto& handle) { + return handle.kind == GuiSketchDragHandleKind::Endpoint && + handle.position == Point2{8.0, 2.0}; + }); + REQUIRE(endpoint != controller.value().handles().end()); + REQUIRE(controller.value().begin(endpoint->id)); + auto refused = controller.value().flush({20.0, 0.0}); + CHECK_FALSE(refused); + CHECK_FALSE(controller.value().latest_preview().has_value()); + CHECK_FALSE(controller.value().commit(session)); + CHECK(session.part_document()->find_sketch(SketchId("sketch.fixed")) + ->find_line_segment(SketchEntityId("line.a"))->end() == before); + } +} + +TEST_CASE("Block 110 arc center and radius handles use Block 109 solver target families", + "[gui][sketch-drag][integration][sketch-live-solve]") { + GuiDocumentSession session; + GuiSketchWorkbench workbench; + REQUIRE(session.create_part(DocumentId("part.arc_drag"), "Arc Drag")); + REQUIRE(workbench.create_xy_datum(session, DatumPlaneId("datum.xy"), "XY")); + REQUIRE(workbench.create_sketch( + session, Sketch::create(SketchId("sketch.arc"), "Arc", DatumPlaneId("datum.xy")).value())); + REQUIRE(workbench.add_arc( + session, SketchId("sketch.arc"), + ArcSegment::create_three_point(SketchEntityId("arc.a"), {5.0, 0.0}, {0.0, 5.0}, {-5.0, 0.0}) + .value())); + + auto radius_controller = + GuiSketchDragController::create(*session.part_document(), SketchId("sketch.arc")); + REQUIRE(radius_controller); + const auto* radius = find_handle(radius_controller.value(), GuiSketchDragHandleKind::Radius, + "entity/arc.a"); + REQUIRE(radius != nullptr); + REQUIRE(radius_controller.value().begin(radius->id)); + auto radius_preview = radius_controller.value().flush({7.0, 0.0}); + REQUIRE(radius_preview); + const auto resized = arc_geometry(radius_preview.value().topology(), "entity/arc.a"); + CHECK(resized.radius == Catch::Approx(7.0).margin(1.0e-5)); + radius_controller.value().cancel(); + + auto center_controller = + GuiSketchDragController::create(*session.part_document(), SketchId("sketch.arc")); + REQUIRE(center_controller); + const auto* center = find_handle(center_controller.value(), GuiSketchDragHandleKind::Center, + "entity/arc.a"); + REQUIRE(center != nullptr); + REQUIRE(center_controller.value().begin(center->id)); + auto center_preview = center_controller.value().flush({2.0, 3.0}); + REQUIRE(center_preview); + const auto moved = arc_geometry(center_preview.value().topology(), "entity/arc.a"); + CHECK(moved.center.x == Catch::Approx(2.0).margin(1.0e-5)); + CHECK(moved.center.y == Catch::Approx(3.0).margin(1.0e-5)); +} + +TEST_CASE("Block 110 offscreen mouse drag publishes live solve and one release transaction", + "[gui][sketch-drag][integration][sketch-live-solve]") { + REQUIRE(qApp != nullptr); + MainWindow window; + install_sketch_interaction_binder(window); + seed_drag_line(window.session(), window.sketch_workbench(), "part.mouse_drag", "sketch.mouse", false); + window.refresh_command_state(); + window.show(); + qApp->processEvents(); + + auto* tree = window.findChild(QStringLiteral("blcad.model_browser")); + auto* edit = window.findChild(QStringLiteral("blcad.action.edit_sketch")); + auto* viewport = window.findChild(QStringLiteral("blcad.occt_viewport")); + REQUIRE(tree != nullptr); + REQUIRE(edit != nullptr); + REQUIRE(viewport != nullptr); + QTreeWidgetItem* sketch_item = nullptr; + for (int index = 0; index < tree->topLevelItemCount() && sketch_item == nullptr; ++index) + sketch_item = find_item(tree->topLevelItem(index), u"sketch.mouse"); + REQUIRE(sketch_item != nullptr); + tree->setCurrentItem(sketch_item); + edit->trigger(); + qApp->processEvents(); + REQUIRE(window.active_sketch().has_value()); + REQUIRE(viewport->sketch_interaction_active()); + REQUIRE(viewport->sketch_drag_handle_count() > 0U); + + auto start_screen = viewport->sketch_plane_to_screen({8.0, 2.0}); + auto end_screen = viewport->sketch_plane_to_screen({20.0, 0.0}); + REQUIRE(start_screen); + REQUIRE(end_screen); + + QMouseEvent press(QEvent::MouseButtonPress, + QPointF(start_screen.value().x, start_screen.value().y), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &press); + REQUIRE(window.sketch_workspace().stage() == GuiSketchInteractionStage::SelectedHandle); + + QMouseEvent move(QEvent::MouseMove, QPointF(end_screen.value().x, end_screen.value().y), + Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &move); + qApp->processEvents(); + CHECK(window.sketch_workspace().stage() == GuiSketchInteractionStage::DragCandidate); + CHECK(window.sketch_workspace().status().remaining_dof == 0U); + CHECK(window.session().part_document()->find_sketch(SketchId("sketch.mouse")) + ->find_line_segment(SketchEntityId("line.a"))->end() == Point2{8.0, 2.0}); + + QMouseEvent release(QEvent::MouseButtonRelease, + QPointF(end_screen.value().x, end_screen.value().y), Qt::LeftButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &release); + qApp->processEvents(); + + CHECK((window.sketch_workspace().stage() == GuiSketchInteractionStage::Idle || + window.sketch_workspace().stage() == GuiSketchInteractionStage::Hover)); + CHECK_FALSE(window.session().task().active()); + CHECK(viewport->sketch_selection_enabled()); + const Point2 committed = window.session().part_document()->find_sketch(SketchId("sketch.mouse")) + ->find_line_segment(SketchEntityId("line.a"))->end(); + CHECK(committed.x == Catch::Approx(20.0).margin(1.0e-5)); + CHECK(committed.y == Catch::Approx(0.0).margin(1.0e-5)); + CHECK(window.session().undo_label() == "Drag sketch handle"); +}