commit d1d83db13f281c741fd8f760f38dcc73bc012190
parent ef27b5f5b9bed91f6abedddd278976e4754cafa2
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Mon, 19 Aug 2024 09:21:58 -0700
Add Precise mode to editor
This is the start of the actual interface I want the editor to have.
It's going to require some vector math, so I'v included the
threejs-math library, and while the whole thing is pretty big, I'm
only using Vector2 from it which isn't too bad.
It also required some changes to the editor:
- Rename mapPoints to mapSelected,
- and add a new method mapPoints which allows you to specify the points to map
- Add ui group to draw
- Have keydown & keyup events listen on the document
Diffstat:
5 files changed, 296 insertions(+), 15 deletions(-)
diff --git a/Makefile b/Makefile
@@ -1,7 +1,8 @@
prefix = /var/www/htdocs/www.spaceplanner.app
libs =\
github.com/svgdotjs/svg.js@3.2.4 \
- github.com/svgdotjs/svg.panzoom.js@2.1.2
+ github.com/svgdotjs/svg.panzoom.js@2.1.2 \
+ github.com/ros2jsguy/threejs-math@9a7b4c81c58b200b11670fd597edadd5972c0ae5 \
install:
rsync $$(libnames ${libs} | sed 's/^/--exclude=lib\//') -va --del files/ ${prefix}
diff --git a/files/floorplans/floorplan/editor.js b/files/floorplans/floorplan/editor.js
@@ -214,6 +214,8 @@ export class FloorplanEditor {
this.draw.rect().attr({ id: "grid" })
+ this.ui = this.draw.group().attr({ id: "ui" })
+
let data = this.draw.group().attr({ id: "floorplan" })
data.group().attr({ id: "walls" }) // lines
data.group().attr({ id: "points" }) // circles
@@ -359,6 +361,7 @@ export class FloorplanEditor {
}
this.backend.addPoint(point)
this.updateDisplay()
+ return this.selectedPoint()
}
pointAt(point) {
@@ -372,13 +375,17 @@ export class FloorplanEditor {
return pointInside
}
- mapPoints(type) {
- let pointId = function(id) { return id.split("_")[1] }
+ mapSelected(type) {
let points = this.selectedPoints()
+ return this.mapPoints(type, points.a, points.b)
+ }
+
+ mapPoints(type, p1, p2) {
+ let pointId = function(id) { return id.split("_")[1] }
this.backend.mapPoints(type,
- pointId(points.a.attr("id")),
- pointId(points.b.attr("id"))
+ pointId(p1.attr("id")),
+ pointId(p2.attr("id"))
)
this.updateDisplay()
}
@@ -504,8 +511,13 @@ export class FloorplanEditor {
function remove_mode_handlers(target, mode_handlers) {
for (let event in mode_handlers) {
for (let handler in mode_handlers[event]) {
- console.debug("Remove mode handler", event, handler, "from", target)
- target.off(event, mode_handlers[event][handler])
+ console.debug("Remove mode handler", event, handler, "to", target)
+ let h = mode_handlers[event][handler]
+ if (event === "keydown" || event === "keyup") {
+ document.removeEventListener(event, h)
+ } else {
+ target.off(event, h)
+ }
}
}
}
@@ -514,7 +526,12 @@ function add_mode_handlers(target, mode_handlers) {
for (let event in mode_handlers) {
for (let handler in mode_handlers[event]) {
console.debug("Add mode handler", event, handler, "to", target)
- target.on(event, mode_handlers[event][handler])
+ let h = mode_handlers[event][handler]
+ if (event === "keydown" || event === "keyup") {
+ document.addEventListener(event, h)
+ } else {
+ target.on(event, h)
+ }
}
}
}
diff --git a/files/floorplans/floorplan/main.css b/files/floorplans/floorplan/main.css
@@ -63,6 +63,15 @@ svg {
border-color: blue;
}
+aside.terminal {
+ position: absolute;
+ bottom: 5vh;
+ right: 5vw;
+ background-color: slategrey;
+ border: thin ridge darkslategrey;
+ color: white;
+}
+
aside.message {
position: absolute;
box-sizing: border-box;
@@ -75,3 +84,7 @@ aside.message {
border-radius: 0;
padding: .2em;
}
+
+input.invalid {
+ background-color: lightgrey;
+}
diff --git a/files/floorplans/floorplan/main.js b/files/floorplans/floorplan/main.js
@@ -3,8 +3,33 @@ import "/lib/github.com/svgdotjs/svg.panzoom.js/svg.panzoom.js"
import * as ui from "/lib/ui.js"
import * as etc from "/lib/etc.js"
import { FloorplanEditor as Editor } from "./editor.js"
+import { Vector2 } from "/lib/github.com/ros2jsguy/threejs-math/math/Vector2.js"
+
+SVG.extend(SVG.Point, {
+ vec: function() {
+ return new Vector2(this.x, this.y)
+ }
+})
+
+SVG.extend(SVG.Line, {
+ vecs: function() {
+ let a = this.array()
+ let vecs = []
+ for (let i in a) {
+ vecs.push(new Vector2(a[i][0], a[i][1]))
+ }
+ return vecs
+ }
+})
+
+SVG.extend(SVG.Shape, {
+ vec: function() {
+ return new Vector2(this.x(), this.y())
+ }
+})
const messageTimeout = 4000
+const movingAddTimeout = 250
const buttons = {
left: 0,
@@ -52,6 +77,7 @@ function init() {
}
}
})
+ editor.useUnits("imperial")
editor.draw.viewbox(0, 0, editor.units.get("foot", 40), editor.units.get("foot", 40))
let push = ui.button("Push", "Push updates", "arrow-up",
@@ -65,8 +91,7 @@ function init() {
for (let mode in modes) {
editor.addMode(mode, modes[mode])
}
- editor.useMode("testing")
- editor.useGrid("imperial")
+ editor.useMode("Precise")
let toolbar = document.querySelector("header")
.appendChild(document.createElement("ul"))
@@ -79,12 +104,17 @@ function init() {
)
))
toolbar.append(item(
- selector(editor, editor.grids, function(system) { editor.useGrid(system) },
- { current: "imperial", text: "Grid systems:" }
+ selector(editor, editor.units.systems, function(system) { editor.useUnits(system) },
+ { current: editor.unitSystem, text: "Units:" }
)
))
editor.backend.pull()
+ .then(function() {
+ if (editor.draw.findExactlyOne("#points").children().length === 0) {
+ editor.addPoint({ x: 0, y: 0 })
+ }
+ })
}
function selector(editor, things, select, options) {
@@ -124,12 +154,12 @@ function selector(editor, things, select, options) {
}
let modes = {
- none: {
+ None: {
handlers: {
contextmenu: preventDefaultHandler
}
},
- testing: {
+ Testing: {
points: true,
handlers: {
/*
@@ -140,7 +170,222 @@ let modes = {
contextmenu: preventDefaultHandler,
click: addWallHandler
}
+ },
+ Precise: {
+ points: true,
+ handlers: {
+ contextmenu: preventDefaultHandler,
+ mousedown: preciseAddWallHandler,
+ mousemove: preciseAddWallHandler,
+ mouseup: preciseAddWallHandler,
+ keydown: preciseAddWallHandler
+ }
+ }
+}
+
+// mousedown, mousemove, mouseup, keydown
+function preciseAddWallHandler(event, editor, state) {
+ const cleanup = function() {
+ state.line.remove()
+ state.point.remove()
+ state.terminal.remove()
+ for (let i in state) {
+ delete state[i]
+ }
+ }
+ const updatePoint = function(p, options) {
+ options = options ?? {}
+ let origin = state.from.vec()
+ state.point.move(p.x, p.y)
+ let instead = editor.pointAt(p)
+ if (instead && instead != state.from) {
+ state.point.hide()
+ p = instead.select().pos()
+ state.gotsnapped = true
+ } else if (state.gotsnapped) {
+ state.gotsnapped = false
+ state.point.show().select()
+ }
+ state.line.plot(origin.x, origin.y, p.x, p.y)
+ if (!options.leave_input) {
+ state.len.value = userLength(editor,
+ editor.units.snapTo(origin.distanceTo(p), editor.unit))
+ }
+ }
+ const addWall = function() {
+ state.point.remove()
+ let p = editor.addPoint(state.point.pos())
+ editor.mapPoints("wall", state.from, p)
+ cleanup()
+ editor.finishAction()
+ }
+
+ if (!event.key && event.button !== buttons.left) {
+ return
+ }
+
+ let p = editor.draw.point(event.clientX, event.clientY).vec()
+ if (event.type === "mousedown") {
+ if (state.point) {
+ if (!state.moving &&
+ (Date.now() - state.lastmoving <= movingAddTimeout)) {
+ if (state.from.vec().distanceTo(p) > 0) {
+ addWall()
+ } else {
+ cleanup();
+ }
+ event.preventDefault()
+ return
+ }
+ if (state.point.inside(p.x, p.y)) {
+ state.moving = true
+ }
+ event.preventDefault()
+ return
+ }
+
+ state.from = editor.pointAt(p)
+ if (!state.from) {
+ return
+ }
+ state.moving = true
+ state.line = editor.ui.line()
+ .addClass("wall")
+ .addClass("preview")
+ state.point = editor.ui.circle()
+ .addClass("point")
+ .addClass("preview")
+ .select()
+ state.terminal = document.body
+ .appendChild(document.createElement("aside"))
+ state.terminal.classList.add("terminal")
+ state.len = state.terminal
+ .appendChild(document.createElement("input"))
+ state.len.value = 0
+ state.len.addEventListener("input", function(event) {
+ let vecs = state.line.vecs()
+ let len
+ try {
+ len = editor.units.snapTo(
+ parseUserLength(editor, event.target.value), editor.unit
+ )
+ }
+ catch (err) {
+ state.len.classList.add("invalid")
+ console.log("Invalid input length", err)
+ return
+ }
+ state.len.classList.remove("invalid")
+ if (len> 0) {
+ vecs[1] = setLength(vecs[0], vecs[1], len)
+ updatePoint(vecs[1], { leave_input: true })
+ }
+ })
+ event.preventDefault()
+ return
+ }
+
+ if (!state.line) {
+ return
+ }
+
+ if (event.type === "mousemove") {
+ if (!state.moving) {
+ return
+ }
+ let sp = state.from.vec()
+ p = snap(editor.units.snapTo(p, editor.unit), sp, 8)
+ updatePoint(p)
+ } else if (event.type === "mouseup") {
+ state.moving = false
+ state.lastmoving = Date.now()
+ } else if (event.type === "keydown") {
+ if (event.key === "Enter") {
+ addWall()
+ return
+ } else if (event.key !== "Escape") {
+ return
+ }
+ cleanup();
+ } else {
+ return
+ }
+ event.preventDefault()
+}
+
+function parseUserLength(editor, length) {
+ let a = length.replaceAll(" ", "").split(/([0-9]+)/)
+ let amount
+ let rebuilt = []
+ for (let i in a) {
+ if (a[i].length === 0) {
+ ;
+ } else if (!amount) {
+ amount = Number(a[i])
+ if (amount === NaN) {
+ throw new Error("Invalid number")
+ }
+ } else {
+ if (!editor.units.symbols[a[i]]) {
+ throw new Error("Invalid user length")
+ }
+ rebuilt.push({ symbol: a[i], amount: amount })
+ amount = null
+ }
+ }
+ if (amount) {
+ rebuilt.push({ unit: editor.unit, amount: amount })
+ }
+
+ return editor.units.combine(rebuilt)
+}
+
+function userLength(editor, units) {
+ let a = editor.units.separate(units, editor.unitSystem)
+ let words = []
+ for (let i in a) {
+ if (!a[i].unit) {
+ // We don't allow anything smaller than smallest defined unit,
+ // though maybe this should be an error condition
+ continue
+ }
+ words.push(String(a[i].amount) + (a[i].symbol ?? a[i].name))
+ }
+ return words.join(" ")
+}
+
+// I suppose this is why math is important...
+// and the internet:
+// <https://stackoverflow.com/questions/42510144/calculate-coordinates-for-45-degree-snap>
+// UPDATE: Probably find an easy way using threejs-math now
+function snap(point, on, directions) {
+ let factor = (directions ?? 4) / 2
+ let dx = point.x - on.x
+ let dy = point.y - on.y
+ let dist = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2))
+ let angle = Math.atan2(dy, dx)
+ angle = Math.round(angle / Math.PI * factor) / factor * Math.PI
+ return new Vector2(
+ on.x + dist * Math.cos(angle),
+ on.y + dist * Math.sin(angle)
+ )
+}
+
+function setLength(a, b, length) {
+ /*
+ * Not sure if a zero length line is worth supporting, it doesn't
+ * really work naturally. To support it you would need another
+ * store of information in addition to the vector
+ */
+ if (length <= 0) {
+ throw new Error("Zero length line wouldn't be able to be lengthened again")
+ length = 0
}
+ /*
+ * Basically make it's origin zero, normalize it to be from
+ * 0-1, multiply it by length, then add the origin back to it.
+ */
+ return b.sub(a).normalize().multiplyScalar(length).add(a)
}
// click
@@ -155,7 +400,7 @@ function addWallHandler(click, editor) {
editor.addPoint(editor.draw.point(click.clientX, click.clientY))
editor.updateDisplay()
if (editor.draw.findOne("#points").children().length >= 2) {
- editor.mapPoints("wall")
+ editor.mapSelected("wall")
}
editor.finishAction()
click.preventDefault()
diff --git a/files/floorplans/floorplan/svg.css b/files/floorplans/floorplan/svg.css
@@ -1,5 +1,10 @@
/* SVG element CSS */
+.preview.wall {
+ stroke: grey;
+ stroke-width: 400;
+}
+
#walls > line {
stroke: black;
stroke-width: 400;