--import config.lua
--import utils.lua
--import uiLib.lua
--import polyUtil.lua

--import userConfig.lua

LevelEditor = {}

LevelEditor.currentPageId = 1
LevelEditor.pagesData = {}

LevelEditor.screenWidth = 340.0
LevelEditor.screenHeight = 320.0
LevelEditor.screenSize = vec2(LevelEditor.screenWidth, LevelEditor.screenHeight)
LevelEditor.rightSideSpacing = 30.0

CAMERA_PERSPECTIVE = 1
CAMERA_ORTHO = 2
LevelEditor.cameraType = CAMERA_PERSPECTIVE

LevelEditor.camSideMat = mat4()
LevelEditor.camPerspMat = mat4()

LevelEditor.camManipSide = {pos = vec3(10.0, 0.0, 0.0), rot = eulerRot(0.0, 90.0, 0.0), focusDist = 0.25, ortho = true}
LevelEditor.camManipPersp = {pos = vec3(0.0, 0.0, 10.0), rot = eulerRot(0.0, 0.0, 0.0), focusDist = 10.0, ortho = false}

LevelEditor.camFov = degToRad(35.0)
LevelEditor.orthoCamZoomMin = 0.05
LevelEditor.orthoCamZoomMax = 2.0

LevelEditor.mouseInViewport = -1
LevelEditor.clickedViewport = -1
LevelEditor.mousePressedInViewport = -1
LevelEditor.manipulatingCamera = false
LevelEditor.mouseClickStartedOutside = false
LevelEditor.mousePressedOnUI = false
LevelEditor.skipClickRelease = false

LevelEditor.sideViewAxis = "X"

LevelEditor.models = {}
LevelEditor.meshes = {}
LevelEditor.modelMeshes = {}
LevelEditor.localBoundingBoxes = {}
LevelEditor.boundingBoxes = {}
LevelEditor.hoveredModel = -1
LevelEditor.selectedModels = {}

LevelEditor.propValues = {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0}
LevelEditor.propSliders = {}
LevelEditor.propsEnabled = false

LevelEditor.bilinearInterp = false
LevelEditor.ditherEnabled = false

LevelEditor.shadingType = 1

LevelEditor.activeTool = "select"
LevelEditor.draggingSelection = false

LevelEditor.undoStack = {}
LevelEditor.undoStackId = 0
LevelEditor.dirty = false

LevelEditor.assetName = ""

LevelEditor.snapEnabled = false
LevelEditor.tileBoundsEnabled = false
LevelEditor.snapPosition = 0.5
LevelEditor.snapRotation = 15.0
LevelEditor.snapScale = 0.25

LevelEditor.moveDragging = false
LevelEditor.moveClickedViewport = -1
LevelEditor.moveStartMousePos3d = nil
LevelEditor.moveModelStartPositions = {}

LevelEditor.rotateDragging = false
LevelEditor.rotateClickedViewport = -1
LevelEditor.rotateCenter = nil
LevelEditor.rotateOffsetMat = nil
LevelEditor.rotateRestMat = nil
LevelEditor.rotateStartForward = nil
LevelEditor.rotateStartDir = nil
LevelEditor.rotateModelStartPositions = {}
LevelEditor.rotateModelStartRotations = {}
LevelEditor.rotateModelStartScales = {}

LevelEditor.scaleDragging = false
LevelEditor.scaleClickedViewport = -1
LevelEditor.scaleCenter = nil
LevelEditor.scaleRest = nil
LevelEditor.scaleModelStartPositions = {}
LevelEditor.scaleModelStartRotations = {}
LevelEditor.scaleModelStartScales = {}

LevelEditor.cloneDragging = false
LevelEditor.cloneModelOffsets = {}
LevelEditor.cloneNewModelIndices = {}
LevelEditor.clonePlaneOrigin = vec3(0.0, 0.0, 0.0)

LevelEditor.loadAssetDragging = false
LevelEditor.loadAssetModelIndex = -1
LevelEditor.loadAssetPlaneOrigin = vec3(0.0, 0.0, 0.0)

LevelEditor.toolButtons = {}
LevelEditor.toolButtonsByName = {}

function start()
	UILib.init()

	LevelEditor.currentPageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

	Utils.registerShortcut("frame selected", "shortcutFrameSelected", "F", nil, Config)

	Utils.registerShortcut("select", "shortcutSelectTool", "Q", nil, Config)
	Utils.registerShortcut("move", "shortcutMoveTool", "W", nil, Config)
	Utils.registerShortcut("rotate", "shortcutRotateTool", "E", nil, Config)
	Utils.registerShortcut("scale", "shortcutScaleTool", "R", nil, Config)
	Utils.registerShortcut("snap", "shortcutSnap", "S", nil, Config)
	Utils.registerShortcut("clone", "shortcutCloneTool", "C", nil, Config)
	Utils.registerShortcut("delete", "shortcutDeleteSelected", "DELETE", nil, Config)
	Utils.registerShortcut("delete2", "shortcutDeleteSelected", "BACKSPACE", nil, Config)

	local programId = getProgramId()
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "escape", key = KEY_ESCAPE, callback = "shortcutEscape", programId = programId})

	buildUI()

	storeUndo()
	LevelEditor.dirty = false

	setSideViewAxis("Y")

	LevelEditor.tileBoundsEnabled = Utils.getAppVar("tileBoundsEnabled") or false
	LevelEditor.snapEnabled = Utils.getAppVar("levelEditor_snapEnabled") or false
	_setCameraType(Utils.getAppVar("cameraType") or CAMERA_PERSPECTIVE)
end

function clearProgram()
	_newLevel()
end

function programActive(state)
	if state == false then return end

	LevelEditor.bilinearInterp = invokeProgram(MAIN_PROGRAMID, "getProjectVar", "video_bilinearInterpEnabled")
	LevelEditor.ditherEnabled = invokeProgram(MAIN_PROGRAMID, "getProjectVar", "video_ditherEnabled")

	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

	LevelEditor.meshes = {}

	for i = 1, #LevelEditor.models do
		if not isValidAddress(LevelEditor.models[i]) then
			goto continueReload
		end

		local path = getModelData(LevelEditor.models[i], "path", "")
		if path ~= "" and path ~= "__utility__" then
			local mat = getModelMatrix(LevelEditor.models[i])
			local name = getModelName(LevelEditor.models[i])
			-- destroy drops the model data, so the mirror flag has to be carried across by hand
			local pos = vec3(mat.position)
			local rot = mat.rotation
			local signedScale = _getModelSignedScale(LevelEditor.models[i])
			destroy(LevelEditor.models[i])

			local rootModel = loadModel(path, pageId)[1]
			setModelData(rootModel, "path", path)
			setModelName(rootModel, name)
			if LevelEditor.tileBoundsEnabled then
				local tsx = getModelData(rootModel, "tileSizeX", 1)
				local tsy = getModelData(rootModel, "tileSizeY", 1)
				local tsz = getModelData(rootModel, "tileSizeZ", 1)
				local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
				LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
			else
				LevelEditor.localBoundingBoxes[i] = computeWorldBoundingBox(rootModel)
			end
			LevelEditor.modelMeshes[i] = {}
			collectMeshes(rootModel, LevelEditor.modelMeshes[i])

			LevelEditor.models[i] = rootModel
			mat = _setModelTransform(rootModel, pos, rot, signedScale)
			LevelEditor.boundingBoxes[i] = transformBounds(LevelEditor.localBoundingBoxes[i], mat)
		end

		if LevelEditor.modelMeshes[i] then
			for _, mesh in ipairs(LevelEditor.modelMeshes[i]) do
				LevelEditor.meshes[#LevelEditor.meshes + 1] = mesh
			end
		end

		::continueReload::
	end

	if LevelEditor.hierarchyInspector and LevelEditor.hierarchyInspector.visible and LevelEditor.levelHierarchy then
		LevelEditor.hierarchyInspector:inspect(LevelEditor.levelHierarchy)
	end

	if LevelEditor.assetName ~= "" then
		invokeProgram(MAIN_PROGRAMID, "selectAsset", LevelEditor.assetName)
	end

	LevelEditor.setShadingType(Utils.getAppVar("levelEditor_shadingType") or 2)
end

function setSelectedModels(list)
	LevelEditor.selectedModels = list

	selectionChanged()
end

function isModelSelected(modelIndex)
	for i = 1, #LevelEditor.selectedModels do
		if LevelEditor.selectedModels[i] == modelIndex then
			return true
		end
	end
	return false
end

function modelNameFromPath(path)
	local tokens = splitStr(path, ".")
	return tokens[1] or path
end

function nextUtilityName()
	local count = 0
	for i = 1, #LevelEditor.models do
		if getModelData(LevelEditor.models[i], "path", "") == "__utility__" then
			count = count + 1
		end
	end
	return "util" .. (count + 1)
end

function setAssetName(assetName)
	assetName = replaceStr(assetName, ".lvl", "")

	LevelEditor.assetName = assetName

	updateViewTitle()
end

function updateViewTitle()
	local programId = getProgramId()
	local topBarTitle = "level editor"
	if LevelEditor.assetName ~= "" then
		topBarTitle = "level editor: " .. LevelEditor.assetName
	end
	if LevelEditor.dirty then
		topBarTitle = topBarTitle .. " *"
	end

	invokeProgram(MAIN_PROGRAMID, "setViewTitle", programId, topBarTitle)
end

function findNewLevelName()
	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")
	local files = listFiles("page" .. pageId)

	for i = 1, 32 do
		local fileExists = false

		for fileIt = 1, #files do
			if files[fileIt] == "le" .. i .. ".lvl" then
				fileExists = true
				break
			end
		end

		if fileExists == false then
			return "le" .. i
		end
	end

	return ""
end

function newLevelMenuPressed()
	UILib.openModal(LevelEditor.screenWidth, LevelEditor.screenHeight, "all unsaved changes will be lost",
		 "ok", function()
			_newLevel()
		 end,
		 "cancel", function() UILib.closeModal() end)
end

function _newLevel()
	for i = 1, #LevelEditor.models do
		destroy(LevelEditor.models[i])
	end

	LevelEditor.models = {}
	LevelEditor.localBoundingBoxes = {}
	LevelEditor.boundingBoxes = {}
	LevelEditor.modelMeshes = {}
	LevelEditor.meshes = {}
	setSelectedModels({})
	LevelEditor.hoveredModel = -1
	LevelEditor.levelHierarchy = {}
	if LevelEditor.hierarchyInspector then
		LevelEditor.hierarchyInspector:setVisible(false)
	end
	LevelEditor.undoStack = {}
	LevelEditor.undoStackId = 0
	storeUndo()
	LevelEditor.dirty = false

	setAssetName("")
end

function saveMenuPressed()
	if LevelEditor.assetName == "" then
		local newName = findNewLevelName()
		setAssetName(newName)
	end

	local data = {models = {}}

	for i = 1, #LevelEditor.models do
		local mat = getModelMatrix(LevelEditor.models[i])
		local euler = quatToEuler(mat.rotation)
		local scale = _getModelSignedScale(LevelEditor.models[i])
		local modelData = getAllModelData(LevelEditor.models[i])
		data.models[i] = {
			path = getModelData(LevelEditor.models[i], "path", ""),
			name = getModelName(LevelEditor.models[i]),
			position = {mat.position.x, mat.position.y, mat.position.z},
			rotation = {euler.x, euler.y, euler.z},
			scale = {scale.x, scale.y, scale.z},
			data = next(modelData) ~= nil and modelData or nil
		}
	end

	if LevelEditor.levelHierarchy and #LevelEditor.levelHierarchy > 0 then
		data.hierarchy = serializeHierarchy(LevelEditor.levelHierarchy)
	end

	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")
	local dataStr = Utils.serializeTable(data)
	saveFile(LevelEditor.assetName .. ".lvl", pageId, dataStr)

	LevelEditor.dirty = false
	updateViewTitle()

	invokeProgram(MAIN_PROGRAMID, "refreshAssetIcons")
end

function saveNewMenuPressed()
	local newName = findNewLevelName()
	setAssetName(newName)
	saveMenuPressed()
end

function _loadLevel(filename)
	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

	for i = 1, #LevelEditor.models do
		destroy(LevelEditor.models[i])
	end

	local topModels = loadLevel(filename, pageId)

	LevelEditor.models = {}
	LevelEditor.localBoundingBoxes = {}
	LevelEditor.boundingBoxes = {}
	LevelEditor.modelMeshes = {}
	LevelEditor.meshes = {}
	setSelectedModels({})
	LevelEditor.hoveredModel = -1

	local function traverseModel(model)
		local path = getModelData(model, "path", "")
		if path ~= "" then
			local idx = #LevelEditor.models + 1
			LevelEditor.models[idx] = model

			local mat = getModelMatrix(model)

			if path == "__utility__" then
				LevelEditor.localBoundingBoxes[idx] = {min = vec3(-0.25, -0.25, -0.25), max = vec3(0.25, 0.25, 0.25)}
				LevelEditor.modelMeshes[idx] = {}
			else
				if LevelEditor.tileBoundsEnabled then
					local tsx = getModelData(model, "tileSizeX", 1)
					local tsy = getModelData(model, "tileSizeY", 1)
					local tsz = getModelData(model, "tileSizeZ", 1)
					local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
					LevelEditor.localBoundingBoxes[idx] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
				else
					local identityMat = mat4()
					setModelMatrix(model, identityMat)
					LevelEditor.localBoundingBoxes[idx] = computeWorldBoundingBox(model)
					setModelMatrix(model, mat)
				end
				collectMeshes(model, LevelEditor.meshes)
				LevelEditor.modelMeshes[idx] = {}
				collectMeshes(model, LevelEditor.modelMeshes[idx])
			end

			LevelEditor.boundingBoxes[idx] = transformBounds(LevelEditor.localBoundingBoxes[idx], mat)
		end

		local children = getModelChildren(model)
		for i = 1, #children do
			traverseModel(children[i])
		end
	end

	for i = 1, #topModels do
		traverseModel(topModels[i])
	end

	LevelEditor.levelHierarchy = buildLevelHierarchyFromTree()

	LevelEditor.undoStack = {}
	LevelEditor.undoStackId = 0
	storeUndo()
	LevelEditor.dirty = false
	updateViewTitle()
end

function setSideViewAxis(axis)
	if axis == LevelEditor.sideViewAxis then
		return
	end

	local oldButton = LevelEditor.axisButtons[LevelEditor.sideViewAxis]
	oldButton.textColor = vec4Copy(Config.uiColors.panelLight)
	setSprite(oldButton.sprite, oldButton)

	local forward = vec3(0.0, 0.0, 1.0) * LevelEditor.camManipSide.rot
	local pos = LevelEditor.camManipSide.pos
	local dist = vec3Dot(pos, forward)
	local projected = pos - (forward * dist)

	local newRot
	if axis == "X" then
		newRot = eulerRot(0.0, 90.0, 0.0)
	elseif axis == "Y" then
		newRot = eulerRot(-90.0, 0.0, 0.0)
	elseif axis == "Z" then
		newRot = eulerRot(0.0, 0.0, 0.0)
	end

	local newForward = vec3(0.0, 0.0, 1.0) * newRot
	LevelEditor.camManipSide.rot = newRot
	LevelEditor.camManipSide.pos = projected + (newForward * dist)

	LevelEditor.sideViewAxis = axis
	LevelEditor.sideViewLabelData.text = LevelEditor.sideViewAxisNames[axis]
	setSprite(LevelEditor.sideViewLabelSprite, LevelEditor.sideViewLabelData)

	local newButton = LevelEditor.axisButtons[axis]
	newButton.textColor = vec4Copy(Config.uiColors.icon)
	setSprite(newButton.sprite, newButton)
end

function LevelEditor.switchCameraPerspective()
	if LevelEditor.cameraType == CAMERA_PERSPECTIVE then
		_setCameraType(CAMERA_ORTHO)
	else
		_setCameraType(CAMERA_PERSPECTIVE)
	end
end

function _setCameraType(cameraType)
	local camManip = LevelEditor.camManipPersp
	local tanHalfFov = math.tan(LevelEditor.camFov / 2.0)
	local forward = vec3(0.0, 0.0, 1.0) * camManip.rot

	if cameraType == CAMERA_ORTHO then
		local focusPoint = camManip.pos - (forward * camManip.focusDist)
		LevelEditor.cameraType = CAMERA_ORTHO
		camManip.ortho = true
		camManip.perspFocusDist = camManip.focusDist
		camManip.focusDist = clamp(1.0 / (camManip.perspFocusDist * tanHalfFov), LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax)
	else
		if camManip.perspFocusDist == nil then
			camManip.perspFocusDist = 10.0
			camManip.focusDist = 0.25
		end

		local focusPoint = camManip.pos - (forward * camManip.perspFocusDist)
		LevelEditor.cameraType = CAMERA_PERSPECTIVE
		camManip.ortho = false
		camManip.focusDist = clamp(1.0 / (camManip.focusDist * tanHalfFov), 1.0, 50.0)
		camManip.perspFocusDist = nil
		camManip.pos = focusPoint + (forward * camManip.focusDist)
	end

	LevelEditor.cameraPerspButton.text = camManip.ortho and "o" or "p"
	LevelEditor.cameraPerspButton.changed = true

	Utils.setAppVar("cameraType", LevelEditor.cameraType)

	invokeProgram(MAIN_PROGRAMID, "refreshModelVisualizer")
end

function makeToolButton(posX, posY, iconUV, toolName, callback)
	local button = UILib.buttonIcon(
		vec3(posX, posY, 0.0),
		vec2(12.0, 12.0),
		Config.uiColors.button,
		vec2(2.0, 2.0), vec2(8.0, 8.0),
		iconUV, vec2(8.0, 8.0),
		Config.uiColors.icon, callback)
	button.roundCorner = 0.3
	button.toolName = toolName

	LevelEditor.toolButtons[#LevelEditor.toolButtons + 1] = button
	LevelEditor.toolButtonsByName[toolName] = button

	return button
end

function setActiveTool(toolName)
	LevelEditor.activeTool = toolName

	for i = 1, #LevelEditor.toolButtons do
		UILib.setButtonPressed(LevelEditor.toolButtons[i], false)
	end

	local button = LevelEditor.toolButtonsByName[toolName]
	if button ~= nil then
		UILib.setButtonPressed(button, true)
	end
end

function snapSwitch()
	LevelEditor.snapEnabled = not LevelEditor.snapEnabled

	Utils.setAppVar("levelEditor_snapEnabled", LevelEditor.snapEnabled)
end

function selectToolStart()
	UILib.setCursor(UILib.CURSOR)

	setActiveTool("select")
end

function moveToolStart()
	UILib.setCursor(UILib.CURSOR)

	setActiveTool("move")
end

function rotateToolStart()
	UILib.setCursor(UILib.CURSOR)

	setActiveTool("rotate")
end

function scaleToolStart()
	UILib.setCursor(UILib.CURSOR)

	setActiveTool("scale")
end

function boxEditToolStart()
	UILib.setCursor(UILib.CURSOR)
	
	setActiveTool("boxEdit")
end

function cloneToolStart()
	local sourceInfo = {}
	local avgPos = vec3(0.0, 0.0, 0.0)
	local count = 0

	for i = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[i]
		local mat = getModelMatrix(LevelEditor.models[modelIndex])
		local pos = vec3(mat.position)
		sourceInfo[#sourceInfo + 1] = {
			position = pos,
			rotation = mat.rotation,
			scale = _getModelSignedScale(LevelEditor.models[modelIndex]),
			assetPath = getModelData(LevelEditor.models[modelIndex], "path", ""),
			name = getModelName(LevelEditor.models[modelIndex])
		}
		avgPos = avgPos + pos
		count = count + 1
	end

	if count == 0 then return end
	avgPos = avgPos * (1.0 / count)

	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

	LevelEditor.cloneModelOffsets = {}
	LevelEditor.cloneNewModelIndices = {}
	local newSelection = {}

	for i = 1, #sourceInfo do
		local info = sourceInfo[i]
		local offset = info.position - avgPos

		local rootModel
		local newIndex = #LevelEditor.models + 1
		if info.assetPath == "__utility__" then
			rootModel = allocModel()
			setModelData(rootModel, "utility", 1)
			LevelEditor.localBoundingBoxes[newIndex] = {min = vec3(-0.25, -0.25, -0.25), max = vec3(0.25, 0.25, 0.25)}
			LevelEditor.modelMeshes[newIndex] = {}
		else
			rootModel = loadModel(info.assetPath, pageId)[1]
			if LevelEditor.tileBoundsEnabled then
				local tsx = getModelData(rootModel, "tileSizeX", 1)
				local tsy = getModelData(rootModel, "tileSizeY", 1)
				local tsz = getModelData(rootModel, "tileSizeZ", 1)
				local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
				LevelEditor.localBoundingBoxes[newIndex] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
			else
				LevelEditor.localBoundingBoxes[newIndex] = computeWorldBoundingBox(rootModel)
			end
			collectMeshes(rootModel, LevelEditor.meshes)
			LevelEditor.modelMeshes[newIndex] = {}
			collectMeshes(rootModel, LevelEditor.modelMeshes[newIndex])
		end
		LevelEditor.models[newIndex] = rootModel
		setModelData(rootModel, "path", info.assetPath)
		setModelName(rootModel, info.name)

		local cloneMat = _setModelTransform(rootModel,
			info.position + vec3(1, 0, 0), info.rotation, info.scale)
		LevelEditor.boundingBoxes[newIndex] = transformBounds(LevelEditor.localBoundingBoxes[newIndex], cloneMat)

		if LevelEditor.levelHierarchy then
			LevelEditor.levelHierarchy[#LevelEditor.levelHierarchy + 1] = {
				name = info.name,
				modelIndex = newIndex
			}
		end

		LevelEditor.cloneNewModelIndices[#LevelEditor.cloneNewModelIndices + 1] = newIndex
		LevelEditor.cloneModelOffsets[#LevelEditor.cloneModelOffsets + 1] = vec3(offset.x, offset.y, offset.z)
		newSelection[#newSelection + 1] = newIndex
	end

	setSelectedModels(newSelection)
	LevelEditor.clonePlaneOrigin = vec3(avgPos.x, avgPos.y, avgPos.z)
	LevelEditor.cloneDragging = true
	setActiveTool("clone")
end

function addUtilityModelToolStart()
	local model = allocModel()
	setModelData(model, "utility", 1)

	local modelIndex = #LevelEditor.models + 1
	LevelEditor.models[modelIndex] = model
	setModelData(model, "path", "__utility__")

	local name = nextUtilityName()
	setModelName(model, name)

	local utilityBoxSize = 1.0
	LevelEditor.localBoundingBoxes[modelIndex] = {min = vec3(-utilityBoxSize, -utilityBoxSize, -utilityBoxSize), max = vec3(utilityBoxSize, utilityBoxSize, utilityBoxSize)}
	LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], getModelMatrix(model))
	LevelEditor.modelMeshes[modelIndex] = {}

	if LevelEditor.levelHierarchy then
		LevelEditor.levelHierarchy[#LevelEditor.levelHierarchy + 1] = {
			name = name,
			modelIndex = modelIndex
		}
	end

	LevelEditor.loadAssetModelIndex = modelIndex
	LevelEditor.loadAssetPlaneOrigin = vec3(0.0, 0.0, 0.0)
	LevelEditor.loadAssetDragging = true
	setSelectedModels({modelIndex})
	setActiveTool("addUtilityModel")
end

function shortcutFixedRotate()
	if UILib.focusedSpriteWidget ~= nil then return end
	fixedRotateToolStart()
end

function fixedRotateToolStart()
	if #LevelEditor.selectedModels == 0 then return end

	local center = vec3(0.0, 0.0, 0.0)
	local count = 0
	for i = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[i]
		local mat = getModelMatrix(LevelEditor.models[modelIndex])
		center = center + mat.position
		count = count + 1
	end
	center = center * (1.0 / count)

	local fwd = LevelEditor.camSideMat.forwardAxis
	local halfAngle = degToRad(-90.0) * 0.5
	local s = math.sin(halfAngle)
	local rotQuat = quat(fwd.x * s, fwd.y * s, fwd.z * s, math.cos(halfAngle))

	local rotateMat = mat4()
	rotateMat.rotation = rotQuat

	for i = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[i]
		local mat = getModelMatrix(LevelEditor.models[modelIndex])

		local offset = mat.position - center
		local newMat = _setModelTransform(LevelEditor.models[modelIndex],
			center + (rotateMat * offset),
			mat.rotation * rotQuat,
			_getModelSignedScale(LevelEditor.models[modelIndex]))

		LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], newMat)
	end

	storeUndo()
end

function shortcutSelectTool()
	if UILib.focusedSpriteWidget ~= nil then return end
	selectToolStart()
end

function shortcutMoveTool()
	if UILib.focusedSpriteWidget ~= nil then return end
	moveToolStart()
end

function shortcutRotateTool()
	if UILib.focusedSpriteWidget ~= nil then return end
	rotateToolStart()
end

function shortcutScaleTool()
	if UILib.focusedSpriteWidget ~= nil then return end
	scaleToolStart()
end

function shortcutSnap()
	if UILib.focusedSpriteWidget ~= nil then return end
	snapSwitch()
end

function shortcutCloneTool()
	if UILib.focusedSpriteWidget ~= nil then return end
	cloneToolStart()
end

function shortcutDeleteSelected()
	if UILib.focusedSpriteWidget ~= nil then return end
	deleteSelectedModels()
end

function shortcutSave()
	if UILib.focusedSpriteWidget ~= nil then return end
	saveMenuPressed()
end

function _moveSelectedModels(deltaVec)
	if #LevelEditor.selectedModels == 0 then return end

	local camRotMat = mat4(LevelEditor.camSideMat)
	camRotMat.position = vec3(0.0, 0.0, 0.0)

	local step = LevelEditor.snapPosition
	local camDeltaVec = camRotMat * (deltaVec * step)

	for i = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[i]
		local mat = getModelMatrix(LevelEditor.models[modelIndex])
		local newPos = mat.position + camDeltaVec

		if LevelEditor.snapEnabled then
			newPos.x = step * round(newPos.x / step)
			newPos.y = step * round(newPos.y / step)
			newPos.z = step * round(newPos.z / step)
		end

		mat.position = newPos
		setModelMatrix(LevelEditor.models[modelIndex], mat)
		LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
	end

	storeUndo()
end

function shortcutLeft()
	if UILib.focusedSpriteWidget ~= nil then return end
	_moveSelectedModels(vec3(-1.0, 0.0, 0.0))
end

function shortcutRight()
	if UILib.focusedSpriteWidget ~= nil then return end
	_moveSelectedModels(vec3(1.0, 0.0, 0.0))
end

function shortcutUp()
	if UILib.focusedSpriteWidget ~= nil then return end
	_moveSelectedModels(vec3(0.0, 1.0, 0.0))
end

function shortcutDown()
	if UILib.focusedSpriteWidget ~= nil then return end
	_moveSelectedModels(vec3(0.0, -1.0, 0.0))
end

function shortcutFrameSelected()
	if UILib.focusedSpriteWidget ~= nil then return end
	_frameSelected()
end

function _frameSelected()
	if #LevelEditor.models == 0 then
		return
	end

	local minPoint = vec3(9999.0, 9999.0, 9999.0)
	local maxPoint = vec3(-9999.0, -9999.0, -9999.0)

	local hasSelection = #LevelEditor.selectedModels > 0

	local function expandPoint(v)
		if v.x < minPoint.x then minPoint.x = v.x end
		if v.y < minPoint.y then minPoint.y = v.y end
		if v.z < minPoint.z then minPoint.z = v.z end
		if v.x > maxPoint.x then maxPoint.x = v.x end
		if v.y > maxPoint.y then maxPoint.y = v.y end
		if v.z > maxPoint.z then maxPoint.z = v.z end
	end

	local function expandBounds(bb)
		local m = bb.matrix
		expandPoint(m * vec3(bb.min.x, bb.min.y, bb.min.z))
		expandPoint(m * vec3(bb.max.x, bb.min.y, bb.min.z))
		expandPoint(m * vec3(bb.min.x, bb.max.y, bb.min.z))
		expandPoint(m * vec3(bb.min.x, bb.min.y, bb.max.z))
		expandPoint(m * vec3(bb.max.x, bb.max.y, bb.min.z))
		expandPoint(m * vec3(bb.max.x, bb.min.y, bb.max.z))
		expandPoint(m * vec3(bb.min.x, bb.max.y, bb.max.z))
		expandPoint(m * vec3(bb.max.x, bb.max.y, bb.max.z))
	end

	if hasSelection then
		for i = 1, #LevelEditor.selectedModels do
			expandBounds(LevelEditor.boundingBoxes[LevelEditor.selectedModels[i]])
		end
	else
		for i = 1, #LevelEditor.models do
			expandBounds(LevelEditor.boundingBoxes[i])
		end
	end

	local size = vec3Length(maxPoint - minPoint)
	if size < 0.001 then
		size = 1.0
	end

	local center = vec3Lerp(maxPoint, minPoint, 0.5)

	local sideForward = LevelEditor.camSideMat.forwardAxis
	LevelEditor.camManipSide.pos = center + (sideForward * 10.0)
	LevelEditor.camManipSide.focusDist = clamp((1.0 / size) * 0.5, LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax)

	local perspForward = LevelEditor.camPerspMat.forwardAxis
	if LevelEditor.camManipPersp.ortho then
		LevelEditor.camManipPersp.pos = center + (perspForward * 10.0)
		LevelEditor.camManipPersp.focusDist = clamp((1.0 / size) * 0.5, LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax)
		LevelEditor.camManipPersp.perspFocusDist = 10.0
	else
		LevelEditor.camManipPersp.pos = center + (perspForward * (size * 5.0))
		LevelEditor.camManipPersp.focusDist = vec3Length(LevelEditor.camManipPersp.pos - center)
	end
end

function _abortCurrentTool()
	local wasDragging = LevelEditor.moveDragging or LevelEditor.rotateDragging or
		LevelEditor.scaleDragging or LevelEditor.boxEditDragging or LevelEditor.cloneDragging or
		LevelEditor.loadAssetDragging or LevelEditor.draggingSelection

	if wasDragging then
		local undoEntry = LevelEditor.undoStack[LevelEditor.undoStackId]
		if undoEntry ~= nil then
			restoreUndoEntry(undoEntry)
		end
	end

	LevelEditor.moveDragging = false
	LevelEditor.rotateDragging = false
	LevelEditor.scaleDragging = false
	LevelEditor.boxEditDragging = false
	LevelEditor.cloneDragging = false
	LevelEditor.loadAssetDragging = false
	LevelEditor.draggingSelection = false

	setActiveTool("select")
	UILib.closeMenu()
end

function shortcutEscape()
	_abortCurrentTool()
end

function setPage(pageId)
	if pageId == LevelEditor.currentPageId then
		return
	end

	_abortCurrentTool()

	local pageData = LevelEditor.pagesData[LevelEditor.currentPageId]
	if pageData == nil then
		pageData = {}
		LevelEditor.pagesData[LevelEditor.currentPageId] = pageData
	end

	pageData.modelCount = 0
	pageData.modelPaths = {}
	pageData.modelNames = {}
	pageData.modelMatrices = {}
	pageData.modelDatas = {}
	for i = 1, #LevelEditor.models do
		if isValidAddress(LevelEditor.models[i]) then
			local idx = pageData.modelCount + 1
			pageData.modelPaths[idx] = getModelData(LevelEditor.models[i], "path", "")
			pageData.modelNames[idx] = getModelName(LevelEditor.models[i])
			local mat = getModelMatrix(LevelEditor.models[i])
			pageData.modelMatrices[idx] = {
				position = vec3(mat.position),
				rotation = mat.rotation,
				-- signed, like storeUndo and saveMenuPressed: mat.scale drops the mirror sign,
				-- which would restore the page with unmirrored geometry
				scale = _getModelSignedScale(LevelEditor.models[i])
			}
			pageData.modelDatas[idx] = getAllModelData(LevelEditor.models[i])
			pageData.modelCount = idx
		end
	end

	pageData.selectedModels = {}
	for i = 1, #LevelEditor.selectedModels do
		pageData.selectedModels[i] = LevelEditor.selectedModels[i]
	end
	pageData.undoStack = LevelEditor.undoStack
	pageData.undoStackId = LevelEditor.undoStackId
	pageData.dirty = LevelEditor.dirty
	pageData.assetName = LevelEditor.assetName
	pageData.activeTool = LevelEditor.activeTool
	if LevelEditor.levelHierarchy then
		pageData.levelHierarchy = deepCopyHierarchy(LevelEditor.levelHierarchy)
	else
		pageData.levelHierarchy = nil
	end

	for i = 1, #LevelEditor.models do
		if isValidAddress(LevelEditor.models[i]) then
			destroy(LevelEditor.models[i])
		end
	end

	LevelEditor.currentPageId = pageId

	local newPageData = LevelEditor.pagesData[pageId]

	if newPageData == nil then
		LevelEditor.models = {}
		LevelEditor.meshes = {}
		LevelEditor.modelMeshes = {}
		LevelEditor.localBoundingBoxes = {}
		LevelEditor.boundingBoxes = {}
		setSelectedModels({})
		LevelEditor.hoveredModel = -1
		LevelEditor.levelHierarchy = {}
		LevelEditor.undoStack = {}
		LevelEditor.undoStackId = 0
		storeUndo()
		LevelEditor.dirty = false
		setAssetName("")
		setActiveTool("select")
	else
		LevelEditor.models = {}
		LevelEditor.meshes = {}
		LevelEditor.modelMeshes = {}
		LevelEditor.localBoundingBoxes = {}
		LevelEditor.boundingBoxes = {}

		for i = 1, newPageData.modelCount do
			local rootModel
			if newPageData.modelPaths[i] == "__utility__" then
				rootModel = allocModel()
				setModelData(rootModel, "utility", 1)
				LevelEditor.localBoundingBoxes[i] = {min = vec3(-0.25, -0.25, -0.25), max = vec3(0.25, 0.25, 0.25)}
				LevelEditor.modelMeshes[i] = {}
			else
				rootModel = loadModel(newPageData.modelPaths[i], pageId)[1]
				if LevelEditor.tileBoundsEnabled then
					local tsx = getModelData(rootModel, "tileSizeX", 1)
					local tsy = getModelData(rootModel, "tileSizeY", 1)
					local tsz = getModelData(rootModel, "tileSizeZ", 1)
					local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
					LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
				else
					LevelEditor.localBoundingBoxes[i] = computeWorldBoundingBox(rootModel)
				end
				collectMeshes(rootModel, LevelEditor.meshes)
				LevelEditor.modelMeshes[i] = {}
				collectMeshes(rootModel, LevelEditor.modelMeshes[i])
			end

			LevelEditor.models[i] = rootModel
			setModelData(rootModel, "path", newPageData.modelPaths[i])
			setModelName(rootModel, newPageData.modelNames[i])

			if newPageData.modelDatas and newPageData.modelDatas[i] then
				for k, v in pairs(newPageData.modelDatas[i]) do
					setModelData(rootModel, k, v)
				end
				if LevelEditor.tileBoundsEnabled and newPageData.modelPaths[i] ~= "__utility__" then
					local tsx = getModelData(rootModel, "tileSizeX", 1)
					local tsy = getModelData(rootModel, "tileSizeY", 1)
					local tsz = getModelData(rootModel, "tileSizeZ", 1)
					local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
					LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
				end
			end

			local mat = _setModelTransform(rootModel,
				newPageData.modelMatrices[i].position,
				newPageData.modelMatrices[i].rotation,
				newPageData.modelMatrices[i].scale)

			LevelEditor.boundingBoxes[i] = transformBounds(LevelEditor.localBoundingBoxes[i], mat)
		end

		local restored = {}
		for i = 1, #newPageData.selectedModels do
			restored[i] = newPageData.selectedModels[i]
		end
		setSelectedModels(restored)
		LevelEditor.hoveredModel = -1
		LevelEditor.undoStack = newPageData.undoStack
		LevelEditor.undoStackId = newPageData.undoStackId
		LevelEditor.dirty = newPageData.dirty
		setAssetName(newPageData.assetName)
		setActiveTool(newPageData.activeTool)

		if newPageData.levelHierarchy then
			LevelEditor.levelHierarchy = deepCopyHierarchy(newPageData.levelHierarchy)
		else
			LevelEditor.levelHierarchy = {}
		end
	end

	if LevelEditor.hierarchyInspector then
		LevelEditor.hierarchyInspector:setVisible(false)
		UILib.setButtonPressed(LevelEditor.toolButtonsByName["edit hierarchy"], false)
	end
end

function deleteSelectedModels()
	if #LevelEditor.selectedModels == 0 then return end

	local deletedSet = {}
	for i = 1, #LevelEditor.selectedModels do
		deletedSet[LevelEditor.selectedModels[i]] = true
	end

	local keepIndices = {}
	for i = 1, #LevelEditor.models do
		if not deletedSet[i] then
			keepIndices[#keepIndices + 1] = i
		end
	end

	local newModels = {}
	local newLocalBoundingBoxes = {}
	local newBoundingBoxes = {}
	local newModelMeshes = {}

	for newIndex, oldIndex in ipairs(keepIndices) do
		newModels[newIndex] = LevelEditor.models[oldIndex]
		newLocalBoundingBoxes[newIndex] = LevelEditor.localBoundingBoxes[oldIndex]
		newBoundingBoxes[newIndex] = LevelEditor.boundingBoxes[oldIndex]
		newModelMeshes[newIndex] = LevelEditor.modelMeshes[oldIndex]
	end

	LevelEditor.models = newModels
	LevelEditor.localBoundingBoxes = newLocalBoundingBoxes
	LevelEditor.boundingBoxes = newBoundingBoxes
	LevelEditor.modelMeshes = newModelMeshes

	LevelEditor.meshes = {}
	for i = 1, #LevelEditor.models do
		for _, mesh in ipairs(LevelEditor.modelMeshes[i]) do
			LevelEditor.meshes[#LevelEditor.meshes + 1] = mesh
		end
	end

	if LevelEditor.levelHierarchy then
		local indexMap = {}
		for newIndex, oldIndex in ipairs(keepIndices) do
			indexMap[oldIndex] = newIndex
		end
		removeHierarchyNodesByIndex(LevelEditor.levelHierarchy, deletedSet)
		remapHierarchyIndices(LevelEditor.levelHierarchy, indexMap)
	end

	setSelectedModels({})
	LevelEditor.hoveredModel = -1

	storeUndo()
end

function removeHierarchyNodesByIndex(nodeList, deletedIndices)
	local i = 1
	while i <= #nodeList do
		if deletedIndices[nodeList[i].modelIndex] then
			local removed = table.remove(nodeList, i)
			if removed.children then
				for j = #removed.children, 1, -1 do
					table.insert(nodeList, i, removed.children[j])
				end
			end
		else
			if nodeList[i].children then
				removeHierarchyNodesByIndex(nodeList[i].children, deletedIndices)
			end
			i = i + 1
		end
	end
end

function remapHierarchyIndices(nodeList, indexMap)
	for i = 1, #nodeList do
		nodeList[i].modelIndex = indexMap[nodeList[i].modelIndex]
		if nodeList[i].children then
			remapHierarchyIndices(nodeList[i].children, indexMap)
		end
	end
end

function deepCopyHierarchy(nodeList)
	local copy = {}
	for i = 1, #nodeList do
		local node = nodeList[i]
		local newNode = {
			name = node.name,
			modelIndex = node.modelIndex
		}
		if node.children then
			newNode.children = deepCopyHierarchy(node.children)
		end
		copy[i] = newNode
	end
	return copy
end

function flattenHierarchyNodes(nodeList, result)
	for i = 1, #nodeList do
		result[#result + 1] = nodeList[i]
		if nodeList[i].children then
			flattenHierarchyNodes(nodeList[i].children, result)
		end
	end
end

function rebuildFlatArraysFromHierarchy()
	local flat = {}
	flattenHierarchyNodes(LevelEditor.levelHierarchy, flat)

	local newModels = {}
	local newLocalBB = {}
	local newBB = {}
	local newModelMeshes = {}
	local newMeshes = {}

	for i = 1, #flat do
		local idx = flat[i].modelIndex
		newModels[i] = LevelEditor.models[idx]
		newLocalBB[i] = LevelEditor.localBoundingBoxes[idx]
		newBB[i] = LevelEditor.boundingBoxes[idx]
		newModelMeshes[i] = LevelEditor.modelMeshes[idx]
		newMeshes[i] = LevelEditor.meshes[idx]
	end

	LevelEditor.models = newModels
	LevelEditor.localBoundingBoxes = newLocalBB
	LevelEditor.boundingBoxes = newBB
	LevelEditor.modelMeshes = newModelMeshes
	LevelEditor.meshes = newMeshes

	setSelectedModels({})

	for i = 1, #flat do
		flat[i].modelIndex = i
	end
end

function buildLevelHierarchyFromFlat()
	local hierarchy = {}
	for i = 1, #LevelEditor.models do
		local name = getModelName(LevelEditor.models[i])
		if name == "" then name = "model " .. i end
		hierarchy[i] = {
			name = name,
			modelIndex = i
		}
	end
	return hierarchy
end

function findModelIndex(model)
	for i = 1, #LevelEditor.models do
		if LevelEditor.models[i] == model then
			return i
		end
	end
	return nil
end

function buildLevelHierarchyFromTree()
	local childrenOf = {}
	local roots = {}
	for i = 1, #LevelEditor.models do
		local parent = getModelParent(LevelEditor.models[i])
		if parent ~= nil then
			local parentIdx = findModelIndex(parent)
			if parentIdx then
				if childrenOf[parentIdx] == nil then
					childrenOf[parentIdx] = {}
				end
				childrenOf[parentIdx][#childrenOf[parentIdx] + 1] = i
			else
				roots[#roots + 1] = i
			end
		else
			roots[#roots + 1] = i
		end
	end

	local function buildNode(modelIdx)
		local name = getModelName(LevelEditor.models[modelIdx])
		if name == "" then name = "model " .. modelIdx end
		local node = {
			name = name,
			modelIndex = modelIdx
		}
		if childrenOf[modelIdx] then
			node.children = {}
			for _, childIdx in ipairs(childrenOf[modelIdx]) do
				node.children[#node.children + 1] = buildNode(childIdx)
			end
		end
		return node
	end

	local hierarchy = {}
	for _, rootIdx in ipairs(roots) do
		hierarchy[#hierarchy + 1] = buildNode(rootIdx)
	end
	return hierarchy
end

function serializeHierarchy(nodeList)
	local result = {}
	for i = 1, #nodeList do
		local node = nodeList[i]
		local entry = {modelIndex = node.modelIndex}
		if node.children and #node.children > 0 then
			entry.children = serializeHierarchy(node.children)
		end
		result[i] = entry
	end
	return result
end

function deserializeHierarchy(savedNodes)
	local result = {}
	for i = 1, #savedNodes do
		local saved = savedNodes[i]
		local name = getModelName(LevelEditor.models[saved.modelIndex])
		if name == "" then name = "model " .. saved.modelIndex end
		local node = {
			modelIndex = saved.modelIndex,
			name = name
		}
		if saved.children and #saved.children > 0 then
			node.children = deserializeHierarchy(saved.children)
		end
		result[i] = node
	end
	return result
end

function levelHierarchyChanged()
	LevelEditor.levelHierarchy = LevelEditor.hierarchyInspector.hierarchyTable
	rebuildFlatArraysFromHierarchy()
end

function hierarchyEditorToggle()
	if LevelEditor.hierarchyInspector.visible then
		LevelEditor.hierarchyInspector:setVisible(false)
		UILib.setButtonPressed(LevelEditor.toolButtonsByName["edit hierarchy"], false)
		return
	end

	if LevelEditor.levelHierarchy == nil or #LevelEditor.levelHierarchy == 0 then
		LevelEditor.levelHierarchy = buildLevelHierarchyFromFlat()
	end

	LevelEditor.hierarchyInspector:inspect(LevelEditor.levelHierarchy)
	LevelEditor.hierarchyInspector:setVisible(true)
	LevelEditor.hierarchyInspector.hierarchyChangeCallback = levelHierarchyChanged
	LevelEditor.hierarchyInspector.nodeClickCallback = levelHierarchyNodeClicked
	LevelEditor.hierarchyInspector.nodeRenameCallback = function(node, newName)
		if node.modelIndex and LevelEditor.models[node.modelIndex] then
			setModelName(LevelEditor.models[node.modelIndex], newName)
		end
	end
	UILib.setButtonPressed(LevelEditor.toolButtonsByName["edit hierarchy"], true)
end

function levelHierarchyNodeClicked(node)
	setSelectedModels({node.modelIndex})
	updateHierarchySelection()
end

function updateHierarchySelection()
	if LevelEditor.levelHierarchy == nil then return end

	local function markSelection(nodeList)
		for i = 1, #nodeList do
			nodeList[i].selected = isModelSelected(nodeList[i].modelIndex)
			if nodeList[i].children then
				markSelection(nodeList[i].children)
			end
		end
	end

	markSelection(LevelEditor.levelHierarchy)
	if LevelEditor.hierarchyInspector and LevelEditor.hierarchyInspector.visible then
		LevelEditor.hierarchyInspector:refresh()
	end
end

function LevelEditor.tileBoundsEnabledSwitch()
	LevelEditor.tileBoundsEnabled = not LevelEditor.tileBoundsEnabled
	if LevelEditor.tileBoundsEnabled and not LevelEditor.snapEnabled then
		snapSwitch()
	end
	for i = 1, #LevelEditor.models do
		local rootModel = LevelEditor.models[i]
		if LevelEditor.tileBoundsEnabled then
			local tsx = getModelData(rootModel, "tileSizeX", 1)
			local tsy = getModelData(rootModel, "tileSizeY", 1)
			local tsz = getModelData(rootModel, "tileSizeZ", 1)
			local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
			LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
		else
			LevelEditor.localBoundingBoxes[i] = computeWorldBoundingBox(rootModel)
		end
		LevelEditor.boundingBoxes[i] = transformBounds(LevelEditor.localBoundingBoxes[i], getModelMatrix(rootModel))
	end

	Utils.setAppVar("tileBoundsEnabled", LevelEditor.tileBoundsEnabled)
end

function buildUI()
	local offset = 0
	local label = "file"
	local fileMenuButton = UILib.button(label, vec3(0, 0, 0.0), vec2(8 * #label, 8), Config.uiColors.panel)
	fileMenuButton.callback = function()
		UILib.openMenu(fileMenuButton, {
			{"new level", newLevelMenuPressed},
			{"save", saveMenuPressed},
			{"save new", saveNewMenuPressed},
		})
	end

	offset = offset + 8 * #label + 8
	local label = "edit"
	local editMenuButton = UILib.button(label, vec3(offset, 0, 0.0), vec2(8 * #label, 8), Config.uiColors.panel)
	editMenuButton.callback = function()
		UILib.openMenu(editMenuButton, {
			{"undo", function() _undo() end},
			{"redo", function() _redo() end},
			{"frame selected", function() _frameSelected() end},
			{LevelEditor.tileBoundsEnabled and "disable tile bounds" or "enable tile bounds", function()
				LevelEditor.tileBoundsEnabledSwitch()
			end},
		})
	end

	offset = offset + 8 * #label + 8 + (8 * 22)
	LevelEditor.axisButtons = {}
	local axisLabels = {"X", "Y", "Z"}
	for i = 1, #axisLabels do
		local axisLabel = axisLabels[i]
		local axisButton = UILib.button(axisLabel, vec3(offset + (8 * i), 0, 0.0), vec2(8, 8), Config.uiColors.panel)
		axisButton.callback = function() setSideViewAxis(axisLabel) end
		axisButton.textColor = vec4Copy(Config.uiColors.panelLight)
		setSprite(axisButton.sprite, axisButton)
		LevelEditor.axisButtons[axisLabel] = axisButton
		offset = offset + 8
	end

	LevelEditor.axisButtons["X"].desc = "side view"
	LevelEditor.axisButtons["Y"].desc = "top view"
	LevelEditor.axisButtons["Z"].desc = "front view"

	local activeAxisButton = LevelEditor.axisButtons[LevelEditor.sideViewAxis]
	activeAxisButton.textColor = vec4Copy(Config.uiColors.icon)
	setSprite(activeAxisButton.sprite, activeAxisButton)

	offset = offset + 8 + 8
	label = "p"
	LevelEditor.cameraPerspButton = UILib.button(label, vec3(8.0 * 10.0, 0.0, 10.0), vec2(8 * #label, 8), Config.uiColors.panel)
	LevelEditor.cameraPerspButton.textColor = Config.uiColors.panelLight
	LevelEditor.cameraPerspButton.changed = true
	LevelEditor.cameraPerspButton.callback = function() LevelEditor.switchCameraPerspective() end
	LevelEditor.cameraPerspButton.desc = "persp/ortho camera"

	local shadeOffset = 8.0 * 12.0

	LevelEditor.shade4Button = UILib.buttonIcon(
		vec3(shadeOffset + (8 * 2), 0.0, 0.0), vec2(8.0, 8.0),
		Config.uiColors.panel,
		vec2(0.0, 0.0), vec2(8.0, 8.0),
		vec2(8.0 * 15.0, 8.0 * 5.0), vec2(8.0, 8.0),
		Config.uiColors.icon, function() LevelEditor.setShadingType(4) end)
	LevelEditor.shade4Button.roundCorner = 0.3
	LevelEditor.shade4Button.desc = "texture unlit"

	LevelEditor.shade1Button = UILib.buttonIcon(
		vec3(shadeOffset + 8.0, 0.0, 0.0), vec2(8.0, 8.0),
		Config.uiColors.panel,
		vec2(0.0, 0.0), vec2(8.0, 8.0),
		vec2(8.0 * 15.0, 0.0), vec2(8.0, 8.0),
		Config.uiColors.icon, function() LevelEditor.setShadingType(1) end)
	LevelEditor.shade1Button.roundCorner = 0.3
	LevelEditor.shade1Button.desc = "texture shaded"

	LevelEditor.shade2Button = UILib.buttonIcon(
		vec3(shadeOffset, 0.0, 0.0), vec2(8.0, 8.0),
		Config.uiColors.panel,
		vec2(0.0, 0.0), vec2(8.0, 8.0),
		vec2(8.0 * 14.0, 0.0), vec2(8.0, 8.0),
		Config.uiColors.icon, function() LevelEditor.setShadingType(2) end)
	LevelEditor.shade2Button.roundCorner = 0.3
	LevelEditor.shade2Button.desc = "shaded"

	local toolCol1 = LevelEditor.screenWidth - LevelEditor.rightSideSpacing + 2.0
	local toolCol2 = toolCol1 + 14.0
	local toolRow = 8.0

	local selectButton = makeToolButton(toolCol1, toolRow, vec2(8.0 * 2, 8.0), "select", selectToolStart)
	selectButton.desc = "select"

	local moveButton = makeToolButton(toolCol2, toolRow, vec2(8.0, 8.0), "move", moveToolStart)
	moveButton.desc = "move"

	toolRow = toolRow + 14.0
	local rotateButton = makeToolButton(toolCol1, toolRow, vec2(8.0 * 4, 0.0), "rotate", rotateToolStart)
	rotateButton.desc = "rotate"

	local scaleButton = makeToolButton(toolCol2, toolRow, vec2(8.0 * 5, 0.0), "scale", scaleToolStart)
	scaleButton.desc = "scale"

	toolRow = toolRow + 14.0
	LevelEditor.snapButton = UILib.buttonIcon(
		vec3(toolCol1, toolRow, 0.0),
		vec2(12.0, 12.0),
		Config.uiColors.panel,
		vec2(2.0, 2.0), vec2(8.0, 8.0),
		vec2(8.0 * 15, 8.0 * 4), vec2(8.0, 8.0),
		Config.uiColors.panelLight, snapSwitch)
	LevelEditor.snapButton.desc = "snap ON/OFF"

	toolRow = toolRow + 14.0 + 8.0
	local hierarchyButton = makeToolButton(toolCol1, toolRow, vec2(8.0 * 3, 8.0 * 6), "edit hierarchy", hierarchyEditorToggle)
	hierarchyButton.desc = "edit hierarchy"

	local addUtilityModelButton = makeToolButton(toolCol2, toolRow, vec2(8.0 * 8, 0.0), "add utility model", addUtilityModelToolStart)
	addUtilityModelButton.desc = "add utility model"

	toolRow = toolRow + 14.0
	local cloneButton = makeToolButton(toolCol1, toolRow, vec2(8.0 * 6, 8.0 * 6), "clone", cloneToolStart)
	cloneButton.desc = "clone"

	local boxEditButton = makeToolButton(toolCol2, toolRow, vec2(0.0, 0.0), "boxEdit", boxEditToolStart)
	boxEditButton.desc = "box edit"

	toolRow = toolRow + 14.0
	local fixedRotButton = makeToolButton(toolCol1, toolRow, vec2(8.0 * 11, 8.0 * 6), "fixedRotate", fixedRotateToolStart)
	fixedRotButton.desc = "rotate 90"

	local hierarchyInspectorWidth = 8 * 18
	local hierarchyInspectorHeight = 8 * 32
	local rightBarWidth = LevelEditor.rightSideSpacing
	local hierarchyInspectorX = LevelEditor.screenWidth - hierarchyInspectorWidth - rightBarWidth
	local hierarchyInspectorY = 8 * 2

	LevelEditor.hierarchyInspector = UILib.hierarchyInspector()
	LevelEditor.hierarchyInspector.position.x = hierarchyInspectorX
	LevelEditor.hierarchyInspector.position.y = hierarchyInspectorY
	LevelEditor.hierarchyInspector.size.x = hierarchyInspectorWidth
	LevelEditor.hierarchyInspector.size.y = hierarchyInspectorHeight
	LevelEditor.hierarchyInspector:setVisible(false)

	setActiveTool("select")

	LevelEditor.utilitySprite = allocSprite()
	LevelEditor.utilitySpriteData = {}
	LevelEditor.utilitySpriteData.position = vec3(0.0, 0.0, 0.0)
	LevelEditor.utilitySpriteData.billboard = 1.0
	LevelEditor.utilitySpriteData.billboardPixelPerfect = 1.0
	LevelEditor.utilitySpriteData.size = vec2(6.0, 6.0)
	LevelEditor.utilitySpriteData.color = Config.paletteNames.white

	LevelEditor.backgroundSprite = allocSprite()
	LevelEditor.backgroundSpriteData = {}
	LevelEditor.backgroundSpriteData.position = vec3(0.0, 0.0, UILib.uiDepth_views - 1)
	LevelEditor.backgroundSpriteData.size = vec2(340, 320)
	LevelEditor.backgroundSpriteData.color = Config.uiColors.levelEditorBackground
	setSprite(LevelEditor.backgroundSprite, LevelEditor.backgroundSpriteData)

	local viewportWidth = LevelEditor.screenWidth - LevelEditor.rightSideSpacing - 1.0
	local viewportHeight = round((LevelEditor.screenHeight - 9.0) / 2)

	LevelEditor.sideViewSpriteData = {}
	LevelEditor.sideViewSpriteData.position = vec3(1.0, 8.0, 0.0)
	LevelEditor.sideViewSpriteData.size = vec2(viewportWidth + 0.1, viewportHeight)
	LevelEditor.sideViewSpriteData.color = Config.uiColors.levelEditorViewport
	LevelEditor.sideViewSpriteData.border = 0.0

	LevelEditor.sideViewSprite = allocSprite()
	setSprite(LevelEditor.sideViewSprite, LevelEditor.sideViewSpriteData)

	LevelEditor.sideViewAxisNames = {X = "side", Y = "top", Z = "front"}
	LevelEditor.sideViewLabelSprite = allocSprite()
	LevelEditor.sideViewLabelData = {}
	LevelEditor.sideViewLabelData.position = vec3(viewportWidth - (8 * 5), 8.0, 0.1)
	LevelEditor.sideViewLabelData.size = vec2(8 * 5, 8)
	LevelEditor.sideViewLabelData.color = vec4(0, 0, 0, 0)
	LevelEditor.sideViewLabelData.textColor = Config.paletteNames.grey
	LevelEditor.sideViewLabelData.text = LevelEditor.sideViewAxisNames[LevelEditor.sideViewAxis]
	setSprite(LevelEditor.sideViewLabelSprite, LevelEditor.sideViewLabelData)

	LevelEditor.perspViewSpriteData = {}
	LevelEditor.perspViewSpriteData.position = vec3(1.0, 8.0 + viewportHeight + 1.0, 0.0)
	LevelEditor.perspViewSpriteData.size = vec2(viewportWidth - (8.0 * 6), viewportHeight - 2.0)
	LevelEditor.perspViewSpriteData.color = Config.uiColors.levelEditorViewport
	LevelEditor.perspViewSpriteData.border = 0.0

	LevelEditor.perspViewSprite = allocSprite()
	setSprite(LevelEditor.perspViewSprite, LevelEditor.perspViewSpriteData)

	local propX = LevelEditor.perspViewSpriteData.position.x + LevelEditor.perspViewSpriteData.size.x
	local propPanelWidth = LevelEditor.screenWidth - propX - 4.0
	local propLabelWidth = 8.0 * 4
	local propRowHeight = 8.0
	local propRowPitch = 10.0
	local propGroupGap = 8.0
	local propFieldGap = 4.0
	local propFieldWidth = 8 * 5
	local propY = LevelEditor.perspViewSpriteData.position.y

	local propLabels = {"posX", "posY", "posZ", "rotX", "rotY", "rotZ", "sclX", "sclY", "sclZ"}
	local propDesc = {"position X", "position Y", "position Z", "rotation X", "rotation Y", "rotation Z", "scale X", "scale Y", "scale Z"}
	local axisColors = {Config.paletteNames.red, Config.paletteNames.green, Config.paletteNames.blue}

	LevelEditor.propSliders = {}
	for i = 1, #propLabels do
		local idx = i
		local rowY = propY + (i - 1) * propRowPitch + math.floor((i - 1) / 3) * propGroupGap
		local axisColor = axisColors[((i - 1) % 3) + 1]

		LevelEditor.propSliders[i] = UILib.slider(propLabels[i], vec3(propX, rowY, 0.0), vec2(propLabelWidth, propRowHeight), vec2(propFieldWidth, propRowHeight), {
			sensitivity = (i >= 7) and 0.01 or 0.1,
			defaultValue = LevelEditor.propValues[i],
			fieldOffset = vec2(propLabelWidth + propFieldGap, 0.0),
			labelColor = Config.uiColors.panel,
			labelTextColor = axisColor,
			fieldColor = axisColor * 0.5,
			fieldColorHover = axisColor + 10,
			fieldTextColor = Config.uiColors.levelEditorPropFieldText,
			fieldTextColorActive = Config.uiColors.levelEditorPropFieldTextActive,
			onChange = function(slider, newValue)
				LevelEditor.propValues[idx] = newValue
				_applyPropsToModels()
			end,
			onRelease = function(slider, finalValue)
				storeUndo()
			end,
			onTextEdit = function(slider, newValue)
				LevelEditor.propValues[idx] = newValue
				_applyPropsToModels()
				storeUndo()
			end,
		})
		LevelEditor.propSliders[i].field.desc = propDesc[i]
	end

	toolRow = LevelEditor.screenHeight - (8 * 4) + 2
	local tagPosX = LevelEditor.perspViewSpriteData.position.x + LevelEditor.perspViewSpriteData.size.x + 2.0
	local tagSizeX = (8.0 * 8) + 10.0

	LevelEditor.tag1 = UILib.textLine(vec3(tagPosX, toolRow, 0.0), vec2(tagSizeX, 8.0), function() _tagTextChanged(1) end)
	LevelEditor.tag1.colorBase = Config.uiColors.textField * 0.5
	LevelEditor.tag1.editingEnabled = false
	LevelEditor.tag1.desc = "set tag1 on selected models"

	toolRow = toolRow + 10.0
	LevelEditor.tag2 = UILib.textLine(vec3(tagPosX, toolRow, 0.0), vec2(tagSizeX, 8.0), function() _tagTextChanged(2) end)
	LevelEditor.tag2.colorBase = Config.uiColors.textField * 0.5
	LevelEditor.tag2.editingEnabled = false
	LevelEditor.tag2.desc = "set tag2 on selected models"

	toolRow = toolRow + 10.0
	LevelEditor.tag3 = UILib.textLine(vec3(tagPosX, toolRow, 0.0), vec2(tagSizeX, 8.0), function() _tagTextChanged(3) end)
	LevelEditor.tag3.colorBase = Config.uiColors.textField * 0.5
	LevelEditor.tag3.editingEnabled = false
	LevelEditor.tag3.desc = "set tag3 on selected models"

	_setPropsEnabled(false)

	updateViewTitle()

	local programId = getProgramId()
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "undo", key = KEY_Z, modifier = KEY_LEFT_CONTROL, callback = "shortcutUndo", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "redo", key = KEY_Y, modifier = KEY_LEFT_CONTROL, callback = "shortcutRedo", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "redo", key = KEY_Z, modifier = {KEY_LEFT_CONTROL, KEY_LEFT_SHIFT}, callback = "shortcutRedo", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "save", key = KEY_S, modifier = KEY_LEFT_CONTROL, callback = "shortcutSave", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "rotate 90", key = KEY_T, callback = "shortcutFixedRotate", programId = programId})

	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "move selected models", key = KEY_LEFT, callback = "shortcutLeft", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "move selected models", key = KEY_RIGHT, callback = "shortcutRight", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "move selected models", key = KEY_UP, callback = "shortcutUp", programId = programId})
	invokeProgram(MAIN_PROGRAMID, "registerShortcut", {desc = "move selected models", key = KEY_DOWN, callback = "shortcutDown", programId = programId})

	LevelEditor.descPanel = UILib.panel("", vec3(0.0, LevelEditor.screenHeight - 8, 1.0), vec2(LevelEditor.screenWidth - (8.0 * 10) + 2, 8.0), Config.uiColors.descPanel)
	LevelEditor.descPanel.visible = false
	LevelEditor.descPanel.changed = true
	LevelEditor.statusText = ""

	LevelEditor.shadingTypeChanged()
end

function LevelEditor.setShadingType(shadingType)
	LevelEditor.shadingType = shadingType
	LevelEditor.shadingTypeChanged()

	Utils.setAppVar("levelEditor_shadingType", shadingType)
end

function LevelEditor.shadingTypeChanged()
	LevelEditor.shade4Button.iconSpriteData.color = vec4Copy(Config.uiColors.panelLight)
	LevelEditor.shade1Button.iconSpriteData.color = vec4Copy(Config.uiColors.panelLight)
	LevelEditor.shade2Button.iconSpriteData.color = vec4Copy(Config.uiColors.panelLight)

	if LevelEditor.shadingType == 1 then
		LevelEditor.shade1Button.iconSpriteData.color = vec4Copy(Config.uiColors.icon)
	elseif LevelEditor.shadingType == 2 then
		LevelEditor.shade2Button.iconSpriteData.color = vec4Copy(Config.uiColors.icon)
	elseif LevelEditor.shadingType == 4 then
		LevelEditor.shade4Button.iconSpriteData.color = vec4Copy(Config.uiColors.icon)
	end

	setSprite(LevelEditor.shade4Button.iconSprite, LevelEditor.shade4Button.iconSpriteData)
	setSprite(LevelEditor.shade1Button.iconSprite, LevelEditor.shade1Button.iconSpriteData)
	setSprite(LevelEditor.shade2Button.iconSprite, LevelEditor.shade2Button.iconSpriteData)
end

function collectMeshes(model, meshes)
	local mesh = getModelMesh(model)
	if mesh ~= nil then
		meshes[#meshes + 1] = mesh
	end
	local children = getModelChildren(model)
	for i = 1, #children do
		collectMeshes(children[i], meshes)
	end
end

function transformBounds(localBB, modelMatrix)
	return {min = localBB.min, max = localBB.max, matrix = mat4(modelMatrix)}
end

function hideHierarchyInspector()
	LevelEditor.hierarchyInspector:setVisible(false)
	UILib.setButtonPressed(LevelEditor.toolButtonsByName["edit hierarchy"], false)
end

function openAsset(assetName)
	hideHierarchyInspector()

	local tokens = splitStr(assetName, ".")
	local fileType = tokens[2]

	if fileType == "lvl" then
		setAssetName(assetName)
		_loadLevel(assetName)
		return
	end

	local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

	local rootModel = loadModel(assetName, pageId)[1]

	local modelIndex = #LevelEditor.models + 1
	LevelEditor.models[modelIndex] = rootModel
	setModelData(rootModel, "path", assetName)

	local name = modelNameFromPath(assetName)
	setModelName(rootModel, name)

	if LevelEditor.tileBoundsEnabled then
		local tsx = getModelData(rootModel, "tileSizeX", 1)
		local tsy = getModelData(rootModel, "tileSizeY", 1)
		local tsz = getModelData(rootModel, "tileSizeZ", 1)
		local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
		LevelEditor.localBoundingBoxes[modelIndex] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
	else
		LevelEditor.localBoundingBoxes[modelIndex] = computeWorldBoundingBox(rootModel)
	end
	LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], getModelMatrix(rootModel))
	collectMeshes(rootModel, LevelEditor.meshes)
	LevelEditor.modelMeshes[modelIndex] = {}
	collectMeshes(rootModel, LevelEditor.modelMeshes[modelIndex])

	if LevelEditor.levelHierarchy then
		LevelEditor.levelHierarchy[#LevelEditor.levelHierarchy + 1] = {
			name = name,
			modelIndex = modelIndex
		}
	end

	LevelEditor.loadAssetModelIndex = modelIndex
	LevelEditor.loadAssetPlaneOrigin = vec3(0.0, 0.0, 0.0)
	LevelEditor.loadAssetDragging = true
	setSelectedModels({modelIndex})
	setActiveTool("loadAsset")
end

function updateMeshLight()
	local lightDir = vec3Normalize(vec3(0.5, 1.0, 0.7))
	local lightColor = vec4(255.0, 255.0, 255.0, 255.0)
	local ambientColor = vec4(100.0, 100.0, 100.0, 255.0)

	local lightOnCamera = lightDir * LevelEditor.camManipPersp.rot

	-- the uniforms are inert unless the mesh is drawn inside a lighting region, so keep them
	-- up to date regardless of the shading type: drawPerspView decides whether lighting applies
	for i = 1, #LevelEditor.meshes do
		PolyUtil.meshLight(LevelEditor.meshes[i], lightOnCamera, lightColor, ambientColor)
	end
end

function insideViewport(mousePos, viewSpriteData)
	return intersectRect(mousePos, vec2(viewSpriteData.position.x, viewSpriteData.position.y), viewSpriteData.size)
end

function viewportToScreen(screenPoint, viewSpriteData, ortho)
	local viewSpriteCenterX = (LevelEditor.screenWidth * 0.5)
	local viewSpriteLeft = viewSpriteCenterX - (viewSpriteData.size.x * 0.5)
	local viewSpriteRight = viewSpriteCenterX + (viewSpriteData.size.x * 0.5)

	local screenPointInViewport = vec2(0.0, 0.0)
	screenPointInViewport.x = remapRange(
		screenPoint.x, viewSpriteData.position.x, viewSpriteData.position.x + viewSpriteData.size.x,
		viewSpriteLeft, viewSpriteRight)

	local viewSpriteCenterY = (LevelEditor.screenHeight * 0.5)
	local viewSpriteTopBound = viewSpriteCenterY + (viewSpriteData.size.y * 0.5)
	local viewSpriteBottomBound = viewSpriteCenterY - (viewSpriteData.size.y * 0.5)

	screenPointInViewport.y = remapRange(screenPoint.y,
		viewSpriteData.position.y, viewSpriteData.position.y + viewSpriteData.size.y,
		viewSpriteBottomBound, viewSpriteTopBound)

	if ortho == true then
		screenPointInViewport.x = LevelEditor.screenWidth - screenPointInViewport.x
		screenPointInViewport.y = LevelEditor.screenHeight - screenPointInViewport.y
	end

	return screenPointInViewport
end

function updateSelection()
	if LevelEditor.mousePos == nil then
		return
	end

	LevelEditor.hoveredModel = -1

	if LevelEditor.manipulatingCamera == true or LevelEditor.draggingSelection == true then
		return
	end

	local viewSpriteData = nil
	local cameraMat = nil
	local camManip = nil
	local ortho = false

	if LevelEditor.mouseInViewport == 2 then
		viewSpriteData = LevelEditor.perspViewSpriteData
		cameraMat = LevelEditor.camPerspMat
		camManip = LevelEditor.camManipPersp
		ortho = camManip.ortho
	elseif LevelEditor.mouseInViewport == 1 then
		viewSpriteData = LevelEditor.sideViewSpriteData
		cameraMat = LevelEditor.camSideMat
		camManip = LevelEditor.camManipSide
		ortho = true
	end

	if viewSpriteData == nil then
		return
	end

	local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
	local ray = screenRay(mousePosInViewport, LevelEditor.camFov, cameraMat, ortho)

	if ortho == true then
		ray = screenRay(mousePosInViewport, -camManip.focusDist, cameraMat, ortho)
	end

	local closestDist = math.huge
	for i = 1, #LevelEditor.models do
		local bb = LevelEditor.boundingBoxes[i]
		local dist = intersectBox(ray.origin, ray.direction, bb.min, bb.max, bb.matrix)
		if dist > -1.0 and dist < closestDist then
			closestDist = dist
			LevelEditor.hoveredModel = i
		end
	end

	local selectableTools = LevelEditor.activeTool == "select" or LevelEditor.activeTool == "move" or LevelEditor.activeTool == "rotate" or LevelEditor.activeTool == "scale" or LevelEditor.activeTool == "boxEdit"
	local notDragging = not LevelEditor.moveDragging and not LevelEditor.rotateDragging and not LevelEditor.scaleDragging and not LevelEditor.boxEditDragging and not LevelEditor.draggingSelection
	if LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT and selectableTools and LevelEditor.manipulatingCamera == false and notDragging then
		if LevelEditor.skipClickRelease == true then
			LevelEditor.skipClickRelease = false

			return
		end

		if LevelEditor.hoveredModel ~= -1 then
			if getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true then
				if isModelSelected(LevelEditor.hoveredModel) then
					local new = {}
					for i = 1, #LevelEditor.selectedModels do
						if LevelEditor.selectedModels[i] ~= LevelEditor.hoveredModel then
							new[#new + 1] = LevelEditor.selectedModels[i]
						end
					end
					setSelectedModels(new)
				else
					local new = {}
					for i = 1, #LevelEditor.selectedModels do
						new[i] = LevelEditor.selectedModels[i]
					end
					new[#new + 1] = LevelEditor.hoveredModel
					setSelectedModels(new)
				end
			elseif getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true then
				if not isModelSelected(LevelEditor.hoveredModel) then
					local new = {}
					for i = 1, #LevelEditor.selectedModels do
						new[i] = LevelEditor.selectedModels[i]
					end
					new[#new + 1] = LevelEditor.hoveredModel
					setSelectedModels(new)
				end
			else
				setSelectedModels({LevelEditor.hoveredModel})
			end
		else
			setSelectedModels({})
		end
		updateHierarchySelection()

		storeUndo()
	end
end

function _tagTextChanged(tagId)
	if #LevelEditor.selectedModels == 0 then
		return
	end

	local textSpriteData = nil
	if tagId == 1 then
		textSpriteData = LevelEditor.tag1
	elseif tagId == 2 then
		textSpriteData = LevelEditor.tag2
	elseif tagId == 3 then
		textSpriteData = LevelEditor.tag3
	end

	for i = 1, #LevelEditor.selectedModels do
		local modelId = LevelEditor.selectedModels[i]
		local model = LevelEditor.models[modelId]
		setModelData(model, "tag" .. tagId, textSpriteData.text)
	end
end

-- The single place a model's transform is written, so the recorded scale signs can never drift
-- from the matrix. Rendering needs nothing else: a negative axis mirrors the matrix, and both the
-- shader normals and the triangle winding are derived from it at draw time.
function _setModelTransform(model, position, rotation, scale)
	local mat = mat4()
	mat.position = position
	mat.rotation = rotation
	mat.scale = scale
	setModelMatrix(model, mat)

	-- The sign cannot be read back out of the matrix: mat.scale returns axis lengths, and only
	-- the determinant's sign is well defined. Record the flipped axes, because saving, undo,
	-- page snapshots and the properties panel all need the scale back as a signed number.
	setModelData(model, "scaleFlipX", scale.x < 0.0 and 1 or 0)
	setModelData(model, "scaleFlipY", scale.y < 0.0 and 1 or 0)
	setModelData(model, "scaleFlipZ", scale.z < 0.0 and 1 or 0)

	return mat
end

function _getModelSignedScale(model)
	local scale = getModelMatrix(model).scale

	scale.x = getModelData(model, "scaleFlipX", 0) > 0.5 and -scale.x or scale.x
	scale.y = getModelData(model, "scaleFlipY", 0) > 0.5 and -scale.y or scale.y
	scale.z = getModelData(model, "scaleFlipZ", 0) > 0.5 and -scale.z or scale.z

	return scale
end

function _applyPropsToModels()
	for i = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[i]

		local mat = _setModelTransform(LevelEditor.models[modelIndex],
			vec3(LevelEditor.propValues[1], LevelEditor.propValues[2], LevelEditor.propValues[3]),
			eulerRot(LevelEditor.propValues[4], LevelEditor.propValues[5], LevelEditor.propValues[6]),
			vec3(LevelEditor.propValues[7], LevelEditor.propValues[8], LevelEditor.propValues[9]))

		LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
	end
end

function _refreshProps()
	local modelIndex = LevelEditor.selectedModels[1]
	if modelIndex == nil then return end

	local model = LevelEditor.models[modelIndex]
	if model == nil then return end

	local mat = getModelMatrix(model)
	local euler = quatToEuler(mat.rotation)

	-- signed, so a mirrored model keeps showing its negative scale in the panel instead of
	-- snapping back to positive and un-mirroring itself on the next property edit
	local scale = _getModelSignedScale(model)

	LevelEditor.propValues[1] = mat.position.x
	LevelEditor.propValues[2] = mat.position.y
	LevelEditor.propValues[3] = mat.position.z
	LevelEditor.propValues[4] = euler.x
	LevelEditor.propValues[5] = euler.y
	LevelEditor.propValues[6] = euler.z
	LevelEditor.propValues[7] = scale.x
	LevelEditor.propValues[8] = scale.y
	LevelEditor.propValues[9] = scale.z

	for i = 1, #LevelEditor.propSliders do
		local slider = LevelEditor.propSliders[i]
		if slider.field.editing ~= true and UILib._draggingSlider ~= slider then
			slider:setValue(LevelEditor.propValues[i])
		end
	end
end

function _setPropsEnabled(enabled)
	LevelEditor.propsEnabled = enabled

	local axisColors = {Config.paletteNames.red, Config.paletteNames.green, Config.paletteNames.blue}

	for i = 1, #LevelEditor.propSliders do
		local slider = LevelEditor.propSliders[i]
		local field = slider.field
		local axisColor = axisColors[((i - 1) % 3) + 1]

		if enabled == true then
			field.hoverEnabled = true
			field.colorBase = axisColor * 0.5
			field.color = axisColor * 0.5
			field.colorHover = axisColor + 10
			field.textColor = Config.uiColors.levelEditorPropFieldText
		else
			field.hoverEnabled = false
			field.colorBase = axisColor * 0.1
			field.color = axisColor * 0.1
			field.textColor = Config.uiColors.levelEditorPropFieldText * 0.3
		end
		field.changed = true

		if slider.label ~= nil then
			if enabled == true then
				slider.label.textColor = vec4Copy(axisColor)
			else
				slider.label.textColor = axisColor * 0.3
			end
			slider.label.changed = true
		end
	end
end

function selectionChanged()
	if #LevelEditor.selectedModels == 0 then
		_setPropsEnabled(false)

		LevelEditor.tag1.text = ""
		LevelEditor.tag1.colorBase = Config.uiColors.textField * 0.5
		LevelEditor.tag1.editingEnabled = false

		LevelEditor.tag2.text = ""
		LevelEditor.tag2.colorBase = Config.uiColors.textField * 0.5
		LevelEditor.tag2.editingEnabled = false

		LevelEditor.tag3.text = ""
		LevelEditor.tag3.colorBase = Config.uiColors.textField * 0.5
		LevelEditor.tag3.editingEnabled = false
	else
		_refreshProps()
		_setPropsEnabled(true)

		local firstSelectedModelId = LevelEditor.selectedModels[1]
		local fistSelectedModel = LevelEditor.models[firstSelectedModelId]

		LevelEditor.tag1.text = getModelData(fistSelectedModel, "tag1", "")
		LevelEditor.tag1.colorBase = Config.uiColors.textField
		LevelEditor.tag1.editingEnabled = true

		LevelEditor.tag2.text = getModelData(fistSelectedModel, "tag2", "")
		LevelEditor.tag2.colorBase = Config.uiColors.textField
		LevelEditor.tag2.editingEnabled = true

		LevelEditor.tag3.text = getModelData(fistSelectedModel, "tag3", "")
		LevelEditor.tag3.colorBase = Config.uiColors.textField
		LevelEditor.tag3.editingEnabled = true
	end
end

function selectModelsInRect(rectMin, rectMax)
	local rectModels = {}

	for i = 1, #LevelEditor.models do
		local bb = LevelEditor.boundingBoxes[i]
		local center = bb.matrix * ((bb.min + bb.max) * 0.5)
		local inside = false

		if LevelEditor.sideViewAxis == "X" then
			inside = center.y > rectMin.y and center.y < rectMax.y and
				center.z > rectMin.z and center.z < rectMax.z
		elseif LevelEditor.sideViewAxis == "Y" then
			inside = center.x > rectMin.x and center.x < rectMax.x and
				center.z > rectMin.z and center.z < rectMax.z
		else
			inside = center.x > rectMin.x and center.x < rectMax.x and
				center.y > rectMin.y and center.y < rectMax.y
		end

		if inside then
			rectModels[#rectModels + 1] = i
		end
	end

	local ctrlHeld = getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true
	local shiftHeld = getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true

	if ctrlHeld then
		local newSelection = {}
		for i = 1, #LevelEditor.selectedModels do
			newSelection[i] = LevelEditor.selectedModels[i]
		end
		for i = 1, #rectModels do
			if isModelSelected(rectModels[i]) then
				local filtered = {}
				for j = 1, #newSelection do
					if newSelection[j] ~= rectModels[i] then
						filtered[#filtered + 1] = newSelection[j]
					end
				end
				newSelection = filtered
			else
				newSelection[#newSelection + 1] = rectModels[i]
			end
		end
		setSelectedModels(newSelection)
	elseif shiftHeld then
		local newSelection = {}
		for i = 1, #LevelEditor.selectedModels do
			newSelection[i] = LevelEditor.selectedModels[i]
		end
		for i = 1, #rectModels do
			if not isModelSelected(rectModels[i]) then
				newSelection[#newSelection + 1] = rectModels[i]
			end
		end
		setSelectedModels(newSelection)
	else
		setSelectedModels(rectModels)
	end
	storeUndo()
end

function selectToolUpdate()
	if LevelEditor.moveDragging or LevelEditor.rotateDragging or LevelEditor.scaleDragging then
		return
	end

	if LevelEditor.manipulatingCamera then
		return
	end

	if LevelEditor.draggingSelection and LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT then
		LevelEditor.draggingSelection = false

		local viewSpriteData = LevelEditor.sideViewSpriteData
		local cameraMat = LevelEditor.camSideMat
		local camManip = LevelEditor.camManipSide
		local fov = camManip.focusDist

		local screenP1 = LevelEditor.mousePressedPos
		local screenP3 = LevelEditor.mousePos

		local p1 = screenToWorld(viewportToScreen(screenP1, viewSpriteData, true), LevelEditor.screenSize, fov, cameraMat, 1.0, true)
		local p3 = screenToWorld(viewportToScreen(screenP3, viewSpriteData, true), LevelEditor.screenSize, fov, cameraMat, 1.0, true)

		local delta = (p3 - p1) * 0.5
		delta.x = abs(delta.x)
		delta.y = abs(delta.y)
		delta.z = abs(delta.z)

		local rectCenter = vec3Lerp(p1, p3, 0.5)
		selectModelsInRect(rectCenter - delta, rectCenter + delta)
		return
	end

	if LevelEditor.clickedViewport == -1 then
		LevelEditor.draggingSelection = false
		return
	end

	if LevelEditor.clickedViewport ~= 1 then
		return
	end

	if LevelEditor.mouseDown == MOUSE_BUTTON_LEFT then
		local mouseDelta = LevelEditor.mousePressedPos - LevelEditor.mousePos

		if LevelEditor.draggingSelection == false and not LevelEditor.boxEditDragging and vec2Length(mouseDelta) > 0.1 then
			LevelEditor.draggingSelection = true
		end
	end

	if LevelEditor.mouseDown == -1 then
		LevelEditor.draggingSelection = false
	end
end

function getMousePos3dInViewport(mousePos, viewportId)
	local viewSpriteData = nil
	local cameraMat = nil
	local camManip = nil
	local ortho = false

	if viewportId == 2 then
		viewSpriteData = LevelEditor.perspViewSpriteData
		cameraMat = LevelEditor.camPerspMat
		camManip = LevelEditor.camManipPersp
		ortho = camManip.ortho
	elseif viewportId == 1 then
		viewSpriteData = LevelEditor.sideViewSpriteData
		cameraMat = LevelEditor.camSideMat
		camManip = LevelEditor.camManipSide
		ortho = true
	end

	if viewSpriteData == nil then return nil end

	local mousePosInViewport = viewportToScreen(mousePos, viewSpriteData, ortho)
	return screenToWorld(mousePosInViewport, LevelEditor.screenSize, camManip.focusDist, cameraMat, LevelEditor.camFov, ortho)
end

function moveToolUpdate()
	if LevelEditor.activeTool ~= "move" then
		return
	end

	if LevelEditor.hoveredModel ~= -1 and isModelSelected(LevelEditor.hoveredModel) then
		UILib.setCursor(UILib.CURSOR_MOVE)
	else
		UILib.setCursor(UILib.CURSOR)
	end

	local modifierHeld = getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true or getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true
	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT and
		LevelEditor.hoveredModel ~= -1 and
		isModelSelected(LevelEditor.hoveredModel) and
		LevelEditor.mouseInViewport ~= -1 and
		not modifierHeld then

		LevelEditor.moveDragging = true
		LevelEditor.moveClickedViewport = LevelEditor.mouseInViewport
		LevelEditor.moveStartMousePos3d = getMousePos3dInViewport(LevelEditor.mousePos, LevelEditor.moveClickedViewport)

		LevelEditor.moveModelStartPositions = {}
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local mat = getModelMatrix(LevelEditor.models[modelIndex])
			LevelEditor.moveModelStartPositions[modelIndex] = vec3(mat.position)
		end
	end

	if LevelEditor.moveDragging and LevelEditor.mouseDown == MOUSE_BUTTON_LEFT then
		local currentPos3d = getMousePos3dInViewport(LevelEditor.mousePos, LevelEditor.moveClickedViewport)
		if currentPos3d ~= nil and LevelEditor.moveStartMousePos3d ~= nil then
			local delta = currentPos3d - LevelEditor.moveStartMousePos3d

			for _si = 1, #LevelEditor.selectedModels do
				local modelIndex = LevelEditor.selectedModels[_si]
				local mat = getModelMatrix(LevelEditor.models[modelIndex])
				local newPos = LevelEditor.moveModelStartPositions[modelIndex] + delta

				if LevelEditor.snapEnabled then
					local step = LevelEditor.snapPosition
					newPos.x = step * round(newPos.x / step)
					newPos.y = step * round(newPos.y / step)
					newPos.z = step * round(newPos.z / step)
				end

				mat.position = newPos
				setModelMatrix(LevelEditor.models[modelIndex], mat)

				LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
			end
		end
	end

	if LevelEditor.moveDragging and LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT then
		LevelEditor.moveDragging = false
		storeUndo()
	end
end

function rotateToolUpdate()
	if LevelEditor.activeTool ~= "rotate" then
		return
	end

	if LevelEditor.hoveredModel ~= -1 and isModelSelected(LevelEditor.hoveredModel) then
		UILib.setCursor(UILib.CURSOR_ROTATE)
	else
		UILib.setCursor(UILib.CURSOR)
	end

	local modifierHeld = getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true or getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true
	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT and
		LevelEditor.hoveredModel ~= -1 and
		isModelSelected(LevelEditor.hoveredModel) and
		LevelEditor.mouseInViewport ~= -1 and
		not modifierHeld then

		LevelEditor.rotateDragging = true
		LevelEditor.rotateClickedViewport = LevelEditor.mouseInViewport

		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.rotateClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		LevelEditor.rotateCenter = vec3(0.0, 0.0, 0.0)
		local count = 0
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local bb = LevelEditor.boundingBoxes[modelIndex]
			LevelEditor.rotateCenter = LevelEditor.rotateCenter + (bb.matrix * ((bb.min + bb.max) * 0.5))
			count = count + 1
		end
		LevelEditor.rotateCenter = LevelEditor.rotateCenter * (1.0 / count)

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local clickPos3d = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.rotateCenter, -cameraMat.forwardAxis))

		local clickMat = mat4LookAt(vec3(0.0, 0.0, 0.0),
			cameraMat.forwardAxis,
			vec3Normalize(clickPos3d - LevelEditor.rotateCenter))

		local cameraRestMat = mat4LookAt(vec3(0.0, 0.0, 0.0),
			cameraMat.forwardAxis,
			cameraMat.upAxis)

		mat4Invert(clickMat)
		LevelEditor.rotateOffsetMat = cameraRestMat * clickMat
		mat4Invert(cameraRestMat)
		LevelEditor.rotateRestMat = cameraRestMat

		LevelEditor.rotateStartForward = vec3(cameraMat.forwardAxis.x, cameraMat.forwardAxis.y, cameraMat.forwardAxis.z)
		LevelEditor.rotateStartDir = vec3Normalize(clickPos3d - LevelEditor.rotateCenter)

		LevelEditor.rotateModelStartPositions = {}
		LevelEditor.rotateModelStartRotations = {}
		LevelEditor.rotateModelStartScales = {}
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local mat = getModelMatrix(LevelEditor.models[modelIndex])
			LevelEditor.rotateModelStartPositions[modelIndex] = vec3(mat.position)
			LevelEditor.rotateModelStartRotations[modelIndex] = mat.rotation
			-- signed: mat.scale drops the mirror, and the apply below writes this straight back
			LevelEditor.rotateModelStartScales[modelIndex] = _getModelSignedScale(LevelEditor.models[modelIndex])
		end
	end

	if LevelEditor.rotateDragging and LevelEditor.mouseDown == MOUSE_BUTTON_LEFT then
		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.rotateClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local p = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.rotateCenter, -cameraMat.forwardAxis))

		local rotateMat
		if LevelEditor.snapEnabled then
			local currentDir = vec3Normalize(p - LevelEditor.rotateCenter)
			local fwd = LevelEditor.rotateStartForward
			local cosA = vec3Dot(LevelEditor.rotateStartDir, currentDir)
			local sinA = vec3Dot(vec3Cross(LevelEditor.rotateStartDir, currentDir), fwd)
			local angle = math.atan(sinA, cosA)

			local stepRad = degToRad(LevelEditor.snapRotation)
			angle = stepRad * round(angle / stepRad)

			local halfAngle = angle * 0.5
			local s = math.sin(halfAngle)
			local snapQuat = quat(fwd.x * s, fwd.y * s, fwd.z * s, math.cos(halfAngle))

			rotateMat = mat4()
			rotateMat.rotation = snapQuat
		else
			local dragMat = mat4LookAt(vec3(0.0, 0.0, 0.0),
				cameraMat.forwardAxis,
				vec3Normalize(p - LevelEditor.rotateCenter))

			rotateMat = LevelEditor.rotateOffsetMat * (dragMat * LevelEditor.rotateRestMat)
		end

		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local mat = mat4()
			mat.rotation = rotateMat.rotation * LevelEditor.rotateModelStartRotations[modelIndex]
			mat.scale = LevelEditor.rotateModelStartScales[modelIndex]

			local offset = LevelEditor.rotateModelStartPositions[modelIndex] - LevelEditor.rotateCenter
			mat.position = LevelEditor.rotateCenter + (rotateMat * offset)
			setModelMatrix(LevelEditor.models[modelIndex], mat)

			LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
		end
	end

	if LevelEditor.rotateDragging and LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT then
		LevelEditor.rotateDragging = false
		storeUndo()
	end
end

function scaleToolUpdate()
	if LevelEditor.activeTool ~= "scale" then
		return
	end

	if LevelEditor.hoveredModel ~= -1 and isModelSelected(LevelEditor.hoveredModel) then
		UILib.setCursor(UILib.CURSOR_SCALE)
	else
		UILib.setCursor(UILib.CURSOR)
	end

	local modifierHeld = getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true or getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true
	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT and
		LevelEditor.hoveredModel ~= -1 and
		isModelSelected(LevelEditor.hoveredModel) and
		LevelEditor.mouseInViewport ~= -1 and
		not modifierHeld then

		LevelEditor.scaleDragging = true
		LevelEditor.scaleClickedViewport = LevelEditor.mouseInViewport

		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.scaleClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		LevelEditor.scaleCenter = vec3(0.0, 0.0, 0.0)
		local count = 0
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local bb = LevelEditor.boundingBoxes[modelIndex]
			LevelEditor.scaleCenter = LevelEditor.scaleCenter + (bb.matrix * ((bb.min + bb.max) * 0.5))
			count = count + 1
		end
		LevelEditor.scaleCenter = LevelEditor.scaleCenter * (1.0 / count)

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local p = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.scaleCenter, -cameraMat.forwardAxis))

		local delta = p - LevelEditor.scaleCenter
		LevelEditor.scaleRest = vec3(abs(delta.x), abs(delta.y), abs(delta.z))

		LevelEditor.scaleModelStartPositions = {}
		LevelEditor.scaleModelStartRotations = {}
		LevelEditor.scaleModelStartScales = {}
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local mat = getModelMatrix(LevelEditor.models[modelIndex])
			LevelEditor.scaleModelStartPositions[modelIndex] = vec3(mat.position)
			LevelEditor.scaleModelStartRotations[modelIndex] = mat.rotation
			-- signed: mat.scale drops the mirror, and worldScaleMat * startRS below carries the
			-- sign through the product. The drag factors are built from abs(), so a gizmo can
			-- only preserve a mirror, never introduce one - the flag needs no update on drag.
			LevelEditor.scaleModelStartScales[modelIndex] = _getModelSignedScale(LevelEditor.models[modelIndex])
		end
	end

	if LevelEditor.scaleDragging and LevelEditor.mouseDown == MOUSE_BUTTON_LEFT then
		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.scaleClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local p = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.scaleCenter, -cameraMat.forwardAxis))

		local scaleVec = p - LevelEditor.scaleCenter

		local scaleX = 1.0
		if LevelEditor.scaleRest.x > 0.0 then
			scaleX = abs(scaleVec.x) / LevelEditor.scaleRest.x
		end

		local scaleY = 1.0
		if LevelEditor.scaleRest.y > 0.0 then
			scaleY = abs(scaleVec.y) / LevelEditor.scaleRest.y
		end

		local scaleZ = 1.0
		if LevelEditor.scaleRest.z > 0.0 then
			scaleZ = abs(scaleVec.z) / LevelEditor.scaleRest.z
		end

		local scale
		if LevelEditor.scaleClickedViewport == 2 then
			scale = vec3(scaleX, scaleY, scaleZ)
		else
			if LevelEditor.sideViewAxis == "X" then
				scale = vec3(1.0, scaleY, scaleZ)
			elseif LevelEditor.sideViewAxis == "Y" then
				scale = vec3(scaleX, 1.0, scaleZ)
			else
				scale = vec3(scaleX, scaleY, 1.0)
			end
		end

		if getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true then
			local uniform = math.max(scale.x, scale.y, scale.z)
			scale = vec3(uniform, uniform, uniform)
		end

		if LevelEditor.snapEnabled then
			local step = LevelEditor.snapScale
			scale.x = math.max(step, step * round(scale.x / step))
			scale.y = math.max(step, step * round(scale.y / step))
			scale.z = math.max(step, step * round(scale.z / step))
		end

		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local startRS = mat4()
			startRS.rotation = LevelEditor.scaleModelStartRotations[modelIndex]
			startRS.scale = LevelEditor.scaleModelStartScales[modelIndex]

			local worldScaleMat = mat4()
			worldScaleMat.scale = scale

			local mat = worldScaleMat * startRS

			local offset = LevelEditor.scaleModelStartPositions[modelIndex] - LevelEditor.scaleCenter
			mat.position = LevelEditor.scaleCenter + (offset * scale)
			setModelMatrix(LevelEditor.models[modelIndex], mat)

			LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
		end
	end

	if LevelEditor.scaleDragging and LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT then
		LevelEditor.scaleDragging = false
		storeUndo()
	end
end

function boxEditToolUpdate()
	if LevelEditor.activeTool ~= "boxEdit" then
		return
	end

	if LevelEditor.hoveredModel ~= -1 and isModelSelected(LevelEditor.hoveredModel) then
		UILib.setCursor(UILib.CURSOR_SCALE)
	else
		UILib.setCursor(UILib.CURSOR)
	end

	local modifierHeld = getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true or getKeyDown(KEY_LEFT_SHIFT) == true or getKeyDown(KEY_RIGHT_SHIFT) == true
	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT and
		LevelEditor.hoveredModel ~= -1 and
		isModelSelected(LevelEditor.hoveredModel) and
		LevelEditor.mouseInViewport ~= -1 and
		not modifierHeld then

		LevelEditor.boxEditDragging = true
		LevelEditor.boxEditClickedViewport = LevelEditor.mouseInViewport

		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.boxEditClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		LevelEditor.boxEditCenter = vec3(0.0, 0.0, 0.0)
		local count = 0
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local mat = getModelMatrix(LevelEditor.models[modelIndex])
			LevelEditor.boxEditCenter = LevelEditor.boxEditCenter + vec3(mat.position)
			count = count + 1
		end
		LevelEditor.boxEditCenter = LevelEditor.boxEditCenter * (1.0 / count)

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local p = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.boxEditCenter, -cameraMat.forwardAxis))

		local delta = p - LevelEditor.boxEditCenter
		LevelEditor.boxEditRest = vec3(abs(delta.x), abs(delta.y), abs(delta.z))

		LevelEditor.boxEditStartTileSizes = {}
		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local bb = LevelEditor.localBoundingBoxes[modelIndex]
			LevelEditor.boxEditStartTileSizes[modelIndex] = {
				x = bb.max.x - bb.min.x,
				y = bb.max.y - bb.min.y,
				z = bb.max.z - bb.min.z
			}
		end
	end

	if LevelEditor.boxEditDragging and LevelEditor.mouseDown == MOUSE_BUTTON_LEFT then
		local viewSpriteData, cameraMat, camManip, ortho
		if LevelEditor.boxEditClickedViewport == 2 then
			viewSpriteData = LevelEditor.perspViewSpriteData
			cameraMat = LevelEditor.camPerspMat
			camManip = LevelEditor.camManipPersp
			ortho = camManip.ortho
		else
			viewSpriteData = LevelEditor.sideViewSpriteData
			cameraMat = LevelEditor.camSideMat
			camManip = LevelEditor.camManipSide
			ortho = true
		end

		local fov = LevelEditor.camFov
		if ortho then
			fov = -camManip.focusDist
		end

		local mouseInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, ortho)
		local ray = screenRay(mouseInViewport, fov, cameraMat, ortho)
		local p = ray.origin + (ray.direction * intersectPlane(ray.origin, ray.direction, LevelEditor.boxEditCenter, -cameraMat.forwardAxis))

		local scaleVec = p - LevelEditor.boxEditCenter

		local scaleX = 1.0
		if LevelEditor.boxEditRest.x > 0.0 then
			scaleX = abs(scaleVec.x) / LevelEditor.boxEditRest.x
		end

		local scaleY = 1.0
		if LevelEditor.boxEditRest.y > 0.0 then
			scaleY = abs(scaleVec.y) / LevelEditor.boxEditRest.y
		end

		local scaleZ = 1.0
		if LevelEditor.boxEditRest.z > 0.0 then
			scaleZ = abs(scaleVec.z) / LevelEditor.boxEditRest.z
		end

		local scale
		if LevelEditor.boxEditClickedViewport == 2 then
			scale = vec3(scaleX, scaleY, scaleZ)
		else
			if LevelEditor.sideViewAxis == "X" then
				scale = vec3(1.0, scaleY, scaleZ)
			elseif LevelEditor.sideViewAxis == "Y" then
				scale = vec3(scaleX, 1.0, scaleZ)
			else
				scale = vec3(scaleX, scaleY, 1.0)
			end
		end

		if getKeyDown(KEY_LEFT_CONTROL) == true or getKeyDown(KEY_RIGHT_CONTROL) == true then
			local uniform = math.max(scale.x, scale.y, scale.z)
			scale = vec3(uniform, uniform, uniform)
		end

		if LevelEditor.snapEnabled then
			local step = LevelEditor.snapScale
			scale.x = math.max(step, step * round(scale.x / step))
			scale.y = math.max(step, step * round(scale.y / step))
			scale.z = math.max(step, step * round(scale.z / step))
		end

		for _si = 1, #LevelEditor.selectedModels do
			local modelIndex = LevelEditor.selectedModels[_si]
			local model = LevelEditor.models[modelIndex]
			local startTS = LevelEditor.boxEditStartTileSizes[modelIndex]

			local newTSX = math.max(0.01, startTS.x * scale.x)
			local newTSY = math.max(0.01, startTS.y * scale.y)
			local newTSZ = math.max(0.01, startTS.z * scale.z)

			setModelData(model, "tileSizeX", newTSX)
			setModelData(model, "tileSizeY", newTSY)
			setModelData(model, "tileSizeZ", newTSZ)

			local half = vec3(newTSX * 0.5, newTSY * 0.5, newTSZ * 0.5)
			LevelEditor.localBoundingBoxes[modelIndex] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
			LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], getModelMatrix(model))
		end
	end

	if LevelEditor.boxEditDragging and LevelEditor.mouseReleased == MOUSE_BUTTON_LEFT then
		LevelEditor.boxEditDragging = false
		storeUndo()
	end
end

function cloneToolUpdate()
	if LevelEditor.activeTool ~= "clone" then
		return
	end

	UILib.setCursor(UILib.CURSOR)

	if not LevelEditor.cloneDragging then
		return
	end

	if LevelEditor.mouseInViewport == -1 then
		return
	end

	local mousePos3d = nil

	if LevelEditor.mouseInViewport == 1 then
		local viewSpriteData = LevelEditor.sideViewSpriteData
		local cameraMat = LevelEditor.camSideMat
		local camManip = LevelEditor.camManipSide

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, true)
		local ray = screenRay(mousePosInViewport, -camManip.focusDist, cameraMat, true)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.clonePlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	elseif LevelEditor.mouseInViewport == 2 then
		local viewSpriteData = LevelEditor.perspViewSpriteData
		local cameraMat = LevelEditor.camPerspMat
		local camManip = LevelEditor.camManipPersp

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, camManip.ortho)
		local fov = camManip.ortho and -camManip.focusDist or LevelEditor.camFov
		local ray = screenRay(mousePosInViewport, fov, cameraMat, camManip.ortho)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.clonePlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	end

	if mousePos3d == nil then
		return
	end

	for i = 1, #LevelEditor.cloneNewModelIndices do
		local modelIndex = LevelEditor.cloneNewModelIndices[i]
		local offset = LevelEditor.cloneModelOffsets[i]
		local mat = getModelMatrix(LevelEditor.models[modelIndex])

		local newPos = mousePos3d + offset

		if LevelEditor.snapEnabled then
			local step = LevelEditor.snapPosition
			newPos.x = step * round(newPos.x / step)
			newPos.y = step * round(newPos.y / step)
			newPos.z = step * round(newPos.z / step)
		end

		mat.position = newPos
		setModelMatrix(LevelEditor.models[modelIndex], mat)
		LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)
	end

	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT then
		LevelEditor.cloneDragging = false
		local newSelection = {}
		for i = 1, #LevelEditor.cloneNewModelIndices do
			newSelection[#newSelection + 1] = LevelEditor.cloneNewModelIndices[i]
		end
		setSelectedModels(newSelection)
		storeUndo()

		setActiveTool("select")

		LevelEditor.skipClickRelease = true
	end
end

function loadAssetToolUpdate()
	if LevelEditor.activeTool ~= "loadAsset" then
		return
	end

	UILib.setCursor(UILib.CURSOR)

	if not LevelEditor.loadAssetDragging then
		return
	end

	if LevelEditor.mouseInViewport == -1 then
		return
	end

	local mousePos3d = nil

	if LevelEditor.mouseInViewport == 1 then
		local viewSpriteData = LevelEditor.sideViewSpriteData
		local cameraMat = LevelEditor.camSideMat
		local camManip = LevelEditor.camManipSide

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, true)
		local ray = screenRay(mousePosInViewport, -camManip.focusDist, cameraMat, true)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.loadAssetPlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	elseif LevelEditor.mouseInViewport == 2 then
		local viewSpriteData = LevelEditor.perspViewSpriteData
		local cameraMat = LevelEditor.camPerspMat
		local camManip = LevelEditor.camManipPersp

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, camManip.ortho)
		local fov = camManip.ortho and -camManip.focusDist or LevelEditor.camFov
		local ray = screenRay(mousePosInViewport, fov, cameraMat, camManip.ortho)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.loadAssetPlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	end

	if mousePos3d == nil then
		return
	end

	local modelIndex = LevelEditor.loadAssetModelIndex
	local mat = getModelMatrix(LevelEditor.models[modelIndex])

	local newPos = mousePos3d

	if LevelEditor.snapEnabled then
		local step = LevelEditor.snapPosition
		newPos.x = step * round(newPos.x / step)
		newPos.y = step * round(newPos.y / step)
		newPos.z = step * round(newPos.z / step)
	end

	mat.position = newPos
	setModelMatrix(LevelEditor.models[modelIndex], mat)
	LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)

	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT then
		LevelEditor.loadAssetDragging = false
		setSelectedModels({modelIndex})
		storeUndo()

		setActiveTool("select")

		LevelEditor.skipClickRelease = true
	end
end

function addUtilityModelToolUpdate()
	if LevelEditor.activeTool ~= "addUtilityModel" then
		return
	end

	UILib.setCursor(UILib.CURSOR)

	if not LevelEditor.loadAssetDragging then
		return
	end

	if LevelEditor.mouseInViewport == -1 then
		return
	end

	local mousePos3d = nil

	if LevelEditor.mouseInViewport == 1 then
		local viewSpriteData = LevelEditor.sideViewSpriteData
		local cameraMat = LevelEditor.camSideMat
		local camManip = LevelEditor.camManipSide

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, true)
		local ray = screenRay(mousePosInViewport, -camManip.focusDist, cameraMat, true)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.loadAssetPlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	elseif LevelEditor.mouseInViewport == 2 then
		local viewSpriteData = LevelEditor.perspViewSpriteData
		local cameraMat = LevelEditor.camPerspMat
		local camManip = LevelEditor.camManipPersp

		local mousePosInViewport = viewportToScreen(LevelEditor.mousePos, viewSpriteData, camManip.ortho)
		local fov = camManip.ortho and -camManip.focusDist or LevelEditor.camFov
		local ray = screenRay(mousePosInViewport, fov, cameraMat, camManip.ortho)
		local planeNormal = -cameraMat.forwardAxis
		local t = intersectPlane(ray.origin, ray.direction, LevelEditor.loadAssetPlaneOrigin, planeNormal)
		mousePos3d = ray.origin + (ray.direction * t)
	end

	if mousePos3d == nil then
		return
	end

	local modelIndex = LevelEditor.loadAssetModelIndex
	local mat = getModelMatrix(LevelEditor.models[modelIndex])

	local newPos = mousePos3d

	if LevelEditor.snapEnabled then
		local step = LevelEditor.snapPosition
		newPos.x = step * round(newPos.x / step)
		newPos.y = step * round(newPos.y / step)
		newPos.z = step * round(newPos.z / step)
	end

	mat.position = newPos
	setModelMatrix(LevelEditor.models[modelIndex], mat)
	LevelEditor.boundingBoxes[modelIndex] = transformBounds(LevelEditor.localBoundingBoxes[modelIndex], mat)

	if LevelEditor.mousePressed == MOUSE_BUTTON_LEFT then
		LevelEditor.loadAssetDragging = false
		setSelectedModels({modelIndex})
		storeUndo()

		setActiveTool("select")

		LevelEditor.skipClickRelease = true
	end
end

function storeUndo()
	local undoEntry = {}

	undoEntry.modelCount = #LevelEditor.models

	undoEntry.modelPaths = {}
	undoEntry.modelNames = {}
	undoEntry.modelDatas = {}
	for i = 1, #LevelEditor.models do
		undoEntry.modelPaths[i] = getModelData(LevelEditor.models[i], "path", "")
		undoEntry.modelNames[i] = getModelName(LevelEditor.models[i])
		undoEntry.modelDatas[i] = getAllModelData(LevelEditor.models[i])
	end

	undoEntry.modelMatrices = {}
	for i = 1, #LevelEditor.models do
		local mat = getModelMatrix(LevelEditor.models[i])
		undoEntry.modelMatrices[i] = {
			position = vec3(mat.position),
			rotation = mat.rotation,
			scale = _getModelSignedScale(LevelEditor.models[i])
		}
	end

	undoEntry.selectedModels = {}
	for i = 1, #LevelEditor.selectedModels do
		undoEntry.selectedModels[i] = LevelEditor.selectedModels[i]
	end

	if LevelEditor.levelHierarchy then
		undoEntry.hierarchy = deepCopyHierarchy(LevelEditor.levelHierarchy)
	end

	LevelEditor.undoStackId = LevelEditor.undoStackId + 1
	LevelEditor.undoStack[LevelEditor.undoStackId] = undoEntry

	for i = LevelEditor.undoStackId + 1, #LevelEditor.undoStack do
		LevelEditor.undoStack[i] = nil
	end

	if not LevelEditor.dirty then
		LevelEditor.dirty = true
		updateViewTitle()
	end
end

function restoreUndoEntry(undoEntry)
	local currentCount = #LevelEditor.models
	local targetCount = undoEntry.modelCount

	local pathsMatch = currentCount == targetCount
	if pathsMatch then
		for i = 1, currentCount do
			if getModelData(LevelEditor.models[i], "path", "") ~= undoEntry.modelPaths[i] then
				pathsMatch = false
				break
			end
		end
	end

	if not pathsMatch then
		local pageId = invokeProgram(MAIN_PROGRAMID, "getCurrentPage")

		for i = 1, math.max(currentCount, targetCount) do
			LevelEditor.models[i] = nil
			LevelEditor.localBoundingBoxes[i] = nil
			LevelEditor.boundingBoxes[i] = nil
			LevelEditor.modelMeshes[i] = nil
		end

		LevelEditor.meshes = {}

		for i = 1, targetCount do
			local rootModel
			if undoEntry.modelPaths[i] == "__utility__" then
				rootModel = allocModel()
				setModelData(rootModel, "utility", 1)
				LevelEditor.localBoundingBoxes[i] = {min = vec3(-0.25, -0.25, -0.25), max = vec3(0.25, 0.25, 0.25)}
				LevelEditor.modelMeshes[i] = {}
			else
				rootModel = loadModel(undoEntry.modelPaths[i], pageId)[1]
				if LevelEditor.tileBoundsEnabled then
					local tsx = getModelData(rootModel, "tileSizeX", 1)
					local tsy = getModelData(rootModel, "tileSizeY", 1)
					local tsz = getModelData(rootModel, "tileSizeZ", 1)
					local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
					LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
				else
					LevelEditor.localBoundingBoxes[i] = computeWorldBoundingBox(rootModel)
				end
				collectMeshes(rootModel, LevelEditor.meshes)
				LevelEditor.modelMeshes[i] = {}
				collectMeshes(rootModel, LevelEditor.modelMeshes[i])
			end
			LevelEditor.models[i] = rootModel
			setModelData(rootModel, "path", undoEntry.modelPaths[i])
			if undoEntry.modelNames and undoEntry.modelNames[i] then
				setModelName(rootModel, undoEntry.modelNames[i])
			end
			if undoEntry.modelDatas and undoEntry.modelDatas[i] then
				for k, v in pairs(undoEntry.modelDatas[i]) do
					setModelData(rootModel, k, v)
				end
				if LevelEditor.tileBoundsEnabled and undoEntry.modelPaths[i] ~= "__utility__" then
					local tsx = getModelData(rootModel, "tileSizeX", 1)
					local tsy = getModelData(rootModel, "tileSizeY", 1)
					local tsz = getModelData(rootModel, "tileSizeZ", 1)
					local half = vec3(tsx * 0.5, tsy * 0.5, tsz * 0.5)
					LevelEditor.localBoundingBoxes[i] = {min = vec3(-half.x, -half.y, -half.z), max = vec3(half.x, half.y, half.z)}
				end
			end
		end
	end

	for i = 1, targetCount do
		local saved = undoEntry.modelMatrices[i]
		if saved ~= nil then
			local mat = _setModelTransform(LevelEditor.models[i], saved.position, saved.rotation, saved.scale)

			LevelEditor.boundingBoxes[i] = transformBounds(LevelEditor.localBoundingBoxes[i], mat)
		end
	end

	local restored = {}
	for i = 1, #undoEntry.selectedModels do
		restored[i] = undoEntry.selectedModels[i]
	end
	setSelectedModels(restored)

	if undoEntry.hierarchy then
		LevelEditor.levelHierarchy = deepCopyHierarchy(undoEntry.hierarchy)
	else
		LevelEditor.levelHierarchy = buildLevelHierarchyFromFlat()
	end

	if LevelEditor.hierarchyInspector and LevelEditor.hierarchyInspector.visible then
		LevelEditor.hierarchyInspector:inspect(LevelEditor.levelHierarchy)
	end
end

function shortcutUndo()
	if UILib.focusedSpriteWidget ~= nil then return end
	_undo()
end

function _undo()
	if getKeyDown(KEY_LEFT_SHIFT) == true then
		return
	end

	LevelEditor.undoStackId = LevelEditor.undoStackId - 1

	if LevelEditor.undoStackId < 1 then
		LevelEditor.undoStackId = 1

		return
	end

	local undoEntry = LevelEditor.undoStack[LevelEditor.undoStackId]

	restoreUndoEntry(undoEntry)
end

function shortcutRedo()
	if UILib.focusedSpriteWidget ~= nil then return end
	_redo()
end

function _redo()
	LevelEditor.undoStackId = LevelEditor.undoStackId + 1

	local undoEntry = LevelEditor.undoStack[LevelEditor.undoStackId]

	if undoEntry == nil then
		LevelEditor.undoStackId = LevelEditor.undoStackId - 1

		return
	end

	restoreUndoEntry(undoEntry)
end

function cameraManipRot(cameraMat, camManip)
	if UILib.isHovered() == true then
		return
	end

	local mouseDelta = LevelEditor.mousePressedPos - LevelEditor.mousePos
	if vec2Length(mouseDelta) > 0.0 then
		LevelEditor.manipulatingCamera = true
		local forward = vec3(0.0, 0.0, 1.0) * camManip.rot
		local orbitDist = camManip.ortho and camManip.perspFocusDist or camManip.focusDist
		local focusPoint = camManip.pos - (forward * orbitDist)
		local delta = mouseDelta
		camManip.rot = eulerRot(delta.y, 0.0, 0.0) * camManip.rot * eulerRot(0.0, delta.x, 0.0)
		local newForward = vec3(0.0, 0.0, 1.0) * camManip.rot
		camManip.pos = focusPoint + (newForward * orbitDist)
		LevelEditor.mousePressedPos = LevelEditor.mousePos
	end
end

function cameraManipPan(cameraMat, camManip)
	if UILib.isHovered() == true then
		return
	end

	local mouseDelta = LevelEditor.mousePressedPos - LevelEditor.mousePos
	if vec2Length(mouseDelta) > 0.0 then
		LevelEditor.manipulatingCamera = true
		local p1 = screenToWorld(LevelEditor.mousePressedPos, LevelEditor.screenSize, camManip.focusDist, cameraMat, LevelEditor.camFov, camManip.ortho)
		local p2 = screenToWorld(LevelEditor.mousePos, LevelEditor.screenSize, camManip.focusDist, cameraMat, LevelEditor.camFov, camManip.ortho)
		if camManip.ortho == false then
			camManip.pos = camManip.pos + (p1 - p2)
		else
			camManip.pos = camManip.pos - (p1 - p2)
		end
		LevelEditor.mousePressedPos = LevelEditor.mousePos
	end
end

function cameraManipZoom(cameraMat, camManip)
	if UILib.isHovered() == true then
		return
	end

	local mouseDelta = LevelEditor.mousePressedPos - LevelEditor.mousePos
	if vec2Length(mouseDelta) > 0.0 then
		LevelEditor.manipulatingCamera = true

		local deltaLen = mouseDelta.x
		if abs(mouseDelta.y) > abs(mouseDelta.x) then
			deltaLen = mouseDelta.y
		end

		local forward = vec3(0.0, 0.0, 1.0) * camManip.rot
		if camManip.ortho == false then
			local focusSpeed = 0.1
			local focusPoint = camManip.pos - (forward * camManip.focusDist)
			camManip.focusDist = clamp(camManip.focusDist + (deltaLen * focusSpeed), 1.0, 50.0)
			camManip.pos = focusPoint + (forward * camManip.focusDist)
		else
			local focusSpeed = 0.001
			camManip.focusDist = clamp(camManip.focusDist - (deltaLen * focusSpeed), LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax)
		end
		LevelEditor.mousePressedPos = LevelEditor.mousePos
	end
end

function cameraManipTrackpadPan(trackpadDelta, cameraMat, camManip)
	if UILib.isHovered() == true then
		return
	end

	LevelEditor.manipulatingCamera = true

	local p1 = screenToWorld(LevelEditor.mousePos, LevelEditor.screenSize, camManip.focusDist, cameraMat, LevelEditor.camFov, camManip.ortho)
	local p2 = screenToWorld(LevelEditor.mousePos - (trackpadDelta * 10.0), LevelEditor.screenSize, camManip.focusDist, cameraMat, LevelEditor.camFov, camManip.ortho)

	if camManip.ortho == false then
		camManip.pos = camManip.pos + (p1 - p2)
	else
		camManip.pos = camManip.pos - (p1 - p2)
	end
end

function cameraManipScrollWheelZoom(scroll, cameraMat, camManip)
	if UILib.isHovered() == true then
		return
	end

	LevelEditor.manipulatingCamera = true

	local forward = vec3(0.0, 0.0, 1.0) * camManip.rot
	if camManip.ortho == false then
		local focusSpeed = 1.5
		local focusPoint = camManip.pos - (forward * camManip.focusDist)
		camManip.focusDist = clamp(camManip.focusDist + (scroll * focusSpeed), 1.0, 50.0)
		camManip.pos = focusPoint + (forward * camManip.focusDist)
	else
		local focusSpeed = 0.05
		camManip.focusDist = clamp(camManip.focusDist - (scroll * focusSpeed), LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax)
	end
end

function updateCamera(cameraMat, camManip, viewSpriteData, viewId)
	if LevelEditor.mouseDown == -1 then
		LevelEditor.manipulatingCamera = false
	end

	if LevelEditor.mouseInViewport == viewId then
		local trackpadPan = getTrackpadPanned()
		if vec2Length(trackpadPan) > 0.0 then
			cameraManipTrackpadPan(trackpadPan, cameraMat, camManip)
		end

		local scrollWheel = getMouseWheelScrolled()
		if scrollWheel ~= 0.0 then
			cameraManipScrollWheelZoom(scrollWheel, cameraMat, camManip)
		end

		local pinchZoom = getTrackpadPinchZoom()
		if pinchZoom ~= 0.0 then
			cameraManipScrollWheelZoom(-pinchZoom, cameraMat, camManip)
		end
	end

	if LevelEditor.clickedViewport == viewId and viewId == 2 then
		local notToolDragging = not LevelEditor.moveDragging and not LevelEditor.rotateDragging and not LevelEditor.scaleDragging
		if LevelEditor.mouseDown == MOUSE_BUTTON_LEFT and notToolDragging and LevelEditor.draggingSelection == false then
			cameraManipRot(cameraMat, camManip)
		end
	end

	if LevelEditor.mouseInViewport == viewId then
		if getMouseButtonDown(MOUSE_BUTTON_MIDDLE) == true then
			cameraManipPan(cameraMat, camManip)
		end

		if getMouseButtonDown(MOUSE_BUTTON_RIGHT) == true then
			cameraManipZoom(cameraMat, camManip)
		end
	end

	cameraMat.position = camManip.pos
	cameraMat.rotation = camManip.rot

	if camManip.ortho == true then
		setCameraOrtho(cameraMat, camManip.focusDist)
	else
		setCamera(cameraMat, LevelEditor.camFov)
	end
end

function drawBoundingBox(bb, color)
	local m = bb.matrix
	local c1 = m * vec3(bb.min.x, bb.min.y, bb.min.z)
	local c2 = m * vec3(bb.max.x, bb.min.y, bb.min.z)
	local c3 = m * vec3(bb.min.x, bb.max.y, bb.min.z)
	local c4 = m * vec3(bb.min.x, bb.min.y, bb.max.z)
	local c5 = m * vec3(bb.max.x, bb.max.y, bb.min.z)
	local c6 = m * vec3(bb.max.x, bb.min.y, bb.max.z)
	local c7 = m * vec3(bb.min.x, bb.max.y, bb.max.z)
	local c8 = m * vec3(bb.max.x, bb.max.y, bb.max.z)

	drawLine(c1, c2, color)
	drawLine(c1, c3, color)
	drawLine(c1, c4, color)

	drawLine(c8, c7, color)
	drawLine(c8, c6, color)
	drawLine(c8, c5, color)

	drawLine(c3, c5, color)
	drawLine(c3, c7, color)

	drawLine(c2, c5, color)
	drawLine(c2, c6, color)

	drawLine(c4, c6, color)
	drawLine(c4, c7, color)
end

function drawUtilityModelAxes(modelIndex)
	local model = LevelEditor.models[modelIndex]
	if getModelData(model, "utility", 0) ~= 1 then
		return
	end

	local mat = getModelMatrix(model)
	local pos = mat.position
	local rot = mat.rotation
	local axisLen = 0.5

	local right = vec3(1.0, 0.0, 0.0) * rot
	local up = vec3(0.0, 1.0, 0.0) * rot
	local forward = vec3(0.0, 0.0, 1.0) * rot

	drawLine(pos, pos + (right * axisLen), Config.paletteNames.red)
	drawLine(pos, pos + (up * axisLen), Config.paletteNames.green)
	drawLine(pos, pos + (forward * axisLen), Config.paletteNames.blue)

	LevelEditor.utilitySpriteData.position = pos
	setSprite(LevelEditor.utilitySprite, LevelEditor.utilitySpriteData)
	drawSprite(LevelEditor.utilitySprite)
end

function drawViewportGrid(camManip)
	setDepthEnabled(false)

	local gridScale = remapRange(camManip.focusDist, LevelEditor.orthoCamZoomMin, LevelEditor.orthoCamZoomMax, 0.0, 1.0)
	drawGrid(gridScale, camManip.rot, Config.uiColors.levelEditorGrid)

	drawLine(vec3(0.0, -0.1, 0.0), vec3(0.0, 0.1, 0.0), Config.uiColors.levelEditorGrid + 50)
	drawLine(vec3(-0.1, 0.0, 0.0), vec3(0.1, 0.0, 0.0), Config.uiColors.levelEditorGrid + 50)
	drawLine(vec3(0.0, 0.0, -0.1), vec3(0.0, 0.0, 0.1), Config.uiColors.levelEditorGrid + 50)

	setDepthEnabled(true)
end

function drawSideView()
	local viewSpriteCenterX = (LevelEditor.screenWidth * 0.5)
	local viewSpriteLeft = viewSpriteCenterX - (LevelEditor.sideViewSpriteData.size.x * 0.5)
	local viewSpriteCenterY = (LevelEditor.screenHeight * 0.5)
	local viewSpriteBottom = viewSpriteCenterY - (LevelEditor.sideViewSpriteData.size.y * 0.5)

	setSprite(LevelEditor.sideViewSprite, LevelEditor.sideViewSpriteData)
	startRenderToSprite(LevelEditor.sideViewSprite,
		vec2(viewSpriteLeft, viewSpriteBottom),
		LevelEditor.sideViewSpriteData.size)

	drawViewportGrid(LevelEditor.camManipSide)

	local backfaceCull = false
	setDepthEnabled(false)

	local showAllBounds = LevelEditor.activeTool == "move" or LevelEditor.activeTool == "clone" or LevelEditor.activeTool == "loadAsset" or LevelEditor.activeTool == "boxEdit"

	for i = 1, #LevelEditor.models do
		if not isValidAddress(LevelEditor.models[i]) then
			goto continueDrawLoop
		end

		local meshes = LevelEditor.modelMeshes[i]
		for m = 1, #meshes do
			drawWireframe(meshes[m], Config.uiColors.levelEditorWireframe, backfaceCull)
		end

		if showAllBounds and not isModelSelected(i) then
			drawBoundingBox(LevelEditor.boundingBoxes[i], Config.uiColors.levelEditorInactiveBounds)
		end

		drawUtilityModelAxes(i)

		::continueDrawLoop::
	end

	for _si = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[_si]
		drawBoundingBox(LevelEditor.boundingBoxes[modelIndex], Config.uiColors.levelEditorSelected)
	end

	if LevelEditor.hoveredModel ~= -1 and not isModelSelected(LevelEditor.hoveredModel) then
		drawBoundingBox(LevelEditor.boundingBoxes[LevelEditor.hoveredModel], Config.uiColors.levelEditorPickHighlight)
	end

	if LevelEditor.draggingSelection then
		local viewSpriteData = LevelEditor.sideViewSpriteData
		local camManip = LevelEditor.camManipSide
		local cameraMat = LevelEditor.camSideMat
		local fov = camManip.focusDist

		local screenP1 = LevelEditor.mousePressedPos
		local screenP2 = vec2(LevelEditor.mousePos.x, LevelEditor.mousePressedPos.y)
		local screenP3 = LevelEditor.mousePos
		local screenP4 = vec2(LevelEditor.mousePressedPos.x, LevelEditor.mousePos.y)

		local ray1 = screenRay(viewportToScreen(screenP1, viewSpriteData, true), -fov, cameraMat, true)
		local ray2 = screenRay(viewportToScreen(screenP2, viewSpriteData, true), -fov, cameraMat, true)
		local ray3 = screenRay(viewportToScreen(screenP3, viewSpriteData, true), -fov, cameraMat, true)
		local ray4 = screenRay(viewportToScreen(screenP4, viewSpriteData, true), -fov, cameraMat, true)

		drawLine(ray1.origin + ray1.direction, ray2.origin + ray2.direction)
		drawLine(ray2.origin + ray2.direction, ray3.origin + ray3.direction)
		drawLine(ray3.origin + ray3.direction, ray4.origin + ray4.direction)
		drawLine(ray4.origin + ray4.direction, ray1.origin + ray1.direction)
	end

	setDepthEnabled(true)
end

function drawPerspView()
	setBilinearInterpEnabled(LevelEditor.bilinearInterp)

	local viewSpriteCenterX = (LevelEditor.screenWidth * 0.5)
	local viewSpriteLeft = viewSpriteCenterX - (LevelEditor.perspViewSpriteData.size.x * 0.5)
	local viewSpriteCenterY = (LevelEditor.screenHeight * 0.5)
	local viewSpriteBottom = viewSpriteCenterY - (LevelEditor.perspViewSpriteData.size.y * 0.5)

	setSprite(LevelEditor.perspViewSprite, LevelEditor.perspViewSpriteData)
	startRenderToSprite(LevelEditor.perspViewSprite,
		vec2(viewSpriteLeft, viewSpriteBottom),
		LevelEditor.perspViewSpriteData.size)
	
	local useTexture = 0.0
	if LevelEditor.shadingType == 1 or LevelEditor.shadingType == 4 then
		useTexture = 1.0
	end

	local lit = LevelEditor.shadingType ~= 4

	if lit then
		PolyUtil.beginLighting()
	end

	for i = 1, #LevelEditor.models do
		local meshes = LevelEditor.modelMeshes[i]
		for m = 1, #meshes do
			setMeshMicroShader(meshes[m], "useTexture", useTexture, MICROSHADER_UNIFORM)
			drawMesh(meshes[m])
		end
	end

	if lit then
		PolyUtil.endLighting()
	end

	-- the axes draw lines/sprites, which must stay outside the lighting region
	for i = 1, #LevelEditor.models do
		drawUtilityModelAxes(i)
	end

	for _si = 1, #LevelEditor.selectedModels do
		local modelIndex = LevelEditor.selectedModels[_si]
		drawBoundingBox(LevelEditor.boundingBoxes[modelIndex], Config.uiColors.levelEditorSelected)
	end

	if LevelEditor.hoveredModel ~= -1 and not isModelSelected(LevelEditor.hoveredModel) then
		drawBoundingBox(LevelEditor.boundingBoxes[LevelEditor.hoveredModel], Config.uiColors.levelEditorPickHighlight)
	end

	setBilinearInterpEnabled(false)
end

function updateMouseInput()
	if LevelEditor.mouseDown == -1 then
		LevelEditor.mousePressedInViewport = -1
	end

	if LevelEditor.mouseClickStartedOutside == true then
		if getMouseButtonDown(MOUSE_BUTTON_LEFT) == false and
			getMouseButtonDown(MOUSE_BUTTON_MIDDLE) == false and
			getMouseButtonDown(MOUSE_BUTTON_RIGHT) == false then

			LevelEditor.mouseClickStartedOutside = false
		end

		return
	end

	LevelEditor.mousePos = getMousePosition()

	LevelEditor.mouseInViewport = -1
	if insideViewport(LevelEditor.mousePos, LevelEditor.sideViewSpriteData) then
		LevelEditor.mouseInViewport = 1
	elseif insideViewport(LevelEditor.mousePos, LevelEditor.perspViewSpriteData) then
		LevelEditor.mouseInViewport = 2
	end

	LevelEditor.mousePressed = -1
	LevelEditor.mouseReleased = -1
	LevelEditor.mouseDown = -1

	if getMouseButtonPressed(MOUSE_BUTTON_LEFT) == true then
		if LevelEditor.mousePos.x > LevelEditor.screenWidth or
			LevelEditor.mousePos.y > LevelEditor.screenHeight then
			LevelEditor.mouseClickStartedOutside = true
		end
		LevelEditor.mousePressed = MOUSE_BUTTON_LEFT
	elseif getMouseButtonPressed(MOUSE_BUTTON_MIDDLE) == true then
		LevelEditor.mousePressed = MOUSE_BUTTON_MIDDLE
	elseif getMouseButtonPressed(MOUSE_BUTTON_RIGHT) == true then
		LevelEditor.mousePressed = MOUSE_BUTTON_RIGHT
	end

	if getMouseButtonReleased(MOUSE_BUTTON_LEFT) == true then
		LevelEditor.mouseReleased = MOUSE_BUTTON_LEFT
	elseif getMouseButtonReleased(MOUSE_BUTTON_MIDDLE) == true then
		LevelEditor.mouseReleased = MOUSE_BUTTON_MIDDLE
	elseif getMouseButtonReleased(MOUSE_BUTTON_RIGHT) == true then
		LevelEditor.mouseReleased = MOUSE_BUTTON_RIGHT
	end

	if getMouseButtonDown(MOUSE_BUTTON_LEFT) == true then
		LevelEditor.mouseDown = MOUSE_BUTTON_LEFT
	elseif getMouseButtonDown(MOUSE_BUTTON_MIDDLE) == true then
		LevelEditor.mouseDown = MOUSE_BUTTON_MIDDLE
	elseif getMouseButtonDown(MOUSE_BUTTON_RIGHT) == true then
		LevelEditor.mouseDown = MOUSE_BUTTON_RIGHT
	end

	if LevelEditor.mousePressed ~= -1 and UILib.isHovered() == true then
		LevelEditor.mousePressed = -1
		LevelEditor.mousePressedOnUI = true
	end

	if LevelEditor.mousePressedOnUI == true then
		LevelEditor.mouseDown = -1
	end

	if LevelEditor.mouseReleased ~= -1 and LevelEditor.mousePressedOnUI == true then
		LevelEditor.mousePressedOnUI = false
		LevelEditor.mouseReleased = -1
	end

	if LevelEditor.mousePressed ~= -1 then
		LevelEditor.clickedViewport = LevelEditor.mouseInViewport
		LevelEditor.mousePressedPos = LevelEditor.mousePos
	end

	if LevelEditor.mouseDown == -1 then
		LevelEditor.clickedViewport = -1
	end
end

function updateStatusBar()
	local desc = nil

	local hovered = UILib.hoveredSpriteWidget
	if hovered ~= nil and hovered.desc ~= nil then
		if LevelEditor.statusText ~= hovered.desc then
			desc = hovered.desc
			LevelEditor.statusText = desc

			local shortcut = Utils.registeredShortcuts[desc]
			if shortcut ~= nil then
				if shortcut.modifier ~= nil then
					desc = desc .. " - ( " .. shortcut.modifier .. " + " .. shortcut.key .. " )"
				else
					desc = desc .. " - ( " .. shortcut.key .. " )"
				end
			end

			LevelEditor.descPanel.text = desc
			LevelEditor.descPanel.visible = true
			LevelEditor.descPanel.changed = true
		end
	else
		if LevelEditor.descPanel.visible == true then
			LevelEditor.descPanel.text = ""
			LevelEditor.statusText = ""
			LevelEditor.descPanel.visible = false
			LevelEditor.descPanel.changed = true
		end
	end
end

function updateUI()
	if LevelEditor.snapEnabled ~= LevelEditor.snapButton.pressed then
		LevelEditor.snapButton.iconSpriteData.color = vec4Copy(Config.uiColors.panelLight)
		if LevelEditor.snapEnabled == true then
			LevelEditor.snapButton.iconSpriteData.color = vec4Copy(Config.uiColors.icon)
		end
		LevelEditor.snapButton.pressed = LevelEditor.snapEnabled
		setSprite(LevelEditor.snapButton.iconSprite, LevelEditor.snapButton.iconSpriteData)
	end

	if #LevelEditor.selectedModels > 0 and UILib.isSliderDragging() == false then
		_refreshProps()
	end
end

function update()
	updateMeshLight()
	updateMouseInput()
	updateSelection()
	selectToolUpdate()
	moveToolUpdate()
	rotateToolUpdate()
	scaleToolUpdate()
	boxEditToolUpdate()
	cloneToolUpdate()
	loadAssetToolUpdate()
	addUtilityModelToolUpdate()
	updateUI()

	drawSprite2D(LevelEditor.backgroundSprite)

	updateCamera(LevelEditor.camSideMat, LevelEditor.camManipSide, LevelEditor.sideViewSpriteData, 1)
	drawSideView()
	drawSprite2D(LevelEditor.sideViewSprite)
	drawSprite2D(LevelEditor.sideViewLabelSprite)

	updateCamera(LevelEditor.camPerspMat, LevelEditor.camManipPersp, LevelEditor.perspViewSpriteData, 2)
	drawPerspView()

	if LevelEditor.ditherEnabled == true then
		setDitherEnabled(true)
	end

	drawSprite2D(LevelEditor.perspViewSprite)

	if LevelEditor.ditherEnabled == true then
		setDitherEnabled(false)
	end

	updateStatusBar()

	UILib.update()
end
