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
+19 -1
View File
@@ -19,13 +19,14 @@ export type {
export type {
Point,
FloorType,
WallFinish,
Room,
RoomFull,
CreateRoomDto,
UpdateRoomDto,
} from './types/room.js';
export { FLOOR_TYPES } from './types/room.js';
export { FLOOR_TYPES, WALL_FINISHES, DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from './types/room.js';
export type {
WallDirection,
@@ -36,6 +37,9 @@ export type {
WallOpening,
CreateWallOpeningDto,
UpdateWallOpeningDto,
HorizontalAnchor,
VerticalAnchor,
PositionAnchor,
ElectricalType,
ElectricalItem,
CreateElectricalItemDto,
@@ -45,9 +49,12 @@ export type {
CreateFurnitureItemDto,
UpdateFurnitureItemDto,
Annotation,
CreateAnnotationDto,
UpdateAnnotationDto,
BatchSyncOpeningsDto,
BatchSyncElectricalDto,
BatchSyncFurnitureDto,
BatchSyncAnnotationsDto,
} from './types/elements.js';
export {
@@ -56,6 +63,11 @@ export {
DOOR_OPEN_DIRECTIONS,
ELECTRICAL_TYPES,
FURNITURE_TYPES,
HORIZONTAL_ANCHORS,
VERTICAL_ANCHORS,
DEFAULT_POSITION_ANCHOR,
anchorOffsetToCenter,
rotatedAnchorOffsetToCenter,
} from './types/elements.js';
export type {
@@ -90,6 +102,9 @@ export {
batchSyncOpeningsSchema,
batchSyncElectricalSchema,
batchSyncFurnitureSchema,
createAnnotationSchema,
updateAnnotationSchema,
batchSyncAnnotationsSchema,
type BulkUpdateWallsInput,
type CreateWallOpeningInput,
type UpdateWallOpeningInput,
@@ -100,4 +115,7 @@ export {
type BatchSyncOpeningsInput,
type BatchSyncElectricalInput,
type BatchSyncFurnitureInput,
type CreateAnnotationInput,
type UpdateAnnotationInput,
type BatchSyncAnnotationsInput,
} from './schemas/elements.schema.js';
+70 -1
View File
@@ -1,5 +1,20 @@
import { z } from 'zod';
import { WALL_DIRECTIONS, OPENING_TYPES, DOOR_OPEN_DIRECTIONS, ELECTRICAL_TYPES, FURNITURE_TYPES } from '../types/elements.js';
import {
WALL_DIRECTIONS,
OPENING_TYPES,
DOOR_OPEN_DIRECTIONS,
ELECTRICAL_TYPES,
FURNITURE_TYPES,
HORIZONTAL_ANCHORS,
VERTICAL_ANCHORS,
} from '../types/elements.js';
// ── Position anchor ──
const positionAnchorSchema = z.object({
horizontal: z.enum(HORIZONTAL_ANCHORS),
vertical: z.enum(VERTICAL_ANCHORS),
});
// ── Wall schemas ──
@@ -34,6 +49,11 @@ export const createWallOpeningSchema = z.object({
height: z.number().positive('Height must be positive'),
elevationFromFloor: z.number().min(0).optional(),
openDirection: doorOpenDirectionEnum.optional(),
positionAnchor: positionAnchorSchema.optional(),
gridCols: z.number().int().min(1).max(10).optional(),
gridRows: z.number().int().min(1).max(10).optional(),
slopeDepth: z.number().min(0).max(2).optional(),
frameThickness: z.number().min(0).max(0.5).optional(),
});
export const updateWallOpeningSchema = z.object({
@@ -43,6 +63,11 @@ export const updateWallOpeningSchema = z.object({
height: z.number().positive('Height must be positive').optional(),
elevationFromFloor: z.number().min(0).optional(),
openDirection: doorOpenDirectionEnum.optional(),
positionAnchor: positionAnchorSchema.optional(),
gridCols: z.number().int().min(1).max(10).optional(),
gridRows: z.number().int().min(1).max(10).optional(),
slopeDepth: z.number().min(0).max(2).optional(),
frameThickness: z.number().min(0).max(0.5).optional(),
});
export type CreateWallOpeningInput = z.infer<typeof createWallOpeningSchema>;
@@ -59,6 +84,9 @@ export const createElectricalItemSchema = z.object({
wallId: z.string().nullish(),
elevationFromFloor: z.number().min(0).nullish(),
rotation: z.number().min(0).max(360).optional(),
count: z.number().int().min(1).max(20).optional(),
positionAnchor: positionAnchorSchema.optional(),
label: z.string().max(255).nullish(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
@@ -69,6 +97,9 @@ export const updateElectricalItemSchema = z.object({
wallId: z.string().nullish(),
elevationFromFloor: z.number().min(0).nullish(),
rotation: z.number().min(0).max(360).optional(),
count: z.number().int().min(1).max(20).optional(),
positionAnchor: positionAnchorSchema.optional(),
label: z.string().max(255).nullish(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
@@ -89,6 +120,10 @@ export const createFurnitureItemSchema = z.object({
rotation: z.number().min(0).max(360).optional(),
elevationFromFloor: z.number().min(0).optional(),
label: z.string().max(255).nullish(),
positionAnchor: positionAnchorSchema.optional(),
showProjection: z.boolean().optional(),
opacity: z.number().min(0).max(1).optional(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
export const updateFurnitureItemSchema = z.object({
@@ -101,6 +136,10 @@ export const updateFurnitureItemSchema = z.object({
rotation: z.number().min(0).max(360).optional(),
elevationFromFloor: z.number().min(0).optional(),
label: z.string().max(255).nullish(),
positionAnchor: positionAnchorSchema.optional(),
showProjection: z.boolean().optional(),
opacity: z.number().min(0).max(1).optional(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
export type CreateFurnitureItemInput = z.infer<typeof createFurnitureItemSchema>;
@@ -146,3 +185,33 @@ export const batchSyncFurnitureSchema = z.object({
});
export type BatchSyncFurnitureInput = z.infer<typeof batchSyncFurnitureSchema>;
// ── Annotation schemas ──
export const createAnnotationSchema = z.object({
x: z.number(),
y: z.number(),
text: z.string().max(2000),
fontSize: z.number().int().positive().max(96).optional(),
color: z.string().max(32).optional(),
attachedToId: z.string().nullish(),
projectionOffsetX: z.number().nullish(),
projectionOffsetY: z.number().nullish(),
});
export const updateAnnotationSchema = createAnnotationSchema.partial();
export const batchSyncAnnotationsSchema = z.object({
create: z.array(createAnnotationSchema),
update: z.array(
z.object({
id: z.string().min(1),
data: updateAnnotationSchema,
}),
),
delete: z.array(z.string().min(1)),
});
export type CreateAnnotationInput = z.infer<typeof createAnnotationSchema>;
export type UpdateAnnotationInput = z.infer<typeof updateAnnotationSchema>;
export type BatchSyncAnnotationsInput = z.infer<typeof batchSyncAnnotationsSchema>;
+8 -1
View File
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { FLOOR_TYPES } from '../types/room.js';
import { FLOOR_TYPES, WALL_FINISHES } from '../types/room.js';
const floorTypeEnum = z.enum(FLOOR_TYPES);
const wallFinishEnum = z.enum(WALL_FINISHES);
const pointSchema = z.object({
x: z.number(),
@@ -21,6 +22,9 @@ export const createRoomSchema = z.object({
posY: z.number().optional(),
floorType: floorTypeEnum.optional(),
wallColor: z.string().max(20).optional(),
wallFinish: wallFinishEnum.optional(),
outletWidth: z.number().positive('Outlet width must be positive').optional(),
outletHeight: z.number().positive('Outlet height must be positive').optional(),
});
export const updateRoomSchema = z.object({
@@ -36,6 +40,9 @@ export const updateRoomSchema = z.object({
posY: z.number().optional(),
floorType: floorTypeEnum.optional(),
wallColor: z.string().max(20).optional(),
wallFinish: wallFinishEnum.optional(),
outletWidth: z.number().positive('Outlet width must be positive').optional(),
outletHeight: z.number().positive('Outlet height must be positive').optional(),
});
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
+240
View File
@@ -36,11 +36,45 @@ export interface WallOpening {
readonly roomId: string;
readonly wallId: string;
readonly type: OpeningType;
/**
* Position along the wall (meters from wall start). Interpreted relative to
* `positionAnchor.horizontal`: `left` = left edge, `middle` = center,
* `right` = right edge of the opening's bounding box along the wall.
*/
readonly positionAlongWall: number;
readonly width: number;
readonly height: number;
/**
* Elevation from the floor (meters). Interpreted relative to
* `positionAnchor.vertical`: `top` = top edge, `middle` = center,
* `bottom` = bottom edge of the opening's bounding box.
*/
readonly elevationFromFloor: number;
readonly openDirection: DoorOpenDirection;
/** Defines what (positionAlongWall, elevationFromFloor) refers to on the opening's bounding box. */
readonly positionAnchor: PositionAnchor;
/**
* Window grid subdivision. `gridCols` is the number of vertical panes
* across the window (so N-1 internal mullions), `gridRows` is the
* number of horizontal panes stacked vertically. Both default to 2 for
* newly created windows (classic 2×2 cross). Ignored for doors.
*/
readonly gridCols: number;
readonly gridRows: number;
/**
* Window reveal (Russian: откос) depth in meters. Defines the depth of
* the angled jamb/sill panels framing the window inside the wall opening.
* `0` means no visible reveal — the window appears flush with the wall
* surface. Ignored for doors. Renderers should clamp the value so it does
* not exceed half the wall thickness.
*/
readonly slopeDepth: number;
/**
* Frame member thickness in meters (used for both doors and windows).
* Controls the visual width of the frame posts/bars in the 3D view.
* Defaults to 0.03 m if unset.
*/
readonly frameThickness: number;
}
export interface CreateWallOpeningDto {
@@ -51,6 +85,11 @@ export interface CreateWallOpeningDto {
readonly height: number;
readonly elevationFromFloor?: number;
readonly openDirection?: DoorOpenDirection;
readonly positionAnchor?: PositionAnchor;
readonly gridCols?: number;
readonly gridRows?: number;
readonly slopeDepth?: number;
readonly frameThickness?: number;
}
export interface UpdateWallOpeningDto {
@@ -60,6 +99,117 @@ export interface UpdateWallOpeningDto {
readonly height?: number;
readonly elevationFromFloor?: number;
readonly openDirection?: DoorOpenDirection;
readonly positionAnchor?: PositionAnchor;
readonly gridCols?: number;
readonly gridRows?: number;
readonly slopeDepth?: number;
readonly frameThickness?: number;
}
// ── Position anchor ──
//
// Describes which point on an item's bounding box its (x, y) refers to.
// Applies to electrical items and furniture. `middle/middle` is the default
// for newly created items; legacy data may use other anchors (furniture
// historically used `left/top` and electrical historically used `middle/middle`).
export const HORIZONTAL_ANCHORS = ['left', 'middle', 'right'] as const;
export type HorizontalAnchor = (typeof HORIZONTAL_ANCHORS)[number];
export const VERTICAL_ANCHORS = ['top', 'middle', 'bottom'] as const;
export type VerticalAnchor = (typeof VERTICAL_ANCHORS)[number];
export interface PositionAnchor {
readonly horizontal: HorizontalAnchor;
readonly vertical: VerticalAnchor;
}
export const DEFAULT_POSITION_ANCHOR: PositionAnchor = {
horizontal: 'middle',
vertical: 'middle',
};
/**
* Computes the offset (in local item coordinates, before rotation) from the
* anchored point to the bounding-box center, given the box dimensions.
*
* Convention: local +x = right, local +y = down (top-left origin).
* Returns the vector that, when added to the anchored (x, y), yields the
* geometric center of the bounding box.
*
* Note: this helper IGNORES rotation. For items where the anchor should
* track the rotated visual (e.g. "left" should always mean the leftmost
* edge of the rendered shape), use {@link rotatedAnchorOffsetToCenter}.
*/
export function anchorOffsetToCenter(
anchor: PositionAnchor,
width: number,
height: number,
): { readonly dx: number; readonly dy: number } {
const dx =
anchor.horizontal === 'left'
? width / 2
: anchor.horizontal === 'right'
? -width / 2
: 0;
const dy =
anchor.vertical === 'top'
? height / 2
: anchor.vertical === 'bottom'
? -height / 2
: 0;
return { dx, dy };
}
/**
* Computes the offset from the anchored point to the bounding-box center,
* **respecting rotation**. The horizontal/vertical anchor labels refer to
* the rotated visual's world axes — e.g. with `anchor.horizontal = 'left'`
* the (x, y) point is the leftmost edge of the rotated rectangle as seen
* in world space, regardless of how the underlying width/depth are oriented
* in the item's local frame.
*
* Math: a `width × height` rectangle rotated by `rotationDeg` produces a
* world-axis-aligned bounding box with half-extents
* `halfX = |w/2 · cos r| + |d/2 · sin r|`
* `halfY = |w/2 · sin r| + |d/2 · cos r|`
* For each anchor, the center sits at ±halfX or ±halfY from the anchored
* point along the world axis.
*
* Reduces to {@link anchorOffsetToCenter} when `rotationDeg = 0`. For the
* `middle/middle` anchor (the default for newly placed items) the offset is
* always `(0, 0)` regardless of rotation.
*/
export function rotatedAnchorOffsetToCenter(
anchor: PositionAnchor,
width: number,
height: number,
rotationDeg: number,
): { readonly dx: number; readonly dy: number } {
const r = (rotationDeg * Math.PI) / 180;
const cos = Math.cos(r);
const sin = Math.sin(r);
const halfW = width / 2;
const halfH = height / 2;
// World-axis half-extents of the rotated rectangle. Both terms get
// absolute-valued so the result is the support along the +x / +y axes
// regardless of rotation quadrant.
const halfX = Math.abs(halfW * cos) + Math.abs(halfH * sin);
const halfY = Math.abs(halfW * sin) + Math.abs(halfH * cos);
const dx =
anchor.horizontal === 'left'
? halfX
: anchor.horizontal === 'right'
? -halfX
: 0;
const dy =
anchor.vertical === 'top'
? halfY
: anchor.vertical === 'bottom'
? -halfY
: 0;
return { dx, dy };
}
// ── ElectricalItem ──
@@ -83,6 +233,21 @@ export interface ElectricalItem {
readonly wallId: string | null;
readonly elevationFromFloor: number | null;
readonly rotation: number;
/**
* For OUTLET: number of individual outlets in the group (>= 1). Renderers
* should draw `count` adjacent outlet boundaries sized by the room's
* `outletWidth`/`outletHeight`. Ignored for other electrical types.
*/
readonly count: number;
/** Defines what the (x, y) coordinates refer to on the bounding box. */
readonly positionAnchor: PositionAnchor;
/**
* User-supplied display name for this item (e.g. "Behind TV", "Counter"),
* used in the projection view and properties panel as an override for the
* generic type code. `null` means "use the default label from the symbol
* definition" — UI components should fall back to that, not write it back.
*/
readonly label: string | null;
readonly metadata: Record<string, unknown> | null;
}
@@ -93,6 +258,9 @@ export interface CreateElectricalItemDto {
readonly wallId?: string | null;
readonly elevationFromFloor?: number | null;
readonly rotation?: number;
readonly count?: number;
readonly positionAnchor?: PositionAnchor;
readonly label?: string | null;
readonly metadata?: Record<string, unknown> | null;
}
@@ -103,6 +271,9 @@ export interface UpdateElectricalItemDto {
readonly wallId?: string | null;
readonly elevationFromFloor?: number | null;
readonly rotation?: number;
readonly count?: number;
readonly positionAnchor?: PositionAnchor;
readonly label?: string | null;
readonly metadata?: Record<string, unknown> | null;
}
@@ -110,17 +281,28 @@ export interface UpdateElectricalItemDto {
export const FURNITURE_TYPES = [
'BED',
'CRIB',
'DESK',
'WARDROBE',
'SOFA',
'TABLE',
'CHAIR',
'OFFICE_CHAIR',
'SHELF',
'NIGHTSTAND',
'DRESSER',
'DRESSING_TABLE',
'BOOKCASE',
'TV',
'PC_TOWER',
'AC_UNIT',
'RADIATOR',
'WALL_COLLAGE',
'CURTAIN',
'PLANT',
'MIRROR',
'DIGITAL_PIANO',
'SPEAKER',
'OTHER',
] as const;
export type FurnitureType = (typeof FURNITURE_TYPES)[number];
@@ -137,6 +319,19 @@ export interface FurnitureItem {
readonly rotation: number;
readonly elevationFromFloor: number;
readonly label: string | null;
/** Defines what the (x, y) coordinates refer to on the bounding box. */
readonly positionAnchor: PositionAnchor;
/** When true, the wall projection view shows dimension/offset overlays for this item. */
readonly showProjection?: boolean;
/** Render opacity in the [0, 1] range (1 = fully opaque). */
readonly opacity?: number;
/**
* Type-specific extension bag for properties that don't deserve their own
* column. Keys are domain-specific — e.g. CURTAIN uses `openAmount: number`
* (0=closed, 1=fully open) and `fabricColor: string` (hex). Persisted as
* JSON in the database.
*/
readonly metadata?: Record<string, unknown> | null;
}
export interface CreateFurnitureItemDto {
@@ -149,6 +344,10 @@ export interface CreateFurnitureItemDto {
readonly rotation?: number;
readonly elevationFromFloor?: number;
readonly label?: string | null;
readonly positionAnchor?: PositionAnchor;
readonly showProjection?: boolean;
readonly opacity?: number;
readonly metadata?: Record<string, unknown> | null;
}
export interface UpdateFurnitureItemDto {
@@ -161,6 +360,10 @@ export interface UpdateFurnitureItemDto {
readonly rotation?: number;
readonly elevationFromFloor?: number;
readonly label?: string | null;
readonly positionAnchor?: PositionAnchor;
readonly showProjection?: boolean;
readonly opacity?: number;
readonly metadata?: Record<string, unknown> | null;
}
// ── Annotation ──
@@ -175,6 +378,15 @@ export interface Annotation {
readonly color?: string;
/** If set, this annotation follows the item with this ID. x,y become offsets from the item. */
readonly attachedToId?: string;
/**
* Optional offset in projection (wall-elevation) space, in meters.
* `projectionOffsetX` is along the wall (positive = right of anchor) and
* `projectionOffsetY` is in projection-pixel direction (positive = down).
* Stored separately from `x`/`y` so dragging in the elevation view does not
* corrupt the floor-plan offset.
*/
readonly projectionOffsetX?: number;
readonly projectionOffsetY?: number;
}
// ── Batch sync DTOs ──
@@ -196,3 +408,31 @@ export interface BatchSyncFurnitureDto {
readonly update: readonly { readonly id: string; readonly data: UpdateFurnitureItemDto }[];
readonly delete: readonly string[];
}
export interface CreateAnnotationDto {
readonly x: number;
readonly y: number;
readonly text: string;
readonly fontSize?: number;
readonly color?: string;
readonly attachedToId?: string | null;
readonly projectionOffsetX?: number | null;
readonly projectionOffsetY?: number | null;
}
export interface UpdateAnnotationDto {
readonly x?: number;
readonly y?: number;
readonly text?: string;
readonly fontSize?: number;
readonly color?: string;
readonly attachedToId?: string | null;
readonly projectionOffsetX?: number | null;
readonly projectionOffsetY?: number | null;
}
export interface BatchSyncAnnotationsDto {
readonly create: readonly CreateAnnotationDto[];
readonly update: readonly { readonly id: string; readonly data: UpdateAnnotationDto }[];
readonly delete: readonly string[];
}
+36 -1
View File
@@ -1,4 +1,4 @@
import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from './elements.js';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, Annotation } from './elements.js';
export interface Point {
readonly x: number;
@@ -17,6 +17,29 @@ export const FLOOR_TYPES = [
] as const;
export type FloorType = (typeof FLOOR_TYPES)[number];
/**
* How walls are visually finished in the 3D view. PAINT uses the room's
* `wallColor` flat-tinted; the textured finishes ignore wallColor and apply
* a real PBR material instead.
*/
export const WALL_FINISHES = [
'PAINT',
'PLASTER',
'BRICK',
'CONCRETE',
'WOOD_PANEL',
'WALLPAPER',
] as const;
export type WallFinish = (typeof WALL_FINISHES)[number];
/**
* Default physical dimensions of a single outlet face plate (meters).
* Used when a room does not override these values. ~7 cm matches a typical
* European single outlet faceplate.
*/
export const DEFAULT_OUTLET_WIDTH = 0.07;
export const DEFAULT_OUTLET_HEIGHT = 0.07;
export interface Room {
readonly id: string;
readonly apartmentId: string;
@@ -32,6 +55,11 @@ export interface Room {
readonly posY: number;
readonly floorType: FloorType;
readonly wallColor: string;
readonly wallFinish: WallFinish;
/** Physical width of a single outlet face plate (meters), used to draw outlet boundaries. */
readonly outletWidth: number;
/** Physical height of a single outlet face plate (meters), used to draw outlet boundaries. */
readonly outletHeight: number;
readonly createdAt: string;
readonly updatedAt: string;
}
@@ -41,6 +69,7 @@ export interface RoomFull extends Room {
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly annotations: readonly Annotation[];
}
export interface CreateRoomDto {
@@ -56,6 +85,9 @@ export interface CreateRoomDto {
readonly posY?: number;
readonly floorType?: FloorType;
readonly wallColor?: string;
readonly wallFinish?: WallFinish;
readonly outletWidth?: number;
readonly outletHeight?: number;
}
export interface UpdateRoomDto {
@@ -71,4 +103,7 @@ export interface UpdateRoomDto {
readonly posY?: number;
readonly floorType?: FloorType;
readonly wallColor?: string;
readonly wallFinish?: WallFinish;
readonly outletWidth?: number;
readonly outletHeight?: number;
}