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:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
+142
View File
@@ -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;
+188
View File
@@ -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(),
};
+71
View File
@@ -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,
};
}