@orbat-mapper/tactical-draw 0.2.0-alpha.7 → 0.2.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -518,10 +518,12 @@ interface EditSession {
518
518
  */
519
519
  readonly sizeAnchor: SizeAnchor;
520
520
  /**
521
- * Resolve the outer `edit` promise with a `ControlMeasureSnapshot` of the
522
- * current working state.
521
+ * Commit: returns the `ControlMeasureSnapshot` of the current working state
522
+ * and resolves the outer `edit` promise with the same value (the same
523
+ * reference — no double-bake of ground-anchored sizes). Returns `undefined`
524
+ * after the session has settled (idempotent no-op).
523
525
  */
524
- close(): void;
526
+ close(): ControlMeasureSnapshot | undefined;
525
527
  /** Reject the outer `edit` promise with reason `"session"`. */
526
528
  abort(): void;
527
529
  /**
@@ -592,12 +594,22 @@ interface EditChangeEvent {
592
594
  * transform box's corner-scale / rotate / body-translate gestures, applied to
593
595
  * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
594
596
  *
595
- * `measures` is the immutable edit-start input, index-aligned with every
596
- * snapshot array this session or its change events produce. After the
597
- * session settles (`close` / `abort`), every method is a silent no-op.
597
+ * The member set is mutable via [[setMembers]] (#187): members can join or
598
+ * leave a live session without a session boundary. Because membership changes
599
+ * over the session's lifetime, snapshot arrays are keyed by **measure id**, not
600
+ * by index — a snapshot's identity is `snapshot.measure.id`. `measures` is a
601
+ * live view of the current members' join-time inputs; a `TransformChangeEvent`'s
602
+ * arrays are index-aligned with `session.measures` *as of that emission*. After
603
+ * the session settles (`close` / `abort` / an emptying `setMembers`), every
604
+ * method is a silent no-op.
598
605
  */
599
606
  interface TransformSession {
600
- /** Immutable edit-start inputs, index-aligned with every snapshot array. */
607
+ /**
608
+ * Live view of the current members' join-time input measures — a fresh array
609
+ * on each read, in current membership order. Grows/shrinks as [[setMembers]]
610
+ * adds or removes members. Not stable across membership changes: key
611
+ * snapshots by `snapshot.measure.id` rather than relying on index alignment.
612
+ */
601
613
  readonly measures: readonly ControlMeasure[];
602
614
  /**
603
615
  * Live working control points for the member whose id is `measureId`, or
@@ -615,10 +627,50 @@ interface TransformSession {
615
627
  /** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
616
628
  setSizeAnchor(anchor: SizeAnchor): void;
617
629
  /**
618
- * Resolve the outer `editMany` promise with index-aligned
619
- * `ControlMeasureSnapshot[]` of the current working state.
630
+ * Replace the member set in place — the primitive behind shift-click toggle,
631
+ * marquee replace, and single-edit → group folding, without tearing the
632
+ * session down (#187). Members are matched to the current set by measure id:
633
+ *
634
+ * - **Retained** members (id in both the current and next sets) carry their
635
+ * current working state forward; the passed measure object is ignored for
636
+ * them. Their `dirty` baseline stays their original join-time input, so a
637
+ * session that has already moved things stays dirty across the call.
638
+ * - **Removed** members (id in the current set, absent from the next) are
639
+ * committed at their current working state.
640
+ * - **Added** members (id only in the next set) join at their passed input
641
+ * state; if currently on the graphics layer they are lifted off it exactly
642
+ * like initial members.
643
+ *
644
+ * The transform box re-derives from the next member set at the session's
645
+ * current box angle. Session-scoped state (`sizeAnchor`, box angle,
646
+ * non-geometry dirty) is unchanged.
647
+ *
648
+ * **Return value** — the committed exit snapshots of the members removed *by
649
+ * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
650
+ * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
651
+ * empty array when nothing was removed. This is the *only* delivery of a
652
+ * removed member's exit state: the outer `editMany` promise resolves with the
653
+ * snapshots of the members in the set **at close time only**. Removed members
654
+ * are NOT written back to the graphics layer by the controller — the host
655
+ * folds the returned snapshots into its document and calls `td.render()`, the
656
+ * same contract as [[close]]. Returns `undefined` (a silent no-op) after the
657
+ * session has settled.
658
+ *
659
+ * `setMembers([])` on a live session delegates to [[close]]: it commits every
660
+ * current member, resolves the promise with their snapshots, and returns that
661
+ * same array. Duplicate ids in `measures` throw `TypeError` synchronously,
662
+ * without mutating session state.
663
+ */
664
+ setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
665
+ /**
666
+ * Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
667
+ * set at close time) and resolves the outer `editMany` promise with the same
668
+ * value (the same reference — no double-bake of ground-anchored sizes). A
669
+ * member removed mid-session via [[setMembers]] is not included here; its exit
670
+ * snapshot was returned from that `setMembers` call. Returns `undefined` after
671
+ * the session has settled (idempotent no-op).
620
672
  */
621
- close(): void;
673
+ close(): ControlMeasureSnapshot[] | undefined;
622
674
  /** Reject the outer `editMany` promise with reason `"session"`. */
623
675
  abort(): void;
624
676
  /**
@@ -638,13 +690,17 @@ interface TransformSession {
638
690
  /**
639
691
  * Delivered to `TransformSession.onChange` listeners once per completed group
640
692
  * gesture. Both `measures` and `previous` are index-aligned with
641
- * `session.measures`. See `EditChangeEvent` for the identical emission
642
- * contract this mirrors.
693
+ * `session.measures` *as of this emission*. Key by `measure.id` rather than
694
+ * relying on index alignment across emissions. A `setMembers` call from within
695
+ * a listener takes effect immediately and supersedes this emission's
696
+ * `reject()` (the rollback baseline no longer matches the member set);
697
+ * `close()` and `abort()` still apply, against the new membership. See
698
+ * `EditChangeEvent` for the identical emission contract this mirrors.
643
699
  */
644
700
  interface TransformChangeEvent {
645
- /** Working state after the completed gesture, index-aligned with `session.measures`. */
701
+ /** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
646
702
  readonly measures: ControlMeasureSnapshot[];
647
- /** State immediately before this gesture, index-aligned with `session.measures`. */
703
+ /** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
648
704
  readonly previous: ControlMeasureSnapshot[];
649
705
  /** The session the event belongs to. */
650
706
  readonly session: TransformSession;
@@ -652,7 +708,8 @@ interface TransformChangeEvent {
652
708
  * Roll every member's working preview back to `previous` after all
653
709
  * listeners have run. Multiple calls collapse to a single rollback.
654
710
  * Overridden by `close()` or `abort()` if either is also called during this
655
- * emission.
711
+ * emission, and dropped if a listener called `session.setMembers` (the
712
+ * rollback baseline no longer matches the member set).
656
713
  */
657
714
  reject(): void;
658
715
  /** Shortcut for `event.session.close()`, deferred until the emission ends. */
@@ -945,6 +1002,14 @@ declare class TacticalDraw {
945
1002
  * working copy. Empty while idle.
946
1003
  */
947
1004
  private excludedInteractionIds;
1005
+ /**
1006
+ * Live member ids of the active group transform, or `undefined` when none.
1007
+ * Falls back to the session ref because `onSession` fires from inside the
1008
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
1009
+ * the session ref is registered first (`trackTransformSession`), so a
1010
+ * `render()` from inside `onSession` still sees live membership.
1011
+ */
1012
+ private transformMemberIds;
948
1013
  /**
949
1014
  * Reconcile the graphics layer against the authoritative list of measures.
950
1015
  * Idempotent. Throws synchronously on duplicate measure ids *before*
@@ -1028,9 +1093,13 @@ declare class TacticalDraw {
1028
1093
  * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1029
1094
  * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1030
1095
  * translate) applies rigidly to all of them at once. Returns a Promise that
1031
- * resolves with index-aligned `ControlMeasureSnapshot[]` on
1032
- * `session.close()` and rejects with `TacticalDrawAbortError` on
1033
- * signal / Escape / destroy / preempt / `session.abort()`.
1096
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
1097
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
1098
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
1099
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
1100
+ * are id-keyed, not index-aligned with this input. The promise resolves with
1101
+ * the members in the set at close time; a member removed mid-session had its
1102
+ * exit snapshot returned from the `setMembers` call that removed it.
1034
1103
  *
1035
1104
  * `measures.length` must be >= 1; a single-element array behaves like an
1036
1105
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
package/dist/index.mjs CHANGED
@@ -699,7 +699,7 @@ function runInteraction(spec) {
699
699
  clearLayers();
700
700
  }
701
701
  function commit(produce) {
702
- if (settled) return;
702
+ if (settled) return void 0;
703
703
  let value;
704
704
  try {
705
705
  value = produce();
@@ -714,6 +714,7 @@ function runInteraction(spec) {
714
714
  teardown();
715
715
  resolveFn(value);
716
716
  if (onSettled) onSettled();
717
+ return value;
717
718
  }
718
719
  function fail(error) {
719
720
  if (settled) return;
@@ -2259,8 +2260,7 @@ function runEdit(opts) {
2259
2260
  return sizeAnchor;
2260
2261
  },
2261
2262
  close() {
2262
- if (ctx.settled) return;
2263
- ctx.commit(rebuildRendered);
2263
+ return ctx.commit(rebuildRendered);
2264
2264
  },
2265
2265
  abort() {
2266
2266
  if (ctx.settled) return;
@@ -2664,49 +2664,67 @@ const CLICK_SLOP_PX = 3;
2664
2664
  * thin outline (its own fit at the same angle)
2665
2665
  * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
2666
2666
  * contracts as `runEdit`, scoped to the group
2667
+ * - membership is mutable via `session.setMembers` (#187): members join/leave
2668
+ * a live session without a session boundary
2667
2669
  */
2668
2670
  function runGroupTransform(opts) {
2669
- const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2671
+ const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2670
2672
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2671
- const memberIds = new Set(measures.map((m) => m.id));
2672
2673
  let sizeAnchor = opts.sizeAnchor ?? "ground";
2673
- const editStartPointsByMember = measures.map((m) => m.controlPoints.map((p) => [...p]));
2674
- const workingPointsByMember = editStartPointsByMember.map((pts) => pts.map((p) => [...p]));
2675
- const counts = workingPointsByMember.map((pts) => pts.length);
2674
+ /** Build a fresh working record for `measure`, snapshotting its edit-start points. */
2675
+ function createMemberState(measure) {
2676
+ const editStartPoints = measure.controlPoints.map((p) => [...p]);
2677
+ return {
2678
+ measure,
2679
+ editStartPoints,
2680
+ workingPoints: editStartPoints.map((p) => [...p]),
2681
+ optionsOverride: null,
2682
+ box: null,
2683
+ lastPreviewFeatures: []
2684
+ };
2685
+ }
2686
+ /** The single mutable source of truth for the current member set. */
2687
+ let members = measures.map(createMemberState);
2676
2688
  /**
2677
- * Per-member options patch, index-aligned with `measures`, applied on top of
2678
- * the member's own `options` when building its working/rendered measure.
2679
- * This exists solely to freeze orientation-derived option defaults (e.g.
2680
- * Battle Position's echelon anchor) the moment a group rotate gesture
2681
- * starts (ADR-0023) — there is no public per-member `setOptions` here, and
2682
- * the "no per-measure setOptions" contract for the group session still
2683
- * holds; a host cannot reach this array.
2689
+ * `true` once any member has been lifted off the graphics layer (initial or a
2690
+ * mid-session `setMembers` add). Gates the abort-path restore so a session
2691
+ * that never touched the graphics layer doesn't rewrite it on abort.
2684
2692
  */
2685
- let optionsOverrideByMember = measures.map(() => null);
2686
- /** Effective options for member `i` against a given override array, merged onto its own options. */
2687
- function effectiveOptionsWith(i, overrides) {
2688
- const override = overrides[i];
2689
- if (!override) return measures[i].options;
2693
+ let anyLiftedFromGraphics = false;
2694
+ /** A member's own options merged with an override patch (a null override leaves them unchanged). */
2695
+ function mergeOptions(base, override) {
2696
+ if (!override) return base;
2690
2697
  return {
2691
- ...measures[i].options ?? {},
2698
+ ...base ?? {},
2692
2699
  ...override
2693
2700
  };
2694
2701
  }
2695
- /** Effective options for member `i`: its own options merged with the *live* override. */
2702
+ /** Effective options for member `i`: its own options merged with its *live* override. */
2696
2703
  function effectiveOptions(i) {
2697
- return effectiveOptionsWith(i, optionsOverrideByMember);
2704
+ const member = members[i];
2705
+ return mergeOptions(member.measure.options, member.optionsOverride);
2698
2706
  }
2699
- /** The working measure for member `i` against a given override array, with live working points. */
2700
- function workingMeasureForWith(i, overrides) {
2707
+ /** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
2708
+ function buildMemberSnapshot(member, override) {
2709
+ const working = {
2710
+ ...member.measure,
2711
+ options: mergeOptions(member.measure.options, override),
2712
+ controlPoints: member.workingPoints.map((p) => [...p])
2713
+ };
2714
+ const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2701
2715
  return {
2702
- ...measures[i],
2703
- options: effectiveOptionsWith(i, overrides),
2704
- controlPoints: workingPointsByMember[i].map((p) => [...p])
2716
+ measure: cloneControlMeasure(committed),
2717
+ render: renderMeasureForAdapter(adapter, committed, renderOpts)
2705
2718
  };
2706
2719
  }
2707
2720
  /** The working measure for member `i`: original measure, live effective options, live working points. */
2708
2721
  function workingMeasureFor(i) {
2709
- return workingMeasureForWith(i, optionsOverrideByMember);
2722
+ const member = members[i];
2723
+ return {
2724
+ ...member.measure,
2725
+ options: effectiveOptions(i),
2726
+ controlPoints: member.workingPoints.map((p) => [...p])
2727
+ };
2710
2728
  }
2711
2729
  let dirty = false;
2712
2730
  /**
@@ -2723,22 +2741,22 @@ function runGroupTransform(opts) {
2723
2741
  let boxAngle = 0;
2724
2742
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
2725
2743
  let groupBox = null;
2726
- /** Each member's own fitted box (same angle as `groupBox`), index-aligned with `measures`. */
2727
- let memberBoxes = measures.map(() => null);
2728
- /** The features written to the preview layer for each member on the last paint. */
2729
- let lastPreviewFeaturesByMember = measures.map(() => []);
2730
2744
  let ctx;
2731
2745
  let driver;
2732
2746
  const dragProjection = {
2733
2747
  toPixel: (lonLat) => driver.toPixel(lonLat),
2734
2748
  fromPixel: (pixel) => driver.fromPixel(pixel)
2735
2749
  };
2736
- function liftOriginalsFromGraphics() {
2737
- measures.forEach((measure, i) => {
2738
- if (!wasOnGraphicsLayer[i]) return;
2739
- const ids = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2740
- if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2741
- });
2750
+ /**
2751
+ * Lift `member` off the graphics layer when the host reports it currently
2752
+ * there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
2753
+ * Used for both initial and mid-session-added members.
2754
+ */
2755
+ function liftMemberFromGraphics(member) {
2756
+ if (!isOnGraphicsLayer(member.measure.id)) return;
2757
+ const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2758
+ if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2759
+ anyLiftedFromGraphics = true;
2742
2760
  }
2743
2761
  function pixelsFor(features) {
2744
2762
  const positions = [];
@@ -2755,7 +2773,7 @@ function runGroupTransform(opts) {
2755
2773
  return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
2756
2774
  }
2757
2775
  /**
2758
- * Re-fit `groupBox` and every `memberBoxes[i]` from `lastPreviewFeaturesByMember`.
2776
+ * Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
2759
2777
  * While a box gesture is in flight, `groupBox` itself is not touched here —
2760
2778
  * `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
2761
2779
  * (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
@@ -2766,18 +2784,18 @@ function runGroupTransform(opts) {
2766
2784
  const angle = currentAngle();
2767
2785
  const padding = interactionStyle?.boxPadding ?? 0;
2768
2786
  const allPixels = [];
2769
- memberBoxes = lastPreviewFeaturesByMember.map((features) => {
2770
- const pixels = pixelsFor(features);
2787
+ for (const member of members) {
2788
+ const pixels = pixelsFor(member.lastPreviewFeatures);
2771
2789
  allPixels.push(...pixels);
2772
- return fitTransformBox(pixels, angle, padding);
2773
- });
2790
+ member.box = fitTransformBox(pixels, angle, padding);
2791
+ }
2774
2792
  if (activeBoxDrag) return;
2775
2793
  groupBox = fitTransformBox(allPixels, boxAngle, padding);
2776
2794
  }
2777
2795
  function renderAllPreviews() {
2778
2796
  if (ctx.settled) return;
2779
2797
  const allFeatures = [];
2780
- lastPreviewFeaturesByMember = measures.map((_measure, i) => {
2798
+ members.forEach((member, i) => {
2781
2799
  const working = workingMeasureFor(i);
2782
2800
  let features;
2783
2801
  try {
@@ -2785,17 +2803,17 @@ function runGroupTransform(opts) {
2785
2803
  } catch {
2786
2804
  features = [];
2787
2805
  }
2806
+ member.lastPreviewFeatures = features;
2788
2807
  allFeatures.push(...features);
2789
- return features;
2790
2808
  });
2791
2809
  ctx.writeLayer(previewLayer, allFeatures);
2792
2810
  refitBoxes();
2793
2811
  }
2794
2812
  function rescaleOnViewChange() {
2795
2813
  if (ctx.settled) return;
2796
- if (measures.some((m) => measureUsesAdapterResolution({
2797
- kind: m.kind,
2798
- options: m.options
2814
+ if (members.some((m) => measureUsesAdapterResolution({
2815
+ kind: m.measure.kind,
2816
+ options: m.measure.options
2799
2817
  }))) renderAllPreviews();
2800
2818
  else refitBoxes();
2801
2819
  renderHandlesLayer();
@@ -2821,59 +2839,62 @@ function runGroupTransform(opts) {
2821
2839
  features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
2822
2840
  features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
2823
2841
  }
2824
- measures.forEach((measure, i) => {
2825
- const memberBox = memberBoxes[i];
2826
- if (!memberBox) return;
2827
- features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
2828
- });
2842
+ for (const member of members) {
2843
+ const memberBox = member.box;
2844
+ if (!memberBox) continue;
2845
+ features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
2846
+ }
2829
2847
  ctx.writeLayer(handlesLayer, features);
2830
2848
  }
2831
2849
  /**
2832
2850
  * Builds every member's committed snapshot against a given override array
2833
- * rather than the live `optionsOverrideByMember` — used to render a
2834
- * gesture's pre-freeze "previous" snapshot when a rotate has already
2835
- * mutated the live overrides by the time the commit runs.
2851
+ * rather than the live per-member override — used to render a gesture's
2852
+ * pre-freeze "previous" snapshot when a rotate has already mutated the live
2853
+ * overrides by the time the commit runs. Index-aligned with `members`, which
2854
+ * is sound because membership cannot change within a gesture emission.
2836
2855
  */
2837
2856
  function rebuildRenderedAllWith(overrides) {
2838
- return measures.map((_measure, i) => {
2839
- const working = workingMeasureForWith(i, overrides);
2840
- const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2841
- return {
2842
- measure: cloneControlMeasure(committed),
2843
- render: renderMeasureForAdapter(adapter, committed, renderOpts)
2844
- };
2845
- });
2857
+ return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
2846
2858
  }
2847
2859
  function rebuildRenderedAll() {
2848
- return rebuildRenderedAllWith(optionsOverrideByMember);
2860
+ return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
2849
2861
  }
2850
2862
  function recomputeDirty() {
2851
- dirty = nonGeometryDirty || workingPointsByMember.some((pts, i) => !pointsEqualWithinTolerance(pts, editStartPointsByMember[i], DIRTY_EPSILON));
2863
+ dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
2852
2864
  }
2853
- /** Split `points` (the concatenation of every member's control points) back per member and write them into `workingPointsByMember`. */
2865
+ /** Split `points` (the concatenation of every member's control points) back per member. */
2854
2866
  function applyConcatenatedPoints(points) {
2855
2867
  const rounded = roundControlPoints(points);
2856
2868
  let offset = 0;
2857
- for (let i = 0; i < counts.length; i++) {
2858
- const c = counts[i];
2869
+ for (const member of members) {
2870
+ const c = member.workingPoints.length;
2859
2871
  const slice = rounded.slice(offset, offset + c);
2860
- workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...slice);
2872
+ member.workingPoints.splice(0, member.workingPoints.length, ...slice);
2861
2873
  offset += c;
2862
2874
  }
2863
2875
  }
2864
2876
  function concatWorkingPoints() {
2865
2877
  const out = [];
2866
- for (const pts of workingPointsByMember) out.push(...pts.map((p) => [...p]));
2878
+ for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
2867
2879
  return out;
2868
2880
  }
2869
2881
  function clonePointsByMember() {
2870
- return workingPointsByMember.map((pts) => pts.map((p) => [...p]));
2882
+ return members.map((m) => m.workingPoints.map((p) => [...p]));
2871
2883
  }
2872
2884
  function restorePointsByMember(snapshot) {
2873
- for (let i = 0; i < workingPointsByMember.length; i++) workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...snapshot[i]);
2885
+ members.forEach((member, i) => {
2886
+ member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
2887
+ });
2874
2888
  }
2875
2889
  function cloneOptionsOverrideByMember() {
2876
- return optionsOverrideByMember.map((o) => o ? { ...o } : null);
2890
+ return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
2891
+ }
2892
+ /** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
2893
+ function applyOptionsOverrides(overrides) {
2894
+ members.forEach((member, i) => {
2895
+ const o = overrides[i];
2896
+ member.optionsOverride = o ? { ...o } : null;
2897
+ });
2877
2898
  }
2878
2899
  /**
2879
2900
  * Pin orientation-derived option defaults (e.g. Battle Position's echelon
@@ -2882,10 +2903,10 @@ function runGroupTransform(opts) {
2882
2903
  * per-member since the group has no single working-options set.
2883
2904
  */
2884
2905
  function freezeOrientationForRotate() {
2885
- measures.forEach((measure, i) => {
2886
- const patch = freezeOrientationOptions(measure.kind, workingPointsByMember[i].map((p) => [...p]), effectiveOptions(i));
2887
- if (patch) optionsOverrideByMember[i] = {
2888
- ...optionsOverrideByMember[i] ?? {},
2906
+ members.forEach((member, i) => {
2907
+ const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
2908
+ if (patch) member.optionsOverride = {
2909
+ ...member.optionsOverride ?? {},
2889
2910
  ...patch
2890
2911
  };
2891
2912
  });
@@ -2915,6 +2936,7 @@ function runGroupTransform(opts) {
2915
2936
  abortRequested = true;
2916
2937
  }
2917
2938
  };
2939
+ const membersAtEmission = members;
2918
2940
  const errors = [];
2919
2941
  for (const listener of [...changeListeners]) try {
2920
2942
  listener(event);
@@ -2923,11 +2945,11 @@ function runGroupTransform(opts) {
2923
2945
  }
2924
2946
  if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
2925
2947
  else if (closeRequested) ctx.commit(rebuildRenderedAll);
2926
- else if (rejectRequested && !ctx.settled) {
2948
+ else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
2927
2949
  restorePointsByMember(previousPointsByMember);
2928
2950
  sizeAnchor = previousSizeAnchor;
2929
2951
  nonGeometryDirty = previousNonGeometryDirty;
2930
- optionsOverrideByMember = previousOptionsOverrideByMember.map((o) => o ? { ...o } : null);
2952
+ applyOptionsOverrides(previousOptionsOverrideByMember);
2931
2953
  renderAllPreviews();
2932
2954
  renderHandlesLayer();
2933
2955
  recomputeDirty();
@@ -2968,12 +2990,63 @@ function runGroupTransform(opts) {
2968
2990
  dirty = true;
2969
2991
  emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
2970
2992
  }
2993
+ /**
2994
+ * Cancel an in-flight box drag without committing it: restore the pre-drag
2995
+ * geometry and option overrides, re-enable drag pan, and clear the drag. Used
2996
+ * by `setMembers` so a membership change never bakes a half-finished drag; the
2997
+ * caller is responsible for the follow-up repaint (`renderAllPreviews` /
2998
+ * `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
2999
+ * rather than rendering the outgoing member set only to redo it immediately.
3000
+ */
3001
+ function cancelActiveBoxDrag() {
3002
+ if (!activeBoxDrag) return;
3003
+ const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
3004
+ activeBoxDrag = null;
3005
+ driver.setDragPanEnabled(true);
3006
+ applyConcatenatedPoints(preDragPoints);
3007
+ applyOptionsOverrides(preDragOptionsOverrideByMember);
3008
+ dragInFlight = false;
3009
+ }
3010
+ function setMembers(next) {
3011
+ if (ctx.settled) return void 0;
3012
+ const seen = /* @__PURE__ */ new Set();
3013
+ for (const m of next) {
3014
+ if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
3015
+ seen.add(m.id);
3016
+ }
3017
+ if (next.length === 0) return close();
3018
+ cancelActiveBoxDrag();
3019
+ const nextIds = new Set(next.map((m) => m.id));
3020
+ const currentById = new Map(members.map((m) => [m.measure.id, m]));
3021
+ const removedSnapshots = [];
3022
+ for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
3023
+ const nextMembers = [];
3024
+ for (const measure of next) {
3025
+ const existing = currentById.get(measure.id);
3026
+ if (existing) nextMembers.push(existing);
3027
+ else {
3028
+ const member = createMemberState(measure);
3029
+ liftMemberFromGraphics(member);
3030
+ nextMembers.push(member);
3031
+ }
3032
+ }
3033
+ members = nextMembers;
3034
+ renderAllPreviews();
3035
+ renderHandlesLayer();
3036
+ recomputeDirty();
3037
+ return removedSnapshots;
3038
+ }
3039
+ function close() {
3040
+ return ctx.commit(rebuildRenderedAll);
3041
+ }
2971
3042
  const session = {
2972
- measures,
3043
+ get measures() {
3044
+ return members.map((m) => m.measure);
3045
+ },
2973
3046
  controlPointsFor(measureId) {
2974
- const index = measures.findIndex((m) => m.id === measureId);
2975
- if (index < 0) return void 0;
2976
- return workingPointsByMember[index].map((p) => [...p]);
3047
+ const member = members.find((m) => m.measure.id === measureId);
3048
+ if (!member) return void 0;
3049
+ return member.workingPoints.map((p) => [...p]);
2977
3050
  },
2978
3051
  get dirty() {
2979
3052
  return dirty;
@@ -2984,10 +3057,8 @@ function runGroupTransform(opts) {
2984
3057
  setSizeAnchor(next) {
2985
3058
  setSizeAnchor(next);
2986
3059
  },
2987
- close() {
2988
- if (ctx.settled) return;
2989
- ctx.commit(rebuildRenderedAll);
2990
- },
3060
+ setMembers,
3061
+ close,
2991
3062
  abort() {
2992
3063
  if (ctx.settled) return;
2993
3064
  ctx.fail(new TacticalDrawAbortError("session"));
@@ -3100,7 +3171,8 @@ function runGroupTransform(opts) {
3100
3171
  return;
3101
3172
  }
3102
3173
  if (localPreview || localHandles) return;
3103
- if (localGraphics && !memberIds.has(localGraphics.id)) {
3174
+ const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
3175
+ if (localGraphics && !isMember) {
3104
3176
  ctx.commit(rebuildRenderedAll);
3105
3177
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
3106
3178
  return;
@@ -3116,7 +3188,7 @@ function runGroupTransform(opts) {
3116
3188
  attach(c) {
3117
3189
  ctx = c;
3118
3190
  driver = adapter.createEditPointerDriver();
3119
- liftOriginalsFromGraphics();
3191
+ for (const member of members) liftMemberFromGraphics(member);
3120
3192
  const unsubDown = driver.onPointerDown(onDriverDown);
3121
3193
  const unsubMove = driver.onPointerMove(onDriverMove);
3122
3194
  const unsubUp = driver.onPointerUp(onDriverUp);
@@ -3124,7 +3196,6 @@ function runGroupTransform(opts) {
3124
3196
  const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
3125
3197
  const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
3126
3198
  adapter.on("click", handleClick);
3127
- const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
3128
3199
  c.onTeardown(() => {
3129
3200
  adapter.off("click", handleClick);
3130
3201
  unsubPickPreview();
@@ -3139,7 +3210,9 @@ function runGroupTransform(opts) {
3139
3210
  dragInFlight = false;
3140
3211
  }
3141
3212
  driver.dispose();
3142
- }, anyWasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
3213
+ }, () => {
3214
+ if (anyLiftedFromGraphics) restoreOriginalToGraphics();
3215
+ });
3143
3216
  renderAllPreviews();
3144
3217
  renderHandlesLayer();
3145
3218
  recomputeDirty();
@@ -3151,7 +3224,9 @@ function runGroupTransform(opts) {
3151
3224
  get settled() {
3152
3225
  return core.settled;
3153
3226
  },
3154
- measureIds: measures.map((m) => m.id),
3227
+ get measureIds() {
3228
+ return members.map((m) => m.measure.id);
3229
+ },
3155
3230
  abort(reason, cause) {
3156
3231
  core.abort(reason, cause);
3157
3232
  }
@@ -3297,10 +3372,21 @@ var TacticalDraw = class {
3297
3372
  excludedInteractionIds() {
3298
3373
  const ids = /* @__PURE__ */ new Set();
3299
3374
  if (this.activeEdit) ids.add(this.activeEdit.measureId);
3300
- if (this.activeTransform) for (const id of this.activeTransform.measureIds) ids.add(id);
3375
+ for (const id of this.transformMemberIds() ?? []) ids.add(id);
3301
3376
  return ids;
3302
3377
  }
3303
3378
  /**
3379
+ * Live member ids of the active group transform, or `undefined` when none.
3380
+ * Falls back to the session ref because `onSession` fires from inside the
3381
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
3382
+ * the session ref is registered first (`trackTransformSession`), so a
3383
+ * `render()` from inside `onSession` still sees live membership.
3384
+ */
3385
+ transformMemberIds() {
3386
+ if (this.activeTransform) return this.activeTransform.measureIds;
3387
+ return this.activeTransformSessionRef?.measures.map((m) => m.id);
3388
+ }
3389
+ /**
3304
3390
  * Reconcile the graphics layer against the authoritative list of measures.
3305
3391
  * Idempotent. Throws synchronously on duplicate measure ids *before*
3306
3392
  * mutating any layer. An empty list before any prior render is a no-op.
@@ -3339,7 +3425,7 @@ var TacticalDraw = class {
3339
3425
  externalEdited = measures.find((m) => m.id === editedId);
3340
3426
  editedRemoved = externalEdited === void 0;
3341
3427
  }
3342
- const transformIds = this.activeTransform?.measureIds;
3428
+ const transformIds = this.transformMemberIds();
3343
3429
  const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
3344
3430
  const excluded = this.excludedInteractionIds();
3345
3431
  const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
@@ -3355,10 +3441,14 @@ var TacticalDraw = class {
3355
3441
  editHandle.abort("removed");
3356
3442
  });
3357
3443
  }
3358
- if (transformMemberRemoved && this.activeTransform) {
3359
- const transformHandle = this.activeTransform;
3444
+ if (transformMemberRemoved) {
3445
+ const sessionRef = this.activeTransformSessionRef;
3360
3446
  queueMicrotask(() => {
3361
- transformHandle.abort("removed");
3447
+ const handle = this.activeTransform;
3448
+ if (!handle || handle.settled) return;
3449
+ if (this.activeTransformSessionRef !== sessionRef) return;
3450
+ const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
3451
+ if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
3362
3452
  });
3363
3453
  }
3364
3454
  }
@@ -3564,9 +3654,13 @@ var TacticalDraw = class {
3564
3654
  * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
3565
3655
  * preview layer; the box (corner scale, rotate grip, drag-anywhere body
3566
3656
  * translate) applies rigidly to all of them at once. Returns a Promise that
3567
- * resolves with index-aligned `ControlMeasureSnapshot[]` on
3568
- * `session.close()` and rejects with `TacticalDrawAbortError` on
3569
- * signal / Escape / destroy / preempt / `session.abort()`.
3657
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
3658
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
3659
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
3660
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
3661
+ * are id-keyed, not index-aligned with this input. The promise resolves with
3662
+ * the members in the set at close time; a member removed mid-session had its
3663
+ * exit snapshot returned from the `setMembers` call that removed it.
3570
3664
  *
3571
3665
  * `measures.length` must be >= 1; a single-element array behaves like an
3572
3666
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
@@ -3582,7 +3676,7 @@ var TacticalDraw = class {
3582
3676
  }
3583
3677
  const preempt = this.guardAndPreempt();
3584
3678
  if (preempt) return preempt;
3585
- const wasOnGraphicsLayer = measures.map((m) => this.lastRenderedMeasures.some((rendered) => rendered.id === m.id));
3679
+ const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
3586
3680
  const restoreOriginalToGraphics = () => {
3587
3681
  this.renderMeasuresToGraphics(this.lastRenderedMeasures);
3588
3682
  };
@@ -3608,7 +3702,7 @@ var TacticalDraw = class {
3608
3702
  handleIdPrefix,
3609
3703
  sizeAnchor: options?.sizeAnchor,
3610
3704
  restoreOriginalToGraphics,
3611
- wasOnGraphicsLayer,
3705
+ isOnGraphicsLayer,
3612
3706
  onSettled,
3613
3707
  onClickAwayGraphicsPick
3614
3708
  }), (handle) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.7",
3
+ "version": "0.2.0-alpha.8",
4
4
  "description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",