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

import SwiftUI
import UniformTypeIdentifiers
import Combine
import simd

//extension UTType {
//	static var draw4D: UTType {
//		UTType(importedAs: "org.geometrygames.draw4d.drawing")
//	}
//}
extension UTType {
	nonisolated static var draw4D: UTType {
		UTType(exportedAs: "org.geometrygames.Draw4D-mobile.drawing")
	}
}

//	X-, y- and z-coordinates in the range [-gBoxSize, +gBoxSize]
//	are guaranteed to sit within the bounding box walls.
//	X-, y- and z-coordinates outside that range will sit
//	outside the walls, but otherwise won't cause problems.
//
//	2022-03-31  4D Draw now clamps x-, y- and z-coordinates
//	to [-1, +1], for consistency with the w-coordinate,
//	which we draw by mapping w ∈ [-1, +1] to the range
//	of hues [gHueKata, gHueAna].  Restricting
//	all coordinates to the same range [-1, +1] keeps
//	the bounds consistent under 4D Draw's 90° rotations.
//
//		When gMakeIcon is true
//			Choose a box size that leaves a bottom margin
//			whose height is roughly 1/13 the height
//			of the figure we're drawing, so that we can
//			crop the exported image to match the margins
//			in the icons of iOS's built-in apps, which have
//			(top margin) : (content) : (bottom margin)
//			in the proportion 1 : 13 : 1.
//			(Hmmm... Apple's icon contents fit within a disk
//			of radius 13/15, while ours extend slightly beyond
//			that disk.  Oh well, it should be fine.)
//
let gBoxSize = ((gMakeIcon || gMakeGraphicsForHypercubeSlideInLecture) ? 1.51 : 1.25)

//	It's best that the grid spacing be a number of the form m/2ⁿ,
//	which has both a terminating binary expansion and a terminating decimal expansion.
//	That way, when we save the document, all points will have exact coordinates.
//	For example, 0.25₁₀ = 1/4 = 0.01₂ is good,
//	but 0.1₁₀ = 1/10 = 0.0001100110011001100110011001100110011001100110011…₂ is bad.
//
let gGridSpacing = 0.25


//	The gPerspectiveFactor gives the ratio
//
//		   distance from observer to center of screen
//		------------------------------------------------
//		distance from edge of screen to center of screen
//
//	It's needed to draw the scene and also to interpret touches.
//
let gPerspectiveFactor = 3.0

//	gEyeDistance gives the distance from the user's eye
//	to the center of the box.  In world coordinates,
//	the user's eye sits at
//
//		(0.0, 0.0, -gEyeDistance)
//
let gEyeDistance = (gPerspectiveFactor + 1.0) * gBoxSize
let gEyePositionInWorldCoordinates = SIMD3<Double>(0.0, 0.0, -gEyeDistance)

//	To distinguish nodes whose x-, y- and z-coordinates all agree,
//	add an artifical offset of
//
//			(Δx, Δy, Δz) = (the4DShear, the4DShear, the4DShear)
//	where
//			the4DShear = w · g4DShearFactor
//
//	to every point.  Unlike in 4D Maze, here it might be better
//	not to put g4DShearFactor under user control.  That is,
//	it might be better to keep g4DShearFactor small, to maintain
//	the the drawing's fidelity.  Of course g4DShearFactor should
//	never be exactly 0, partly to avoid possible division by 0,
//	but also to avoid the z-fighting that would occur if we tried
//	to superimpose two nodes exactly.
//
let g4DShearFactor = 0.0078125


//	Implement Draw4DPoint and Draw4DEdge as classes (not structs)
//	so they get passed by reference on the Undo/Redo queues.
//	Some advantages:
//
//		When an Undo/Redo operation moves a Draw4DPoint,
//		it moves the original, not just a copy.
//
//		encodeFileFormat() can find the array index
//		of a Draw4DPoint.
//

@Observable class Draw4DPoint: Equatable {

	var itsPosition: simd_quatd	//	= x·i + y·j + z·k + w·1
	
	init(at position: simd_quatd) {

		itsPosition = position
	}

	nonisolated static func == (lhs: Draw4DPoint, rhs: Draw4DPoint) -> Bool {
		return lhs === rhs	//	same instance
	}
}

class Draw4DEdge: Equatable {

	var itsStart: Draw4DPoint
	var itsEnd: Draw4DPoint
	
	init(
		from start: Draw4DPoint,
		to end: Draw4DPoint
	) {
	
		itsStart = start
		itsEnd = end
	}

	nonisolated static func == (lhs: Draw4DEdge, rhs: Draw4DEdge) -> Bool {
		return lhs === rhs	//	same instance
	}
}


struct QuaternionPair {

	//	Left-multiplication by a unit quaternion q
	//
	//		p ⟼ q p
	//
	//	takes 1 (the "north pole") to q via
	//	a clockwise corkscrew motion (a Clifford translation),
	//	while right-multiplication by a unit quaternion q
	//
	//		p ⟼ p q
	//
	//	takes 1 to q via a counterclockwise corkscrew motion.
	//	Or vice-versa, depending on whether you view
	//	your coordinate system as left-handed or right-handed.
	//
	//	More precisely, if
	//
	//		q = sin(θ) (ux i + uy j + uz k) + cos(θ) 1,
	//
	//	where u = (ux, uy, uz) is a unit vector, then
	//	left- (resp. right-) multiplication translates
	//	the north pole 1 a distance θ along the great circle
	//	running from 1 through q, while rotating around that
	//	same great circle by the same angle θ.
	//
	//	The common application of quaternions in computer graphics
	//	left-multiplies by q and right-multipies by q⁻¹
	//
	//		p ⟼ q p q⁻¹
	//
	//	which "does a clockwise corkscrew motion" and
	//	"undoes a counterclockwise corkscrew motion".
	//	The net effect is that the translational components
	//	cancel, taking the north pole 1 back to 1,
	//	but the rotational components add, leaving
	//	the whole space rotated by angle 2θ.
	//
	//	An alternative is to left- and right-multiply
	//	by the same quaternion (with no inverse)
	//
	//		p ⟼ q p q
	//
	//	which "does a clockwise corkscrew motion" and
	//	"does a counterclockwise corkscrew motion".
	//	The net effect is that the rotational components
	//	cancel, but the translational components add,
	//	thus translating the north pole 1 a distance 2θ
	//	along the great circle running from 1 through q.
	//
	//	We'll use this alternative version ( p ⟼ q p q )
	//	to realize certain 4D transformations.
	//
	//	By the way, if we allow arbitrary unit quaternions
	//	for q_left and q_right, then
	//
	//		p ⟼ q_left p q_right
	//
	//	realizes the full rotation group SO(4).
	//	Twice, in fact, because (q_left, q_right) and
	//	( - q_left, - q_right ) give the same rotation.
	
	var left: simd_quatd
	var right: simd_quatd
}

let gQuaternionPairIdentity = QuaternionPair(
								left: gQuaternionIdentity,
								right: gQuaternionIdentity)

struct AnimatedRotation {

	let itsTotalDuration: CFTimeInterval
	let itsTotalAngle: Double

	//	When did the animated rotation begin?
	let itsStartTime: CFAbsoluteTime	//	in seconds since the system's reference date

	//	When the animation parameter is θ, we'll set
	//
	//		left-multiplier  = Sin(θ) itsLeftImaginaryUnitVector  + Cos(θ) 1
	//		right-multiplier = Sin(θ) itsRightImaginaryUnitVector + Cos(θ) 1
	//
	let itsLeftImaginaryUnitVector: SIMD3<Double>	//	unit length
	let itsRightImaginaryUnitVector: SIMD3<Double>	//	unit length
}



enum TouchMode: Int, CaseIterable, Identifiable {
	
	case neutral		= 0
	case movePoints		= 1
	case addPoints		= 2
	case deletePoints	= 3
	case addEdges		= 4
	case deleteEdges	= 5
	
	var id: Int { self.rawValue }

	var nameKey: LocalizedStringKey {
		switch self {
		case .neutral:		return "Neutral"
		case .movePoints:	return "MovePoints"
		case .addPoints:	return "AddPoints"
		case .deletePoints:	return "DeletePoints"
		case .addEdges:		return "AddEdges"
		case .deleteEdges:	return "DeleteEdges"
		}
	}

	var imageName: String {
		switch self {
		case .neutral:		return "Touch Modes/Neutral"
		case .movePoints:	return "Touch Modes/Move Points"
		case .addPoints:	return "Touch Modes/Add Points"
		case .deletePoints:	return "Touch Modes/Delete Points"
		case .addEdges:		return "Touch Modes/Add Edges"
		case .deleteEdges:	return "Touch Modes/Delete Edges"
		}
	}
}


//	Enable g4DDrawForTalks only briefly, when compiling
//	4D Draw for use on macOS during talks, and then disable it again.
//#warning("g4DDrawForTalks is enabled")
let g4DDrawForTalks = false


//	Set gGetScreenshotOrientations to true while looking
//	for an aesthetically pleasing value for itsOrientation
//	for each type of screenshot, copy your preferred values
//	into setOrientationForScreenshot(), and then set gGetScreenshotOrientations
//	back to false before making the screenshots.
//
//		Note:  The screenshots require a standard set of figures,
//		which are permanently stored in the directory
//
//			06 4D Draw/App Store materials/screenshots - drawing files/
//
//		to get them to show up in the simulator, copy them
//		into the simulator's Documents directory.
//		The simulator's Documents directory path will be different
//		for each device that the simulator supports.  Draw4DApp's
//		init() function calls PrintDocumentDirectoryPathToConsole()
//		to show what that path is.
//
//#warning("disable gGetScreenshotOrientations")
let gGetScreenshotOrientations = false

//	To make screenshots, set gMakeScreenshots to true.
//	In addition, go to the Info.plist file and set
//
//		View controller-based status bar appearance = NO
//		Status bar is initially hidden = YES
//
//	and then restore at least the first one to YES after making the screenshots.
//#warning("restore status bar visibility after making screenshots")
//
//#warning("disable gMakeScreenshots")
let gMakeScreenshots = false

let gMakeIcon = false
let gHypercubeOrientationForIcon = simd_quatd(
									ix:  0.5535511218229185,
									iy: -0.1545749637069926,
									iz:  0.17991713980605648,
									r:   0.7983217139271505)

let gMakeGraphicsForHypercubeSlideInLecture = false
let gHypercubeOrientationForLecture = simd_quatd(
									ix: -0.27127221726532465,
									iy:  0.21169675808512908,
									iz: -0.03606222054517402,
									r:   0.9382405784263008)

let gTubeRadius: Double = {
	if gMakeGraphicsForHypercubeSlideInLecture {
		return 0.09375
	} else if gMakeIcon {
		return 0.0625
	} else {
		return 0.03125
	}
}()
let gNodeRadius: Double = {
	if gMakeGraphicsForHypercubeSlideInLecture || gMakeIcon {
		return 1.5 * gTubeRadius
	} else {
		return 2.0 * gTubeRadius
	}
}()


@Observable final class Draw4DDocument: GeometryGamesUpdatable, ReferenceFileDocument {

// MARK: -
// MARK: Model data

	//	Note that the changeCount has nothing to do with saving changes;
	//	SwiftUI saves changes iff there are items on the undo stack.

	//	Design note:  It's tempting to distinguish changes
	//	to the model itself (for example, to its nodes and tubes)
	//	from changes to how it's presented (for example,
	//	its orientation in space), and in 4D Draw I think
	//	such a distinction could work well.  The GeometryGamesView
	//	could keep a presentation change count, while this Draw4DDocument
	//	keeps an model change count.  In principle this would make it
	//	possible to present two or more views of the same underlying
	//	model, but viewed at different orientations.  Unfortunately
	//	this distinction could become messy in other apps;
	//	for example in Curved Spaces if the user changes
	//	the curvature of the space, all the current orientations
	//	become invalid.  [That could be handled easily enough:
	//	the View could have some onChange(of:) code to reset
	//	orientations when the space topology changes.  A trickier
	//	issue would be that the Views would be driving their own
	//	animations, but somehow in an app like the Torus Games
	//	Pool game, we'd need to arrange for the Model to get
	//	updated once per frame, even if multiple windows are
	//	viewing it.]  Another potential snag is that the Draw4DThumbnailProvider
	//	creates a document and a renderer, but not a view.
	//	To avoid such problems, the Geometry Games apps
	//	let the "model" contain all data -- even trivial stuff
	//	like the object's orientation in space -- to make it easier
	//	to keep everything consistent.
	
	var itsPoints: [Draw4DPoint] = []
	var itsEdges: [Draw4DEdge] = []

	//	Just as unit-length quaterions q, acting by conjugation q⁻¹ p q,
	//	give the 3-dimensional rotation group SO(3) (for details, see
	//
	//		https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation ),
	//
	//	QuaternionPairs (see definition above) give
	//	the 4-dimensional rotation group SO(4).  QuaternionPairs are
	//
	//	  - far more convenient than matrices to work with
	//	  - faster than matrices when composing rotations
	//	  - but slower than matrices when applying
	//			a rotation to a point
	//
	//	So we'll use quaternions and QuaternionPairs for most
	//	of our computations and then, at the last moment,
	//	we'll switch to 4×4 matrices to tell the vertex shader
	//	where to draw the various nodes, tubes, etc.
	//
	var itsOrientation: simd_quatd = {
		if gMakeIcon {
			return gHypercubeOrientationForIcon
		} else if gMakeGraphicsForHypercubeSlideInLecture {
			return gHypercubeOrientationForLecture
		} else {
			return gQuaternionIdentity
		}
	}()  {
		didSet {
			changeCount += 1
		}
	}
	
	//	Assuming a constant frame rate, with gFramePeriod seconds
	//	from the start of one frame to the start of the next,
	//	let itsIncrement be the small additional rotation
	//	that we want post-multiply for each successive frame.
	//
	var itsIncrement: simd_quatd?	//	= nil when polyhedron isn't rotating

	var itsBoxIsEnabled: Bool = (gMakeIcon ? false : true) {
		didSet{ changeCount += 1 }
	}
	func setBoxIsEnabled(		//	provideThumbnailAsync() needs setBoxIsEnabled
		_ boxIsEnabled: Bool	//	because it can't set itsBoxIsEnabled directly.
	) {
		itsBoxIsEnabled = boxIsEnabled
	}
	var itsInertiaIsEnabled: Bool = (gGetScreenshotOrientations ? false : true)

	var itsAnimatedRotation: AnimatedRotation?	//	= nil when no animation is in progress

	//	This document's properties' default values
	//	define an unlocked empty drawing, for which
	//	.addPoints is an appropriate initial TouchMode.
	var itsTouchMode: TouchMode = .addPoints {
		didSet { itsSelectedPoint = nil }
	}
	
	//	While selecting the endpoint for a new Draw4DEdge,
	//	or while moving any Draw4DPoint, it's convenient
	//	to keep track of which point is selected.
	var itsSelectedPoint: Draw4DPoint? = nil {
		didSet { changeCount += 1 }
	}
	
	//	The user may lock the drawing's content to prevent accidental changes.
	//
	//		Known issue:  I don't want Lock/Unlock to go
	//		onto the Undo stack because, among other reasons,
	//		locking a drawing disables the Undo and Redo buttons.
	//		But this has the unfortunate side-effect that if
	//		a user opens a drawing, locks it, and then closes it,
	//		the drawing won't get re-saved and its lockedness
	//		will be forgotten.  I don't know how to ask
	//		a SwiftUI ReferenceFileDocument to re-save a document
	//		when no changes are on the Undo stack.
	//
	//		Another, less important, known issue:
	//		I'd like to write code like
	//
	//			var itsContentIsLocked: Bool = false {
	//				didSet { updateCapabilityFlags(UNDO MANAGER) }
	//
	//		but here in the Draw4DDocument we don't have access
	//		to the UndoManager, so we instead need to wait
	//		for the LockButton to call updateCapabilityFlags().
	//
	var itsContentIsLocked: Bool = false

	//	Undo and Redo are an integral part of making a drawing
	//	in 4D Draw, so 4D Draw provides convenient (and obvious!)
	//	Undo and Redo buttons at the top of the scene
	//	(as opposed to insisting on that the user do
	//	a 3-finger swipe left/right to undo/redo, or a 3-finger tap
	//	to access iOS's built-in Undo|Cut|Copy|Paste|Redo bar).
	//
	//	Unfortunately
	//
	//		"UndoManager.canUndo is not KVO compliant"
	//
	//	as explained on the web page
	//
	//		https://stackoverflow.com/questions/60647857/undomanagers-canundo-property-not-updating-in-swiftui
	//
	//	A comment on that same page goes on to say why
	//	listening for an NSUndoManagerCheckpoint notification
	//	won't work either, because it generates an infinite recursion
	//	when checking the redo state.
	//
	//	So to keep the code straightforward and robust,
	//	let's keep a variable itsUndoRedoCount that we update
	//	whenever we modify the Undo/Redo stack, so that our
	//	Undo and Redo buttons will get enabled or disabled appropriately.
	//
	var itsUndoManager: UndoManager? {
		//	Draw4DContentView's body() will set this.
	
		didSet {
			//	In draw4DDragGesture, dragBegan() relies
			//	on groupsByEvent=false when handling the .addPoints case.
			itsUndoManager?.groupsByEvent = false
		}
	}
	var itsUndoRedoCount: Int = 0

	//	The initial values given here correspond
	//	to an unlocked empty drawing.
	var itCanRotateQuarterTurns: Bool = true
	var itCanMovePoint: Bool = false
	var itCanAddPoint: Bool = true
	var itCanDeletePoint: Bool = false
	var itCanAddEdge: Bool = false
	var itCanDeleteEdge: Bool = false
	

// MARK: -
// MARK: Initialization

	//	The ReferenceFileDocument protocol is defined in terms of an
	//
	//		associatedtype Snapshot
	//
	//	4D Draw drawings get saved as textual descriptions,
	//	so we let this associatedtype be a String.
	//
	typealias Snapshot = String
	nonisolated static var readableContentTypes: [UTType] { [.draw4D] }

	var itsInitialSnapshot: String? = nil	//	used only with g4DDrawForTalks

	//	decodeFileFormat() should, I hope, have no problems
	//	decoding the files we've written ourselves.
	//	But if the user creates a .draw4D externally
	//	and adds it to our file folder, then it would be nice
	//	to clearly explain any error we might find in it.
	//	For an example of how to show an error alert, see
	//
	//		https://augmentedcode.io/2020/03/01/alert-and-localizederror-in-swiftui/
	//
	//	Alas... trying to show an error during an init()
	//	seems somehow risky, so I'm not going to attempt this just yet.
	//	Maybe some other day.  For now, just let the error propagate
	//	up the line (at which point the SwiftUI will silently
	//	abort opening the drawing).

	//	Initializes a Draw4DDocument for an empty drawing
	nonisolated init() {

		//	Whenever the user opens a drawing,
		//	SwiftUI first calls this init() to open
		//	an empty drawing, then immediately thereafter
		//	calls init(configuration: ReadConfiguration)
		//	to open the drawing that the user requested.
		//	Presumably it uses the empty drawing to put
		//	a window up as quickly as possible, just
		//	in case the requested drawing is slow to load.
	
		//	This document's properties' default values
		//	already define an unlocked empty drawing.

		//	It would be nice to call updateCapabilityFlags() here,
		//	but unfortunately that's not possible in a nonisolated init().
		//	Fortunately, if we take care that the capabilities flags'
		//	default values are consistent with an unlocked empty drawing,
		//	all will be well.
	//	updateCapabilityFlags()
	}

	//	Initializes a Draw4DDocument when opening a file.
	//
	//	----------------------------
	//
	//	ReferenceFileDocument's nonisolated protocol
	//	conformance will be an issue in Swift 6.
	//
	//	On the one hand, even though we're modifying mutable variables,
	//	there's no danger of a data race because as long as we're
	//	still in the init(), no other code knows about this instance
	//	of MyDocument (and we avoid starting Tasks that could
	//	access our variables).
	//
	//	On the other hand, there's currently no way to tell
	//	the Swift 6 compiler that. So as soon as we uncomment
	//	the 'nonisolated' keyword here, Swift 6 will complain
	//	about modifying isolated state in a nonisolated context.
	//
//	nonisolated
	init(configuration: ReadConfiguration) throws {

		guard let theDrawingAsUTF8EncodedData = configuration.file.regularFileContents,
			  let theDrawingAsText = String(data: theDrawingAsUTF8EncodedData, encoding: .utf8)
		else {
			throw CocoaError(.fileReadCorruptFile)
		}

		let theFileName = configuration.file.filename?.prefix(while: {$0 != "."}) ?? ""

		itsInitialSnapshot = (g4DDrawForTalks ? theDrawingAsText : nil)

		do {
			try decodeFileFormat(theDrawingAsText)
		} catch {

			//	If decodeFileFormat( throws an error while opening a drawing,
			//	the following print() call prints the message to the console.
			//	The drawing doesn't get opened.
			//
			//	I don't know the best way to present the message to the user.
			//	The view modifier alert(_:isPresented:actions:message:) can
			//	be only be attached to a View, so somehow Draw4DApp()'s
			//	call to DocumentGroup() would need to pass the word along
			//	to the Draw4DContentView(), that it should display
			//	an empty drawing with an error alert on top of it.
			//
			//	Let's hope that no errors will get thrown as long the user
			//	opens only files that 4D Draw itself created (knock on wood!).
			//
			print("Error in decodeFileFormat: \(error)")	//	See comment above re error reporting
			throw error
		}

		if gMakeScreenshots {
			switch theFileName {
			case "vertex-first hypercube (step 3)":		itsTouchMode = .addPoints
			case "vertex-first hypercube (step 1)":		itsTouchMode = .movePoints
			case "alternating vertices of hypercube":	itsTouchMode = .addPoints
			case "hypertetrahedron":					itsTouchMode = .neutral
			default:									itsTouchMode = .neutral
			}
		} else {
			//	By default theInitialTouchMode is .neutral.
			//	But for an unlocked empty drawing, .addPoints might
			//	be a better starting mode, especially for new users.
			itsTouchMode = (itsPoints.count == 0 && !itsContentIsLocked) ?
				.addPoints : .neutral
		}

		updateCapabilityFlags()
	}

	//	Initializes a Draw4DDocument for use when creating a thumbnail
	init(drawingAsText: String) async throws {

		itsInitialSnapshot = (g4DDrawForTalks ? drawingAsText : nil)

		do {
			try decodeFileFormat(drawingAsText)
		} catch {

			//	If decodeFileFormat() throws an error while creating
			//	a thumbnail, nothing gets printed here, probably
			//	because the thumbnail generation code sits
			//	in a separate module and runs in a separate thread
			//	(maybe even a separate process???).
			//	The file gets a generic thumbnail based on the app icon.
			//
			print("Error in decodeFileFormat: \(error)")	//	See comment above re error reporting
			throw error
		}

		updateCapabilityFlags()
	}
	
// MARK: -
// MARK: Saving to file

	//	Apple's introduction to document-based apps at
	//
	//		https://developer.apple.com/documentation/swiftui/building_a_document-based_app_with_swiftui
	//
	//	says
	//
	//		Implementing undo management also alerts SwiftUI
	//		when the document changes.
	//
	//	In other words, when the app goes into the background
	//	(and at other times too, when nothing else is happening),
	//	SwiftUI saves the document iff there are unsaved changes
	//	on the undo stack.
	//
	//	----------------------------
	//
	//	ReferenceFileDocument's nonisolated protocol
	//	conformance will be an issue in Swift 6.
	//
	//	On the one hand, Apple's documentation for snapshot() says
	//
	//		SwiftUI prevents document edits during the snapshot
	//		operation to ensure that the model state remains coherent.
	//		After the call completes, SwiftUI reenables edits,
	//		and then calls the fileWrapper(snapshot:configuration:) method,
	//		where you serialize the snapshot and store it to a file.
	//
	//	So even though we're modifying mutable variables,
	//	there's no danger of a data race.
	//
	//	On the other hand, there's currently no way to tell
	//	the Swift 6 compiler that. So as soon as we uncomment
	//	the 'nonisolated' keyword here, Swift 6 will complain
	//	about modifying isolated state in a nonisolated context.
	//
//	nonisolated
	func snapshot(contentType: UTType) throws -> String {

		if let theInitialSnapshot = itsInitialSnapshot { // g4DDrawForTalks is enabled

			//	When g4DDrawForTalks is enabled, we always return
			//	the original snapshot that we saved when the user first
			//	opened the file.  That way we can modify a drawing
			//	during a talk without affecting the saved version
			//	(except that the file modification date will change,
			//	even thought the contents remain the same).
			
			 return theInitialSnapshot
			 
		} else {
		
			let theDrawingAsText = encodeFileFormat()
			return theDrawingAsText
		}
	}

	nonisolated func fileWrapper(snapshot: String, configuration: WriteConfiguration) throws -> FileWrapper {

		//	Converting a Swift String to UTF-8 will always succeed,
		//	so it's safe to force unwrap snapshot.data()'s optional return value.
		//	The reason the return value is an optional is that
		//	if we were converting to, say, ASCII or Latin-1,
		//	the conversion would fail when given, say, Cyrillic letters
		//	or Chinese characters.
		//
		let theDrawingAsUTF8EncodedData = snapshot.data(using: .utf8)!

		return FileWrapper.init(regularFileWithContents: theDrawingAsUTF8EncodedData)
	}


// MARK: -
// MARK: Capabilities

	func updateCapabilityFlags() {
	
		itsUndoRedoCount += 1
		
		itCanRotateQuarterTurns = !itsContentIsLocked
		
		itCanMovePoint = (itsPoints.count > 0 && !itsContentIsLocked)
		itCanAddPoint = !itsContentIsLocked
		itCanDeletePoint = (itsPoints.count > 0 && !itsContentIsLocked)
		itCanAddEdge = (
			//	The user may add a new edge unless all n·(n-1)/2 pairs of points
			//	are already connected, or the content is locked.
				itsEdges.count < itsPoints.count * (itsPoints.count - 1) / 2
			&&
				!itsContentIsLocked
		)
		itCanDeleteEdge = (itsEdges.count > 0 && !itsContentIsLocked)

		//	If the currently selected TouchMode is no longer valid,
		//	switch to .neutral instead.
		let theCurrentTouchModeIsValid: Bool
		switch itsTouchMode {
			
		case .neutral:
			theCurrentTouchModeIsValid = true
			
		case .movePoints:
			theCurrentTouchModeIsValid = itCanMovePoint
			
		case .addPoints:
			theCurrentTouchModeIsValid = itCanAddPoint
			
		case .deletePoints:
			theCurrentTouchModeIsValid = itCanDeletePoint
			
		case .addEdges:
			theCurrentTouchModeIsValid = itCanAddEdge
			
		case .deleteEdges:
			theCurrentTouchModeIsValid = itCanDeleteEdge
		}
		if !theCurrentTouchModeIsValid {
			itsTouchMode = .neutral
		}
	}


// MARK: -
// MARK: Animation

	var changeCount: UInt64 = 0
	func updateModel() -> UInt64 {	//	returns the model's display change count

		if itsInertiaIsEnabled {
			if let theIncrement = itsIncrement {

				//	Post-multiply ( => left-multiply) itsOrientation by theIncrement.
				itsOrientation = theIncrement * itsOrientation

				//	Normalize itsOrientation to keep its length
				//	from gradually drifting away from 1.
				//
				//		Note:  The length stays very close to 1
				//		at all times, so we don't have to worry about
				//		passing in the zero quaternion here.
				//
				itsOrientation = simd_normalize(itsOrientation)

				changeCount += 1
			}
		}
		
		switch itsTouchMode {
		
		case .neutral, .movePoints, .addPoints:
			break

		case .deletePoints, .addEdges, .deleteEdges:

			//	Render every frame while points or edges are flashing.
			changeCount += 1
			
		}
		
		if let theAnimatedRotation = itsAnimatedRotation {
		
			//	Render every frame while an animated rotation is in progress.
			changeCount += 1
			
			//	Is it time to finalize the animation?
			if CFAbsoluteTimeGetCurrent()
				>= theAnimatedRotation.itsStartTime + theAnimatedRotation.itsTotalDuration {
				
				//	Stop the animation.
				itsAnimatedRotation = nil
				
				//	Rotate all points by the given rotation.
				rotateQuarterTurn(
					leftImaginaryUnitVector: theAnimatedRotation.itsLeftImaginaryUnitVector,
					rightImaginaryUnitVector: theAnimatedRotation.itsRightImaginaryUnitVector)
			}
		}

		return changeCount
	}


// MARK: -
// MARK: Undo/Redo operations

	func addPoint(
		_ point: Draw4DPoint
	) {

		let theDoOperation = {

			self.itsPoints.append(point)
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		let theUndoOperation = {

			//	Other undo/redo operations might have changed the order
			//	of the Draw4DPoints in itsPoints, so we can't assume that
			//	the given point sits at the end of the Array.
			self.itsPoints.removeAll(where: {aPoint in aPoint === point})
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		
		doSomethingUndoably(
			doOperation: theDoOperation,
			undoOperation: theUndoOperation)
	}

	func addEdge(
		_ edge: Draw4DEdge
	) {

		let theDoOperation = {

			self.itsEdges.append(edge)
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		let theUndoOperation = {

			//	Other undo/redo operations might have changed the order
			//	of the Draw4DEdges in itsEdges, so we can't assume that
			//	the given edge sits at the end of the Array.
			self.itsEdges.removeAll(where: {anEdge in anEdge === edge})
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		
		doSomethingUndoably(
			doOperation: theDoOperation,
			undoOperation: theUndoOperation)
	}

	//	deleteElements supports deleting points and edges simultaneously,
	//	because when the user deletes a point, we'll also need to delete
	//	all edges incident to that point.
	func deleteElements(
		points: [Draw4DPoint],
		edges: [Draw4DEdge]
	) {

		let theDoOperation = {
		
			self.itsPoints.removeAll(where: {aPoint in points.contains(aPoint)})
			self.itsEdges.removeAll(where: {anEdge in edges.contains(anEdge)})
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		let theUndoOperation = {

			self.itsPoints.append(contentsOf: points)
			self.itsEdges.append(contentsOf: edges)
		
			self.itsSelectedPoint = nil	//	overkill, but robust and predictable
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		
		doSomethingUndoably(
			doOperation: theDoOperation,
			undoOperation: theUndoOperation)
	}
	
	
	func movePoint(
		_ point: Draw4DPoint,
		to newPosition: simd_quatd
	) {
	
		let theOldPosition = point.itsPosition

		let theOperation = { position in
		
			point.itsPosition = position
		
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		let theDoOperation   = {
			theOperation(newPosition)
		}
		let theUndoOperation = {
			theOperation(theOldPosition)
		}
		
		doSomethingUndoably(
			doOperation: theDoOperation,
			undoOperation: theUndoOperation)
	}

	
	func rotateQuarterTurn(
		leftImaginaryUnitVector: SIMD3<Double>,
		rightImaginaryUnitVector: SIMD3<Double>
	) {

		//	Interpolating between the north pole quaternion 1
		//	and a given quaternion q (which we'll take, in turn, to be
		//	the leftImaginaryUnitVector and the rightImaginaryUnitVector)
		//	according to
		//
		//		cos(θ) 1 + sin(θ) q
		//
		//	gives a corkscrew motion (a Clifford translation) of angle θ.
		//	In the present case, we want the left and right factors
		//	to each contribute a corkscrew motion of angle π/4,
		//	so their combined effect will be a pure rotation of angle π/2.
		//	Thus we interpolate as
		//
		//		cos(π/4) 1 + sin(π/4) q
		//	  = √½ 1 + √½ q
		//
		//	and the final action on a point p (written as a quaternion) is
		//
		//		p ↦ (√½ 1 + √½ q_left)·p·(√½ 1 + √½ q_right)
		//
		//	While that formula would give an essentially correct answer,
		//	multiplying by those irrational coefficients (√½) risks
		//	ending up with, for example, 0.0000000000000001 or 0.9999999999999999
		//	where 1.0 is expected.  To avoid such numerical errors,
		//	let's factor out those coefficients and combine them
		//
		//		√½ · √½ = 1/2
		//
		//	to get a formula that will give exact results
		//	(at least when the point p's coordinates' binary expressions
		//	all end in a trailing 0 or two),
		//
		//		p ↦ ½·(1 + q_left)·p·(1 + q_right)
		//
		
		let theLeftFactor  = simd_quatd(real: 1.0, imag: leftImaginaryUnitVector )
		let theRightFactor = simd_quatd(real: 1.0, imag: rightImaginaryUnitVector)

		let theLeftFactorInverse  = simd_quatd(real: 1.0, imag: -leftImaginaryUnitVector )
		let theRightFactorInverse = simd_quatd(real: 1.0, imag: -rightImaginaryUnitVector)

		let theOperation = { (leftFactor: simd_quatd, rightFactor: simd_quatd) in

			for thePoint in self.itsPoints {
			
				thePoint.itsPosition = 0.5	//	see explanation above
									 * leftFactor
									 * thePoint.itsPosition
									 * rightFactor
			}
		
			self.changeCount += 1
			self.updateCapabilityFlags()
		}
		let theDoOperation   = {
			theOperation(theLeftFactor, theRightFactor)
		}
		let theUndoOperation = {
			theOperation(theLeftFactorInverse, theRightFactorInverse)
		}
		
		doSomethingUndoably(
			doOperation: theDoOperation,
			undoOperation: theUndoOperation)
	}
	
	func changeNothing() {
	
		//	Do nothing.
		//	Please see the call to this function in Draw4DContentView
		//	for an explanation of why this is useful.

		let theNullOperation = {
			self.itsUndoRedoCount += 1
		}
		
		doSomethingUndoably(
			doOperation: theNullOperation,
			undoOperation: theNullOperation)
	}
	
	func undoLastChange() {
	
		if let theUndoManager = itsUndoManager,
		   theUndoManager.canUndo {
		   
			theUndoManager.undo()
		}
	}

	func doSomethingUndoably(
		doOperation:   @escaping () -> Void,
		undoOperation: @escaping () -> Void
	) {

		guard let theUndoManager = itsUndoManager
		else { return }
	
		doOperation()

		//	Note: We rely on theUndoManager.groupsByEvent = false
		
		theUndoManager.beginUndoGrouping()
		theUndoManager.registerUndo(withTarget: self) { doc in
			
			//	When the user requests an Undo action,
			//	the UndoManager will
			//
			//	1. Swap the undo and redo queues,
			//
			//	2. call this code,
			//
			doc.doSomethingUndoably(
				doOperation: undoOperation,
				undoOperation: doOperation)
			//
			//	3. and then swap the undo and redo queues back again.
			//
			//	So this closure must call registerUndo() synchronously,
			//	before the UndoManager swaps the undo and redo queues back.
			//	If we instead called registerUndo() within a Task,
			//	it wouldn't get executed until after the undo and redo queues
			//	had been swapped back.
		}
		theUndoManager.endUndoGrouping()
	}
	

// MARK: -
// MARK: Point eligibility

	func pointsAreConnected(
		_ pointA: Draw4DPoint,
		_ pointB: Draw4DPoint
	) -> Bool {
	
		let theConnectingEdge = itsEdges.first(where: {anEdge in
		
			(anEdge.itsStart === pointA && anEdge.itsEnd === pointB)
		 ||
			(anEdge.itsStart === pointB && anEdge.itsEnd === pointA)
		})
		
		return theConnectingEdge != nil
	}
	
	func pointIsSaturated(
		_ point: Draw4DPoint
	) -> Bool {
	
		var theIncidentEdgeCount = 0
		for theEdge in itsEdges {
		
			if theEdge.itsStart === point
			|| theEdge.itsEnd   === point {
			
				theIncidentEdgeCount += 1
			}
		}
		
		return theIncidentEdgeCount == itsPoints.count - 1
	}


// MARK: -
// MARK: File encoding

	enum Draw4DFileEncodingError: Error {
		case fileFormatError(reason: String)
	}

	func encodeFileFormat() -> String {

		var theDrawingAsText = ""
		
		theDrawingAsText.append("4D Draw figure\n")
		theDrawingAsText.append("format 1.1\n")

		theDrawingAsText.append(itsContentIsLocked ?
									"content is locked\n" :
									"content is unlocked\n")
		theDrawingAsText.append("\n")

		
		//	Write the points.

		theDrawingAsText.append("points\n")

		for thePoint in itsPoints {
			
			theDrawingAsText.append(
				String(format: "  %19.16lf %19.16lf %19.16lf %19.16lf\n",
						thePoint.itsPosition.imag[0],
						thePoint.itsPosition.imag[1],
						thePoint.itsPosition.imag[2],
						thePoint.itsPosition.real))
		}
		theDrawingAsText.append("\n")

		
		//	Write the edges.

		theDrawingAsText.append("edges\n")
		
		for theEdge in itsEdges {
		
			//	Note:  The triple "===" checks whether
			//	two references point to the same instance.
			
			guard let theStartIndex = itsPoints.firstIndex(where: { thePoint in
				thePoint === theEdge.itsStart
			}) else {
				preconditionFailure( "Internal error:  theEdge.itsStart ∉ itsPoints")
			}
			guard let  theEndIndex  = itsPoints.firstIndex(where: { thePoint in
				thePoint === theEdge.itsEnd
			}) else {
				preconditionFailure( "Internal error:   theEdge.itsEnd  ∉ itsPoints")
			}

			theDrawingAsText.append(
				String(format: "  %3d %3d\n",
						theStartIndex,
						theEndIndex))
		}

		return theDrawingAsText
	}

	func decodeFileFormat(
		_ drawingAsText: String
	) throws {

		//
		//	Split the drawingAsText String into tokens, separated by white space.
		//
		let theRawTokens = drawingAsText.components(separatedBy: .whitespacesAndNewlines)
		let t = theRawTokens.filter{ $0.count > 0 }	//	't' = "tokens"
		let theNumTokens = t.count

		//
		//	Read the file header.
		//
		
		if  t[0] != "4D" || t[1] != "Draw" || t[2] != "figure"
		 || t[3] != "format" {
			throw Draw4DFileEncodingError.fileFormatError(reason: "invalid header format")
		}
		
		let theVersionTokens = t[4].split(separator: ".")
		guard let theFileFormatMajorVersionNumber = Int(theVersionTokens[0]) else {
			throw Draw4DFileEncodingError.fileFormatError(reason: "unparsable major version number")
		}
		guard let theFileFormatMinorVersionNumber = Int(theVersionTokens[1]) else {
			throw Draw4DFileEncodingError.fileFormatError(reason: "unparsable minor version number")
		}

		//	Currently only versions 1.0 and 1.1 exist.
		if theFileFormatMajorVersionNumber != 1
		|| theFileFormatMinorVersionNumber < 0
		|| theFileFormatMinorVersionNumber > 1 {
			throw Draw4DFileEncodingError.fileFormatError(reason: "invalid file format version number")
		}
		
		//	Count the tokens as we go along.
		var i = 5
		
		if theFileFormatMajorVersionNumber > 1		//	version 1.1 or later?
		|| theFileFormatMinorVersionNumber > 0 {
				
			if  t[i] != "content" || t[i+1] != "is" {
				throw Draw4DFileEncodingError.fileFormatError(reason: "missing 'content is (un)locked' line")
			}

			switch t[i+2] {
			case "locked":
				itsContentIsLocked = true
			case "unlocked":
				itsContentIsLocked = false
			default:
				throw Draw4DFileEncodingError.fileFormatError(reason: "invalid 'content is (un)locked' line")
			}

			i += 3
		}
		
		//	Read the points.

		if t[i] != "points" {
			throw Draw4DFileEncodingError.fileFormatError(reason: "'points' section not found at expected location")
		}
		i += 1
		
		var theNumPoints = 0
		while i + 4 <= theNumTokens
				&& t[i] != "edges" {

			guard let x = Double(t[i + 0]),
				  let y = Double(t[i + 1]),
				  let z = Double(t[i + 2]),
				  let w = Double(t[i + 3]) else {
				throw Draw4DFileEncodingError.fileFormatError(reason: "invalid data for point (\theNumPoints)")
			}
			i += 4

			itsPoints.append(Draw4DPoint(at: simd_quatd(ix: x, iy: y, iz: z, r: w)))
			theNumPoints += 1
		}

		//	Read the edges.

		if t[i] != "edges" {
			throw Draw4DFileEncodingError.fileFormatError(reason:
					"'edges' section not found at expected location")
		}
		i += 1
		
		var theNumEdges = 0
		while i + 2 <= theNumTokens {

			guard let a = Int(t[i + 0]),
				  let b = Int(t[i + 1]) else {
				throw Draw4DFileEncodingError.fileFormatError(reason:
						"invalid data for edge (\theNumEdges)")
			}
			i += 2

			if a < 0
			|| a >= theNumPoints
			|| b < 0
			|| b >= theNumPoints {
				throw Draw4DFileEncodingError.fileFormatError(reason:
						"an index for edge (\theNumEdges) exceeds the number of points (or is negative)")
			}

			itsEdges.append(Draw4DEdge(
							from: itsPoints[a],
							to: itsPoints[b]))
			theNumEdges += 1
		}
	}
}
