stuff
This commit is contained in:
parent
6207554c99
commit
8ba00eb9e2
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,3 +38,4 @@ lerna-debug.log*
|
||||
.env*
|
||||
/private
|
||||
/database
|
||||
/usercontent/*
|
||||
|
147
package-lock.json
generated
147
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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']);
|
||||
}
|
||||
|
8
src/app-building/plan-renderer/plan-renderer.module.ts
Normal file
8
src/app-building/plan-renderer/plan-renderer.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlanRendererService } from './plan-renderer.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlanRendererService],
|
||||
exports: [PlanRendererService],
|
||||
})
|
||||
export class PlanRendererModule {}
|
45
src/app-building/plan-renderer/plan-renderer.service.ts
Normal file
45
src/app-building/plan-renderer/plan-renderer.service.ts
Normal 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;
|
||||
}
|
||||
}
|
163
src/app-building/plan-renderer/renderer/index.ts
Normal file
163
src/app-building/plan-renderer/renderer/index.ts
Normal 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();
|
||||
}
|
||||
}
|
50
src/app-building/plan-renderer/renderer/plan-validators.ts
Normal file
50
src/app-building/plan-renderer/renderer/plan-validators.ts
Normal 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;
|
107
src/app-building/plan-renderer/renderer/renderer-utils.ts
Normal file
107
src/app-building/plan-renderer/renderer/renderer-utils.ts
Normal 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,
|
||||
);
|
||||
}
|
@ -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];
|
||||
}
|
@ -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 })
|
||||
|
@ -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),
|
||||
|
@ -36,6 +36,7 @@ export class StorageStoredItemResponseDto extends OmitType(StoredItem, [
|
||||
'addedBy',
|
||||
'transactions',
|
||||
'storage',
|
||||
'building',
|
||||
'item',
|
||||
]) {
|
||||
@ApiProperty({ type: StorageActorResponse })
|
||||
|
1
src/app-storage/dto/storage-stored-item-request.dto.ts
Normal file
1
src/app-storage/dto/storage-stored-item-request.dto.ts
Normal file
@ -0,0 +1 @@
|
||||
import { IsBoolean } from 'class-validator';
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
13
src/shared/utils/validator.utils.ts
Normal file
13
src/shared/utils/validator.utils.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user