Second year at Archilogic
One could consider that an app fundamentally has 3 layers: a data layer, an application logic layer, and a UI layer. In the case of the Editor app I was working on at Archilogic, it would look something like:
While my first year was mostly dedicated to refreshing the whole UI, the second year was all about swapping out the data layer, and the tightly coupled application layer.
Preparing the way
To ease the transition from the old to the new data format, the core of the app needed to be refactored in a way that abstracted away the actual implementation details of the interactions with the data: how a floor model is loaded, how an element on the floor plan is selected/modified/copied/deleted, how the floor is then persisted via API, etc. The concept of an action was introduced. An action is defined by
- a condition under which it can run. Some actions are only possible in 2D, some are not possible when the app is offline, some are only allowed for some user role, etc.
- what it will run. Actions update the canvas, change the app state, and modify elements.
- an optional keyboard shortcut
Actions are triggered by users via form inputs, keyboard shortcuts, left-click context-menu, or UI buttons. For now, only editing in 2D is possible, but everything has been put in place to introduce 3D editing in the future, thanks to abstracting interactions with the canvas.
The new data format
For many years, a floor would be stored in a bit of a messy format called Scene Structure. The new data format is much more structured. Spaces are defined by edges, which are defined by vertices. You have guessed it, those vertices and edges make a graph. Hence, the name of the new data format: Space Graph.
A simple space—in other words, a room—delimited by 4 walls, with a door, a desk and a chair
is represented in Scene Structure as seen on the left, and in Space Graph on the right:
{
"v": "2.1",
"x": 0,
"y": 0,
"z": 0,
"id": "cf336998-e7e4-4f11-ae42-20125ca94c26",
"ry": 0,
"type": "plan",
"class": [],
"floorArea": 32.5,
"modelDisplayName": "New floor",
"children": [
{
"x": 0,
"y": 0,
"z": 0,
"id": "07491751-c03b-496e-adad-24b99ffa2185",
"ry": 0,
"type": "level",
"children": [
{
"h": 0.2,
"x": 0,
"y": 0,
"z": 0,
"id": "43b8dceb-56c8-4170-ba4f-650f8f8de0a8",
"ry": 0,
"type": "polyfloor",
"usage": "Private office",
"polygon": [
[
5.85,
0.15
],
[
0.15,
0.15
],
[
0.15,
5.85
],
[
5.85,
5.85
]
],
"hCeiling": 3,
"materials": {
"top": "basic-floor"
},
"hasCeiling": false,
"polygonHoles": [],
"children": []
},
{
"h": 3,
"l": 6,
"w": 0.15,
"x": 0.075,
"y": 0,
"z": 0,
"id": "a2b416cc-b45c-4e4b-ab02-b6ea8b7a86a6",
"ry": -90,
"type": "wall",
"materials": {
"top": {
"colorDiffuse": [
0.12549019607843137,
0.12549019607843137,
0.12549019607843137
]
},
"back": "basic-wall",
"front": "basic-wall"
},
"baseHeight": 0,
"backHasBase": false,
"controlLine": "center",
"frontHasBase": false,
"children": []
},
{
"h": 3,
"l": 5.7,
"w": 0.15,
"x": 0.15,
"y": 0,
"z": 5.925,
"id": "b2f0c124-2c74-49a1-975b-774e9cf35b89",
"ry": 0,
"type": "wall",
"materials": {
"top": {
"colorDiffuse": [
0.12549019607843137,
0.12549019607843137,
0.12549019607843137
]
},
"back": "basic-wall",
"front": "basic-wall"
},
"baseHeight": 0,
"backHasBase": false,
"controlLine": "center",
"frontHasBase": false,
"children": []
},
{
"h": 3,
"l": 5.85,
"w": 0.15,
"x": 5.925,
"y": 0,
"z": 6,
"id": "2f5fef72-997c-4e23-a04c-96e0c94c5f80",
"ry": -270,
"type": "wall",
"materials": {
"top": {
"colorDiffuse": [
0.12549019607843137,
0.12549019607843137,
0.12549019607843137
]
},
"back": "basic-wall",
"front": "basic-wall"
},
"baseHeight": 0,
"backHasBase": false,
"controlLine": "center",
"frontHasBase": false,
"children": []
},
{
"h": 3,
"l": 5.85,
"w": 0.15,
"x": 6,
"y": 0,
"z": 0.075,
"id": "540593c3-db1c-4c92-8249-bf9275d1d3fe",
"ry": -180,
"type": "wall",
"materials": {
"top": {
"colorDiffuse": [
0.12549019607843137,
0.12549019607843137,
0.12549019607843137
]
},
"back": "basic-wall",
"front": "basic-wall"
},
"baseHeight": 0,
"backHasBase": false,
"controlLine": "center",
"frontHasBase": false,
"children": [
{
"h": 2,
"l": 1,
"w": 0.05,
"x": 2.5,
"y": 0,
"z": 0,
"id": "19747ebe-52d3-43be-be5f-6ec5456118ec",
"ry": 0,
"side": "front",
"type": "door",
"class": [],
"hinge": "right",
"doorType": "singleSwing",
"doorAngle": 90,
"leafWidth": 0.03,
"threshold": true,
"handleType": "squareEdged",
"leafOffset": 0.005,
"frameLength": 0.05,
"frameOffset": 0,
"fixLeafRatio": 0.3,
"thresholdHeight": 0.01,
"children": []
}
]
},
{
"x": 3,
"y": 0,
"z": 4,
"id": "8311dab0-6090-46a2-b0c5-2d93db39edfe",
"ry": 0,
"src": "!d18c0430-b896-44c2-8b5e-d38defe18a08",
"type": "interior",
"children": []
},
{
"x": 3,
"y": 0,
"z": 4.5,
"id": "aad211c0-00ae-49c0-b7a1-959944f4c26c",
"ry": 180,
"src": "!7ed123c3-b6c8-4b76-9b4e-69a1562c179b",
"type": "interior",
"children": []
}
]
}
]
}
{
"schemaVersion": "0.16.7",
"spatialStructure": {
"id": "07491751-c03b-496e-adad-24b99ffa2185",
"type": "spatialStructure:layout",
"spatialGraph": {
"vertices": [
{
"id": "cc5802d7-5fee-492c-a6f2-4b57a9bd9ad0",
"type": "spatialGraph:vertex",
"position": [
0,
0
]
},
{
"id": "06be49ac-2ab4-44ab-9442-626b497096a6",
"type": "spatialGraph:vertex",
"position": [
0,
6
]
},
{
"id": "fde5c965-0e30-4887-833b-9157de2f3ec8",
"type": "spatialGraph:vertex",
"position": [
6,
6
]
},
{
"id": "449f2bde-0d68-4ea8-886a-2d3b151eaabe",
"type": "spatialGraph:vertex",
"position": [
6,
0
]
}
],
"edges": [
{
"id": "10226dd1-d701-4285-b364-51fedd8bebf1",
"type": "spatialGraph:edge",
"vertices": [
"cc5802d7-5fee-492c-a6f2-4b57a9bd9ad0",
"06be49ac-2ab4-44ab-9442-626b497096a6"
]
},
{
"id": "4df310c9-01d8-4555-a9f4-e9017c02ed18",
"type": "spatialGraph:edge",
"vertices": [
"06be49ac-2ab4-44ab-9442-626b497096a6",
"fde5c965-0e30-4887-833b-9157de2f3ec8"
]
},
{
"id": "7060299c-d755-434d-aaff-30c023bef636",
"type": "spatialGraph:edge",
"vertices": [
"fde5c965-0e30-4887-833b-9157de2f3ec8",
"449f2bde-0d68-4ea8-886a-2d3b151eaabe"
]
},
{
"id": "e58a459d-8938-4f0d-bcd9-6ce3bf080919",
"type": "spatialGraph:edge",
"vertices": [
"449f2bde-0d68-4ea8-886a-2d3b151eaabe",
"cc5802d7-5fee-492c-a6f2-4b57a9bd9ad0"
]
}
]
},
"spaces": [
{
"id": "43b8dceb-56c8-4170-ba4f-650f8f8de0a8",
"type": "layout:space",
"boundaries": [
{
"edges": [
"7060299c-d755-434d-aaff-30c023bef636",
"4df310c9-01d8-4555-a9f4-e9017c02ed18",
"10226dd1-d701-4285-b364-51fedd8bebf1",
"e58a459d-8938-4f0d-bcd9-6ce3bf080919"
]
}
],
"attributes": {
"program": "none",
"usage": "Private office"
}
}
],
"elements": [
{
"id": "a2b416cc-b45c-4e4b-ab02-b6ea8b7a86a6",
"type": "element:wall",
"parameters": {
"width": 0.15,
"height": 3,
"offset": -0.075,
"elevation": 0,
"materials": {
"top": "#202020",
"bottom": "#000000",
"side1": "asm:basic-wall",
"side2": "asm:basic-wall",
"join1": "asm:basic-wall",
"join2": "asm:basic-wall"
}
},
"edge": "10226dd1-d701-4285-b364-51fedd8bebf1"
},
{
"id": "b2f0c124-2c74-49a1-975b-774e9cf35b89",
"type": "element:wall",
"parameters": {
"width": 0.15,
"height": 3,
"offset": -0.075,
"elevation": 0,
"materials": {
"top": "#202020",
"bottom": "#000000",
"side1": "asm:basic-wall",
"side2": "asm:basic-wall",
"join1": "asm:basic-wall",
"join2": "asm:basic-wall"
}
},
"edge": "4df310c9-01d8-4555-a9f4-e9017c02ed18"
},
{
"id": "2f5fef72-997c-4e23-a04c-96e0c94c5f80",
"type": "element:wall",
"parameters": {
"width": 0.15,
"height": 3,
"offset": -0.075,
"elevation": 0,
"materials": {
"top": "#202020",
"bottom": "#000000",
"side1": "asm:basic-wall",
"side2": "asm:basic-wall",
"join1": "asm:basic-wall",
"join2": "asm:basic-wall"
}
},
"edge": "7060299c-d755-434d-aaff-30c023bef636"
},
{
"id": "540593c3-db1c-4c92-8249-bf9275d1d3fe",
"type": "element:wall",
"parameters": {
"width": 0.15,
"height": 3,
"offset": -0.075,
"elevation": 0,
"materials": {
"top": "#202020",
"bottom": "#000000",
"side1": "asm:basic-wall",
"side2": "asm:basic-wall",
"join1": "asm:basic-wall",
"join2": "asm:basic-wall"
}
},
"edge": "e58a459d-8938-4f0d-bcd9-6ce3bf080919",
"elements": [
{
"id": "6a688745-449d-40f6-a8b0-8e326da106f5",
"type": "element:opening",
"parameters": {
"position": [
2.5,
0
],
"dimensions": [
1,
2
],
"materials": {
"top": "#FFFFFF",
"bottom": "#FFFFFF",
"sides": "#FFFFFF"
}
},
"elements": [
{
"id": "19747ebe-52d3-43be-be5f-6ec5456118ec",
"type": "element:door",
"position": [
0,
0,
0
],
"parameters": {
"length": 1,
"height": 2,
"frameThickness": 0.05,
"frameDepth": 0.15,
"doorType": "singleSwing",
"doorAngle": 90,
"hardware": true,
"hingeSide": "right",
"doorSide": "side2",
"materials": {
"frame": "asm:doorLeaf-flush-white",
"leaf": "asm:doorLeaf-flush-white"
}
},
"rotation": 0,
"rotationAxis": [
0,
1,
0
]
}
]
}
]
},
{
"id": "f114c64b-50fb-4341-b8a8-e137116a0679",
"type": "element:floor",
"boundaries": [
{
"edges": [
"7060299c-d755-434d-aaff-30c023bef636",
"4df310c9-01d8-4555-a9f4-e9017c02ed18",
"10226dd1-d701-4285-b364-51fedd8bebf1",
"e58a459d-8938-4f0d-bcd9-6ce3bf080919"
]
}
],
"parameters": {
"height": 0.2,
"elevation": 0,
"materials": {
"top": "asm:basic-floor",
"bottom": "#808080",
"sides": "asm:basic-wall"
}
}
},
{
"id": "8311dab0-6090-46a2-b0c5-2d93db39edfe",
"type": "element:asset",
"position": [
3,
0,
4
],
"rotation": 0,
"rotationAxis": [
0,
1,
0
],
"product": "d18c0430-b896-44c2-8b5e-d38defe18a08",
"geometries": []
},
{
"id": "aad211c0-00ae-49c0-b7a1-959944f4c26c",
"type": "element:asset",
"position": [
3,
0,
4.5
],
"rotation": 180,
"rotationAxis": [
0,
1,
0
],
"product": "7ed123c3-b6c8-4b76-9b4e-69a1562c179b",
"geometries": []
}
],
"annotations": [],
"views": []
},
"sharedResources": {
"products": [
{
"id": "d18c0430-b896-44c2-8b5e-d38defe18a08",
"type": "product:static",
"attributes": {
"categories": [
"tables"
],
"subCategories": [
"desk"
],
"thumbnail": "https://microservices.archilogic.com/storage/get/60b0f3cd-60d9-43a4-ada2-508051bb2eda/535e624259ee6b0200000484/220527-1148_xwD24l/snapshot.png",
"boundingBox": {
"min": [
-0.8,
0,
-0.4
],
"max": [
0.8,
0.75,
0.4
]
},
"updatedAt": "2022-09-23T09:16:50.232Z"
},
"geometries": [
{
"type": "reference:geometry",
"geometry": {
"type": "geometry:uri",
"format": "data3d",
"id": "productGeometry-d18c0430-b896",
"uri": "/535e624259ee6b0200000484/220523-1625-5rnt5i/archilogic_2022-05-23_16-25-07_1Z50Tb.gz.data3d.buffer"
}
}
],
"name": "Desk 160/80"
},
{
"id": "7ed123c3-b6c8-4b76-9b4e-69a1562c179b",
"type": "product:static",
"attributes": {
"categories": [
"seating"
],
"subCategories": [
"taskChair"
],
"thumbnail": "https://microservices.archilogic.com/storage/get/60b0f3cd-60d9-43a4-ada2-508051bb2eda/535e624259ee6b0200000484/220523-0237_d9124l/snapshot.png",
"boundingBox": {
"min": [
-0.35,
0,
-0.31
],
"max": [
0.35,
1.06,
0.31
]
},
"seatCapacity": 1,
"updatedAt": "2022-09-23T09:09:16.075Z"
},
"geometries": [
{
"type": "reference:geometry",
"geometry": {
"type": "geometry:uri",
"format": "data3d",
"id": "productGeometry-7ed123c3-b6c8",
"uri": "/535e624259ee6b0200000484/220504-1057-cb8z7m/archilogic_2022-05-04_10-57-51_pgm4gn.gz.data3d.buffer"
}
}
],
"name": "Task Chair"
}
],
"geometries": [],
"materials": [],
"relations": []
}
}
You can clearly see how much better the data is organized, for example with position and properties attributes. With Scene Structure, spaces needed to be calculated based on all the walls’ position, length and thickness. Now with Space Graph, a space is simply defined by edges. The data can also be checked for validity because a vertex has to belong to an edge, edges must intersect at vertices, etc.
The new Space Graph format is also self-contained, as it for example contains the product data for the assets (desk and chair). For Scene Structure, this data was externalized in the 2D/3D renderer.
The old and the new data format being so fundamentally different, abstracting them as much as possible was an essential first step, as described in the previous section. But it also meant that pretty much everything non-UI-related has been rewritten. Another difficulty in the transition to the new format was that a lot was designed and implemented by the Space Graph SDK team members along the way. I would often be blocked for a couple of days, then be submerged by a slew of improvements I needed to integrate into the Editor app.
Other improvements
I was a bit reluctant at first to introduce even more changes in parallel to swapping out the data format, but it turned out that the concepts the team wanted to push were totally worth it.
One of those improvements was enforcing that all changes to the Space Graph data would go through undo-redo operations. Those are an integral part of the Space Graph SDK, so the client apps do not need to know about all the side effects of, for example, deleting a vertex or an edge. They are called undo-redo because you can then replay the user’s actions very easily, even in a fun way:
Another massive improvement was changing how walls are drawn. It is very important to make sure the graph behind Space Graph is as simple as possible, i.e. create the fewest possible vertices. We added all kinds of snapping behaviors for that:
Coding fun
The wall snapping was only one of many user interactions that were very fun to code. I had not done much graphics programming since my studies 20 years ago, and it was the thing I enjoyed most at the time.
A feature that had been requested for years was “box multi-selection”: the user draws a rectangle (box), and everything inside that rectangle gets selected. Every drawing app has this. In a couple of days, I came up with an implementation involving object-aligned and axis-aligned bounding boxes, and a 2D spatial indexing library called RBush:
selectCandidates() {
if (!this.selectionRectangle) {
return
}
this.selectedNodeIds = new Set<string>()
const selectionRectangleMinX = this.selectionRectangle[0][0]
const selectionRectangleMinY = this.selectionRectangle[0][1]
const selectionRectangleMaxX = this.selectionRectangle[2][0]
const selectionRectangleMaxY = this.selectionRectangle[2][1]
// find intersections of the selection rectangle with axis-aligned bounding
// boxes of layout nodes
const candidatesAfterFirstPass: SelectionCandidate[] = this.spatialIndex.search({
minX: selectionRectangleMinX,
minY: selectionRectangleMinY,
maxX: selectionRectangleMaxX,
maxY: selectionRectangleMaxY
})
// first pass: non-rotated layout nodes are final matches
//
// Non-rotated layout nodes have identical axis-aligned and object-aligned
// bounding boxes, so we know for sure that they have to be selected
const candidatesForSecondPass: SelectionCandidate[] = []
for (const selectionCandidate of candidatesAfterFirstPass) {
if (selectionCandidate.isAxisAligned) {
this.selectedNodeIds.add(selectionCandidate.nodeId)
} else {
candidatesForSecondPass.push(selectionCandidate)
}
}
// second pass: layout nodes with their axis-aligned bounding boxes entirely
// inside the selection rectangle are final matches
const candidatesForThirdPass: Partial<SelectionCandidate>[] = []
for (const selectionCandidate of candidatesForSecondPass) {
const rectangleContainsCandidate =
selectionRectangleMinX <= selectionCandidate.minX &&
selectionCandidate.maxX <= selectionRectangleMaxX &&
selectionRectangleMinY <= selectionCandidate.minY &&
selectionCandidate.maxY <= selectionRectangleMaxY
if (rectangleContainsCandidate) {
this.selectedNodeIds.add(selectionCandidate.nodeId)
} else {
candidatesForThirdPass.push({
objectAlignedBoundingBox: selectionCandidate.objectAlignedBoundingBox,
nodeId: selectionCandidate.nodeId
})
}
}
// third pass: layout nodes with an intersection between the edges of their
// object-aligned bounding box and the edges of the selection
// rectangle are final matches.
//
// This effectively means that there is an intersection between the selection
// rectangle and the layout node’s object-aligned bounding box
for (const selectionCandidate of candidatesForThirdPass) {
if (this.rectangleAndCandidateIntersect(selectionCandidate.objectAlignedBoundingBox || [])) {
this.selectedNodeIds.add(selectionCandidate.nodeId)
}
}
if (this.selectedNodeIds.size === 0) {
this.emit('unselectAll')
} else {
this.emit('select', this.selectedNodeIds)
}
}
The code is surprisingly simple, but it took me several iterations to get to that point. In debug mode, the algorithm is kinda visible:
Once users could easily multi-select elements, they obviously wanted to move and rotate them. I one-upped it and added the possibility to move the rotation center. For assets (desks, chairs, …) it makes sense. Less so for walls, but it is fun to use:
Another often requested feature was ways to align assets. The minimal version to do it is bounding box snapping:
A last example of 2D-programming is the wall connection helper. Instead of having the user painstakingly try to position 2 walls so they would nicely align and connect, do the heavy lifting in the app:
More typical web app programming was involved the last two interactions I want to show here: window/door frame positioning, and wall location line positioning:
Visual fun
In frontend development, there is the coding fun, and then there is the satisfaction of creating something visually fun. I really liked the data visualization aspect of my time at Archilogic.
Unit tests, and the future
If you have worked with me, you know that I value having a good test coverage. I introduced coverage measurement in the Editor app 2 months after I joined. It was… bad. I never got to reach my 80% coverage goal, but tests got a lot better:
when I joined | after 2.5 years | |
---|---|---|
branches | 8% | 53% |
functions | 7% | 62% |
lines | 10% | 67% |
statements | 10% | 66% |
I semi-succeeded to make my fellow colleagues adopt the more descriptive test descriptions, making tests act as specifications. So I tried to lead by example. Here are some of the nicer ones.
Some unit tests for the Quality Control plugin:
QualityControl > renders all progress bars at 0%, 1 opened group and sub-group with 1 active check and 1 unanswered check, 1 closed group and sub-group with 1 unanswered checks, 1 closed group and sub-group with 2 unanswered checks
QualityControl > when skipping to check 2.1.1 and making it pass > renders the overall progress bar at 20%, the group 2 and sub-group 2.1 progress bars at 100%, the other progress bars at 0%, 1 closed group and sub-group with 2 unanswered checks, 1 opened group and sub-group with 1 answered check, 1 opened group and sub-group with 1 active check and 1 unanswered check
QualityControl > when skipping to check 2.1.1 and making it pass > then going back to check 1.1.1 and making it pass > renders the overall progress bar at 40%, the group 1 and sub-group 1.1 progress bars at 50%, the group 2 and sub-group 2.1 progress bars at 100%, the group 3 and sub-group 3.1 progress bars at 0%, 1 opened group and sub-group with 1 answered check and 1 active check, 1 opened group and sub-group with 1 answered check, 1 closed group and sub-group with 2 unanswered checks
QualityControl > when skipping to check 2.1.1 and making it pass > then going back to check 1.1.1 and making it pass > then the check 1.1.2 fails > renders the overall progress bar at 60%, the group 1, sub-group 1.1, the group 2 and sub-group 2.1 progress bars at 100%, the group 3 and sub-group 3.1 progress bars at 0%, 1 closed group and sub-group with 2 answered checks, 1 opened group and sub-group with 1 active and answered check, 1 opened group and sub-group with 2 unanswered checks
QualityControl > when skipping to check 2.1.1 and making it pass > then going back to check 1.1.1 and making it pass > then the check 1.1.2 fails > then the check 3.1.1 fails > renders the overall progress bar at 80%, the group 1, sub-group 1.1, the group 2 and sub-group 2.1 progress bars at 100%, the group 3 and sub-group 3.1 progress bars at 50%, 1 closed group and sub-group with 2 answered checks, 1 closed group and sub-group with 1 answered check, 1 opened group and subgroup with 1 answered check and 1 active check
QualityControl > when skipping to check 2.1.1 and making it pass > then going back to check 1.1.1 and making it pass > then the check 1.1.2 fails > then the check 3.1.1 fails > then the comment on check 1.1.2 is modified, then the user goes back to group 3 and sub-group 3.1 and check 3.1.2 passes, and finally the report is shown > renders a report
Some unit tests for the 2D wall connection helper:
ConnectEdgesAction > when there are 2 walls that would intersect if they were longer > connects the walls
ConnectEdgesAction > when there are 2 walls that are on the same line and that would intersect if they were longer > connects the walls
ConnectEdgesAction > when there are 2 walls that are on the same line and that would intersect if they were longer (independent of edge directions) > connects the walls
ConnectEdgesAction > when there are 2 "vertical" walls (all vertices have same x value) that are on the same line and that would intersect if they were longer > connects the walls
ConnectEdgesAction > when there are 2 walls that are on the same line, and that would intersect if they were longer, but there is another wall in-between > does nothing
ConnectEdgesAction > when there are 2 parallel walls with geometries that would not overlap even if the walls were longer > does nothing
ConnectEdgesAction > when there are 2 parallel walls with geometries that overlap > connects the walls via a connector edge
ConnectEdgesAction > when there are 2 parallel walls with geometries that would overlap if the walls were longer > connects the walls via a connector edge
Some unit tests for a UI component in the Editor app:
Toolbar
when nobody is logged in
✓ renders nothing
when somebody is logged in
✓ renders a menu button
✓ does not render an input for the name
✓ renders 2 slots
✓ renders a user menu button
then a floor is loaded
✓ renders a skeleton loader
then the floor name is available
✓ renders the floor name
then the name is changed and the saving succeeds
✓ calls the API
✓ displays a success message
then the name is changed but the saving fails
✓ displays the error
when somebody with insufficient rights is logged in
✓ does not render the 2 slots
Sometimes ASCII drawing would help make tests more readable. For example, in this test for the aforementioned multi-selection rotation, there are 3 selected points a, b and c (d is not selected) that are rotated 90º around the point o, resulting in points (a), (b) and ©:
/**
* ┆
* · ┼ · c · · · · ·
* ┆
* · ┼ · · · · · · ·
* ┆
* · a · (a)b · · · · ·
* ┆
* · ┼ o · · · · · ·
* ┆
* ┼┄┄┄┼┄┄┄┼┄┄(b)┄┄┼┄┄(c)┄┄┼┄┄┄d┄┄┄┼
* ┆
*/
Or this drawing for the bounding box calculation tests:
/**
* ┆
* · ┼ · x · ·
* ┆ ⟋ ⟍
* · ┼ x · ⟍ ·
* ┆ ⟍ ⟍
* ┼┄┄┄┼┄┄┄┼┄┄┄⟍┄┄┄┼┄┄┄x
* ┆ ⟍ ⟋
* · ┼ · · x ·
* ┆
*/
I have not gotten the chance to improve the overall architecture of the app. I feel like the UI could be further uncoupled from the app’s core logic. State management could be improved as well.
Kudos
I would like to end this by giving some kudos to the people I worked closely with:
- Frederic is the brain behind many of the high-level concept in Space Graph and the 2D editing. He also coded a lot of the now legacy Scene Structure data format, and 2D editing.
- Stavros is the other brain behind Space Graph, and the main contributor
- Ben is the frontend team lead, and he helped me a lot with infrastructure issues and code reviews
- Eimhin is the product owner of the Editor app. He brought a lot of industry knowledge, and provided many good ideas, for example for wall snapping, window/door frame positioning, etc.