//	Draw4DGestures.swift
//
//	© 2025 by Jeff Weeks
//	See TermsOfUse.txt

import SwiftUI


let maxRotationalSpeed: Double = 4.0	//	radians/second
let minRotationalSpeed: Double = 0.1	//	radians/second, to suppress any small unintended motions

let maxTranslationalSpeed: Double = 4.0	//	radians/second
let minTranslationalSpeed: Double = 0.1	//	radians/second, to suppress any small unintended motions
let minFrameθ: Double = minTranslationalSpeed * gFramePeriod
let maxFrameθ: Double = maxTranslationalSpeed * gFramePeriod

let π = Double.pi


// MARK: -
// MARK: Drag gesture

//	In .movePoints mode,
//		if a drag begins on a point, the drag moves that point, but
//		if a drag begins elsewhere, the drag rotates the drawing.
//
//	In .addPoints move, when a drag begins on a wall
//		it creates a new point, but thereafter that drag
//		moves the newly created point, just as if
//		it were in .movePoints mode.
//
//	Given this lack of a one-to-one correspondence
//	between the TouchMode and the purpose of the drag,
//	we must decide (and remember) a drag's purpose
//	when it first begins.
//
enum Draw4DDragPurpose {

	case unknown
	case toMovePoint
	case toRotateFigure
	case failed
}

struct Draw4DDragState {

	var purpose: Draw4DDragPurpose = .unknown

	//	While moving itsSelectedPoint, keep track of
	//	which axis we're moving it along, and
	//	how far we've moved it along that axis.
	//
	//		Note: When we first set movePointAxis
	//		we'll sumbit a zero-length translation,
	//		so that dragToMovePointChanged() can always
	//		undo the previous translation before submitting
	//		a new one.
	//
	var movePointAxis: Int? = nil	//	∈ {0,1,2,3}
	
	//	The projection of the translation axis
	//	onto the plane of the display, normalized to unit length.
	var projectedDirection: SIMD2<Double>? = nil
	
	//	While rotating the figure, keep track
	//	of the previous drag point on the unit sphere.
	var previousPoint: SIMD3<Double>? = nil	//	on unit sphere
}


func draw4DDragGesture(
	modelData: Draw4DDocument,
	viewSize: CGSize,
	snapToGridIsEnabled: Bool,
	dragState: GestureState<Draw4DDragState>
) -> some Gesture {

	//	SwiftUI will clear the dragState before calling onEnded,
	//	so let's keep a separate copy of the purpose for onEnded to use.
	var thePurposeCopy = dragState.wrappedValue.purpose

	let theDragGesture = DragGesture()
	.updating(dragState) { value, theDragState, transaction in
	
		switch theDragState.purpose {

		case .unknown:
		
			dragBegan(
				dragState: &theDragState,
				modelData: modelData,
				startLocation: value.startLocation,
				viewSize: viewSize)

		case .toMovePoint:

			dragToMovePointChanged(
				dragState: &theDragState,
				modelData: modelData,
				translation: value.translation,
				viewSize: viewSize)

		case .toRotateFigure:

			dragToRotateFigureChanged(
				dragState: &theDragState,
				modelData: modelData,
				startLocation: value.startLocation,
				location: value.location,
				viewSize: viewSize)

		case .failed:
			break
			
		}
		
		thePurposeCopy = theDragState.purpose
	}
	.onEnded() {value in

		//	Caution:  This onEnded callback gets called
		//	when the gesture ends normally, but not when,
		//	say, a DragGesture gets interrupted when
		//	the user places a second finger on the display.

		switch thePurposeCopy {

		case .toMovePoint:
		
			dragToMovePointEnded(
				modelData: modelData,
				snapToGridIsEnabled: snapToGridIsEnabled)

		case .toRotateFigure:

			dragToRotateFigureEnded(
				modelData: modelData,
				location: value.location,
				velocity: value.velocity,
				viewSize: viewSize)

		case .unknown, .failed:
			break
			
		}
		
		thePurposeCopy = .unknown
	}
	
	return theDragGesture
}

func dragBegan(
	dragState: inout Draw4DDragState,
	modelData: Draw4DDocument,
	startLocation: CGPoint,
	viewSize: CGSize
) {

	let theHitTestRay = hitTestRayInModelCoordinates(
							touchPoint: startLocation,
							viewSize: viewSize,
							orientation: modelData.itsOrientation)

	switch modelData.itsTouchMode {
	
	case .neutral:
		
		dragState.purpose = .toRotateFigure
		
	case .movePoints, .addPoints:

		//	If a drag gesture begins on an existing point,
		//	treat it exactly the same in .addPoints mode
		//	as in .movePoints mode.
		if let theHitPoint = findHitPoint(
								ray: theHitTestRay,
								points: modelData.itsPoints) {
		
			//	We can now commit to dragging the point,
			//	but we should wait until the user's finger
			//	has traveled a non-trivial distance before
			//	deciding what axis to drag it along.
			modelData.itsSelectedPoint = theHitPoint
			
			dragState.purpose = .toMovePoint

		} else {	//	No point was hit

			if modelData.itsTouchMode == .movePoints {
			
				dragState.purpose = .toRotateFigure
			
			} else {	//	.addPoints
			
				//	Did the user touch a wall?
				if let (thePosition, theAxis, theProjectionDirection)
					= findWallHitPosition(
						ray: theHitTestRay,
						orientation: modelData.itsOrientation) {

					//	Create a new point.
					let theNewPoint = Draw4DPoint(
						at: simd_quatd(vector: clampToUnitCube(thePosition)))
					modelData.addPoint(theNewPoint)
				
					//	Let the user move that point outward from the wall.
					modelData.itsSelectedPoint = theNewPoint
					dragState.purpose = .toMovePoint
					dragState.movePointAxis = theAxis
					dragState.projectedDirection = theProjectionDirection

					//	Put a zero-distance translation onto the Undo stack,
					//	so dragToMovePointChanged() will have something
					//	to undo before it submits an up-to-date translation.
					//
					//		Note: Draw4DDocument's doSomethingUndoably()
					//		puts this movePoint operation into a separate
					//		undo group from the addPoint operation that
					//		we submitted a few lines above. This allows
					//		dragToMovePointChanged() to undo the movePoint
					//		with undoing the addPoint.
					//
					modelData.movePoint(
								theNewPoint,
								to: theNewPoint.itsPosition)
				
				} else {
				
					//	The user touched neither an existing point nor a wall
					//	(meaning the user touched the black background),
					//	so rotate the whole figure.

					dragState.purpose = .toRotateFigure
				}
			}
		}
		
	case .deletePoints:

		//	Only a tap (not a drag) can delete a point.
		//	draw4DTapGesture() handles such taps.
		
		dragState.purpose = .toRotateFigure
		
	case .addEdges:

		//	Only taps (not drags) can select the endpoints
		//	for a new edge. draw4DTapGesture() handles such taps.
		
		dragState.purpose = .toRotateFigure
		
	case .deleteEdges:

		//	Only a tap (not a drag) can delete an edge.
		//	draw4DTapGesture() handles such taps.
		
		dragState.purpose = .toRotateFigure

	}

	//	Suppress the usual per-frame increment
	//	while the user is moving a point or rotating the figure.
	modelData.itsIncrement = nil
}

func dragToMovePointChanged(
	dragState: inout Draw4DDragState,
	modelData: Draw4DDocument,
	translation: CGSize,
	viewSize: CGSize
) {

	guard let theSelectedPoint = modelData.itsSelectedPoint else {
		assertionFailure("itsSelectedPoint is unexpectedly nil in dragToMovePointChanged")
		dragState.purpose = .failed
		return
	}

	//	Do we still need to pick a point-in-motion axis and a projected direction?
	if dragState.projectedDirection == nil {
	
		//	If the user's finger has traveled a non-trivial distance,
		//	we may decide what axis to drag the point along.
		let theDragDirection = SIMD2<Double>(
								 translation.width,
								-translation.height)	//	flip the y component
		let theDragDistance = length(theDragDirection)
		let theMinimumNontrivialDistance = 8.0	//	in points
		if theDragDistance > theMinimumNontrivialDistance {

			let (theAxis, theNewlyFoundProjectedDirection)
							= chooseMovePointAxis(
								hitPointPosition: theSelectedPoint.itsPosition,
								dragDirection: theDragDirection,
								orientation: modelData.itsOrientation)
		
			dragState.movePointAxis = theAxis
			dragState.projectedDirection = theNewlyFoundProjectedDirection

			//	Put a zero-distance translation onto the Undo stack,
			//	so we'll have something to undo when we later submit
			//	updated translations as the user continues to drag
			//	the point.
			modelData.movePoint(
						theSelectedPoint,
						to: theSelectedPoint.itsPosition)
			
		} else {
		
			//	Wait patiently for the user to move their finger a little further.
			return
		}
	}

	guard let theMovePointAxis = dragState.movePointAxis else {
		assertionFailure("movePointAxis is unexpectedly nil")
		dragState.purpose = .failed
		return
	}
	guard let theProjectedDirection = dragState.projectedDirection else {
		assertionFailure("projectedDirection is unexpectedly nil")
		dragState.purpose = .failed
		return
	}

	let theRawTranslation = mapTranslationToIntrinsicCoordinates(
								translation,
								viewSize: viewSize)
	
	let theTranslationDistance = dot(theRawTranslation, theProjectedDirection)

	//	Undo the previous movePoint(),
	modelData.undoLastChange()

	//	compute theSelectedPoint's new position,
	var thePosition = theSelectedPoint.itsPosition.vector
	thePosition[theMovePointAxis] += theTranslationDistance
	thePosition[theMovePointAxis]
		= max(min(thePosition[theMovePointAxis], +1.0), -1.0)
		//	See comment accompanying the definition of gBoxSize
		//	for an explanation of why we clamp to [-1.0, +1.0]
		//	instead of [-gBoxSize, +gBoxSize].

	//	and apply it (undoably!).
	modelData.movePoint(
				theSelectedPoint,
				to: simd_quatd(vector: thePosition))
}

func dragToMovePointEnded(
	modelData: Draw4DDocument,
	snapToGridIsEnabled: Bool
) {

	//	Caution: If a DragGesture gets interrupted,
	//	the .onEnded() closure doesn't get called,
	//	so this dragToMovePointEnded() doesn't get
	//	called and the point doesn't snap to the grid.

	guard let theSelectedPoint = modelData.itsSelectedPoint else {
		assertionFailure("itsSelectedPoint is unexpectedly nil in dragToMovePointEnded")
		return
	}

	if snapToGridIsEnabled {
	
		let theUnsnappedPosition = theSelectedPoint.itsPosition.vector
		let theSnappedPosition = snapToGrid(theUnsnappedPosition)

		modelData.undoLastChange()
		modelData.movePoint(
			theSelectedPoint,
			to: simd_quatd(vector: theSnappedPosition))
	}
}

func dragToRotateFigureChanged(
	dragState: inout Draw4DDragState,
	modelData: Draw4DDocument,
	startLocation: CGPoint,
	location: CGPoint,
	viewSize: CGSize
) {

	//	On the first call to dragToRotateFigureChanged()
	//	during a given drag, the previousPoint will be nil
	//	and we'll use the drag's startLocation instead.
	//	Thereafter we'll store the point from one call
	//	for use in the next.
	//
	let p₀ = dragState.previousPoint ??
		mapToUnitSphere(touchPoint: startLocation, viewSize: viewSize)

	let p₁ = mapToUnitSphere(touchPoint: location, viewSize: viewSize)

	let (theAxis, θ) = parallelTransportAxisAndAngle(p₀: p₀, p₁: p₁)
	let theIncrement = simd_quatd(angle: θ, axis: theAxis)

	modelData.itsOrientation
		= simd_normalize( theIncrement * modelData.itsOrientation )

	//	Update thePreviousPoint for use in the next call to updating().
	dragState.previousPoint = p₁
}

func dragToRotateFigureEnded(
	modelData: Draw4DDocument,
	location: CGPoint,
	velocity: CGSize,
	viewSize: CGSize
) {

	//	If itsOriention is almost axis aligned, snap to perfect alignment.
	if let theAxisAlignedOrientation
		= snapOrientationToAxes(modelData.itsOrientation) {
	
		modelData.itsOrientation = theAxisAlignedOrientation
		modelData.itsIncrement = nil
		
	} else {

		let theTouchPoint₀ = location
		let theTouchPoint₁ = CGPoint(
			x: location.x + gFramePeriod * velocity.width,
			y: location.y + gFramePeriod * velocity.height)
	
		let p₀ = mapToUnitSphere(touchPoint: theTouchPoint₀, viewSize: viewSize)
		let p₁ = mapToUnitSphere(touchPoint: theTouchPoint₁, viewSize: viewSize)

		let (theAxis, θ) = parallelTransportAxisAndAngle(p₀: p₀, p₁: p₁)

		let theClampedθ = min(θ, maxFrameθ)

		//	If the user tries to stop the motion at the end of a drag,
		//	but leaves some some residual motion ( θ < minFrameθ ),
		//	set theIncrement to nil to stop the motion entirely.
		//
		let theIncrement = theClampedθ > minFrameθ ?
							simd_quatd(angle: theClampedθ, axis: theAxis) :
							nil
		
		modelData.itsIncrement = theIncrement
	}
				
	if gGetScreenshotOrientations {
		print(modelData.itsOrientation)
		modelData.itsIncrement = nil	//	Suppress inertia
	}
}


// MARK: -
// MARK: Coordinates

func mapTouchPointToIntrinsicCoordinates(
	_ touchPoint: CGPoint,	//	0 ≤ touchPoint.x|y ≤ viewSize.width|height
	viewSize: CGSize
) -> SIMD3<Double> {		//	(x, y, -gBoxSize) in intrinsic coordinates

	//	Shift the coordinates to place the origin at the center of the view.
	var x = touchPoint.x - 0.5 * viewSize.width
	var y = touchPoint.y - 0.5 * viewSize.height

	//	Flip from iOS's Y-down coordinates
	//	to Metal's Y-up coordinates.
	y = -y

	//	Convert the coordinates to intrinsic units (IU).
	let theIntrinsicUnitsPerPixelOrPoint = intrinsicUnitsPerPixelOrPoint(
												viewWidth: viewSize.width,
												viewHeight: viewSize.height)
	x *= theIntrinsicUnitsPerPixelOrPoint
	y *= theIntrinsicUnitsPerPixelOrPoint
	
	return SIMD3<Double>(x, y, -gBoxSize)
}

func mapTranslationToIntrinsicCoordinates(
	_ translation: CGSize,	//	in points
	viewSize: CGSize
) -> SIMD2<Double> {		//	in intrinsic coordinates

	var Δx = translation.width
	var Δy = translation.height

	//	Flip from iOS's Y-down coordinates
	//	to Metal's Y-up coordinates.
	Δy = -Δy

	//	Convert to intrinsic units (IU).
	let theIntrinsicUnitsPerPixelOrPoint = intrinsicUnitsPerPixelOrPoint(
												viewWidth: viewSize.width,
												viewHeight: viewSize.height)
	Δx *= theIntrinsicUnitsPerPixelOrPoint
	Δy *= theIntrinsicUnitsPerPixelOrPoint
	
	return SIMD2<Double>(Δx, Δy)
}

func snapToGrid(
	_ point: SIMD4<Double>
) -> SIMD4<Double> {

	var theSnappedPoint = SIMD4<Double>.zero
	
	for i in 0...3 {
		theSnappedPoint[i] = (point[i] / gGridSpacing).rounded() * gGridSpacing
	}
	
	return theSnappedPoint
}

func clampToUnitCube(
	_ point: SIMD4<Double>
) -> SIMD4<Double> {

	var theClampedPoint = SIMD4<Double>.zero
	
	for i in 0...3 {
		theClampedPoint[i] = min(+1.0, max(-1.0, point[i]))
	}
	
	return theClampedPoint
}

func snapOrientationToAxes(
	_ orientation: simd_quatd
) -> simd_quatd? {	//	returns nil if orientation isn't already close to axis-aligned

	//	The axis-aligned orientations of a cube correspond
	//	exactly to the elements of the group Isom(cube).
	//	Because we record the cube's orientation as a quaternion,
	//	we must list the elements of the corresponding "binary" group
	//	which maps 2-to-1 onto Isom(cube).
	//	The group Isom(cube) == Isom(octahedron) is most commonly
	//	called the octahedral group, and its 2-fold cover
	//	is the binary octahedral group.
	
	let rhf = 0.70710678118654752440	//	√½	(rhf = Root of one HalF)

	let theAxisAlignedOrientations: [simd_quatd] = [

		//	identity
		simd_quatd(ix: 0.0, iy: 0.0, iz: 0.0, r: 1.0), simd_quatd(ix: 0.0, iy: 0.0, iz: 0.0, r:-1.0),
		
		//	2-fold rotations about face centers
		simd_quatd(ix: 1.0, iy: 0.0, iz: 0.0, r: 0.0), simd_quatd(ix:-1.0, iy: 0.0, iz: 0.0, r: 0.0),
		simd_quatd(ix: 0.0, iy: 1.0, iz: 0.0, r: 0.0), simd_quatd(ix: 0.0, iy:-1.0, iz: 0.0, r: 0.0),
		simd_quatd(ix: 0.0, iy: 0.0, iz: 1.0, r: 0.0), simd_quatd(ix: 0.0, iy: 0.0, iz:-1.0, r: 0.0),
		
		//	2-fold rotations about edge centers
		simd_quatd(ix:-rhf, iy:-rhf, iz: 0.0, r: 0.0), simd_quatd(ix: rhf, iy: rhf, iz: 0.0, r: 0.0),
		simd_quatd(ix:-rhf, iy: rhf, iz: 0.0, r: 0.0), simd_quatd(ix: rhf, iy:-rhf, iz: 0.0, r: 0.0),
		simd_quatd(ix: 0.0, iy:-rhf, iz:-rhf, r: 0.0), simd_quatd(ix: 0.0, iy: rhf, iz: rhf, r: 0.0),
		simd_quatd(ix: 0.0, iy:-rhf, iz: rhf, r: 0.0), simd_quatd(ix: 0.0, iy: rhf, iz:-rhf, r: 0.0),
		simd_quatd(ix:-rhf, iy: 0.0, iz:-rhf, r: 0.0), simd_quatd(ix: rhf, iy: 0.0, iz: rhf, r: 0.0),
		simd_quatd(ix: rhf, iy: 0.0, iz:-rhf, r: 0.0), simd_quatd(ix:-rhf, iy: 0.0, iz: rhf, r: 0.0),
		
		//	3-fold rotations about vertices
		simd_quatd(ix:-0.5, iy:-0.5, iz:-0.5, r: 0.5), simd_quatd(ix: 0.5, iy: 0.5, iz: 0.5, r:-0.5),
		simd_quatd(ix:-0.5, iy:-0.5, iz: 0.5, r: 0.5), simd_quatd(ix: 0.5, iy: 0.5, iz:-0.5, r:-0.5),
		simd_quatd(ix:-0.5, iy: 0.5, iz:-0.5, r: 0.5), simd_quatd(ix: 0.5, iy:-0.5, iz: 0.5, r:-0.5),
		simd_quatd(ix:-0.5, iy: 0.5, iz: 0.5, r: 0.5), simd_quatd(ix: 0.5, iy:-0.5, iz:-0.5, r:-0.5),
		simd_quatd(ix: 0.5, iy:-0.5, iz:-0.5, r: 0.5), simd_quatd(ix:-0.5, iy: 0.5, iz: 0.5, r:-0.5),
		simd_quatd(ix: 0.5, iy:-0.5, iz: 0.5, r: 0.5), simd_quatd(ix:-0.5, iy: 0.5, iz:-0.5, r:-0.5),
		simd_quatd(ix: 0.5, iy: 0.5, iz:-0.5, r: 0.5), simd_quatd(ix:-0.5, iy:-0.5, iz: 0.5, r:-0.5),
		simd_quatd(ix: 0.5, iy: 0.5, iz: 0.5, r: 0.5), simd_quatd(ix:-0.5, iy:-0.5, iz:-0.5, r:-0.5),
		
		//	4-fold rotations about face centers
		simd_quatd(ix:-rhf, iy: 0.0, iz: 0.0, r: rhf), simd_quatd(ix: rhf, iy: 0.0, iz: 0.0, r:-rhf),
		simd_quatd(ix: rhf, iy: 0.0, iz: 0.0, r: rhf), simd_quatd(ix:-rhf, iy: 0.0, iz: 0.0, r:-rhf),
		simd_quatd(ix: 0.0, iy:-rhf, iz: 0.0, r: rhf), simd_quatd(ix: 0.0, iy: rhf, iz: 0.0, r:-rhf),
		simd_quatd(ix: 0.0, iy: rhf, iz: 0.0, r: rhf), simd_quatd(ix: 0.0, iy:-rhf, iz: 0.0, r:-rhf),
		simd_quatd(ix: 0.0, iy: 0.0, iz:-rhf, r: rhf), simd_quatd(ix: 0.0, iy: 0.0, iz: rhf, r:-rhf),
		simd_quatd(ix: 0.0, iy: 0.0, iz: rhf, r: rhf), simd_quatd(ix: 0.0, iy: 0.0, iz:-rhf, r:-rhf)
	]

	//	At the end of a rotation, how close must itsOrientation
	//	be to the nearest axis-aligned orientation in order
	//	to trigger a snap-to-axis?
	let theSnapToAxisTolerance = 0.999

	//	The given orientation will closely align
	//	with at most one of theAxisAlignedOrientations
	let theSnappedOrientation = theAxisAlignedOrientations.first(where: { alignedOrientation in
		dot(orientation.vector, alignedOrientation.vector) > theSnapToAxisTolerance })
	
	return theSnappedOrientation
}


// MARK: -
// MARK: Hit testing

//	When the user taps the display with his/her finger,
//	we'll need to decide which object (if any) gets hit.
//	Interpret the tap as a ray that begins at the user's eye
//
//		p₀ = (0.0, 0.0, -gEyeDistance) in world coordinates
//
//	passes through the selected point
//
//		p₁ = ( x,   y,    -gBoxSize  ) in world coordinates
//
//	and continues into the scene.
//
struct HitTestRay {

	//	Parameterize the ray as
	//
	//			p₀ + t(p₁ - p₀)
	//
	let p₀: SIMD3<Double>
	let p₁: SIMD3<Double>
	
	func rotated(by rotation: simd_quatd) -> HitTestRay {
	
		return HitTestRay(
			p₀: rotation.act(p₀),
			p₁: rotation.act(p₁))
	}
}
	
func hitTestRayInModelCoordinates(
	touchPoint: CGPoint,
	viewSize: CGSize,
	orientation: simd_quatd
) -> HitTestRay {

	let theSurfacePoint = mapTouchPointToIntrinsicCoordinates(touchPoint, viewSize: viewSize)
	
	let theHitTestRayInWorldCoordinates = HitTestRay(
		p₀: gEyePositionInWorldCoordinates,
		p₁: theSurfacePoint)
		
	let theHitTestRayInModelCoordinates
		= theHitTestRayInWorldCoordinates.rotated(by: orientation.inverse)

	return theHitTestRayInModelCoordinates
}

func findHitPoint(
	ray: HitTestRay,	//	in model coordinates
	points: [Draw4DPoint]
) -> Draw4DPoint? {
	
	//	First try to find a hit point using the actual gNodeRadius.
	//	If that fails, try again using a larger "halo radius",
	//	which recognizes a hit even if the user's finger
	//	is merely close to a node without actually touching it.
	//
	//		Note:  It's important to check for an actual hit
	//		before testing for a "halo hit", because we don't
	//		want a nearer point's halo to eclipse a further point's
	//		actual node.
	//
	
	let theHaloRadius = 2.0 * gNodeRadius

	let theNearestHitPoint: Draw4DPoint?
	if let theActualHitPoint = findHitPoint(
									ray: ray,
									points: points,
									nodeRadius: gNodeRadius) {
									
		theNearestHitPoint = theActualHitPoint
		
	} else if let theHaloHitPoint = findHitPoint(
									ray: ray,
									points: points,
									nodeRadius: theHaloRadius) {
									
		theNearestHitPoint = theHaloHitPoint
		
	} else {
		
		theNearestHitPoint = nil
	}
	
	return theNearestHitPoint
}
	
func findHitPoint(
	ray: HitTestRay,	//	in model coordinates
	points: [Draw4DPoint],
	nodeRadius: Double
) -> Draw4DPoint? {

	var theNearestHitPoint: Draw4DPoint? = nil
	var theNearestHitT = Double.greatestFiniteMagnitude
		//	= value of t in the definition of HitTestRay
	
	for thePoint in points {
	
		if let t = findPointHitTime(
					ray: ray,
					position: thePoint.itsPosition,
					nodeRadius: nodeRadius) {
		
			if t < theNearestHitT {
			
				theNearestHitPoint = thePoint
				theNearestHitT = t
			}
		}
	}
	
	return theNearestHitPoint
}

func findPointHitTime(
	ray: HitTestRay,	//	in model coordinates
	position: simd_quatd,
	nodeRadius: Double
) -> Double? {	//	If the ray hits the ball, returns the hit time parameter t;
				//	otherwise returns nil

	//	The Draw4DRenderer draws each point with an offset
	//	proportional to its w coordinate.
	let theOffset = position.real * g4DShearFactor
	let theOffsetCenter = position.imag
							+ SIMD3<Double>(theOffset, theOffset, theOffset)

	//	Let
	//		P(t) = P₀ + t(P₁ - P₀)
	//
	//		Q = node's center
	//
	//		r = node's radius
	//
	//	We seek a value of t for which
	//
	//		| P(t) - Q |² = r²
	//
	//	Following our nose gives
	//
	//		| P₀ + t(P₁ - P₀) - Q |² = r²
	//
	//		| (P₀ - Q) + t(P₁ - P₀) |² = r²
	//
	//	Rewriting in terms of
	//
	//		u = P₀ - Q
	//		v = P₁ - P₀
	//
	let u = ray.p₀ - theOffsetCenter
	let v = ray.p₁ - ray.p₀
	//
	//	gives
	//
	//		| u + t v |² = r²
	//
	//	which expands to
	//
	//		u·u + 2(u·v)t + (v·v)t² = r²
	//
	//	which is simply a quadratic equation
	//
	//		a t² + b t + c = 0
	//
	//	with coefficients
	//
	//		a = v·v
	//		b = 2(u·v)
	//		c = u·u - r²
	//
	let a = dot(v,v)
	let b = 2.0 * dot(u,v)
	let c = dot(u,u) - nodeRadius * nodeRadius

	//	Solve a t² + b t + c = 0 using the quadratic formula
	//
	//		    -b ± sqrt(b² - 4ac)
	//		t = -------------------
	//		            2a
	//
	let theDiscriminant = b*b - 4.0*a*c
	if theDiscriminant >= 0.0
	{
		//	We know P₁ ≠ P₀, so
		//
		//		a = |v|² = |P₁ - P₀|² > 0
		//
		precondition(
			a > 0,
			"Internal error:  non-positive leading coefficient in quadratic equation")
		
		//	Report the smaller solution, which corresponds
		//	to the intersection point closer to the observer.
		//
		let t = (-b - sqrt(theDiscriminant))
			  / (2.0 * a)	//	a > 0 for reason shown above

		return t
		
	} else {
	
		//	The ray does not intersect the sphere.
		return nil
	}
}

func findHitEdge(
	ray: HitTestRay,	//	in model coordinates
	edges: [Draw4DEdge]
) -> Draw4DEdge? {

	//	First try to find a hit edge using the actual gTubeRadius.
	//	If that fails, try again using a larger "halo radius",
	//	which recognizes a hit even if the user's finger
	//	is merely close to a tube without actually touching it.
	//
	//		Note:  It's important to check for an actual hit
	//		before testing for a "halo hit", because we don't
	//		want a nearer edge's halo to eclipse a further edge's
	//		actual tube.
	//
	
	let theHaloRadius = 2.0 * gTubeRadius

	let theNearestHitEdge: Draw4DEdge?
	if let theActualHitEdge = findHitEdge(
									ray: ray,
									edges: edges,
									tubeRadius: gTubeRadius) {
									
		theNearestHitEdge = theActualHitEdge
		
	} else if let theHaloHitEdge = findHitEdge(
									ray: ray,
									edges: edges,
									tubeRadius: theHaloRadius) {
									
		theNearestHitEdge = theHaloHitEdge
		
	} else {
		
		theNearestHitEdge = nil
	}
	
	return theNearestHitEdge
}

func findHitEdge(
	ray: HitTestRay,	//	in model coordinates
	edges: [Draw4DEdge],
	tubeRadius: Double
) -> Draw4DEdge? {

	var theNearestHitEdge: Draw4DEdge? = nil
	var theNearestHitT	//	= value of t in the definition of HitTestRay
		 = Double.greatestFiniteMagnitude
	
	for theEdge in edges {
	
		if let t = findEdgeHitTime(
					ray: ray,
					edgeStartPosition: theEdge.itsStart.itsPosition,
					edgeEndPosition: theEdge.itsEnd.itsPosition,
					tubeRadius: tubeRadius) {
		
			if t < theNearestHitT {
			
				theNearestHitEdge = theEdge
				theNearestHitT = t
			}
		}
	}
	
	return theNearestHitEdge
}

func findEdgeHitTime(
	ray: HitTestRay,	//	in model coordinates
	edgeStartPosition: simd_quatd,
	edgeEndPosition: simd_quatd,
	tubeRadius: Double
) -> Double? {	//	If the ray hits the cylinder, returns the hit time parameter s;
				//	otherwise returns nil

	//	For brevity, rewrite the ray's parameterization as
	//
	//		P = p₀ + s*(p₁ - p₀)
	//
	//	Note:  Here we use 's', not 't'.
	//
	let p₀ = ray.p₀
	let p₁ = ray.p₁

	//	Parameterize the edge (and the line that contains it) as
	//
	//		Q = q₀ + t*(q₁ - q₀)
	//
	//	Note: Draw4DRenderer draws each point with an offset
	//	proportional to its w coordinate, so we need to take
	//	that offset into account here too.
	//
	let theStartOffset = edgeStartPosition.real * g4DShearFactor
	let theEndOffset   = edgeEndPosition.real   * g4DShearFactor
	let q₀ = edgeStartPosition.imag
				+ SIMD3<Double>(theStartOffset, theStartOffset, theStartOffset)
	let q₁ = edgeEndPosition.imag
				+ SIMD3<Double>(theEndOffset, theEndOffset, theEndOffset)

	//	Our plan is to find the values of s and t that
	//	minimize the squared distance between a point
	//	on the ray and a point on the edge.
	//
	//	Let v be the vector running from P to Q
	//
	//		v = Q - P
	//		  = (q₀ + t*(q₁ - q₀)) - (p₀ + s*(p₁ - p₀))
	//		  = (q₀ - p₀) + t*(q₁ - q₀) - s*(p₁ - p₀)
	//
	//	For simplicity, pre-compute the constants as
	//
	let α = q₀ - p₀
	let β = p₀ - p₁	//	note reversed order
	let γ = q₁ - q₀
	//
	//	so the formula for v becomes
	//
	//		v = α + βs + γt
	//
	//	The squared distance d from Q to P is given by the dot product
	//
	//		d = v·v
	//		  = (α + βs + γt)·(α + βs + γt)
	//
	//	Assuming the ray isn't parallel to the edge,
	//	distSq takes it minimum value where
	//	the partial derivatives ∂d/∂s and ∂d/∂t are zero.
	//	Let's compute those partial derivatives
	//	using the product rule (which works for dot products
	//	the same as for scalar products).
	//
	//		∂d/∂s = β·(α + βs + γt) + (α + βs + γt)·β
	//		      = 2 β·(α + βs + γt)
	//
	//		∂d/∂t = γ·(α + βs + γt) + (α + βs + γt)·γ
	//		      = 2 γ·(α + βs + γt)
	//
	//	Setting the partial derivatives equal to zero
	//	gives two linear equations in two variables
	//
	//		β·(α + βs + γt) = 0
	//		γ·(α + βs + γt) = 0
	//	=>
	//		β·α + β·β s + β·γ t = 0
	//		γ·α + γ·β s + γ·γ t = 0
	//	=>
	//		β·β s + β·γ t = -β·α
	//		γ·β s + γ·γ t = -γ·α
	//
	//	which we may write as a matrix equation
	//
	//		( β·β  β·γ )(s) = (-α·β)
	//		( β·γ  γ·γ )(t)   (-α·γ)
	//
	//	This equation will have a unique solution
	//	iff the determinant of the matrix is non-zero
	//
	//		det = (β·β)(γ·γ) - (β·γ)(β·γ) ≠ 0
	//
	let ββ = dot(β,β)
	let γγ = dot(γ,γ)
	let αβ = dot(α,β)
	let αγ = dot(α,γ)
	let βγ = dot(β,γ)
	let theDeterminant = ββ*γγ - βγ*βγ
	//
	//	Thinking of the vector β (resp. γ) as a constant b (resp. g)
	//	times a unit vector, it's not too hard to see that
	//
	//		theDeterminant = b²g²(1 - cos(θ))
	//
	//	where θ is the angle between the vectors β and γ.
	//	The constants b and g are both on the order of 1,
	//	while for small θ,
	//
	//		1 - cos(θ) ≈ 1 - (1 - θ²/2)
	//				   = θ²/2
	//
	//	So if we want to consider all hit test rays that
	//	sit at an angle of, say, ~ 0.01 radians or greater
	//	relative to the edge, we may require theDeterminant
	//	to be 0.0001 or greater.
	//
	if theDeterminant < 0.0001 {
		return nil
	}
	//
	//	Given theDeterminant ≠ 0, the solution for (s,t) is
	//
	//		(s) = ( γ·γ -β·γ )(-α·β) / det
	//		(t)   (-β·γ  β·β )(-α·γ)
	//
	let s = -(  γγ*αβ - βγ*αγ ) / theDeterminant
	let t = -( -βγ*αβ + ββ*αγ ) / theDeterminant

	//	If 0 ≤ t ≤ 1, then the point where the ray comes closest
	//	lies on the edge itself, rather than elsewhere on the line
	//	that contains the edge, so we should accept it.
	//
	//		Note:  By rejecting t < 0 or t > 1,
	//		we may, in rare cases, reject an edge
	//		when the ray comes closest to the containing line
	//		at a point outside the edge itself, but
	//		then nevertheless continues on to pass
	//		sufficiently close to the edge itself.
	//		Such rare false negatives are acceptable,
	//		because they occur in situation where
	//		the user might not expect the hit test ray
	//		to touch the given edge.
	//
	if t < 0.0 || t > 1.0 {
		return nil
	}
	
	//	Does the ray come sufficiently close to the edge?
	let v = α + s*β + t*γ
	let theDistanceSquared = dot(v,v)
	if theDistanceSquared <= tubeRadius * tubeRadius {
		return s
	} else {
		return nil
	}
}

func findWallHitPosition(
	ray: HitTestRay,	//	in model coordinates
	orientation: simd_quatd
) -> (SIMD4<Double>, Int, SIMD2<Double>)? {
	//	returns (wall hit position in model coordinates,
	//			 axis ∈ {0,1,2} orthogonal to wall,
	//			 projection of axis onto plane of display,
	//				normalized to unit length)

	//	theRayVector will always have length
	//	at least gPerspectiveFactor * gBoxSize .
	let theRayVector = ray.p₁ - ray.p₀
	
	//	We'll ignore walls that are almost parallel to the ray.
	//	This makes sense from a user-interface point of view,
	//	and also avoids any risk of division by zero.
	let theMinOrthogonalComponent = 0.0001 * length(theRayVector)
	
	for theOrthogonalAxis in 0...2 {
	
		let theOrthogonalComponent = theRayVector[theOrthogonalAxis]
	
		if abs(theOrthogonalComponent) >= theMinOrthogonalComponent {
		
			let theWallCoordinate = sign(theOrthogonalComponent) * gBoxSize
			
			let t = (theWallCoordinate - ray.p₀[theOrthogonalAxis])
				  / theOrthogonalComponent
			
			let theHitPoint =  ray.p₀  +  t * theRayVector
			
			if  abs(theHitPoint[(theOrthogonalAxis + 1)%3]) <= gBoxSize
			 && abs(theHitPoint[(theOrthogonalAxis + 2)%3]) <= gBoxSize {
			 
				//	We now know theHitPoint and theOrthogonalAxis,
				//	but we still need to compute theUnitLengthProjectedAxisVector.
				
				let theHitPointInWorldCoordinates = orientation.act(theHitPoint)

				var theAxisVectorInModelCoordinates = SIMD3<Double>.zero
				theAxisVectorInModelCoordinates[theOrthogonalAxis] = +1.0
				let theAxisVectorInWorldCoordinates
					= orientation.act(theAxisVectorInModelCoordinates)

				let theProjectionDerivative = makeProjectionDerivative(
												at: theHitPointInWorldCoordinates)

				let theProjectedAxisVector = theProjectionDerivative * theAxisVectorInWorldCoordinates
											//	in Swift's right-to-left notation
			
				//	Normalize each of theProjectedBasisVectors to unit length, if possible.
				//	Avoid the built-in normalize() function, which (quite reasonably)
				//	gives normalize(0.0, 0.0) = (NaN, NaN).
				let theUnitLengthProjectedAxisVector: SIMD2<Double>
				let theLength = length(theProjectedAxisVector)
				if theLength > 0.001 {
				
					//	typical case
					theUnitLengthProjectedAxisVector = theProjectedAxisVector / theLength
					
				} else {
				
					//	With the box in its default axis-aligned orientation,
					//	if the user creates a new point at the center of the back wall
					//	theAxisVectorInWorldCoordinates will be pointing straight towards the user
					//	and theProjectedAxisVector will be zero.  In this case,
					//	as a reasonable fallback, set theUnitLengthProjectedAxisVector
					//	to be an arbitrary unit-length vector.
					theUnitLengthProjectedAxisVector = SIMD2<Double>(1.0, 0.0)
				}

				return (
					SIMD4<Double>(theHitPoint, 0.0),
					theOrthogonalAxis,
					theUnitLengthProjectedAxisVector
				)
			}
		}
	}
	
	return nil
}

func chooseMovePointAxis(
	hitPointPosition: simd_quatd,
	dragDirection: SIMD2<Double>,
	orientation: simd_quatd
) -> (Int, SIMD2<Double>)
	//	returns (
	//		axis ∈ {0,1,2,3},
	//		projection of axis onto plane of display,
	//		  normalized to unit length
	//	)
{

	//	Ignore any animated rotation that may be in progress.
	//	The user should know that trying to draw a point
	//	while an animated rotation is in progress is a bad idea.
	let theTransformation = composeTransformation(
								orientation: orientation,
								animatedRotation: nil)

	let (thePosition, _, _) = transformedPositionAndHue(
								position: hitPointPosition,
								transformation: theTransformation)
		
	let theProjectionDerivative = makeProjectionDerivative(at: thePosition)

	let theBasisVectorsInModel: [SIMD3<Double>] = [
		SIMD3<Double>(1.0, 0.0, 0.0),
		SIMD3<Double>(0.0, 1.0, 0.0),
		SIMD3<Double>(0.0, 0.0, 1.0),
		SIMD3<Double>(1.0, 1.0, 1.0)	//	direction of artificial 4D shear,
										//		see comment accompanying g4DShearFactor
	]
	let theBasisVectorsInWorld = theBasisVectorsInModel.map{ basisVector in
		orientation.act(basisVector)
	}
	let theProjectedBasisVectors = theBasisVectorsInWorld.map{ basisVector in
		theProjectionDerivative * basisVector	//	in Swift's right-to-left notation
	}
	let theNormalizedBasisVectors = theProjectedBasisVectors.map{ basisVector -> SIMD2<Double> in
		//	Normalize each of theProjectedBasisVectors to unit length, if possible.
		//	Avoid the built-in normalize() function, which (quite reasonably)
		//	gives normalize(0.0, 0.0) = (NaN, NaN).
		let theLength = length(basisVector)
		if theLength > 0.001 {
			return basisVector / theLength
		} else {
			return basisVector
		}
	}

	//	Which of theNormalizedBasisVectors best matches the dragDirection?
	var theBestAxis = 0	//	∈ {0,1,2,3}
	var theBestDotProduct = 0.0
	for theAxis in 0...3 {
	
		//	Project the dragDirection onto theNormalizedBasisVectors[i].
		//	The latter is a unit vector (or near zero) so the dot product
		//	gives the length of the dragDirection's projection.
		let theDotProduct = abs(dot(dragDirection, theNormalizedBasisVectors[theAxis]))
		if theDotProduct > theBestDotProduct {
			theBestAxis = theAxis
			theBestDotProduct = theDotProduct
		}
	}
	
	return (theBestAxis, theNormalizedBasisVectors[theBestAxis])
}

func makeProjectionDerivative(
	at point: SIMD3<Double>	//	point in world coordinates
) -> simd_double3x2 {

	let x = point[0]
	let y = point[1]
	let z = point[2]
	
	let theOriginToDisplayDistance = gBoxSize
	let theOriginToObserverDistance = gEyeDistance
	let theDisplayToObserverDistance = theOriginToObserverDistance - theOriginToDisplayDistance

	//	In world coordinates, an observer at (0, 0, -OrgToObs)
	//	projects the scene onto the plane z = -OrgToDsp via the function
	//
	//		               DspToObs        DspToObs
	//		p(x,y,z) = ( ------------ x, ------------ y, -OrgToDsp )
	//		             z + OrgToObs    z + OrgToObs
	//
	//	The partial derivatives
	//
	//		∂p        DspToObs
	//		-- = (  ------------,            0,           0 )
	//		∂x      z + OrgToObs
	//
	//		∂p                           DspToObs
	//		-- = (        0,           ------------,      0 )
	//		∂y                         z + OrgToObs
	//
	//		∂p       -DspToObs          -DspToObs
	//		-- = ( --------------- x, --------------- y,  0 )
	//		∂z     (z + OrgToObs)²    (z + OrgToObs)²
	//
	//	tell how the projected point responds to movements
	//	of the original point (x,y,z).

	let theFactorA = theDisplayToObserverDistance / (z + theOriginToObserverDistance)
	let theFactorB =         -theFactorA          / (z + theOriginToObserverDistance)

	var theProjectionDerivative
		= simd_double3x2()	//	initializes to zero matrix (undocumented)
	theProjectionDerivative[0][0] = theFactorA
	theProjectionDerivative[1][1] = theFactorA
	theProjectionDerivative[2][0] = theFactorB * x
	theProjectionDerivative[2][1] = theFactorB * y
	
	return theProjectionDerivative
}


// MARK: -
// MARK: Parallel transport

func mapToUnitSphere(
	touchPoint: CGPoint,	//	0 ≤ touchPoint.x|y ≤ viewSize.width|height
	viewSize: CGSize
) -> SIMD3<Double> {

	let thePoint = mapTouchPointToIntrinsicCoordinates(touchPoint, viewSize: viewSize)
	
	let x = thePoint.x
	let y = thePoint.y
	
	let r = sqrt(x*x + y*y)
	
	//	Use an orthogonal projection.  It's simpler than
	//	the perspective projection that the Draw4DRenderer
	//	uses to render the drawing, yet feels just as natural.
	
	let p = ( r < 1.0 ?
		SIMD3<Double>(x, y, -sqrt(1.0 - r*r)) :	//	in southern hemisphere
		SIMD3<Double>(x/r, y/r, 0.0) )			//	on equator
		
	return p
}

func parallelTransportAxisAndAngle(
	p₀: SIMD3<Double>,	//	unit vector
	p₁: SIMD3<Double>	//	unit vector
) -> (SIMD3<Double>, Double)	//	(the axis, the angle) that parallel transports p₀ to p₁.
								//	The axis has unit length.
								//	The angle is always non-negative.
{
	//	Take a cross product to get the axis of rotation.

	let theCrossProduct = SIMD3<Double>(
							p₀.y*p₁.z - p₀.z*p₁.y,
							p₀.z*p₁.x - p₀.x*p₁.z,
							p₀.x*p₁.y - p₀.y*p₁.x )

	let theCrossProductLength = length(theCrossProduct)
	
	let theAxis: SIMD3<Double>
	let θ: Double
	if theCrossProductLength > 0.0 {
	
		//	Normalize theCrossProduct to unit length
		//	to get a normalized axis of rotation.
		theAxis = theCrossProduct / theCrossProductLength

		//	p₀ and p₁ are both unit vectors, so
		//
		//		theCrossProductLength = |p₀|·|p₁|·sin(θ)
		//							  = sin(θ)
		//
		//		Note:  Using theCosine = p₀·p₁
		//		could be less numerically precise.
		//
		let theSine = theCrossProductLength
		let theSafeSine = min(theSine, 1.0)	//	guard against theSine = 1.0000000000001
		θ = asin(theSafeSine)

	} else {	//	theCrossProductLength = 0.0
	
		//	p₀ and p₁ are equal (or collinear) and the motion is the identity.
		//	We can pick an arbitrary axis and report zero distance.
		//
		//		Note #1:  The touch input values are discrete,
		//		so we shouldn't have to worry about including
		//		any sort of tolerance here.
		//
		//		Note #2:  We're unlikley to ever receive p₁ = -p₀.
		//
		theAxis = SIMD3<Double>(1.0, 0.0, 0.0)
		θ = 0.0
	}
	
	return (theAxis, θ)
}


// MARK: -
// MARK: Rotation gesture

func draw4DRotateGesture(
	modelData: Draw4DDocument,
	previousAngle: GestureState<Double>
) -> some Gesture {
	
	//	When running on macOS (as a "designed for iPadOS" app)
	//	rotations will be recognized iff
	//
	//		Settings > Trackpad > Scroll & Zoom > Rotate
	//
	//	is enabled.  Fortunately that seems to be the default setting.
	
	let theRotateGesture = RotateGesture(minimumAngleDelta: .zero)
	.updating(previousAngle) { value, thePreviousAngle, transaction in

		//	Suppress the usual per-frame increment
		//	while the user is manually rotating the figure.
		modelData.itsIncrement = nil

		let theNewAngle = value.rotation.radians
		
		//	RotateGesture() sometimes returns theNewAngle = NaN. Ouch!
		if theNewAngle.isNaN {
			return
		}

		var Δθ = theNewAngle - thePreviousAngle

		//	Avoid discontinuous jumps by 2π ± ε
		if Δθ > π { Δθ -= 2.0 * π }
		if Δθ < π { Δθ += 2.0 * π }

		let theIncrement = simd_quatd(angle: Δθ, axis: SIMD3<Double>(0.0, 0.0, -1.0))
		modelData.itsOrientation = simd_normalize(theIncrement * modelData.itsOrientation)

		//	Update thePreviousAngle for next time.
		thePreviousAngle = theNewAngle
				
		if gGetScreenshotOrientations {
			print(modelData.itsOrientation)
		}
	}
	.onEnded() { _ in

		//	Trying to decide whether the user wants the figure
		//	to keep rotating or not at the end of the gesture is trickier
		//	than it seems. So let's just stop rotating, no matter what.
		//	The 2-finger rotation is an awkward gesture for the user
		//	to perform in any case. The 1-finger rotation -- with
		//	the user's finger near the edge of the display -- is
		//	an easier way to rotate the figure about an axis
		//	orthogonal to the display.
		//
		modelData.itsIncrement = nil	//	redundant but clear

		//	If itsOriention is almost axis aligned, snap to perfect alignment.
		if let theAxisAlignedOrientation
			= snapOrientationToAxes(modelData.itsOrientation) {
		
			modelData.itsOrientation = theAxisAlignedOrientation
		}
	}
	
	return theRotateGesture
}


// MARK: -
// MARK: Tap gesture

func draw4DTapGesture(
	modelData: Draw4DDocument,
	viewSize: CGSize,
	activePanel: Binding<PanelType>
) -> some Gesture {

	let theSpatialTapGesture = SpatialTapGesture()
	.onEnded() { value in

		if activePanel.wrappedValue != .noPanel {

			activePanel.wrappedValue = .noPanel	//	dismiss panel

		} else if modelData.itsIncrement != nil {

			modelData.itsIncrement = nil		//	stop the rotation

		} else {

			let theHitTestRay = hitTestRayInModelCoordinates(
									touchPoint: value.location,
									viewSize: viewSize,
									orientation: modelData.itsOrientation)

			switch modelData.itsTouchMode {
			
			case .movePoints:

				if let theHitPoint = findHitPoint(
										ray: theHitTestRay,
										points: modelData.itsPoints) {

					modelData.itsSelectedPoint = theHitPoint
				}

			case .deletePoints:

				if let theHitPoint = findHitPoint(
										ray: theHitTestRay,
										points: modelData.itsPoints) {

					let theIncidentEdges = modelData.itsEdges.filter({anEdge in
						anEdge.itsStart === theHitPoint
					 || anEdge.itsEnd === theHitPoint
					})
					
					modelData.deleteElements(
						points: [theHitPoint],
						edges: theIncidentEdges)
				}
			
			case .addEdges:

				if let theHitPoint = findHitPoint(
										ray: theHitTestRay,
										points: modelData.itsPoints) {
				
					//	Has the user already selected the first endpoint?
					if let theSelectedPoint = modelData.itsSelectedPoint {
					
						if theHitPoint == theSelectedPoint {
						
							//	The user has tapped the already selected point.
							//	Un-select it.
							modelData.itsSelectedPoint = nil
							
						} else {	//	theHitPoint ≠ theSelectedPoint

							if !modelData.pointsAreConnected(theHitPoint, theSelectedPoint) {

								//	Connect theHitPoint to theSelectedPoint with an edge.
								let theNewEdge = Draw4DEdge(
													from: theHitPoint,
													to: theSelectedPoint)
								modelData.addEdge(theNewEdge)
								
								//	Clear itsSelectedPoint.
								modelData.itsSelectedPoint = nil
							}
						}
						
					} else {	//	itsSelectedPoint == nil
					
						if !modelData.pointIsSaturated(theHitPoint) {
						
							//	Remember theHitPoint, so that if the user
							//	later selects a second point, we may connect
							//	the two points with an edge.

							modelData.itsSelectedPoint = theHitPoint
						}
					}
				}

			case .deleteEdges:
			
				if let theHitEdge = findHitEdge(
										ray: theHitTestRay,
										edges: modelData.itsEdges) {

					modelData.deleteElements(
						points: [],
						edges: [theHitEdge])
				}
				
			default:
			
				//	Ignore taps in all remaining TouchModes.
				break
				
			}
		}
	}
	
	return theSpatialTapGesture
}
