This commit is contained in:
Evert Prants 2023-01-24 21:03:43 +02:00
parent 6207554c99
commit 8ba00eb9e2
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
22 changed files with 723 additions and 6 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ lerna-debug.log*
.env*
/private
/database
/usercontent/*

147
package-lock.json generated
View File

@ -13,9 +13,11 @@
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "^6.1.4",
"@nestjs/typeorm": "^9.0.1",
"bcrypt": "^5.1.0",
"canvas": "^2.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"jsonwebtoken": "^9.0.0",
@ -1658,6 +1660,23 @@
"typescript": "^4.3.5"
}
},
"node_modules/@nestjs/serve-static": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-3.0.0.tgz",
"integrity": "sha512-TpXjgs4136dQqWUjEcONqppqXDsrJhRkmKWzuBMOUAnP4HjHpNmlycvkHnDnWSoG2YD4a7Enh4ViYGWqCfHStA==",
"dependencies": {
"path-to-regexp": "0.2.5"
},
"peerDependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0"
}
},
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="
},
"node_modules/@nestjs/swagger": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.4.tgz",
@ -3183,6 +3202,20 @@
}
]
},
"node_modules/canvas": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.0.tgz",
"integrity": "sha512-bdTjFexjKJEwtIo0oRx8eD4G2yWoUOXP9lj279jmQ2zMnTQhT8C3512OKz3s+ZOaQlLbE7TuVvRDYDB3Llyy5g==",
"hasInstallScript": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
@ -3652,6 +3685,17 @@
}
}
},
"node_modules/decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"dependencies": {
"mimic-response": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@ -6593,6 +6637,17 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -6775,6 +6830,11 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="
},
"node_modules/nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -7931,6 +7991,35 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"dependencies": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@ -10533,6 +10622,21 @@
"pluralize": "8.0.0"
}
},
"@nestjs/serve-static": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-3.0.0.tgz",
"integrity": "sha512-TpXjgs4136dQqWUjEcONqppqXDsrJhRkmKWzuBMOUAnP4HjHpNmlycvkHnDnWSoG2YD4a7Enh4ViYGWqCfHStA==",
"requires": {
"path-to-regexp": "0.2.5"
},
"dependencies": {
"path-to-regexp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="
}
}
},
"@nestjs/swagger": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.4.tgz",
@ -11717,6 +11821,16 @@
"integrity": "sha512-239m03Pqy0hwxYPYR5JwOIxRJfLTWtle9FV8zosfV5pHg+/51uD4nxcUlM8+mWWGfwKtt8lJNHnD3cWw9VZ6ow==",
"dev": true
},
"canvas": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.0.tgz",
"integrity": "sha512-bdTjFexjKJEwtIo0oRx8eD4G2yWoUOXP9lj279jmQ2zMnTQhT8C3512OKz3s+ZOaQlLbE7TuVvRDYDB3Llyy5g==",
"requires": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
}
},
"chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
@ -12062,6 +12176,14 @@
"ms": "2.1.2"
}
},
"decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"requires": {
"mimic-response": "^2.0.0"
}
},
"dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@ -14287,6 +14409,11 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
},
"mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA=="
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -14444,6 +14571,11 @@
}
}
},
"nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -15298,6 +15430,21 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="
},
"simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"requires": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",

View File

@ -25,9 +25,11 @@
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "^6.1.4",
"@nestjs/typeorm": "^9.0.1",
"bcrypt": "^5.1.0",
"canvas": "^2.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"jsonwebtoken": "^9.0.0",

View File

@ -5,9 +5,16 @@ import { UserModule } from 'src/objects/user/user.module';
import { AuthModule } from 'src/shared/auth/auth.module';
import { AppBuildingController } from './app-building.controller';
import { AppBuildingService } from './app-building.service';
import { PlanRendererModule } from './plan-renderer/plan-renderer.module';
@Module({
imports: [GroupModule, BuildingModule, UserModule, AuthModule],
imports: [
GroupModule,
BuildingModule,
UserModule,
AuthModule,
PlanRendererModule,
],
controllers: [AppBuildingController],
providers: [AppBuildingService],
})

View File

@ -12,6 +12,7 @@ import {
BuildingsCreateRoomRequestDto,
BuildingsUpdateRoomRequestDto,
} from './dto/buildings-create-room-request.dto';
import { PlanRendererService } from './plan-renderer/plan-renderer.service';
@Injectable()
export class AppBuildingService {
@ -19,6 +20,7 @@ export class AppBuildingService {
private readonly buildingService: BuildingService,
private readonly userService: UserService,
private readonly groupService: GroupService,
private readonly planRenderService: PlanRendererService,
) {}
async getUserBuildings(user: User) {
@ -133,6 +135,7 @@ export class AppBuildingService {
Object.assign(floor, body);
this.planRenderService.renderFloor(floor);
await this.buildingService.saveFloor(floor);
return omit(floor, ['building']);
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PlanRendererService } from './plan-renderer.service';
@Module({
providers: [PlanRendererService],
exports: [PlanRendererService],
})
export class PlanRendererModule {}

View File

@ -0,0 +1,45 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Floor } from 'src/objects/building/entities/floor.entity';
import { PlanRenderer } from './renderer';
import { FloorDocument } from './renderer/renderer.interfaces';
import fs from 'fs';
import { join } from 'path';
import { isValidLayer } from './renderer/plan-validators';
@Injectable()
export class PlanRendererService {
public validateFloorDocument(floorDocument: FloorDocument) {
const valid =
floorDocument?.layers &&
!isNaN(floorDocument.height) &&
!isNaN(floorDocument.width) &&
floorDocument.height > 0 &&
floorDocument.width > 0 &&
Array.isArray(floorDocument.layers) &&
floorDocument.layers.every(isValidLayer);
if (!valid)
throw new BadRequestException(
'Floor plan JSON is incorrect or malformed',
);
}
public renderFloor(floor: Floor) {
const plan = JSON.parse(floor.plan || '{}') as FloorDocument;
this.validateFloorDocument(plan);
if (plan.layers.every(({ contents }) => !contents.length)) return;
const filename = `floorplan-${floor.id}.png`;
const output = fs.createWriteStream(
join(process.cwd(), 'usercontent', filename),
);
const pngStream = PlanRenderer.renderFloorDocument(plan);
pngStream.pipe(output);
output.on('finish', () =>
Logger.log(`Floor plan for ID:${plan.id} has been rendered`),
);
floor.planImage = filename;
}
}

View File

@ -0,0 +1,163 @@
import { Canvas, CanvasRenderingContext2D } from 'canvas';
import {
extractLinePoints,
vec2Add,
vec2AngleFromOrigin,
vec2Distance,
vec2DivideScalar,
vec2Sub,
} from './renderer-utils';
import {
BezierSegment,
FloorDocument,
Layer,
Line,
Vec2,
} from './renderer.interfaces';
export class PlanRenderer {
constructor(
public width: number,
public height: number,
public origin: Vec2,
public ctx: CanvasRenderingContext2D,
) {}
draw(layers: Layer[]) {
for (const layer of layers.reverse()) {
if (!layer.visible) continue;
this.drawLayer(layer);
}
}
makeBezier(segment: BezierSegment) {
const [ox, oy] = this.origin;
const bezier = segment as BezierSegment;
const [cp1x, cp1y] = bezier.startControl;
const [cp2x, cp2y] = bezier.endControl;
const [x, y] = bezier.end;
this.ctx.bezierCurveTo(
cp1x - ox,
cp1y - oy,
cp2x - ox,
cp2y - oy,
x - ox,
y - oy,
);
}
makeLinePath(line: Line) {
const [ox, oy] = this.origin;
const [firstSegment, ...segments] = line.segments;
// first segment must have a starting point
if (!firstSegment.start) return;
this.ctx.moveTo(...vec2Sub(firstSegment.start, this.origin));
if (line.type === 'curve') {
const lineLength = vec2Distance(firstSegment.start, firstSegment.end);
const lineAngle = vec2AngleFromOrigin(
firstSegment.end,
firstSegment.start,
);
const ninety = lineAngle + Math.PI / 2;
this.ctx.moveTo(...vec2Sub(firstSegment.end, this.origin));
this.ctx.arc(
firstSegment.start[0] - ox,
firstSegment.start[1] - oy,
lineLength,
lineAngle,
ninety,
);
this.ctx.lineTo(...vec2Sub(firstSegment.start, this.origin));
} else if ((firstSegment as BezierSegment).startControl) {
this.makeBezier(firstSegment as BezierSegment);
} else {
this.ctx.lineTo(...vec2Sub(firstSegment.end, this.origin));
}
for (const segment of segments) {
if (segment.start) {
this.ctx.moveTo(...vec2Sub(segment.start, this.origin));
}
if ((segment as BezierSegment).startControl) {
this.makeBezier(segment as BezierSegment);
continue;
}
this.ctx.lineTo(...vec2Sub(segment.end, this.origin));
}
if (line.closed && line.type !== 'curve') {
this.ctx.closePath();
}
}
setupLine(line: Line) {
this.ctx.beginPath();
if (line.lineDash) {
this.ctx.setLineDash(line.lineDash);
}
this.ctx.strokeStyle = line.color || '#000000';
this.ctx.lineWidth = line.width;
this.ctx.lineCap = line.lineCap || 'butt';
this.ctx.lineJoin = line.lineJoin || 'miter';
}
private drawRoomText(line: Line) {
const points = extractLinePoints(line);
const centerPoint = vec2Sub(
vec2DivideScalar(
points.reduce<Vec2 | null>(
(prev, current) => (prev ? vec2Add(prev, current) : current),
null,
) as Vec2,
points.length,
),
this.origin,
);
this.ctx.font = '16px Arial';
this.ctx.fillStyle = line.color;
const { width } = this.ctx.measureText(line.name);
this.ctx.fillText(line.name, centerPoint[0] - width / 2, centerPoint[1]);
}
private drawLine(line: Line) {
this.setupLine(line);
this.makeLinePath(line);
this.ctx.stroke();
if (line.type === 'room') {
this.drawRoomText(line);
}
}
private drawLayer(layer: Layer) {
for (const item of layer.contents) {
if (!item.visible) continue;
const line = item as Line;
if (line.segments) {
this.drawLine(line);
continue;
}
}
}
public static renderFloorDocument(document: FloorDocument) {
let minBound: Vec2 = [0, 0];
let maxBound: Vec2 = [document.width, document.height];
if (document.boundingBox) {
const offset = 80;
minBound = vec2Sub(document.boundingBox[0], [offset, offset]);
maxBound = vec2Add(document.boundingBox[1], [offset, offset]);
}
const width = maxBound[0] - minBound[0];
const height = maxBound[1] - minBound[1];
const canvas = new Canvas(width, height, 'image');
const ctx = canvas.getContext('2d');
const render = new PlanRenderer(width, height, minBound, ctx);
render.draw(document.layers);
return canvas.createPNGStream();
}
}

View File

@ -0,0 +1,50 @@
import { isValidColor } from 'src/shared/utils/validator.utils';
import {
Vec2,
Line,
BezierSegment,
LayerObject,
Layer,
} from './renderer.interfaces';
export const isValidVec2 = (subject: Vec2) =>
Array.isArray(subject) &&
subject.length === 2 &&
subject.every((entry) => !isNaN(entry));
export const isValidLine = (subject: Line) =>
// Segments must be array
Array.isArray(subject.segments) &&
// Every segment must contain at least a valid end point
subject.segments.every((segment) => {
// Check segment vector
if (segment.start && !isValidVec2(segment.start)) return false;
if (!segment.end || !isValidVec2(segment.end)) return false;
// Check bezier vector
const bezier = segment as BezierSegment;
if (bezier.startControl && !isValidVec2(bezier.startControl)) return false;
if (bezier.endControl && !isValidVec2(bezier.endControl)) return false;
return true;
}) &&
// Color must be a valid color value
isValidColor(subject.color) &&
// Closed must be a boolean
(subject.closed == null || typeof subject.closed === 'boolean');
export const isValidLayerObject = (object: LayerObject) =>
// Must be of correct type
['line', 'room', 'curve', 'object'].includes(object.type) &&
// If has segments, must be a valid line
(!(object as Line).segments || isValidLine(object as Line)) &&
// Name must be present
object.name != null;
export const isValidLayer = (layer: Layer) =>
// Must have correct color value
isValidColor(layer.color) &&
// Must have contents
Array.isArray(layer.contents) &&
// Must have valid contents
layer.contents.every(isValidLayerObject) &&
// Must be named
layer.name != null;

View File

@ -0,0 +1,107 @@
import { Line, Vec2, Vec2Box } from './renderer.interfaces';
export const vec2Length = ([x, y]: Vec2) => Math.abs(Math.sqrt(x * x + y * y));
export const vec2Add = ([x1, y1]: Vec2, [x2, y2]: Vec2): Vec2 => [
x1 + x2,
y1 + y2,
];
export const vec2Multiply = ([x1, y1]: Vec2, [x2, y2]: Vec2): Vec2 => [
x1 * x2,
y1 * y2,
];
export const vec2Sub = ([x1, y1]: Vec2, [x2, y2]: Vec2): Vec2 => [
x1 - x2,
y1 - y2,
];
export const vec2MultiplyScalar = ([x, y]: Vec2, scalar: number): Vec2 => [
x * scalar,
y * scalar,
];
export const vec2DivideScalar = ([x, y]: Vec2, scalar: number): Vec2 => [
x / scalar,
y / scalar,
];
export const vec2Normalize = (vec: Vec2): Vec2 =>
vec2DivideScalar(vec, vec2Length(vec));
export const vec2Distance = (vec1: Vec2, vec2: Vec2) =>
vec2Length(vec2Sub(vec2, vec1));
export const vec2InCircle = (circle: Vec2, point: Vec2, radius: number) =>
vec2Distance(circle, point) <= radius;
export const vec2Inverse = (vec: Vec2): Vec2 => vec2MultiplyScalar(vec, -1);
export const vec2Snap = ([x, y]: Vec2, snapScale: number): Vec2 =>
vec2MultiplyScalar(
[Math.round(x / snapScale), Math.round(y / snapScale)],
snapScale,
);
export const vec2Equals = ([x1, y1]: Vec2, [x2, y2]: Vec2) =>
x1 === x2 && y1 === y2;
export const vec2AngleFromOrigin = (
[x, y]: Vec2,
[ox, oy]: Vec2 = [0, 0],
): number => Math.atan2(y - oy, x - ox);
export const vec2PointFromAngle = (
angle: number,
[ox, oy]: Vec2 = [0, 0],
): Vec2 => [Math.cos(angle) + ox, Math.sin(angle) + oy];
export const deg2rad = (deg: number) => deg * (Math.PI / 180);
export const rad2deg = (rad: number) => rad * (180 / Math.PI);
export const randomNumber = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1) + min);
export const boundingBox = (points: Vec2[], start: Vec2Box): Vec2Box => {
let minX = start[0][0];
let minY = start[0][1];
let maxX = start[1][0];
let maxY = start[1][1];
for (let i = 0; i < points.length; i++) {
if (points[i][0] > maxX) {
maxX = points[i][0];
}
if (points[i][0] < minX) {
minX = points[i][0];
}
if (points[i][1] > maxY) {
maxY = points[i][1];
}
if (points[i][1] < minY) {
minY = points[i][1];
}
}
return [
[minX, minY],
[maxX, maxY],
];
};
export const isValidVec2 = ([x, y]: Vec2) =>
x != null && y != null && !isNaN(x) && !isNaN(y);
export function extractLinePoints(line: Line) {
return line.segments
.reduce<Vec2[]>((list, segment) => {
if (segment.start) return [...list, segment.start, segment.end];
return [...list, segment.end];
}, [])
.filter(
(vec, index, arry) =>
arry.findIndex((point) => vec2Equals(point, vec)) === index,
);
}

View File

@ -0,0 +1,53 @@
export type Vec2 = [number, number];
export type LayerObjectType = 'line' | 'room' | 'curve' | 'object';
export type Vec2Box = [Vec2, Vec2];
export interface LineSegment {
start?: Vec2;
end: Vec2;
}
export interface BezierSegment extends LineSegment {
startControl: Vec2;
endControl: Vec2;
}
export interface LayerObject {
id: number;
databaseId?: number;
name: string;
visible: boolean;
selected: boolean;
type: LayerObjectType;
}
export interface Line extends LayerObject {
segments: LineSegment[];
width: number;
color: string;
render?: Path2D;
lineCap?: CanvasLineCap;
lineJoin?: CanvasLineJoin;
closed?: boolean;
lineDash?: number[];
}
export interface Layer {
id: number;
contents: LayerObject[];
name: string;
color: string;
visible: boolean;
active: boolean;
}
export interface FloorDocument {
id: number;
name: string;
width: number;
height: number;
layers: Layer[];
/**
* Min, Max
*/
boundingBox?: [Vec2, Vec2];
}

View File

@ -21,10 +21,12 @@ import {
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Building } from 'src/objects/building/entities/building.entity';
import { Room } from 'src/objects/building/entities/room.entity';
import { StorageSet } from 'src/objects/storage/entities/storage-set.entity';
import { Storage } from 'src/objects/storage/entities/storage.entity';
import { User } from 'src/objects/user/user.entity';
import { CurrentBuilding } from 'src/shared/decorators/current-building.decorator';
import { CurrentRoom } from 'src/shared/decorators/current-room.decorator';
import { CurrentStorageSet } from 'src/shared/decorators/current-storage-set.decorator';
import { CurrentStorage } from 'src/shared/decorators/current-storage.decorator';
@ -192,6 +194,23 @@ export class AppStorageController {
return this.service.createStorage(user, room, body);
}
@Get('expiring')
@ApiOperation({
summary: 'Get expiring and expired for user in all buildings',
})
@ApiOkResponse({ type: StorageStoredItemResponseDto, isArray: true })
async getExpiringItemsForUser(@LoggedInUser() user: User) {
return this.service.getExpiringOrExpiredItems(user);
}
@Get('expiring/building/:buildingId')
@ApiParam({ name: 'buildingId', description: 'Building ID' })
@ApiOperation({ summary: 'Get expiring and expired items in building' })
@ApiOkResponse({ type: StorageStoredItemResponseDto, isArray: true })
async getExpiringItemsInBuilding(@CurrentBuilding() building: Building) {
return this.service.getExpiringOrExpiredItemsInBuilding(building.id);
}
@Get('item')
@ApiOperation({ summary: 'Search for an item' })
@ApiOkResponse({ type: StorageItemSearchResponseDto, isArray: true })

View File

@ -143,6 +143,22 @@ export class AppStorageService {
return responses;
}
async getExpiringOrExpiredItemsInBuilding(buildingId: number) {
const expiringSoon =
await this.storageService.getExpiredOrExpiringSoonInBuilding(buildingId);
return expiringSoon.map((storedItem) =>
this.formatStoredItem(storedItem, true),
);
}
async getExpiringOrExpiredItems(user: User) {
const expiringSoon =
await this.storageService.getExpiredOrExpiringSoonForSub(user.sub);
return expiringSoon.map((storedItem) =>
this.formatStoredItem(storedItem, true),
);
}
async createStoredItem(
user: User,
item: Item,
@ -150,11 +166,15 @@ export class AppStorageService {
transactionInfo: StorageStoredItemTransactionRequestDto,
additionalInfo?: StorageStoredItemRequestDto,
) {
const building = await this.buildingService.getStorageBuilding(storage.id);
if (!building)
throw new NotFoundException('Building for storage was not found');
// Create stored item
let storedItem = new StoredItem();
storedItem.addedBy = user;
storedItem.item = item;
storedItem.storage = storage;
storedItem.building = building;
additionalInfo && Object.assign(storedItem, additionalInfo);
storedItem = await this.storageService.saveStoredItem(storedItem);
@ -416,9 +436,10 @@ export class AppStorageService {
private formatStoredItem(
storedItem: StoredItem,
fullSet = false,
): StorageStoredItemResponseDto {
return {
...omit(storedItem, ['storage']),
...(fullSet ? storedItem : omit(storedItem, ['storage', 'building'])),
transactions: !!storedItem.transactions?.length
? storedItem.transactions.map((transaction) =>
this.formatTransaction(transaction),

View File

@ -36,6 +36,7 @@ export class StorageStoredItemResponseDto extends OmitType(StoredItem, [
'addedBy',
'transactions',
'storage',
'building',
'item',
]) {
@ApiProperty({ type: StorageActorResponse })

View File

@ -0,0 +1 @@
import { IsBoolean } from 'class-validator';

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AppBuildingModule } from './app-building/app-building.module';
import { AppGroupModule } from './app-group/app-group.module';
import { AppStorageModule } from './app-storage/app-storage.module';
@ -16,6 +18,10 @@ import { SecretsModule } from './shared/secrets/secrets.module';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'usercontent'),
serveRoot: '/usercontent',
}),
ConfigModule.forRoot({
envFilePath: ['.env.development', '.env'],
isGlobal: true,

View File

@ -18,6 +18,20 @@ export class BuildingService {
private readonly groupService: GroupService,
) {}
async getStorageBuilding(storageId: number) {
return this.buildingRepository.findOne({
where: {
floors: {
rooms: {
storages: {
id: storageId,
},
},
},
},
});
}
async getBuildingsByUserSub(sub: string, relations = []) {
return this.buildingRepository.find({
where: {

View File

@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
Column,
CreateDateColumn,
@ -29,6 +29,10 @@ export class Floor {
@Column({ type: 'text' })
plan: string;
@ApiPropertyOptional()
@Column({ type: 'text', nullable: true })
planImage?: string;
@ApiProperty({ type: () => Building })
@ManyToOne(() => Building)
building: Building;

View File

@ -1,9 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Storage } from 'src/objects/storage/entities/storage.entity';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@ -24,6 +26,9 @@ export class Room {
@ManyToOne(() => Floor)
floor: Floor;
@OneToMany(() => Storage, (storage) => storage.room)
storages: Storage[];
@ApiProperty()
@Column()
displayName: string;

View File

@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Building } from 'src/objects/building/entities/building.entity';
import { User } from 'src/objects/user/user.entity';
import {
Column,
@ -26,7 +27,7 @@ export class StoredItem {
})
item: Item;
@ApiPropertyOptional({ type: () => Storage })
@ApiPropertyOptional({ type: () => Storage, nullable: true })
@ManyToOne(() => Storage, {
nullable: true,
onDelete: 'SET NULL',
@ -34,11 +35,19 @@ export class StoredItem {
})
storage?: Storage;
@ApiProperty({ type: () => Building })
@ManyToOne(() => Building, {
nullable: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
building: Building;
@ApiPropertyOptional()
@Column({ nullable: true })
notes?: string;
@ApiProperty({ type: () => User })
@ApiProperty({ type: () => User, nullable: true })
@ManyToOne(() => User, {
onDelete: 'SET NULL',
nullable: true,

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, IsNull, Repository } from 'typeorm';
import { ILike, IsNull, LessThanOrEqual, Repository } from 'typeorm';
import { StoredItemTransaction } from './entities/item-transaction.entity';
import { Item } from './entities/item.entity';
import { StorageSet } from './entities/storage-set.entity';
@ -279,6 +279,44 @@ export class StorageService {
});
}
async getExpiredOrExpiringSoonInBuilding(buildingId: number) {
// 8 days
return this.storedItemRepository.find({
where: {
expiresAt: LessThanOrEqual(new Date(Date.now() + 691200000)),
building: {
id: buildingId,
},
},
order: {
expiresAt: 'ASC',
},
take: 16,
relations: ['item', 'storage'],
});
}
async getExpiredOrExpiringSoonForSub(sub: string) {
// 8 days
return this.storedItemRepository.find({
where: {
expiresAt: LessThanOrEqual(new Date(Date.now() + 691200000)),
building: {
groups: {
members: {
sub,
},
},
},
},
order: {
expiresAt: 'ASC',
},
take: 16,
relations: ['item', 'building', 'storage'],
});
}
async getStoredItemByStorageAndId(
storage: Storage,
storedItemId: number,

View File

@ -0,0 +1,13 @@
export const isValidColor = (color: string) => {
if (!color) return false;
if (
!color.startsWith('#') &&
!color.startsWith('rgb(') &&
!color.startsWith('hsl(') &&
!color.startsWith('hsv(')
) {
return false;
}
// TODO: check color values
return true;
};