www.spaceplanner.app

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

editor.js (23564B)


      1 import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js"
      2 import * as backend from "./backend.js"
      3 import { Vector2 } from "/lib/github.com/mrdoob/three.js/math/Vector2.js"
      4 import * as geometry from "./geometry.js"
      5 
      6 const selectEvent = new Event("select")
      7 const unselectEvent = new Event("unselect")
      8 
      9 SVG.extend(SVG.Svg, {
     10 	unselect: function(list) {
     11 		console.debug("Svg.unselect", list)
     12 
     13 		let selected
     14 		if (list) {
     15 			selected = list
     16 		} else {
     17 			selected = this.find(".selected")
     18 		}
     19 
     20 		// Should also test for selected class
     21 		if (!selected) {
     22 			console.debug("Nothing to unselect")
     23 			return
     24 		}
     25 
     26 		this.find(".last_selected")
     27 			.removeClass("last_selected")
     28 
     29 		let unselected = selected
     30 			.removeClass("selected")
     31 			.addClass("last_selected")
     32 
     33 		// NOTE: Could fire an event, but then I'd have to handle
     34 		// deletions, so I'll leave it until it's needed.
     35 		return unselected
     36 	},
     37 
     38 	select: function(list) {
     39 		console.debug("Svg.select", list)
     40 
     41 		if (list && list.sameElements(this.find(".selected"))) {
     42 			this.fire("reselect", { selected: this.find(".selected") })
     43 			return list
     44 		}
     45 
     46 		this.unselect()
     47 
     48 		if (list) {
     49 			list.addClass("selected")
     50 		}
     51 		this.fire("select", { selected: list })
     52 		return list
     53 	},
     54 
     55 	reselect: function() {
     56 		this.fire("select", { selected: this.find(".selected") })
     57 	}
     58 })
     59 
     60 SVG.extend(SVG.List, {
     61 	selectList: function() {
     62 		let root
     63 		this.each(function(item) {
     64 			if (!root) {
     65 				root = item.root()
     66 			} else if (root != item.root())
     67 				throw new Error("Cannot select from different documents")
     68 			})
     69 
     70 		return root.select(this)
     71 	},
     72 
     73 	array: function() {
     74 		let a = []
     75 		this.each(function(item) {
     76 			a.push(item)
     77 		})
     78 		return a
     79 	},
     80 
     81 	sameElements: function(list2) {
     82 		const cmp = function(a, b) {
     83 			return a.attr("id") < b.attr("id")
     84 		}
     85 
     86 		let a = this.array()
     87 		let b = list2.array()
     88 		if (a.length != b.length) {
     89 			return false
     90 		}
     91 
     92 		a = a.sort(cmp)
     93 		b = b.sort(cmp)
     94 		for (let i = 0; i < a.length; ++i) {
     95 			if (a[i].attr("id") !== b[i].attr("id")) {
     96 				return false
     97 			}
     98 		}
     99 		return true
    100 	}
    101 
    102 })
    103 
    104 SVG.extend(SVG.Element, {
    105 	select: function() {
    106 		return new SVG.List([this]).selectList()[0]
    107 	},
    108 
    109 	findOneMax: function(selector) {
    110 		let results = this.find(selector)
    111 		if (results.length > 1) {
    112 			throw new Error("Found more than one element")
    113 		}
    114 		if (results.length == 1)
    115 			return results[0]
    116 		return undefined
    117 	},
    118 
    119 	findExactlyOne: function(selector) {
    120 		let r = this.findOneMax(selector)
    121 		if (!r) {
    122 			throw new Error("Didn't find " + selector)
    123 		}
    124 		return r
    125 	},
    126 
    127 	touching: function(x, y, minsize) {
    128 		let b = this.bbox()
    129 		let d = minsize - b.width 
    130 		if (d > 0) {
    131 			b.x -= d / 2
    132 			b.width = minsize
    133 		}
    134 		d = minsize - b.height
    135 		if (d > 0) {
    136 			b.y -= d / 2
    137 			b.height = minsize
    138 		}
    139 		return (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height)
    140 	}
    141 })
    142 
    143 // May not be needed anymore
    144 SVG.extend(SVG.Circle, {
    145 	// Maybe this already exists?
    146 	pos: function() {
    147 		let attrs = this.attr(["cx", "cy"])
    148 		return { x: attrs.cx, y: attrs.cy }
    149 	}
    150 })
    151 
    152 class Units {
    153 	constructor() {
    154 		this.data = {}
    155 		this.systems = {}
    156 		this.symbols = {}
    157 	}
    158 
    159 	add(name, factor, options) {
    160 		options = options ?? {}
    161 
    162 		if (!name || !factor) {
    163 			throw new Error("Requires name and factor")
    164 		}
    165 		if (this.data[name]) {
    166 			throw new Error("Already exists")
    167 		}
    168 		if (options.base && (!this.data[options.base] || this.data[options.base].next)) {
    169 			throw new Error("Invalid base (already used or does not exist)")
    170 		}
    171 		if (options.base && options.system) {
    172 			throw new Error("Class may only be set on base units")
    173 		}
    174 		if (options.system && this.systems[options.system]) {
    175 			throw new Error("Unit system already exists")
    176 		}
    177 		if (options.symbol && this.symbols[options.symbol]) {
    178 			throw new Error("Symbol already exists")
    179 		}
    180 
    181 		this.data[name] = {
    182 			name: name,
    183 			factor: factor,
    184 		}
    185 		if (options.system) {
    186 			this.data[name].system = options.system
    187 			this.systems[options.system] = name
    188 		}
    189 		if (options.symbol) {
    190 			this.data[name].symbol = options.symbol
    191 			this.symbols[options.symbol] = name
    192 		}
    193 		if (options.base) {
    194 			this.data[options.base].next = name
    195 			this.data[name].base = options.base
    196 		}
    197 	}
    198 
    199 	get(name, num) {
    200 		if (!name || !this.data[name]) {
    201 			throw new Error("Invalid unit")
    202 		}
    203 		let n = this.data[name].factor
    204 		if (this.data[name].base) {
    205 			n *= this.get(this.data[name].base)
    206 		}
    207 		return n * (num ?? 1)
    208 	}
    209 
    210 	system(name) {
    211 		return this.data[this.smallest(name)].system
    212 	}
    213 
    214 	smallest(name) {
    215 		return this.walk(name, "base")
    216 	}
    217 
    218 	biggest(name) {
    219 		return this.walk(name, "next")
    220 	}
    221 
    222 	walk(name, key) {
    223 		while (this.data[name][key]) {
    224 			name = this.data[name][key]
    225 		}
    226 		return name
    227 	}
    228 
    229 	separate(units, system, options) {
    230 		options = options ?? {}
    231 		let parts = []
    232 		let unit = this.biggest(this.systems[system])
    233 
    234 		do {
    235 			let n = this.get(unit)
    236 			if (units >= n) {
    237 				let amount = units / n
    238 				if (this.data[unit].base || options.whole) {
    239 					amount = Math.floor(amount)
    240 				}
    241 				units -= amount * n // not sure about floating mod in js
    242 				parts.push({ unit: unit, symbol: this.data[unit].symbol, amount: amount })
    243 			}
    244 		} while (units > 0 && (unit = this.data[unit].base))
    245 		if (units > 0) {
    246 			parts.push({ "amount": units })
    247 		}
    248 		return parts
    249 	}
    250 
    251 	combine(parts) {
    252 		let t = 0
    253 		for (let i in parts) {
    254 			if (!parts[i].unit) {
    255 				if (!parts[i].symbol) {
    256 					throw new Error("Requires unit or symbol")
    257 				}
    258 				parts[i].unit = this.symbols[parts[i].symbol]
    259 			}
    260 			t += this.get(parts[i].unit, parts[i].amount)
    261 		}
    262 		return t
    263 	}
    264 
    265 	snapTo(x, unit) {
    266 		let n = this.get(unit)
    267 		let f = function(x) {
    268 			x = Math.round(x)
    269 			return x - (x % n)
    270 		}
    271 
    272 		if (typeof x === "number") {
    273 			return f(x)
    274 		} else if (Array.isArray(x)) {
    275 			for (let i in x) {
    276 				x[i] = f(x[i])
    277 			}
    278 		} else if (typeof x === "object") {
    279 			for (let i in x) {
    280 				if (typeof x[i] === "number") {
    281 					x[i] = f(x[i])
    282 				}
    283 			}
    284 		} else {
    285 			throw new Error("Unable to snap that")
    286 		}
    287 
    288 		return x
    289 	}
    290 }
    291 
    292 export class FloorplanEditor {
    293 	constructor(svg, floorplan, options) {
    294 		if (!options) {
    295 			options = {}
    296 		}
    297 
    298 		let editor = this
    299 
    300 		this.draw = svg
    301 		this.mode
    302 		this.modes = {}
    303 		this.mode_states = {}
    304 
    305 		// Setup units
    306 		this.units = new Units()
    307 		this.units.add("inch", 96, { symbol: '"', system: "imperial" })
    308 		this.units.add("foot", 12, { base: "inch", symbol: "'" })
    309 		this.units.add("centimeter", this.units.get("inch") / 2.54, { symbol: "cm", system: "metric" })
    310 		this.units.add("meter", 100, { symbol: "m", base: "centimeter" })
    311 
    312 		if (!options.backend) {
    313 			options.backend = {}
    314 		}
    315 		if (!options.backend.callbacks) {
    316 			options.backend.callbacks = {}
    317 		}
    318 		options.backend.callbacks["patch"] = function(diff) { editor.applyOp(diff) }
    319 
    320 		this.backend = new backend.FloorplanBackend(floorplan, options.backend)
    321 
    322 		this.grids = {}
    323 		for (let system in this.units.systems) {
    324 			this.grids[system] = gridSystem(this, system)
    325 		}
    326 
    327 		this.draw.rect().attr({ id: "grid" })
    328 		this.useGrid()
    329 
    330 		this.ui = {}
    331 		this.ui.bottom = this.draw.group().attr({ id: "bottom" })
    332 
    333 		let data = this.draw.group().attr({ id: "floorplan" })
    334 		this.doorSwings = data.group().attr({ id: "door_swings" })
    335 		data.group().attr({ id: "pointmaps" }) // lines
    336 		data.group().attr({ id: "points" }) // circles
    337 		this.layouts = data.group().attr({ id: "furniture_layouts" })  // g of furniture
    338 		this.layout = "1"
    339 
    340 		this.ui.top = this.draw.group().attr({ id: "top" })
    341 		this.ui.labels = this.draw.group().attr({ id: "labels" })
    342 
    343 		// Resize grid when appropriate
    344 		this.draw.on("zoom", function(event) {
    345 			editor.updateGrid(event.detail.box)
    346 		})
    347 		this.draw.on("panning", function(event) {
    348 			editor.updateGrid(event.detail.box)
    349 		})
    350 		let resize = new ResizeObserver(function(entries) {
    351 			if (entries[0].target != editor.draw.node) {
    352 				throw new Error("Expected draw node")
    353 			}
    354 			console.debug("Editor resized")
    355 			editor.updateGrid()
    356 		})
    357 		resize.observe(editor.draw.node)
    358 
    359 		let selectionRemoval = new MutationObserver(function(mutations) {
    360 			for (const m of mutations) {
    361 				if (m.type === "childList" && m.removedNodes) {
    362 					m.removedNodes.forEach(function(node) {
    363 						if (node.classList && node.classList.contains("selected")) {
    364 							console.debug("selectionRemoval",
    365 								"Detected selected node being removed")
    366 							editor.draw.reselect()
    367 							return
    368 						}
    369 					})
    370 				}
    371 			}
    372 		})
    373 		selectionRemoval.observe(this.draw.node, { childList: true, subtree: true })
    374 
    375 		this.draw.on("select", function(event) {
    376 			editor.selection = event.detail.selection
    377 		})
    378 
    379 		this.initialized = this.backend.initialized
    380 	}
    381 
    382 	useUnits(system) {
    383 		if (!this.units.systems[system]) {
    384 			throw new Error("No such system")
    385 		}
    386 		this.unitSystem = system
    387 		this.useGrid(system)
    388 	}
    389 
    390 	get unit() {
    391 		return this.units.systems[this.unitSystem]
    392 	}
    393 
    394 	addMode(name, mode) {
    395 		if (this.modes[name]) {
    396 			throw new Error("Mode already exists")
    397 		}
    398 		if (!mode) {
    399 			throw new Error("No mode")
    400 		}
    401 
    402 		this.modes[name] = {}
    403 		for (let key in mode) {
    404 			if (key !== "handlers") {
    405 				this.modes[name][key] = mode[key]
    406 			}
    407 		}
    408 
    409 		const getHandler = function(editor, handler) {
    410 			editor.mode_states[handler] = {}
    411 			return function(event) {
    412 				return handler(event, state, editor.mode_states[handler])
    413 			}
    414 		}
    415 
    416 		// to pass use in another function
    417 		let state = this
    418 		this.modes[name].handlers = {}
    419 		this.mode_states[name] = {}
    420 		for (let type in mode.handlers) {
    421 			this.modes[name]["handlers"][type] = []
    422 
    423 			let a = mode.handlers[type]
    424 			if (typeof a === "function") {
    425 				a = [ a ]
    426 			} else if (typeof a !== "object") {
    427 				delete this.modes[name]
    428 				throw new Error("Expected function or object")
    429 			}
    430 
    431 			for (let i in a) {
    432 				console.debug("Create mode handler", name, type, a[i])
    433 				this["modes"][name]["handlers"][type]
    434 					.push(getHandler(this, a[i]))
    435 			}
    436 		}
    437 
    438 		console.log("Add mode", mode)
    439 		return this
    440 	}
    441 
    442 	useMode(newmode) {
    443 		if (newmode && !this.modes[newmode]) {
    444 			throw new Error("'" + newmode + "': Invalid mode")
    445 		}
    446 
    447 		if (newmode === this.mode) {
    448 			return this
    449 		}
    450 
    451 		if (this.mode) {
    452 			remove_mode_handlers(this.draw, this.modes[this.mode].handlers)
    453 		}
    454 
    455 		if (newmode) {
    456 			let points = this.draw.findOne("#points")
    457 			if (this.modes[newmode].points) {
    458 				points.attr("visibility", null)
    459 			} else {
    460 				points.attr("visibility", "hidden")
    461 			}
    462 			add_mode_handlers(this.draw, this.modes[newmode].handlers)
    463 		}
    464 
    465 		this.mode = newmode
    466 		console.log("Mode", this.mode)
    467 		return this
    468 	}
    469 
    470 	useGrid(system) {
    471 		let grid = this.draw.findExactlyOne("#grid")
    472 		if (!system) {
    473 			grid.attr("visibility", "hidden")
    474 		} else {
    475 			grid.fill(this.grids[system].url()).attr("visibility", null)
    476 		}
    477 	}
    478 
    479 	furnitureLabels(show) {
    480 		console.debug("Editor.furnitureLabels",	show)
    481 		const l = this.ui.labels
    482 		if (show === undefined) {
    483 			return !l.hasClass("hidden")
    484 		}
    485 		if (show) {
    486 			l.removeClass("hidden")
    487 		} else {
    488 			l.addClass("hidden")
    489 		}
    490 	}
    491 
    492 	updateGrid(box) {
    493 		let grid = this.draw.findExactlyOne("#grid")
    494 		if (!box) {
    495 			box = this.draw.viewbox()
    496 		}
    497 
    498 		const swap = { x: "y", y: "x", width: "height", height: "width" }
    499 		const map = { width: "x", height: "y" }
    500 
    501 		// Reads easy, right?
    502 		let real = this.draw.node.getBoundingClientRect()
    503 		let base = (real.width > real.height) ? "height" : "width"
    504 		let val = real[swap[base]] * (box[base] / real[base])
    505 		let diff = val - box[swap[base]]
    506 		box[map[swap[base]]] -= diff / 2
    507 		box[swap[base]] = val
    508 		const margin = 10000
    509 		grid.size(box.width + margin, box.height + margin).move(box.x - margin / 2, box.y - margin / 2)
    510 	}
    511 
    512 	fitToView() {
    513 		const adj = function(ns, t, pos, siz) {
    514 			t[pos] += (t[siz] - ns) / 2
    515 			t[siz] = ns
    516 		}
    517 		const add = function(d, t, pos, siz) {
    518 			return adj(t[siz] + d, t, pos, siz)
    519 		}
    520 
    521 		let bbox = this.draw.findOne("#floorplan").bbox()
    522 		let ft = this.units.get("foot")
    523 		let min = ft * 20
    524 		add(ft * 2, bbox, "x", "width")
    525 		if (bbox.width < min) {
    526 			adj(min, bbox, "x", "width")
    527 		}
    528 		add(ft * 2, bbox, "y", "height")
    529 		if (bbox.height < min) {
    530 			adj(min, bbox, "y", "height")
    531 		}
    532 		this.draw.viewbox(bbox)
    533 		this.updateGrid()
    534 	}
    535 
    536 	// Should be called after each user "action"
    537 	finishAction() {
    538 		this.backend.history.mark()
    539 		return this.backend.push()
    540 	}
    541 
    542 	undo() {
    543 		this.backend.undo()
    544 		return this.backend.push()
    545 	}
    546 
    547 	redo() {
    548 		this.backend.redo()
    549 		return this.backend.push()
    550 	}
    551 
    552 	addPoint(point, force) {
    553 		if (!force) {
    554 			let already = this.pointAt(point)
    555 			if (already != null) {
    556 				return already
    557 			}
    558 		}
    559 		return this.backend.addPoint(point)
    560 	}
    561 
    562 	movePoint(point, coordinate) {
    563 		return this.backend.addPoint(coordinate, getID(point, "points"))
    564 	}
    565 
    566 	remove(...elements) {
    567 		let later = []
    568 
    569 		for (let i in elements) {
    570 			let id = getID(elements[i])
    571 			if (backend.idType(id) === "pntmap") {
    572 				this.backend.unmapPoints(id)
    573 			} else if (backend.idType(id) === "furmap") {
    574 				// For now, just remove the furniture too
    575 				later.push(this.backend.reqObj(id).furniture_id)
    576 				this.backend.unmapFurniture(id)
    577 			} else {
    578 				later.push(id)
    579 			}
    580 		}
    581 
    582 		let editor = this
    583 		const pointsAngle = function(a, b) {
    584 			a = editor.backend.reqObj(a)
    585 			b = editor.backend.reqObj(b)
    586 			return geometry.lineAngle(new Vector2(a.x, a.y), new Vector2(b.x, b.y))
    587 		}
    588 
    589 		for (let i in later) {
    590 			let t = backend.idType(later[i])
    591 			if (t === "pnt") {
    592 				let p = later[i]
    593 				for (let o in this.backend.mappedPoints[p]) {
    594 					let a1 = pointsAngle(p, o)
    595 					for (let o2 in this.backend.mappedPoints[p]) {
    596 						if (o2 === o) {
    597 							continue
    598 						}
    599 
    600 						let a2 = pointsAngle(p, o2)
    601 						if ((geometry.deg(Math.abs(a1 - a2)) % 180) < 1) {
    602 							editor.backend.mapPoints({ a: o, b: o2 })
    603 						}
    604 					}
    605 				}
    606 				this.backend.removePoint(later[i], { unmap: true })
    607 			} else if (t === "fur") {
    608 				this.backend.removeFurniture(later[i])
    609 			} else {
    610 				throw new Error(backend.idType(later[i]) + ": Unsupported type")
    611 			}
    612 		}
    613 
    614 		this.backend.removeOrphans()
    615 	}
    616 
    617 	pointAt(point) {
    618 		return this.thingAt(point, "#points")
    619 	}
    620 
    621 	thingAt(point, selector, options) {
    622 		options = options ?? {}
    623 		options.max = 1
    624 		return this.thingsAt(point, selector, options)[0]
    625 	}
    626 
    627 	thingsAt(point, selector, options) {
    628 		options = options ?? {}
    629 
    630 		let children = this.draw.find(selector ?? "*")
    631 			.children()
    632 			.toArray()
    633 
    634 		let done = {}
    635 		let inside = []
    636 		for (let i = 0; i < children.length; ++i) {
    637 			if (children[i][options.method ?? "insideT"](point.x, point.y, options.minsize)) {
    638 				if (inside.push(children[i]) >= options.max) {
    639 					return inside
    640 				}
    641 				children[i] = null
    642 			}
    643 		}
    644 
    645 		return inside
    646 	}
    647 
    648 	mapPoints(params, id) {
    649 		if (params.a) {
    650 			params.a = getID(params.a, "points")
    651 		}
    652 		if (params.b) {
    653 			params.b = getID(params.b, "points")
    654 		}
    655 		return this.backend.mapPoints(params, id)
    656 	}
    657 
    658 	addFurniture(params, id) {
    659 		return this.backend.addFurniture(params, id)
    660 	}
    661 
    662 	mapFurniture(params, id) {
    663 		return this.backend.mapFurniture(params, id)
    664 	}
    665 
    666 	addMappedFurniture(params, id) {
    667 		return this.backend.addMappedFurniture(params, id)
    668 	}
    669 
    670 	selected(type) {
    671 		if (type === "furniture_maps") {
    672 			type = "furniture_layouts > * >"
    673 		}
    674 		return this.draw.find(`#${type} > .selected`)
    675 	}
    676 
    677 	selectedOne(type) {
    678 		let sel = this.selected(type)
    679 		if (sel.length != 1) {
    680 			return null
    681 		}
    682 		return sel[0]
    683 	}
    684 
    685 	applyDiffs(diffs) {
    686 		for (let op in diffs) {
    687 			this.applyOp(diffs[op])
    688 		}
    689 	}
    690 
    691 	applyOp(diff) {
    692 		console.debug("Editor.applyOp", diff)
    693 		let editor = this
    694 
    695 		let ops = {
    696 			add: {
    697 				points: function(id, value) {
    698 					let cur = editor.draw.findOneMax(byId(id))
    699 					// Update pointmaps
    700 					if (cur) {
    701 						cur.cx(value.x).cy(value.y)
    702 							.select()
    703 					} else {
    704 						editor.draw.findOne("#points")
    705 							.circle(400)
    706 							.cx(value.x).cy(value.y)
    707 							.attr({ id })
    708 							.addClass("point")
    709 							.select()
    710 
    711 					}
    712 					for (let oth in editor.backend.mappedPoints[id]) {
    713 						let map = editor.backend.mappedPoints[id][oth]
    714 						ops.add.pointmaps(map, editor.backend.obj(map))
    715 					}
    716 				},
    717 				pointmaps: function(id, value) {
    718 					let a = editor.backend.obj(value.a)
    719 					let b = editor.backend.obj(value.b)
    720 					let wall = editor.draw.findOneMax(byId(id))
    721 					if (wall) {
    722 						wall.plot(a.x, a.y, b.x, b.y)
    723 							.removeClass(wall.data("type"))
    724 							.addClass(value.type)
    725 							.data("type", value.type)
    726 					} else {
    727 						wall = editor.draw.findExactlyOne("#pointmaps")
    728 							.line(a.x, a.y, b.x, b.y)
    729 							.stroke({ color: "black", width: 350 })
    730 							.attr({ id })
    731 							.addClass(value.type)
    732 							.data("type", value.type)
    733 					}
    734 
    735 					let sid = swingID(id)
    736 					if (value.type !== "door" || !value.door_swing) {
    737 						let s = editor.draw.findOne(byId(sid))
    738 						if (s != null) {
    739 							s.remove()
    740 						}
    741 					} else {
    742 						a = new Vector2(a.x, a.y)
    743 						b = new Vector2(b.x, b.y)
    744 						if (value.door_swing.at(0) === "b") {
    745 							let t = a
    746 							a = b
    747 							b = t
    748 						}
    749 
    750 						let deg = 90
    751 						if (value.door_swing.at(1) === "-") {
    752 							deg = -deg
    753 						}
    754 						let e = b.clone().rotateAround(a, geometry.rad(deg))
    755 						let r = a.distanceTo(b)
    756 						let d = `M ${b.x} ${b.y} A ${r} ${r} ${deg} 0 ${deg < 0 ? 0 : 1} ${e.x} ${e.y} L ${a.x} ${a.y} Z`
    757 
    758 						let swing = editor.draw.findOne(byId(sid))
    759 						if (swing != null) {
    760 							swing.plot(d)
    761 						} else {
    762 							swing = editor.doorSwings.path(d)
    763 								.fill("rgba(0,0,0,.05)").stroke({ width: 100, color: "#AAA", dasharray: "400 100" })
    764 								.attr({ id: sid })
    765 						}
    766 					}
    767 				},
    768 				furniture: function(id, value) {
    769 					let maps = editor.backend.cache.furniture_maps
    770 					for (let mid in maps) {
    771 						if (maps[mid].furniture_id == id) {
    772 							let m = editor.draw.findOneMax(byId(mid))
    773 							if (m == null) {
    774 								ops.add.furniture_maps(id, editor.backend.cache[id])
    775 								m = editor.draw.findOneMax(byId(mid))
    776 							}
    777 							m.size(value.width, value.depth)
    778 							let t = m.findOne("title")
    779 							if (t == null) {
    780 								t = m.element("title")
    781 							}
    782 							t.words(furniture_name(value))
    783 							m.load(furnitureImage(value))
    784 						}
    785 					}
    786 				},
    787 				furniture_maps: function(id, value) {
    788 					let f = editor.backend.reqObj(value.furniture_id)
    789 					let fm = editor.draw.findOneMax(byId(id))
    790 					if (!fm) {
    791 						fm = editor.layoutG().image(furnitureImage(f))
    792 							.size(f.width, f.depth)
    793 							.attr({ id, preserveAspectRatio: "none" })
    794 						fm.on("error", function() {
    795 							if (this.attr("href") === "/furniture/any/default.svg") {
    796 								throw new Error("Unable to load furniture assets")
    797 							}
    798 							this.load("/furniture/any/default.svg")
    799 						})
    800 					}
    801 					fm.cx(value.x).cy(value.y)
    802 
    803 					let tid = id + "_text"
    804 					let text = editor.draw.findOne(byId(tid))
    805 					if (!f.name) {
    806 						if (text) {
    807 							text.remove()
    808 						}
    809 					} else {
    810 						if (!text) {
    811 							text = editor.ui.labels.text(f.name) 
    812 								.attr({ id: tid })
    813 								.font('size', '8in')
    814 						} else {
    815 							text.plain(f.name)
    816 						}
    817 						text.cx(value.x).cy(value.y)
    818 					}
    819 
    820 					fm.transform({
    821 						rotate: value.angle
    822 					})
    823 				}
    824 			},
    825 			remove: {
    826 				points: function(id) {
    827 					// Remove pointmaps
    828 					editor.draw.findExactlyOne(byId(id)).remove()
    829 				},
    830 				pointmaps: function(id) {
    831 					editor.draw.findExactlyOne(byId(id)).remove()
    832 					let s = editor.draw.findOne(byId(swingID(id)))
    833 					if (s != null) {
    834 						s.remove()
    835 					}
    836 				},
    837 				furniture: function(name) {},
    838 				furniture_maps: function(id) {
    839 					editor.draw.findExactlyOne(byId(id)).remove()
    840 				}
    841 			}
    842 		}
    843 		if (ops.replace) {
    844 			throw new Error("You messed up")
    845 		}
    846 		ops.replace = ops.add
    847 		ops.new = ops.add
    848 
    849 		if (!ops[diff.op]) {
    850 			throw new Error(diff.op + ": Unexpected patch operation")
    851 		}
    852 
    853 		let id = backend.parsePath(diff.path)
    854 		let t = backend.idTable(id)
    855 		if (!ops[diff.op][t]) {
    856 			throw new Error("Unhandled patch")
    857 		}
    858 		ops[diff.op][t](id, diff.value)
    859 	}
    860 
    861 	switchLayout(name) {
    862 		if (this.layout != null) {
    863 			this.layouts.findExactlyOne(byId(layoutID(this.layout))).hide()
    864 		}
    865 		this.layouts.findExactlyOne(byId(layoutID(name))).show()
    866 		this.layout = name
    867 	}
    868 
    869 	layoutG(name) {
    870 		if (name == null) {
    871 			name = this.layout
    872 		}
    873 		let id = layoutID(name)
    874 		let layout = this.layouts.findOneMax(byId(id))
    875 		if (layout) {
    876 			return layout
    877 		}
    878 		layout = this.layouts.group().attr({id: id})
    879 		return layout
    880 	}
    881 
    882 	findObj(id) {
    883 		return this.draw.findExactlyOne(byId(getID(id)))
    884 	}
    885 
    886 	variety(id) {
    887 		let t = backend.idType(id)
    888 		if (t === "furmap") {
    889 			id = this.backend.reqObj(id).furniture_id
    890 		} else if (t !== "fur") {
    891 			throw new Error(id + ": Unable to get furniture definition from that ID")
    892 		}
    893 
    894 		let f = structuredClone(this.backend.reqObj(id))
    895 		let d = f.depth
    896 		return this.varietyFrom(f)
    897 	}
    898 
    899 	varietyFrom(params) {
    900 		if (this.backend.params.furniture[params.type] == null) {
    901 			throw new Error(params.type + ": Invalid furniture type")
    902 		}
    903 		let vars = this.backend.params.furniture[params.type].varieties
    904 		for (let v in vars) {
    905 			if (params.width == vars[v].width && params.depth == vars[v].depth) {
    906 				return v
    907 			}
    908 		}
    909 		return null
    910 	}
    911 
    912 	pointmapLength(map) {
    913 		map = this.backend.reqObj(getID(map))
    914 		let a = this.backend.reqObj(map.a)
    915 		a = new Vector2(a.x, a.y)
    916 		let b = this.backend.reqObj(map.b)
    917 		b = new Vector2(b.x, b.y)
    918 		return a.distanceTo(b)
    919 	}
    920 }
    921 
    922 function layoutID(name) {
    923 	return "layout_" + name
    924 }
    925 
    926 function remove_mode_handlers(target, mode_handlers) {
    927 	for (let event in mode_handlers) {
    928 		for (let handler in mode_handlers[event]) {
    929 			console.debug("Remove mode handler", event, handler, "to", target)
    930 			let h = mode_handlers[event][handler]
    931 			if (event === "keydown" || event === "keyup") {
    932 				document.removeEventListener(event, h)
    933 			} else {
    934 				target.off(event, h)
    935 			}
    936 		}
    937 	}
    938 }
    939 
    940 function add_mode_handlers(target, mode_handlers) {
    941 	for (let event in mode_handlers) {
    942 		for (let handler in mode_handlers[event]) {
    943 			console.debug("Add mode handler", event, handler, "to", target)
    944 			let h = mode_handlers[event][handler]
    945 			if (event === "keydown" || event === "keyup") {
    946 				document.addEventListener(event, h)
    947 			} else {
    948 				target.on(event, h)
    949 			}
    950 		}
    951 	}
    952 }
    953 
    954 function gridPattern(editor, unit, using) {
    955 	let n = editor.units.get(unit)
    956 	return editor.draw.pattern(n, n, function(on) {
    957 		if (using) {
    958 			on.rect(n, n).fill(using.url())
    959 		}
    960 		on.path(`M ${n} 0 L 0 0 0 ${n}`)
    961 			.fill("none")
    962 			.stroke({ width: n / 50, color: "grey" })
    963 	}).attr({ id: "grid_" + unit + "_pattern", patternUnits: "userSpaceOnUse" })
    964 }
    965 
    966 function gridSystem(editor, system) {
    967 	let unit = editor.units.systems[system]
    968 	let last
    969 
    970 	do {
    971 		last = gridPattern(editor, unit, last)
    972 	} while ((unit = editor.units.data[unit].next));
    973 	return last
    974 }
    975 
    976 export function getID(thing, type) {
    977 	console.debug("getID", thing, type)
    978 	let id
    979 	if (typeof thing === "object") {
    980 		if (typeof thing.attr === "function") {
    981 			id = thing.attr("id")
    982 		} else if (typeof thing.type === "string" && typeof thing.id === "string") {
    983 			id = thing.id
    984 		}
    985 	} else if (typeof thing === "string") {
    986 		id = thing
    987 	}
    988 
    989 	if (id == undefined) {
    990 		console.error("Couldn't get id from", thing)
    991 		throw new Error("Invalid id")
    992 	}
    993 	if (type && backend.idTable(id) != type) {
    994 		throw new Error(`${backend.idTable(id)}: Invalid table (wanted ${type})`)
    995 	}
    996 	return id
    997 }
    998 
    999 function byId(id) {
   1000 	return "#" + id
   1001 }
   1002 
   1003 function furniture_name(f) {
   1004 	return f.name ? `${f.name} (${f.type})` : f.type
   1005 }
   1006 
   1007 function swingID(id) {
   1008 	return id + "_swing"
   1009 }
   1010 
   1011 function furnitureImage(f) {
   1012 	return `/furniture/${f.type}/${f.style ?? "default"}.svg`
   1013 }