feat: editor improvements and collapsible sidebars
Add collapse/expand toggle for the AppShell navigation sidebar and the editor properties panel (both persisted to localStorage). Bundles other in-progress editor work including position anchors, outlet sizing, PBR textures, window slope/frame depth, curtain metadata, and various 2D/3D rendering tweaks.
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Annotation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"x" REAL NOT NULL DEFAULT 0,
|
||||
"y" REAL NOT NULL DEFAULT 0,
|
||||
"text" TEXT NOT NULL,
|
||||
"fontSize" INTEGER,
|
||||
"color" TEXT,
|
||||
"attachedToId" TEXT,
|
||||
"projectionOffsetX" REAL,
|
||||
"projectionOffsetY" REAL,
|
||||
CONSTRAINT "Annotation_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_ElectricalItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"x" REAL NOT NULL,
|
||||
"y" REAL NOT NULL,
|
||||
"wallId" TEXT,
|
||||
"elevationFromFloor" REAL,
|
||||
"rotation" REAL NOT NULL DEFAULT 0,
|
||||
"count" INTEGER NOT NULL DEFAULT 1,
|
||||
"anchorH" TEXT NOT NULL DEFAULT 'middle',
|
||||
"anchorV" TEXT NOT NULL DEFAULT 'middle',
|
||||
"metadata" TEXT,
|
||||
CONSTRAINT "ElectricalItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ElectricalItem" ("elevationFromFloor", "id", "metadata", "roomId", "rotation", "type", "wallId", "x", "y") SELECT "elevationFromFloor", "id", "metadata", "roomId", "rotation", "type", "wallId", "x", "y" FROM "ElectricalItem";
|
||||
DROP TABLE "ElectricalItem";
|
||||
ALTER TABLE "new_ElectricalItem" RENAME TO "ElectricalItem";
|
||||
CREATE INDEX "ElectricalItem_roomId_idx" ON "ElectricalItem"("roomId");
|
||||
CREATE TABLE "new_FurnitureItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"x" REAL NOT NULL,
|
||||
"y" REAL NOT NULL,
|
||||
"width" REAL NOT NULL,
|
||||
"depth" REAL NOT NULL,
|
||||
"height" REAL NOT NULL,
|
||||
"rotation" REAL NOT NULL DEFAULT 0,
|
||||
"elevationFromFloor" REAL NOT NULL DEFAULT 0,
|
||||
"label" TEXT,
|
||||
"anchorH" TEXT NOT NULL DEFAULT 'middle',
|
||||
"anchorV" TEXT NOT NULL DEFAULT 'middle',
|
||||
"showProjection" BOOLEAN NOT NULL DEFAULT false,
|
||||
"opacity" REAL NOT NULL DEFAULT 1,
|
||||
CONSTRAINT "FurnitureItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_FurnitureItem" ("depth", "height", "id", "label", "roomId", "rotation", "type", "width", "x", "y") SELECT "depth", "height", "id", "label", "roomId", "rotation", "type", "width", "x", "y" FROM "FurnitureItem";
|
||||
DROP TABLE "FurnitureItem";
|
||||
ALTER TABLE "new_FurnitureItem" RENAME TO "FurnitureItem";
|
||||
CREATE INDEX "FurnitureItem_roomId_idx" ON "FurnitureItem"("roomId");
|
||||
CREATE TABLE "new_Room" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"apartmentId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"shape" TEXT NOT NULL DEFAULT '[]',
|
||||
"width" REAL,
|
||||
"height" REAL,
|
||||
"wallHeight" REAL NOT NULL DEFAULT 2.7,
|
||||
"plinthHeight" REAL NOT NULL DEFAULT 0.06,
|
||||
"plinthThickness" REAL NOT NULL DEFAULT 0.01,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"posX" REAL NOT NULL DEFAULT 0,
|
||||
"posY" REAL NOT NULL DEFAULT 0,
|
||||
"floorType" TEXT NOT NULL DEFAULT 'CONCRETE',
|
||||
"wallColor" TEXT NOT NULL DEFAULT '#f5f0eb',
|
||||
"outletWidth" REAL NOT NULL DEFAULT 0.07,
|
||||
"outletHeight" REAL NOT NULL DEFAULT 0.07,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Room_apartmentId_fkey" FOREIGN KEY ("apartmentId") REFERENCES "Apartment" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Room" ("apartmentId", "createdAt", "height", "id", "name", "order", "plinthHeight", "plinthThickness", "posX", "posY", "shape", "updatedAt", "wallHeight", "width") SELECT "apartmentId", "createdAt", "height", "id", "name", "order", "plinthHeight", "plinthThickness", "posX", "posY", "shape", "updatedAt", "wallHeight", "width" FROM "Room";
|
||||
DROP TABLE "Room";
|
||||
ALTER TABLE "new_Room" RENAME TO "Room";
|
||||
CREATE INDEX "Room_apartmentId_idx" ON "Room"("apartmentId");
|
||||
CREATE TABLE "new_WallOpening" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"wallId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"positionAlongWall" REAL NOT NULL,
|
||||
"width" REAL NOT NULL,
|
||||
"height" REAL NOT NULL,
|
||||
"elevationFromFloor" REAL NOT NULL DEFAULT 0,
|
||||
"openDirection" TEXT NOT NULL DEFAULT 'LEFT',
|
||||
"anchorH" TEXT NOT NULL DEFAULT 'middle',
|
||||
"anchorV" TEXT NOT NULL DEFAULT 'middle',
|
||||
CONSTRAINT "WallOpening_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WallOpening_wallId_fkey" FOREIGN KEY ("wallId") REFERENCES "Wall" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_WallOpening" ("elevationFromFloor", "height", "id", "positionAlongWall", "roomId", "type", "wallId", "width") SELECT "elevationFromFloor", "height", "id", "positionAlongWall", "roomId", "type", "wallId", "width" FROM "WallOpening";
|
||||
DROP TABLE "WallOpening";
|
||||
ALTER TABLE "new_WallOpening" RENAME TO "WallOpening";
|
||||
CREATE INDEX "WallOpening_roomId_idx" ON "WallOpening"("roomId");
|
||||
CREATE INDEX "WallOpening_wallId_idx" ON "WallOpening"("wallId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Annotation_roomId_idx" ON "Annotation"("roomId");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add reveal (откос) depth to WallOpening. Existing rows default to 0
|
||||
-- (no visible slope), preserving prior render output for legacy windows.
|
||||
ALTER TABLE "WallOpening" ADD COLUMN "slopeDepth" REAL NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Per-opening frame member thickness (doors and windows). Existing rows
|
||||
-- default to 0.03 m which matches the previous hard-coded constant in the
|
||||
-- 3D renderers, so legacy openings render unchanged.
|
||||
ALTER TABLE "WallOpening" ADD COLUMN "frameThickness" REAL NOT NULL DEFAULT 0.03;
|
||||
@@ -32,6 +32,9 @@ model Room {
|
||||
posY Float @default(0)
|
||||
floorType String @default("CONCRETE")
|
||||
wallColor String @default("#f5f0eb")
|
||||
wallFinish String @default("PAINT")
|
||||
outletWidth Float @default(0.07)
|
||||
outletHeight Float @default(0.07)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
apartment Apartment @relation(fields: [apartmentId], references: [id], onDelete: Cascade)
|
||||
@@ -39,6 +42,7 @@ model Room {
|
||||
openings WallOpening[]
|
||||
electricalItems ElectricalItem[]
|
||||
furnitureItems FurnitureItem[]
|
||||
annotations Annotation[]
|
||||
|
||||
@@index([apartmentId])
|
||||
}
|
||||
@@ -68,6 +72,12 @@ model WallOpening {
|
||||
height Float
|
||||
elevationFromFloor Float @default(0)
|
||||
openDirection String @default("LEFT") // LEFT, RIGHT, INWARD, OUTWARD
|
||||
anchorH String @default("middle") // left, middle, right (positionAlongWall = center by default)
|
||||
anchorV String @default("bottom") // top, middle, bottom (elevationFromFloor = bottom edge by default)
|
||||
gridCols Int @default(2) // window pane subdivision — columns
|
||||
gridRows Int @default(2) // window pane subdivision — rows
|
||||
slopeDepth Float @default(0) // window reveal (откос) depth in meters; 0 = no slope shown
|
||||
frameThickness Float @default(0.03) // door/window frame member thickness in meters
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
wall Wall @relation(fields: [wallId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -84,6 +94,10 @@ model ElectricalItem {
|
||||
wallId String?
|
||||
elevationFromFloor Float?
|
||||
rotation Float @default(0)
|
||||
count Int @default(1)
|
||||
anchorH String @default("middle") // left, middle, right
|
||||
anchorV String @default("middle") // top, middle, bottom
|
||||
label String? // user-supplied display name; falls back to symbol def label when null
|
||||
metadata String? // JSON
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -102,7 +116,28 @@ model FurnitureItem {
|
||||
rotation Float @default(0)
|
||||
elevationFromFloor Float @default(0)
|
||||
label String?
|
||||
anchorH String @default("middle") // left, middle, right
|
||||
anchorV String @default("middle") // top, middle, bottom
|
||||
showProjection Boolean @default(false)
|
||||
opacity Float @default(1)
|
||||
metadata String? // JSON — type-specific extension bag (e.g. curtain open amount + fabric color)
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId])
|
||||
}
|
||||
|
||||
model Annotation {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
x Float @default(0)
|
||||
y Float @default(0)
|
||||
text String
|
||||
fontSize Int?
|
||||
color String?
|
||||
attachedToId String?
|
||||
projectionOffsetX Float?
|
||||
projectionOffsetY Float?
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId])
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
batchSyncOpeningsSchema,
|
||||
batchSyncElectricalSchema,
|
||||
batchSyncFurnitureSchema,
|
||||
batchSyncAnnotationsSchema,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Wall,
|
||||
WallOpening,
|
||||
ElectricalItem,
|
||||
FurnitureItem,
|
||||
Annotation,
|
||||
ApiResponse,
|
||||
ApiListResponse,
|
||||
} from '@house-plan-maker/shared';
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
toOpeningResponse,
|
||||
toElectricalResponse,
|
||||
toFurnitureResponse,
|
||||
toAnnotationResponse,
|
||||
} from '../utils/mappers.js';
|
||||
|
||||
const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
@@ -105,6 +108,15 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
height: input.height,
|
||||
elevationFromFloor: input.elevationFromFloor ?? 0,
|
||||
openDirection: input.openDirection ?? 'LEFT',
|
||||
// Openings store canonical (positionAlongWall = center, elevationFromFloor = bottom).
|
||||
// PositionAnchor on openings is a view-only preference used by the
|
||||
// properties panel; renderers always read canonical values.
|
||||
anchorH: input.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: input.positionAnchor?.vertical ?? 'bottom',
|
||||
gridCols: input.gridCols ?? 2,
|
||||
gridRows: input.gridRows ?? 2,
|
||||
slopeDepth: input.slopeDepth ?? 0,
|
||||
frameThickness: input.frameThickness ?? 0.03,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -136,6 +148,14 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.height !== undefined && { height: input.height }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }),
|
||||
...(input.openDirection !== undefined && { openDirection: input.openDirection }),
|
||||
...(input.positionAnchor !== undefined && {
|
||||
anchorH: input.positionAnchor.horizontal,
|
||||
anchorV: input.positionAnchor.vertical,
|
||||
}),
|
||||
...(input.gridCols !== undefined && { gridCols: input.gridCols }),
|
||||
...(input.gridRows !== undefined && { gridRows: input.gridRows }),
|
||||
...(input.slopeDepth !== undefined && { slopeDepth: input.slopeDepth }),
|
||||
...(input.frameThickness !== undefined && { frameThickness: input.frameThickness }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -208,6 +228,10 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
wallId: input.wallId ?? null,
|
||||
elevationFromFloor: input.elevationFromFloor ?? null,
|
||||
rotation: input.rotation ?? 0,
|
||||
count: Math.max(1, Math.round(input.count ?? 1)),
|
||||
anchorH: input.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: input.positionAnchor?.vertical ?? 'middle',
|
||||
label: input.label ?? null,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
},
|
||||
});
|
||||
@@ -240,6 +264,12 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.wallId !== undefined && { wallId: input.wallId ?? null }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor ?? null }),
|
||||
...(input.rotation !== undefined && { rotation: input.rotation }),
|
||||
...(input.count !== undefined && { count: Math.max(1, Math.round(input.count)) }),
|
||||
...(input.positionAnchor !== undefined && {
|
||||
anchorH: input.positionAnchor.horizontal,
|
||||
anchorV: input.positionAnchor.vertical,
|
||||
}),
|
||||
...(input.label !== undefined && { label: input.label ?? null }),
|
||||
...(input.metadata !== undefined && {
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
}),
|
||||
@@ -318,6 +348,9 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
rotation: input.rotation ?? 0,
|
||||
elevationFromFloor: input.elevationFromFloor ?? 0,
|
||||
label: input.label ?? null,
|
||||
anchorH: input.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: input.positionAnchor?.vertical ?? 'middle',
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -352,6 +385,13 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.rotation !== undefined && { rotation: input.rotation }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }),
|
||||
...(input.label !== undefined && { label: input.label ?? null }),
|
||||
...(input.positionAnchor !== undefined && {
|
||||
anchorH: input.positionAnchor.horizontal,
|
||||
anchorV: input.positionAnchor.vertical,
|
||||
}),
|
||||
...(input.metadata !== undefined && {
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -410,6 +450,14 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(item.data.height !== undefined && { height: item.data.height }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor }),
|
||||
...(item.data.openDirection !== undefined && { openDirection: item.data.openDirection }),
|
||||
...(item.data.positionAnchor !== undefined && {
|
||||
anchorH: item.data.positionAnchor.horizontal,
|
||||
anchorV: item.data.positionAnchor.vertical,
|
||||
}),
|
||||
...(item.data.gridCols !== undefined && { gridCols: item.data.gridCols }),
|
||||
...(item.data.gridRows !== undefined && { gridRows: item.data.gridRows }),
|
||||
...(item.data.slopeDepth !== undefined && { slopeDepth: item.data.slopeDepth }),
|
||||
...(item.data.frameThickness !== undefined && { frameThickness: item.data.frameThickness }),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -426,6 +474,12 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
height: item.height,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
openDirection: item.openDirection ?? 'LEFT',
|
||||
anchorH: item.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: item.positionAnchor?.vertical ?? 'bottom',
|
||||
gridCols: item.gridCols ?? 2,
|
||||
gridRows: item.gridRows ?? 2,
|
||||
slopeDepth: item.slopeDepth ?? 0,
|
||||
frameThickness: item.frameThickness ?? 0.03,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -468,6 +522,12 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(item.data.wallId !== undefined && { wallId: item.data.wallId ?? null }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor ?? null }),
|
||||
...(item.data.rotation !== undefined && { rotation: item.data.rotation }),
|
||||
...(item.data.count !== undefined && { count: Math.max(1, Math.round(item.data.count)) }),
|
||||
...(item.data.positionAnchor !== undefined && {
|
||||
anchorH: item.data.positionAnchor.horizontal,
|
||||
anchorV: item.data.positionAnchor.vertical,
|
||||
}),
|
||||
...(item.data.label !== undefined && { label: item.data.label ?? null }),
|
||||
...(item.data.metadata !== undefined && {
|
||||
metadata: item.data.metadata ? JSON.stringify(item.data.metadata) : null,
|
||||
}),
|
||||
@@ -486,6 +546,10 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
wallId: item.wallId ?? null,
|
||||
elevationFromFloor: item.elevationFromFloor ?? null,
|
||||
rotation: item.rotation ?? 0,
|
||||
count: Math.max(1, Math.round(item.count ?? 1)),
|
||||
anchorH: item.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: item.positionAnchor?.vertical ?? 'middle',
|
||||
label: item.label ?? null,
|
||||
metadata: item.metadata ? JSON.stringify(item.metadata) : null,
|
||||
},
|
||||
});
|
||||
@@ -532,6 +596,15 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(item.data.rotation !== undefined && { rotation: item.data.rotation }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor }),
|
||||
...(item.data.label !== undefined && { label: item.data.label ?? null }),
|
||||
...(item.data.showProjection !== undefined && { showProjection: item.data.showProjection }),
|
||||
...(item.data.opacity !== undefined && { opacity: item.data.opacity }),
|
||||
...(item.data.positionAnchor !== undefined && {
|
||||
anchorH: item.data.positionAnchor.horizontal,
|
||||
anchorV: item.data.positionAnchor.vertical,
|
||||
}),
|
||||
...(item.data.metadata !== undefined && {
|
||||
metadata: item.data.metadata ? JSON.stringify(item.data.metadata) : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -550,6 +623,11 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
rotation: item.rotation ?? 0,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
label: item.label ?? null,
|
||||
showProjection: item.showProjection ?? false,
|
||||
opacity: item.opacity ?? 1,
|
||||
anchorH: item.positionAnchor?.horizontal ?? 'middle',
|
||||
anchorV: item.positionAnchor?.vertical ?? 'middle',
|
||||
metadata: item.metadata ? JSON.stringify(item.metadata) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -559,6 +637,70 @@ const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
return { data: allItems.map(toFurnitureResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Batch Sync: Annotations ──
|
||||
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/annotations/batch',
|
||||
async (request, reply): Promise<ApiListResponse<Annotation>> => {
|
||||
const input = batchSyncAnnotationsSchema.parse(request.body);
|
||||
const roomId = request.params.id;
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// SQLite is single-writer; nesting transactional calls under
|
||||
// `$transaction(async tx => ...)` deadlocks against the outer client.
|
||||
// Run the operations sequentially without a wrapping interactive
|
||||
// transaction — each call is atomic on its own.
|
||||
const annotationModel = prisma.annotation;
|
||||
|
||||
if (input.delete.length > 0) {
|
||||
await annotationModel.deleteMany({
|
||||
where: { id: { in: [...input.delete] }, roomId },
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of input.update) {
|
||||
// Skip ids that don't exist (e.g. an annotation already deleted by
|
||||
// another tab). updateMany silently no-ops on missing rows.
|
||||
await annotationModel.updateMany({
|
||||
where: { id: item.id, roomId },
|
||||
data: {
|
||||
...(item.data.x !== undefined && { x: item.data.x }),
|
||||
...(item.data.y !== undefined && { y: item.data.y }),
|
||||
...(item.data.text !== undefined && { text: item.data.text }),
|
||||
...(item.data.fontSize !== undefined && { fontSize: item.data.fontSize ?? null }),
|
||||
...(item.data.color !== undefined && { color: item.data.color ?? null }),
|
||||
...(item.data.attachedToId !== undefined && { attachedToId: item.data.attachedToId ?? null }),
|
||||
...(item.data.projectionOffsetX !== undefined && { projectionOffsetX: item.data.projectionOffsetX ?? null }),
|
||||
...(item.data.projectionOffsetY !== undefined && { projectionOffsetY: item.data.projectionOffsetY ?? null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of input.create) {
|
||||
await annotationModel.create({
|
||||
data: {
|
||||
roomId,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
text: item.text,
|
||||
fontSize: item.fontSize ?? null,
|
||||
color: item.color ?? null,
|
||||
attachedToId: item.attachedToId ?? null,
|
||||
projectionOffsetX: item.projectionOffsetX ?? null,
|
||||
projectionOffsetY: item.projectionOffsetY ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const allItems = await annotationModel.findMany({ where: { roomId } });
|
||||
return { data: allItems.map(toAnnotationResponse) };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default elementRoutes;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
toOpeningResponse,
|
||||
toElectricalResponse,
|
||||
toFurnitureResponse,
|
||||
toAnnotationResponse,
|
||||
} from '../utils/mappers.js';
|
||||
|
||||
const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
@@ -72,6 +73,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.posY !== undefined && { posY: input.posY }),
|
||||
...(input.floorType !== undefined && { floorType: input.floorType }),
|
||||
...(input.wallColor !== undefined && { wallColor: input.wallColor }),
|
||||
...(input.wallFinish !== undefined && { wallFinish: input.wallFinish }),
|
||||
...(input.outletWidth !== undefined && { outletWidth: input.outletWidth }),
|
||||
...(input.outletHeight !== undefined && { outletHeight: input.outletHeight }),
|
||||
} as Parameters<typeof prisma.room.create>[0]['data'],
|
||||
});
|
||||
|
||||
@@ -107,6 +111,7 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
openings: true,
|
||||
electricalItems: true,
|
||||
furnitureItems: true,
|
||||
annotations: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,6 +126,7 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
openings: room.openings.map(toOpeningResponse),
|
||||
electricalItems: room.electricalItems.map(toElectricalResponse),
|
||||
furnitureItems: room.furnitureItems.map(toFurnitureResponse),
|
||||
annotations: room.annotations.map(toAnnotationResponse),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -155,6 +161,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
...(input.posY !== undefined && { posY: input.posY }),
|
||||
...(input.floorType !== undefined && { floorType: input.floorType }),
|
||||
...(input.wallColor !== undefined && { wallColor: input.wallColor }),
|
||||
...(input.wallFinish !== undefined && { wallFinish: input.wallFinish }),
|
||||
...(input.outletWidth !== undefined && { outletWidth: input.outletWidth }),
|
||||
...(input.outletHeight !== undefined && { outletHeight: input.outletHeight }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,6 +190,180 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/rooms/:id/clone — deep-copy a room and all its contents.
|
||||
// Creates a new room in the same apartment with the same scalar properties
|
||||
// and a fresh copy of every wall, opening, electrical item, furniture item,
|
||||
// and annotation. The clone is named "<original> (copy)" and placed at the
|
||||
// end of the apartment's room order. Wall and opening IDs are regenerated
|
||||
// and remapped so the new openings reference the new walls.
|
||||
//
|
||||
// The whole copy runs inside a single Prisma transaction so a partial
|
||||
// failure leaves no orphan rows behind.
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/clone',
|
||||
async (request, reply): Promise<ApiResponse<Room>> => {
|
||||
const source = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
include: {
|
||||
walls: { include: { openings: true } },
|
||||
electricalItems: true,
|
||||
furnitureItems: true,
|
||||
annotations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Compute the next `order` for the apartment so the clone lands at the
|
||||
// end of the list rather than colliding with the source's order.
|
||||
const maxOrder = await prisma.room.aggregate({
|
||||
where: { apartmentId: source.apartmentId },
|
||||
_max: { order: true },
|
||||
});
|
||||
const nextOrder = (maxOrder._max.order ?? 0) + 1;
|
||||
|
||||
const cloned = await prisma.$transaction(async (tx) => {
|
||||
// 1. Create the room shell with all scalar fields copied verbatim.
|
||||
const room = await tx.room.create({
|
||||
data: {
|
||||
apartmentId: source.apartmentId,
|
||||
name: `${source.name} (copy)`,
|
||||
shape: source.shape,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
wallHeight: source.wallHeight,
|
||||
plinthHeight: source.plinthHeight,
|
||||
plinthThickness: source.plinthThickness,
|
||||
order: nextOrder,
|
||||
posX: source.posX,
|
||||
posY: source.posY,
|
||||
floorType: source.floorType,
|
||||
wallColor: source.wallColor,
|
||||
wallFinish: (source as Record<string, unknown>).wallFinish ?? 'PAINT',
|
||||
outletWidth: source.outletWidth,
|
||||
outletHeight: source.outletHeight,
|
||||
} as Parameters<typeof tx.room.create>[0]['data'],
|
||||
});
|
||||
|
||||
// 2. Re-create walls and remember the old→new id map so openings
|
||||
// can be re-pointed at the cloned wall ids.
|
||||
const wallIdMap = new Map<string, string>();
|
||||
for (const w of source.walls) {
|
||||
const newWall = await tx.wall.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
startX: w.startX,
|
||||
startY: w.startY,
|
||||
endX: w.endX,
|
||||
endY: w.endY,
|
||||
thickness: w.thickness,
|
||||
direction: w.direction,
|
||||
},
|
||||
});
|
||||
wallIdMap.set(w.id, newWall.id);
|
||||
}
|
||||
|
||||
// 3. Re-create wall openings, mapping wallId through the map.
|
||||
for (const w of source.walls) {
|
||||
for (const o of w.openings) {
|
||||
const mappedWallId = wallIdMap.get(o.wallId);
|
||||
if (!mappedWallId) continue;
|
||||
await tx.wallOpening.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
wallId: mappedWallId,
|
||||
type: o.type,
|
||||
positionAlongWall: o.positionAlongWall,
|
||||
width: o.width,
|
||||
height: o.height,
|
||||
elevationFromFloor: o.elevationFromFloor,
|
||||
openDirection: o.openDirection,
|
||||
anchorH: o.anchorH,
|
||||
anchorV: o.anchorV,
|
||||
gridCols: o.gridCols,
|
||||
gridRows: o.gridRows,
|
||||
slopeDepth: o.slopeDepth,
|
||||
frameThickness: o.frameThickness,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Re-create electrical items. wallId points at the source wall id;
|
||||
// re-map through wallIdMap, falling back to null if the source had
|
||||
// no wallId (free-placed item).
|
||||
for (const e of source.electricalItems) {
|
||||
await tx.electricalItem.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
type: e.type,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
wallId: e.wallId ? wallIdMap.get(e.wallId) ?? null : null,
|
||||
elevationFromFloor: e.elevationFromFloor,
|
||||
rotation: e.rotation,
|
||||
count: e.count,
|
||||
anchorH: e.anchorH,
|
||||
anchorV: e.anchorV,
|
||||
label: e.label,
|
||||
metadata: e.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Re-create furniture items.
|
||||
for (const f of source.furnitureItems) {
|
||||
await tx.furnitureItem.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
type: f.type,
|
||||
x: f.x,
|
||||
y: f.y,
|
||||
width: f.width,
|
||||
depth: f.depth,
|
||||
height: f.height,
|
||||
rotation: f.rotation,
|
||||
elevationFromFloor: f.elevationFromFloor,
|
||||
label: f.label,
|
||||
showProjection: f.showProjection,
|
||||
opacity: f.opacity,
|
||||
anchorH: f.anchorH,
|
||||
anchorV: f.anchorV,
|
||||
metadata: f.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Re-create annotations. attachedToId references electrical or
|
||||
// furniture by id; we don't have an old→new id map for those, so
|
||||
// drop the attachment for cloned annotations rather than leave a
|
||||
// dangling reference. Free-floating annotations are preserved.
|
||||
for (const a of source.annotations) {
|
||||
await tx.annotation.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
x: a.x,
|
||||
y: a.y,
|
||||
text: a.text,
|
||||
fontSize: a.fontSize,
|
||||
color: a.color,
|
||||
attachedToId: null,
|
||||
projectionOffsetX: a.projectionOffsetX,
|
||||
projectionOffsetY: a.projectionOffsetY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return room;
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toRoomResponse(cloned) };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function toRoomResponse(room: {
|
||||
@@ -196,6 +379,8 @@ function toRoomResponse(room: {
|
||||
order: number;
|
||||
posX?: number | null;
|
||||
posY?: number | null;
|
||||
outletWidth?: number | null;
|
||||
outletHeight?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Room {
|
||||
@@ -214,6 +399,9 @@ function toRoomResponse(room: {
|
||||
posY: room.posY ?? 0,
|
||||
floorType: (((room as Record<string, unknown>).floorType as string) ?? 'CONCRETE') as Room['floorType'],
|
||||
wallColor: ((room as Record<string, unknown>).wallColor as string) ?? '#f5f0eb',
|
||||
wallFinish: (((room as Record<string, unknown>).wallFinish as string) ?? 'PAINT') as Room['wallFinish'],
|
||||
outletWidth: room.outletWidth ?? 0.07,
|
||||
outletHeight: room.outletHeight ?? 0.07,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -4,19 +4,33 @@ import {
|
||||
DOOR_OPEN_DIRECTIONS,
|
||||
ELECTRICAL_TYPES,
|
||||
FURNITURE_TYPES,
|
||||
HORIZONTAL_ANCHORS,
|
||||
VERTICAL_ANCHORS,
|
||||
DEFAULT_POSITION_ANCHOR,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Wall,
|
||||
WallOpening,
|
||||
ElectricalItem,
|
||||
FurnitureItem,
|
||||
Annotation,
|
||||
WallDirection,
|
||||
OpeningType,
|
||||
DoorOpenDirection,
|
||||
ElectricalType,
|
||||
FurnitureType,
|
||||
PositionAnchor,
|
||||
HorizontalAnchor,
|
||||
VerticalAnchor,
|
||||
} from '@house-plan-maker/shared';
|
||||
|
||||
function toPositionAnchor(h: string | null | undefined, v: string | null | undefined): PositionAnchor {
|
||||
return {
|
||||
horizontal: validateEnum<HorizontalAnchor>(h ?? 'middle', HORIZONTAL_ANCHORS, DEFAULT_POSITION_ANCHOR.horizontal),
|
||||
vertical: validateEnum<VerticalAnchor>(v ?? 'middle', VERTICAL_ANCHORS, DEFAULT_POSITION_ANCHOR.vertical),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string, returning the fallback on failure.
|
||||
* The fallback type is independent of T so that `null` can be passed.
|
||||
@@ -69,6 +83,12 @@ export function toOpeningResponse(opening: {
|
||||
height: number;
|
||||
elevationFromFloor: number;
|
||||
openDirection: string;
|
||||
anchorH?: string | null;
|
||||
anchorV?: string | null;
|
||||
gridCols?: number | null;
|
||||
gridRows?: number | null;
|
||||
slopeDepth?: number | null;
|
||||
frameThickness?: number | null;
|
||||
}): WallOpening {
|
||||
return {
|
||||
id: opening.id,
|
||||
@@ -80,6 +100,15 @@ export function toOpeningResponse(opening: {
|
||||
height: opening.height,
|
||||
elevationFromFloor: opening.elevationFromFloor,
|
||||
openDirection: validateEnum<DoorOpenDirection>(opening.openDirection, DOOR_OPEN_DIRECTIONS, 'LEFT'),
|
||||
positionAnchor: {
|
||||
horizontal: validateEnum<HorizontalAnchor>(opening.anchorH ?? 'middle', HORIZONTAL_ANCHORS, 'middle'),
|
||||
// Openings default to vertical=bottom (canonical: elevationFromFloor is the bottom edge).
|
||||
vertical: validateEnum<VerticalAnchor>(opening.anchorV ?? 'bottom', VERTICAL_ANCHORS, 'bottom'),
|
||||
},
|
||||
gridCols: Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2))),
|
||||
gridRows: Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2))),
|
||||
slopeDepth: Math.max(0, Math.min(2, opening.slopeDepth ?? 0)),
|
||||
frameThickness: Math.max(0, Math.min(0.5, opening.frameThickness ?? 0.03)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +121,10 @@ export function toElectricalResponse(item: {
|
||||
wallId: string | null;
|
||||
elevationFromFloor: number | null;
|
||||
rotation: number;
|
||||
count?: number | null;
|
||||
anchorH?: string | null;
|
||||
anchorV?: string | null;
|
||||
label?: string | null;
|
||||
metadata: string | null;
|
||||
}): ElectricalItem {
|
||||
return {
|
||||
@@ -103,6 +136,9 @@ export function toElectricalResponse(item: {
|
||||
wallId: item.wallId,
|
||||
elevationFromFloor: item.elevationFromFloor,
|
||||
rotation: item.rotation,
|
||||
count: Math.max(1, Math.round(item.count ?? 1)),
|
||||
positionAnchor: toPositionAnchor(item.anchorH, item.anchorV),
|
||||
label: item.label ?? null,
|
||||
metadata: item.metadata ? safeJsonParse<Record<string, unknown>, null>(item.metadata, null) : null,
|
||||
};
|
||||
}
|
||||
@@ -119,6 +155,11 @@ export function toFurnitureResponse(item: {
|
||||
rotation: number;
|
||||
elevationFromFloor?: number | null;
|
||||
label: string | null;
|
||||
anchorH?: string | null;
|
||||
anchorV?: string | null;
|
||||
showProjection?: boolean | null;
|
||||
opacity?: number | null;
|
||||
metadata?: string | null;
|
||||
}): FurnitureItem {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -132,5 +173,35 @@ export function toFurnitureResponse(item: {
|
||||
rotation: item.rotation,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
label: item.label,
|
||||
positionAnchor: toPositionAnchor(item.anchorH, item.anchorV),
|
||||
showProjection: item.showProjection ?? false,
|
||||
opacity: item.opacity ?? 1,
|
||||
metadata: item.metadata ? safeJsonParse<Record<string, unknown>, null>(item.metadata, null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toAnnotationResponse(item: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
attachedToId: string | null;
|
||||
projectionOffsetX: number | null;
|
||||
projectionOffsetY: number | null;
|
||||
}): Annotation {
|
||||
return {
|
||||
id: item.id,
|
||||
roomId: item.roomId,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
text: item.text,
|
||||
fontSize: item.fontSize ?? undefined,
|
||||
color: item.color ?? undefined,
|
||||
attachedToId: item.attachedToId ?? undefined,
|
||||
projectionOffsetX: item.projectionOffsetX ?? undefined,
|
||||
projectionOffsetY: item.projectionOffsetY ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user