소스 검색

Sprint 10 Batch 3: align and distribute multi-selected elements

Adds a framework-free AlignmentCalculator (6 alignment edges, center-spaced
distribution along 2 axes) operating on world-space bounding boxes, plus a
new toolbar row wired to it for the current multi-selection. Completes
Sprint 10's committed scope (13/13 points).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 4 일 전
부모
커밋
c98da3ad98
9개의 변경된 파일477개의 추가작업 그리고 38개의 파일을 삭제
  1. +1
    -1
      backlog/backlog.md
  2. +34
    -6
      backlog/epics/06_layout_efficiency_and_operator_tooling.md
  3. +2
    -1
      backlog/sprints/sprint-10.md
  4. +109
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs
  5. +12
    -2
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  6. +154
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs
  7. +101
    -27
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  8. +60
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  9. +4
    -1
      state.md

+ 1
- 1
backlog/backlog.md 파일 보기

@@ -10,7 +10,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (2 of 4 stories Done — "Snap elements to grid and guides" Sprint 7, "Select multiple elements at once on the canvas" Sprint 10 Batch 2; "Align and distribute multiple elements" Ready, committed as Sprint 10 Batch 3, depends on the multi-selection now shipped; "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (3 of 4 stories Done — "Snap elements to grid and guides" Sprint 7, "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" Sprint 10 Batches 2-3; "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty — the epic's last remaining story) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |

## Notes


+ 34
- 6
backlog/epics/06_layout_efficiency_and_operator_tooling.md 파일 보기

@@ -176,7 +176,35 @@ apply the same operation (like alignment) to several elements at once instead of
`TemplateCanvasControl`'s single-selection fields and hit-testing).
**Dependencies:** None. Unblocks "Align and distribute multiple elements" below.

### Align and distribute multiple elements - Status: Ready
### Align and distribute multiple elements - Status: Done
**Development verification note (Sprint 10 Batch 3, 2026-10-27):** A new framework-free
`AlignmentCalculator` (Desktop.Core) computes pure position deltas from a list of world-space
bounding boxes — `ComputeAlignmentDeltas` (left/right/top/bottom/horizontal-center/vertical-center,
relative to the selection's combined bounding box) and `ComputeDistributionDeltas` (equal
center-to-center spacing along an axis, a documented MVP simplification: center spacing rather than
edge-to-edge gap spacing, simpler to reason about at the cost of not equalizing visible gaps between
differently-sized items — noted as a candidate refinement, not attempted here). `TemplateCanvasControl`
builds the mixed bounding-box list from `CanvasElementEditor.GetWorldBounds` (standalone elements,
exposing the same rotation-aware AABB Batch 2's rubber-band selection already used) and a new
`GetAddressControlWorldBounds` (Address Controls, extracted from Batch 2's `AddressControlsInRect`
to avoid a second copy of the same corner-rotation math), then applies the returned deltas back to
each item as a simple `X += Dx; Y += Dy`. A new toolbar row (`TemplateDesignerForm`: Align
Left/Right/Top/Bottom, Center Horiz./Vert., Distribute Horiz./Vert. — appended to the existing
element toolbar, which wraps to a second line on its own rather than needing a new form layout row)
is enabled only while `TemplateCanvasControl.MultiSelectionCount >= 2`, refreshed from inside the
existing `RefreshSelectionLabel` so it can never drift out of sync with actual selection state —
disabled, not silently a no-op, per this story's own AC. Tests: 12 new `AlignmentCalculatorTests`
(Desktop.Core, framework-free) covering all 6 alignment edges, 3- and 4-item distribution (including
an out-of-position-order case proving items are ranked by current center before being evenly
respaced), the sub-3-item and empty-list no-op cases. Full suite 376/376 -> 388/388. Live-verified
with a reflection-driven harness against the real built form: the toolbar correctly disables with 0
or 1 selected and enables with 2+; Align Left/Align Top moved a 3-element selection to the expected
shared edge exactly; Distribute Horizontal left the two extreme elements' centers unchanged and
placed the middle element's center at the exact arithmetic midpoint between them; a **fresh**
full-form screenshot (not reused from Batch 2, per the Sprint 7 retrospective carry-in this story's
own conversation notes call out explicitly) confirms the new toolbar row, its enabled state with 3
items selected, and every selected item's highlight border are all visually correct together.

**Card**
As a **print operator**, I want to align multiple objects together, so that address blocks and
motifs look consistent.
@@ -213,13 +241,13 @@ motifs look consistent.
reusing Batch 1's full-form screenshot after adding a new control).

**Confirmation (Acceptance Criteria)**
- [ ] The designer supports the alignment operations listed above for the current multi-selection.
- [ ] The designer supports the distribution operations listed above for the current
- [x] The designer supports the alignment operations listed above for the current multi-selection.
- [x] The designer supports the distribution operations listed above for the current
multi-selection.
- [ ] Results are reflected immediately on the canvas (no save/reopen required to see the effect).
- [ ] Alignment/distribution operations are disabled or produce no effect when fewer than two
- [x] Results are reflected immediately on the canvas (no save/reopen required to see the effect).
- [x] Alignment/distribution operations are disabled or produce no effect when fewer than two
elements are selected (clearly communicated, not a silent no-op).
- [ ] If this story adds any new toolbar/menu/UI affordance to `TemplateDesignerForm` to trigger
- [x] If this story adds any new toolbar/menu/UI affordance to `TemplateDesignerForm` to trigger
alignment/distribution, it is verified with an actual **full built-form smoke** (a
freshly-captured screenshot, not a reused one from the prerequisite story) — not only a
canvas-only check.


+ 2
- 1
backlog/sprints/sprint-10.md 파일 보기

@@ -9,7 +9,7 @@
|---|---|---|---|
| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping) <br> - [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly |
| Select multiple elements at once on the canvas | 5 points | Done | - [x] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected <br> - [x] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release <br> - [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [x] Visible selection indication for every selected item, not just the primary one <br> - [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [x] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes) <br> - [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [x] Live built-form smoke (touches the properties panel's multi-item behavior) |
| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height) <br> - [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) |
| Align and distribute multiple elements | 5 points | Done | - [x] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height) <br> - [x] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [x] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [x] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [x] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) |

## Notes
- **Capacity signal:** completed totals across Sprints 1-9 are **20, 19, 18, 18, 15, 18, 18, 13, 13** — nine data points. The 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented, deliberate under-commits (a large/risky story, then two fresh-priority stories), not capacity misses.
@@ -36,3 +36,4 @@ Sequenced by dependency and risk: the small, independent indicator story first (
| 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
| 2 | 2026-10-27 | **Ad-hoc regression fix, before starting Batch 2:** user reported clicking on and moving a dynamic placeholder had become "not smooth any more." Full detail in `epics/02_template_designer_gui_foundation.md`'s new post-Sprint-10-Batch-1 note. Root cause, found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` rather than guessing: `SelectionChanged` fired unconditionally on every left-click, including a click on an already-selected element (the normal way to start a drag), and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time that fires — a cost that only became perceptible (~40-60ms/click, measured) once this sprint's added property-panel rows made the refresh heavier. Fixed by only raising `SelectionChanged` when the pre-click and post-click selection identity actually differ. Live-verified with the same instrumented harness: an already-selected element's second click dropped from ~50ms to 0.295ms; a genuine selection change still pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix is isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
| 3 | 2026-10-27 | **Batch 2** ("Select multiple elements at once on the canvas", 5 points) done, all ACs met. Built as a strictly additive layer per the story's own conversation notes: `CanvasElementEditor.MultiSelected` (a `HashSet<TextElementLayout>`) alongside the existing `Selected` field, and a parallel `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls, with a `CollapseMultiSelectionIfSingular` rule ensuring every existing single-select code path keeps working unchanged whenever 0 or 1 items end up selected. Rubber-band selection (`CanvasElementEditor.ElementsInRect`, rotation-aware via rotated-corner AABB, plus a parallel `TemplateCanvasControl.AddressControlsInRect`) and modifier-click toggle (`ToggleMultiSelect`, seeding from the current single selection) both replace or extend the multi-selection on release/click; group drag (`BeginMultiDrag`/`MultiDragTo`, snapping the shared *delta* once rather than each item's position independently) moves the whole set together preserving relative offsets; `DrawAddressControl` was split into separate `isPrimarySelected` (handles) and `isMultiSelected` (highlight-only) parameters so every selected item is visibly marked without ambiguous multi-item handles; the properties panel shows "N items selected" and disables per-item editing (Delete disabled too, rather than extended to a bulk operation — out of this story's scope). Tests: +12 (Desktop.Core, including a rotated-element `ElementsInRect` case and a snap-to-grid `MultiDragTo` case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form: rubber-band selected exactly the 2 of 3 elements it should; the panel showed the disabled "2 items selected." state; toggling a third item in and the first back out produced the exact expected membership; a group drag moved two elements by an identical measured delta with their relative offset exactly unchanged and the unselected third element untouched; clicking empty space cleared the selection; a real canvas screenshot confirmed every multi-selected item shows the highlight border. One disclosed evidence-depth caveat: `Control.ModifierKeys` has no public setter and queries live keyboard state, so modifier-click was verified by calling the same private toggle method the click handler delegates to, directly, rather than through a real simulated key-press. One pre-existing, unrelated cosmetic gap found (not fixed) during verification: a plain element's selection highlight border visibly wraps only part of a multi-word display string — confirmed present identically for ordinary single-selection, logged to `logs/technical_debt_log.md` (2026-10-27) rather than fixed in-scope. | Batch 3, "Align and distribute multiple elements" (5 points, epic 6, depends on Batch 2's multi-selection now existing). | None. |
| 4 | 2026-10-27 | **Batch 3** ("Align and distribute multiple elements", 5 points) done, all ACs met — Sprint 10 committed scope now 13/13 points complete. New framework-free `AlignmentCalculator` (Desktop.Core) computes pure position deltas from a list of world-space bounding boxes: `ComputeAlignmentDeltas` (6 edges, relative to the selection's combined bounding box) and `ComputeDistributionDeltas` (equal center-to-center spacing, a documented MVP simplification vs. edge-to-edge gap spacing). `TemplateCanvasControl` feeds it a mixed bounds list built from a newly-public `CanvasElementEditor.GetWorldBounds` (standalone elements, reusing Batch 2's rotation-aware AABB) and a new `GetAddressControlWorldBounds` (Address Controls, extracted from Batch 2's `AddressControlsInRect` to remove a duplicate copy of the corner-rotation math), applying each returned delta as a plain `X += Dx; Y += Dy`. A new toolbar row (8 buttons: 6 alignment edges + 2 distribution axes) was appended to the existing element toolbar — it wraps to a second line on its own, no new form layout row needed — enabled only while 2+ items are multi-selected, refreshed from inside the existing `RefreshSelectionLabel` so it can never drift out of sync. Tests: +12 (`AlignmentCalculatorTests`, Desktop.Core, framework-free, covering all 6 edges, 3- and 4-item distribution including an out-of-order case, and the sub-3/empty no-op cases). Full suite 376/376 -> 388/388. Live-verified with a reflection-driven harness against the real built form: the toolbar correctly disabled at 0 and 1 selected, enabled at 2+; Align Left/Top moved a 3-element selection to the exact expected shared edge; Distribute Horizontal left both extreme elements' centers unchanged and placed the middle element's center at the exact arithmetic midpoint; a **fresh** full-form screenshot (not reused from Batch 2, per this story's own conversation notes) confirmed the new toolbar row, its enabled state, and every selected item's highlight all render correctly together. | Sprint Review and Retrospective (sprint's committed scope is now fully done). | None. |

+ 109
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs 파일 보기

@@ -0,0 +1,109 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 10, "Align and distribute multiple elements": pure position-delta math for aligning or
/// distributing a multi-selection, operating on plain world-space bounding boxes rather than any
/// concrete element type — <c>TemplateCanvasControl</c> is responsible for computing each selected
/// standalone element's and Address Control's bounding box (mirroring how it already computes
/// bounds for rubber-band selection) and applying the returned delta to whichever kind of item
/// each box came from. Deltas — not absolute positions — are returned so the caller only ever adds
/// an offset to an item's existing (X, Y), the same "move by a shared/derived amount" style
/// <see cref="CanvasElementEditor.MultiDragTo"/> already uses.
/// </summary>
public static class AlignmentCalculator
{
public enum Edge
{
Left,
Right,
Top,
Bottom,
HorizontalCenter,
VerticalCenter,
}

public enum Axis
{
Horizontal,
Vertical,
}

/// <summary>Computes, for each bounding box in <paramref name="bounds"/>, the (Dx, Dy) delta
/// that moves it to the given <paramref name="edge"/> of the combined bounding box of every
/// item in the selection — e.g. <see cref="Edge.Left"/> moves every item's left edge to the
/// leftmost of all their current left edges. Returns one delta per input box, in the same
/// order.</summary>
public static IReadOnlyList<(double Dx, double Dy)> ComputeAlignmentDeltas(
IReadOnlyList<(double MinX, double MinY, double MaxX, double MaxY)> bounds, Edge edge)
{
if (bounds.Count == 0)
{
return Array.Empty<(double, double)>();
}

var left = bounds.Min(b => b.MinX);
var right = bounds.Max(b => b.MaxX);
var top = bounds.Max(b => b.MaxY);
var bottom = bounds.Min(b => b.MinY);
var horizontalCenter = (left + right) / 2.0;
var verticalCenter = (bottom + top) / 2.0;

var deltas = new (double Dx, double Dy)[bounds.Count];
for (var i = 0; i < bounds.Count; i++)
{
var b = bounds[i];
deltas[i] = edge switch
{
Edge.Left => (left - b.MinX, 0.0),
Edge.Right => (right - b.MaxX, 0.0),
Edge.Top => (0.0, top - b.MaxY),
Edge.Bottom => (0.0, bottom - b.MinY),
Edge.HorizontalCenter => (horizontalCenter - ((b.MinX + b.MaxX) / 2.0), 0.0),
Edge.VerticalCenter => (0.0, verticalCenter - ((b.MinY + b.MaxY) / 2.0)),
_ => (0.0, 0.0),
};
}

return deltas;
}

/// <summary>Computes, for each bounding box in <paramref name="bounds"/>, the delta that
/// spaces its center evenly between the centers of the two extreme (first and last, ordered by
/// center position along <paramref name="axis"/>) items — those two extremes get a zero delta
/// (they anchor the distributed range), and every item between them moves to an evenly-spaced
/// center position. A documented MVP simplification: spacing is computed between item
/// *centers*, not edge-to-edge gaps — center spacing is simpler to reason about and predictable
/// regardless of each item's own size, at the cost of not equalizing visible gaps between
/// differently-sized items (edge-based distribution is a candidate future refinement). Fewer
/// than 3 items has nothing meaningful to distribute (with exactly 2, the "items between the
/// two extremes" set is empty) and returns all-zero deltas rather than moving anything.</summary>
public static IReadOnlyList<(double Dx, double Dy)> ComputeDistributionDeltas(
IReadOnlyList<(double MinX, double MinY, double MaxX, double MaxY)> bounds, Axis axis)
{
var count = bounds.Count;
var deltas = new (double Dx, double Dy)[count];
if (count < 3)
{
return deltas;
}

double Center(int i) => axis == Axis.Horizontal
? (bounds[i].MinX + bounds[i].MaxX) / 2.0
: (bounds[i].MinY + bounds[i].MaxY) / 2.0;

var order = Enumerable.Range(0, count).OrderBy(Center).ToArray();
var firstCenter = Center(order[0]);
var lastCenter = Center(order[^1]);
var step = (lastCenter - firstCenter) / (count - 1);

for (var rank = 0; rank < count; rank++)
{
var index = order[rank];
var targetCenter = firstCenter + (step * rank);
var delta = targetCenter - Center(index);
deltas[index] = axis == Axis.Horizontal ? (delta, 0.0) : (0.0, delta);
}

return deltas;
}
}

+ 12
- 2
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs 파일 보기

@@ -201,8 +201,7 @@ public sealed class CanvasElementEditor
var result = new List<TextElementLayout>();
foreach (var element in _document.Elements)
{
var (width, height) = _measureText(element);
var (elMinX, elMinY, elMaxX, elMaxY) = WorldBounds(element, width, height);
var (elMinX, elMinY, elMaxX, elMaxY) = GetWorldBounds(element);
if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{
result.Add(element);
@@ -212,6 +211,17 @@ public sealed class CanvasElementEditor
return result;
}

/// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
/// world-space AABB <see cref="ElementsInRect"/> uses for rubber-band hit-testing, exposed
/// publicly so <see cref="TemplateCanvasControl"/> can feed it into
/// <see cref="AlignmentCalculator"/> alongside the parallel Address Control bounds it computes
/// itself.</summary>
public (double MinX, double MinY, double MaxX, double MaxY) GetWorldBounds(TextElementLayout element)
{
var (width, height) = _measureText(element);
return WorldBounds(element, width, height);
}

private static (double MinX, double MinY, double MaxX, double MaxY) WorldBounds(
TextElementLayout element, double width, double height)
{


+ 154
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs 파일 보기

@@ -0,0 +1,154 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class AlignmentCalculatorTests
{
// Three boxes at varying positions/sizes, reused across several tests:
// A: (0,0)-(10,10) B: (50,20)-(70,30) C: (100,-10)-(110,0)
private static readonly (double MinX, double MinY, double MaxX, double MaxY)[] ThreeBoxes =
{
(0, 0, 10, 10),
(50, 20, 70, 30),
(100, -10, 110, 0),
};

[Fact]
public void ComputeAlignmentDeltas_Left_MovesEveryBoxToTheLeftmostMinX()
{
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Left);

Assert.Equal(0.0, deltas[0].Dx, precision: 6); // A is already leftmost (MinX=0)
Assert.Equal(-50.0, deltas[1].Dx, precision: 6); // B's MinX=50 -> 0
Assert.Equal(-100.0, deltas[2].Dx, precision: 6); // C's MinX=100 -> 0
Assert.All(deltas, d => Assert.Equal(0.0, d.Dy, precision: 6));
}

[Fact]
public void ComputeAlignmentDeltas_Right_MovesEveryBoxToTheRightmostMaxX()
{
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Right);

// Rightmost MaxX across all three is 110 (box C).
Assert.Equal(100.0, deltas[0].Dx, precision: 6); // A's MaxX=10 -> 110
Assert.Equal(40.0, deltas[1].Dx, precision: 6); // B's MaxX=70 -> 110
Assert.Equal(0.0, deltas[2].Dx, precision: 6); // C already rightmost
}

[Fact]
public void ComputeAlignmentDeltas_Top_MovesEveryBoxToTheTopmostMaxY()
{
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Top);

// Topmost MaxY across all three is 30 (box B).
Assert.Equal(20.0, deltas[0].Dy, precision: 6); // A's MaxY=10 -> 30
Assert.Equal(0.0, deltas[1].Dy, precision: 6); // B already topmost
Assert.Equal(30.0, deltas[2].Dy, precision: 6); // C's MaxY=0 -> 30
Assert.All(deltas, d => Assert.Equal(0.0, d.Dx, precision: 6));
}

[Fact]
public void ComputeAlignmentDeltas_Bottom_MovesEveryBoxToTheBottommostMinY()
{
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Bottom);

// Bottommost MinY across all three is -10 (box C).
Assert.Equal(-10.0, deltas[0].Dy, precision: 6); // A's MinY=0 -> -10
Assert.Equal(-30.0, deltas[1].Dy, precision: 6); // B's MinY=20 -> -10
Assert.Equal(0.0, deltas[2].Dy, precision: 6); // C already bottommost
}

[Fact]
public void ComputeAlignmentDeltas_HorizontalCenter_MovesEveryBoxToTheOverallHorizontalCenter()
{
// Overall bounds: MinX=0 (A), MaxX=110 (C) -> horizontal center = 55.
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.HorizontalCenter);

Assert.Equal(50.0, deltas[0].Dx, precision: 6); // A's center=5 -> 55
Assert.Equal(-5.0, deltas[1].Dx, precision: 6); // B's center=60 -> 55
Assert.Equal(-50.0, deltas[2].Dx, precision: 6); // C's center=105 -> 55
}

[Fact]
public void ComputeAlignmentDeltas_VerticalCenter_MovesEveryBoxToTheOverallVerticalCenter()
{
// Overall bounds: MinY=-10 (C), MaxY=30 (B) -> vertical center = 10.
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.VerticalCenter);

Assert.Equal(5.0, deltas[0].Dy, precision: 6); // A's center=5 -> 10
Assert.Equal(-15.0, deltas[1].Dy, precision: 6); // B's center=25 -> 10
Assert.Equal(15.0, deltas[2].Dy, precision: 6); // C's center=-5 -> 10
}

[Fact]
public void ComputeAlignmentDeltas_EmptyList_ReturnsEmpty()
{
var deltas = AlignmentCalculator.ComputeAlignmentDeltas(Array.Empty<(double, double, double, double)>(), AlignmentCalculator.Edge.Left);

Assert.Empty(deltas);
}

[Fact]
public void ComputeDistributionDeltas_ThreeItems_KeepsExtremesFixedAndCentersTheMiddleOne()
{
// Centers along X: A=5, B=60, C=105. Ordered: A(5), B(60), C(105).
// Evenly spaced target centers between the extremes (5 and 105): 5, 55, 105.
var deltas = AlignmentCalculator.ComputeDistributionDeltas(ThreeBoxes, AlignmentCalculator.Axis.Horizontal);

Assert.Equal(0.0, deltas[0].Dx, precision: 6); // A (leftmost extreme) stays fixed
Assert.Equal(-5.0, deltas[1].Dx, precision: 6); // B's center 60 -> 55
Assert.Equal(0.0, deltas[2].Dx, precision: 6); // C (rightmost extreme) stays fixed
Assert.All(deltas, d => Assert.Equal(0.0, d.Dy, precision: 6));
}

[Fact]
public void ComputeDistributionDeltas_Vertical_UsesYCentersInstead()
{
// Centers along Y: A=5, B=25, C=-5. Ordered by Y center: C(-5), A(5), B(25).
// Evenly spaced target centers between the extremes (-5 and 25): -5, 10, 25.
var deltas = AlignmentCalculator.ComputeDistributionDeltas(ThreeBoxes, AlignmentCalculator.Axis.Vertical);

Assert.Equal(5.0, deltas[0].Dy, precision: 6); // A's center 5 -> 10 (middle rank)
Assert.Equal(0.0, deltas[1].Dy, precision: 6); // B (topmost extreme) stays fixed
Assert.Equal(0.0, deltas[2].Dy, precision: 6); // C (bottommost extreme) stays fixed
}

[Fact]
public void ComputeDistributionDeltas_FewerThanThreeItems_ReturnsAllZeroDeltas()
{
var twoBoxes = new[] { ThreeBoxes[0], ThreeBoxes[2] };

var deltas = AlignmentCalculator.ComputeDistributionDeltas(twoBoxes, AlignmentCalculator.Axis.Horizontal);

Assert.All(deltas, d => Assert.Equal((0.0, 0.0), d));
}

[Fact]
public void ComputeDistributionDeltas_EmptyList_ReturnsEmpty()
{
var deltas = AlignmentCalculator.ComputeDistributionDeltas(Array.Empty<(double, double, double, double)>(), AlignmentCalculator.Axis.Horizontal);

Assert.Empty(deltas);
}

[Fact]
public void ComputeDistributionDeltas_FourItems_SpacesTheTwoMiddleOnesEvenly()
{
var boxes = new (double MinX, double MinY, double MaxX, double MaxY)[]
{
(0, 0, 2, 2), // center X = 1
(10, 0, 12, 2), // center X = 11 (out of order on purpose)
(30, 0, 32, 2), // center X = 31 (rightmost extreme)
(5, 0, 7, 2), // center X = 6
};

var deltas = AlignmentCalculator.ComputeDistributionDeltas(boxes, AlignmentCalculator.Axis.Horizontal);

// Order by center: box0(1), box3(6), box1(11), box2(31). Step = (31-1)/3 = 10.
// Target centers by rank: 1, 11, 21, 31.
Assert.Equal(0.0, deltas[0].Dx, precision: 6); // box0 stays at center 1
Assert.Equal(10.0, deltas[1].Dx, precision: 6); // box1's center 11 -> 21
Assert.Equal(0.0, deltas[2].Dx, precision: 6); // box2 (rightmost extreme) stays fixed
Assert.Equal(5.0, deltas[3].Dx, precision: 6); // box3's center 6 -> 11
}
}

+ 101
- 27
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs 파일 보기

@@ -311,6 +311,72 @@ public sealed class TemplateCanvasControl : Control
return false;
}

/// <summary>Sprint 10, "Align and distribute multiple elements": aligns every item in the
/// current multi-selection to the given <paramref name="edge"/> of the selection's combined
/// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are
/// expected to disable themselves via <see cref="MultiSelectionCount"/> rather than relying on
/// this guard alone, per this story's "clearly disabled, not a silent no-op" AC.</summary>
public void AlignSelection(AlignmentCalculator.Edge edge)
{
if (MultiSelectionCount < 2)
{
return;
}

ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls);
}

/// <summary>Distributes the current multi-selection with equal center-to-center spacing along
/// the given <paramref name="axis"/> — see <see cref="AlignmentCalculator.ComputeDistributionDeltas"/>
/// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and,
/// per that method's own remarks, has no visible effect below 3, since there is nothing to
/// place between the two extreme items).</summary>
public void DistributeSelection(AlignmentCalculator.Axis axis)
{
if (MultiSelectionCount < 2)
{
return;
}

ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls);
}

/// <summary>Builds the combined, fixed-order bounding-box list <see cref="AlignmentCalculator"/>
/// operates on — standalone elements first, then Address Controls — so a returned delta list
/// can be zipped back to the exact item it applies to via <paramref name="elements"/>/
/// <paramref name="controls"/>' matching order.</summary>
private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds(
out List<TextElementLayout> elements, out List<AddressControlLayout> controls)
{
elements = _editor.MultiSelected.ToList();
controls = _multiSelectedAddressControls.ToList();

var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count);
bounds.AddRange(elements.Select(_editor.GetWorldBounds));
bounds.AddRange(controls.Select(GetAddressControlWorldBounds));
return bounds;
}

private void ApplySelectionDeltas(
IReadOnlyList<(double Dx, double Dy)> deltas, List<TextElementLayout> elements, List<AddressControlLayout> controls)
{
for (var i = 0; i < elements.Count; i++)
{
elements[i].X += deltas[i].Dx;
elements[i].Y += deltas[i].Dy;
}

for (var i = 0; i < controls.Count; i++)
{
var delta = deltas[elements.Count + i];
controls[i].X += delta.Dx;
controls[i].Y += delta.Dy;
}

Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
}

private (double X, double Y) DefaultNewElementPosition()
{
// Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
@@ -564,33 +630,7 @@ public sealed class TemplateCanvasControl : Control
var result = new List<AddressControlLayout>();
foreach (var control in _document.AddressControls)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var left = control.X;
var right = control.X + control.Width;

double elMinX, elMinY, elMaxX, elMaxY;
if (control.RotationAngle == 0)
{
(elMinX, elMinY, elMaxX, elMaxY) = (left, bottom, right, top);
}
else
{
var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
elMinX = double.MaxValue;
elMinY = double.MaxValue;
elMaxX = double.MinValue;
elMaxY = double.MinValue;
foreach (var (cornerX, cornerY) in corners)
{
var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
elMinX = Math.Min(elMinX, rx);
elMaxX = Math.Max(elMaxX, rx);
elMinY = Math.Min(elMinY, ry);
elMaxY = Math.Max(elMaxY, ry);
}
}

var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control);
if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{
result.Add(control);
@@ -600,6 +640,40 @@ public sealed class TemplateCanvasControl : Control
return result;
}

/// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
/// world-space AABB <see cref="AddressControlsInRect"/> already computed inline for rubber-band
/// hit-testing, extracted so alignment/distribution can feed it into
/// <see cref="AlignmentCalculator"/> alongside <see cref="CanvasElementEditor.GetWorldBounds"/>
/// for standalone elements.</summary>
private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var left = control.X;
var right = control.X + control.Width;

if (control.RotationAngle == 0)
{
return (left, bottom, right, top);
}

var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var (cornerX, cornerY) in corners)
{
var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
minX = Math.Min(minX, rx);
maxX = Math.Max(maxX, rx);
minY = Math.Min(minY, ry);
maxY = Math.Max(maxY, ry);
}

return (minX, minY, maxX, maxY);
}

/// <summary>Finalizes a rubber-band drag on release: computes the final (normalized) rectangle,
/// finds every element/control it intersects, and either replaces the multi-selection with that
/// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,


+ 60
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs 파일 보기

@@ -70,6 +70,19 @@ public sealed class TemplateDesignerForm : Form
private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false };
private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." };

// Sprint 10, "Align and distribute multiple elements": enabled only while 2+ items are
// multi-selected (see RefreshAlignmentButtonsEnabled) — disabled, not silently a no-op, per
// this story's own AC, matching the same "clearly disabled" treatment the rest of this
// toolbar already gives every selection-dependent control.
private readonly Button _alignLeftButton = new() { Text = "Align Left", AutoSize = true, Enabled = false };
private readonly Button _alignRightButton = new() { Text = "Align Right", AutoSize = true, Enabled = false };
private readonly Button _alignTopButton = new() { Text = "Align Top", AutoSize = true, Enabled = false };
private readonly Button _alignBottomButton = new() { Text = "Align Bottom", AutoSize = true, Enabled = false };
private readonly Button _alignCenterHButton = new() { Text = "Center Horiz.", AutoSize = true, Enabled = false };
private readonly Button _alignCenterVButton = new() { Text = "Center Vert.", AutoSize = true, Enabled = false };
private readonly Button _distributeHButton = new() { Text = "Distribute Horiz.", AutoSize = true, Enabled = false };
private readonly Button _distributeVButton = new() { Text = "Distribute Vert.", AutoSize = true, Enabled = false };

// CSV field mapping (Sprint 3, Batches 2-4).
private readonly Button _loadCsvButton = new() { Text = "Load &CSV...", AutoSize = true };
private readonly Label _csvStatusLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No CSV loaded." };
@@ -1217,11 +1230,58 @@ public sealed class TemplateDesignerForm : Form
_selectionLabel.Margin = new Padding(18, 6, 3, 3);
panel.Controls.Add(_selectionLabel);

// Sprint 10, "Align and distribute multiple elements": a new button group, enabled only
// while 2+ items are multi-selected (RefreshAlignmentButtonsEnabled, called from every
// place selection state already gets refreshed). Appended to this same toolbar rather than
// a new TableLayoutPanel row — FlowLayoutPanel wraps to a second line on its own once it
// runs out of horizontal space, so no layout restructuring is needed.
_alignLeftButton.Margin = new Padding(18, 3, 3, 3);
_alignLeftButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Left);
_alignRightButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Right);
_alignTopButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Top);
_alignBottomButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Bottom);
_alignCenterHButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.HorizontalCenter);
_alignCenterVButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.VerticalCenter);
_distributeHButton.Margin = new Padding(12, 3, 3, 3);
_distributeHButton.Click += (_, _) => _canvas.DistributeSelection(AlignmentCalculator.Axis.Horizontal);
_distributeVButton.Click += (_, _) => _canvas.DistributeSelection(AlignmentCalculator.Axis.Vertical);

panel.Controls.Add(_alignLeftButton);
panel.Controls.Add(_alignRightButton);
panel.Controls.Add(_alignTopButton);
panel.Controls.Add(_alignBottomButton);
panel.Controls.Add(_alignCenterHButton);
panel.Controls.Add(_alignCenterVButton);
panel.Controls.Add(_distributeHButton);
panel.Controls.Add(_distributeVButton);

return panel;
}

/// <summary>Sprint 10, "Align and distribute multiple elements": the align/distribute buttons
/// are enabled only while 2+ items are multi-selected — disabled, not silently a no-op, per
/// this story's own AC. Called everywhere <see cref="RefreshSelectionLabel"/> already is, so it
/// never drifts out of sync with the canvas's actual selection state.</summary>
private void RefreshAlignmentButtonsEnabled()
{
var enabled = _canvas.MultiSelectionCount >= 2;
_alignLeftButton.Enabled = enabled;
_alignRightButton.Enabled = enabled;
_alignTopButton.Enabled = enabled;
_alignBottomButton.Enabled = enabled;
_alignCenterHButton.Enabled = enabled;
_alignCenterVButton.Enabled = enabled;
_distributeHButton.Enabled = enabled;
_distributeVButton.Enabled = enabled;
}

private void RefreshSelectionLabel()
{
// Sprint 10: kept in lock-step with this method rather than added at every one of its own
// call sites — every place selection state can change already calls RefreshSelectionLabel,
// so piggybacking here means the alignment buttons can never drift out of sync with it.
RefreshAlignmentButtonsEnabled();

// Sprint 5: was `selected.IsDynamic ? "{ColumnName}" : StaticText`, which silently showed
// an empty "{}" for any genuinely mixed-content element (ColumnName is null once an
// element has more than one run) — found via live GUI verification against the real


+ 4
- 1
state.md 파일 보기

@@ -22,7 +22,9 @@

**Sprint 10 Batch 2 complete (2026-10-27):** "Select multiple elements at once on the canvas" (5/13 points) shipped — a strictly additive multi-select layer (`CanvasElementEditor.MultiSelected` for standalone elements, `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls) alongside the existing single-selection fields, collapsing back to ordinary single-select whenever 0-1 items end up selected so no existing code path changed behavior. Rubber-band selection and modifier-click toggle both work across a mixed selection of elements and Address Controls; group drag moves the whole set by one shared delta, preserving relative offsets; every selected item is visibly highlighted; the properties panel shows "N items selected" and disables per-item editing. Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected). Live-verified via a reflection-driven built-form harness (rubber-band, toggle, group-drag delta math, panel state, a real canvas screenshot) with one disclosed caveat (`Control.ModifierKeys` has no public setter, so modifier-click was verified by calling the same private toggle method directly rather than through a simulated key-press) and one pre-existing, unrelated cosmetic gap found and logged rather than fixed in-scope (a selection highlight border under-wraps a multi-word display string — `logs/technical_debt_log.md`, 2026-10-27). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`.

**Next action:** Continue Sprint 10 as `dev-team`: Batch 3, "Align and distribute multiple elements" (5 pts, epic 6, depends on Batch 2's multi-selection now existing), per `backlog/sprints/sprint-10.md`. This completes the sprint's committed scope (13/13 points) once done — plan for Sprint Review and Retrospective to follow immediately after, per the standard state-driven handoff. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision; two technical debt items from Batch 2's verification (Address-Control interaction logic still untested WinForms-only, and the selection-highlight width cosmetic gap), both Open/Low and non-blocking.
**Sprint 10 Batch 3 complete (2026-10-27):** "Align and distribute multiple elements" (5/13 points) shipped — Sprint 10's full committed scope (13/13 points) is now Done. New framework-free `AlignmentCalculator` (Desktop.Core) computes position deltas for 6 alignment edges and 2 distribution axes (center-to-center spacing, a documented MVP simplification) from a list of world-space bounding boxes; `TemplateCanvasControl` feeds it a mixed bounds list (standalone elements via a newly-public `CanvasElementEditor.GetWorldBounds`, Address Controls via a new `GetAddressControlWorldBounds` extracted from Batch 2's rubber-band code) and applies the deltas back. A new 8-button toolbar row triggers the operations, enabled only at 2+ selected items. Full suite 376/376 -> 388/388. Live-verified via reflection-driven harness including a **fresh** full-form screenshot (not reused from Batch 2, per this story's own carry-in note). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`.

**Next action:** Sprint 10's committed scope is fully done (13/13 points, all 3 batches). Per the automated state-driven handoff, proceed directly into Sprint Review (`product-owner` leads, `process/04_sprint_review.md`) and then Sprint Retrospective (`scrum-master` leads, `process/05_sprint_retrospective.md`) without stopping to ask — the retrospective's own completion is this workflow's next designated pause point (per `AGENTS.md`), not before. Still open, non-blocking, to consider for Sprint 11 planning: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred from Sprint 10); "Undo and redo layout changes" (epic 6, 13 pts, now the last story in this epic); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision; two technical debt items from Sprint 10's verification (Address-Control interaction logic still untested WinForms-only, growing with two features now built on it; the selection-highlight width cosmetic gap), both Open/Low and non-blocking.

## Phase reference

@@ -98,3 +100,4 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 3 - Sprint execution (ad hoc, outside formal ceremony) | User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by directly instrumenting `TemplateCanvasControl.OnMouseDown` with `Stopwatch` timers rather than guessing: `SelectionChanged` fired unconditionally on every left-click — including a click on an already-selected element, the normal way to begin dragging it — and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time, a cost (~40-60ms/click, measured) that only became perceptible once this sprint's added property-panel rows made the refresh heavier. Fixed by capturing the pre-click selection identity and only raising `SelectionChanged` when it actually changed (`RaiseSelectionChangedIfDifferent`). Live-verified with the same instrumented reflection harness: an already-selected element's second click dropped from ~50ms to 0.295ms end-to-end in `OnMouseDown`, while a genuine selection change still correctly pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). Documented in `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/sprints/sprint-10.md`. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 2, "Select multiple elements at once on the canvas" (5/13 points). Additive multi-select layer alongside existing single-selection state (`CanvasElementEditor.MultiSelected`, `TemplateCanvasControl._multiSelectedAddressControls`), collapsing to ordinary single-select at 0-1 items so every existing code path is unaffected. Rubber-band selection (rotation-aware AABB intersection) and modifier-click toggle both work across mixed elements/Address Controls; group drag moves the set by one shared delta preserving relative offsets (snapping the delta once, not each item's position independently); every selected item is visibly highlighted; properties panel shows "N items selected," disabled for per-item editing. 12 new Desktop.Core tests, full suite 364/364 -> 376/376. Live-verified via reflection-driven built-form harness including a real canvas screenshot; one disclosed caveat (`ModifierKeys` has no public setter, so modifier-click verified via direct private-method call) and one unrelated pre-existing cosmetic gap found and logged, not fixed in-scope (selection-highlight width under-wrap). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 3, "Align and distribute multiple elements" (5/13 points) — sprint's full committed scope (13/13) now Done. New framework-free `AlignmentCalculator` (Desktop.Core) computes deltas for 6 alignment edges and center-spaced distribution along 2 axes from world-space bounding boxes; `TemplateCanvasControl` feeds it a mixed elements/Address-Controls bounds list (reusing/extracting Batch 2's rotation-aware AABB code) and applies deltas back as plain position offsets. New 8-button toolbar row enabled only at 2+ selected items, refreshed in lock-step with existing selection-state refresh so it can't drift out of sync. 12 new Desktop.Core tests, full suite 376/376 -> 388/388. Live-verified via reflection-driven harness: exact expected alignment/distribution results, correct button enable/disable at 0/1/2+ selected, and a fresh full-form screenshot (not reused from Batch 2, per this story's own carry-in note) confirming the new toolbar and highlights render correctly together. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. |

불러오는 중...
취소
저장

Powered by TurnKey Linux.