From 8ba00eb9e2aad157a39d4dd7202d307d48c0ffcd Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Tue, 24 Jan 2023 21:03:43 +0200 Subject: [PATCH] stuff --- .gitignore | 1 + package-lock.json | 147 ++++++++++++++++ package.json | 2 + src/app-building/app-building.module.ts | 9 +- src/app-building/app-building.service.ts | 3 + .../plan-renderer/plan-renderer.module.ts | 8 + .../plan-renderer/plan-renderer.service.ts | 45 +++++ .../plan-renderer/renderer/index.ts | 163 ++++++++++++++++++ .../plan-renderer/renderer/plan-validators.ts | 50 ++++++ .../plan-renderer/renderer/renderer-utils.ts | 107 ++++++++++++ .../renderer/renderer.interfaces.ts | 53 ++++++ src/app-storage/app-storage.controller.ts | 19 ++ src/app-storage/app-storage.service.ts | 23 ++- .../dto/storage-item-response.dto.ts | 1 + .../dto/storage-stored-item-request.dto.ts | 1 + src/app.module.ts | 6 + src/objects/building/building.service.ts | 14 ++ src/objects/building/entities/floor.entity.ts | 6 +- src/objects/building/entities/room.entity.ts | 5 + .../storage/entities/stored-item.entity.ts | 13 +- src/objects/storage/storage.service.ts | 40 ++++- src/shared/utils/validator.utils.ts | 13 ++ 22 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 src/app-building/plan-renderer/plan-renderer.module.ts create mode 100644 src/app-building/plan-renderer/plan-renderer.service.ts create mode 100644 src/app-building/plan-renderer/renderer/index.ts create mode 100644 src/app-building/plan-renderer/renderer/plan-validators.ts create mode 100644 src/app-building/plan-renderer/renderer/renderer-utils.ts create mode 100644 src/app-building/plan-renderer/renderer/renderer.interfaces.ts create mode 100644 src/app-storage/dto/storage-stored-item-request.dto.ts create mode 100644 src/shared/utils/validator.utils.ts diff --git a/.gitignore b/.gitignore index 9f1077a..01dca62 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ lerna-debug.log* .env* /private /database +/usercontent/* diff --git a/package-lock.json b/package-lock.json index ef6d585..908147b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 53737fc..6c8f0c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app-building/app-building.module.ts b/src/app-building/app-building.module.ts index d4271f0..68349ae 100644 --- a/src/app-building/app-building.module.ts +++ b/src/app-building/app-building.module.ts @@ -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], }) diff --git a/src/app-building/app-building.service.ts b/src/app-building/app-building.service.ts index 737154d..6110d2f 100644 --- a/src/app-building/app-building.service.ts +++ b/src/app-building/app-building.service.ts @@ -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']); } diff --git a/src/app-building/plan-renderer/plan-renderer.module.ts b/src/app-building/plan-renderer/plan-renderer.module.ts new file mode 100644 index 0000000..1f1a08f --- /dev/null +++ b/src/app-building/plan-renderer/plan-renderer.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PlanRendererService } from './plan-renderer.service'; + +@Module({ + providers: [PlanRendererService], + exports: [PlanRendererService], +}) +export class PlanRendererModule {} diff --git a/src/app-building/plan-renderer/plan-renderer.service.ts b/src/app-building/plan-renderer/plan-renderer.service.ts new file mode 100644 index 0000000..6b79d91 --- /dev/null +++ b/src/app-building/plan-renderer/plan-renderer.service.ts @@ -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; + } +} diff --git a/src/app-building/plan-renderer/renderer/index.ts b/src/app-building/plan-renderer/renderer/index.ts new file mode 100644 index 0000000..cb086e8 --- /dev/null +++ b/src/app-building/plan-renderer/renderer/index.ts @@ -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( + (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(); + } +} diff --git a/src/app-building/plan-renderer/renderer/plan-validators.ts b/src/app-building/plan-renderer/renderer/plan-validators.ts new file mode 100644 index 0000000..6053a43 --- /dev/null +++ b/src/app-building/plan-renderer/renderer/plan-validators.ts @@ -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; diff --git a/src/app-building/plan-renderer/renderer/renderer-utils.ts b/src/app-building/plan-renderer/renderer/renderer-utils.ts new file mode 100644 index 0000000..f470c36 --- /dev/null +++ b/src/app-building/plan-renderer/renderer/renderer-utils.ts @@ -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((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, + ); +} diff --git a/src/app-building/plan-renderer/renderer/renderer.interfaces.ts b/src/app-building/plan-renderer/renderer/renderer.interfaces.ts new file mode 100644 index 0000000..2c0d9b5 --- /dev/null +++ b/src/app-building/plan-renderer/renderer/renderer.interfaces.ts @@ -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]; +} diff --git a/src/app-storage/app-storage.controller.ts b/src/app-storage/app-storage.controller.ts index ff952ec..88d7ad6 100644 --- a/src/app-storage/app-storage.controller.ts +++ b/src/app-storage/app-storage.controller.ts @@ -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 }) diff --git a/src/app-storage/app-storage.service.ts b/src/app-storage/app-storage.service.ts index 3392e8d..391b6f9 100644 --- a/src/app-storage/app-storage.service.ts +++ b/src/app-storage/app-storage.service.ts @@ -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), diff --git a/src/app-storage/dto/storage-item-response.dto.ts b/src/app-storage/dto/storage-item-response.dto.ts index bb568d1..17c18b6 100644 --- a/src/app-storage/dto/storage-item-response.dto.ts +++ b/src/app-storage/dto/storage-item-response.dto.ts @@ -36,6 +36,7 @@ export class StorageStoredItemResponseDto extends OmitType(StoredItem, [ 'addedBy', 'transactions', 'storage', + 'building', 'item', ]) { @ApiProperty({ type: StorageActorResponse }) diff --git a/src/app-storage/dto/storage-stored-item-request.dto.ts b/src/app-storage/dto/storage-stored-item-request.dto.ts new file mode 100644 index 0000000..d306683 --- /dev/null +++ b/src/app-storage/dto/storage-stored-item-request.dto.ts @@ -0,0 +1 @@ +import { IsBoolean } from 'class-validator'; diff --git a/src/app.module.ts b/src/app.module.ts index bde587d..5ba523f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/objects/building/building.service.ts b/src/objects/building/building.service.ts index 079e6d4..661170e 100644 --- a/src/objects/building/building.service.ts +++ b/src/objects/building/building.service.ts @@ -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: { diff --git a/src/objects/building/entities/floor.entity.ts b/src/objects/building/entities/floor.entity.ts index fc177ed..6466e90 100644 --- a/src/objects/building/entities/floor.entity.ts +++ b/src/objects/building/entities/floor.entity.ts @@ -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; diff --git a/src/objects/building/entities/room.entity.ts b/src/objects/building/entities/room.entity.ts index fe7a5d3..f15b273 100644 --- a/src/objects/building/entities/room.entity.ts +++ b/src/objects/building/entities/room.entity.ts @@ -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; diff --git a/src/objects/storage/entities/stored-item.entity.ts b/src/objects/storage/entities/stored-item.entity.ts index 43d411f..77c17b0 100644 --- a/src/objects/storage/entities/stored-item.entity.ts +++ b/src/objects/storage/entities/stored-item.entity.ts @@ -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, diff --git a/src/objects/storage/storage.service.ts b/src/objects/storage/storage.service.ts index b046f6e..9912f96 100644 --- a/src/objects/storage/storage.service.ts +++ b/src/objects/storage/storage.service.ts @@ -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, diff --git a/src/shared/utils/validator.utils.ts b/src/shared/utils/validator.utils.ts new file mode 100644 index 0000000..013eeb1 --- /dev/null +++ b/src/shared/utils/validator.utils.ts @@ -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; +};