diff --git a/docs/en/phase-0-reactflow-parity-audit.md b/docs/en/phase-0-reactflow-parity-audit.md
index 833a22d6..3cb1f297 100644
--- a/docs/en/phase-0-reactflow-parity-audit.md
+++ b/docs/en/phase-0-reactflow-parity-audit.md
@@ -234,6 +234,10 @@ Phase 546 is GitHub #215 / `avalonia-node-map-0l9`, the post-Phase-545 whiteboar
Phase 547 is GitHub #217 / `avalonia-node-map-rs0`, the first whiteboard primitive model skeleton. This internal Core model slice records `WHITEBOARD_PRIMITIVE_MODEL_SKELETON`: rectangle/freehand primitive identity, kind, renderer-neutral geometry, style/brush metadata, z-order, and edit lifecycle metadata are now covered by focused Core/Editor tests and remain separate from graph nodes, connections, groups, selection rectangles, lasso feedback, renderer projection, and persistence/schema state. Phase 547 is stacked after PR #216 and must not merge before Phase 546. It authorizes no public API claim expansion, no Avalonia pointer coordinator edits, no toolbar/tool activation, no eraser behavior, no renderer adapter implementation, no persistence/schema change, no schema version bump, no screenshot manifest expansion, no Cookbook visual proof implementation, no UI redesign, no retained API removal, and no full React Flow whiteboard parity.
+## Phase 548 Update
+
+Phase 548 is GitHub #219 / `avalonia-node-map-10p`, the first whiteboard primitive renderer adapter skeleton. This internal Core adapter slice records `WHITEBOARD_PRIMITIVE_RENDERER_ADAPTER_SKELETON`: `GraphWhiteboardPrimitiveRendererAdapter` maps rectangle/freehand primitive projection into `GraphWhiteboardPrimitiveSceneSnapshot` scene items, and `GraphWhiteboardPrimitiveHitTestResult` records topmost primitive hit-testing evidence with z-order plus edit lifecycle metadata. The adapter remains renderer-neutral and separate from `GraphDocument`, `GraphEditorSceneSnapshot`, and `NodeCanvasConnectionSceneRenderer`. Phase 548 is stacked after PR #218 and must not merge before Phase 547. It authorizes no GraphDocument schema change, no public drawing API, no Avalonia renderer rewrite, no NodeCanvasConnectionSceneRenderer edits, no pointer coordinator changes, no toolbar/tool activation, no eraser behavior, no persistence/schema work, no schema version bump, no screenshot manifest expansion, no Cookbook visual proof implementation, no UI redesign, no retained API removal, and no full React Flow whiteboard parity.
+
## Phase 489 Update
Phase 489 closed GitHub #101 / `avalonia-node-map-6sc` through PR #102 as a renderer virtualization design spike on branch `perf/renderer-virtualization-spike`. This slice was docs/tests only: it defined the proof contract required before any future ItemsRepeater/Skia-style renderer virtualization, background graph index, or graph-size claim expansion. It made no public API change and no runtime change. The current evidence remains viewport-budgeted scene projection/rendering, not a true renderer virtualization contract; `xlarge` stays telemetry-only.
@@ -561,6 +565,8 @@ Phase 546 records the post-Phase-545 whiteboard implementation queue refresh thr
Phase 547 records the first whiteboard primitive model skeleton through GitHub #217 / `avalonia-node-map-rs0`. It adds only internal renderer-neutral model types and focused contract coverage for primitive identity, kind, geometry, style, z-order, edit lifecycle, and separation from graph/selection state; renderer adapter, persistence/schema, pointer coordinator, toolbar, eraser, screenshot, and Cookbook implementation remain later tracker-backed work.
+Phase 548 records the whiteboard primitive renderer adapter skeleton through GitHub #219 / `avalonia-node-map-10p`. It adds only internal renderer-neutral projection and topmost primitive hit-testing evidence for the Phase 547 primitive model; persistence/schema, Avalonia pointer coordinators, toolbar activation, eraser behavior, screenshot manifests, Cookbook implementation, and any full React Flow whiteboard parity claim remain later tracker-backed work.
+
| GitHub | Bead | Title | Priority | Likely write set | Parallelism |
| --- | --- | --- | --- | --- | --- |
| #193 | `avalonia-node-map-8l6` | Phase 535: refresh post-lasso visual feedback parity queue | P2 | parity roadmap docs and focused docs tests | Current docs/test queue refresh. Blocks the next implementation wave because it replaces the stale Phase 534 current row with tracker-backed follow-ups. |
@@ -576,7 +582,7 @@ Phase 547 records the first whiteboard primitive model skeleton through GitHub #
| #209 | `avalonia-node-map-7ns` | Phase 545: whiteboard Cookbook and screenshot proof route gate | P3 | Cookbook route, screenshot proof route, visual gate docs, and focused tests | Stacked after Phase 544 / PR #213; do not merge before Phase 544. This gate owns the Cookbook/screenshot proof route without claiming full React Flow whiteboard parity. |
| #215 | `avalonia-node-map-0l9` | Phase 546: post-Phase-545 whiteboard implementation queue refresh | P4 | parity roadmap docs and focused docs tests | Stacked after PR #214; do not merge before Phase 545. This queue refresh records the next candidates without runtime/API/model/schema/renderer/screenshot-manifest behavior. |
| #217 | `avalonia-node-map-rs0` | Phase 547: whiteboard primitive model skeleton | P2 | Core/Editor primitive model tests, internal model types, and renderer-neutral contract docs | Stacked after PR #216; do not merge before Phase 546. This slice adds the internal model skeleton only and must not touch renderer adapters, persistence/schema, pointer coordinators, screenshot manifests, toolbar UX, or eraser behavior. |
-| TBD | TBD | Phase 548: whiteboard primitive renderer adapter skeleton | P2 | renderer adapter tests, scene snapshot projection proof, and hit-testing evidence | Candidate after Phase 547 model skeleton. Must keep persistence/schema, Cookbook screenshot rows, pointer coordinators, and toolbar activation out of scope. |
+| #219 | `avalonia-node-map-10p` | Phase 548: whiteboard primitive renderer adapter skeleton | P2 | renderer adapter tests, scene snapshot projection proof, and topmost primitive hit-testing evidence | Stacked after PR #218; do not merge before Phase 547. Must keep persistence/schema, Cookbook screenshot rows, pointer coordinators, and toolbar activation out of scope. |
| TBD | TBD | Phase 549: whiteboard primitive persistence decision implementation gate | P3 | persistence decision tests, schema/surface choice, migration criteria, and compatibility coverage | Candidate after Phase 547 and the Phase 544 policy gate. Must decide storage ownership before any saved whiteboard primitive claim. |
| TBD | TBD | Phase 550: whiteboard primitive Cookbook screenshot implementation gate | P3 | Cookbook screenshot implementation gate docs/tests, route metadata, shell state, and non-overlap visual proof | Candidate after model, renderer, and persistence decisions. Must add visual proof before any whiteboard visual parity claim. |
@@ -627,7 +633,7 @@ Phase 547 records the first whiteboard primitive model skeleton through GitHub #
- `visual/phase-545-whiteboard-cookbook-screenshot-proof`: owns #209 / `avalonia-node-map-7ns`; current stacked docs/test gate for the whiteboard Cookbook and screenshot proof route gate.
- `docs/phase-546-post-whiteboard-queue`: owns #215 / `avalonia-node-map-0l9`; current stacked docs/test queue refresh after the Phase 545 proof-route gate, with no runtime/API/model/schema/renderer/screenshot-manifest changes.
- `feature/phase-547-whiteboard-primitive-model-skeleton`: owns #217 / `avalonia-node-map-rs0`; current stacked implementation worktree for the internal whiteboard primitive model skeleton after Phase 546, with renderer adapters, persistence/schema, pointer coordinators, screenshot manifests, toolbar UX, eraser behavior, and public API claim expansion out of scope.
-- `feature/phase-548-whiteboard-renderer-adapter-skeleton`: future candidate for whiteboard primitive renderer adapter and hit-testing proof after the model skeleton.
+- `feature/phase-548-whiteboard-renderer-adapter-skeleton`: owns #219 / `avalonia-node-map-10p`; current stacked implementation worktree for the internal renderer-neutral whiteboard primitive renderer adapter and topmost primitive hit-testing proof after the Phase 547 model skeleton, with persistence/schema, pointer coordinators, toolbar, eraser, screenshot manifests, and Cookbook implementation out of scope.
- `feature/phase-549-whiteboard-persistence-decision`: future candidate for the first persistence decision implementation gate after model ownership is settled.
- `visual/phase-550-whiteboard-cookbook-screenshot-implementation`: future candidate for the Cookbook screenshot implementation gate after model, renderer, and persistence decisions.
@@ -693,4 +699,5 @@ Current coverage includes scene-level route captures plus ten manifest-driven fu
- Phase 545 is GitHub #209 / `avalonia-node-map-7ns`; it records the whiteboard Cookbook and screenshot proof route gate and `WHITEBOARD_COOKBOOK_SCREENSHOT_PROOF_ROUTE_GATE` without screenshot manifest expansion, runtime UI behavior, UI redesign, drawing tool implementation, renderer changes, persistence/schema changes, pointer coordinator edits, retained API removal, or full React Flow whiteboard parity.
- Phase 546 is GitHub #215 / `avalonia-node-map-0l9`; it records the post-Phase-545 whiteboard implementation queue refresh and `WHITEBOARD_POST_GATE_QUEUE_REFRESH` without runtime behavior changes, public API changes, production model/schema changes, renderer-layer changes, screenshot manifest expansion, pointer coordinator edits, UI redesign, drawing tool implementation, eraser behavior, toolbar work, retained API removal, or full React Flow whiteboard parity.
- Phase 547 is GitHub #217 / `avalonia-node-map-rs0`; it records `WHITEBOARD_PRIMITIVE_MODEL_SKELETON` with internal Core model types and focused Core/Editor contract tests only. Renderer adapters, persistence/schema, pointer coordinators, toolbar/tool activation, eraser behavior, screenshot manifests, Cookbook visual proof, public API claim expansion, retained API removal, and full React Flow whiteboard parity remain out of scope.
+- Phase 548 is GitHub #219 / `avalonia-node-map-10p`; it records `WHITEBOARD_PRIMITIVE_RENDERER_ADAPTER_SKELETON` with internal Core renderer-neutral projection and hit-testing tests only. GraphDocument schema changes, public drawing API, Avalonia renderer rewrites, NodeCanvasConnectionSceneRenderer edits, pointer coordinators, toolbar/tool activation, eraser behavior, persistence/schema work, screenshot manifests, Cookbook visual proof, retained API removal, and full React Flow whiteboard parity remain out of scope.
- Product code remains out of scope for Phase 478, Phase 484, Phase 490, Phase 491, Phase 492, Phase 493, Phase 494, Phase 495, Phase 497, Phase 498, Phase 499, Phase 500, Phase 501, Phase 502, Phase 503, Phase 504, Phase 505, Phase 506, Phase 507, Phase 508, Phase 509, Phase 510, Phase 511, Phase 512, Phase 513, Phase 520, Phase 521, Phase 522, Phase 523, Phase 524, Phase 525, Phase 526, Phase 527, Phase 528, Phase 529, Phase 535, Phase 539, Phase 540, Phase 541, and Phase 546 unless a focused test proves a specific missing contract.
diff --git a/docs/zh-CN/phase-0-reactflow-parity-audit.md b/docs/zh-CN/phase-0-reactflow-parity-audit.md
index 204e2b35..85993658 100644
--- a/docs/zh-CN/phase-0-reactflow-parity-audit.md
+++ b/docs/zh-CN/phase-0-reactflow-parity-audit.md
@@ -234,6 +234,10 @@ Phase 546 是 GitHub #215 / `avalonia-node-map-0l9`,记录 post-Phase-545 whit
Phase 547 是 GitHub #217 / `avalonia-node-map-rs0`,承接第一条 whiteboard primitive model skeleton。本 internal Core model slice 记录 `WHITEBOARD_PRIMITIVE_MODEL_SKELETON`:rectangle/freehand primitive identity、kind、renderer-neutral geometry、style/brush metadata、z-order 和 edit lifecycle metadata 现在由 focused Core/Editor tests 覆盖,并继续与 graph nodes、connections、groups、selection rectangles、lasso feedback、renderer projection 和 persistence/schema state 分离。Phase 547 stacked after PR #216,不能早于 Phase 546 合并。它不授权 public API claim expansion、Avalonia pointer coordinator edits、toolbar/tool activation、eraser behavior、renderer adapter implementation、persistence/schema change、schema version bump、screenshot manifest expansion、Cookbook visual proof implementation、UI redesign、retained API removal 或 full React Flow whiteboard parity。
+## Phase 548 更新
+
+Phase 548 是 GitHub #219 / `avalonia-node-map-10p`,承接第一条 whiteboard primitive renderer adapter skeleton。本 internal Core adapter slice 记录 `WHITEBOARD_PRIMITIVE_RENDERER_ADAPTER_SKELETON`:`GraphWhiteboardPrimitiveRendererAdapter` 将 rectangle/freehand primitive projection 映射为 `GraphWhiteboardPrimitiveSceneSnapshot` scene items,`GraphWhiteboardPrimitiveHitTestResult` 记录带 z-order 与 edit lifecycle metadata 的 topmost primitive hit-testing evidence。该 adapter 保持 renderer-neutral,并继续与 `GraphDocument`、`GraphEditorSceneSnapshot` 和 `NodeCanvasConnectionSceneRenderer` 分离。Phase 548 stacked after PR #218,不能早于 Phase 547 合并。它不授权 no GraphDocument schema change、no public drawing API、no Avalonia renderer rewrite、no NodeCanvasConnectionSceneRenderer edits、no pointer coordinator changes、no toolbar/tool activation、no eraser behavior、no persistence/schema work、no schema version bump、no screenshot manifest expansion、no Cookbook visual proof implementation、no UI redesign、no retained API removal 或 no full React Flow whiteboard parity。
+
## Phase 489 更新
Phase 489 通过 PR #102 关闭 GitHub #101 / `avalonia-node-map-6sc`,完成 `perf/renderer-virtualization-spike` 分支上的 renderer virtualization design spike。本 slice 只做 docs/tests:先定义未来声明 ItemsRepeater/Skia-style renderer virtualization、background graph index 或扩大 graph-size claim 前必须满足的 proof contract。不做 public API change,也不做 runtime change。当前证据仍只支持 viewport-budgeted scene projection/rendering,不是真正的 renderer virtualization contract;`xlarge` 继续保持 telemetry-only。
@@ -561,6 +565,8 @@ Phase 546 记录 post-Phase-545 whiteboard implementation queue refresh,通过
Phase 547 通过 GitHub #217 / `avalonia-node-map-rs0` 记录第一条 whiteboard primitive model skeleton。它只添加 internal renderer-neutral model types,并用 focused contract coverage 证明 primitive identity、kind、geometry、style、z-order、edit lifecycle 以及与 graph/selection state 的分离;renderer adapter、persistence/schema、pointer coordinator、toolbar、eraser、screenshot 和 Cookbook implementation 都保留为后续 tracker-backed work。
+Phase 548 记录 whiteboard primitive renderer adapter skeleton,通过 GitHub #219 / `avalonia-node-map-10p`。它只为 Phase 547 primitive model 添加 internal renderer-neutral projection 与 topmost primitive hit-testing evidence;persistence/schema、Avalonia pointer coordinators、toolbar activation、eraser behavior、screenshot manifests、Cookbook implementation,以及任何 full React Flow whiteboard parity claim 都保留为后续 tracker-backed work。
+
| GitHub | Bead | 标题 | 优先级 | 可能 write set | 并行边界 |
| --- | --- | --- | --- | --- | --- |
| #193 | `avalonia-node-map-8l6` | Phase 535: refresh post-lasso visual feedback parity queue | P2 | parity roadmap docs 和 focused docs tests | Current docs/test queue refresh。它用 tracker-backed follow-ups 替换 stale Phase 534 current row,因此会阻塞下一批 implementation wave。 |
@@ -576,7 +582,7 @@ Phase 547 通过 GitHub #217 / `avalonia-node-map-rs0` 记录第一条 whiteboar
| #209 | `avalonia-node-map-7ns` | Phase 545: whiteboard Cookbook and screenshot proof route gate | P3 | Cookbook route、screenshot proof route、visual gate docs 和 focused tests | Stacked after Phase 544 / PR #213;不要早于 Phase 544 合并。本 gate 负责 Cookbook/screenshot proof route,不声明 full React Flow whiteboard parity。 |
| #215 | `avalonia-node-map-0l9` | Phase 546: post-Phase-545 whiteboard implementation queue refresh | P4 | parity roadmap docs 和 focused docs tests | Stacked after PR #214;do not merge before Phase 545。本 queue refresh 只记录下一批 candidates,不做 runtime/API/model/schema/renderer/screenshot-manifest behavior。 |
| #217 | `avalonia-node-map-rs0` | Phase 547: whiteboard primitive model skeleton | P2 | Core/Editor primitive model tests、internal model types 和 renderer-neutral contract docs | Stacked after PR #216;do not merge before Phase 546。本 slice 只添加 internal model skeleton,不得触碰 renderer adapters、persistence/schema、pointer coordinators、screenshot manifests、toolbar UX 或 eraser behavior。 |
-| TBD | TBD | Phase 548: whiteboard primitive renderer adapter skeleton | P2 | renderer adapter tests、scene snapshot projection proof 和 hit-testing evidence | Phase 547 model skeleton 后的 candidate。persistence/schema、Cookbook screenshot rows、pointer coordinators 和 toolbar activation 都保持 out of scope。 |
+| #219 | `avalonia-node-map-10p` | Phase 548: whiteboard primitive renderer adapter skeleton | P2 | renderer adapter tests、scene snapshot projection proof 和 topmost primitive hit-testing evidence | Stacked after PR #218;do not merge before Phase 547。persistence/schema、Cookbook screenshot rows、pointer coordinators 和 toolbar activation 都保持 out of scope。 |
| TBD | TBD | Phase 549: whiteboard primitive persistence decision implementation gate | P3 | persistence decision tests、schema/surface choice、migration criteria 和 compatibility coverage | Phase 547 与 Phase 544 policy gate 后的 candidate。任何 saved whiteboard primitive claim 前必须先决定 storage ownership。 |
| TBD | TBD | Phase 550: whiteboard primitive Cookbook screenshot implementation gate | P3 | Cookbook screenshot implementation gate docs/tests、route metadata、shell state 和 non-overlap visual proof | model、renderer 与 persistence decisions 后的 candidate。任何 whiteboard visual parity claim 前必须先补 visual proof。 |
@@ -627,7 +633,7 @@ Phase 547 通过 GitHub #217 / `avalonia-node-map-rs0` 记录第一条 whiteboar
- `visual/phase-545-whiteboard-cookbook-screenshot-proof`:负责 #209 / `avalonia-node-map-7ns`;当前 stacked docs/test gate,用于 whiteboard Cookbook and screenshot proof route gate。
- `docs/phase-546-post-whiteboard-queue`:负责 #215 / `avalonia-node-map-0l9`;当前 stacked docs/test queue refresh,承接 Phase 545 proof-route gate,不做 runtime/API/model/schema/renderer/screenshot-manifest changes。
- `feature/phase-547-whiteboard-primitive-model-skeleton`:负责 #217 / `avalonia-node-map-rs0`;当前 stacked implementation worktree,用于 Phase 546 后的 internal whiteboard primitive model skeleton。renderer adapters、persistence/schema、pointer coordinators、screenshot manifests、toolbar UX、eraser behavior 和 public API claim expansion 都保持 out of scope。
-- `feature/phase-548-whiteboard-renderer-adapter-skeleton`:future candidate,用于 model skeleton 后的 whiteboard primitive renderer adapter 和 hit-testing proof。
+- `feature/phase-548-whiteboard-renderer-adapter-skeleton`:负责 #219 / `avalonia-node-map-10p`;当前 stacked implementation worktree,用于 Phase 547 model skeleton 后的 internal renderer-neutral whiteboard primitive renderer adapter 与 topmost primitive hit-testing proof。persistence/schema、pointer coordinators、toolbar、eraser、screenshot manifests 和 Cookbook implementation 都保持 out of scope。
- `feature/phase-549-whiteboard-persistence-decision`:future candidate,用于 model ownership 稳定后的第一条 persistence decision implementation gate。
- `visual/phase-550-whiteboard-cookbook-screenshot-implementation`:future candidate,用于 model、renderer 与 persistence decisions 后的 Cookbook screenshot implementation gate。
@@ -693,4 +699,5 @@ Phase 547 通过 GitHub #217 / `avalonia-node-map-rs0` 记录第一条 whiteboar
- Phase 545 是 GitHub #209 / `avalonia-node-map-7ns`;它记录 whiteboard Cookbook and screenshot proof route gate 和 `WHITEBOARD_COOKBOOK_SCREENSHOT_PROOF_ROUTE_GATE`,不做 screenshot manifest expansion、runtime UI behavior、UI redesign、drawing tool implementation、renderer changes、persistence/schema changes、pointer coordinator edits、retained API removal 或 full React Flow whiteboard parity。
- Phase 546 是 GitHub #215 / `avalonia-node-map-0l9`;它记录 post-Phase-545 whiteboard implementation queue refresh 和 `WHITEBOARD_POST_GATE_QUEUE_REFRESH`,不做 runtime behavior changes、public API changes、production model/schema changes、renderer-layer changes、screenshot manifest expansion、pointer coordinator edits、UI redesign、drawing tool implementation、eraser behavior、toolbar work、retained API removal 或 full React Flow whiteboard parity。
- Phase 547 是 GitHub #217 / `avalonia-node-map-rs0`;它用 internal Core model types 和 focused Core/Editor contract tests 记录 `WHITEBOARD_PRIMITIVE_MODEL_SKELETON`。renderer adapters、persistence/schema、pointer coordinators、toolbar/tool activation、eraser behavior、screenshot manifests、Cookbook visual proof、public API claim expansion、retained API removal 和 full React Flow whiteboard parity 都保持 out of scope。
+- Phase 548 是 GitHub #219 / `avalonia-node-map-10p`;它用 internal Core renderer-neutral projection 和 hit-testing tests 记录 `WHITEBOARD_PRIMITIVE_RENDERER_ADAPTER_SKELETON`。GraphDocument schema changes、public drawing API、Avalonia renderer rewrites、NodeCanvasConnectionSceneRenderer edits、pointer coordinators、toolbar/tool activation、eraser behavior、persistence/schema work、screenshot manifests、Cookbook visual proof、retained API removal 和 full React Flow whiteboard parity 都保持 out of scope。
- Phase 478、Phase 484、Phase 490、Phase 491、Phase 492、Phase 493、Phase 494、Phase 495、Phase 497、Phase 498、Phase 499、Phase 500、Phase 501、Phase 502、Phase 503、Phase 504、Phase 505、Phase 506、Phase 507、Phase 508、Phase 509、Phase 510、Phase 511、Phase 512、Phase 513、Phase 520、Phase 521、Phase 522、Phase 523、Phase 524、Phase 525、Phase 526、Phase 527、Phase 528、Phase 529、Phase 535、Phase 539、Phase 540、Phase 541 和 Phase 546 都不修改产品代码;除非 focused test 证明存在具体 missing contract。
diff --git a/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveHitTestResult.cs b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveHitTestResult.cs
new file mode 100644
index 00000000..65029974
--- /dev/null
+++ b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveHitTestResult.cs
@@ -0,0 +1,14 @@
+namespace AsterGraph.Core.Models;
+
+///
+/// Internal hit-test result for renderer-neutral whiteboard primitive scene data.
+///
+/// Hit primitive identifier.
+/// Hit primitive kind.
+/// Hit primitive stacking order.
+/// Hit primitive edit lifecycle metadata.
+internal sealed record GraphWhiteboardPrimitiveHitTestResult(
+ string PrimitiveId,
+ GraphWhiteboardPrimitiveKind Kind,
+ int ZIndex,
+ GraphWhiteboardPrimitiveEditLifecycle EditLifecycle);
diff --git a/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveRendererAdapter.cs b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveRendererAdapter.cs
new file mode 100644
index 00000000..bb432ecd
--- /dev/null
+++ b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveRendererAdapter.cs
@@ -0,0 +1,133 @@
+namespace AsterGraph.Core.Models;
+
+///
+/// Internal renderer-neutral adapter for whiteboard primitive scene data.
+///
+internal static class GraphWhiteboardPrimitiveRendererAdapter
+{
+ private const double MinimumFreehandHitTolerance = 4d;
+
+ ///
+ /// Projects whiteboard primitive model snapshots into renderer-neutral scene data.
+ ///
+ /// Whiteboard primitives to project.
+ /// Renderer-neutral whiteboard primitive scene snapshot.
+ public static GraphWhiteboardPrimitiveSceneSnapshot Project(IReadOnlyList primitives)
+ {
+ ArgumentNullException.ThrowIfNull(primitives);
+
+ return new GraphWhiteboardPrimitiveSceneSnapshot(
+ primitives
+ .Select(primitive => new GraphWhiteboardPrimitiveSceneItem(
+ primitive.Id,
+ primitive.Kind,
+ primitive.Geometry.Origin,
+ primitive.Geometry.Size,
+ primitive.Geometry.Points,
+ primitive.Style,
+ primitive.ZIndex,
+ primitive.EditLifecycle))
+ .ToList());
+ }
+
+ ///
+ /// Finds the topmost primitive scene item containing the graph-space point.
+ ///
+ /// Whiteboard primitive scene snapshot.
+ /// Graph-space hit-test point.
+ /// The topmost hit result, or when no primitive contains the point.
+ public static GraphWhiteboardPrimitiveHitTestResult? HitTest(
+ GraphWhiteboardPrimitiveSceneSnapshot scene,
+ GraphPoint worldPoint)
+ {
+ ArgumentNullException.ThrowIfNull(scene);
+
+ var hit = scene.Primitives
+ .Select((primitive, index) => new IndexedPrimitive(primitive, index))
+ .Where(item => Contains(item.Primitive, worldPoint))
+ .OrderByDescending(item => item.Primitive.ZIndex)
+ .ThenByDescending(item => item.Index)
+ .Select(item => item.Primitive)
+ .FirstOrDefault();
+
+ return hit is null
+ ? null
+ : new GraphWhiteboardPrimitiveHitTestResult(
+ hit.Id,
+ hit.Kind,
+ hit.ZIndex,
+ hit.EditLifecycle);
+ }
+
+ private static bool Contains(GraphWhiteboardPrimitiveSceneItem primitive, GraphPoint point)
+ => primitive.Kind switch
+ {
+ GraphWhiteboardPrimitiveKind.Freehand => ContainsFreehandPrimitive(primitive, point),
+ _ => ContainsBounds(primitive, point),
+ };
+
+ private static bool ContainsBounds(GraphWhiteboardPrimitiveSceneItem primitive, GraphPoint point)
+ {
+ var left = primitive.BoundsOrigin.X;
+ var top = primitive.BoundsOrigin.Y;
+ var right = left + Math.Max(0d, primitive.BoundsSize.Width);
+ var bottom = top + Math.Max(0d, primitive.BoundsSize.Height);
+
+ return point.X >= left
+ && point.X <= right
+ && point.Y >= top
+ && point.Y <= bottom;
+ }
+
+ private static bool ContainsFreehandPrimitive(GraphWhiteboardPrimitiveSceneItem primitive, GraphPoint point)
+ {
+ if (!ContainsBounds(primitive, point) || primitive.Points.Count == 0)
+ {
+ return false;
+ }
+
+ var tolerance = Math.Max(MinimumFreehandHitTolerance, primitive.Style.StrokeThickness / 2d);
+ if (primitive.Points.Count == 1)
+ {
+ return DistanceSquared(point, primitive.Points[0]) <= tolerance * tolerance;
+ }
+
+ for (var index = 1; index < primitive.Points.Count; index++)
+ {
+ if (DistanceToSegmentSquared(point, primitive.Points[index - 1], primitive.Points[index]) <= tolerance * tolerance)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static double DistanceToSegmentSquared(GraphPoint point, GraphPoint start, GraphPoint end)
+ {
+ var segmentX = end.X - start.X;
+ var segmentY = end.Y - start.Y;
+ var lengthSquared = (segmentX * segmentX) + (segmentY * segmentY);
+ if (lengthSquared <= double.Epsilon)
+ {
+ return DistanceSquared(point, start);
+ }
+
+ var projected = (((point.X - start.X) * segmentX) + ((point.Y - start.Y) * segmentY)) / lengthSquared;
+ var clamped = Math.Clamp(projected, 0d, 1d);
+ var closest = new GraphPoint(
+ start.X + (clamped * segmentX),
+ start.Y + (clamped * segmentY));
+
+ return DistanceSquared(point, closest);
+ }
+
+ private static double DistanceSquared(GraphPoint first, GraphPoint second)
+ {
+ var deltaX = first.X - second.X;
+ var deltaY = first.Y - second.Y;
+ return (deltaX * deltaX) + (deltaY * deltaY);
+ }
+
+ private readonly record struct IndexedPrimitive(GraphWhiteboardPrimitiveSceneItem Primitive, int Index);
+}
diff --git a/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneItem.cs b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneItem.cs
new file mode 100644
index 00000000..d86a7e3c
--- /dev/null
+++ b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneItem.cs
@@ -0,0 +1,89 @@
+namespace AsterGraph.Core.Models;
+
+///
+/// Internal renderer-neutral scene item for a whiteboard primitive.
+///
+internal sealed record GraphWhiteboardPrimitiveSceneItem
+{
+ private string _id = string.Empty;
+ private IReadOnlyList _points = [];
+
+ ///
+ /// Creates a renderer-neutral whiteboard primitive scene item.
+ ///
+ public GraphWhiteboardPrimitiveSceneItem(
+ string Id,
+ GraphWhiteboardPrimitiveKind Kind,
+ GraphPoint BoundsOrigin,
+ GraphSize BoundsSize,
+ IReadOnlyList? Points,
+ GraphWhiteboardPrimitiveStyle Style,
+ int ZIndex,
+ GraphWhiteboardPrimitiveEditLifecycle EditLifecycle)
+ {
+ this.Id = Id;
+ this.Kind = Kind;
+ this.BoundsOrigin = BoundsOrigin;
+ this.BoundsSize = BoundsSize;
+ this.Points = Points ?? [];
+ this.Style = Style ?? throw new ArgumentNullException(nameof(Style));
+ this.ZIndex = ZIndex;
+ this.EditLifecycle = EditLifecycle ?? throw new ArgumentNullException(nameof(EditLifecycle));
+ }
+
+ ///
+ /// Stable primitive identifier.
+ ///
+ public string Id
+ {
+ get => _id;
+ init
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value);
+ _id = value;
+ }
+ }
+
+ ///
+ /// Primitive drawing shape.
+ ///
+ public GraphWhiteboardPrimitiveKind Kind { get; init; }
+
+ ///
+ /// Primitive bounds origin in graph world space.
+ ///
+ public GraphPoint BoundsOrigin { get; init; }
+
+ ///
+ /// Primitive bounds size in graph world space.
+ ///
+ public GraphSize BoundsSize { get; init; }
+
+ ///
+ /// Optional renderer-neutral points for freehand primitives.
+ ///
+ public IReadOnlyList Points
+ {
+ get => _points;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ _points = value.ToList();
+ }
+ }
+
+ ///
+ /// Renderer-neutral primitive style.
+ ///
+ public GraphWhiteboardPrimitiveStyle Style { get; init; }
+
+ ///
+ /// Relative stacking order among whiteboard primitives.
+ ///
+ public int ZIndex { get; init; }
+
+ ///
+ /// Edit lifecycle metadata for future authoring tools.
+ ///
+ public GraphWhiteboardPrimitiveEditLifecycle EditLifecycle { get; init; }
+}
diff --git a/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneSnapshot.cs b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneSnapshot.cs
new file mode 100644
index 00000000..5792df40
--- /dev/null
+++ b/src/AsterGraph.Core/Models/GraphWhiteboardPrimitiveSceneSnapshot.cs
@@ -0,0 +1,31 @@
+namespace AsterGraph.Core.Models;
+
+///
+/// Internal renderer-neutral scene snapshot for whiteboard primitives.
+///
+internal sealed record GraphWhiteboardPrimitiveSceneSnapshot
+{
+ private IReadOnlyList _primitives = [];
+
+ ///
+ /// Creates a whiteboard primitive scene snapshot.
+ ///
+ /// Renderer-neutral primitive scene items.
+ public GraphWhiteboardPrimitiveSceneSnapshot(IReadOnlyList? Primitives = null)
+ {
+ this.Primitives = Primitives ?? [];
+ }
+
+ ///
+ /// Renderer-neutral primitive scene items.
+ ///
+ public IReadOnlyList Primitives
+ {
+ get => _primitives;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ _primitives = value.ToList();
+ }
+ }
+}
diff --git a/tests/AsterGraph.Demo.Tests/ReactFlowParityRoadmapDocsTests.cs b/tests/AsterGraph.Demo.Tests/ReactFlowParityRoadmapDocsTests.cs
index efb7e116..b8c46379 100644
--- a/tests/AsterGraph.Demo.Tests/ReactFlowParityRoadmapDocsTests.cs
+++ b/tests/AsterGraph.Demo.Tests/ReactFlowParityRoadmapDocsTests.cs
@@ -1404,6 +1404,39 @@ public void ParityRoadmapDocs_RecordPhase546PostWhiteboardQueueRefreshInBothLoca
Assert.Contains("Phase 546 记录 post-Phase-545 whiteboard implementation queue refresh", chineseParity, StringComparison.Ordinal);
}
+ [Fact]
+ public void ParityRoadmapDocs_RecordPhase548WhiteboardPrimitiveRendererAdapterSkeletonInBothLocales()
+ {
+ var englishParity = ReadRepoFile("docs/en/phase-0-reactflow-parity-audit.md");
+ var chineseParity = ReadRepoFile("docs/zh-CN/phase-0-reactflow-parity-audit.md");
+
+ foreach (var contents in new[] { englishParity, chineseParity })
+ {
+ Assert.Contains("Phase 548", contents, StringComparison.Ordinal);
+ Assert.Contains("GitHub #219", contents, StringComparison.Ordinal);
+ Assert.Contains("avalonia-node-map-10p", contents, StringComparison.Ordinal);
+ Assert.Contains("whiteboard primitive renderer adapter skeleton", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("WHITEBOARD_PRIMITIVE_RENDERER_ADAPTER_SKELETON", contents, StringComparison.Ordinal);
+ Assert.Contains("GraphWhiteboardPrimitiveRendererAdapter", contents, StringComparison.Ordinal);
+ Assert.Contains("GraphWhiteboardPrimitiveSceneSnapshot", contents, StringComparison.Ordinal);
+ Assert.Contains("GraphWhiteboardPrimitiveHitTestResult", contents, StringComparison.Ordinal);
+ Assert.Contains("rectangle/freehand primitive projection", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("topmost primitive", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no GraphDocument schema", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no NodeCanvasConnectionSceneRenderer", contents, StringComparison.Ordinal);
+ Assert.Contains("no pointer coordinator", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no toolbar", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no full React Flow whiteboard parity", contents, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("| TBD | TBD | Phase 548: whiteboard primitive renderer adapter skeleton", contents, StringComparison.Ordinal);
+ Assert.DoesNotContain("whiteboard primitives are rendered in Avalonia", contents, StringComparison.OrdinalIgnoreCase);
+ }
+
+ AssertPostPhase545Queue(ExtractIssueWaveTable(englishParity));
+ AssertPostPhase545Queue(ExtractIssueWaveTable(chineseParity));
+ Assert.Contains("Phase 548 records the whiteboard primitive renderer adapter skeleton", englishParity, StringComparison.Ordinal);
+ Assert.Contains("Phase 548 记录 whiteboard primitive renderer adapter skeleton", chineseParity, StringComparison.Ordinal);
+ }
+
[Fact]
public void ParityRoadmapDocs_RecordPhase501PostPhase500QueueRefreshInBothLocales()
{
@@ -1611,7 +1644,7 @@ private static void AssertPostPhase545Queue(string table)
{
Assert.Contains("| #215 | `avalonia-node-map-0l9` | Phase 546: post-Phase-545 whiteboard implementation queue refresh", table, StringComparison.Ordinal);
Assert.Contains("| #217 | `avalonia-node-map-rs0` | Phase 547: whiteboard primitive model skeleton", table, StringComparison.Ordinal);
- Assert.Contains("Phase 548: whiteboard primitive renderer adapter skeleton", table, StringComparison.Ordinal);
+ Assert.Contains("| #219 | `avalonia-node-map-10p` | Phase 548: whiteboard primitive renderer adapter skeleton", table, StringComparison.Ordinal);
Assert.Contains("Phase 549: whiteboard primitive persistence decision implementation gate", table, StringComparison.Ordinal);
Assert.Contains("Phase 550: whiteboard primitive Cookbook screenshot implementation gate", table, StringComparison.Ordinal);
Assert.Contains("TBD", table, StringComparison.Ordinal);
@@ -1619,17 +1652,21 @@ private static void AssertPostPhase545Queue(string table)
Assert.Contains("internal model types", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("renderer-neutral contract docs", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("renderer adapter tests", table, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("topmost primitive hit-testing evidence", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("persistence decision tests", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Cookbook screenshot implementation gate", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Stacked after PR #214", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("do not merge before Phase 545", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Stacked after PR #216", table, StringComparison.OrdinalIgnoreCase);
Assert.Contains("do not merge before Phase 546", table, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Stacked after PR #218", table, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("do not merge before Phase 547", table, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("| #206 | `avalonia-node-map-b31` | Phase 542: whiteboard primitive core model contract gate | P2 | core/editor model tests, primitive identity/geometry/style contract docs, and API inventory | Future candidate", table, StringComparison.Ordinal);
Assert.DoesNotContain("| #207 | `avalonia-node-map-aj8` | Phase 543: whiteboard renderer projection and hit-testing proof gate | P2 | renderer projection tests, hit-testing proof, and bounded Avalonia/editor evidence | Future candidate", table, StringComparison.Ordinal);
Assert.DoesNotContain("| #208 | `avalonia-node-map-32n` | Phase 544: whiteboard primitive persistence schema policy gate | P3 | persistence/schema policy docs, migration criteria, and compatibility tests | Future candidate", table, StringComparison.Ordinal);
Assert.DoesNotContain("| #209 | `avalonia-node-map-7ns` | Phase 545: whiteboard Cookbook and screenshot proof route gate | P3 | Cookbook route, screenshot proof route, visual gate docs, and focused tests | Future candidate", table, StringComparison.Ordinal);
Assert.DoesNotContain("| TBD | TBD | Phase 547: whiteboard primitive model skeleton", table, StringComparison.Ordinal);
+ Assert.DoesNotContain("| TBD | TBD | Phase 548: whiteboard primitive renderer adapter skeleton", table, StringComparison.Ordinal);
}
private static void AssertBuiltInComponentMatrix(string table)
diff --git a/tests/AsterGraph.Editor.Tests/WhiteboardPrimitiveRendererAdapterContractsTests.cs b/tests/AsterGraph.Editor.Tests/WhiteboardPrimitiveRendererAdapterContractsTests.cs
new file mode 100644
index 00000000..4ec5b2ab
--- /dev/null
+++ b/tests/AsterGraph.Editor.Tests/WhiteboardPrimitiveRendererAdapterContractsTests.cs
@@ -0,0 +1,183 @@
+using AsterGraph.Core.Models;
+using AsterGraph.Editor.Runtime;
+using Xunit;
+
+namespace AsterGraph.Editor.Tests;
+
+public sealed class WhiteboardPrimitiveRendererAdapterContractsTests
+{
+ [Fact]
+ public void Project_CreatesRendererNeutralSceneDataForRectangleAndFreehandPrimitives()
+ {
+ var rectangle = CreateRectanglePrimitive("rectangle-001", zIndex: 1);
+ var freehand = CreateFreehandPrimitive(
+ "freehand-001",
+ zIndex: 2,
+ [
+ new GraphPoint(32d, 42d),
+ new GraphPoint(52d, 64d),
+ new GraphPoint(74d, 48d),
+ ]);
+
+ var scene = GraphWhiteboardPrimitiveRendererAdapter.Project([rectangle, freehand]);
+
+ Assert.Equal(2, scene.Primitives.Count);
+ var rectangleScene = Assert.Single(scene.Primitives, item => item.Id == "rectangle-001");
+ Assert.Equal(GraphWhiteboardPrimitiveKind.Rectangle, rectangleScene.Kind);
+ Assert.Equal(new GraphPoint(10d, 20d), rectangleScene.BoundsOrigin);
+ Assert.Equal(new GraphSize(80d, 48d), rectangleScene.BoundsSize);
+ Assert.Empty(rectangleScene.Points);
+ Assert.Equal("#F2D45C", rectangleScene.Style.FillHex);
+ Assert.Equal("#222222", rectangleScene.Style.StrokeHex);
+ Assert.Equal(1.5d, rectangleScene.Style.StrokeThickness);
+ Assert.Equal(0.8d, rectangleScene.Style.Opacity);
+ Assert.Equal(GraphWhiteboardPrimitiveEditState.Committed, rectangleScene.EditLifecycle.State);
+
+ var freehandScene = Assert.Single(scene.Primitives, item => item.Id == "freehand-001");
+ Assert.Equal(GraphWhiteboardPrimitiveKind.Freehand, freehandScene.Kind);
+ Assert.Equal(new GraphPoint(30d, 40d), freehandScene.BoundsOrigin);
+ Assert.Equal(new GraphSize(60d, 40d), freehandScene.BoundsSize);
+ Assert.Equal(
+ [
+ new GraphPoint(32d, 42d),
+ new GraphPoint(52d, 64d),
+ new GraphPoint(74d, 48d),
+ ],
+ freehandScene.Points);
+ Assert.Equal(GraphWhiteboardPrimitiveEditState.Editing, freehandScene.EditLifecycle.State);
+ Assert.Equal("freehand-drag", freehandScene.EditLifecycle.ActiveHandleKey);
+ }
+
+ [Fact]
+ public void HitTest_ReturnsTopmostPrimitiveByZIndexAndPreservesEditLifecycleEvidence()
+ {
+ var lower = CreateRectanglePrimitive("lower-rectangle", zIndex: 1);
+ var upper = CreateRectanglePrimitive(
+ "upper-rectangle",
+ zIndex: 8,
+ origin: new GraphPoint(24d, 28d),
+ size: new GraphSize(36d, 24d),
+ lifecycle: new GraphWhiteboardPrimitiveEditLifecycle(
+ GraphWhiteboardPrimitiveEditState.Editing,
+ ActiveHandleKey: "resize-east"));
+
+ var scene = GraphWhiteboardPrimitiveRendererAdapter.Project([lower, upper]);
+
+ var hit = GraphWhiteboardPrimitiveRendererAdapter.HitTest(scene, new GraphPoint(30d, 34d));
+
+ Assert.NotNull(hit);
+ Assert.Equal("upper-rectangle", hit.PrimitiveId);
+ Assert.Equal(GraphWhiteboardPrimitiveKind.Rectangle, hit.Kind);
+ Assert.Equal(8, hit.ZIndex);
+ Assert.Equal(GraphWhiteboardPrimitiveEditState.Editing, hit.EditLifecycle.State);
+ Assert.Equal("resize-east", hit.EditLifecycle.ActiveHandleKey);
+ Assert.Null(GraphWhiteboardPrimitiveRendererAdapter.HitTest(scene, new GraphPoint(500d, 500d)));
+ }
+
+ [Fact]
+ public void HitTest_FreehandPrimitiveIgnoresPointInsideBoundsButAwayFromStroke()
+ {
+ var freehand = CreateFreehandPrimitive(
+ "freehand-001",
+ zIndex: 2,
+ [
+ new GraphPoint(32d, 42d),
+ new GraphPoint(52d, 64d),
+ new GraphPoint(74d, 48d),
+ ]);
+ var scene = GraphWhiteboardPrimitiveRendererAdapter.Project([freehand]);
+
+ var hit = GraphWhiteboardPrimitiveRendererAdapter.HitTest(scene, new GraphPoint(88d, 78d));
+
+ Assert.Null(hit);
+ }
+
+ [Fact]
+ public void HitTest_FreehandPrimitiveReturnsHitNearStrokeSegment()
+ {
+ var freehand = CreateFreehandPrimitive(
+ "freehand-001",
+ zIndex: 2,
+ [
+ new GraphPoint(32d, 42d),
+ new GraphPoint(52d, 64d),
+ new GraphPoint(74d, 48d),
+ ]);
+ var scene = GraphWhiteboardPrimitiveRendererAdapter.Project([freehand]);
+
+ var hit = GraphWhiteboardPrimitiveRendererAdapter.HitTest(scene, new GraphPoint(53d, 63d));
+
+ Assert.NotNull(hit);
+ Assert.Equal("freehand-001", hit.PrimitiveId);
+ Assert.Equal(GraphWhiteboardPrimitiveKind.Freehand, hit.Kind);
+ }
+
+ [Fact]
+ public void RendererAdapter_StaysInternalRendererNeutralAndSeparateFromGraphSceneAndAvaloniaRenderers()
+ {
+ Assert.False(typeof(GraphWhiteboardPrimitiveRendererAdapter).IsPublic);
+ Assert.False(typeof(GraphWhiteboardPrimitiveSceneSnapshot).IsPublic);
+ Assert.False(typeof(GraphWhiteboardPrimitiveSceneItem).IsPublic);
+ Assert.False(typeof(GraphWhiteboardPrimitiveHitTestResult).IsPublic);
+
+ Assert.Equal(typeof(GraphWhiteboardPrimitive).Assembly, typeof(GraphWhiteboardPrimitiveRendererAdapter).Assembly);
+ Assert.DoesNotContain(
+ typeof(GraphWhiteboardPrimitiveRendererAdapter).Assembly.GetReferencedAssemblies(),
+ assemblyName => assemblyName.Name is "AsterGraph.Editor" or "AsterGraph.Avalonia");
+
+ AssertNoWhiteboardProperty(typeof(GraphDocument));
+ AssertNoWhiteboardProperty(typeof(GraphEditorSceneSnapshot));
+
+ var avaloniaConnectionRenderer = Type.GetType(
+ "AsterGraph.Avalonia.Controls.Internal.NodeCanvasConnectionSceneRenderer, AsterGraph.Avalonia");
+ Assert.NotNull(avaloniaConnectionRenderer);
+ Assert.NotEqual(
+ avaloniaConnectionRenderer!.Assembly,
+ typeof(GraphWhiteboardPrimitiveRendererAdapter).Assembly);
+ }
+
+ private static GraphWhiteboardPrimitive CreateRectanglePrimitive(
+ string id,
+ int zIndex,
+ GraphPoint? origin = null,
+ GraphSize? size = null,
+ GraphWhiteboardPrimitiveEditLifecycle? lifecycle = null)
+ => new(
+ id,
+ GraphWhiteboardPrimitiveKind.Rectangle,
+ new GraphWhiteboardPrimitiveGeometry(
+ origin ?? new GraphPoint(10d, 20d),
+ size ?? new GraphSize(80d, 48d)),
+ new GraphWhiteboardPrimitiveStyle(
+ FillHex: "#F2D45C",
+ StrokeHex: "#222222",
+ StrokeThickness: 1.5d,
+ Opacity: 0.8d),
+ zIndex,
+ lifecycle ?? GraphWhiteboardPrimitiveEditLifecycle.Default);
+
+ private static GraphWhiteboardPrimitive CreateFreehandPrimitive(
+ string id,
+ int zIndex,
+ IReadOnlyList points)
+ => new(
+ id,
+ GraphWhiteboardPrimitiveKind.Freehand,
+ new GraphWhiteboardPrimitiveGeometry(
+ new GraphPoint(30d, 40d),
+ new GraphSize(60d, 40d),
+ points),
+ GraphWhiteboardPrimitiveStyle.Default,
+ zIndex,
+ new GraphWhiteboardPrimitiveEditLifecycle(
+ GraphWhiteboardPrimitiveEditState.Editing,
+ ActiveHandleKey: "freehand-drag"));
+
+ private static void AssertNoWhiteboardProperty(Type type)
+ {
+ Assert.DoesNotContain(
+ type.GetProperties(),
+ property => property.Name.Contains("Whiteboard", StringComparison.OrdinalIgnoreCase)
+ || property.PropertyType.Name.Contains("Whiteboard", StringComparison.OrdinalIgnoreCase));
+ }
+}