www.spaceplanner.app

Web client to the spaceplanner API
git clone git://jacobedwards.org/www.spaceplanner.app
Log | Files | Refs

main.js (44672B)


      1 import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js"
      2 import "/lib/github.com/svgdotjs/svg.panzoom.js/svg.panzoom.js"
      3 import * as ui from "/lib/ui.js"
      4 import * as etc from "/lib/etc.js"
      5 import * as lib from "./editor.js"	// Confusing, but I don't want to fix variable conflict
      6 import { Vector2 } from "/lib/github.com/mrdoob/three.js/math/Vector2.js"
      7 import * as geometry from "./geometry.js"
      8 import * as backend from "./backend.js"
      9 import * as api from "/lib/api.js"
     10 
     11 const defaultMode = "Edit"
     12 const messageTimeout = 4000
     13 
     14 const buttons = {
     15 	left: 0,
     16 	middle: 1,
     17 	right: 2
     18 }
     19 
     20 const params = {
     21 	longpress: 350,
     22 	notMoveRadius: 20,
     23 	threshold: 400
     24 }
     25 
     26 const panBit = 1
     27 const zoomBit = 2
     28 
     29 const modes = {
     30 	View: {
     31 		handlers: {
     32 			contextmenu: preventDefaultHandler,
     33 			keydown: keyHandler
     34 		}
     35 	},
     36 	Edit: {
     37 		points: true,
     38 		handlers: {
     39 			contextmenu: preventDefaultHandler,
     40 			pointerdown: [singlePointerHandler, selectionHandler, selectionBoxHandler, precisePointHandler, precisePointMapHandler, furnitureHandler, addFurnitureHandler],
     41 			pointermove: [singlePointerHandler, selectionBoxHandler, precisePointHandler, precisePointMapHandler, furnitureHandler, addFurnitureHandler],
     42 			pointerup: [singlePointerHandler, selectionBoxHandler, precisePointHandler, precisePointMapHandler, furnitureHandler, addFurnitureHandler],
     43 			pointercancel: [singlePointerHandler,  selectionBoxHandler, precisePointHandler, precisePointMapHandler, furnitureHandler],
     44 			keydown: [keyHandler],
     45 			select: selectHandler,
     46 			reselect: selectHandler
     47 		}
     48 	},
     49 	Select: {
     50 		points: true,
     51 		handlers: {
     52 			contextmenu: preventDefaultHandler,
     53 			pointerdown: [singlePointerHandler, selectionHandler, selectionBoxHandler],
     54 			pointermove: [singlePointerHandler, selectionBoxHandler],
     55 			pointerup: [singlePointerHandler, selectionBoxHandler],
     56 			pointercancel: [singlePointerHandler, selectionBoxHandler],
     57 			keydown: keyHandler,
     58 			select: selectHandler,
     59 			reselect: selectHandler
     60 		}
     61 	}
     62 }
     63 
     64 let State = {
     65 	panZoom: 0,
     66 	pointOp: 'Create',
     67 	snapAngle: true,
     68 	snapPoints: true,
     69 	lastClick: null,
     70 	furnRotationSnap: true
     71 }
     72 
     73 const debug = (new URLSearchParams(new URL(document.URL).search)).get("debug") != undefined
     74 
     75 // turn off bubbling
     76 const escapeEvent = new Event("escape")
     77 const cancelEvent = new PointerEvent("pointercancel")
     78 
     79 etc.handle_wrap(init)
     80 
     81 function init() {
     82 	ui.wait("Loading data...")
     83 
     84 	let floorplan_id = (new URLSearchParams(new URL(document.URL).search)).get("id")
     85 	if (!floorplan_id) {
     86 		document.location.href = "/floorplans"
     87 		return
     88 	}
     89 
     90 	let floorplan
     91 	if (floorplan_id === "flp_demo") {
     92 		let f = document.body.appendChild(document.createElement("footer"))
     93 		f.id = "demo_footer"
     94 		f.append(document.createTextNode("Missing something? Click "))
     95 		let a = f.appendChild(document.createElement("a"))
     96 		a.append(document.createTextNode("here"))
     97 		a.href = "/features/upcoming.html"
     98 		f.append(document.createTextNode(" to see upcoming features or put in a request"))
     99 	} else {
    100 		etc.authorize()
    101 		floorplan = { user: localStorage.getItem("username"), id: floorplan_id }
    102 	}
    103 
    104 	etc.bar()
    105 
    106 	let h1 = document.querySelector("h1")
    107 	let suffix = h1.appendChild(document.createTextNode(""))
    108 	if (!floorplan) {
    109 		h1.textContent = "Demo"
    110 	} else {
    111 		api.fetch("GET", `floorplans/:user/${floorplan.id}`)
    112 			.then(function(metadata) {
    113 				h1.textContent = metadata.name
    114 			})
    115 			.catch(function(err) {
    116 				document.location.href = "/floorplans"
    117 			})
    118 	}
    119 
    120 	let draw = SVG()
    121 		.addTo("#editor")
    122 		.panZoom({
    123 			panButton: buttons.left,
    124 			// These need to be set using device size
    125 			zoomMin: .001,
    126 			zoomMax: .5,
    127 			zoomFactor: .5
    128 		})
    129 
    130 	let editor = new lib.FloorplanEditor(draw, floorplan,
    131 		{ backend: {
    132 			callbacks: {
    133 				pull: function() {
    134 					suffix.data = ""
    135 				},
    136 				push: function() {
    137 					suffix.data = ""
    138 					status()
    139 				},
    140 				pusherror: fetchError
    141 			}
    142 		}
    143 	})
    144 
    145 	editor.initialized
    146 		.then(function() { run(editor) })
    147 		.catch(etc.error)
    148 }
    149 
    150 function run(editor) {
    151 	ui.wait("Initializing editor...")
    152 
    153 	editor.useUnits("imperial")
    154 	editor.draw.hide()
    155 
    156 	for (let mode in modes) {
    157 		editor.addMode(mode, modes[mode])
    158 	}
    159 	editor.useMode(defaultMode)
    160 	editor.furnitureLabels(false)
    161 
    162 	let toolbar = document.querySelector("header")
    163 		.appendChild(document.createElement("ul"))
    164 	toolbar.classList.add("toolbar")
    165 
    166 	let undoRedo = document.createElement("li")
    167 	// These could be hidden when they wouldn't have any effect
    168 	undoRedo.append(ui.button("Undo",
    169 		"Undo last action (you may also press control-z)",
    170 		"arrow-undo-circle", {
    171 			handlers: {
    172 				click: function() {
    173 					editor.undo()
    174 				}
    175 			}
    176 		})
    177 	)
    178 	undoRedo.append(ui.button("Redo",
    179 		"Redo next action (you may also press control-y)",
    180 		"arrow-redo-circle", {
    181 			handlers: {
    182 				click: function() {
    183 					editor.redo()
    184 				}
    185 			}
    186 		})
    187 	)
    188 
    189 	let addFurn = ui.button("Add Furniture", "Add furniture", null, { handlers: {
    190 		// TODO: Create it at the last clicked point if on screen, else somewhere reasonable on screen
    191 		click: function() {
    192 			let p
    193 			const v = editor.draw.viewbox()
    194 			const c = { x: v.x + v.width / 2, y: v.y + v.height / 2 }
    195 			if (State.lastClick != null) {
    196 				p = State.lastClick
    197 				if (p.x < v.x || p.x > v.x + v.width || p.y < p.height || p.y > p.y + p.height) {
    198 					p = c
    199 				}
    200 			} else {
    201 				p = c
    202 			}
    203 			furnitureMenu(editor, p)
    204 		}
    205 	}})
    206 
    207 	const newGroup = function(name) {
    208 		return toolbar.appendChild(item(toolGroup(name))).querySelector("ul")
    209 	}
    210 
    211 	let tg = newGroup("More...")
    212 
    213 	tg.append(item(ui.button("Fit to view", "Fit the floor plan into view", null, {
    214 		handlers: { click: function() {
    215 			editor.fitToView()
    216 		}}
    217 	})))
    218 	tg.append(item(ui.button("Furniture labels", "Toggle furniture label visability", null, {
    219 		handlers: { click: function() {
    220 			editor.furnitureLabels(!editor.furnitureLabels())
    221 		}}
    222 	})))
    223 	tg.append(item(
    224 		selector(editor.modes, function(mode) {
    225 			if (mode === "View") {
    226 				editor.draw.unselect()
    227 				escape()
    228 			}
    229 			editor.useMode(mode)
    230 		},
    231 			{ current: editor.mode, text: "Mode:" }
    232 		)
    233 	))
    234 	tg.append(item(checkToggle("Angle snap", {
    235 			title: "Snap points to 45° angle",
    236 			off: function() { State.snapAngle = false },
    237 			on: function() { State.snapAngle = true },
    238 			value: State.snapAngle
    239 	})))
    240 	tg.append(item(checkToggle("Point snap", {
    241 			title: "Snap points to other points",
    242 			off: function() { State.snapPoints = false },
    243 			on: function() { State.snapPoints = true },
    244 			value: State.snapPoints
    245 	})))
    246 	tg.append(item(checkToggle("Furniture angle snap", {
    247 			title: "Snap furniture rotation at 45 degree angles",
    248 			off: function() { State.furnRotationSnap = false },
    249 			on: function() { State.furnRotationSnap = true },
    250 			value: State.furnRotationSnap
    251 	})))
    252 
    253 	toolbar.append(undoRedo)
    254 	toolbar.append(item(addFurn))
    255 
    256 	if (debug) {
    257 		toolbar.append(item(
    258 			selector(editor.units.systems, function(system) { editor.useUnits(system) },
    259 				{ current: editor.unitSystem, text: "Units:" }
    260 			)
    261 		))
    262 	}
    263 
    264 	editor.useGrid()
    265 	editor.draw.show()
    266 	editor.backend.pull()
    267 		.then(function() {
    268 			ui.wait()
    269 			if (editor.draw.findExactlyOne("#points").children().length === 0) {
    270 				editor.addPoint({ x: 0, y: 0 })
    271 			}
    272 			editor.useGrid("imperial")
    273 			editor.fitToView()
    274 		})
    275 
    276 	editor.draw.on("touchstart", function(e){ e.preventDefault() });
    277 	editor.draw.on("touchmove", function(e){ e.preventDefault() });
    278 	editor.draw.on("pinchZoomStart", function() { State.panZoom |= zoomBit })
    279 	editor.draw.on("pinchZoomEnd", function() { State.panZoom &= ~zoomBit})
    280 	editor.draw.on("panStart", function() { State.panZoom |= panBit })
    281 	editor.draw.on("panEnd", function() { State.panZoom &= ~panBit })
    282 }
    283 
    284 function checkToggle(name, params) {
    285 	if (!params || !params.off || !params.on) {
    286 		throw new Error("Requires on and off values")
    287 	}
    288 
    289 	const run = function(value) {
    290 		if (value) {
    291 			params.on()
    292 		} else {
    293 			params.off()
    294 		}
    295 	}
    296 
    297 	let c = document.createElement("form")
    298 	c.classList.add("check_toggle")
    299 
    300 	let label = c.appendChild(document.createElement("label"))
    301 	label.append(document.createTextNode(name + ": "))
    302 
    303 	let input = c.appendChild(document.createElement("input"))
    304 	input.setAttribute("type", "checkbox")
    305 	input.setAttribute("checked", params.value ?? false)
    306 	input.addEventListener("change", function(ev) { run(ev.target.checked) })
    307 	if (params.title) {
    308 		input.setAttribute("title", params.title)
    309 	}
    310 
    311 	if (params.value != undefined) {
    312 		input.checked = params.value
    313 		run(params.value)
    314 	}
    315 	return c
    316 }
    317 
    318 function selectHandler(event, editor, state) {
    319 	let old = document.getElementById("selOps")
    320 	if (!event.detail.selected) {
    321 		if (old) {
    322 			old.remove()
    323 		}
    324 		return
    325 	}
    326 	let a = event.detail.selected.array()
    327 	let c = document.createElement("li")
    328 	c.setAttribute("id", "selOps")
    329 
    330 	const refresh = function() {
    331 		selectHandler(event, editor, state)
    332 	}
    333 
    334 	c.append(ui.button("Unselect", "Unselect selection", null, {
    335 		handlers: { click: function() { editor.draw.select() } }
    336 	}))
    337 
    338 	c.append(ui.input("Delete", "Delete selected objects", {
    339 			attributes: { type: "button", value: "Delete" },
    340 			handlers: { click: function() {
    341 				editor.remove(...a)
    342 				editor.finishAction()
    343 			}},
    344 		})
    345 	)
    346 
    347 	let ids = []
    348 	for (let i = 0; i < a.length; ++i) {
    349 		ids.push(lib.getID(a[i]))
    350 	}
    351 
    352 	let groups = {}
    353 	let cnt = 0
    354 	for (let i = 0; i < ids.length; ++i) {
    355 		let t = backend.idType(ids[i])
    356 		if (groups[t] === undefined) {
    357 			groups[t] = []
    358 		}
    359 		groups[t].push(ids[i])
    360 		++cnt
    361 	}
    362 
    363 	if (groups.pnt && groups.pnt.length === cnt) {
    364 		const pmode = function(mode) { State.pointOp = mode }
    365 		// NOTE: Not sure if this is the behavior I want.
    366 		//pmode("Create")
    367 		c.appendChild(
    368 			selector({ Create: true, Move: true }, pmode, { current: State.pointOp })
    369 		)
    370 	}
    371 
    372 	if (groups.pntmap !== undefined || groups.pnt !== undefined) {
    373 		const getMaps = function() {
    374 			let maps = {}
    375 			if (groups.pnt) {
    376 				for (let i = 0; i < groups.pnt.length; ++i) {
    377 					for (let id in editor.backend.mappedPoints[groups.pnt[i]]) {
    378 						id = editor.backend.mappedPoints[groups.pnt[i]][id]
    379 						maps[id] = editor.backend.reqObj(id)
    380 					}
    381 				}
    382 			}
    383 			if (groups.pntmap) {
    384 				for (let i = 0; i < groups.pntmap.length; ++i) {
    385 					maps[groups.pntmap[i]] = editor.backend.cache.pointmaps[groups.pntmap[i]]
    386 				}
    387 			}
    388 			return maps
    389 		}
    390 		const changeTypes = function(newvalue) {
    391 			editor.finishAction()
    392 			const maps = getMaps()
    393 			for (let id in maps) {
    394 				editor.mapPoints({ type: newvalue }, id)
    395 			}
    396 			editor.finishAction()
    397 			return refresh()
    398 		}
    399 
    400 		let current
    401 		const maps = getMaps()
    402 		for (let id in maps) {
    403 			if (current === undefined) {
    404 				current = maps[id].type
    405 			} else if (current !== maps[id].type) {
    406 				current = null
    407 				break;
    408 			}
    409 		}
    410 		c.appendChild(
    411 			selector(editor.backend.params.pointmaps.types, changeTypes, { current, text: "Type:" })
    412 		)
    413 	}
    414 
    415 	if (groups.pntmap && cnt === 1) {
    416 		c.appendChild(document.createElement("li"))
    417 			.appendChild(ui.button("Subdivide", "Subdivide pointmap", null, {
    418 				handlers: {
    419 					click: function(ev) {
    420 						handled(ev)
    421 						let pm = editor.findObj(groups.pntmap[0])
    422 						let p
    423 						if (State.lastClick) {
    424 							// This will mostly work well, but if the user manages to click
    425 							// somewhere else and keep this selected it'll maybe not be where
    426 							// they want.
    427 							p = pm.closestPoint(new Vector2(State.lastClick.x, State.lastClick.y))
    428 						} else {
    429 							p = pm.interpolatedPoint(0.5)
    430 						}
    431 						let pid = editor.addPoint(p)
    432 						pm = editor.backend.reqObj(groups.pntmap[0])
    433 						editor.mapPoints({ a: pid, b: pm.a })
    434 						editor.mapPoints({ a: pid, b: pm.b })
    435 						editor.remove(groups.pntmap[0])
    436 					}
    437 				}
    438 			}))
    439 		c.appendChild(document.createElement("li"))
    440 			.appendChild(document.createTextNode("Length: " +
    441 		    userLength(editor, editor.pointmapLength(groups.pntmap[0]))))
    442 
    443 		const dolengths = function(m, point) {
    444 			let intr = m.closestLinearInterpolation(new Vector2(point.x, point.y))
    445 			let points = m.vecs()
    446 			let len = geometry.length(points[0], points[1])
    447 			let al = len * intr
    448 			let bl = len * (1.0 - intr) 
    449 			if (!geometry.compareVecs(points[0], points[1])) {
    450 				let t = al
    451 				al = bl
    452 				bl = t
    453 			}
    454 
    455 			let it = c.appendChild(document.createElement("li"))
    456 			it.id = "pointmap_lengths"
    457 			it.append(document.createTextNode("("))
    458 			let a = it.appendChild(document.createElement("span"))
    459 			it.append(document.createTextNode(" × "))
    460 			let b = it.appendChild(document.createElement("span"))
    461 			it.append(document.createTextNode(")"))
    462 
    463 			a.textContent = userLength(editor, al)
    464 			b.textContent = userLength(editor, bl)
    465 		}
    466 		let m = editor.findObj(groups.pntmap[0])
    467 		dolengths(m, State.lastClick)
    468 		/*
    469 		 * In the future, I could change the function to update the values,
    470 		 * but it wouldn't work well on touch devices anyway.
    471 		 *m.on("pointermove", function(ev) { dolengths(m, editor.draw.point(ev.clientX, ev.clientY)) })
    472 		 */
    473 
    474 		let pm = editor.backend.reqObj(groups.pntmap[0])
    475 		if (pm.type === "door") {
    476 			const swingButton = function(backward) {
    477 				return ui.button(backward ? "prevswing" : "swing",
    478 					`Swing door${backward ? " backward" : ""} (you may also click, drag, and release on a door)`,
    479 					backward ? "arrow-back" : "arrow-forward", {
    480 					handlers: {
    481 						click: function() {
    482 							let pm = editor.backend.reqObj(groups.pntmap[0])
    483 							editor.mapPoints({ door_swing: (backward ? prevSwing : nextSwing)(pm.door_swing) }, groups.pntmap[0])
    484 						}
    485 					}
    486 				})
    487 			}
    488 			let swingOps = c.appendChild(document.createElement("li"))
    489 			swingOps.append(document.createTextNode("Swing: "))
    490 			swingOps.append(swingButton(true))
    491 			swingOps.append(swingButton(false))
    492 			swingOps.append(ui.button("Reset", "Reset door swing", null, {
    493 					handlers: {
    494 						click: function() {
    495 							editor.mapPoints({ door_swing: null }, groups.pntmap[0])
    496 						}
    497 					}
    498 				}))
    499 		}
    500 	}
    501 
    502 	if (groups.furmap) {
    503 		if (groups.furmap.length !== 1) {
    504 			document.querySelectorAll(".furniture_menu").forEach(
    505 				function(e) {
    506 					e.remove()
    507 				}
    508 			)
    509 		} else if (cnt === 1) {
    510 			furnitureMenu(editor, groups.furmap[0])
    511 		}
    512 	}
    513 
    514 	if (old) {
    515 		old.replaceWith(c)
    516 	} else {
    517 		document.querySelector(".toolbar")
    518 			.appendChild(c)
    519 	}
    520 }
    521 
    522 function selector(things, select, options) {
    523 	options = options ?? {}
    524 
    525 	let form = document.createElement("form")
    526 	form.classList.add("selection")
    527 
    528 	if (options.text) {
    529 		form.appendChild(document.createTextNode(options.text + " "))
    530 	}
    531 
    532 	let list = form.appendChild(document.createElement("select"))
    533 	list.addEventListener("change", function(event) { select(event.target.value) })
    534 
    535 	let isArray = Array.isArray(things)
    536 	for (let thing in things) {
    537 		if (isArray) {
    538 			thing = things[thing]
    539 		}
    540 
    541 		console.debug("selector", options.text ?? "something", thing)
    542 		list.appendChild(document.createElement("option"))
    543 			.appendChild(document.createTextNode(thing))
    544 	}
    545 
    546 	list.value = options.current
    547 
    548 	return form
    549 }
    550 
    551 // pointerdown
    552 function selectionHandler(event, editor) {
    553 	let sel
    554 
    555 	if (event.pointerType === "mouse" && event.button === buttons.right) {
    556 		return
    557 	}
    558 
    559 	let p = editor.draw.point(event.clientX, event.clientY)
    560 	State.lastClick = structuredClone(p)
    561 	let order = [ "#" + editor.layoutG(), "#points", "#pointmaps" ]
    562 	for (let i = 0; !sel && i < order.length; ++i) {
    563 		sel = editor.thingAt(p, order[i])
    564 	}
    565 
    566 	if (!sel) {
    567 		let close = editor.thingsAt(p, order.join(","), { method: "touching", minsize: 3500 })
    568 		let dist
    569 		let closest
    570 		for (let i = 0; i < close.length; ++i) {
    571 			let tmp = close[i].distanceTo(p.x, p.y)
    572 			if (dist == null || tmp < dist) {
    573 				dist = tmp
    574 				closest = close[i]
    575 			}
    576 		}
    577 		sel = closest
    578 	}
    579 
    580 	if (sel != null) {
    581 		if (editor.mode !== "Select") {
    582 			sel.select()
    583 		} else {
    584 			let selection = addSelection(editor, sel, true)
    585 			if (selection.length === 0) {
    586 				editor.draw.select()
    587 			} else {
    588 				selection.selectList()
    589 			}
    590 		}
    591 	} else {
    592 		if (editor.mode !== "Select") {
    593 			editor.draw.select()
    594 		}
    595 		escape()
    596 	}
    597 }
    598 
    599 // pointerdown, pointercancel, pointermove, pointerup
    600 function selectionBoxHandler(ev, editor, state) {
    601 	const cleanup = function() {
    602 		if (state.rect) {
    603 			state.rect.remove()
    604 		}
    605 		for (let k in state) {
    606 			delete state[k]
    607 		}
    608 	}
    609 	const useEv = function(ev) {
    610 		if (ev.pointerType === "mouse") {
    611 			if (ev.type === "pointermove") {
    612 				return true
    613 			}
    614 			let p = primaryPointer(ev)
    615 			return state.shiftStart ? p : !p
    616 		} else if (editor.mode === "Select") {
    617 			return primaryPointer(ev)
    618 		} else {
    619 			return false
    620 		}
    621 	}
    622 
    623 	if (ev.type === "pointercancel") {
    624 		cleanup()
    625 		return
    626 	}
    627 
    628 	if (ev.type === "pointerdown" && ev.shiftKey) {
    629 		state.shiftStart = true
    630 	}
    631 
    632 	if (ev.type === "pointerdown" && useEv(ev)) {
    633 		handled(ev)
    634 		let p = editor.draw.point(ev.clientX, ev.clientY)
    635 		if (state.rect) {
    636 			console.error("selectionBoxHandler state got messed up")
    637 			cleanup()
    638 		}
    639 		state.origin = structuredClone(p)
    640 		state.rect = editor.ui.top.rect(0, 0)
    641 			.move(p.x, p.y)
    642 			.stroke({ width: 50, dasharray: [350, 350], color: "blue" })
    643 			.fill("rgba(0,0,1,.025)")
    644 		return
    645 	}
    646 
    647 	if (!state.rect) {
    648 		return
    649 	}
    650 
    651 	let p = editor.draw.point(ev.clientX, ev.clientY)
    652 	if (ev.type === "pointermove" && useEv(ev)) {
    653 		handled(ev)
    654 		let params = {}
    655 		if (state.origin.x > p.x) {
    656 			params.x = p.x
    657 			params.width = state.origin.x - p.x
    658 		} else {
    659 			params.x = state.origin.x
    660 			params.width = p.x - state.origin.x 
    661 		}
    662 		if (state.origin.y > p.y) {
    663 			params.y = p.y
    664 			params.height = state.origin.y - p.y
    665 		} else {
    666 			params.y = state.origin.y 
    667 			params.height = p.y - state.origin.y 
    668 		}
    669 		state.rect.move(params.x, params.y).size(params.width, params.height)
    670 	} else if (ev.type === "pointerup" && useEv(ev)) {
    671 		handled(ev)
    672 		let objects = editor.draw.find("#floorplan > :not(#furniture_layouts, #door_swings) > *, #furniture_layouts > * > *")
    673 		let s = state.rect.rbox(state.rect.root())
    674 		let selection = []
    675 		objects.each(function() {
    676 			let b = this.rbox(this.root())
    677 			if (((b.x >= s.x && b.x <= s.x2) || (b.x2 >= s.x && b.x2 <= s.x2)) &&
    678 			    ((b.y >= s.y && b.y <= s.y2) || (b.y2 >= s.y && b.y2 <= s.y2))) {
    679 				selection.push(this)
    680 			}
    681 				
    682 		})
    683 		if (selection.length > 0) {
    684 			addSelection(editor, selection).selectList()
    685 		}
    686 		cleanup()
    687 	}
    688 }
    689 
    690 function keyHandler(ev, editor) {
    691 	if (ev.key === "Escape") {
    692 		escape()
    693 	/*} else if (ev.key === "Backspace" || ev.key === "Delete") {
    694 		editor.remove(...editor.draw.find(".selected").array())*/
    695 	} else if (ev.key === "+") {
    696 		editor.draw.zoom(editor.draw.zoom() * 1.25)
    697 		editor.updateGrid()
    698 	} else if (ev.key === "-" || ev.key === "_") {
    699 		editor.draw.zoom(editor.draw.zoom() / 1.25)
    700 		editor.updateGrid()
    701 	} else {
    702 		if (!event.ctrlKey) {
    703 			return
    704 		}
    705 		if (event.key === "z") {
    706 			editor.undo()
    707 		} else if (event.key === "y") {
    708 			editor.redo()	
    709 		} else {
    710 			return
    711 		}
    712 	}
    713 
    714 	handled(ev)
    715 }
    716 
    717 function addSelection(editor, objects, flip) {
    718 	if (!Array.isArray(objects)) {
    719 		objects = [objects]
    720 	}
    721 
    722 	let sel = editor.draw.find(".selected")
    723 	for (let i = 0; i < objects.length; ++i) {
    724 		let si = sel.indexOf(objects[i])
    725 		if (si >= 0) {
    726 			if (flip) {
    727 				sel.splice(si, 1)
    728 			}
    729 		} else {
    730 			sel.push(objects[i])
    731 		}
    732 	}
    733 	return sel
    734 }
    735 
    736 function radioMenu(editor, key, values, initial, options) {
    737 	options = options ?? {}
    738 	options.callbacks = options.callbacks ?? {}
    739 
    740 	let menu = document.createElement("div")
    741 	menu.classList.add("menu")
    742 
    743 	menu.appendChild(document.createTextNode(key + ": "))
    744 
    745 	let form = menu.appendChild(document.createElement("form"))
    746 	let container = form
    747 
    748 	if (options.legend) {
    749 		container.appendChild(document.createElement("legend"))
    750 			.appendChild(document.createTextNode(options.legend))
    751 	}
    752 
    753 	let radios = radioInputs(key, values, initial)
    754 	for (let i in radios) {
    755 		if (options.callbacks.change) {
    756 			radios[i].addEventListener("change", function(event) {
    757 				options.callbacks.change(event.target.value)
    758 				handled(event)
    759 			})
    760 		}
    761 		container.append(radios[i])
    762 	}
    763 
    764 	if (options.nosubmit) {
    765 		return menu
    766 	}
    767 
    768 	container.appendChild(document.createTextNode(" "))
    769 	let submit = container.appendChild(document.createElement("input"))
    770 	submit.setAttribute("type", "submit")
    771 	submit.setAttribute("value", "Change")
    772 
    773 	form.addEventListener("submit", function(event) {
    774 		if (options.callbacks.commit) {
    775 			options.callbacks.commit(event)
    776 		}
    777 		handled(event)
    778 	})
    779 
    780 	return menu
    781 }
    782 
    783 function radioInputs(key, values, initial) {
    784 	let radios = []
    785 	for (let i in values) {
    786 		let label = document.createElement("label")
    787 		let radio = label.appendChild(document.createElement("input"))
    788 		radio.setAttribute("type", "radio")
    789 		radio.setAttribute("name", key)
    790 		radio.setAttribute("value", values[i])
    791 		if (values[i] === initial) {
    792 			radio.setAttribute("checked", true)
    793 		}
    794 		label.append(radio)
    795 		label.append(document.createTextNode(values[i]))
    796 		radios.push(label)
    797 	}
    798 	return radios
    799 }
    800 
    801 // pointerdown, pointermove, pointerup
    802 function precisePointHandler(event, editor, state) {
    803 	const init = function() {
    804 		state.menu = document.body.querySelector(".toolbar")
    805 			.appendChild(document.createElement("li"))
    806 		state.menu.classList.add("menu")
    807 		state.menu.appendChild(document.createTextNode("Length: "))
    808 		state.len = state.menu
    809 			.appendChild(document.createElement("input"))
    810 		state.len.value = 0
    811 		state.len.addEventListener("input", function(event) {
    812 			let vecs = state.line.vecs()
    813 			let len =  editor.units.snapTo(unitInput(editor, event.target.value), editor.unit)
    814 			if (len == null) {
    815 				return
    816 			}
    817 			if (len> 0) {
    818 				vecs[1] = geometry.length(vecs[0], vecs[1], len)
    819 				updatePoint(vecs[1], { leave_input: true })
    820 			}
    821 		})
    822 	}
    823 
    824 	const cleanup = function() {
    825 		if (state.moveTimeout != null) {
    826 			clearTimeout(state.moveTimeout)
    827 		}
    828 		if (state.menu != undefined) {
    829 			state.menu.remove()
    830 		}
    831 		for (let i in state) {
    832 			delete state[i]
    833 		}
    834 	}
    835 
    836 	const updatePoint = function(p, options) {
    837 		options = options ?? {}
    838 
    839 		let points = editor.thingsAt(p, "#points")
    840 		let fid = !state.moveOp ? lib.getID(state.from) : null
    841 		let tid = lib.getID(state.to)
    842 		delete state.onPoint
    843 		for (let i in points) {
    844 			let id = lib.getID(points[i])
    845 			if (id !== tid && (!fid || id !== fid)) {
    846 				state.onPoint = id
    847 				p = editor.backend.obj(id)
    848 			}
    849 		}
    850 
    851 		editor.movePoint(state.to, p)
    852 
    853 		if (!options.leave_input) {
    854 			unitInput(editor, state.len,
    855 			    editor.units.snapTo(state.origin.distanceTo(p), editor.unit))
    856 		}
    857 	}
    858 
    859 	const doMove = function() {
    860 		const ad = function(a, b) {
    861 			return Math.abs(a - b)
    862 		}
    863 
    864 		// This is racy
    865 		state.moveTimeout = null
    866 		if (state.nosnap) {
    867 			updatePoint(state.move)
    868 			return
    869 		}
    870 
    871 		let snapped = state.move
    872 		if (State.snapAngle) {
    873 			snapped = snap(editor.units.snapTo(state.move, editor.unit), state.origin, 8)
    874 		}
    875 
    876 		if (State.snapPoints) {
    877 			const updsnaps = function(snaps, k, from, test) {
    878 				let d = ad(from[k], test[k])
    879 				if (d <= params.threshold) {
    880 					if (!snaps[k] || d < snaps[k].d) {
    881 						snaps[k] = { d, v: test[k] }
    882 					}
    883 				}
    884 			}
    885 
    886 			let points = editor.backend.cache.points
    887 			let snaps = {}
    888 			let exclude = lib.getID(state.to)
    889 			for (let p in points) {
    890 				if (p != exclude) {
    891 					updsnaps(snaps, "x", snapped, points[p])
    892 					updsnaps(snaps, "y", snapped, points[p])
    893 				}
    894 			}
    895 			if (snaps.x != null) {
    896 				snapped.x = snaps.x.v
    897 			}
    898 			if (snaps.y != null) {
    899 				snapped.y = snaps.y.v
    900 			}
    901 		}
    902 		updatePoint(snapped)
    903 	}
    904 
    905 	const revert = function() {
    906 		/*
    907 		 * NOTE: WARNING: If allowing asyncronous edits this would be bad
    908 		 * I should introduce a revert function which takes diffs and reverts
    909 		 * them specifically, and I suppose pass a diff id with every single action.
    910 		 * I think asyncronous actions would add very little in terms of value,
    911 		 * and take time to implement. Better to disallow for now.
    912 		 */
    913 		editor.finishAction()
    914 		editor.undo()
    915 		cleanup()
    916 	}
    917 
    918 	const onMap = function(p) {
    919 		let maps = editor.thingsAt(p, "#pointmaps")
    920 		let toMaps = editor.backend.mappedPoints[state.to]
    921 
    922 		let map
    923 		for (let i = 0; !map && i < maps.length; ++i) {
    924 			let mid = lib.getID(maps[i])
    925 			let good = true
    926 			for (let k in toMaps) {
    927 				if (toMaps[k] === mid) {
    928 					good = false
    929 					break
    930 				}
    931 			}
    932 			if (good) {
    933 				map = maps[i]
    934 			}
    935 		}
    936 
    937 		if (map == null) {
    938 			return null
    939 		}
    940 		return { point: map.whereIsPoint(p.x, p.y), map: map }
    941 	}
    942 
    943 	const commit = function() {
    944 		if (state.onPoint) {
    945 			let tid = lib.getID(state.to)
    946 			for (let oth in editor.backend.mappedPoints[tid]) {
    947 				if (oth !== state.onPoint) {
    948 					editor.mapPoints({ a: state.onPoint, b: oth }, editor.backend.mappedPoints[state.to][oth])
    949 				}
    950 			}
    951 			editor.remove(tid)
    952 			state.to = editor.findObj(state.onPoint)
    953 		}
    954 
    955 		let on = onMap(state.to.vec())
    956 		if (on !== null) {
    957 			let mapD = editor.backend.reqObj(lib.getID(on.map))
    958 			editor.movePoint(state.to, on.point)
    959 			editor.mapPoints({ a: mapD.a, b: state.to })
    960 			editor.mapPoints({ a: mapD.b, b: state.to })
    961 			editor.remove(on.map)
    962 		}
    963 		editor.finishAction()
    964 		cleanup()
    965 	}
    966 
    967 	if (State.panZoom || event.type === "pointercancel") {
    968 		if (state.to) {
    969 			revert()
    970 		}
    971 		return
    972 	}
    973 
    974 	if (event.type === "pointermove") {
    975 		if (!primaryMove(event)) {
    976 			return
    977 		}
    978 	} else if (!truelyPrimary(event)) {
    979 		return
    980 	}
    981 
    982 	let cursor = editor.draw.point(event.clientX, event.clientY).vec()
    983 	if (state.to == undefined) {
    984 		if (event.type === "pointerdown") {
    985 			if (state.from != undefined) {
    986 				return
    987 			}
    988 
    989 			state.from = editor.selectedOne("points")
    990 			if (!state.from) {
    991 				return
    992 			}
    993 
    994 			if  (State.pointOp === 'Move') {
    995 				state.to = state.from
    996 				state.from = null
    997 				state.moveOp = true
    998 
    999 				// I want the first pointmap defined, but this for now
   1000 				let m = editor.backend.mappedPoints[lib.getID(state.to)]
   1001 				for (let point in m) {
   1002 					state.from = editor.findObj(point)
   1003 					break
   1004 				}
   1005 				if (!state.from) {
   1006 					// I mean, there really shouldn't be an orphaned point,
   1007 					// and I see no reason to move the only point in the plan
   1008 					cleanup()
   1009 					throw new Error("Can't move unmapped points")
   1010 				}
   1011 				init()
   1012 			}
   1013 
   1014 			state.origin = cursor
   1015 		} else if (event.type === "pointerup") {
   1016 			if (state.from) {
   1017 				cleanup()
   1018 			} else {
   1019 				return
   1020 			}
   1021 		} else if (event.type === "pointermove" && state.origin != undefined &&
   1022 		    state.origin.distanceTo(cursor) > params.threshold) {
   1023 			state.to = editor.addPoint(cursor, true)
   1024 			editor.mapPoints({ type: "wall", a: state.from, b: state.to})
   1025 			state.to = editor.findObj(state.to)
   1026 			init()
   1027 		} else {
   1028 			return
   1029 		}
   1030 		handled(event)
   1031 		return
   1032 	}
   1033 
   1034 	if (state.to == undefined) {
   1035 		return
   1036 	}
   1037 	if (!state.from) {
   1038 		throw new Error("Hmm")
   1039 	}
   1040 
   1041 	if (event.type === "pointermove") {
   1042 		// This is still far too expensive, it runs up my fans in seconds.
   1043 		state.move = cursor
   1044 		state.nosnap = event.shiftKey
   1045 		if (state.moveTimeout == null) {
   1046 			state.moveTimeout = setTimeout(doMove, 35)
   1047 		}
   1048 	} else if (event.type === "pointerup") {
   1049 		if (!state.moveOp && state.from && state.from.inside(cursor.x, cursor.y)) {
   1050 			revert()
   1051 		} else {
   1052 			// Not that it makes much difference, but should probably use
   1053 			// state.to's position
   1054 			if (state.to && state.origin.distanceTo(cursor) > 0) {
   1055 				commit()
   1056 			} else {
   1057 				cleanup()
   1058 			}
   1059 		}
   1060 	}  else {
   1061 		console.warn("Bit of a state mismatch, not that big of a deal though")
   1062 		commit()
   1063 	}
   1064 
   1065 	handled(event)
   1066 }
   1067 
   1068 // pointerdown, pointermove, pointerup
   1069 function precisePointMapHandler(event, editor, state) {
   1070 	const cleanup = function() {
   1071 		for (let i in state) {
   1072 			delete state[i]
   1073 		}
   1074 	}
   1075 
   1076 	if (event.type === "pointercancel") {
   1077 		cleanup()
   1078 		return
   1079 	}
   1080 
   1081 	let cursor = editor.draw.point(event.clientX, event.clientY).vec()
   1082 	if (editor.thingAt(cursor, "#points")) {
   1083 		return
   1084 	}
   1085 
   1086 	if (event.type === "pointermove") {
   1087 		if (!state.door) {
   1088 			return
   1089 		}
   1090 		handled(event)
   1091 
   1092 		let door = editor.findObj(state.doorID)
   1093 		if (state.doorSwingFrom.distanceTo(cursor) < params.threshold) {
   1094 			return
   1095 		}
   1096 
   1097 		let o = door.point_offset(cursor)
   1098 		if (state.hinge === "b") {
   1099 			o = -o
   1100 		}
   1101 
   1102 		let v = door.vecs()
   1103 		if (v[0].y < v[1].y) {
   1104 			o = -o
   1105 		}
   1106 
   1107 		let s = (o > 0 ? "+" : "-")
   1108 		editor.backend.mapPoints({ door_swing: state.hinge + s }, state.doorID)
   1109 	}
   1110 
   1111 	if (event.type === "pointerup") {
   1112 		if (!state.door) {
   1113 			return
   1114 		}
   1115 		handled(event)
   1116 		editor.finishAction()
   1117 		cleanup()
   1118 		return
   1119 	}
   1120 
   1121 	let map = editor.selectedOne("pointmaps")
   1122 	if (map == null) {
   1123 		return
   1124 	}
   1125 
   1126 	let id = lib.getID(map)
   1127 	let data = editor.backend.obj(id)
   1128 	if (data.type !== "door" || !truelyPrimary(event)) {
   1129 		return
   1130 	}
   1131 
   1132 	if (event.type === "pointerdown") {
   1133 		handled(event)
   1134 		state.door = data
   1135 		state.doorID = id
   1136 		state.doorSwingFrom = cursor.clone()
   1137 		state.hinge = Math.round(editor.findObj(id).closestLinearInterpolation(cursor)) ? "a" : "b"
   1138 	} else {
   1139 		console.log("Hmm", event)
   1140 	}
   1141 }
   1142 
   1143 // pointerdown, pointerup
   1144 function addFurnitureHandler(ev, editor, state) {
   1145 	const cleanup = function() {
   1146 		if (state.timeout) {
   1147 			clearTimeout(state.timeout)
   1148 		}
   1149 		for (let k in state) {
   1150 			delete state[k]
   1151 		}
   1152 	}
   1153 
   1154 	let p = new Vector2(ev.clientX, ev.clientY)
   1155 
   1156 	if (!state.down) {
   1157 		if (ev.pointerType === "mouse" && ev.type === "pointerdown") {
   1158 			state.down = p
   1159 			state.time = ev.timeStamp
   1160 			state.timeout = setTimeout(function() {
   1161 				furnitureMenu(editor, editor.draw.point(state.down.x, state.down.y).vec())
   1162 				cleanup()
   1163 			}, params.longpress)
   1164 		}
   1165 		return
   1166 	}
   1167 
   1168 	if (ev.type === "pointermove") {
   1169 		if (state.down.distanceTo(p) < params.notMoveRadius) {
   1170 			return
   1171 		}
   1172 	}
   1173 
   1174 	cleanup()
   1175 }
   1176 
   1177 // pointerdown, pointerup, pointermove
   1178 function furnitureHandler(ev, editor, state) {
   1179 	const doMove = function() {
   1180 		// racy
   1181 		if (state.move) {
   1182 			let id = state.moving.attr("id")
   1183 			editor.mapFurniture({ x: state.move.x + state.offset.x, y: state.move.y + state.offset.y }, id)
   1184 			delete state.move
   1185 			state.moved = true
   1186 		}
   1187 	}
   1188 	const cleanup = function() {
   1189 		if (state.moved) {
   1190 			editor.finishAction()
   1191 		}
   1192 		for (let k in state) {
   1193 			delete state[k]
   1194 		}
   1195 	}
   1196 
   1197 	if (state.panZoom || ev.type === "pointercancel") {
   1198 		cleanup()
   1199 		return
   1200 	}
   1201 
   1202 	let press = editor.draw.point(ev.clientX, ev.clientY).vec()
   1203 	let sel = editor.draw.find("#furniture_layouts > * > .selected").array()
   1204 	if (sel.length !== 1) {
   1205 		return
   1206 	}
   1207 
   1208 	if (ev.type === "pointerdown" && truelyPrimary(ev)) {
   1209 		handled(ev)
   1210 		state.moving = sel[0]
   1211 		state.origin = press
   1212 		let m = editor.backend.reqObj(lib.getID(sel[0]))
   1213 		state.offset = { x: m.x - state.origin.x, y: m.y - state.origin.y }
   1214 		return
   1215 	}
   1216 
   1217 	if (!state.moving) {
   1218 		return
   1219 	}
   1220 
   1221 	if (ev.type === "pointermove" && primaryMove(ev)) {
   1222 		if (!state.moved && press.distanceTo(state.origin) < params.threshold) {
   1223 			return
   1224 		}
   1225 		state.moved = true
   1226 		handled(ev)
   1227 		if (state.move) {
   1228 			state.move = press
   1229 		} else {
   1230 			state.move = press
   1231 			setTimeout(doMove, 60)
   1232 		}
   1233 		return
   1234 	}
   1235 
   1236 	if (ev.type === "pointerup" && truelyPrimary(ev)) {
   1237 		handled(ev)
   1238 		doMove()
   1239 		cleanup()
   1240 		return
   1241 	}
   1242 }
   1243 
   1244 function enumSelection(input, values, selected) {
   1245 	let a = typeof(values.keys) === "function"
   1246 	for (let i in values) {
   1247 		let opt = input.appendChild(document.createElement("option"))
   1248 		opt.appendChild(document.createTextNode(a ? values[i] : i))
   1249 	}
   1250 }
   1251 
   1252 function furnitureMenu(editor, pointOrID) {
   1253 	let id
   1254 	if (typeof pointOrID === "string") {
   1255 		id = pointOrID
   1256 	} else {
   1257 		id = newFurniture(editor, pointOrID)
   1258 	}
   1259 
   1260 	let menu = document.createElement("div")
   1261 	menu.id = "furniture_menu"
   1262 	menu.classList.add("escapable")
   1263 
   1264 	let subs = []
   1265 	menu.addEventListener("escape", function() {
   1266 		editor.finishAction()
   1267 		menu.remove()
   1268 	})
   1269 
   1270 	subs.push(menu.appendChild(furnitureTools(editor, id)))
   1271 	menu.append(document.createElement("hr"))
   1272 	subs.push(menu.appendChild(furnitureParamsMenu(editor, id)))
   1273 
   1274 	let oldMenu = document.getElementById("furniture_menu")
   1275 	if (oldMenu) {
   1276 		oldMenu.replaceWith(menu)
   1277 	} else {
   1278 		document.body.append(menu)
   1279 	}
   1280 }
   1281 
   1282 function furnitureTools(editor, id) {
   1283 	let c = document.createElement("div")
   1284 
   1285 	c.append(ui.button("Duplicate", "Duplicate furniture parameters into a new piece", null, {
   1286 		handlers: { click:
   1287 			function() {
   1288 				editor.finishAction()
   1289 				let params = allFurnitureParams(editor, id)
   1290 				params.x += params.width * .6
   1291 				params.y -= params.depth * .6
   1292 				delete params.name
   1293 				delete params.furniture_id
   1294 				editor.finishAction()
   1295 				id = editor.addMappedFurniture(params)
   1296 				editor.findObj(id).select()
   1297 			}
   1298 		}
   1299 	}))
   1300 
   1301 	return c
   1302 }
   1303 
   1304 function furnitureParamsMenu(editor, id) {
   1305 	const styles = function(type) {
   1306 		let styles = ['default']
   1307 		if (editor.backend.params.furniture[type].styles == null) {
   1308 			return styles
   1309 		}
   1310 		return styles.concat(editor.backend.params.furniture[type].styles)
   1311 	}
   1312 
   1313 	editor.finishAction()
   1314 
   1315 	let params = allFurnitureParams(editor, id)
   1316 	delete params.x
   1317 	delete params.y
   1318 
   1319 	let items = [
   1320 		menuItem("name", "Name", { attributes: { value: params.name ?? "" } }),
   1321 		menuItem("type", "Type", { break: false, enum: editor.backend.params.furniture, attributes: { value: params.type, required: true } }),
   1322 		menuItem("style", "Style"),
   1323 		menuItem("variety", "Variety"),
   1324 		menuItem("width", "Width", { attributes: { value: userLength(editor, params.width), required: true } }),
   1325 		menuItem("depth", "Depth", { attributes: { value: userLength(editor, params.depth), required: true } }),
   1326 		menuItem("angle", "Angle", { attributes: { value: params.angle ?? 0, min: 0, max: 359, step: 1, type: "range", required: true } })
   1327 	]
   1328 	let keys = {}
   1329 	for (let i in items) {
   1330 		keys[items[i].attributes.name] = i
   1331 	}
   1332 
   1333 	const fromVariety = function(type, variety) {
   1334 		console.log(`Setting with and depth to ${variety} ${type}`)
   1335 		if (variety == null) {
   1336 			return
   1337 		}
   1338 
   1339 		let v = editor.backend.params.furniture[type].varieties[variety]
   1340 		params.width = v.width
   1341 		items[keys.width].input.value = userLength(editor, v.width)
   1342 		params.depth = v.depth
   1343 		items[keys.depth].input.value = userLength(editor, v.depth)
   1344 		tryUpdate()
   1345 	}
   1346 	const newVariety = function(init) {
   1347 		let vars = editor.backend.params.furniture[items[keys.type].input.value].varieties
   1348 		if (vars == undefined) {
   1349 			items[keys.variety].container.classList.add("hidden")
   1350 			fromVariety()
   1351 			return
   1352 		}
   1353 
   1354 		let cnt = 0
   1355 		for (let k in vars) {
   1356 			if (++cnt > 1) {
   1357 				break
   1358 			}
   1359 		}
   1360 
   1361 		let v
   1362 		if (cnt === 1) {
   1363 			v = menuItem("variety", "Variety", { attributes: { type: "button", value: "Reset" } })
   1364 		} else {
   1365 			v = menuItem("variety", "Variety", { enum: vars })
   1366 		}
   1367 		let c = makeItem(v)
   1368 		items[keys.variety].container.replaceWith(c)
   1369 		items[keys.variety] = v
   1370 		updateVariety()
   1371 		fromVariety(items[keys.type].input.value, init ? null : defKey(vars))
   1372 		if (cnt > 1) {
   1373 			c.addEventListener("input", function(ev) {
   1374 				fromVariety(items[keys.type].input.value, ev.target.value)
   1375 			})
   1376 		} else {
   1377 			c.addEventListener("click", function() {
   1378 				fromVariety(items[keys.type].input.value, defKey(vars))
   1379 				updateVariety()
   1380 			})
   1381 		}
   1382 	}
   1383 	const newStyle = function() {
   1384 		let typeStyles = styles(params.type)
   1385 		if (typeStyles.length === 1) {
   1386 			items[keys.style].container.classList.add("hidden")
   1387 		} else {
   1388 			let s = menuItem("style", "Style", { enum: typeStyles })
   1389 			items[keys.style].container.replaceWith(makeItem(s))
   1390 			items[keys.style] = s
   1391 			if (params.style != null) {
   1392 				items[keys.style].input.value = params.style
   1393 			}
   1394 		}
   1395 	}
   1396 	const updateVariety = function() {
   1397 		let vars = editor.backend.params.furniture[params.type].varieties
   1398 		let cnt = 0
   1399 		for (let k in vars) {
   1400 			if (++cnt > 1) {
   1401 				break
   1402 			}
   1403 		}
   1404 		if (cnt > 1) {
   1405 			items[keys.variety].input.value = editor.varietyFrom(params)
   1406 		} else if (cnt === 1) {
   1407 			if (editor.varietyFrom(params)) {
   1408 				items[keys.variety].input.setAttribute("disabled", true)
   1409 			} else {
   1410 				items[keys.variety].input.removeAttribute("disabled")
   1411 			}
   1412 		}
   1413 	}
   1414 	const tryUpdate = function() {
   1415 			let err = menu.querySelector(".error")
   1416 			if (err) {
   1417 				err.remove()
   1418 			}
   1419 			for (let i in items) {
   1420 				// If invalid, don't even try
   1421 				if (!items[i].input.validity.valid) {
   1422 					return
   1423 				}
   1424 			}
   1425 			editor.addMappedFurniture(params, id)
   1426 	}
   1427 
   1428 	let menu = makeMenu(items)
   1429 	menu.classList.add("furniture_params_menu")
   1430 	items[keys.type].input.value = params.type
   1431 	newVariety(true)
   1432 	newStyle()
   1433 	menu.addEventListener("input", function(ev) {
   1434 		handled(ev)
   1435 		try {
   1436 			console.debug("furnitureMenu.input(ev)", ev.target.name, ev.target.value)
   1437 			if (ev.target.name === "width" || ev.target.name === "depth") {
   1438 				let u = unitInput(editor, ev.target)
   1439 				if (u == undefined) {
   1440 					return
   1441 				}
   1442 				if (u <= 0) {
   1443 					ev.target.setCustomValidity(ui.capitalize(ev.target.name) + " must be greater than zero")
   1444 				} else {
   1445 					ev.target.setCustomValidity("")
   1446 				}
   1447 				ev.target.reportValidity()
   1448 				params[ev.target.name] = u
   1449 				updateVariety()
   1450 			} else {
   1451 				if (ev.target.name === "style" && ev.target.value === "default") {
   1452 					params[ev.target.name] = null
   1453 				} else {
   1454 					if (ev.target.name === "angle") {
   1455 						let a
   1456 						if (ev.target.value.length === 0) {
   1457 							a = 0
   1458 						} else if (!State.furnRotationSnap) {
   1459 							a = ev.target.value
   1460 						} else {
   1461 							const snapOn = 45
   1462 							const snapAt = 15
   1463 							a = ev.target.value
   1464 							let d = (a % snapOn)
   1465 							if (d < snapAt) {
   1466 								a -= d
   1467 							} else if (d > (snapOn - snapAt)) {
   1468 								a -= d - snapOn
   1469 							}
   1470 							a %= 360
   1471 						}
   1472 						params[ev.target.name] = a
   1473 					} else {
   1474 						params[ev.target.name] = ev.target.value.length === 0 ? null : ev.target.value
   1475 					}
   1476 				}
   1477 				if (ev.target.name === "type") {
   1478 					newVariety()
   1479 					newStyle()
   1480 				}
   1481 			}
   1482 			tryUpdate()
   1483 		}
   1484 		catch(err) {
   1485 			etc.error(err, menu)
   1486 			throw err
   1487 		}
   1488 	})
   1489 
   1490 	return menu
   1491 }
   1492 
   1493 function allFurnitureParams(editor, id) {
   1494 	let params = structuredClone(editor.backend.reqObj(id))
   1495 	let fp = editor.backend.reqObj(params.furniture_id)
   1496 	for (let k in fp) {
   1497 		params[k] = fp[k]
   1498 	}
   1499 	return params
   1500 }
   1501 
   1502 function newFurniture(editor, point) {
   1503 	if (point == null) {
   1504 		point = { x: 0, y: 0 }
   1505 	}
   1506 
   1507 	let type = "any"
   1508 	let vars = editor.backend.params.furniture[type].varieties
   1509 	let v
   1510 	if (def(vars)) {
   1511 		v = def(vars)
   1512 	} else {
   1513 		let s = editor.units.get("inch", 32)
   1514 		v = { width: s, depth: s }
   1515 	}
   1516 	let params = {
   1517 		x: point.x,
   1518 		y: point.y,
   1519 		type,
   1520 		width: v.width,
   1521 		depth: v.depth,
   1522 		name: null
   1523 
   1524 	}
   1525 	let id = editor.addMappedFurniture(params)
   1526 
   1527 	editor.finishAction()
   1528 	editor.findObj(id).select()
   1529 
   1530 	return id
   1531 }
   1532 
   1533 function makeMenu(items) {
   1534 	let c = document.createElement("form")
   1535 
   1536 	// In case I make c != form later
   1537 	let form = c
   1538 
   1539 	for (let i in items) {
   1540 		form.append(makeItem(items[i]))
   1541 		if (items[i].break) {
   1542 			form.appendChild(document.createElement("br"))
   1543 		}
   1544 	}
   1545 
   1546 	return c
   1547 }
   1548 
   1549 function makeItem(item) {
   1550 	if (item.label != null) {
   1551 		item.container = document.createElement("label")
   1552 		let label = item.container
   1553 		label.appendChild(document.createTextNode(item.label + ": "))
   1554 	}
   1555 
   1556 	item.input = document.createElement(item.enum ? "select" : "input")
   1557 	if (!item.container) {
   1558 		item.container = item.input
   1559 	} else {
   1560 		item.container.appendChild(item.input)
   1561 	}
   1562 
   1563 	if (item.enum != null) {
   1564 		enumSelection(item.input, item.enum)
   1565 	}
   1566 	for (let a in item.attributes ?? {}) {
   1567 		item.input.setAttribute(a, item.attributes[a])
   1568 	}
   1569 	for (let c in item.classes ?? {}) {
   1570 		item.container.classList.add(item.classes[c])
   1571 	}
   1572 	/*
   1573 	if (item.break) {
   1574 		// NOTE: Want this to be done in CSS instead of with <br>s
   1575 		item.container.classList.add("break")
   1576 	}
   1577 	*/
   1578 	return item.container
   1579 }
   1580 
   1581 function menuItem(name, label, options) {
   1582 	options = options ?? {}
   1583 	let attributes = options.attributes ?? {}
   1584 
   1585 	if (options.enum != null && typeof options.enum != "object") {
   1586 		throw new Error("Expected object for menuItem enum")
   1587 	}
   1588 	if (name == undefined) {
   1589 		throw new Error("Must have name")
   1590 	}
   1591 
   1592 	attributes.name = name
   1593 	if (options.value != undefined) {
   1594 		attributes.value = options.value
   1595 	}
   1596 	if (attributes.title == undefined && label != undefined) {
   1597 		attributes.title = label
   1598 	}
   1599 
   1600 	return {
   1601 		label,
   1602 		attributes,
   1603 		enum: options.enum,
   1604 		break: options.break == null ? true : options.break
   1605 	}
   1606 }
   1607 
   1608 function unitInput(editor, input, value) {
   1609 	if (value != null) {
   1610 		input.value = userLength(editor, value)
   1611 		return
   1612 	}
   1613 
   1614 	try {
   1615 		return parseUserLength(editor, input.value)
   1616 	}
   1617 	catch(err) {
   1618 		input.setCustomValidity(err)
   1619 		input.reportValidity()
   1620 		console.warn(err)
   1621 	}
   1622 }
   1623 
   1624 
   1625 function parseUserLength(editor, length) {
   1626 	let a = length
   1627 		.replaceAll(" ", "")
   1628 		.replaceAll("‘", "'")
   1629 		.replaceAll("’", "'")
   1630 		.replaceAll("“", '"')
   1631 		.replaceAll("”", '"')
   1632 		.split(/([0-9.]+)/)
   1633 
   1634 	let amount
   1635 	let rebuilt = []
   1636 	for (let i in a) {
   1637 		if (a[i].length === 0) {
   1638 			;
   1639 		} else  if (!amount) {
   1640 			amount = Number(a[i])
   1641 			if (amount === NaN) {
   1642 				throw new Error("Invalid number")
   1643 			}
   1644 		} else {
   1645 			if (!editor.units.symbols[a[i]]) {
   1646 				throw new Error("Invalid user length")
   1647 			}
   1648 			rebuilt.push({ symbol: a[i], amount: amount })
   1649 			amount = null
   1650 		}
   1651 	}
   1652 	if (amount) {
   1653 		rebuilt.push({ unit: editor.unit, amount: amount })
   1654 	}
   1655 
   1656 	return editor.units.combine(rebuilt)
   1657 }
   1658 
   1659 function userLength(editor, units) {
   1660 	let a = editor.units.separate(units, editor.unitSystem, { whole: true })
   1661 	let words = []
   1662 	for (let i in a) {
   1663 		if (!a[i].unit) {
   1664 			// We don't allow anything smaller than smallest defined unit,
   1665 			// though maybe this should be an error condition
   1666 			continue
   1667 		}
   1668 
   1669 		words.push(a[i].amount + (a[i].symbol ?? a[i].name ?? ""))
   1670 	}
   1671 	return words.join(" ")
   1672 }
   1673 
   1674 // I suppose this is why math is important...
   1675 // and the internet:
   1676 // <https://stackoverflow.com/questions/42510144/calculate-coordinates-for-45-degree-snap>
   1677 // UPDATE: Probably find an easy way using threejs-math now
   1678 function snap(point, on, directions) {
   1679 	let factor = (directions ?? 4) / 2
   1680 	let dx = point.x - on.x
   1681 	let dy = point.y - on.y
   1682 	let dist = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2))
   1683 	let angle = Math.atan2(dy, dx)
   1684 	angle = Math.round(angle / Math.PI * factor) / factor * Math.PI
   1685 	return new Vector2(
   1686 		on.x + dist * Math.cos(angle),
   1687 		on.y + dist * Math.sin(angle)
   1688 	)
   1689 }
   1690 
   1691 function preventDefaultHandler(event) {
   1692 	handled(event)
   1693 }
   1694 
   1695 function singlePointerHandler(ev, editor, state) {
   1696 	if (ev.type === "pointerdown") {
   1697 		state[ev.pointerId] = true
   1698 	} else if (ev.type === "pointerup" || ev.type === "pointercancel") {
   1699 		delete state[ev.pointerId]
   1700 	}
   1701 
   1702 	// Send all events but pointerdown on to the other handlers
   1703 	if (ev.type !== "pointerdown" && ev.type !== "pointermove") {
   1704 		return
   1705 	}
   1706 
   1707 	let cnt = 0
   1708 	for (let k in state) {
   1709 		if (++cnt > 1) {
   1710 			editor.draw.fire(cancelEvent);
   1711 			handled(ev)
   1712 			return
   1713 		}
   1714 	}
   1715 }
   1716 
   1717 function notify(message, id) {
   1718 	console.log("Notify", message)
   1719 
   1720 	let e = document.createElement("aside")
   1721 	e.id = id
   1722 	e.classList.add("message")
   1723 	e.textContent = message
   1724 
   1725 	let old
   1726 	if (id) {
   1727 		old = document.getElementById(id)
   1728 	}
   1729 
   1730 	if (old) {
   1731 		old.replaceWith(e)
   1732 	} else {
   1733 		document.body.prepend(e)
   1734 	}
   1735 	setTimeout(function() { e.remove() }, messageTimeout)
   1736 }
   1737 
   1738 function item(node) {
   1739 	let i = document.createElement("li")
   1740 	i.append(node)
   1741 	return i
   1742 }
   1743 
   1744 function elapsed(since) {
   1745 	return Date.now() - since
   1746 }
   1747 
   1748 function handled(event) {
   1749 	event.stopImmediatePropagation()
   1750 	event.preventDefault()
   1751 }
   1752 
   1753 function escape() {
   1754 	document.body.querySelectorAll(".escapable").forEach(function(e) {
   1755 		console.debug("Escape", e)
   1756 		e.dispatchEvent(escapeEvent)
   1757 	})
   1758 }
   1759 
   1760 function primaryPointer(ev) {
   1761 	if (ev.type === "pointermove") {
   1762 		return primaryMove(ev)
   1763 	} else {
   1764 		return truelyPrimary(ev)
   1765 	}
   1766 }
   1767 
   1768 function truelyPrimary(ev) {
   1769 	if (ev.pointerType === "mouse") {
   1770 		return ev.button === buttons.left
   1771 	}
   1772 	return ev.isPrimary
   1773 }
   1774 
   1775 function primaryMove(ev) {
   1776 	if (ev.pointerType === "mouse") {
   1777 		return true
   1778 	}
   1779 	return ev.isPrimary
   1780 }
   1781 
   1782 function precision(a) {
   1783 	if (!isFinite(a)) {
   1784 		return 0;
   1785 	}
   1786 	let e = 1
   1787 	let p = 0
   1788 	while (Math.round(a * e) / e !== a) {
   1789 		e *= 10
   1790 		p++
   1791 	}
   1792 	return p
   1793 }
   1794 
   1795 function nextSwing(swing) {
   1796 	let next
   1797 	if (!swing) {
   1798 		next = 'a-'
   1799 	} else if (swing[1] === '-') {
   1800 		next = (swing[0] === 'a' ? 'b' : 'a') + '+'
   1801 	} else {
   1802 		next = swing[0] + '-'
   1803 	}
   1804 	console.debug("nextSwing", `${swing} -> ${next}`)
   1805 	return next
   1806 }
   1807 
   1808 function prevSwing(swing) {
   1809 	let prev
   1810 	if (!swing) {
   1811 		prev = 'a-'
   1812 	} else if (swing[1] === '+') {
   1813 		prev = (swing[0] === 'a' ? 'b' : 'a') + '-'
   1814 	} else {
   1815 		prev = swing[0] + '+'
   1816 	}
   1817 	console.debug("prevSwing", `${swing} -> ${prev}`)
   1818 	return prev
   1819 }
   1820 
   1821 function def(obj) {
   1822 	return obj[defKey(obj)]
   1823 }
   1824 
   1825 function defKey(obj) {
   1826 	for (let i in obj) {
   1827 		return i
   1828 	}
   1829 }
   1830 
   1831 function status(msg) {
   1832 	let s = document.getElementById("status")
   1833 	s.classList[msg instanceof Error ? "add" : "remove"]("error")
   1834 	s.textContent = msg ?? ""
   1835 }
   1836 
   1837 function fetchError(err) {
   1838 	if (err instanceof api.FetchError) {
   1839 		status("Network error: " + err)
   1840 	} else {
   1841 		status(err)
   1842 	}
   1843 }
   1844 
   1845 function toolGroup(name) {
   1846 	let g = document.createElement("div")
   1847 	g.classList.add("toolgroup")
   1848 	let n = g.appendChild(document.createElement("button"))
   1849 	n.textContent = name
   1850 	g.append(document.createElement("ul"))
   1851 	return g
   1852 }