@@ -42,10 +42,6 @@ public sealed class AstGraphEditor(AstNode root)
4242
4343 private bool fitted ;
4444
45- private bool fontScaled ;
46-
47- private readonly Dictionary < int , ViewState > viewSpace = [ ] ;
48-
4945 private string ? editingField ;
5046
5147 /// <summary>
@@ -82,21 +78,16 @@ public sealed class AstGraphEditor(AstNode root)
8278 /// Gets or sets how large the graph is drawn, as a multiplier: 1 draws it at its own size.
8379 /// </summary>
8480 /// <remarks>
85- /// A view setting, not a document one. The graph's positions and the layout that arranges them
86- /// are kept at their own scale whatever this is, and the zoom is applied and undone around the
87- /// one frame that draws them — so the simulation is never asked to work in a space that changes
88- /// under it, and a node dragged while zoomed out lands where the pointer was.
89- /// <para>
90- /// The node editor underneath has no zoom of its own, so this is what there is: it scales the
91- /// distances between nodes and the text inside them together, which is what makes a whole graph
92- /// fit on screen rather than merely spreading it out.
93- /// </para>
81+ /// The node editor's own, forwarded rather than held here. It is the renderer that scales node
82+ /// positions on their way into ImNodes and unscales them on the way back, which is the only seam
83+ /// a zoom can sit at when ImNodes has none of its own; a second copy of the value here would only
84+ /// be something to keep in step with it.
9485 /// </remarks>
9586 public float Zoom
9687 {
97- get ;
98- set => field = Math . Clamp ( value , MinZoom , MaxZoom ) ;
99- } = 1f ;
88+ get => renderer . Zoom ;
89+ set => renderer . Zoom = value ;
90+ }
10091
10192 /// <summary>
10293 /// Gets or sets a value indicating whether <see cref="Draw"/> puts the inspector panel beside the
@@ -173,8 +164,6 @@ public void Draw(Vector2 size, float deltaTime)
173164 Vector2 origin = ImGui . GetCursorScreenPos ( ) ;
174165 ImGui . BeginChild ( "ast-graph-canvas" , graphSize , ImGuiChildFlags . None , ImGuiWindowFlags . NoScrollbar | ImGuiWindowFlags . NoScrollWithMouse ) ;
175166
176- EnterViewSpace ( ) ;
177-
178167 renderer . Render ( Graph . Engine , graphSize ) ;
179168
180169 ApplyNodeMovement ( ) ;
@@ -188,8 +177,6 @@ public void Draw(Vector2 size, float deltaTime)
188177 renderer . RenderDebugOverlays ( Graph . Engine , origin , graphSize , showDebug : true ) ;
189178 }
190179
191- LeaveViewSpace ( ) ;
192-
193180 ImGui . EndChild ( ) ;
194181
195182 if ( ShowInspector )
@@ -220,21 +207,6 @@ public void Draw(Vector2 size, float deltaTime)
220207 /// </summary>
221208 private const float MinimumGraphWidth = 160f ;
222209
223- /// <summary>
224- /// The smallest the graph is drawn, as a multiplier of its own size.
225- /// </summary>
226- private const float MinZoom = 0.25f ;
227-
228- /// <summary>
229- /// The largest the graph is drawn, as a multiplier of its own size.
230- /// </summary>
231- private const float MaxZoom = 2f ;
232-
233- /// <summary>
234- /// How much of the canvas a fitted graph is asked to fill, leaving a margin around it.
235- /// </summary>
236- private const float FitMargin = 0.9f ;
237-
238210 /// <summary>
239211 /// The width of the toolbar's zoom slider.
240212 /// </summary>
@@ -287,7 +259,7 @@ private void DrawToolbar()
287259 ImGui . SameLine ( ) ;
288260 ImGui . SetNextItemWidth ( ZoomSliderWidth ) ;
289261 float percent = Zoom * 100f ;
290- if ( ImGui . SliderFloat ( "##zoom" , ref percent , MinZoom * 100f , MaxZoom * 100f , "%.0f%%" ) )
262+ if ( ImGui . SliderFloat ( "##zoom" , ref percent , NodeEditorRenderer . MinZoom * 100f , NodeEditorRenderer . MaxZoom * 100f , "%.0f%%" ) )
291263 {
292264 Zoom = percent / 100f ;
293265 }
@@ -561,113 +533,6 @@ public void Redo()
561533 }
562534 }
563535
564- /// <summary>
565- /// Scales the graph into the space it is drawn in, for the duration of one frame's drawing.
566- /// </summary>
567- /// <remarks>
568- /// The node editor underneath has no zoom, and it takes each node's position straight out of the
569- /// engine, so the only place a zoom can be applied is the engine itself. Doing that permanently
570- /// would put the simulation in a space that changes whenever the user drags the slider — its rest
571- /// length, its repulsion distance and its overlap margin are all lengths, and none of them would
572- /// mean the same thing afterwards.
573- /// <para>
574- /// So the scaling is put on before the frame is drawn and taken off again after, by
575- /// <see cref="LeaveViewSpace"/>. Between those two calls the engine holds view positions, which is
576- /// what the renderer draws and what the user's drag is read back in; outside them it holds the
577- /// graph's own, which is what the layout runs on and what a fit is measured against.
578- /// </para>
579- /// <para>
580- /// Scaled about the world origin — the middle of the canvas — so zooming keeps whatever is in the
581- /// middle of the view in the middle of the view, rather than sending the graph towards a corner.
582- /// The text is scaled to match, because a node's box is sized from the text inside it: without
583- /// that, zooming out would only move the nodes closer together while they stayed the same size,
584- /// which packs them tighter instead of showing more.
585- /// </para>
586- /// </remarks>
587- private void EnterViewSpace ( )
588- {
589- fontScaled = false ;
590- viewSpace . Clear ( ) ;
591-
592- if ( IsUnzoomed )
593- {
594- return ;
595- }
596-
597- Vector2 centre = Graph . Engine . WorldOrigin ;
598- foreach ( Node node in Graph . Engine . Nodes . ToArray ( ) )
599- {
600- Vector2 view = ( ( node . Position - centre ) * Zoom ) + centre ;
601- viewSpace [ node . Id ] = new ViewState ( node . Position , node . Dimensions , view ) ;
602- Graph . Engine . UpdateNodePosition ( node . Id , view ) ;
603- }
604-
605- ImGui . PushFont ( ImGui . GetFont ( ) , ImGui . GetFontSize ( ) * Zoom ) ;
606- fontScaled = true ;
607- }
608-
609- /// <summary>
610- /// Takes the scaling back off, returning the engine to the graph's own space.
611- /// </summary>
612- /// <remarks>
613- /// A node the frame did not touch is put back to exactly the value it had rather than divided by
614- /// the zoom it was multiplied by, and its size is left alone rather than divided at all. That is
615- /// the difference between a transform and its inverse being applied once and being applied every
616- /// frame: a size only arrives from the renderer when it has measured a new one, so dividing them
617- /// unconditionally would divide the same value again on every frame it did not change, and a
618- /// graph left alone for a second would have nodes thousands of times their real size.
619- /// </remarks>
620- private void LeaveViewSpace ( )
621- {
622- if ( fontScaled )
623- {
624- ImGui . PopFont ( ) ;
625- fontScaled = false ;
626- }
627-
628- if ( IsUnzoomed )
629- {
630- return ;
631- }
632-
633- Vector2 centre = Graph . Engine . WorldOrigin ;
634- foreach ( Node node in Graph . Engine . Nodes . ToArray ( ) )
635- {
636- // A node the frame replaced — a rebuild reassigns every identifier — is not one this frame
637- // put into view space, so it is converted rather than restored.
638- bool known = viewSpace . TryGetValue ( node . Id , out ViewState state ) ;
639-
640- Graph . Engine . UpdateNodePosition (
641- node . Id ,
642- known && state . View == node . Position
643- ? state . Position
644- : ( ( node . Position - centre ) / Zoom ) + centre ) ;
645-
646- // Untouched means the renderer reported no new measurement, so what is there is still the
647- // size from before the frame and is already in the graph's own space.
648- if ( ! known || state . Dimensions != node . Dimensions )
649- {
650- Graph . Engine . UpdateNodeDimensions ( node . Id , node . Dimensions / Zoom ) ;
651- }
652- }
653-
654- viewSpace . Clear ( ) ;
655- }
656-
657- /// <summary>
658- /// Gets a value indicating whether the view is at the graph's own scale, where the transform is
659- /// the identity and is skipped rather than applied as one.
660- /// </summary>
661- private bool IsUnzoomed => Math . Abs ( Zoom - 1f ) < 0.0001f ;
662-
663- /// <summary>
664- /// What a node looked like before the frame scaled it, and what it was scaled to.
665- /// </summary>
666- /// <param name="Position">Its position in the graph's own space.</param>
667- /// <param name="Dimensions">Its size in the graph's own space.</param>
668- /// <param name="View">The position it was drawn at, so a value still equal to it is one nothing moved.</param>
669- private readonly record struct ViewState ( Vector2 Position , Vector2 Dimensions , Vector2 View ) ;
670-
671536 /// <summary>
672537 /// Writes back the positions the user dragged nodes to, and the sizes ImNodes measured.
673538 /// </summary>
@@ -781,77 +646,26 @@ public void Add(AstNode node, Vector2 position)
781646 /// </summary>
782647 /// <returns>True if there was anything to bring into view.</returns>
783648 /// <remarks>
784- /// The layout arranges nodes wherever the forces take them, and a user can drag one anywhere, so
785- /// a document can end up off the edge of the view with no clue which way to scroll back. Worse,
786- /// a graph can simply be bigger than the canvas, which no amount of centring fixes.
649+ /// The layout arranges nodes wherever the forces take them, and a user can drag one anywhere, so a
650+ /// document can end up off the edge of the view with no clue which way to scroll back. Worse, a
651+ /// graph can simply be bigger than the canvas, which no amount of centring fixes.
787652 /// <para>
788- /// Centring moves the arrangement rather than the view: the renderer writes each node's position
789- /// into the node editor every frame, so panning the editor is undone as soon as it is read back —
790- /// the positions are the only thing that decides where a node is drawn. The whole arrangement is
791- /// translated, so the shape the layout settled into is preserved rather than being disturbed by
792- /// the act of looking at it.
793- /// </para>
794- /// <para>
795- /// The zoom is then whatever makes the arrangement's own extent fit inside the canvas, with a
796- /// margin so nothing sits against an edge, and never more than <see cref="MaxZoom"/> — a graph
797- /// small enough to be magnified is shown at its own size rather than blown up to fill the room.
798- /// A graph too big even at <see cref="MinZoom"/> is shown as small as the view goes, which is the
799- /// most of it that can be had.
653+ /// The node editor does both parts — it owns the zoom, and centring means moving the nodes, which
654+ /// is its business rather than this application's. What is decided here is the canvas: the world
655+ /// origin is kept on the middle of it, so twice the origin is the whole of it.
800656 /// </para>
801657 /// </remarks>
802658 public bool FitView ( )
803659 {
804- Node [ ] nodes = [ .. Graph . Engine . Nodes ] ;
805- if ( nodes . Length == 0 )
660+ if ( ! renderer . FitToView ( Graph . Engine , Graph . Engine . WorldOrigin * 2f ) )
806661 {
807662 return false ;
808663 }
809664
810- // Measured across each node's whole extent rather than its top-left corner, so a wide node on
811- // one edge does not pull the arrangement off centre by half its width.
812- Vector2 lowest = new ( float . MaxValue , float . MaxValue ) ;
813- Vector2 highest = new ( float . MinValue , float . MinValue ) ;
814-
815- foreach ( Node node in nodes )
816- {
817- lowest = Vector2 . Min ( lowest , node . Position ) ;
818- highest = Vector2 . Max ( highest , node . Position + node . Dimensions ) ;
819- }
820-
821- Vector2 offset = Graph . Engine . WorldOrigin - ( ( lowest + highest ) * 0.5f ) ;
822- foreach ( Node node in nodes )
823- {
824- Graph . Engine . UpdateNodePosition ( node . Id , node . Position + offset ) ;
825- }
826-
827- // The origin is kept on the middle of the canvas, so the canvas is twice it.
828- Zoom = FittingZoom ( highest - lowest , Graph . Engine . WorldOrigin * 2f ) ;
829-
830665 statusMessage = "Brought the graph into view." ;
831666 return true ;
832667 }
833668
834- /// <summary>
835- /// Works out the largest zoom an arrangement still fits the canvas at.
836- /// </summary>
837- /// <param name="extent">How much room the arrangement takes at its own scale.</param>
838- /// <param name="canvas">The room there is to show it in.</param>
839- /// <returns>The zoom to use, within the range the view allows.</returns>
840- /// <remarks>
841- /// Never above one, so fitting only ever zooms out. Magnifying a small graph to fill the canvas
842- /// would be a surprising answer to "fit": the user asked to see all of it, and they already can.
843- /// </remarks>
844- private static float FittingZoom ( Vector2 extent , Vector2 canvas )
845- {
846- if ( extent . X <= 0f || extent . Y <= 0f || canvas . X <= 0f || canvas . Y <= 0f )
847- {
848- return 1f ;
849- }
850-
851- float fitting = Math . Min ( canvas . X / extent . X , canvas . Y / extent . Y ) * FitMargin ;
852- return Math . Clamp ( Math . Min ( fitting , 1f ) , MinZoom , MaxZoom ) ;
853- }
854-
855669 /// <summary>
856670 /// Selects a node, so the inspector shows it and the graph highlights it.
857671 /// </summary>
0 commit comments